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
Prev Previous commit
Next Next commit
Implemented PGP signing, to be further tested
  • Loading branch information
dododevs committed Aug 22, 2021
commit 8598ca7c9d8389aa6aed09da0df2d06d75de8800
175 changes: 122 additions & 53 deletions post_office/gpg.py
Original file line number Diff line number Diff line change
@@ -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
def _create_message(self, msg):
msg = super()._create_message(msg)
return process_message(msg, self.pubkeys, self.privkey)
11 changes: 6 additions & 5 deletions post_office/mail.py
Original file line number Diff line number Diff line change
@@ -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)
27 changes: 16 additions & 11 deletions post_office/models.py
Original file line number Diff line number Diff line change
@@ -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,
8 changes: 8 additions & 0 deletions post_office/settings.py
Original file line number Diff line number Diff line change
@@ -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)