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:
Piotr F. Mieszkowski 2022-10-17 20:13:37 +02:00
parent bae25791e0
commit c3d9220f0b
4 changed files with 67 additions and 72 deletions

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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'))