diff --git a/README.md b/README.md index 23ff4f24..6a400c29 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ With this optional dependency, HTML emails are nicely rendered inside the Django admin backend. Without this library, all HTML tags will otherwise be stripped for security reasons. +- [PGPy](https://pgpy.readthedocs.io) + +Allow to send encrypted and signed mails as per [RFC3156](https://datatracker.ietf.org/doc/html/rfc3156) +and [RFC4880](https://datatracker.ietf.org/doc/html/rfc4880). + ## Installation [![Build @@ -125,6 +130,8 @@ these arguments: | priority | No | `high`, `medium`, `low` or `now` (sent immediately) | | backend | No | Alias of the backend you want to use, `default` will be used if not specified. | | render_on_delivery | No | Setting this to `True` causes email to be lazily rendered during delivery. `template` is required when `render_on_delivery` is True. With this option, the full email content is never stored in the DB. May result in significant space savings if you're sending many emails using the same template. | +| recipients_pubkeys | No | Array of PGP keys of the recipients to be used to encrypt the message. Can be a list of strings containing the armorized public key or PGPKey objects. | +| pgp_signed | No | Whether the email should be signed with a configuration-provided key. | Here are a few examples. @@ -611,6 +618,22 @@ POST_OFFICE = { } ``` +### Signature + +`post-office` >= 3.6 allows you to send singed encrypted emails via PGPy. + +To configure the private key to be used for sign, add the following to your +`settings.py`: + +```python +POST_OFFICE = { + ... + 'PGP_SIGNING_KEY_PATH': '/path/to/my/key.asc', + 'PGP_SIGNING_KEY_PASSPHRASE': 'mysecretpassphrase', # Only required when the private key is blocked with a passphrase +} +``` + + Performance ----------- diff --git a/post_office/gpg.py b/post_office/gpg.py new file mode 100644 index 00000000..5594123a --- /dev/null +++ b/post_office/gpg.py @@ -0,0 +1,277 @@ +# Copyright (c) 2021 The Document Foundation +# The implementation of PGP Encryption support for django-post_office has been done +# on behalf of TDF by +# Andrea Esposito +# Marco Marinello + +from django.core.mail import EmailMessage, EmailMultiAlternatives, SafeMIMEMultipart +from email.mime.application import MIMEApplication +from email.mime.text import MIMEText +from email.encoders import encode_7or8bit, encode_quopri, encode_base64 + +from .settings import get_signing_key_path, get_signing_key_passphrase + + +def find_public_keys_for_encryption(primary): + """ + A function that isolates a (or some) subkey(s) from a primary key + (if it has any) based on its usage flags, looking for the one(s) that can + be used for encryption. + It returns an empty list if it cannot find any. + """ + try: + from pgpy.constants import KeyFlags + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + encryption_keys = [] + if not primary: + return encryption_keys + for k in primary.subkeys.keys(): + subkey = primary.subkeys[k] + flags = subkey._get_key_flags() + if KeyFlags.EncryptCommunications in flags and KeyFlags.EncryptStorage in flags: + encryption_keys.append(subkey) + + return encryption_keys + + +def find_private_key_for_signing(primary): + """ + A function that returns the primary key or one of its subkeys, ensured + to be the most recent key the can be used for signing. + """ + try: + from pgpy.constants import KeyFlags + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + if not primary: + return None + + most_recent_signing_key = None + for k in primary.subkeys.keys(): + subkey = primary.subkeys[k] + flags = subkey._get_key_flags() + if KeyFlags.Sign in flags and (not most_recent_signing_key or + most_recent_signing_key.created < subkey.created): + most_recent_signing_key = subkey + + return most_recent_signing_key if most_recent_signing_key else primary + + +def find_public_key_for_recipient(pubkeys, recipient): + """ + A function that looks through a list of valid public keys (validated using validate_public_keys) + trying to match the email of the given recipient. + """ + for pubkey in pubkeys: + for userid in pubkey.userids: + if userid.email == recipient: + return pubkey + return None + + +def encrypt_with_pubkeys(_pubkeys, payload): + try: + from pgpy.constants import SymmetricKeyAlgorithm + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + pubkeys = [] + for pubkey in _pubkeys: + suitable = find_public_keys_for_encryption(pubkey) + if suitable: + pubkeys.extend(suitable) + + if len(pubkeys) < 1: + return payload + elif len(pubkeys) == 1: + return pubkeys[0].encrypt(payload) + + cipher = SymmetricKeyAlgorithm.AES256 + skey = cipher.gen_key() + + for pubkey in pubkeys: + payload = pubkey.encrypt(payload, cipher=cipher, sessionkey=skey) + + del skey + return payload + + +def sign_with_privkey(_privkey, payload): + privkey = find_private_key_for_signing(_privkey) + if not privkey: + return payload + + if not privkey.is_unlocked: + raise ValueError('The selected signing private key is locked') + + return privkey.sign(payload) + + +def safe_encode(mime): + try: + from pgpy import PGPMessage + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + if isinstance(mime, str): + return str.encode('quoted-printable') + if mime.is_multipart: + for payload in mime.get_payload(): + safe_encode(payload) + else: + if not mime: + return mime + del mime['Content-Transfer-Encoding'] + if isinstance(mime, MIMEText): + encode_quopri(mime) + else: + encode_base64(mime) + return mime + + +def process_message(msg, pubkeys, privkey): + """ + Apply signature and/or encryption to the given message payload. + This function also applies the Quoted-Printable or Base64 transfer + encoding to both non-multipart and multipart (recursively) messages + and replaces newline characters with sequences, as per RFC 3156. + A rather rustic workaround has been put in place to prevent the leading + '\n ' sequence of the boundary parameter in the Content-Type header from + invalidating the signature. + """ + try: + from pgpy import PGPMessage + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + msg = safe_encode(msg) + payload = msg.as_string().replace( + '\n boundary', ' boundary' + ).replace('\n', '\r\n') + + if privkey: + if privkey.is_unlocked: + signature = privkey.sign(payload) + else: + passphrase = get_signing_key_passphrase() + if not passphrase: + raise ValueError('No key passphrase found to unlock, cannot sign') + with privkey.unlock(passphrase): + signature = privkey.sign(payload) + del passphrase + + signature = MIMEApplication( + str(signature), + _subtype='pgp-signature', + _encoder=encode_7or8bit + ) + msg = SafeMIMEMultipart( + _subtype='signed', + _subparts=[msg, signature], + micalg='pgp-sha256', + protocol='application/pgp-signature' + ) + + if pubkeys: + payload = encrypt_with_pubkeys( + pubkeys, PGPMessage.new(str(msg)) + ) + + control = MIMEApplication( + "Version: 1", + _subtype='pgp-encrypted', + _encoder=encode_7or8bit + ) + data = MIMEApplication( + str(payload), + _encoder=encode_7or8bit + ) + msg = SafeMIMEMultipart( + _subtype='encrypted', + _subparts=[control, data], + protocol='application/pgp-encrypted' + ) + + return msg + + +class EncryptedOrSignedEmailMessage(EmailMessage): + """ + A class representing an RFC3156 compliant MIME multipart message containing + an OpenPGP-encrypted simple email message. + """ + + def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): + super().__init__(**kwargs) + + try: + from pgpy import PGPKey + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + if pubkeys: + self.pubkeys = [PGPKey.from_blob(pubkey)[0] + for pubkey in pubkeys] + else: + self.pubkeys = [] + + if sign_with_privkey: + path = get_signing_key_path() + if not path: + raise ValueError('No key path found, cannot sign message') + self.privkey = find_private_key_for_signing( + PGPKey.from_file(path)[0] + ) + else: + self.privkey = None + + if not self.pubkeys and not self.privkey: + raise ValueError( + 'EncryptedOrSignedEmailMessage requires either a non-null and non-empty list of gpg ' + 'public keys or a valid private key') + + def _create_message(self, msg): + msg = super()._create_message(msg) + return process_message(msg, self.pubkeys, self.privkey) + + +class EncryptedOrSignedEmailMultiAlternatives(EmailMultiAlternatives): + """ + A class representing an RFC3156 compliant MIME multipart message containing + an OpenPGP-encrypted multipart/alternative email message (with multiple + versions e.g. plain text and html). + """ + + def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): + super().__init__(**kwargs) + + try: + from pgpy import PGPKey + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + if pubkeys: + self.pubkeys = [PGPKey.from_blob(pubkey)[0] for pubkey in pubkeys] + else: + self.pubkeys = [] + + if sign_with_privkey: + path = get_signing_key_path() + if not path: + raise ValueError('No key path found, cannot sign message') + self.privkey = find_private_key_for_signing( + PGPKey.from_file(path)[0] + ) + else: + self.privkey = None + + if not self.pubkeys and not self.privkey: + raise ValueError( + 'EncryptedOrSignedEmailMultiAlternatives requires either a non-null and non-empty list of gpg ' + 'public keys or a valid private key') + + def _create_message(self, msg): + msg = super()._create_message(msg) + return process_message(msg, self.pubkeys, self.privkey) diff --git a/post_office/mail.py b/post_office/mail.py index ba77b682..1e9bc07f 100644 --- a/post_office/mail.py +++ b/post_office/mail.py @@ -20,7 +20,7 @@ ) from .signals import email_queued from .utils import ( - create_attachments, get_email_template, parse_emails, parse_priority, split_emails, + create_attachments, get_email_template, parse_emails, parse_priority, split_emails, validate_public_keys, ) logger = setup_loghandlers("INFO") @@ -29,7 +29,7 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', html_message='', context=None, scheduled_time=None, expires_at=None, headers=None, template=None, priority=None, render_on_delivery=False, commit=True, - backend=''): + backend='', recipients_pubkeys=None, pgp_signed=False): """ Creates an email from supplied keyword arguments. If template is specified, email subject and content will be rendered during delivery. @@ -59,7 +59,8 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', expires_at=expires_at, message_id=message_id, headers=headers, priority=priority, status=status, - context=context, template=template, backend_alias=backend + context=context, template=template, backend_alias=backend, + pgp_pubkeys=recipients_pubkeys, pgp_signed=pgp_signed ) else: @@ -86,7 +87,8 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', expires_at=expires_at, message_id=message_id, headers=headers, priority=priority, status=status, - backend_alias=backend + backend_alias=backend, + pgp_pubkeys=recipients_pubkeys, pgp_signed=pgp_signed ) if commit: @@ -99,7 +101,7 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', message='', html_message='', scheduled_time=None, expires_at=None, headers=None, priority=None, attachments=None, render_on_delivery=False, log_level=None, commit=True, cc=None, bcc=None, language='', - backend=''): + backend='', recipients_pubkeys=None, pgp_signed=False): try: recipients = parse_emails(recipients) except ValidationError as e: @@ -115,6 +117,11 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', except ValidationError as e: raise ValidationError('bcc: %s' % e.message) + try: + recipients_pubkeys = validate_public_keys(recipients_pubkeys) + except ValidationError as e: + raise ValidationError('pubkeys: %s' % e.message) + if sender is None: sender = settings.DEFAULT_FROM_EMAIL @@ -151,7 +158,8 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', email = create(sender, recipients, cc, bcc, subject, message, html_message, context, scheduled_time, expires_at, headers, template, priority, - render_on_delivery, commit=commit, backend=backend) + render_on_delivery, commit=commit, backend=backend, + recipients_pubkeys=recipients_pubkeys, pgp_signed=pgp_signed) if attachments: attachments = create_attachments(attachments) diff --git a/post_office/models.py b/post_office/models.py index 34312a3b..36b1b07f 100644 --- a/post_office/models.py +++ b/post_office/models.py @@ -7,6 +7,7 @@ from django.core.exceptions import ValidationError from django.core.mail import EmailMessage, EmailMultiAlternatives from django.db import models +from django.db.models.fields import BooleanField from django.utils.encoding import smart_str from django.utils.translation import pgettext_lazy, gettext_lazy as _ from django.utils import timezone @@ -18,6 +19,7 @@ from .connections import connections from .settings import context_field_class, get_log_level, get_template_engine, get_override_recipients from .validators import validate_email_with_name, validate_template_syntax +from .gpg import EncryptedOrSignedEmailMessage, EncryptedOrSignedEmailMultiAlternatives, sign_with_privkey PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4)) @@ -71,6 +73,8 @@ class Email(models.Model): context = context_field_class(_('Context'), blank=True, null=True) backend_alias = models.CharField(_("Backend alias"), blank=True, default='', max_length=64) + pgp_pubkeys = JSONField(blank=True, null=True) + pgp_signed = BooleanField(default=False) class Meta: app_label = 'post_office' @@ -126,25 +130,51 @@ def prepare_email_message(self): if html_message: if plaintext_message: - msg = EmailMultiAlternatives( - subject=subject, body=plaintext_message, from_email=self.from_email, - to=self.to, bcc=self.bcc, cc=self.cc, - headers=headers, connection=connection) - msg.attach_alternative(html_message, "text/html") + if self.pgp_pubkeys or self.pgp_signed: + msg = EncryptedOrSignedEmailMultiAlternatives( + subject=subject, body=plaintext_message, from_email=self.from_email, + to=self.to, bcc=self.bcc, cc=self.cc, + headers=headers, connection=connection, + pubkeys=self.pgp_pubkeys, + sign_with_privkey=self.pgp_signed) + msg.attach_alternative(html_message, "text/html") + else: + msg = EmailMultiAlternatives( + subject=subject, body=plaintext_message, from_email=self.from_email, + to=self.to, bcc=self.bcc, cc=self.cc, + headers=headers, connection=connection) + msg.attach_alternative(html_message, "text/html") else: - msg = EmailMultiAlternatives( - subject=subject, body=html_message, from_email=self.from_email, - to=self.to, bcc=self.bcc, cc=self.cc, - headers=headers, connection=connection) - msg.content_subtype = 'html' + if self.pgp_pubkeys or self.pgp_signed: + msg = EncryptedOrSignedEmailMultiAlternatives( + subject=subject, body=html_message, from_email=self.from_email, + to=self.to, bcc=self.bcc, cc=self.cc, + headers=headers, connection=connection, + pubkeys=self.pgp_pubkeys, + sign_with_privkey=self.pgp_signed) + msg.content_subtype = 'html' + else: + msg = EmailMultiAlternatives( + subject=subject, body=html_message, from_email=self.from_email, + to=self.to, bcc=self.bcc, cc=self.cc, + headers=headers, connection=connection) + msg.content_subtype = 'html' if hasattr(multipart_template, 'attach_related'): multipart_template.attach_related(msg) else: - msg = EmailMessage( - subject=subject, body=plaintext_message, from_email=self.from_email, - to=self.to, bcc=self.bcc, cc=self.cc, - headers=headers, connection=connection) + if self.pgp_pubkeys or self.pgp_signed: + msg = EncryptedOrSignedEmailMessage( + subject=subject, body=plaintext_message, from_email=self.from_email, + to=self.to, bcc=self.bcc, cc=self.cc, + headers=headers, connection=connection, + pubkeys=self.pgp_pubkeys, + sign_with_privkey=self.pgp_signed) + else: + msg = EmailMessage( + subject=subject, body=plaintext_message, from_email=self.from_email, + to=self.to, bcc=self.bcc, cc=self.cc, + headers=headers, connection=connection) for attachment in self.attachments.all(): if attachment.headers: diff --git a/post_office/settings.py b/post_office/settings.py index ae20d029..76c8899f 100644 --- a/post_office/settings.py +++ b/post_office/settings.py @@ -124,6 +124,14 @@ def get_message_id_fqdn(): return get_config().get('MESSAGE_ID_FQDN', DNS_NAME) +def get_signing_key_path(): + return get_config().get('PGP_SIGNING_KEY_PATH', None) + + +def get_signing_key_passphrase(): + return get_config().get('PGP_SIGNING_KEY_PASSPHRASE', None) + + CONTEXT_FIELD_CLASS = get_config().get('CONTEXT_FIELD_CLASS', 'jsonfield.JSONField') context_field_class = import_string(CONTEXT_FIELD_CLASS) diff --git a/post_office/utils.py b/post_office/utils.py index c5d6f9a7..e083e461 100644 --- a/post_office/utils.py +++ b/post_office/utils.py @@ -157,3 +157,40 @@ def cleanup_expired_mails(cutoff_date, delete_attachments=True): attachments_count = 0 return emails_count, attachments_count + + +def validate_public_keys(pubkeys): + """ + A function that validates a list of PGP keys. + This function tries to parse each key from either PGPKey objects or + strings (armored public key). It also checks whether the parsed keys are + not expired. + If the check is successful, it then returns a list of strings + where each element is a key from the input list, as an armored PGP + ASCII string. + None value is also converted into an empty list. + """ + try: + from pgpy import PGPKey + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + if isinstance(pubkeys, str): + pubkeys = [pubkeys] + elif pubkeys is None: + pubkeys = [] + + for i, pubkey in enumerate(pubkeys): + try: + if isinstance(pubkey, str): + pubkeys[i] = PGPKey.from_blob(pubkey)[0] + if pubkeys[i].is_expired: + raise ValueError('the given key has expired on this moment: %s' % str(pubkeys[i].expires_at)) + elif isinstance(pubkey, PGPKey): + pass + else: + raise ValueError('the given key is either null or of an invalid type') + except ValueError as e: + raise ValidationError('Invalid PGP key: %s' % str(e)) + + return [str(pubkey) for pubkey in pubkeys] \ No newline at end of file