@@ -73,7 +73,7 @@ class node. Any parts of other types are each stored in separate files
7373an exception, the original message is bounced back to the sender with the
7474explanatory message given in the exception.
7575
76- $Id: mailgw.py,v 1.189 2007-09-01 16:14:21 forsberg Exp $
76+ $Id: mailgw.py,v 1.190 2007-09-22 07:25:34 jpend Exp $
7777"""
7878__docformat__ = 'restructuredtext'
7979
@@ -85,6 +85,11 @@ class node. Any parts of other types are each stored in separate files
8585from roundup .mailer import Mailer , MessageSendError
8686from roundup .i18n import _
8787
88+ try :
89+ import pyme , pyme .core , pyme .gpgme
90+ except ImportError :
91+ pyme = None
92+
8893SENDMAILDEBUG = os .environ .get ('SENDMAILDEBUG' , '' )
8994
9095class MailGWError (ValueError ):
@@ -141,6 +146,29 @@ def getparam(str, param):
141146 return rfc822 .unquote (f [i + 1 :].strip ())
142147 return None
143148
149+ def check_pgp_sigs (sig ):
150+ ''' Theoretically a PGP message can have several signatures. GPGME returns
151+ status on all signatures in a linked list. Walk that linked list making
152+ sure all signatures are valid.
153+ '''
154+ while sig != None :
155+ if not sig .summary & pyme .gpgme .GPGME_SIGSUM_VALID :
156+ # try to narrow down the actual problem to give a more useful
157+ # message in our bounce
158+ if sig .summary & pyme .gpgme .GPGME_SIGSUM_KEY_MISSING :
159+ raise MailUsageError , \
160+ _ ('' "Message signed with unknown key: " + sig .fpr )
161+ elif sig .summary & pyme .gpgme .GPGME_SIGSUM_KEY_EXPIRED :
162+ raise MailUsageError , \
163+ _ ('' "Message signed with an expired key: " + sig .fpr )
164+ elif sig .summary & pyme .gpgme .GPGME_SIGSUM_KEY_REVOKED :
165+ raise MailUsageError , \
166+ _ ('' "Message signed with a revoked key: " + sig .fpr )
167+ else :
168+ raise MailUsageError , \
169+ _ ('' "Invalid PGP signature detected." )
170+ sig = sig .next
171+
144172class Message (mimetools .Message ):
145173 ''' subclass mimetools.Message so we can retrieve the parts of the
146174 message...
@@ -157,6 +185,17 @@ def getpart(self):
157185 if not line :
158186 break
159187 if line .strip () in (mid , end ):
188+ # according to rfc 1431 the preceding line ending is part of
189+ # the boundary so we need to strip that
190+ length = s .tell ()
191+ s .seek (- 2 , 1 )
192+ lineending = s .read (2 )
193+ if lineending == '\r \n ' :
194+ s .truncate (length - 2 )
195+ elif lineending [1 ] in ('\r ' , '\n ' ):
196+ s .truncate (length - 1 )
197+ else :
198+ raise ValueError ('Unknown line ending in message.' )
160199 break
161200 s .write (line )
162201 if not s .getvalue ().strip ():
@@ -167,6 +206,7 @@ def getpart(self):
167206 def getparts (self ):
168207 """Get all parts of this multipart message."""
169208 # skip over the intro to the first boundary
209+ self .fp .seek (0 )
170210 self .getpart ()
171211
172212 # accumulate the other parts
@@ -297,6 +337,90 @@ def as_attachment(self):
297337 """Return this message as an attachment."""
298338 return (self .getname (), self .gettype (), self .getbody ())
299339
340+ def pgp_signed (self ):
341+ ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
342+ '''
343+ return self .gettype () == 'multipart/signed' \
344+ and self .typeheader .find ('protocol="application/pgp-signature"' ) != - 1
345+
346+ def pgp_encrypted (self ):
347+ ''' RFC 3156 requires OpenPGP MIME mail to have the protocol parameter
348+ '''
349+ return self .gettype () == 'multipart/encrypted' \
350+ and self .typeheader .find ('protocol="application/pgp-encrypted"' ) != - 1
351+
352+ def decrypt (self ):
353+ ''' decrypt an OpenPGP MIME message
354+ This message must be signed as well as encrypted using the "combined"
355+ method. The decrypted contents are returned as a new message.
356+ '''
357+ (hdr , msg ) = self .getparts ()
358+ # According to the RFC 3156 encrypted mail must have exactly two parts.
359+ # The first part contains the control information. Let's verify that
360+ # the message meets the RFC before we try to decrypt it.
361+ if hdr .getbody () != 'Version: 1' or hdr .gettype () != 'application/pgp-encrypted' :
362+ raise MailUsageError , \
363+ _ ('' "Unknown multipart/encrypted version." )
364+
365+ context = pyme .core .Context ()
366+ ciphertext = pyme .core .Data (msg .getbody ())
367+ plaintext = pyme .core .Data ()
368+
369+ result = context .op_decrypt_verify (ciphertext , plaintext )
370+
371+ if result :
372+ raise MailUsageError , _ ('' "Unable to decrypt your message." )
373+
374+ # we've decrypted it but that just means they used our public
375+ # key to send it to us. now check the signatures to see if it
376+ # was signed by someone we trust
377+ result = context .op_verify_result ()
378+ check_pgp_sigs (result .signatures )
379+
380+ plaintext .seek (0 ,0 )
381+ # pyme.core.Data implements a seek method with a different signature
382+ # than roundup can handle. So we'll put the data in a container that
383+ # the Message class can work with.
384+ c = cStringIO .StringIO ()
385+ c .write (plaintext .read ())
386+ c .seek (0 )
387+ return Message (c )
388+
389+ def verify_signature (self ):
390+ ''' verify the signature of an OpenPGP MIME message
391+ This only handles detached signatures. Old style
392+ PGP mail (i.e. '-----BEGIN PGP SIGNED MESSAGE----')
393+ is archaic and not supported :)
394+ '''
395+ # we don't check the micalg parameter...gpgme seems to
396+ # figure things out on its own
397+ (msg , sig ) = self .getparts ()
398+
399+ if sig .gettype () != 'application/pgp-signature' :
400+ raise MailUsageError , \
401+ _ ('' "No PGP signature found in message." )
402+
403+ context = pyme .core .Context ()
404+ # msg.getbody() is skipping over some headers that are
405+ # required to be present for verification to succeed so
406+ # we'll do this by hand
407+ msg .fp .seek (0 )
408+ # according to rfc 3156 the data "MUST first be converted
409+ # to its content-type specific canonical form. For
410+ # text/plain this means conversion to an appropriate
411+ # character set and conversion of line endings to the
412+ # canonical <CR><LF> sequence."
413+ # TODO: what about character set conversion?
414+ canonical_msg = re .sub ('(?<!\r )\n ' , '\r \n ' , msg .fp .read ())
415+ msg_data = pyme .core .Data (canonical_msg )
416+ sig_data = pyme .core .Data (sig .getbody ())
417+
418+ context .op_verify (sig_data , msg_data , None )
419+
420+ # check all signatures for validity
421+ result = context .op_verify_result ()
422+ check_pgp_sigs (result .signatures )
423+
300424class MailGW :
301425
302426 def __init__ (self , instance , db , arguments = ()):
@@ -915,7 +1039,7 @@ def handle_message(self, message):
9151039%(tracker_web)suser?template=register
9161040
9171041...before sending mail to the tracker.""" % locals ()
918-
1042+
9191043 raise Unauthorized , _ ("""
9201044You are not a registered user.%(registration_info)s
9211045
@@ -1007,6 +1131,25 @@ def handle_message(self, message):
10071131 messageid = "<%s.%s.%s%s@%s>" % (time .time (), random .random (),
10081132 classname , nodeid , config ['MAIL_DOMAIN' ])
10091133
1134+ # if they've enabled PGP processing then verify the signature
1135+ # or decrypt the message
1136+ if self .instance .config .PGP_ENABLE :
1137+ assert pyme , 'pyme is not installed'
1138+ if self .instance .config .PGP_HOMEDIR :
1139+ os .environ ['GNUPGHOME' ] = self .instance .config .PGP_HOMEDIR
1140+ if message .pgp_signed ():
1141+ message .verify_signature ()
1142+ elif message .pgp_encrypted ():
1143+ # replace message with the contents of the decrypted
1144+ # message for content extraction
1145+ # TODO: encrypted message handling is far from perfect
1146+ # bounces probably include the decrypted message, for
1147+ # instance :(
1148+ message = message .decrypt ()
1149+ else :
1150+ raise MailUsageError , _ ("""
1151+ This tracker has been configured to require all email be PGP signed or
1152+ encrypted.""" )
10101153 # now handle the body - find the message
10111154 content , attachments = message .extract_content ()
10121155 if content is None :
0 commit comments