Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for PGP signed and encrypted mails #376

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
-----------

Expand Down
277 changes: 277 additions & 0 deletions post_office/gpg.py
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>
# Marco Marinello <[email protected]>

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 <CR><LF> 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)
20 changes: 14 additions & 6 deletions post_office/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
Loading