Separate key-cache and key-loader
Extract key-loading code to a dedicated class KeyRing in lacre.keyring module. KeyCache only keeps a static map of identities, making it safe to use in asynchronous context (and race condition resistant).
This commit is contained in:
parent
9f3ad49f14
commit
9696b7e997
4 changed files with 67 additions and 72 deletions
|
@ -31,16 +31,17 @@ import lacre.keyring as kcache
|
|||
class MailEncryptionProxy:
|
||||
"""A mail handler dispatching to appropriate mail operation."""
|
||||
|
||||
def __init__(self, keys: kcache.KeyCache):
|
||||
def __init__(self, keyring: kcache.KeyRing):
|
||||
"""Initialise the mail proxy with a reference to the key cache."""
|
||||
self._keys = keys
|
||||
self._keyring = keyring
|
||||
|
||||
async def handle_DATA(self, server, session, envelope: Envelope):
|
||||
"""Accept a message and either encrypt it or forward as-is."""
|
||||
start = time.process_time()
|
||||
try:
|
||||
keys = await self._keyring.freeze_identities()
|
||||
message = email.message_from_bytes(envelope.content)
|
||||
for operation in gate.delivery_plan(envelope.rcpt_tos, self._keys):
|
||||
for operation in gate.delivery_plan(envelope.rcpt_tos, keys):
|
||||
LOG.debug(f"Sending mail via {operation!r}")
|
||||
new_message = operation.perform(message)
|
||||
gate.send_msg(new_message, operation.recipients(), envelope.mail_from)
|
||||
|
@ -53,7 +54,7 @@ class MailEncryptionProxy:
|
|||
return RESULT_OK
|
||||
|
||||
|
||||
def _init_controller(keys: kcache.KeyCache, tout: float = 5):
|
||||
def _init_controller(keys: kcache.KeyRing, tout: float = 5):
|
||||
proxy = MailEncryptionProxy(keys)
|
||||
host, port = conf.daemon_params()
|
||||
LOG.info(f"Initialising a mail Controller at {host}:{port}")
|
||||
|
@ -90,10 +91,9 @@ def _main():
|
|||
refresh_min = float(conf.get_item('gpg', 'cache_refresh_minutes', 2))
|
||||
|
||||
keyring_path = conf.get_item('gpg', 'keyhome')
|
||||
keys = kcache.KeyCache(keyring_path)
|
||||
keys.load()
|
||||
controller = _init_controller(keys)
|
||||
reloader = _init_reloader(keyring_path, keys)
|
||||
keyring = kcache.KeyRing(keyring_path)
|
||||
controller = _init_controller(keyring)
|
||||
reloader = _init_reloader(keyring_path, keyring)
|
||||
|
||||
LOG.info(f'Watching keyring directory {keyring_path}...')
|
||||
reloader.start()
|
||||
|
|
|
@ -8,12 +8,21 @@ import lacre.text as text
|
|||
import logging
|
||||
from os import stat
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
from asyncio import Semaphore
|
||||
import copy
|
||||
|
||||
import GnuPG
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _sanitize(keys):
|
||||
for fingerprint in keys:
|
||||
keys[fingerprint] = text.sanitize_case_sense(keys[fingerprint])
|
||||
|
||||
return keys
|
||||
|
||||
|
||||
class KeyCacheMisconfiguration(Exception):
|
||||
"""Exception used to signal that KeyCache is misconfigured."""
|
||||
|
||||
|
@ -26,14 +35,12 @@ class KeyCache:
|
|||
[default].
|
||||
"""
|
||||
|
||||
def __init__(self, keyring_dir: str = None):
|
||||
def __init__(self, keys: dict = None):
|
||||
"""Initialise an empty cache.
|
||||
|
||||
With keyring_dir given, set location of the directory from which keys should be loaded.
|
||||
"""
|
||||
self._keys = dict()
|
||||
self._keyring_dir = keyring_dir
|
||||
self._last_mod = None
|
||||
self._keys = keys
|
||||
|
||||
def __getitem__(self, fingerpring):
|
||||
"""Look up email assigned to the given fingerprint."""
|
||||
|
@ -53,44 +60,60 @@ class KeyCache:
|
|||
"""Check if cache contains a key assigned to the given email."""
|
||||
return email in self._keys.values()
|
||||
|
||||
|
||||
class KeyRing:
|
||||
"""A high-level adapter for GnuPG-maintained keyring directory.
|
||||
|
||||
Its role is to keep a cache of keys present in the keyring,
|
||||
reload it when necessary and produce static copies of
|
||||
fingerprint=>email maps.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
"""Initialise the adapter."""
|
||||
self._path = path
|
||||
self._keys = self._load_and_sanitize()
|
||||
self._sema = Semaphore()
|
||||
self._last_mod = None
|
||||
|
||||
def _load_and_sanitize(self):
|
||||
keys = self._load_keyring_from(self._path)
|
||||
return _sanitize(keys)
|
||||
|
||||
def _load_keyring_from(self, keyring_dir):
|
||||
return GnuPG.public_keys(keyring_dir)
|
||||
|
||||
async def freeze_identities(self) -> KeyCache:
|
||||
"""Return a static, async-safe copy of the identity map."""
|
||||
async with self._sema:
|
||||
keys = copy.deepcopy(self._keys)
|
||||
return KeyCache(keys)
|
||||
|
||||
def load(self):
|
||||
"""Load keyring, replacing any previous contents of the cache."""
|
||||
LOG.debug('Reloading keys...')
|
||||
|
||||
if self._keyring_dir is None:
|
||||
LOG.error('Keyringn directory not set!')
|
||||
raise KeyCacheMisconfiguration("Keyring directory not configured")
|
||||
|
||||
last_mod = self._read_mod_time()
|
||||
if self._is_modified(last_mod):
|
||||
self.replace_keyring(self._load_keyring_from(self._keyring_dir))
|
||||
async with self._sema:
|
||||
self.replace_keyring(self._load_keyring_from(self._path))
|
||||
|
||||
self._last_mod = self._read_mod_time()
|
||||
|
||||
reload = load
|
||||
|
||||
def load_keyring_from(self, keyhome: str):
|
||||
"""Add all identities from keyring stored in KEYHOME."""
|
||||
self.extend_keyring(self._load_keyring_from(keyhome))
|
||||
|
||||
def _load_keyring_from(self, keyring_dir):
|
||||
return GnuPG.public_keys(keyring_dir)
|
||||
|
||||
def replace_keyring(self, keys: dict):
|
||||
"""Overwrite previously stored key cache with KEYS."""
|
||||
for fingerprint in keys:
|
||||
keys[fingerprint] = text.sanitize_case_sense(keys[fingerprint])
|
||||
keys = _sanitize(keys)
|
||||
|
||||
LOG.info(f'Storing {len(keys)} keys')
|
||||
self._keys = keys
|
||||
|
||||
def extend_keyring(self, keys: dict):
|
||||
"""Add all keys from KEYS."""
|
||||
for fingerprint in keys:
|
||||
keys[fingerprint] = text.sanitize_case_sense(keys[fingerprint])
|
||||
|
||||
LOG.info(f'Adding {len(keys)} keys')
|
||||
self._keys.update(keys)
|
||||
def _read_mod_time(self):
|
||||
# (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime)
|
||||
MTIME = 8
|
||||
st = stat(self._path)
|
||||
return st[MTIME]
|
||||
|
||||
def _is_modified(self, last_mod):
|
||||
if self._last_mod is None:
|
||||
|
@ -103,24 +126,18 @@ class KeyCache:
|
|||
LOG.debug('Keyring not modified ')
|
||||
return False
|
||||
|
||||
def _read_mod_time(self):
|
||||
# (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime)
|
||||
MTIME = 8
|
||||
st = stat(self._keyring_dir)
|
||||
return st[MTIME]
|
||||
|
||||
|
||||
class KeyringModificationListener(FileSystemEventHandler):
|
||||
"""A filesystem event listener that triggers key cache reload."""
|
||||
|
||||
def __init__(self, cache: KeyCache):
|
||||
def __init__(self, keyring: KeyRing):
|
||||
"""Initialise a listener with a callback to be executed upon each change."""
|
||||
self._cache = cache
|
||||
self._keyring = keyring
|
||||
|
||||
def handle(self, event):
|
||||
"""Reload keys upon FS event."""
|
||||
LOG.debug(f'Reloading on event {event!r}')
|
||||
self._cache.reload()
|
||||
self._keyring.reload()
|
||||
|
||||
# All methods should do the same: reload the key cache.
|
||||
# on_created = handle
|
||||
|
|
|
@ -33,6 +33,7 @@ import os
|
|||
import smtplib
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
# imports for S/MIME
|
||||
from M2Crypto import BIO, SMIME, X509
|
||||
|
@ -138,9 +139,8 @@ def _customise_headers(msg_copy):
|
|||
|
||||
def _load_keys():
|
||||
"""Return a map from a key's fingerprint to email address."""
|
||||
keys = kcache.KeyCache()
|
||||
keys.load_keyring_from(conf.get_item('gpg', 'keyhome'))
|
||||
return keys
|
||||
keyring = kcache.KeyRing(conf.get_item('gpg', 'keyhome'))
|
||||
return asyncio.run(keyring.freeze_identities())
|
||||
|
||||
|
||||
class GpgRecipient:
|
||||
|
|
|
@ -4,39 +4,17 @@ import unittest
|
|||
|
||||
class LacreKeyCacheTest(unittest.TestCase):
|
||||
def test_extend_keyring(self):
|
||||
kc = KeyCache()
|
||||
|
||||
self.assertFalse('FINGERPRINT' in kc)
|
||||
|
||||
kc.extend_keyring({'FINGERPRINT': 'john.doe@example.com'})
|
||||
|
||||
kc = KeyCache({'FINGERPRINT': 'john.doe@example.com'})
|
||||
self.assertTrue('FINGERPRINT' in kc)
|
||||
|
||||
def test_membership_methods(self):
|
||||
kc = KeyCache()
|
||||
|
||||
kc.extend_keyring({
|
||||
kc = KeyCache({
|
||||
'FINGERPRINT': 'alice@example.com',
|
||||
'OTHERPRINT': 'bob@example.com'
|
||||
})
|
||||
|
||||
self.assertTrue('FINGERPRINT' in kc)
|
||||
self.assertFalse('FOOTPRINT' in kc)
|
||||
|
||||
self.assertTrue(kc.has_email('bob@example.com'))
|
||||
|
||||
def test_keyring_replacement(self):
|
||||
kc = KeyCache()
|
||||
|
||||
kc.extend_keyring({
|
||||
'FINGERPRINT': 'alice@example.com',
|
||||
'OTHERPRINT': 'bob@example.com'
|
||||
})
|
||||
|
||||
self.assertTrue('FINGERPRINT' in kc)
|
||||
|
||||
kc.replace_keyring({
|
||||
'FOO': 'foo@example.com',
|
||||
'BAR': 'bar@example.com'
|
||||
})
|
||||
|
||||
self.assertFalse('FINGERPRINT' in kc)
|
||||
self.assertTrue('FOO' in kc)
|
||||
self.assertFalse(kc.has_email('dave@example.com'))
|
||||
|
|
Loading…
Reference in a new issue