Mercurial > p > roundup > code
diff roundup/mailgw.py @ 3915:6b3919328381
support for receiving OpenPGP MIME messages (signed or encrypted)
This introduces some new config options that still need to be documented.
This required a small fix for roundup's handling of MIME boundaries. The
multipart tests were changed to have boundaries that match this new handling.
| author | Justus Pendleton <jpend@users.sourceforge.net> |
|---|---|
| date | Sat, 22 Sep 2007 07:25:35 +0000 |
| parents | 29759dfcfbcf |
| children | 1f3310c0a100 |
line wrap: on
line diff
--- a/roundup/mailgw.py Fri Sep 21 18:05:50 2007 +0000 +++ b/roundup/mailgw.py Sat Sep 22 07:25:35 2007 +0000 @@ -73,7 +73,7 @@ an exception, the original message is bounced back to the sender with the explanatory message given in the exception. -$Id: mailgw.py,v 1.189 2007-09-01 16:14:21 forsberg Exp $ +$Id: mailgw.py,v 1.190 2007-09-22 07:25:34 jpend Exp $ """ __docformat__ = 'restructuredtext' @@ -85,6 +85,11 @@ from roundup.mailer import Mailer, MessageSendError from roundup.i18n import _ +try: + import pyme, pyme.core, pyme.gpgme +except ImportError: + pyme = None + SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '') class MailGWError(ValueError): @@ -141,6 +146,29 @@ return rfc822.unquote(f[i+1:].strip()) return None +def check_pgp_sigs(sig): + ''' Theoretically a PGP message can have several signatures. GPGME returns + status on all signatures in a linked list. Walk that linked list making + sure all signatures are valid. + ''' + while sig != None: + if not sig.summary & pyme.gpgme.GPGME_SIGSUM_VALID: + # try to narrow down the actual problem to give a more useful + # message in our bounce + if sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_MISSING: + raise MailUsageError, \ + _(''"Message signed with unknown key: " + sig.fpr) + elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_EXPIRED: + raise MailUsageError, \ + _(''"Message signed with an expired key: " + sig.fpr) + elif sig.summary & pyme.gpgme.GPGME_SIGSUM_KEY_REVOKED: + raise MailUsageError, \ + _(''"Message signed with a revoked key: " + sig.fpr) + else: + raise MailUsageError, \ + _(''"Invalid PGP signature detected.") + sig = sig.next + class Message(mimetools.Message): ''' subclass mimetools.Message so we can retrieve the parts of the message... @@ -157,6 +185,17 @@ if not line: break if line.strip() in (mid, end): + # according to rfc 1431 the preceding line ending is part of + # the boundary so we need to strip that + length = s.tell() + s.seek(-2, 1) + lineending = s.read(2) + if lineending == '\r\n': + s.truncate(length - 2) + elif lineending[1] in ('\r', '\n'): + s.truncate(length - 1) + else: + raise ValueError('Unknown line ending in message.') break s.write(line) if not s.getvalue().strip(): @@ -167,6 +206,7 @@ def getparts(self): """Get all parts of this multipart message.""" # skip over the intro to the first boundary + self.fp.seek(0) self.getpart() # accumulate the other parts @@ -297,6 +337,90 @@ """Return this message as an attachment.""" return (self.getname(), self.gettype(), self.getbody()) + def pgp_signed(self): + ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter + ''' + return self.gettype() == 'multipart/signed' \ + and self.typeheader.find('protocol="application/pgp-signature"') != -1 + + def pgp_encrypted(self): + ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter + ''' + return self.gettype() == 'multipart/encrypted' \ + and self.typeheader.find('protocol="application/pgp-encrypted"') != -1 + + def decrypt(self): + ''' decrypt an OpenPGP MIME message + This message must be signed as well as encrypted using the "combined" + method. The decrypted contents are returned as a new message. + ''' + (hdr, msg) = self.getparts() + # According to the RFC 3156 encrypted mail must have exactly two parts. + # The first part contains the control information. Let's verify that + # the message meets the RFC before we try to decrypt it. + if hdr.getbody() != 'Version: 1' or hdr.gettype() != 'application/pgp-encrypted': + raise MailUsageError, \ + _(''"Unknown multipart/encrypted version.") + + context = pyme.core.Context() + ciphertext = pyme.core.Data(msg.getbody()) + plaintext = pyme.core.Data() + + result = context.op_decrypt_verify(ciphertext, plaintext) + + if result: + raise MailUsageError, _(''"Unable to decrypt your message.") + + # we've decrypted it but that just means they used our public + # key to send it to us. now check the signatures to see if it + # was signed by someone we trust + result = context.op_verify_result() + check_pgp_sigs(result.signatures) + + plaintext.seek(0,0) + # pyme.core.Data implements a seek method with a different signature + # than roundup can handle. So we'll put the data in a container that + # the Message class can work with. + c = cStringIO.StringIO() + c.write(plaintext.read()) + c.seek(0) + return Message(c) + + def verify_signature(self): + ''' verify the signature of an OpenPGP MIME message + This only handles detached signatures. Old style + PGP mail (i.e. '-----BEGIN PGP SIGNED MESSAGE----') + is archaic and not supported :) + ''' + # we don't check the micalg parameter...gpgme seems to + # figure things out on its own + (msg, sig) = self.getparts() + + if sig.gettype() != 'application/pgp-signature': + raise MailUsageError, \ + _(''"No PGP signature found in message.") + + context = pyme.core.Context() + # msg.getbody() is skipping over some headers that are + # required to be present for verification to succeed so + # we'll do this by hand + msg.fp.seek(0) + # according to rfc 3156 the data "MUST first be converted + # to its content-type specific canonical form. For + # text/plain this means conversion to an appropriate + # character set and conversion of line endings to the + # canonical <CR><LF> sequence." + # TODO: what about character set conversion? + canonical_msg = re.sub('(?<!\r)\n', '\r\n', msg.fp.read()) + msg_data = pyme.core.Data(canonical_msg) + sig_data = pyme.core.Data(sig.getbody()) + + context.op_verify(sig_data, msg_data, None) + + # check all signatures for validity + result = context.op_verify_result() + check_pgp_sigs(result.signatures) + class MailGW: def __init__(self, instance, db, arguments=()): @@ -915,7 +1039,7 @@ %(tracker_web)suser?template=register ...before sending mail to the tracker.""" % locals() - + raise Unauthorized, _(""" You are not a registered user.%(registration_info)s @@ -1007,6 +1131,25 @@ messageid = "<%s.%s.%s%s@%s>"%(time.time(), random.random(), classname, nodeid, config['MAIL_DOMAIN']) + # if they've enabled PGP processing then verify the signature + # or decrypt the message + if self.instance.config.PGP_ENABLE: + assert pyme, 'pyme is not installed' + if self.instance.config.PGP_HOMEDIR: + os.environ['GNUPGHOME'] = self.instance.config.PGP_HOMEDIR + if message.pgp_signed(): + message.verify_signature() + elif message.pgp_encrypted(): + # replace message with the contents of the decrypted + # message for content extraction + # TODO: encrypted message handling is far from perfect + # bounces probably include the decrypted message, for + # instance :( + message = message.decrypt() + else: + raise MailUsageError, _(""" +This tracker has been configured to require all email be PGP signed or +encrypted.""") # now handle the body - find the message content, attachments = message.extract_content() if content is None:
