Merge pull request 'Fix encoding issues' (#123) from post-test-fixes into main
Reviewed-on: #123
This commit is contained in:
commit
07fb8d6ae8
30 changed files with 1605 additions and 680 deletions
|
@ -37,8 +37,23 @@ POS_FINGERPRINT = 9
|
|||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EncryptionException(Exception):
|
||||
"""Represents a failure to encrypt a payload."""
|
||||
|
||||
def __init__(self, issue: str, recipient: str, cause: str):
|
||||
"""Initialise an exception."""
|
||||
self._issue = issue
|
||||
self._recipient = recipient
|
||||
self._cause = cause
|
||||
|
||||
def __str__(self):
|
||||
"""Return human-readable string representation."""
|
||||
return f"issue: {self._issue}; to: {self._recipient}; cause: {self._cause}"
|
||||
|
||||
|
||||
def _build_command(key_home, *args, **kwargs):
|
||||
cmd = ["gpg", '--homedir', key_home] + list(args)
|
||||
cmd = ["gpg", '--homedir', key_home]
|
||||
cmd.extend(args)
|
||||
return cmd
|
||||
|
||||
|
||||
|
@ -130,6 +145,7 @@ def delete_key(keyhome, email):
|
|||
if result[1]:
|
||||
# delete all keys matching this email address
|
||||
p = subprocess.Popen(_build_command(keyhome, '--delete-key', '--batch', '--yes', result[1]), stdin=None, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
p.communicate()
|
||||
p.wait()
|
||||
return True
|
||||
|
||||
|
@ -142,7 +158,7 @@ class GPGEncryptor:
|
|||
def __init__(self, keyhome, recipients=None, charset=None):
|
||||
"""Initialise the wrapper."""
|
||||
self._keyhome = keyhome
|
||||
self._message = b''
|
||||
self._message = None
|
||||
self._recipients = list()
|
||||
self._charset = charset
|
||||
if recipients is not None:
|
||||
|
@ -150,16 +166,39 @@ class GPGEncryptor:
|
|||
|
||||
def update(self, message):
|
||||
"""Append MESSAGE to buffer about to be encrypted."""
|
||||
self._message += message
|
||||
if self._message is None:
|
||||
self._message = message
|
||||
else:
|
||||
self._message += message
|
||||
|
||||
def encrypt(self):
|
||||
"""Feed GnuPG with the message."""
|
||||
p = subprocess.Popen(self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
encdata = p.communicate(input=self._message)[0]
|
||||
p = self._popen()
|
||||
encdata, err = p.communicate(input=self._message)
|
||||
if p.returncode != 0:
|
||||
LOG.debug('Errors: %s', err)
|
||||
details = parse_status(err)
|
||||
raise EncryptionException(details['issue'], details['recipient'], details['cause'])
|
||||
return (encdata, p.returncode)
|
||||
|
||||
def _popen(self):
|
||||
if self._charset:
|
||||
return subprocess.Popen(self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
encoding=self._charset)
|
||||
else:
|
||||
return subprocess.Popen(self._command(), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE)
|
||||
|
||||
def _command(self):
|
||||
cmd = _build_command(self._keyhome, "--trust-model", "always", "--batch", "--yes", "--pgp7", "--no-secmem-warning", "-a", "-e")
|
||||
cmd = _build_command(self._keyhome,
|
||||
"--trust-model", "always",
|
||||
"--status-fd", "2",
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--pgp7",
|
||||
"--no-secmem-warning",
|
||||
"-a", "-e")
|
||||
|
||||
# add recipients
|
||||
for recipient in self._recipients:
|
||||
|
@ -171,7 +210,7 @@ class GPGEncryptor:
|
|||
cmd.append("--comment")
|
||||
cmd.append('Charset: ' + self._charset)
|
||||
|
||||
LOG.debug(f'Built command: {cmd!r}')
|
||||
LOG.debug('Built command: %s', cmd)
|
||||
return cmd
|
||||
|
||||
|
||||
|
@ -195,3 +234,62 @@ class GPGDecryptor:
|
|||
|
||||
def _command(self):
|
||||
return _build_command(self._keyhome, "--trust-model", "always", "--batch", "--yes", "--no-secmem-warning", "-a", "-d")
|
||||
|
||||
|
||||
STATUS_FD_PREFIX = b'[GNUPG:] '
|
||||
STATUS_FD_PREFIX_LEN = len(STATUS_FD_PREFIX)
|
||||
|
||||
KEY_EXPIRED = b'KEYEXPIRED'
|
||||
KEY_REVOKED = b'KEYREVOKED'
|
||||
NO_RECIPIENTS = b'NO_RECP'
|
||||
INVALID_RECIPIENT = b'INV_RECP'
|
||||
|
||||
# INV_RECP reason code descriptions.
|
||||
INVALID_RECIPIENT_CAUSES = [
|
||||
'No specific reason given',
|
||||
'Not Found',
|
||||
'Ambiguous specification',
|
||||
'Wrong key usage',
|
||||
'Key revoked',
|
||||
'Key expired',
|
||||
'No CRL known',
|
||||
'CRL too old',
|
||||
'Policy mismatch',
|
||||
'Not a secret key',
|
||||
'Key not trusted',
|
||||
'Missing certificate',
|
||||
'Missing issuer certificate',
|
||||
'Key disabled',
|
||||
'Syntax error in specification'
|
||||
]
|
||||
|
||||
|
||||
def parse_status(status_buffer: str) -> dict:
|
||||
"""Parse --status-fd output and return important information."""
|
||||
return parse_status_lines(status_buffer.splitlines())
|
||||
|
||||
|
||||
def parse_status_lines(lines: list) -> dict:
|
||||
"""Parse --status-fd output and return important information."""
|
||||
result = {'issue': 'n/a', 'recipient': 'n/a', 'cause': 'Unknown'}
|
||||
|
||||
LOG.debug('Processing stderr lines %s', lines)
|
||||
|
||||
for line in lines:
|
||||
LOG.debug('At gnupg stderr line %s', line)
|
||||
if not line.startswith(STATUS_FD_PREFIX):
|
||||
continue
|
||||
|
||||
if line.startswith(KEY_EXPIRED, STATUS_FD_PREFIX_LEN):
|
||||
result['issue'] = KEY_EXPIRED
|
||||
elif line.startswith(KEY_REVOKED, STATUS_FD_PREFIX_LEN):
|
||||
result['issue'] = KEY_REVOKED
|
||||
elif line.startswith(NO_RECIPIENTS, STATUS_FD_PREFIX_LEN):
|
||||
result['issue'] = NO_RECIPIENTS
|
||||
elif line.startswith(INVALID_RECIPIENT, STATUS_FD_PREFIX_LEN):
|
||||
words = line.split(b' ')
|
||||
reason_code = int(words[2])
|
||||
result['recipient'] = words[3]
|
||||
result['cause'] = INVALID_RECIPIENT_CAUSES[reason_code]
|
||||
|
||||
return result
|
||||
|
|
|
@ -59,6 +59,15 @@ config = /etc/gpg-lacre-logging.conf
|
|||
host = 127.0.0.1
|
||||
port = 10025
|
||||
|
||||
# Maximum size (in bytes) of message body, i.e. data provided after DATA
|
||||
# message. Following value comes from aiosmtpd module's default for this
|
||||
# setting.
|
||||
max_data_bytes = 33554432
|
||||
|
||||
# Sometimes it may make sense to log additional information from mail headers.
|
||||
# This should never be PII, but information like encoding, content types, etc.
|
||||
log_headers = no
|
||||
|
||||
[relay]
|
||||
# the relay settings to use for Postfix
|
||||
# gpg-mailgate will submit email to this relay after it is done processing
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
#
|
||||
|
||||
import email
|
||||
from email.policy import SMTPUTF8
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
|
@ -40,15 +41,29 @@ if missing_params:
|
|||
LOG.error(f"Aborting delivery! Following mandatory config parameters are missing: {missing_params!r}")
|
||||
sys.exit(lacre.EX_CONFIG)
|
||||
|
||||
# Read e-mail from stdin, parse it
|
||||
raw = sys.stdin.read()
|
||||
raw_message = email.message_from_string(raw)
|
||||
from_addr = raw_message['From']
|
||||
# Read recipients from the command-line
|
||||
to_addrs = sys.argv[1:]
|
||||
delivered = False
|
||||
try:
|
||||
# Read e-mail from stdin, parse it
|
||||
raw = sys.stdin.read()
|
||||
raw_message = email.message_from_string(raw, policy=SMTPUTF8)
|
||||
from_addr = raw_message['From']
|
||||
# Read recipients from the command-line
|
||||
to_addrs = sys.argv[1:]
|
||||
|
||||
# Let's start
|
||||
core.deliver_message(raw_message, from_addr, to_addrs)
|
||||
process_t = (time.process_time() - start) * 1000
|
||||
# Let's start
|
||||
core.deliver_message(raw_message, from_addr, to_addrs)
|
||||
process_t = (time.process_time() - start) * 1000
|
||||
|
||||
LOG.info("Message delivered in {process:.2f} ms".format(process=process_t))
|
||||
LOG.info("Message delivered in {process:.2f} ms".format(process=process_t))
|
||||
delivered = True
|
||||
except:
|
||||
LOG.exception('Could not handle message')
|
||||
|
||||
if not delivered:
|
||||
# It seems we weren't able to deliver the message. In case it was some
|
||||
# silly message-encoding issue that shouldn't bounce the message, we just
|
||||
# try recoding the message body and delivering it.
|
||||
try:
|
||||
core.failover_delivery(raw_message, to_addrs, from_addr)
|
||||
except:
|
||||
LOG.exception('Failover delivery failed too')
|
||||
|
|
516
lacre/core.py
516
lacre/core.py
|
@ -26,21 +26,20 @@ module.
|
|||
from email.mime.multipart import MIMEMultipart
|
||||
import copy
|
||||
import email
|
||||
import email.message
|
||||
from email.message import EmailMessage, MIMEPart
|
||||
import email.utils
|
||||
from email.policy import SMTPUTF8
|
||||
import GnuPG
|
||||
import os
|
||||
import smtplib
|
||||
import sys
|
||||
import asyncio
|
||||
|
||||
# imports for S/MIME
|
||||
from M2Crypto import BIO, SMIME, X509
|
||||
from typing import Tuple
|
||||
|
||||
import logging
|
||||
import lacre.text as text
|
||||
import lacre.config as conf
|
||||
import lacre.keyring as kcache
|
||||
import lacre.recipients as recpt
|
||||
import lacre.smime as smime
|
||||
from lacre.transport import send_msg, register_sender, SendFrom
|
||||
from lacre.mailop import KeepIntact, InlineOpenPGPEncrypt, MimeOpenPGPEncrypt
|
||||
|
||||
|
||||
|
@ -52,50 +51,49 @@ def _gpg_encrypt(raw_message, recipients):
|
|||
LOG.error("No valid entry for gpg keyhome. Encryption aborted.")
|
||||
return recipients
|
||||
|
||||
gpg_to, ungpg_to = _identify_gpg_recipients(recipients, _load_keys())
|
||||
gpg_recipients, cleartext_recipients = \
|
||||
recpt.identify_gpg_recipients(recipients, kcache.freeze_and_load_keys())
|
||||
|
||||
LOG.info(f"Got addresses: gpg_to={gpg_to!r}, ungpg_to={ungpg_to!r}")
|
||||
LOG.info(f"Got addresses: gpg_to={gpg_recipients!r}, ungpg_to={cleartext_recipients!r}")
|
||||
|
||||
if gpg_to:
|
||||
LOG.info("Encrypting email to: %s" % ' '.join(x.email() for x in gpg_to))
|
||||
if gpg_recipients:
|
||||
LOG.info("Encrypting email to: %s", gpg_recipients)
|
||||
|
||||
gpg_to_smtp_mime, gpg_to_cmdline_mime, \
|
||||
gpg_to_smtp_inline, gpg_to_cmdline_inline = \
|
||||
_sort_gpg_recipients(gpg_to)
|
||||
mime, inline = _sort_gpg_recipients(gpg_recipients)
|
||||
|
||||
if gpg_to_smtp_mime:
|
||||
if mime:
|
||||
# Encrypt mail with PGP/MIME
|
||||
_gpg_encrypt_and_deliver(raw_message,
|
||||
gpg_to_cmdline_mime, gpg_to_smtp_mime,
|
||||
mime.keys(), mime.emails(),
|
||||
_encrypt_all_payloads_mime)
|
||||
|
||||
if gpg_to_smtp_inline:
|
||||
if inline:
|
||||
# Encrypt mail with PGP/INLINE
|
||||
_gpg_encrypt_and_deliver(raw_message,
|
||||
gpg_to_cmdline_inline, gpg_to_smtp_inline,
|
||||
inline.keys(), inline.emails(),
|
||||
_encrypt_all_payloads_inline)
|
||||
|
||||
LOG.info(f"Not processed emails: {ungpg_to}")
|
||||
return ungpg_to
|
||||
LOG.info('Not processed emails: %s', cleartext_recipients)
|
||||
return cleartext_recipients
|
||||
|
||||
|
||||
def _sort_gpg_recipients(gpg_to):
|
||||
gpg_to_smtp_mime = list()
|
||||
gpg_to_cmdline_mime = list()
|
||||
def _sort_gpg_recipients(gpg_to) -> Tuple[recpt.RecipientList, recpt.RecipientList]:
|
||||
recipients_mime = list()
|
||||
keys_mime = list()
|
||||
|
||||
gpg_to_smtp_inline = list()
|
||||
gpg_to_cmdline_inline = list()
|
||||
recipients_inline = list()
|
||||
keys_inline = list()
|
||||
|
||||
default_to_pgp_mime = conf.config_item_equals('default', 'mime_conversion', 'yes')
|
||||
default_to_pgp_mime = conf.flag_enabled('default', 'mime_conversion')
|
||||
|
||||
for rcpt in gpg_to:
|
||||
# Checking pre defined styles in settings first
|
||||
if conf.config_item_equals('pgp_style', rcpt.email(), 'mime'):
|
||||
gpg_to_smtp_mime.append(rcpt.email())
|
||||
gpg_to_cmdline_mime.extend(rcpt.key().split(','))
|
||||
recipients_mime.append(rcpt.email())
|
||||
keys_mime.extend(rcpt.key().split(','))
|
||||
elif conf.config_item_equals('pgp_style', rcpt.email(), 'inline'):
|
||||
gpg_to_smtp_inline.append(rcpt.email())
|
||||
gpg_to_cmdline_inline.extend(rcpt.key().split(','))
|
||||
recipients_inline.append(rcpt.email())
|
||||
keys_inline.extend(rcpt.key().split(','))
|
||||
else:
|
||||
# Log message only if an unknown style is defined
|
||||
if conf.config_item_set('pgp_style', rcpt.email()):
|
||||
|
@ -104,172 +102,49 @@ def _sort_gpg_recipients(gpg_to):
|
|||
|
||||
# If no style is in settings defined for recipient, use default from settings
|
||||
if default_to_pgp_mime:
|
||||
gpg_to_smtp_mime.append(rcpt.email())
|
||||
gpg_to_cmdline_mime.extend(rcpt.key().split(','))
|
||||
recipients_mime.append(rcpt.email())
|
||||
keys_mime.extend(rcpt.key().split(','))
|
||||
else:
|
||||
gpg_to_smtp_inline.append(rcpt.email())
|
||||
gpg_to_cmdline_inline.extend(rcpt.key().split(','))
|
||||
recipients_inline.append(rcpt.email())
|
||||
keys_inline.extend(rcpt.key().split(','))
|
||||
|
||||
return gpg_to_smtp_mime, gpg_to_cmdline_mime, gpg_to_smtp_inline, gpg_to_cmdline_inline
|
||||
mime = recpt.RecipientList(recipients_mime, keys_mime)
|
||||
inline = recpt.RecipientList(recipients_inline, keys_inline)
|
||||
|
||||
LOG.debug('Loaded recipients: MIME %s; Inline %s', repr(mime), repr(inline))
|
||||
|
||||
return mime, inline
|
||||
|
||||
|
||||
def _gpg_encrypt_and_return(message, cmdline, to, encrypt_f) -> str:
|
||||
def _gpg_encrypt_copy(message: EmailMessage, keys, recipients, encrypt_f):
|
||||
msg_copy = copy.deepcopy(message)
|
||||
_customise_headers(msg_copy)
|
||||
encrypted_payloads = encrypt_f(msg_copy, cmdline)
|
||||
encrypted_payloads = encrypt_f(msg_copy, keys)
|
||||
msg_copy.set_payload(encrypted_payloads)
|
||||
return msg_copy.as_string()
|
||||
return msg_copy
|
||||
|
||||
|
||||
def _gpg_encrypt_and_deliver(message, cmdline, to, encrypt_f):
|
||||
out = _gpg_encrypt_and_return(message, cmdline, to, encrypt_f)
|
||||
send_msg(out, to)
|
||||
def _gpg_encrypt_to_bytes(message: EmailMessage, keys, recipients, encrypt_f) -> bytes:
|
||||
msg_copy = _gpg_encrypt_copy(message, keys, recipients, encrypt_f)
|
||||
return msg_copy.as_bytes(policy=SMTPUTF8)
|
||||
|
||||
|
||||
def _customise_headers(msg_copy):
|
||||
if conf.config_item_equals('default', 'add_header', 'yes'):
|
||||
msg_copy['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate'
|
||||
|
||||
if 'Content-Transfer-Encoding' in msg_copy:
|
||||
msg_copy.replace_header('Content-Transfer-Encoding', '8BIT')
|
||||
else:
|
||||
msg_copy['Content-Transfer-Encoding'] = '8BIT'
|
||||
def _gpg_encrypt_to_str(message: EmailMessage, keys, recipients, encrypt_f) -> str:
|
||||
msg_copy = _gpg_encrypt_copy(message, keys, recipients, encrypt_f)
|
||||
return msg_copy.as_string(policy=SMTPUTF8)
|
||||
|
||||
|
||||
def _load_keys():
|
||||
"""Return a map from a key's fingerprint to email address."""
|
||||
keyring = kcache.KeyRing(conf.get_item('gpg', 'keyhome'))
|
||||
return asyncio.run(keyring.freeze_identities())
|
||||
def _gpg_encrypt_and_deliver(message: EmailMessage, keys, recipients, encrypt_f):
|
||||
out = _gpg_encrypt_to_str(message, keys, recipients, encrypt_f)
|
||||
send_msg(out, recipients)
|
||||
|
||||
|
||||
class GpgRecipient:
|
||||
"""A tuple-like object that contains GPG recipient data."""
|
||||
|
||||
def __init__(self, left, right):
|
||||
"""Initialise a tuple-like object that contains GPG recipient data."""
|
||||
self._left = left
|
||||
self._right = right
|
||||
|
||||
def __getitem__(self, index):
|
||||
"""Pretend this object is a tuple by returning an indexed tuple element."""
|
||||
if index == 0:
|
||||
return self._left
|
||||
elif index == 1:
|
||||
return self._right
|
||||
else:
|
||||
raise IndexError()
|
||||
|
||||
def __repr__(self):
|
||||
"""Return textual representation of this GPG Recipient."""
|
||||
return f"GpgRecipient({self._left!r}, {self._right!r})"
|
||||
|
||||
def email(self):
|
||||
"""Return this recipient's email address."""
|
||||
return self._left
|
||||
|
||||
def key(self):
|
||||
"""Return this recipient's key ID."""
|
||||
return self._right
|
||||
def _customise_headers(message: EmailMessage):
|
||||
if conf.flag_enabled('default', 'add_header'):
|
||||
message['X-GPG-Mailgate'] = 'Encrypted by GPG Mailgate'
|
||||
|
||||
|
||||
def _identify_gpg_recipients(recipients, keys: kcache.KeyCache):
|
||||
# This list will be filled with pairs (M, N), where M is the destination
|
||||
# address we're going to deliver the message to and N is the identity we're
|
||||
# going to encrypt it for.
|
||||
gpg_to = list()
|
||||
|
||||
# This will be the list of recipients that haven't provided us with their
|
||||
# public keys.
|
||||
ungpg_to = list()
|
||||
|
||||
# In "strict mode", only keys included in configuration are used to encrypt
|
||||
# email.
|
||||
strict_mode = conf.strict_mode()
|
||||
|
||||
# GnuPG keys found in our keyring.
|
||||
|
||||
for to in recipients:
|
||||
own_key = _try_configured_key(to, keys)
|
||||
if own_key is not None:
|
||||
gpg_to.append(GpgRecipient(own_key[0], own_key[1]))
|
||||
continue
|
||||
|
||||
direct_key = _try_direct_key_lookup(to, keys, strict_mode)
|
||||
if direct_key is not None:
|
||||
gpg_to.append(GpgRecipient(direct_key[0], direct_key[1]))
|
||||
continue
|
||||
|
||||
domain_key = _try_configured_domain_key(to, keys)
|
||||
if domain_key is not None:
|
||||
gpg_to.append(GpgRecipient(domain_key[0], domain_key[1]))
|
||||
continue
|
||||
|
||||
ungpg_to.append(to)
|
||||
|
||||
LOG.debug(f'Collected recipients; GPG: {gpg_to}; UnGPG: {ungpg_to}')
|
||||
return gpg_to, ungpg_to
|
||||
|
||||
|
||||
def _find_key(recipient, keys, strict_mode):
|
||||
own_key = _try_configured_key(recipient, keys)
|
||||
if own_key is not None:
|
||||
return own_key
|
||||
|
||||
direct_key = _try_direct_key_lookup(recipient, keys, strict_mode)
|
||||
if direct_key is not None:
|
||||
return direct_key
|
||||
|
||||
domain_key = _try_configured_domain_key(recipient, keys)
|
||||
if domain_key is not None:
|
||||
return domain_key
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _try_configured_key(recipient, keys):
|
||||
if conf.config_item_set('enc_keymap', recipient):
|
||||
key = conf.get_item('enc_keymap', recipient)
|
||||
if key in keys:
|
||||
LOG.debug(f"Found key {key} configured for {recipient}")
|
||||
return (recipient, key)
|
||||
|
||||
LOG.debug(f"No configured key found for {recipient}")
|
||||
return None
|
||||
|
||||
|
||||
def _try_direct_key_lookup(recipient, keys, strict_mode):
|
||||
if strict_mode:
|
||||
return None
|
||||
|
||||
if keys.has_email(recipient):
|
||||
LOG.info(f"Found key for {recipient}")
|
||||
return recipient, recipient
|
||||
|
||||
(newto, topic) = text.parse_delimiter(recipient)
|
||||
if keys.has_email(newto):
|
||||
LOG.info(f"Found key for {newto}, stripped {recipient}")
|
||||
return recipient, newto
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _try_configured_domain_key(recipient, keys):
|
||||
parts = recipient.split('@')
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
domain = parts[1]
|
||||
if conf.config_item_set('enc_domain_keymap', domain):
|
||||
domain_key = conf.get_item('enc_domain_keymap', domain)
|
||||
if domain_key in keys:
|
||||
LOG.debug(f"Found domain key {domain_key} for {recipient}")
|
||||
return recipient, domain_key
|
||||
|
||||
LOG.debug(f"No domain key for {recipient}")
|
||||
return None
|
||||
|
||||
|
||||
def _encrypt_all_payloads_inline(message, gpg_to_cmdline):
|
||||
def _encrypt_all_payloads_inline(message: EmailMessage, gpg_to_cmdline):
|
||||
|
||||
# This breaks cascaded MIME messages. Blame PGP/INLINE.
|
||||
encrypted_payloads = list()
|
||||
|
@ -285,172 +160,125 @@ def _encrypt_all_payloads_inline(message, gpg_to_cmdline):
|
|||
return encrypted_payloads
|
||||
|
||||
|
||||
def _encrypt_all_payloads_mime(message: email.message.Message, gpg_to_cmdline):
|
||||
def _encrypt_all_payloads_mime(message: EmailMessage, gpg_to_cmdline):
|
||||
# Convert a plain text email into PGP/MIME attachment style. Modeled after enigmail.
|
||||
pgp_ver_part = email.message.Message()
|
||||
pgp_ver_part.set_payload("Version: 1"+text.EOL)
|
||||
pgp_ver_part = MIMEPart()
|
||||
pgp_ver_part.set_content('Version: 1' + text.EOL_S)
|
||||
pgp_ver_part.set_type("application/pgp-encrypted")
|
||||
pgp_ver_part.set_param('PGP/MIME version identification', "", 'Content-Description')
|
||||
|
||||
encrypted_part = email.message.Message()
|
||||
encrypted_part = MIMEPart()
|
||||
encrypted_part.set_type("application/octet-stream")
|
||||
encrypted_part.set_param('name', "encrypted.asc")
|
||||
encrypted_part.set_param('OpenPGP encrypted message', "", 'Content-Description')
|
||||
encrypted_part.set_param('inline', "", 'Content-Disposition')
|
||||
encrypted_part.set_param('filename', "encrypted.asc", 'Content-Disposition')
|
||||
|
||||
message.preamble = "This is an OpenPGP/MIME encrypted message (RFC 2440 and 3156)"
|
||||
|
||||
boundary = _make_boundary()
|
||||
|
||||
if isinstance(message.get_payload(), str):
|
||||
# WTF! It seems to swallow the first line. Not sure why. Perhaps
|
||||
# it's skipping an imaginary blank line someplace. (ie skipping a header)
|
||||
# Workaround it here by prepending a blank line.
|
||||
# This happens only on text only messages.
|
||||
additionalSubHeader = ""
|
||||
encoding = sys.getdefaultencoding()
|
||||
if 'Content-Type' in message and not message['Content-Type'].startswith('multipart'):
|
||||
additionalSubHeader = "Content-Type: " + message['Content-Type'] + text.EOL
|
||||
encoding = message.get_content_charset(sys.getdefaultencoding())
|
||||
LOG.debug(f"Identified encoding as {encoding}")
|
||||
encrypted_part.set_payload(additionalSubHeader+text.EOL + message.get_payload(decode=True).decode(encoding))
|
||||
LOG.debug('Rewrapping a flat, text-only message')
|
||||
wrapped_payload = _rewrap_payload(message)
|
||||
encrypted_part.set_payload(wrapped_payload.as_string())
|
||||
|
||||
_set_type_and_boundary(message, boundary)
|
||||
|
||||
check_nested = True
|
||||
else:
|
||||
processed_payloads = _generate_message_from_payloads(message)
|
||||
encrypted_part.set_payload(processed_payloads.as_string())
|
||||
|
||||
_set_type_and_boundary(message, boundary)
|
||||
|
||||
check_nested = False
|
||||
|
||||
message.preamble = "This is an OpenPGP/MIME encrypted message (RFC 2440 and 3156)"
|
||||
|
||||
# Use this just to generate a MIME boundary string.
|
||||
junk_msg = MIMEMultipart()
|
||||
junk_str = junk_msg.as_string() # WTF! Without this, get_boundary() will return 'None'!
|
||||
boundary = junk_msg.get_boundary()
|
||||
|
||||
# This also modifies the boundary in the body of the message, ie it gets parsed.
|
||||
if 'Content-Type' in message:
|
||||
message.replace_header('Content-Type', f"multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"{boundary}\""+text.EOL)
|
||||
else:
|
||||
message['Content-Type'] = f"multipart/encrypted; protocol=\"application/pgp-encrypted\"; boundary=\"{boundary}\""+text.EOL
|
||||
|
||||
return [pgp_ver_part, _encrypt_payload(encrypted_part, gpg_to_cmdline, check_nested)]
|
||||
|
||||
|
||||
def _encrypt_payload(payload, gpg_to_cmdline, check_nested=True):
|
||||
def _rewrap_payload(message: EmailMessage) -> MIMEPart:
|
||||
# In PGP/MIME (RFC 3156), the payload has to be a valid MIME entity. In
|
||||
# other words, we need to wrap text/* message's payload in a new MIME
|
||||
# entity.
|
||||
|
||||
wrapper = MIMEPart(policy=SMTPUTF8)
|
||||
content = message.get_content()
|
||||
wrapper.set_content(content)
|
||||
|
||||
wrapper.set_type(message.get_content_type())
|
||||
|
||||
# Copy all Content-Type parameters.
|
||||
for (pname, pvalue) in message.get_params():
|
||||
# Skip MIME type that's also returned by get_params().
|
||||
if not '/' in pname:
|
||||
wrapper.set_param(pname, pvalue)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def _make_boundary():
|
||||
junk_msg = MIMEMultipart()
|
||||
# XXX See EmailTest.test_boundary_generated_after_as_string_call.
|
||||
_ = junk_msg.as_string()
|
||||
return junk_msg.get_boundary()
|
||||
|
||||
|
||||
def _set_type_and_boundary(message: EmailMessage, boundary):
|
||||
message.set_type('multipart/encrypted')
|
||||
message.set_param('protocol', 'application/pgp-encrypted')
|
||||
message.set_param('boundary', boundary)
|
||||
|
||||
|
||||
def _encrypt_payload(payload: EmailMessage, recipients, check_nested=True, **kwargs):
|
||||
raw_payload = payload.get_payload(decode=True)
|
||||
LOG.debug('About to encrypt raw payload: %s', raw_payload)
|
||||
LOG.debug('Original message: %s', payload)
|
||||
|
||||
if check_nested and text.is_payload_pgp_inline(raw_payload):
|
||||
LOG.debug("Message is already pgp encrypted. No nested encryption needed.")
|
||||
return payload
|
||||
|
||||
# No check is needed for conf.get_item('gpg', 'keyhome') as this is already
|
||||
# done in method gpg_encrypt
|
||||
gpg = GnuPG.GPGEncryptor(conf.get_item('gpg', 'keyhome'), gpg_to_cmdline,
|
||||
payload.get_content_charset())
|
||||
gpg = _make_encryptor(raw_payload, recipients)
|
||||
|
||||
gpg.update(raw_payload)
|
||||
encrypted_data, returncode = gpg.encrypt()
|
||||
LOG.debug("Return code from encryption=%d (0 indicates success)." % returncode)
|
||||
if returncode != 0:
|
||||
LOG.info("Encrytion failed with return code %d. Encryption aborted." % returncode)
|
||||
return payload
|
||||
encrypted_data, exit_code = gpg.encrypt()
|
||||
|
||||
payload.set_payload(encrypted_data)
|
||||
isAttachment = payload.get_param('attachment', None, 'Content-Disposition') is not None
|
||||
|
||||
if isAttachment:
|
||||
filename = payload.get_filename()
|
||||
if filename:
|
||||
pgpFilename = filename + ".pgp"
|
||||
if not (payload.get('Content-Disposition') is None):
|
||||
payload.set_param('filename', pgpFilename, 'Content-Disposition')
|
||||
if not (payload.get('Content-Type') is None) and not (payload.get_param('name') is None):
|
||||
payload.set_param('name', pgpFilename)
|
||||
if not (payload.get('Content-Transfer-Encoding') is None):
|
||||
payload.replace_header('Content-Transfer-Encoding', "7bit")
|
||||
_append_gpg_extension(payload)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _smime_encrypt(raw_message, recipients):
|
||||
global LOG
|
||||
global from_addr
|
||||
def _make_encryptor(raw_data, recipients):
|
||||
# No check is needed for conf.get_item('gpg', 'keyhome') as this is already
|
||||
# done in method gpg_encrypt
|
||||
keyhome = conf.get_item('gpg', 'keyhome')
|
||||
|
||||
if not conf.config_item_set('smime', 'cert_path'):
|
||||
LOG.info("No valid path for S/MIME certs found in config file. S/MIME encryption aborted.")
|
||||
return recipients
|
||||
|
||||
cert_path = conf.get_item('smime', 'cert_path')+"/"
|
||||
s = SMIME.SMIME()
|
||||
sk = X509.X509_Stack()
|
||||
smime_to = list()
|
||||
unsmime_to = list()
|
||||
|
||||
for addr in recipients:
|
||||
cert_and_email = _get_cert_for_email(addr, cert_path)
|
||||
|
||||
if not (cert_and_email is None):
|
||||
(to_cert, normal_email) = cert_and_email
|
||||
LOG.debug("Found cert " + to_cert + " for " + addr + ": " + normal_email)
|
||||
smime_to.append(addr)
|
||||
x509 = X509.load_cert(to_cert, format=X509.FORMAT_PEM)
|
||||
sk.push(x509)
|
||||
else:
|
||||
unsmime_to.append(addr)
|
||||
|
||||
if smime_to:
|
||||
s.set_x509_stack(sk)
|
||||
s.set_cipher(SMIME.Cipher('aes_192_cbc'))
|
||||
p7 = s.encrypt(BIO.MemoryBuffer(raw_message.as_string()))
|
||||
# Output p7 in mail-friendly format.
|
||||
out = BIO.MemoryBuffer()
|
||||
out.write('From: ' + from_addr + text.EOL)
|
||||
out.write('To: ' + raw_message['To'] + text.EOL)
|
||||
if raw_message['Cc']:
|
||||
out.write('Cc: ' + raw_message['Cc'] + text.EOL)
|
||||
if raw_message['Bcc']:
|
||||
out.write('Bcc: ' + raw_message['Bcc'] + text.EOL)
|
||||
if raw_message['Subject']:
|
||||
out.write('Subject: ' + raw_message['Subject'] + text.EOL)
|
||||
|
||||
if conf.config_item_equals('default', 'add_header', 'yes'):
|
||||
out.write('X-GPG-Mailgate: Encrypted by GPG Mailgate' + text.EOL)
|
||||
|
||||
s.write(out, p7)
|
||||
|
||||
LOG.debug(f"Sending message from {from_addr} to {smime_to}")
|
||||
|
||||
send_msg(out.read(), smime_to)
|
||||
if unsmime_to:
|
||||
LOG.debug(f"Unable to find valid S/MIME certificates for {unsmime_to}")
|
||||
|
||||
return unsmime_to
|
||||
|
||||
|
||||
def _get_cert_for_email(to_addr, cert_path):
|
||||
insensitive = conf.config_item_equals('default', 'mail_case_insensitive', 'yes')
|
||||
|
||||
LOG.info(f'Retrieving certificate for {to_addr!r} from {cert_path!r}, sensitivity={insensitive!r}')
|
||||
|
||||
files_in_directory = os.listdir(cert_path)
|
||||
for filename in files_in_directory:
|
||||
file_path = os.path.join(cert_path, filename)
|
||||
if not os.path.isfile(file_path):
|
||||
continue
|
||||
|
||||
if insensitive:
|
||||
if filename.casefold() == to_addr:
|
||||
return (file_path, to_addr)
|
||||
else:
|
||||
if filename == to_addr:
|
||||
return (file_path, to_addr)
|
||||
|
||||
# support foo+ignore@bar.com -> foo@bar.com
|
||||
LOG.info(f"An email with topic? {to_addr}")
|
||||
(fixed_up_email, topic) = text.parse_delimiter(to_addr)
|
||||
LOG.info(f'Got {fixed_up_email!r} and {topic!r}')
|
||||
if topic is None:
|
||||
# delimiter not used
|
||||
LOG.info('Topic not found')
|
||||
return None
|
||||
if isinstance(raw_data, str):
|
||||
return GnuPG.GPGEncryptor(keyhome, recipients, 'utf-8')
|
||||
else:
|
||||
LOG.info(f"Looking up certificate for {fixed_up_email} after parsing {to_addr}")
|
||||
return _get_cert_for_email(fixed_up_email, cert_path)
|
||||
return GnuPG.GPGEncryptor(keyhome, recipients)
|
||||
|
||||
|
||||
def _append_gpg_extension(attachment):
|
||||
filename = attachment.get_filename()
|
||||
if not filename:
|
||||
return
|
||||
|
||||
pgpFilename = filename + ".pgp"
|
||||
|
||||
# Attachment name can come from one of two places: Content-Disposition or
|
||||
# Content-Type header, hence the two cases below.
|
||||
|
||||
if not (attachment.get('Content-Disposition') is None):
|
||||
attachment.set_param('filename', pgpFilename, 'Content-Disposition')
|
||||
|
||||
if not (attachment.get('Content-Type') is None) and not (attachment.get_param('name') is None):
|
||||
attachment.set_param('name', pgpFilename)
|
||||
|
||||
|
||||
def _generate_message_from_payloads(payloads, message=None):
|
||||
|
@ -473,26 +301,33 @@ def _get_first_payload(payloads):
|
|||
return payloads
|
||||
|
||||
|
||||
def send_msg(message: str, recipients, fromaddr=None):
|
||||
"""Send MESSAGE to RECIPIENTS to the mail relay."""
|
||||
global from_addr
|
||||
def _recode(m: EmailMessage):
|
||||
payload = m.get_payload()
|
||||
m.set_content(payload)
|
||||
|
||||
if fromaddr is not None:
|
||||
from_addr = fromaddr
|
||||
|
||||
recipients = [_f for _f in recipients if _f]
|
||||
if recipients:
|
||||
LOG.info(f"Sending email to: {recipients!r}")
|
||||
relay = conf.relay_params()
|
||||
smtp = smtplib.SMTP(relay[0], relay[1])
|
||||
if conf.flag_enabled('relay', 'starttls'):
|
||||
smtp.starttls()
|
||||
smtp.sendmail(from_addr, recipients, message)
|
||||
def failover_delivery(message: EmailMessage, recipients, from_address):
|
||||
"""Try delivering message just one last time."""
|
||||
LOG.debug('Failover delivery')
|
||||
|
||||
send = SendFrom(from_address)
|
||||
if message.get_content_maintype() == 'text':
|
||||
LOG.debug('Flat text message, adjusting coding')
|
||||
_recode(message)
|
||||
b = message.as_bytes(policy=SMTPUTF8)
|
||||
send(b, recipients)
|
||||
elif message.get_content_maintype() == 'multipart':
|
||||
LOG.debug('Multipart message, adjusting coding of text entities')
|
||||
for part in message.iter_parts():
|
||||
if part.get_content_maintype() == 'text':
|
||||
_recode(part)
|
||||
b = message.as_bytes(policy=SMTPUTF8)
|
||||
send(b, recipients)
|
||||
else:
|
||||
LOG.info("No recipient found")
|
||||
LOG.warning('No failover strategy, giving up')
|
||||
|
||||
|
||||
def _is_encrypted(raw_message: email.message.Message):
|
||||
def _is_encrypted(raw_message: EmailMessage):
|
||||
if raw_message.get_content_type() == 'multipart/encrypted':
|
||||
return True
|
||||
|
||||
|
@ -503,45 +338,44 @@ def _is_encrypted(raw_message: email.message.Message):
|
|||
return text.is_message_pgp_inline(first_part)
|
||||
|
||||
|
||||
def delivery_plan(recipients, message: email.message.Message, key_cache: kcache.KeyCache):
|
||||
def delivery_plan(recipients, message: EmailMessage, key_cache: kcache.KeyCache):
|
||||
"""Generate a sequence of delivery strategies."""
|
||||
if _is_encrypted(message):
|
||||
LOG.debug(f'Message is already encrypted: {message!r}')
|
||||
LOG.debug('Message is already encrypted: %s', message)
|
||||
return [KeepIntact(recipients)]
|
||||
|
||||
gpg_to, ungpg_to = _identify_gpg_recipients(recipients, key_cache)
|
||||
gpg_recipients, cleartext_recipients = recpt.identify_gpg_recipients(recipients, key_cache)
|
||||
|
||||
gpg_mime_to, gpg_mime_cmd, gpg_inline_to, gpg_inline_cmd = \
|
||||
_sort_gpg_recipients(gpg_to)
|
||||
mime, inline = _sort_gpg_recipients(gpg_recipients)
|
||||
|
||||
keyhome = conf.get_item('gpg', 'keyhome')
|
||||
|
||||
plan = []
|
||||
if gpg_mime_to:
|
||||
plan.append(MimeOpenPGPEncrypt(gpg_mime_to, gpg_mime_cmd, keyhome))
|
||||
if gpg_inline_to:
|
||||
plan.append(InlineOpenPGPEncrypt(gpg_inline_to, gpg_inline_cmd, keyhome))
|
||||
if ungpg_to:
|
||||
plan.append(KeepIntact(ungpg_to))
|
||||
if mime:
|
||||
plan.append(MimeOpenPGPEncrypt(mime.emails(), mime.keys(), keyhome))
|
||||
if inline:
|
||||
plan.append(InlineOpenPGPEncrypt(inline.emails(), inline.keys(), keyhome))
|
||||
if cleartext_recipients:
|
||||
plan.append(KeepIntact(cleartext_recipients))
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
def deliver_message(raw_message: email.message.Message, from_address, to_addrs):
|
||||
def deliver_message(raw_message: EmailMessage, from_address, to_addrs):
|
||||
"""Send RAW_MESSAGE to all TO_ADDRS using the best encryption method available."""
|
||||
global from_addr
|
||||
|
||||
# Ugly workaround to keep the code working without too many changes.
|
||||
from_addr = from_address
|
||||
register_sender(from_address)
|
||||
|
||||
sanitize = text.choose_sanitizer(conf.get_item('default', 'mail_case_insensitive'))
|
||||
recipients_left = [sanitize(recipient) for recipient in to_addrs]
|
||||
|
||||
send = SendFrom(from_address)
|
||||
|
||||
# There is no need for nested encryption
|
||||
LOG.debug("Seeing if it's already encrypted")
|
||||
if _is_encrypted(raw_message):
|
||||
LOG.debug("Message is already encrypted. Encryption aborted.")
|
||||
send_msg(raw_message.as_string(), recipients_left)
|
||||
send(raw_message.as_string(), recipients_left)
|
||||
return
|
||||
|
||||
# Encrypt mails for recipients with known public PGP keys
|
||||
|
@ -552,10 +386,10 @@ def deliver_message(raw_message: email.message.Message, from_address, to_addrs):
|
|||
|
||||
# Encrypt mails for recipients with known S/MIME certificate
|
||||
LOG.debug("Encrypting with S/MIME")
|
||||
recipients_left = _smime_encrypt(raw_message, recipients_left)
|
||||
recipients_left = smime.encrypt(raw_message, recipients_left, from_address)
|
||||
if not recipients_left:
|
||||
return
|
||||
|
||||
# Send out mail to recipients which are left
|
||||
LOG.debug("Sending the rest as text/plain")
|
||||
send_msg(raw_message.as_string(), recipients_left)
|
||||
send(raw_message.as_bytes(policy=SMTPUTF8), recipients_left)
|
||||
|
|
|
@ -2,29 +2,28 @@
|
|||
|
||||
import logging
|
||||
import lacre
|
||||
from lacre.text import DOUBLE_EOL_BYTES
|
||||
import lacre.config as conf
|
||||
import sys
|
||||
from aiosmtpd.controller import Controller
|
||||
from aiosmtpd.smtp import Envelope
|
||||
import asyncio
|
||||
import email
|
||||
from email.policy import SMTPUTF8
|
||||
import time
|
||||
from watchdog.observers import Observer
|
||||
|
||||
# Mail status constants.
|
||||
#
|
||||
# These are the only values that our mail handler is allowed to return.
|
||||
RESULT_OK = '250 OK'
|
||||
RESULT_ERROR = '500 Could not process your message'
|
||||
|
||||
# Load configuration and init logging, in this order. Only then can we load
|
||||
# the last Lacre module, i.e. lacre.mailgate.
|
||||
conf.load_config()
|
||||
lacre.init_logging(conf.get_item("logging", "config"))
|
||||
LOG = logging.getLogger('lacre.daemon')
|
||||
|
||||
from GnuPG import EncryptionException
|
||||
import lacre.core as gate
|
||||
import lacre.keyring as kcache
|
||||
import lacre.transport as xport
|
||||
from lacre.mailop import KeepIntact
|
||||
|
||||
|
||||
class MailEncryptionProxy:
|
||||
|
@ -39,25 +38,72 @@ class MailEncryptionProxy:
|
|||
start = time.process_time()
|
||||
try:
|
||||
keys = await self._keyring.freeze_identities()
|
||||
message = email.message_from_bytes(envelope.content)
|
||||
LOG.debug('Parsing message: %s', self._beginning(envelope))
|
||||
message = email.message_from_bytes(envelope.original_content, policy=SMTPUTF8)
|
||||
LOG.debug('Parsed into %s: %s', type(message), repr(message))
|
||||
|
||||
if message.defects:
|
||||
# Sometimes a weird message cannot be encoded back and
|
||||
# delivered, so before bouncing such messages we at least
|
||||
# record information about the issues. Defects are identified
|
||||
# by email.* package.
|
||||
LOG.warning("Issues found: %d; %s", len(message.defects), repr(message.defects))
|
||||
|
||||
if conf.flag_enabled('daemon', 'log_headers'):
|
||||
LOG.info('Message headers: %s', self._extract_headers(message))
|
||||
|
||||
send = xport.SendFrom(envelope.mail_from)
|
||||
for operation in gate.delivery_plan(envelope.rcpt_tos, message, keys):
|
||||
LOG.debug(f"Sending mail via {operation!r}")
|
||||
new_message = operation.perform(message)
|
||||
gate.send_msg(new_message, operation.recipients(), envelope.mail_from)
|
||||
except TypeError as te:
|
||||
LOG.exception("Got exception while processing", exc_info=te)
|
||||
return RESULT_ERROR
|
||||
try:
|
||||
new_message = operation.perform(message)
|
||||
send(new_message, operation.recipients())
|
||||
except EncryptionException:
|
||||
# If the message can't be encrypted, deliver cleartext.
|
||||
LOG.exception('Unable to encrypt message, delivering in cleartext')
|
||||
if not isinstance(operation, KeepIntact):
|
||||
self._send_unencrypted(operation, message, envelope, send)
|
||||
else:
|
||||
LOG.error(f'Cannot perform {operation}')
|
||||
|
||||
except:
|
||||
LOG.exception('Unexpected exception caught, bouncing message')
|
||||
return xport.RESULT_ERROR
|
||||
|
||||
ellapsed = (time.process_time() - start) * 1000
|
||||
LOG.info(f'Message delivered in {ellapsed:.2f} ms')
|
||||
return RESULT_OK
|
||||
|
||||
return xport.RESULT_OK
|
||||
|
||||
def _send_unencrypted(self, operation, message, envelope, send: xport.SendFrom):
|
||||
keep = KeepIntact(operation.recipients())
|
||||
new_message = keep.perform(message)
|
||||
send(new_message, operation.recipients(), envelope.mail_from)
|
||||
|
||||
def _beginning(self, e: Envelope) -> bytes:
|
||||
double_eol_pos = e.original_content.find(DOUBLE_EOL_BYTES)
|
||||
if double_eol_pos < 0:
|
||||
limit = len(e.original_content)
|
||||
else:
|
||||
limit = double_eol_pos
|
||||
end = min(limit, 2560)
|
||||
return e.original_content[0:end]
|
||||
|
||||
def _extract_headers(self, message: email.message.Message):
|
||||
return {
|
||||
'mime' : message.get_content_type(),
|
||||
'charsets' : message.get_charsets(),
|
||||
'cte' : message['Content-Transfer-Encoding']
|
||||
}
|
||||
|
||||
|
||||
def _init_controller(keys: kcache.KeyRing, tout: float = 5):
|
||||
def _init_controller(keys: kcache.KeyRing, max_body_bytes=None, tout: float = 5):
|
||||
proxy = MailEncryptionProxy(keys)
|
||||
host, port = conf.daemon_params()
|
||||
LOG.info(f"Initialising a mail Controller at {host}:{port}")
|
||||
return Controller(proxy, hostname=host, port=port, ready_timeout=tout)
|
||||
return Controller(proxy, hostname=host, port=port,
|
||||
ready_timeout=tout,
|
||||
data_size_limit=max_body_bytes)
|
||||
|
||||
|
||||
def _init_reloader(keyring_dir: str, reloader) -> kcache.KeyringModificationListener:
|
||||
|
@ -88,8 +134,12 @@ def _main():
|
|||
_validate_config()
|
||||
|
||||
keyring_path = conf.get_item('gpg', 'keyhome')
|
||||
keyring = kcache.KeyRing(keyring_path)
|
||||
controller = _init_controller(keyring)
|
||||
max_data_bytes = int(conf.get_item('daemon', 'max_data_bytes', 2**25))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
keyring = kcache.KeyRing(keyring_path, loop)
|
||||
controller = _init_controller(keyring, max_data_bytes)
|
||||
reloader = _init_reloader(keyring_path, keyring)
|
||||
|
||||
LOG.info(f'Watching keyring directory {keyring_path}...')
|
||||
|
@ -99,7 +149,7 @@ def _main():
|
|||
controller.start()
|
||||
|
||||
try:
|
||||
asyncio.run(_sleep())
|
||||
loop.run_until_complete(_sleep())
|
||||
except KeyboardInterrupt:
|
||||
LOG.info("Finishing...")
|
||||
except:
|
||||
|
|
|
@ -9,7 +9,7 @@ import lacre.config as conf
|
|||
import logging
|
||||
from os import stat
|
||||
from watchdog.events import FileSystemEventHandler, FileSystemEvent
|
||||
from asyncio import Semaphore, run
|
||||
from asyncio import Semaphore, create_task, get_event_loop, run
|
||||
import copy
|
||||
|
||||
import GnuPG
|
||||
|
@ -62,7 +62,7 @@ class KeyCache:
|
|||
def __repr__(self):
|
||||
"""Return text representation of this object."""
|
||||
details = ' '.join(self._keys.keys())
|
||||
return f'<KeyCache {details}>'
|
||||
return '<KeyCache %s>' % (details)
|
||||
|
||||
|
||||
class KeyRing:
|
||||
|
@ -73,12 +73,13 @@ class KeyRing:
|
|||
fingerprint=>email maps.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str):
|
||||
def __init__(self, path: str, loop=None):
|
||||
"""Initialise the adapter."""
|
||||
self._path = path
|
||||
self._keys = self._load_and_sanitize()
|
||||
self._sema = Semaphore()
|
||||
self._last_mod = None
|
||||
self._loop = loop or get_event_loop()
|
||||
|
||||
def _load_and_sanitize(self):
|
||||
keys = self._load_keyring_from(self._path)
|
||||
|
@ -96,13 +97,19 @@ class KeyRing:
|
|||
def load(self):
|
||||
"""Load keyring, replacing any previous contents of the cache."""
|
||||
LOG.debug('Reloading keys...')
|
||||
run(self._load())
|
||||
tsk = create_task(self._load(), 'LoadTask')
|
||||
self._loop.run_until_complete(tsk)
|
||||
|
||||
async def _load(self):
|
||||
last_mod = self._read_mod_time()
|
||||
LOG.debug(f'Keyring was last modified: {last_mod}')
|
||||
if self._is_modified(last_mod):
|
||||
LOG.debug('Keyring has been modified')
|
||||
async with self._sema:
|
||||
LOG.debug('About to re-load the keyring')
|
||||
self.replace_keyring(self._load_keyring_from(self._path))
|
||||
else:
|
||||
LOG.debug('Keyring not modified recently, continuing')
|
||||
|
||||
self._last_mod = self._read_mod_time()
|
||||
|
||||
|
@ -115,7 +122,7 @@ class KeyRing:
|
|||
LOG.info(f'Storing {len(keys)} keys')
|
||||
self._keys = keys
|
||||
|
||||
def _read_mod_time(self):
|
||||
def _read_mod_time(self) -> int:
|
||||
# (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime)
|
||||
# 0 1 2 3 4 5 6 7 8 9
|
||||
MTIME = 8
|
||||
|
@ -133,6 +140,10 @@ class KeyRing:
|
|||
LOG.debug('Keyring not modified ')
|
||||
return False
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return text representation of this keyring."""
|
||||
return '<KeyRing path=%s last_mod=%d>' % (self._path, self._last_mod)
|
||||
|
||||
|
||||
class KeyringModificationListener(FileSystemEventHandler):
|
||||
"""A filesystem event listener that triggers key cache reload."""
|
||||
|
@ -143,11 +154,22 @@ class KeyringModificationListener(FileSystemEventHandler):
|
|||
|
||||
def handle(self, event: FileSystemEvent):
|
||||
"""Reload keys upon FS event."""
|
||||
LOG.debug('FS event: %s, %s', event.event_type, event.src_path)
|
||||
if 'pubring.kbx' in event.src_path:
|
||||
LOG.debug(f'Reloading on event {event!r}')
|
||||
LOG.info('Reloading %s on event: %s', self._keyring, event)
|
||||
self._keyring.reload()
|
||||
|
||||
# All methods should do the same: reload the key cache.
|
||||
# on_created = handle
|
||||
# on_deleted = handle
|
||||
on_modified = handle
|
||||
|
||||
|
||||
def freeze_and_load_keys():
|
||||
"""Load and return keys.
|
||||
|
||||
Doesn't refresh the keys when they change on disk.
|
||||
'"""
|
||||
keyring_dir = conf.get_item('gpg', 'keyhome')
|
||||
keyring = KeyRing(keyring_dir)
|
||||
return run(keyring.freeze_identities())
|
||||
|
|
|
@ -16,6 +16,7 @@ There are 3 operations available:
|
|||
import logging
|
||||
import lacre.core as core
|
||||
from email.message import Message
|
||||
from email.policy import SMTP, SMTPUTF8
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
@ -28,7 +29,7 @@ class MailOperation:
|
|||
"""Initialise the operation with a recipient."""
|
||||
self._recipients = recipients
|
||||
|
||||
def perform(self, message: Message):
|
||||
def perform(self, message: Message) -> bytes:
|
||||
"""Perform this operation on MESSAGE.
|
||||
|
||||
Return target message.
|
||||
|
@ -69,12 +70,12 @@ class InlineOpenPGPEncrypt(OpenPGPEncrypt):
|
|||
"""Initialise strategy object."""
|
||||
super().__init__(recipients, keys, keyhome)
|
||||
|
||||
def perform(self, msg: Message):
|
||||
def perform(self, msg: Message) -> bytes:
|
||||
"""Encrypt with PGP Inline."""
|
||||
LOG.debug('Sending PGP/Inline...')
|
||||
return core._gpg_encrypt_and_return(msg,
|
||||
self._keys, self._recipients,
|
||||
core._encrypt_all_payloads_inline)
|
||||
return core._gpg_encrypt_to_bytes(msg,
|
||||
self._keys, self._recipients,
|
||||
core._encrypt_all_payloads_inline)
|
||||
|
||||
|
||||
class MimeOpenPGPEncrypt(OpenPGPEncrypt):
|
||||
|
@ -84,12 +85,12 @@ class MimeOpenPGPEncrypt(OpenPGPEncrypt):
|
|||
"""Initialise strategy object."""
|
||||
super().__init__(recipients, keys, keyhome)
|
||||
|
||||
def perform(self, msg: Message):
|
||||
def perform(self, msg: Message) -> bytes:
|
||||
"""Encrypt with PGP MIME."""
|
||||
LOG.debug('Sending PGP/MIME...')
|
||||
return core._gpg_encrypt_and_return(msg,
|
||||
self._keys, self._recipients,
|
||||
core._encrypt_all_payloads_mime)
|
||||
return core._gpg_encrypt_to_bytes(msg,
|
||||
self._keys, self._recipients,
|
||||
core._encrypt_all_payloads_mime)
|
||||
|
||||
|
||||
class SMimeEncrypt(MailOperation):
|
||||
|
@ -101,10 +102,10 @@ class SMimeEncrypt(MailOperation):
|
|||
self._email = email
|
||||
self._cert = certificate
|
||||
|
||||
def perform(self, message: Message):
|
||||
def perform(self, message: Message) -> bytes:
|
||||
"""Encrypt with a certificate."""
|
||||
LOG.warning(f"Delivering clear-text to {self._recipients}")
|
||||
return message
|
||||
return message.as_bytes(policy=SMTP)
|
||||
|
||||
def __repr__(self):
|
||||
"""Generate a representation with just method and key."""
|
||||
|
@ -121,9 +122,9 @@ class KeepIntact(MailOperation):
|
|||
"""Initialise pass-through operation for a given recipient."""
|
||||
super().__init__(recipients)
|
||||
|
||||
def perform(self, message: Message):
|
||||
def perform(self, message: Message) -> bytes:
|
||||
"""Return MESSAGE unmodified."""
|
||||
return message.as_string()
|
||||
return message.as_bytes(policy=SMTPUTF8)
|
||||
|
||||
def __repr__(self):
|
||||
"""Return representation with just method and email."""
|
||||
|
|
204
lacre/recipients.py
Normal file
204
lacre/recipients.py
Normal file
|
@ -0,0 +1,204 @@
|
|||
#
|
||||
# gpg-mailgate
|
||||
#
|
||||
# This file is part of the gpg-mailgate source code.
|
||||
#
|
||||
# gpg-mailgate is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# gpg-mailgate source code is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with gpg-mailgate source code. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
"""Recipient processing package.
|
||||
|
||||
Defines:
|
||||
- GpgRecipient, wrapper for user's email and identity.
|
||||
- RecipientList, a wrapper for lists of GpgRecipient objects.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import lacre.config as conf
|
||||
import lacre.keyring as kcache
|
||||
import lacre.text as text
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Recipient:
|
||||
"""Wraps recipient's email."""
|
||||
|
||||
def __init__(self, email):
|
||||
"""Initialise the recipient."""
|
||||
self._email = email
|
||||
|
||||
def email(self) -> str:
|
||||
"""Return email address of this recipient."""
|
||||
return self._email
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of this recipient: the email address."""
|
||||
return self._email
|
||||
|
||||
|
||||
class GpgRecipient(Recipient):
|
||||
"""A tuple-like object that contains GPG recipient data."""
|
||||
|
||||
def __init__(self, left, right):
|
||||
"""Initialise a tuple-like object that contains GPG recipient data."""
|
||||
self._left = left
|
||||
self._right = right
|
||||
|
||||
def __getitem__(self, index):
|
||||
"""Pretend this object is a tuple by returning an indexed tuple element."""
|
||||
if index == 0:
|
||||
return self._left
|
||||
elif index == 1:
|
||||
return self._right
|
||||
else:
|
||||
raise IndexError()
|
||||
|
||||
def __repr__(self):
|
||||
"""Return textual representation of this GPG Recipient."""
|
||||
return f"GpgRecipient({self._left!r}, {self._right!r})"
|
||||
|
||||
def email(self) -> str:
|
||||
"""Return this recipient's email address."""
|
||||
return self._left
|
||||
|
||||
def key(self):
|
||||
"""Return this recipient's key ID."""
|
||||
return self._right
|
||||
|
||||
|
||||
class RecipientList:
|
||||
"""Encalsulates two lists of recipients.
|
||||
|
||||
First list contains addresses, the second - GPG identities.
|
||||
"""
|
||||
|
||||
def __init__(self, recipients=[], keys=[]):
|
||||
"""Initialise lists of recipients and identities."""
|
||||
self._recipients = [GpgRecipient(email, key) for (email, key) in zip(recipients, keys)]
|
||||
|
||||
def emails(self):
|
||||
"""Return list of recipients."""
|
||||
return [r.email() for r in self._recipients]
|
||||
|
||||
def keys(self):
|
||||
"""Return list of GPG identities."""
|
||||
return [r.key() for r in self._recipients]
|
||||
|
||||
def __iadd__(self, recipient: GpgRecipient):
|
||||
"""Append a recipient."""
|
||||
LOG.debug('Adding %s to %s', recipient, self._recipients)
|
||||
self._recipients.append(recipient)
|
||||
LOG.debug('Added; got: %s', self._recipients)
|
||||
return self
|
||||
|
||||
def __len__(self):
|
||||
"""Provide len().
|
||||
|
||||
With this method, it is possible to write code like:
|
||||
|
||||
rl = RecipientList()
|
||||
if rl:
|
||||
# do something
|
||||
"""
|
||||
return len(self._recipients)
|
||||
|
||||
def __repr__(self):
|
||||
"""Returns textual object representation."""
|
||||
return '<RecipientList %d %s>' % (len(self._recipients), ','.join(self.emails()))
|
||||
|
||||
|
||||
def identify_gpg_recipients(recipients, keys: kcache.KeyCache):
|
||||
"""Split recipient list into GPG and non-GPG ones."""
|
||||
# This list will be filled with pairs (M, N), where M is the destination
|
||||
# address we're going to deliver the message to and N is the identity we're
|
||||
# going to encrypt it for.
|
||||
gpg_recipients = list()
|
||||
|
||||
# This will be the list of recipients that haven't provided us with their
|
||||
# public keys.
|
||||
cleartext_recipients = list()
|
||||
|
||||
# In "strict mode", only keys included in configuration are used to encrypt
|
||||