Skip to content

Commit 650667c

Browse files
author
Ralf Schlatterbeck
committed
PGP support is again working (pyme API has changed significantly)...
...and we now have a regression test. We now take care that bounce-messages for incoming encrypted mails or mails where the policy dictates that outgoing traffic should be encrypted is actually pgp-encrypted. Note that the new pgp encrypt option for outgoing mails works only for bounces for now.
1 parent cd4d625 commit 650667c

File tree

6 files changed

+329
-59
lines changed

6 files changed

+329
-59
lines changed

CHANGES.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ Fixed:
2929
is addressed to [email protected] this would (wrongly) match. (Ralf)
3030
- issue2550729: Fix password history display for anydbm backend, thanks
3131
to Ralf Hemmecke for reporting. (Ralf)
32+
- PGP support is again working (pyme API has changed significantly) and
33+
we now have a regression test. We now take care that bounce-messages
34+
for incoming encrypted mails or mails where the policy dictates that
35+
outgoing traffic should be encrypted is actually pgp-encrypted. Note
36+
that the new pgp encrypt option for outgoing mails works only for
37+
bounces for now. (Ralf)
3238

3339
2011-07-15 1.4.19 (r4638)
3440

roundup/configuration.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -799,14 +799,36 @@ def str2value(self, value):
799799
), "Roundup Mail Gateway options"),
800800
("pgp", (
801801
(BooleanOption, "enable", "no",
802-
"Enable PGP processing. Requires pyme."),
802+
"Enable PGP processing. Requires pyme. If you're planning\n"
803+
"to send encrypted PGP mail to the tracker, you should also\n"
804+
"enable the encrypt-option below, otherwise mail received\n"
805+
"encrypted might be sent unencrypted to another user."),
803806
(NullableOption, "roles", "",
804807
"If specified, a comma-separated list of roles to perform\n"
805808
"PGP processing on. If not specified, it happens for all\n"
806-
"users."),
809+
"users. Note that received PGP messages (signed and/or\n"
810+
"encrypted) will be processed with PGP even if the user\n"
811+
"doesn't have one of the PGP roles, you can use this to make\n"
812+
"PGP processing completely optional by defining a role here\n"
813+
"and not assigning any users to that role."),
807814
(NullableOption, "homedir", "",
808815
"Location of PGP directory. Defaults to $HOME/.gnupg if\n"
809816
"not specified."),
817+
(BooleanOption, "encrypt", "no",
818+
"Enable PGP encryption. All outgoing mails are encrypted.\n"
819+
"This requires that keys for all users (with one of the gpg\n"
820+
"roles above or all users if empty) are available. Note that\n"
821+
"it makes sense to educate users to also send mails encrypted\n"
822+
"to the tracker, to enforce this, set 'require_incoming'\n"
823+
"option below (but see the note)."),
824+
(Option, "require_incoming", "signed",
825+
"Require that pgp messages received by roundup are either\n"
826+
"'signed', 'encrypted' or 'both'. If encryption is required\n"
827+
"we do not return the message (in clear) to the user but just\n"
828+
"send an informational message that the message was rejected.\n"
829+
"Note that this still presents known-plaintext to an attacker\n"
830+
"when the users sends the mail a second time with encryption\n"
831+
"turned on."),
810832
), "OpenPGP mail processing options"),
811833
("nosy", (
812834
(RunDetectorOption, "messages_to_author", "no",

roundup/mailer.py

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@
1212
from email.Utils import formatdate, formataddr, specialsre, escapesre
1313
from email.Message import Message
1414
from email.Header import Header
15+
from email.MIMEBase import MIMEBase
1516
from email.MIMEText import MIMEText
1617
from email.MIMEMultipart import MIMEMultipart
1718

19+
try:
20+
import pyme, pyme.core
21+
except ImportError:
22+
pyme = None
23+
24+
1825
class MessageSendError(RuntimeError):
1926
pass
2027

@@ -64,17 +71,14 @@ def __init__(self, config):
6471
os.environ['TZ'] = get_timezone(self.config.TIMEZONE).tzname(None)
6572
time.tzset()
6673

67-
def get_standard_message(self, to, subject, author=None, multipart=False):
68-
'''Form a standard email message from Roundup.
69-
74+
def set_message_attributes(self, message, to, subject, author=None):
75+
''' Add attributes to a standard output message
7076
"to" - recipients list
7177
"subject" - Subject
7278
"author" - (name, address) tuple or None for admin email
7379
7480
Subject and author are encoded using the EMAIL_CHARSET from the
7581
config (default UTF-8).
76-
77-
Returns a Message object.
7882
'''
7983
# encode header values if they need to be
8084
charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
@@ -85,13 +89,6 @@ def get_standard_message(self, to, subject, author=None, multipart=False):
8589
else:
8690
name = unicode(author[0], 'utf-8')
8791
author = nice_sender_header(name, author[1], charset)
88-
89-
if multipart:
90-
message = MIMEMultipart()
91-
else:
92-
message = MIMEText("")
93-
message.set_charset(charset)
94-
9592
try:
9693
message['Subject'] = subject.encode('ascii')
9794
except UnicodeError:
@@ -114,6 +111,17 @@ def get_standard_message(self, to, subject, author=None, multipart=False):
114111
# finally, an aid to debugging problems
115112
message['X-Roundup-Version'] = __version__
116113

114+
def get_standard_message(self, multipart=False):
115+
'''Form a standard email message from Roundup.
116+
Returns a Message object.
117+
'''
118+
charset = getattr(self.config, 'EMAIL_CHARSET', 'utf-8')
119+
if multipart:
120+
message = MIMEMultipart()
121+
else:
122+
message = MIMEText("")
123+
message.set_charset(charset)
124+
117125
return message
118126

119127
def standard_message(self, to, subject, content, author=None):
@@ -127,13 +135,14 @@ def standard_message(self, to, subject, content, author=None):
127135
128136
All strings are assumed to be UTF-8 encoded.
129137
"""
130-
message = self.get_standard_message(to, subject, author)
138+
message = self.get_standard_message()
139+
self.set_message_attributes(message, to, subject, author)
131140
message.set_payload(content)
132141
encode_quopri(message)
133142
self.smtp_send(to, message.as_string())
134143

135144
def bounce_message(self, bounced_message, to, error,
136-
subject='Failed issue tracker submission'):
145+
subject='Failed issue tracker submission', crypt=False):
137146
"""Bounce a message, attaching the failed submission.
138147
139148
Arguments:
@@ -143,18 +152,29 @@ def bounce_message(self, bounced_message, to, error,
143152
ERROR_MESSAGES_TO setting.
144153
- error: the reason of failure as a string.
145154
- subject: the subject as a string.
155+
- crypt: require encryption with pgp for user -- applies only to
156+
mail sent back to the user, not the dispatcher oder admin.
146157
147158
"""
159+
crypt_to = None
160+
if crypt:
161+
crypt_to = to
162+
to = None
148163
# see whether we should send to the dispatcher or not
149164
dispatcher_email = getattr(self.config, "DISPATCHER_EMAIL",
150165
getattr(self.config, "ADMIN_EMAIL"))
151166
error_messages_to = getattr(self.config, "ERROR_MESSAGES_TO", "user")
152167
if error_messages_to == "dispatcher":
153168
to = [dispatcher_email]
169+
crypt = False
170+
crypt_to = None
154171
elif error_messages_to == "both":
155-
to.append(dispatcher_email)
172+
if crypt:
173+
to = [dispatcher_email]
174+
else:
175+
to.append(dispatcher_email)
156176

157-
message = self.get_standard_message(to, subject, multipart=True)
177+
message = self.get_standard_message(multipart=True)
158178

159179
# add the error text
160180
part = MIMEText('\n'.join(error))
@@ -175,15 +195,54 @@ def bounce_message(self, bounced_message, to, error,
175195
part = MIMEText(''.join(body))
176196
message.attach(part)
177197

178-
# send
179-
try:
180-
self.smtp_send(to, message.as_string())
181-
except MessageSendError:
182-
# squash mail sending errors when bouncing mail
183-
# TODO this *could* be better, as we could notify admin of the
184-
# problem (even though the vast majority of bounce errors are
185-
# because of spam)
186-
pass
198+
if to:
199+
# send
200+
self.set_message_attributes(message, to, subject)
201+
try:
202+
self.smtp_send(to, message.as_string())
203+
except MessageSendError:
204+
# squash mail sending errors when bouncing mail
205+
# TODO this *could* be better, as we could notify admin of the
206+
# problem (even though the vast majority of bounce errors are
207+
# because of spam)
208+
pass
209+
if crypt_to:
210+
plain = pyme.core.Data(message.as_string())
211+
cipher = pyme.core.Data()
212+
ctx = pyme.core.Context()
213+
ctx.set_armor(1)
214+
keys = []
215+
adrs = []
216+
for adr in crypt_to:
217+
ctx.op_keylist_start(adr, 0)
218+
# only first key per email
219+
k = ctx.op_keylist_next()
220+
if k is not None:
221+
adrs.append(adr)
222+
keys.append(k)
223+
ctx.op_keylist_end()
224+
crypt_to = adrs
225+
if crypt_to:
226+
try:
227+
ctx.op_encrypt(keys, 1, plain, cipher)
228+
cipher.seek(0,0)
229+
message=MIMEMultipart('encrypted', boundary=None,
230+
_subparts=None, protocol="application/pgp-encrypted")
231+
part=MIMEBase('application', 'pgp-encrypted')
232+
part.set_payload("Version: 1\r\n")
233+
message.attach(part)
234+
part=MIMEBase('application', 'octet-stream')
235+
part.set_payload(cipher.read())
236+
message.attach(part)
237+
except pyme.GPGMEError:
238+
crypt_to = None
239+
if crypt_to:
240+
self.set_message_attributes(message, crypt_to, subject)
241+
try:
242+
self.smtp_send(crypt_to, message.as_string())
243+
except MessageSendError:
244+
# ignore on error, see above.
245+
pass
187246

188247
def exception_message(self):
189248
'''Send a message to the admins with information about the latest

roundup/mailgw.py

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,12 @@ def gpgh_key_getall(key, attr):
159159
for u in key.uids:
160160
yield getattr(u, attr)
161161

162-
def check_pgp_sigs(sigs, gpgctx, author):
162+
def check_pgp_sigs(sigs, gpgctx, author, may_be_unsigned=False):
163163
''' Theoretically a PGP message can have several signatures. GPGME
164164
returns status on all signatures in a list. Walk that list
165-
looking for the author's signature
165+
looking for the author's signature. Note that even if incoming
166+
signatures are not required, the processing fails if there is an
167+
invalid signature.
166168
'''
167169
for sig in sigs:
168170
key = gpgctx.get_key(sig.fpr, False)
@@ -188,7 +190,10 @@ def check_pgp_sigs(sigs, gpgctx, author):
188190
_("Invalid PGP signature detected.")
189191

190192
# we couldn't find a key belonging to the author of the email
191-
raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr
193+
if sigs:
194+
raise MailUsageError, _("Message signed with unknown key: %s") % sig.fpr
195+
elif not may_be_unsigned:
196+
raise MailUsageError, _("Unsigned Message")
192197

193198
class Message(mimetools.Message):
194199
''' subclass mimetools.Message so we can retrieve the parts of the
@@ -452,16 +457,18 @@ def pgp_encrypted(self):
452457
return self.gettype() == 'multipart/encrypted' \
453458
and self.typeheader.find('protocol="application/pgp-encrypted"') != -1
454459

455-
def decrypt(self, author):
460+
def decrypt(self, author, may_be_unsigned=False):
456461
''' decrypt an OpenPGP MIME message
457-
This message must be signed as well as encrypted using the "combined"
458-
method. The decrypted contents are returned as a new message.
462+
This message must be signed as well as encrypted using the
463+
"combined" method if incoming signatures are configured.
464+
The decrypted contents are returned as a new message.
459465
'''
460466
(hdr, msg) = self.getparts()
461467
# According to the RFC 3156 encrypted mail must have exactly two parts.
462468
# The first part contains the control information. Let's verify that
463469
# the message meets the RFC before we try to decrypt it.
464-
if hdr.getbody() != 'Version: 1' or hdr.gettype() != 'application/pgp-encrypted':
470+
if hdr.getbody().strip() != 'Version: 1' \
471+
or hdr.gettype() != 'application/pgp-encrypted':
465472
raise MailUsageError, \
466473
_("Unknown multipart/encrypted version.")
467474

@@ -478,7 +485,8 @@ def decrypt(self, author):
478485
# key to send it to us. now check the signatures to see if it
479486
# was signed by someone we trust
480487
result = context.op_verify_result()
481-
check_pgp_sigs(result.signatures, context, author)
488+
check_pgp_sigs(result.signatures, context, author,
489+
may_be_unsigned = may_be_unsigned)
482490

483491
plaintext.seek(0,0)
484492
# pyme.core.Data implements a seek method with a different signature
@@ -550,6 +558,7 @@ def __init__(self, mailgw, message):
550558
self.props = None
551559
self.content = None
552560
self.attachments = None
561+
self.crypt = False
553562

554563
def handle_ignore(self):
555564
''' Check to see if message can be safely ignored:
@@ -991,22 +1000,33 @@ def pgp_role():
9911000
else:
9921001
return True
9931002

994-
if self.config.PGP_ENABLE and pgp_role():
1003+
if self.config.PGP_ENABLE:
1004+
if pgp_role() and self.config.PGP_ENCRYPT:
1005+
self.crypt = True
9951006
assert pyme, 'pyme is not installed'
9961007
# signed/encrypted mail must come from the primary address
9971008
author_address = self.db.user.get(self.author, 'address')
9981009
if self.config.PGP_HOMEDIR:
9991010
os.environ['GNUPGHOME'] = self.config.PGP_HOMEDIR
1011+
if self.config.PGP_REQUIRE_INCOMING in ('encrypted', 'both') \
1012+
and pgp_role() and not self.message.pgp_encrypted():
1013+
raise MailUsageError, _(
1014+
"This tracker has been configured to require all email "
1015+
"be PGP encrypted.")
10001016
if self.message.pgp_signed():
10011017
self.message.verify_signature(author_address)
10021018
elif self.message.pgp_encrypted():
1003-
# replace message with the contents of the decrypted
1019+
# Replace message with the contents of the decrypted
10041020
# message for content extraction
1005-
# TODO: encrypted message handling is far from perfect
1006-
# bounces probably include the decrypted message, for
1007-
# instance :(
1008-
self.message = self.message.decrypt(author_address)
1009-
else:
1021+
# Note: the bounce-handling code now makes sure that
1022+
# either the encrypted mail received is sent back or
1023+
# that the error message is encrypted if needed.
1024+
encr_only = self.config.PGP_REQUIRE_INCOMING == 'encrypted'
1025+
encr_only = encr_only or not pgp_role()
1026+
self.crypt = True
1027+
self.message = self.message.decrypt(author_address,
1028+
may_be_unsigned = encr_only)
1029+
elif pgp_role():
10101030
raise MailUsageError, _("""
10111031
This tracker has been configured to require all email be PGP signed or
10121032
encrypted.""")
@@ -1449,6 +1469,12 @@ def handle_Message(self, message):
14491469
return self.handle_message(message)
14501470

14511471
# no, we want to trap exceptions
1472+
# Note: by default we return the message received not the
1473+
# internal state of the parsedMessage -- except for
1474+
# MailUsageError, Unauthorized and for unknown exceptions. For
1475+
# the latter cases we make sure the error message is encrypted
1476+
# if needed (if it either was received encrypted or pgp
1477+
# processing is turned on for the user).
14521478
try:
14531479
return self.handle_message(message)
14541480
except MailUsageHelp:
@@ -1466,12 +1492,18 @@ def handle_Message(self, message):
14661492
m.append(str(value))
14671493
m.append('\n\nMail Gateway Help\n=================')
14681494
m.append(fulldoc)
1469-
self.mailer.bounce_message(message, [sendto[0][1]], m)
1495+
if self.parsed_message:
1496+
message = self.parsed_message.message
1497+
crypt = self.parsed_message.crypt
1498+
self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt)
14701499
except Unauthorized, value:
14711500
# just inform the user that he is not authorized
14721501
m = ['']
14731502
m.append(str(value))
1474-
self.mailer.bounce_message(message, [sendto[0][1]], m)
1503+
if self.parsed_message:
1504+
message = self.parsed_message.message
1505+
crypt = self.parsed_message.crypt
1506+
self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt)
14751507
except IgnoreMessage:
14761508
# do not take any action
14771509
# this exception is thrown when email should be ignored
@@ -1492,7 +1524,10 @@ def handle_Message(self, message):
14921524
m.append('An unexpected error occurred during the processing')
14931525
m.append('of your message. The tracker administrator is being')
14941526
m.append('notified.\n')
1495-
self.mailer.bounce_message(message, [sendto[0][1]], m)
1527+
if self.parsed_message:
1528+
message = self.parsed_message.message
1529+
crypt = self.parsed_message.crypt
1530+
self.mailer.bounce_message(message, [sendto[0][1]], m, crypt=crypt)
14961531

14971532
m.append('----------------')
14981533
m.append(traceback.format_exc())
@@ -1523,6 +1558,7 @@ def _handle_message(self, message):
15231558
# commit the changes to the DB
15241559
self.db.commit()
15251560

1561+
self.parsed_message = None
15261562
return nodeid
15271563

15281564
def get_class_arguments(self, class_type, classname=None):

0 commit comments

Comments
 (0)