From 65429936578c1e7248277b26f348388ca374d59d Mon Sep 17 00:00:00 2001 From: Andrea Esposito Date: Wed, 18 Aug 2021 17:25:39 +0200 Subject: [PATCH 1/8] Implemented first attempt of GPG encryption --- post_office/mail.py | 46 ++++++++++++++++++++++++++++++++++++++------ post_office/utils.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/post_office/mail.py b/post_office/mail.py index ba77b682..55ec545d 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, find_public_key_for_recipient, get_email_template, parse_emails, parse_priority, split_emails, parse_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='', pubkey=None): """ Creates an email from supplied keyword arguments. If template is specified, email subject and content will be rendered during delivery. @@ -47,6 +47,12 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', context = '' message_id = make_msgid(domain=get_message_id_fqdn()) if get_message_id_enabled() else None + if pubkey is not None: + if len(recipients) > 1: + raise ValueError('Cannot create a GPG encrypted email with multiple recipients') + if render_on_delivery: + raise ValueError('GPG encryption is currently not supported when using on-delivery rendering') + # If email is to be rendered during delivery, save all necessary # information if render_on_delivery: @@ -74,6 +80,18 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', message = Template(message).render(_context) html_message = Template(html_message).render(_context) + if pubkey is not None: + try: + from pgpy import PGPMessage + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + message = str(pubkey.encrypt( + PGPMessage.new(message) + )) if message else message + html_message = str(pubkey.encrypt( + PGPMessage.new(html_message) + )) if html_message else html_message + email = Email( from_email=sender, to=recipients, @@ -99,7 +117,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='', pubkeys=None): try: recipients = parse_emails(recipients) except ValidationError as e: @@ -115,6 +133,11 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', except ValidationError as e: raise ValidationError('bcc: %s' % e.message) + try: + pubkeys = parse_public_keys(pubkeys) + except ValidationError as e: + raise ValidationError('pubkeys: %s' % e.message) + if sender is None: sender = settings.DEFAULT_FROM_EMAIL @@ -149,9 +172,20 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', if backend and backend not in get_available_backends().keys(): raise ValueError('%s is not a valid backend alias' % backend) - 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) + if len(recipients) == 1: + pubkey = find_public_key_for_recipient(pubkeys, recipients[0]) + 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, pubkey=pubkey) + else: + for recipient in recipients: + pubkey = find_public_key_for_recipient(pubkeys, recipient) + send([recipient], sender, template, context, subject, + message, html_message, scheduled_time, expires_at, + headers, priority, attachments, render_on_delivery, + log_level, commit, cc, bcc, language, backend, + [pubkey] if pubkey else None) + return None if attachments: attachments = create_attachments(attachments) diff --git a/post_office/utils.py b/post_office/utils.py index c5d6f9a7..2a66d429 100644 --- a/post_office/utils.py +++ b/post_office/utils.py @@ -157,3 +157,49 @@ def cleanup_expired_mails(cutoff_date, delete_attachments=True): attachments_count = 0 return emails_count, attachments_count + + +def parse_public_keys(pubkeys): + """ + A function that returns a list of pgpy.PGPKey objects matching the recepients. + 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. + 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].expires_at.now() >= pubkeys[i].expires_at: + 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 pubkeys + + +def find_public_key_for_recipient(pubkeys, recipient): + """ + A function that looks through a list of valid public keys (validated using parse_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 \ No newline at end of file From 7f95155f9d6e471311c0c0083d1e921c3828ec37 Mon Sep 17 00:00:00 2001 From: Andrea Esposito Date: Fri, 20 Aug 2021 23:50:57 +0200 Subject: [PATCH 2/8] Implemented on-delivery encryption, should be working with on-delivery rendering, html alternative and PGP subkeys --- post_office/gpg.py | 164 ++++++++++++++++++++++++++++++++++++++++++ post_office/mail.py | 44 +++--------- post_office/models.py | 53 ++++++++++---- post_office/utils.py | 23 ++---- 4 files changed, 219 insertions(+), 65 deletions(-) create mode 100644 post_office/gpg.py diff --git a/post_office/gpg.py b/post_office/gpg.py new file mode 100644 index 00000000..e32e470f --- /dev/null +++ b/post_office/gpg.py @@ -0,0 +1,164 @@ +from django.core.mail import EmailMessage, EmailMultiAlternatives, SafeMIMEMultipart +from email.mime.application import MIMEApplication +from email.encoders import encode_7or8bit + + +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_public_key_for_recipient(pubkeys, recipient): + """ + A function that looks through a list of valid public keys (validated using parse_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 + + +class EncryptedEmailMessage(EmailMessage): + """ + A class representing an RFC3156 compliant MIME multipart message containing + an OpenPGP-encrypted simple email message. + """ + def __init__(self, pubkeys=None, **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: + raise ValueError('EncryptedEmailMessage requires a non-null and non-empty list of gpg public keys') + + + def _create_message(self, msg): + try: + from pgpy import PGPMessage + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + msg = super()._create_message(msg) + + payload = PGPMessage.new(msg.as_string()) + payload = encrypt_with_pubkeys(self.pubkeys, payload) + + 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 EncryptedEmailMultiAlternatives(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, **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: + raise ValueError('EncryptedEmailMultiAlternatives requires a non-null and non-empty list of gpg public keys') + + + def _create_message(self, msg): + try: + from pgpy import PGPMessage + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') + + msg = super()._create_message(msg) + + payload = PGPMessage.new(msg.as_string()) + payload = encrypt_with_pubkeys(self.pubkeys, payload) + + 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 \ No newline at end of file diff --git a/post_office/mail.py b/post_office/mail.py index 55ec545d..6590fd53 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, find_public_key_for_recipient, get_email_template, parse_emails, parse_priority, split_emails, parse_public_keys, + 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='', pubkey=None): + backend='', pubkeys=None): """ Creates an email from supplied keyword arguments. If template is specified, email subject and content will be rendered during delivery. @@ -47,11 +47,6 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', context = '' message_id = make_msgid(domain=get_message_id_fqdn()) if get_message_id_enabled() else None - if pubkey is not None: - if len(recipients) > 1: - raise ValueError('Cannot create a GPG encrypted email with multiple recipients') - if render_on_delivery: - raise ValueError('GPG encryption is currently not supported when using on-delivery rendering') # If email is to be rendered during delivery, save all necessary # information @@ -65,7 +60,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, + pubkeys=pubkeys ) else: @@ -80,18 +76,6 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', message = Template(message).render(_context) html_message = Template(html_message).render(_context) - if pubkey is not None: - try: - from pgpy import PGPMessage - except ImportError: - raise ModuleNotFoundError('GPG encryption requires pgpy module') - message = str(pubkey.encrypt( - PGPMessage.new(message) - )) if message else message - html_message = str(pubkey.encrypt( - PGPMessage.new(html_message) - )) if html_message else html_message - email = Email( from_email=sender, to=recipients, @@ -104,7 +88,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, + pubkeys=pubkeys ) if commit: @@ -134,7 +119,7 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', raise ValidationError('bcc: %s' % e.message) try: - pubkeys = parse_public_keys(pubkeys) + pubkeys = validate_public_keys(pubkeys) except ValidationError as e: raise ValidationError('pubkeys: %s' % e.message) @@ -172,20 +157,9 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', if backend and backend not in get_available_backends().keys(): raise ValueError('%s is not a valid backend alias' % backend) - if len(recipients) == 1: - pubkey = find_public_key_for_recipient(pubkeys, recipients[0]) - email = create(sender, recipients, cc, bcc, subject, message, html_message, + 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, pubkey=pubkey) - else: - for recipient in recipients: - pubkey = find_public_key_for_recipient(pubkeys, recipient) - send([recipient], sender, template, context, subject, - message, html_message, scheduled_time, expires_at, - headers, priority, attachments, render_on_delivery, - log_level, commit, cc, bcc, language, backend, - [pubkey] if pubkey else None) - return None + render_on_delivery, commit=commit, backend=backend, pubkeys=pubkeys) if attachments: attachments = create_attachments(attachments) diff --git a/post_office/models.py b/post_office/models.py index 34312a3b..6391c85d 100644 --- a/post_office/models.py +++ b/post_office/models.py @@ -18,6 +18,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 EncryptedEmailMessage, EncryptedEmailMultiAlternatives PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4)) @@ -71,6 +72,7 @@ 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) + pubkeys = JSONField(blank=True, null=True) class Meta: app_label = 'post_office' @@ -126,25 +128,48 @@ 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.pubkeys: + msg = EncryptedEmailMultiAlternatives( + 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.pubkeys) + 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.pubkeys: + msg = EncryptedEmailMultiAlternatives( + 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.pubkeys) + 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.pubkeys: + msg = EncryptedEmailMessage( + 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.pubkeys) + 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/utils.py b/post_office/utils.py index 2a66d429..e083e461 100644 --- a/post_office/utils.py +++ b/post_office/utils.py @@ -159,12 +159,15 @@ def cleanup_expired_mails(cutoff_date, delete_attachments=True): return emails_count, attachments_count -def parse_public_keys(pubkeys): +def validate_public_keys(pubkeys): """ - A function that returns a list of pgpy.PGPKey objects matching the recepients. + 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: @@ -181,7 +184,7 @@ def parse_public_keys(pubkeys): try: if isinstance(pubkey, str): pubkeys[i] = PGPKey.from_blob(pubkey)[0] - if pubkeys[i].expires_at.now() >= pubkeys[i].expires_at: + 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 @@ -190,16 +193,4 @@ def parse_public_keys(pubkeys): except ValueError as e: raise ValidationError('Invalid PGP key: %s' % str(e)) - return pubkeys - - -def find_public_key_for_recipient(pubkeys, recipient): - """ - A function that looks through a list of valid public keys (validated using parse_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 \ No newline at end of file + return [str(pubkey) for pubkey in pubkeys] \ No newline at end of file From 8598ca7c9d8389aa6aed09da0df2d06d75de8800 Mon Sep 17 00:00:00 2001 From: Andrea Esposito Date: Sun, 22 Aug 2021 16:11:14 +0200 Subject: [PATCH 3/8] Implemented PGP signing, to be further tested --- post_office/gpg.py | 175 ++++++++++++++++++++++++++++------------ post_office/mail.py | 11 +-- post_office/models.py | 27 ++++--- post_office/settings.py | 8 ++ 4 files changed, 152 insertions(+), 69 deletions(-) diff --git a/post_office/gpg.py b/post_office/gpg.py index e32e470f..830bac8c 100644 --- a/post_office/gpg.py +++ b/post_office/gpg.py @@ -2,6 +2,8 @@ from email.mime.application import MIMEApplication from email.encoders import encode_7or8bit +from .settings import get_signing_key_path, get_signing_key_passphrase + def find_public_keys_for_encryption(primary): """ @@ -27,9 +29,33 @@ def find_public_keys_for_encryption(primary): 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 parse_public_keys) + 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: @@ -66,36 +92,53 @@ def encrypt_with_pubkeys(_pubkeys, payload): return payload -class EncryptedEmailMessage(EmailMessage): +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 process_message(msg, pubkeys, privkey): """ - A class representing an RFC3156 compliant MIME multipart message containing - an OpenPGP-encrypted simple email message. + Apply signature and/or encryption to the given message payload """ - def __init__(self, pubkeys=None, **kwargs): - super().__init__(**kwargs) + try: + from pgpy import PGPMessage + except ImportError: + raise ModuleNotFoundError('GPG encryption requires pgpy module') - try: - from pgpy import PGPKey - except ImportError: - raise ModuleNotFoundError('GPG encryption requires pgpy module') + payload = PGPMessage.new(msg.as_string()) - if pubkeys: - self.pubkeys = [PGPKey.from_blob(pubkey)[0] \ - for pubkey in pubkeys] + if privkey: + if privkey.is_unlocked: + signature = privkey.sign(payload) else: - raise ValueError('EncryptedEmailMessage requires a non-null and non-empty list of gpg public keys') - - - def _create_message(self, msg): - try: - from pgpy import PGPMessage - except ImportError: - raise ModuleNotFoundError('GPG encryption requires pgpy module') - - msg = super()._create_message(msg) + 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' + ) + msg = SafeMIMEMultipart( + _subtype='signed', + _subparts=[msg, signature], + protocol='application/pgp-signature' + ) - payload = PGPMessage.new(msg.as_string()) - payload = encrypt_with_pubkeys(self.pubkeys, payload) + if pubkeys: + payload = encrypt_with_pubkeys( + pubkeys, PGPMessage.new(str(msg)) + ) control = MIMEApplication( "Version: 1", @@ -112,16 +155,15 @@ def _create_message(self, msg): protocol='application/pgp-encrypted' ) - return msg + return msg -class EncryptedEmailMultiAlternatives(EmailMultiAlternatives): +class EncryptedOrSignedEmailMessage(EmailMessage): """ 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). + an OpenPGP-encrypted simple email message. """ - def __init__(self, pubkeys=None, **kwargs): + def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): super().__init__(**kwargs) try: @@ -130,35 +172,62 @@ def __init__(self, pubkeys=None, **kwargs): raise ModuleNotFoundError('GPG encryption requires pgpy module') if pubkeys: - self.pubkeys = [PGPKey.from_blob(pubkey)[0] for pubkey in pubkeys] + self.pubkeys = [PGPKey.from_blob(pubkey)[0] \ + for pubkey in pubkeys] else: - raise ValueError('EncryptedEmailMultiAlternatives requires a non-null and non-empty list of gpg public keys') + 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) - def _create_message(self, msg): try: - from pgpy import PGPMessage + from pgpy import PGPKey except ImportError: raise ModuleNotFoundError('GPG encryption requires pgpy module') - - msg = super()._create_message(msg) - payload = PGPMessage.new(msg.as_string()) - payload = encrypt_with_pubkeys(self.pubkeys, payload) + 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 - 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' - ) + 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') - return msg \ No newline at end of file + def _create_message(self, msg): + msg = super()._create_message(msg) + return process_message(msg, self.pubkeys, self.privkey) \ No newline at end of file diff --git a/post_office/mail.py b/post_office/mail.py index 6590fd53..190835e3 100644 --- a/post_office/mail.py +++ b/post_office/mail.py @@ -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='', pubkeys=None): + backend='', 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. @@ -61,7 +61,7 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', message_id=message_id, headers=headers, priority=priority, status=status, context=context, template=template, backend_alias=backend, - pubkeys=pubkeys + pgp_pubkeys=pubkeys, pgp_signed=pgp_signed ) else: @@ -89,7 +89,7 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', message_id=message_id, headers=headers, priority=priority, status=status, backend_alias=backend, - pubkeys=pubkeys + pgp_pubkeys=pubkeys, pgp_signed=pgp_signed ) if commit: @@ -102,7 +102,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='', pubkeys=None): + backend='', pubkeys=None, pgp_signed=False): try: recipients = parse_emails(recipients) except ValidationError as e: @@ -159,7 +159,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, pubkeys=pubkeys) + render_on_delivery, commit=commit, backend=backend, + pubkeys=pubkeys, pgp_signed=pgp_signed) if attachments: attachments = create_attachments(attachments) diff --git a/post_office/models.py b/post_office/models.py index 6391c85d..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,7 +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 EncryptedEmailMessage, EncryptedEmailMultiAlternatives +from .gpg import EncryptedOrSignedEmailMessage, EncryptedOrSignedEmailMultiAlternatives, sign_with_privkey PRIORITY = namedtuple('PRIORITY', 'low medium high now')._make(range(4)) @@ -72,7 +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) - pubkeys = JSONField(blank=True, null=True) + pgp_pubkeys = JSONField(blank=True, null=True) + pgp_signed = BooleanField(default=False) class Meta: app_label = 'post_office' @@ -128,12 +130,13 @@ def prepare_email_message(self): if html_message: if plaintext_message: - if self.pubkeys: - msg = EncryptedEmailMultiAlternatives( + 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.pubkeys) + pubkeys=self.pgp_pubkeys, + sign_with_privkey=self.pgp_signed) msg.attach_alternative(html_message, "text/html") else: msg = EmailMultiAlternatives( @@ -142,12 +145,13 @@ def prepare_email_message(self): headers=headers, connection=connection) msg.attach_alternative(html_message, "text/html") else: - if self.pubkeys: - msg = EncryptedEmailMultiAlternatives( + 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.pubkeys) + pubkeys=self.pgp_pubkeys, + sign_with_privkey=self.pgp_signed) msg.content_subtype = 'html' else: msg = EmailMultiAlternatives( @@ -159,12 +163,13 @@ def prepare_email_message(self): multipart_template.attach_related(msg) else: - if self.pubkeys: - msg = EncryptedEmailMessage( + 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.pubkeys) + pubkeys=self.pgp_pubkeys, + sign_with_privkey=self.pgp_signed) else: msg = EmailMessage( subject=subject, body=plaintext_message, from_email=self.from_email, 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) From 9d14497858b2365d587beecb199818ed63d7c138 Mon Sep 17 00:00:00 2001 From: Andrea Esposito Date: Mon, 23 Aug 2021 13:36:18 +0200 Subject: [PATCH 4/8] Fixed signature with multipart/alternative messages, minor changes to comply to RFC 3156 --- post_office/gpg.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/post_office/gpg.py b/post_office/gpg.py index 830bac8c..d2828738 100644 --- a/post_office/gpg.py +++ b/post_office/gpg.py @@ -1,10 +1,16 @@ +from email.mime.multipart import MIMEMultipart from django.core.mail import EmailMessage, EmailMultiAlternatives, SafeMIMEMultipart from email.mime.application import MIMEApplication -from email.encoders import encode_7or8bit +from email.encoders import encode_7or8bit, encode_quopri +from email import charset from .settings import get_signing_key_path, get_signing_key_passphrase +utf8_charset_qp = charset.Charset('utf-8') +utf8_charset_qp.body_encoding = charset.QP + + def find_public_keys_for_encryption(primary): """ A function that isolates a (or some) subkey(s) from a primary key @@ -105,14 +111,30 @@ def sign_with_privkey(_privkey, payload): def process_message(msg, pubkeys, privkey): """ - Apply signature and/or encryption to the given message payload + Apply signature and/or encryption to the given message payload. + This function also applies the Quoted-Printable transfer encoding to + both multipart and non-multipart messages and replaces newline characters + with the 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 to + invalidate the signature. """ try: from pgpy import PGPMessage except ImportError: raise ModuleNotFoundError('GPG encryption requires pgpy module') - payload = PGPMessage.new(msg.as_string()) + if msg.is_multipart: + for payload in msg.get_payload(): + del payload['Content-Transfer-Encoding'] + encode_quopri(payload) + else: + del msg['Content-Transfer-Encoding'] + encode_quopri(msg) + + payload = msg.as_string().replace( + '\n boundary', ' boundary' + ).replace('\n', '\r\n') if privkey: if privkey.is_unlocked: @@ -127,11 +149,13 @@ def process_message(msg, pubkeys, privkey): signature = MIMEApplication( str(signature), - _subtype='pgp-signature' + _subtype='pgp-signature', + _encoder=encode_7or8bit ) msg = SafeMIMEMultipart( _subtype='signed', _subparts=[msg, signature], + micalg='pgp-sha256', protocol='application/pgp-signature' ) From c3178c5028312d0abaa46f835965c57a771a039b Mon Sep 17 00:00:00 2001 From: Andrea Esposito Date: Mon, 23 Aug 2021 15:39:18 +0200 Subject: [PATCH 5/8] Cleaned up unused variables and imports --- post_office/gpg.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/post_office/gpg.py b/post_office/gpg.py index d2828738..06418224 100644 --- a/post_office/gpg.py +++ b/post_office/gpg.py @@ -1,16 +1,10 @@ -from email.mime.multipart import MIMEMultipart from django.core.mail import EmailMessage, EmailMultiAlternatives, SafeMIMEMultipart from email.mime.application import MIMEApplication from email.encoders import encode_7or8bit, encode_quopri -from email import charset from .settings import get_signing_key_path, get_signing_key_passphrase -utf8_charset_qp = charset.Charset('utf-8') -utf8_charset_qp.body_encoding = charset.QP - - def find_public_keys_for_encryption(primary): """ A function that isolates a (or some) subkey(s) from a primary key @@ -116,8 +110,8 @@ def process_message(msg, pubkeys, privkey): both multipart and non-multipart messages and replaces newline characters with the 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 to - invalidate the signature. + '\n ' sequence of the boundary parameter in the Content-Type header from + invalidating the signature. """ try: from pgpy import PGPMessage From e2fae2099d61dc8d92400785e04b84d74a3620c9 Mon Sep 17 00:00:00 2001 From: Andrea Esposito Date: Mon, 23 Aug 2021 16:15:47 +0200 Subject: [PATCH 6/8] Fixed signature with attachments --- post_office/gpg.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/post_office/gpg.py b/post_office/gpg.py index 06418224..f46fa269 100644 --- a/post_office/gpg.py +++ b/post_office/gpg.py @@ -1,6 +1,7 @@ from django.core.mail import EmailMessage, EmailMultiAlternatives, SafeMIMEMultipart from email.mime.application import MIMEApplication -from email.encoders import encode_7or8bit, encode_quopri +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 @@ -106,9 +107,9 @@ def sign_with_privkey(_privkey, payload): def process_message(msg, pubkeys, privkey): """ Apply signature and/or encryption to the given message payload. - This function also applies the Quoted-Printable transfer encoding to - both multipart and non-multipart messages and replaces newline characters - with the sequences, as per RFC 3156. + 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. @@ -118,14 +119,23 @@ def process_message(msg, pubkeys, privkey): except ImportError: raise ModuleNotFoundError('GPG encryption requires pgpy module') - if msg.is_multipart: - for payload in msg.get_payload(): - del payload['Content-Transfer-Encoding'] - encode_quopri(payload) - else: - del msg['Content-Transfer-Encoding'] - encode_quopri(msg) - + def safe_encode(mime): + 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 + + msg = safe_encode(msg) payload = msg.as_string().replace( '\n boundary', ' boundary' ).replace('\n', '\r\n') From 2223fb6964e396e92c779d76a593263bd6a59143 Mon Sep 17 00:00:00 2001 From: Marco Marinello Date: Mon, 23 Aug 2021 18:48:38 +0200 Subject: [PATCH 7/8] Copyright, PEP8 compliance and variables naming --- post_office/gpg.py | 80 +++++++++++++++++++++++++++------------------ post_office/mail.py | 17 +++++----- 2 files changed, 56 insertions(+), 41 deletions(-) diff --git a/post_office/gpg.py b/post_office/gpg.py index f46fa269..5594123a 100644 --- a/post_office/gpg.py +++ b/post_office/gpg.py @@ -1,3 +1,9 @@ +# 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 @@ -47,8 +53,8 @@ def find_private_key_for_signing(primary): 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): + 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 @@ -85,7 +91,7 @@ def encrypt_with_pubkeys(_pubkeys, payload): cipher = SymmetricKeyAlgorithm.AES256 skey = cipher.gen_key() - + for pubkey in pubkeys: payload = pubkey.encrypt(payload, cipher=cipher, sessionkey=skey) @@ -104,6 +110,27 @@ def sign_with_privkey(_privkey, payload): 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. @@ -119,22 +146,6 @@ def process_message(msg, pubkeys, privkey): except ImportError: raise ModuleNotFoundError('GPG encryption requires pgpy module') - def safe_encode(mime): - 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 - msg = safe_encode(msg) payload = msg.as_string().replace( '\n boundary', ' boundary' @@ -169,12 +180,12 @@ def safe_encode(mime): ) control = MIMEApplication( - "Version: 1", - _subtype='pgp-encrypted', + "Version: 1", + _subtype='pgp-encrypted', _encoder=encode_7or8bit ) data = MIMEApplication( - str(payload), + str(payload), _encoder=encode_7or8bit ) msg = SafeMIMEMultipart( @@ -191,6 +202,7 @@ 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) @@ -200,11 +212,11 @@ def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): raise ModuleNotFoundError('GPG encryption requires pgpy module') if pubkeys: - self.pubkeys = [PGPKey.from_blob(pubkey)[0] \ - for pubkey in 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: @@ -216,9 +228,11 @@ def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): 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') + 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): + def _create_message(self, msg): msg = super()._create_message(msg) return process_message(msg, self.pubkeys, self.privkey) @@ -229,6 +243,7 @@ class EncryptedOrSignedEmailMultiAlternatives(EmailMultiAlternatives): 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) @@ -238,11 +253,10 @@ def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): raise ModuleNotFoundError('GPG encryption requires pgpy module') if pubkeys: - self.pubkeys = [PGPKey.from_blob(pubkey)[0] \ - for pubkey in 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: @@ -254,8 +268,10 @@ def __init__(self, pubkeys=None, sign_with_privkey=False, **kwargs): 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') + 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) \ No newline at end of file + return process_message(msg, self.pubkeys, self.privkey) diff --git a/post_office/mail.py b/post_office/mail.py index 190835e3..1e9bc07f 100644 --- a/post_office/mail.py +++ b/post_office/mail.py @@ -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='', pubkeys=None, pgp_signed=False): + 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. @@ -47,7 +47,6 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', context = '' message_id = make_msgid(domain=get_message_id_fqdn()) if get_message_id_enabled() else None - # If email is to be rendered during delivery, save all necessary # information if render_on_delivery: @@ -61,7 +60,7 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', message_id=message_id, headers=headers, priority=priority, status=status, context=context, template=template, backend_alias=backend, - pgp_pubkeys=pubkeys, pgp_signed=pgp_signed + pgp_pubkeys=recipients_pubkeys, pgp_signed=pgp_signed ) else: @@ -89,7 +88,7 @@ def create(sender, recipients=None, cc=None, bcc=None, subject='', message='', message_id=message_id, headers=headers, priority=priority, status=status, backend_alias=backend, - pgp_pubkeys=pubkeys, pgp_signed=pgp_signed + pgp_pubkeys=recipients_pubkeys, pgp_signed=pgp_signed ) if commit: @@ -102,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='', pubkeys=None, pgp_signed=False): + backend='', recipients_pubkeys=None, pgp_signed=False): try: recipients = parse_emails(recipients) except ValidationError as e: @@ -119,7 +118,7 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', raise ValidationError('bcc: %s' % e.message) try: - pubkeys = validate_public_keys(pubkeys) + recipients_pubkeys = validate_public_keys(recipients_pubkeys) except ValidationError as e: raise ValidationError('pubkeys: %s' % e.message) @@ -158,9 +157,9 @@ def send(recipients=None, sender=None, template=None, context=None, subject='', raise ValueError('%s is not a valid backend alias' % backend) 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, - pubkeys=pubkeys, pgp_signed=pgp_signed) + context, scheduled_time, expires_at, headers, template, priority, + render_on_delivery, commit=commit, backend=backend, + recipients_pubkeys=recipients_pubkeys, pgp_signed=pgp_signed) if attachments: attachments = create_attachments(attachments) From 9de44ec91ae7e2c440df55bf65082b80153c4f24 Mon Sep 17 00:00:00 2001 From: Marco Marinello Date: Mon, 23 Aug 2021 19:25:03 +0200 Subject: [PATCH 8/8] Updated README.md --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) 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 -----------