Skip to content

Commit 4dc8d72

Browse files
author
Justus Pendleton
committed
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.
1 parent fa5c53c commit 4dc8d72

File tree

3 files changed

+165
-15
lines changed

3 files changed

+165
-15
lines changed

roundup/configuration.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Roundup Issue Tracker configuration support
22
#
3-
# $Id: configuration.py,v 1.47 2007-09-03 17:14:08 jpend Exp $
3+
# $Id: configuration.py,v 1.48 2007-09-22 07:25:34 jpend Exp $
44
#
55
__docformat__ = "restructuredtext"
66

@@ -717,6 +717,13 @@ def str2value(self, value):
717717
(RegExpOption, "blankline_re", r"[\r\n]+\s*[\r\n]+",
718718
"Regular expression matching a blank line."),
719719
), "Roundup Mail Gateway options"),
720+
("pgp", (
721+
(BooleanOption, "enable", "no",
722+
"Enable PGP processing. Requires pyme."),
723+
(NullableOption, "homedir", "",
724+
"Location of PGP directory. Defaults to $HOME/.gnupg if\n"
725+
"not specified."),
726+
), "OpenPGP mail processing options"),
720727
("nosy", (
721728
(RunDetectorOption, "messages_to_author", "no",
722729
"Send nosy messages to the author of the message.",

roundup/mailgw.py

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ class node. Any parts of other types are each stored in separate files
7373
an exception, the original message is bounced back to the sender with the
7474
explanatory 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
8585
from roundup.mailer import Mailer, MessageSendError
8686
from roundup.i18n import _
8787

88+
try:
89+
import pyme, pyme.core, pyme.gpgme
90+
except ImportError:
91+
pyme = None
92+
8893
SENDMAILDEBUG = os.environ.get('SENDMAILDEBUG', '')
8994

9095
class 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+
144172
class 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+
300424
class 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, _("""
9201044
You 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:

test/test_multipart.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
1616
# SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
1717
#
18-
# $Id: test_multipart.py,v 1.7 2004-01-17 13:49:06 jlgijsbers Exp $
18+
# $Id: test_multipart.py,v 1.8 2007-09-22 07:25:35 jpend Exp $
1919

2020
import unittest
2121
from cStringIO import StringIO
@@ -30,7 +30,7 @@ class TestMessage(Message):
3030
'application/pgp-signature': ' name="foo.gpg"\nfoo\n',
3131
'application/pdf': ' name="foo.pdf"\nfoo\n',
3232
'message/rfc822': 'Subject: foo\n\nfoo\n'}
33-
33+
3434
def __init__(self, spec):
3535
"""Create a basic MIME message according to 'spec'.
3636
@@ -44,10 +44,10 @@ def __init__(self, spec):
4444
content_type = line.strip()
4545
if not content_type:
4646
continue
47-
47+
4848
indent = self.getIndent(line)
4949
if indent:
50-
parts.append('--boundary-%s\n' % indent)
50+
parts.append('\n--boundary-%s\n' % indent)
5151
parts.append('Content-type: %s;\n' % content_type)
5252
parts.append(self.table[content_type] % {'indent': indent + 1})
5353

@@ -68,34 +68,34 @@ def setUp(self):
6868
w = self.fp.write
6969
w('Content-Type: multipart/mixed; boundary="foo"\r\n\r\n')
7070
w('This is a multipart message. Ignore this bit.\r\n')
71-
w('--foo\r\n')
71+
w('\r\n--foo\r\n')
7272

7373
w('Content-Type: text/plain\r\n\r\n')
7474
w('Hello, world!\r\n')
7575
w('\r\n')
7676
w('Blah blah\r\n')
7777
w('foo\r\n')
7878
w('-foo\r\n')
79-
w('--foo\r\n')
79+
w('\r\n--foo\r\n')
8080

8181
w('Content-Type: multipart/alternative; boundary="bar"\r\n\r\n')
8282
w('This is a multipart message. Ignore this bit.\r\n')
83-
w('--bar\r\n')
83+
w('\r\n--bar\r\n')
8484

8585
w('Content-Type: text/plain\r\n\r\n')
8686
w('Hello, world!\r\n')
8787
w('\r\n')
8888
w('Blah blah\r\n')
89-
w('--bar\r\n')
89+
w('\r\n--bar\r\n')
9090

9191
w('Content-Type: text/html\r\n\r\n')
9292
w('<b>Hello, world!</b>\r\n')
93-
w('--bar--\r\n')
94-
w('--foo\r\n')
93+
w('\r\n--bar--\r\n')
94+
w('\r\n--foo\r\n')
9595

9696
w('Content-Type: text/plain\r\n\r\n')
9797
w('Last bit\n')
98-
w('--foo--\r\n')
98+
w('\r\n--foo--\r\n')
9999
self.fp.seek(0)
100100

101101
def testMultipart(self):
@@ -185,7 +185,7 @@ def testDeepMultipartAlternative(self):
185185
text/plain
186186
application/pdf
187187
""", ('foo\n', [('foo.pdf', 'application/pdf', 'foo\n')]))
188-
188+
189189
def testSignedText(self):
190190
self.TestExtraction("""
191191
multipart/signed

0 commit comments

Comments
 (0)