Merge lp:~mbp/launchpad/dkim into lp:launchpad

Proposed by Martin Pool
Status: Merged
Approved by: Stuart Bishop
Approved revision: no longer in the source branch.
Merge reported by: Martin Pool
Merged at revision: not available
Proposed branch: lp:~mbp/launchpad/dkim
Merge into: lp:launchpad
Diff against target: 491 lines (+359/-8)
5 files modified
lib/canonical/launchpad/mail/incoming.py (+89/-2)
lib/lp/services/mail/tests/test_dkim.py (+258/-0)
lib/lp/services/mail/tests/test_signedmessage.py (+6/-6)
setup.py (+3/-0)
versions.cfg (+3/-0)
To merge this branch: bzr merge lp:~mbp/launchpad/dkim
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Approve
Review via email: mp+26284@code.launchpad.net

Description of the change

To post a comment you must log in.
Revision history for this message
Martin Pool (mbp) wrote :
Revision history for this message
Stuart Bishop (stub) wrote :
Download full text (4.1 KiB)

Thanks for this. I'll certainly appreciate this feature :-)

=== modified file 'lib/canonical/launchpad/mail/incoming.py'

+def _authenticateDkim(mail):
+ """"Attempt DKIM authentication of email; return True if known authentic"""
+
+ try:
+ dkim_result = dkim.verify(mail.as_string(), dkim_log)
+ except dkim.DKIMException, e:
+ log.warning('DKIM error: %r' % (e,))
+ dkim_result = False
+ except dns.exception.DNSException, e:
+ # many of them have lame messages, thus %r
+ log.warning('DNS exception: %r' % (e,))
+ dkim_result = False

So this is failing DKIM authentication on DNS glitches, and DKIMException (invalid signature, messed up DNS etc.). I think users need to be informed of this. A the moment, they will just get the standard 'unsigned email' response with no hint what went wrong.

This might be too much refactoring for now, so perhaps a bug is in order and we can worry about it if it is a genuine problem. I think we would have to annotate the 'mail' object and have a higher level report failures to the user if strong authentication is required but not available.

+ if mail.signature:
+ log.info('message has gpg signature, therefore not treating DKIM as conclusive')
+ return False

I think this logic is better left to the callsite. Just return success/fail and leave it up to authenticateEmail what signature types should take precidence.

=== added file 'lib/lp/services/mail/tests/test_dkim.py'

+# reuse the handler that records log events
+from lp.services.sshserver.tests.test_events import ListHandler

You don't seem to be using this. If you do, ideally this should be moved somewhere like lp.testing.

+ def monkeypatch_dns(self):
+ self._dns_responses = {}
+ def my_lookup(name):
+ try:
+ return self._dns_responses[name]
+ except KeyError:
+ raise dns.resolver.NXDOMAIN()
+ orig_dnstxt = dkim.dnstxt
+ dkim.dnstxt = my_lookup
+ def restore():
+ dkim.dnstxt = orig_dnstxt
+ self.addCleanup(restore)

This might have been nicer using Mocker (http://labix.org/mocker), which is available as a dependency. I wouldn't worry about changing now though - this is fine and understandable.

+ def test_dkim_valid_strict(self):
+ # FIXME: this fails because some re-wrapping happens after the
+ # message comes in (probably going too/from SignedMessage)
+ # and pydkim correctly complains about that: we would need to
+ # validate against the raw bytes
+ return
+ signed_message = self.fake_signing(plain_content,
+ canonicalize=(dkim.Simple, dkim.Simple))
+ self._dns_responses['example._domainkey.canonical.com.'] = \
+ sample_dns
+ principal = authenticateEmail(signed_message_from_string(signed_message))
+ self.assertStronglyAuthenticated(principal, signed_message)

This test is still disabled. Does this need to be fixed before landing, as we would see this situation in real email too? If not, at a minimum the comment needs to change to an XXX referencing a Launchpad bug.

+ def test_d...

Read more...

review: Needs Fixing
Revision history for this message
Martin Pool (mbp) wrote :
Download full text (5.3 KiB)

> Thanks for this. I'll certainly appreciate this feature :-)

Thanks for the review.

>
> === modified file 'lib/canonical/launchpad/mail/incoming.py'
>
> +def _authenticateDkim(mail):
> + """"Attempt DKIM authentication of email; return True if known
> authentic"""
> +
> + try:
> + dkim_result = dkim.verify(mail.as_string(), dkim_log)
> + except dkim.DKIMException, e:
> + log.warning('DKIM error: %r' % (e,))
> + dkim_result = False
> + except dns.exception.DNSException, e:
> + # many of them have lame messages, thus %r
> + log.warning('DNS exception: %r' % (e,))
> + dkim_result = False
>
> So this is failing DKIM authentication on DNS glitches, and DKIMException
> (invalid signature, messed up DNS etc.). I think users need to be informed of
> this. A the moment, they will just get the standard 'unsigned email' response
> with no hint what went wrong.
>
> This might be too much refactoring for now, so perhaps a bug is in order and
> we can worry about it if it is a genuine problem. I think we would have to
> annotate the 'mail' object and have a higher level report failures to the user
> if strong authentication is required but not available.

I think for now we shouldn't send any mail on DKIM failures, because that would be a likely way for this to go wrong by spamming people. Let's get some experience on real data first by monitoring how many fail and why.

There are a few possibilities:

* message is gpg signed but has broken dkim
* message has a broken signature but it's ok for it to be low-trusted anyhow
* message left the user ok but was broken somewhere in transit
* message was broken in our DC on the way to the incoming queue (we don't know this won't happen)
* message failed to verify because of a transient dns error

If the user expects their messages to be trusted and they aren't, there should be a way for them to find out why. Conversely if they don't care about DKIM (and often it won't be under the user's control to fix) we shouldn't bother them about it. For the moment I think we should let them ask a question in the first case.

>
>
> + if mail.signature:
> + log.info('message has gpg signature, therefore not treating DKIM as
> conclusive')
> + return False
>
> I think this logic is better left to the callsite. Just return success/fail
> and leave it up to authenticateEmail what signature types should take
> precidence.

ok

>
> === added file 'lib/lp/services/mail/tests/test_dkim.py'
>
> +# reuse the handler that records log events
> +from lp.services.sshserver.tests.test_events import ListHandler
>
> You don't seem to be using this. If you do, ideally this should be moved
> somewhere like lp.testing.

ok

>
>
> + def monkeypatch_dns(self):
> + self._dns_responses = {}
> + def my_lookup(name):
> + try:
> + return self._dns_responses[name]
> + except KeyError:
> + raise dns.resolver.NXDOMAIN()
> + orig_dnstxt = dkim.dnstxt
> + dkim.dnstxt = my_lookup
> + def restore():
> + dkim.dnstxt = orig_dnstxt
> + self.addCleanup(restore)
>
> Thi...

Read more...

Revision history for this message
Martin Pool (mbp) wrote :

bug 587783 now has what I think is a good clean patch, and a script to demonstrate it, and I've just sent it upstream.

There is also a source package branch there that can build a .deb of the fixed version or I guess that can be turned into an egg if prefered.

There was some list discussion of this <https://lists.launchpad.net/launchpad-dev/msg03533.html>

The point was also raised that we might want a whitelist of domains who are trusted to have meaningful DKIM headers, so I'll add that and do stub's tweaks.

Revision history for this message
Martin Pool (mbp) wrote :

>
> >
> >
> > + if mail.signature:
> > + log.info('message has gpg signature, therefore not treating DKIM as
> > conclusive')
> > + return False
> >
> > I think this logic is better left to the callsite. Just return success/fail
> > and leave it up to authenticateEmail what signature types should take
> > precidence.
>
> ok

done

>
> >
> > === added file 'lib/lp/services/mail/tests/test_dkim.py'
> >
> > +# reuse the handler that records log events
> > +from lp.services.sshserver.tests.test_events import ListHandler
> >
> > You don't seem to be using this. If you do, ideally this should be moved
> > somewhere like lp.testing.
>
> ok

fixed

> >
> > + def test_dkim_valid_strict(self):
> > + # FIXME: this fails because some re-wrapping happens after the
> > + # message comes in (probably going too/from SignedMessage)
> > + # and pydkim correctly complains about that: we would need to
> > + # validate against the raw bytes
> > + return
> > + signed_message = self.fake_signing(plain_content,
> > + canonicalize=(dkim.Simple, dkim.Simple))
> > + self._dns_responses['example._domainkey.canonical.com.'] = \
> > + sample_dns
> > + principal =
> > authenticateEmail(signed_message_from_string(signed_message))
> > + self.assertStronglyAuthenticated(principal, signed_message)
> >
> > This test is still disabled. Does this need to be fixed before landing, as
> we
> > would see this situation in real email too? If not, at a minimum the comment
> > needs to change to an XXX referencing a Launchpad bug.
>
> Yes, this needs to be fixed before landing; it's due in part to bug 587783.

fixed; there was a bug in python-dkim but I also need to expose the raw bytes of the message for DKIM validation, not the ISignedMessage which doesn't give the contents back byte-for-byte

>
> > + def test_dkim_body_mismatch(self):
> > + # The message message has a syntactically valid DKIM signature that
> > + # doesn't actually correspond to the sender. We log something
> about
> > + # this but we don't want to drop the message.
> > + #
> > + # XXX: This test relies on having real DNS service to look up the
> > + # signing key.
> >
> > Is the XXX comment still valid now you are monkey patching the DNS
> responses?
> > We might have to fix this before landing - I don't know if this is an
> allowed
> > dependency in our tests.
>
> It's out of date.

fixed

I think the main things to do now are

 * add a config option for a list of trusted domains and test it
 * possibly get a final check-over from @elmo
 * get the dependency included -- if you want, you could start that now in parallel?
 * deploy to staging and test there?

Revision history for this message
Martin Pool (mbp) wrote :

A couple more things:

 * We need to check the From address matches the signature; at the moment we don't but I now have a failing test for it
 * python-dkim only checks the first signature so a multiply signed message may be seen as untrusted. I think this is fine for now.

Revision history for this message
Stuart Bishop (stub) wrote :

lp:~stub/launchpad/dkim contains the changes for building the patched pydkim with buildout

Revision history for this message
Martin Pool (mbp) wrote :

<https://code.edge.launchpad.net/~mbp/ubuntu/lucid/pydkim/details> gives pydkim the ability to return details about the signatures that it verified.

Revision history for this message
Stuart Bishop (stub) :
review: Approve
Revision history for this message
Martin Pool (mbp) wrote :

Sorry, could you please re-try with the import restored?

Revision history for this message
Martin Pool (mbp) wrote :

the equivalent was merged by stub

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/mail/incoming.py'
2--- lib/canonical/launchpad/mail/incoming.py 2010-06-04 16:15:38 +0000
3+++ lib/canonical/launchpad/mail/incoming.py 2010-07-12 20:52:02 +0000
4@@ -11,9 +11,13 @@
5 from cStringIO import StringIO as cStringIO
6 from email.utils import getaddresses, parseaddr
7 import email.errors
8+import logging
9 import re
10 import sys
11
12+import dkim
13+import dns.exception
14+
15 import transaction
16 from zope.component import getUtility
17 from zope.interface import directlyProvides, directlyProvidedBy
18@@ -65,10 +69,83 @@
19 """The account for the person sending this email is inactive."""
20
21
22-def authenticateEmail(mail):
23+def extract_address_domain(address):
24+ realname, email_address = email.utils.parseaddr(address)
25+ return email_address.split('@')[1]
26+
27+
28+_trusted_dkim_domains = [
29+ 'gmail.com', 'google.com', 'mail.google.com', 'canonical.com']
30+
31+
32+def _isDkimDomainTrusted(domain):
33+ # XXX: really this should come from a dynamically-modifiable
34+ # configuration, but we don't have such a thing yet.
35+ #
36+ # Being listed here means that we trust the domain not to be an open relay
37+ # or to allow arbitrary intra-domain spoofing.
38+ return domain in _trusted_dkim_domains
39+
40+
41+def _authenticateDkim(signed_message, raw_mail):
42+ """"Attempt DKIM authentication of email; return True if known authentic
43+
44+ :param signed_message: ISignedMessage
45+ :param raw_mail: byte string of the raw contents: needed because
46+ DKIM may be in strict mode and we want to avoid any header rewrapping
47+ involved in parsing and regenerating the message.
48+ """
49+
50+ log = logging.getLogger('mail-authenticate-dkim')
51+ log.setLevel(logging.DEBUG)
52+ # uncomment this for easier test debugging
53+ # log.addHandler(logging.FileHandler('/tmp/dkim.log'))
54+
55+ dkim_log = cStringIO()
56+ log.info('Attempting DKIM authentication of message %s from %s'
57+ % (signed_message['Message-ID'], signed_message['From']))
58+ signing_details = []
59+ try:
60+ # NB: if this fails with a keyword argument error, you need the
61+ # python-dkim 0.3-3.2 that adds it
62+ dkim_result = dkim.verify(raw_mail, dkim_log, details=signing_details)
63+ except dkim.DKIMException, e:
64+ log.warning('DKIM error: %r' % (e,))
65+ dkim_result = False
66+ except dns.exception.DNSException, e:
67+ # many of them have lame messages, thus %r
68+ log.warning('DNS exception: %r' % (e,))
69+ dkim_result = False
70+ else:
71+ log.info('DKIM verification result=%s' % (dkim_result,))
72+ log.debug('DKIM debug log: %s' % (dkim_log.getvalue(),))
73+ if not dkim_result:
74+ return False
75+ # in addition to the dkim signature being valid, we have to check that it
76+ # was actually signed by the user's domain.
77+ if len(signing_details) != 1:
78+ log.errors('expected exactly one DKIM details record: %r' % (signing_details,))
79+ return False
80+ signing_domain = signing_details[0]['d']
81+ from_domain = extract_address_domain(signed_message['From'])
82+ if signing_domain != from_domain:
83+ log.warning("DKIM signing domain %s doesn't match From address %s; "
84+ "disregarding signature"
85+ % (signing_domain, from_domain))
86+ return False
87+ if not _isDkimDomainTrusted(signing_domain):
88+ log.warning("valid DKIM signature from untrusted domain %s"
89+ % (signing_domain,))
90+ return False
91+ return True
92+
93+
94+def authenticateEmail(mail, raw_mail):
95 """Authenticates an email by verifying the PGP signature.
96
97 The mail is expected to be an ISignedMessage.
98+
99+ raw_mail is just a byte string of the uninterpreted mail.
100 """
101
102 signature = mail.signature
103@@ -89,6 +166,16 @@
104 raise InactiveAccount(
105 "Mail from a user with an inactive account.")
106
107+ dkim_result = _authenticateDkim(mail, raw_mail)
108+
109+ if dkim_result:
110+ if mail.signature is not None:
111+ log.info('message has gpg signature, therefore not treating DKIM '
112+ 'success as conclusive')
113+ else:
114+ setupInteraction(principal, email_addr)
115+ return principal
116+
117 if signature is None:
118 # Mark the principal so that application code can check that the
119 # user was weakly authenticated.
120@@ -255,7 +342,7 @@
121 continue
122
123 try:
124- principal = authenticateEmail(mail)
125+ principal = authenticateEmail(mail, raw_mail)
126 except InvalidSignature, error:
127 _handle_user_error(error, mail)
128 continue
129
130=== added file 'lib/lp/services/mail/tests/test_dkim.py'
131--- lib/lp/services/mail/tests/test_dkim.py 1970-01-01 00:00:00 +0000
132+++ lib/lp/services/mail/tests/test_dkim.py 2010-07-12 20:52:02 +0000
133@@ -0,0 +1,258 @@
134+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
135+# GNU Affero General Public License version 3 (see the file LICENSE).
136+
137+"""Test DKIM-signed messages"""
138+
139+__metaclass__ = type
140+
141+import logging
142+import unittest
143+
144+from StringIO import StringIO
145+
146+import dkim
147+import dns.resolver
148+
149+from zope.component import getUtility
150+
151+from canonical.launchpad.mail import (
152+ incoming,
153+ signed_message_from_string,
154+ )
155+from canonical.launchpad.mail.incoming import (
156+ authenticateEmail, )
157+from canonical.launchpad.interfaces.mail import IWeaklyAuthenticatedPrincipal
158+from lp.testing import TestCaseWithFactory
159+from canonical.testing.layers import DatabaseFunctionalLayer
160+
161+
162+# sample private key made with 'openssl genrsa' and public key using 'openssl
163+# rsa -pubout'. Not really the key for canonical.com ;-)
164+sample_privkey = """\
165+-----BEGIN RSA PRIVATE KEY-----
166+MIIBOwIBAAJBANmBe10IgY+u7h3enWTukkqtUD5PR52Tb/mPfjC0QJTocVBq6Za/
167+PlzfV+Py92VaCak19F4WrbVTK5Gg5tW220MCAwEAAQJAYFUKsD+uMlcFu1D3YNaR
168+EGYGXjJ6w32jYGJ/P072M3yWOq2S1dvDthI3nRT8MFjZ1wHDAYHrSpfDNJ3v2fvZ
169+cQIhAPgRPmVYn+TGd59asiqG1SZqh+p+CRYHW7B8BsicG5t3AiEA4HYNOohlgWan
170+8tKgqLJgUdPFbaHZO1nDyBgvV8hvWZUCIQDDdCq6hYKuKeYUy8w3j7cgJq3ih922
171+2qNWwdJCfCWQbwIgTY0cBvQnNe0067WQIpj2pG7pkHZR6qqZ9SE+AjNTHX0CIQCI
172+Mgq55Y9MCq5wqzy141rnxrJxTwK9ABo3IAFMWEov3g==
173+-----END RSA PRIVATE KEY-----
174+"""
175+
176+sample_pubkey = """\
177+-----BEGIN PUBLIC KEY-----
178+MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANmBe10IgY+u7h3enWTukkqtUD5PR52T
179+b/mPfjC0QJTocVBq6Za/PlzfV+Py92VaCak19F4WrbVTK5Gg5tW220MCAwEAAQ==
180+-----END PUBLIC KEY-----
181+"""
182+
183+sample_dns = """\
184+k=rsa; \
185+p=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANmBe10IgY+u7h3enWTukkqtUD5PR52T\
186+b/mPfjC0QJTocVBq6Za/PlzfV+Py92VaCak19F4WrbVTK5Gg5tW220MCAwEAAQ=="""
187+
188+
189+plain_content = """\
190+From: Foo Bar <foo.bar@canonical.com>
191+Date: Fri, 1 Apr 2010 00:00:00 +1000
192+Subject: yet another comment
193+To: 1@bugs.staging.launchpad.net
194+
195+ importance critical
196+
197+Why isn't this fixed yet?"""
198+
199+
200+class TestDKIM(TestCaseWithFactory):
201+ """Messages can be strongly authenticated by DKIM."""
202+
203+ layer = DatabaseFunctionalLayer
204+
205+ def setUp(self):
206+ # Login with admin roles as we aren't testing access here.
207+ TestCaseWithFactory.setUp(self, 'admin@canonical.com')
208+ self._log_output = StringIO()
209+ handler = logging.StreamHandler(self._log_output)
210+ logger = logging.getLogger('mail-authenticate-dkim')
211+ logger.addHandler(handler)
212+ self.addCleanup(lambda: logger.removeHandler(handler))
213+ self.monkeypatch_dns()
214+
215+ def fake_signing(self, plain_message, canonicalize=None):
216+ if canonicalize is None:
217+ canonicalize = (dkim.Relaxed, dkim.Relaxed)
218+ dkim_line = dkim.sign(plain_message,
219+ selector='example',
220+ domain='canonical.com',
221+ privkey=sample_privkey,
222+ debuglog=self._log_output,
223+ canonicalize=canonicalize
224+ )
225+ assert dkim_line[-1] == '\n'
226+ return dkim_line + plain_message
227+
228+ def monkeypatch_dns(self):
229+ self._dns_responses = {}
230+ def my_lookup(name):
231+ try:
232+ return self._dns_responses[name]
233+ except KeyError:
234+ raise dns.resolver.NXDOMAIN()
235+ orig_dnstxt = dkim.dnstxt
236+ dkim.dnstxt = my_lookup
237+ def restore():
238+ dkim.dnstxt = orig_dnstxt
239+ self.addCleanup(restore)
240+
241+ def get_dkim_log(self):
242+ return self._log_output.getvalue()
243+
244+ def assertStronglyAuthenticated(self, principal, signed_message):
245+ if IWeaklyAuthenticatedPrincipal.providedBy(principal):
246+ self.fail('expected strong authentication; got weak:\n'
247+ + self.get_dkim_log() + '\n\n' + signed_message)
248+
249+ def assertWeaklyAuthenticated(self, principal, signed_message):
250+ if not IWeaklyAuthenticatedPrincipal.providedBy(principal):
251+ self.fail('expected weak authentication; got strong:\n'
252+ + self.get_dkim_log() + '\n\n' + signed_message)
253+
254+ def assertDkimLogContains(self, substring):
255+ l = self.get_dkim_log()
256+ if l.find(substring) == -1:
257+ self.fail("didn't find %r in log: %s" % (substring, l))
258+
259+ def test_dkim_garbage_pubkey(self):
260+ signed_message = self.fake_signing(plain_content)
261+ self._dns_responses['example._domainkey.canonical.com.'] = \
262+ 'aothuaonu'
263+ principal = authenticateEmail(signed_message_from_string(signed_message),
264+ signed_message)
265+ self.assertWeaklyAuthenticated(principal, signed_message)
266+ self.assertEqual(principal.person.preferredemail.email,
267+ 'foo.bar@canonical.com')
268+ self.assertDkimLogContains('invalid format in _domainkey txt record')
269+
270+ def test_dkim_valid_strict(self):
271+ signed_message = self.fake_signing(plain_content,
272+ canonicalize=(dkim.Simple, dkim.Simple))
273+ self._dns_responses['example._domainkey.canonical.com.'] = \
274+ sample_dns
275+ principal = authenticateEmail(signed_message_from_string(signed_message),
276+ signed_message)
277+ self.assertStronglyAuthenticated(principal, signed_message)
278+ self.assertEqual(principal.person.preferredemail.email,
279+ 'foo.bar@canonical.com')
280+
281+ def test_dkim_valid(self):
282+ signed_message = self.fake_signing(plain_content)
283+ self._dns_responses['example._domainkey.canonical.com.'] = \
284+ sample_dns
285+ principal = authenticateEmail(signed_message_from_string(signed_message),
286+ signed_message)
287+ self.assertStronglyAuthenticated(principal, signed_message)
288+ self.assertEqual(principal.person.preferredemail.email,
289+ 'foo.bar@canonical.com')
290+
291+ def test_dkim_untrusted_signer(self):
292+ # Valid signature from an untrusted domain -> untrusted
293+ signed_message = self.fake_signing(plain_content)
294+ self._dns_responses['example._domainkey.canonical.com.'] = \
295+ sample_dns
296+ saved_domains = incoming._trusted_dkim_domains[:]
297+ def restore():
298+ incoming._trusted_dkim_domains = saved_domains
299+ self.addCleanup(restore)
300+ incoming._trusted_dkim_domains = []
301+ principal = authenticateEmail(signed_message_from_string(signed_message),
302+ signed_message)
303+ self.assertWeaklyAuthenticated(principal, signed_message)
304+ self.assertEqual(principal.person.preferredemail.email,
305+ 'foo.bar@canonical.com')
306+
307+ def test_dkim_signing_irrelevant(self):
308+ # It's totally valid for a message to be signed by a domain other than
309+ # that of the From-sender, if that domain is relaying the message.
310+ # However, we shouldn't then trust the purported sender, because they
311+ # might have just made it up rather than relayed it.
312+ tweaked_message = plain_content.replace('foo.bar@canonical.com',
313+ 'steve.alexander@ubuntulinux.com')
314+ signed_message = self.fake_signing(tweaked_message)
315+ self._dns_responses['example._domainkey.canonical.com.'] = \
316+ sample_dns
317+ principal = authenticateEmail(signed_message_from_string(signed_message),
318+ signed_message)
319+ self.assertWeaklyAuthenticated(principal, signed_message)
320+ # should come from From, not the dkim signature
321+ self.assertEqual(principal.person.preferredemail.email,
322+ 'steve.alexander@ubuntulinux.com')
323+
324+ def test_dkim_changed_from_address(self):
325+ # if the address part of the message has changed, it's detected. we
326+ # still treat this as weakly authenticated by the purported From-header
327+ # sender, though perhaps in future we would prefer to reject these
328+ # messages.
329+ signed_message = self.fake_signing(plain_content)
330+ self._dns_responses['example._domainkey.canonical.com.'] = \
331+ sample_dns
332+ fiddled_message = signed_message.replace('From: Foo Bar <foo.bar@canonical.com>',
333+ 'From: Carlos <carlos@canonical.com>')
334+ principal = authenticateEmail(signed_message_from_string(fiddled_message),
335+ fiddled_message)
336+ self.assertWeaklyAuthenticated(principal, fiddled_message)
337+ # should come from From, not the dkim signature
338+ self.assertEqual(principal.person.preferredemail.email,
339+ 'carlos@canonical.com')
340+
341+ def test_dkim_changed_from_realname(self):
342+ # if the real name part of the message has changed, it's detected
343+ signed_message = self.fake_signing(plain_content)
344+ self._dns_responses['example._domainkey.canonical.com.'] = \
345+ sample_dns
346+ fiddled_message = signed_message.replace('From: Foo Bar <foo.bar@canonical.com>',
347+ 'From: Evil Foo <foo.bar@canonical.com>')
348+ principal = authenticateEmail(signed_message_from_string(fiddled_message),
349+ fiddled_message)
350+ # we don't care about the real name for determining the principal
351+ self.assertWeaklyAuthenticated(principal, fiddled_message)
352+ self.assertEqual(principal.person.preferredemail.email,
353+ 'foo.bar@canonical.com')
354+
355+ def test_dkim_nxdomain(self):
356+ # if there's no DNS entry for the pubkey
357+ # it should be handled decently
358+ signed_message = self.fake_signing(plain_content)
359+ principal = authenticateEmail(signed_message_from_string(signed_message),
360+ signed_message)
361+ self.assertWeaklyAuthenticated(principal, signed_message)
362+ self.assertEqual(principal.person.preferredemail.email,
363+ 'foo.bar@canonical.com')
364+
365+ def test_dkim_message_unsigned(self):
366+ # degenerate case: no signature treated as weakly authenticated
367+ principal = authenticateEmail(signed_message_from_string(plain_content),
368+ plain_content)
369+ self.assertWeaklyAuthenticated(principal, plain_content)
370+ self.assertEqual(principal.person.preferredemail.email,
371+ 'foo.bar@canonical.com')
372+ # the library doesn't log anything if there's no header at all
373+
374+ def test_dkim_body_mismatch(self):
375+ # The message message has a syntactically valid DKIM signature that
376+ # doesn't actually correspond to what was signed. We log something about
377+ # this but we don't want to drop the message.
378+ signed_message = self.fake_signing(plain_content)
379+ signed_message += 'blah blah'
380+ self._dns_responses['example._domainkey.canonical.com.'] = \
381+ sample_dns
382+ principal = authenticateEmail(signed_message_from_string(signed_message),
383+ signed_message)
384+ self.assertWeaklyAuthenticated(principal, signed_message)
385+ self.assertEqual(principal.person.preferredemail.email,
386+ 'foo.bar@canonical.com')
387+ self.assertDkimLogContains('body hash mismatch')
388+
389+
390+def test_suite():
391+ return unittest.TestLoader().loadTestsFromName(__name__)
392
393=== modified file 'lib/lp/services/mail/tests/test_signedmessage.py'
394--- lib/lp/services/mail/tests/test_signedmessage.py 2009-06-30 16:56:07 +0000
395+++ lib/lp/services/mail/tests/test_signedmessage.py 2010-07-12 20:52:02 +0000
396@@ -28,7 +28,7 @@
397 from canonical.testing.layers import DatabaseFunctionalLayer
398
399 class TestSignedMessage(TestCaseWithFactory):
400- """Test SignedMessage class correctly extracts the GPG signatures."""
401+ """Test SignedMessage class correctly extracts and verifies the GPG signatures."""
402
403 layer = DatabaseFunctionalLayer
404
405@@ -45,7 +45,7 @@
406 msg = signed_message_from_string(email_message.as_string())
407 self.assertIs(None, msg.signedContent)
408 self.assertIs(None, msg.signature)
409- principle = authenticateEmail(msg)
410+ principle = authenticateEmail(msg, msg.as_string())
411 self.assertEqual(sender, principle.person)
412 self.assertTrue(
413 IWeaklyAuthenticatedPrincipal.providedBy(principle))
414@@ -73,7 +73,7 @@
415 # the principle is set to the sender, but weakly authenticated.
416 sender = self.factory.makePerson()
417 msg = self._get_clearsigned_for_person(sender)
418- principle = authenticateEmail(msg)
419+ principle = authenticateEmail(msg, msg.as_string())
420 self.assertIsNot(None, msg.signature)
421 self.assertEqual(sender, principle.person)
422 self.assertTrue(
423@@ -83,7 +83,7 @@
424 # The test keys belong to Sample Person.
425 sender = getUtility(IPersonSet).getByEmail('test@canonical.com')
426 msg = self._get_clearsigned_for_person(sender)
427- principle = authenticateEmail(msg)
428+ principle = authenticateEmail(msg, msg.as_string())
429 self.assertIsNot(None, msg.signature)
430 self.assertEqual(sender, principle.person)
431 self.assertFalse(
432@@ -127,7 +127,7 @@
433 # the principle is set to the sender, but weakly authenticated.
434 sender = self.factory.makePerson()
435 msg = self._get_detached_message_for_person(sender)
436- principle = authenticateEmail(msg)
437+ principle = authenticateEmail(msg, msg.as_string())
438 self.assertIsNot(None, msg.signature)
439 self.assertEqual(sender, principle.person)
440 self.assertTrue(
441@@ -137,7 +137,7 @@
442 # Test a detached correct signature.
443 sender = getUtility(IPersonSet).getByEmail('test@canonical.com')
444 msg = self._get_detached_message_for_person(sender)
445- principle = authenticateEmail(msg)
446+ principle = authenticateEmail(msg, msg.as_string())
447 self.assertIsNot(None, msg.signature)
448 self.assertEqual(sender, principle.person)
449 self.assertFalse(
450
451=== modified file 'setup.py'
452--- setup.py 2010-07-06 08:58:00 +0000
453+++ setup.py 2010-07-12 20:52:02 +0000
454@@ -29,6 +29,8 @@
455 'chameleon.core',
456 'chameleon.zpt',
457 'cssutils',
458+ # Required for pydkim
459+ 'dnspython',
460 'feedvalidator',
461 'funkload',
462 'launchpadlib',
463@@ -51,6 +53,7 @@
464 'paramiko',
465 'python-memcached',
466 'pyasn1',
467+ 'pydkim',
468 'python-openid',
469 'pytz',
470 # This appears to be a broken indirect dependency from zope.security:
471
472=== modified file 'versions.cfg'
473--- versions.cfg 2010-07-09 15:10:07 +0000
474+++ versions.cfg 2010-07-12 20:52:02 +0000
475@@ -13,6 +13,8 @@
476 # Required by Windmill to run on 2.4
477 ctypes = 1.0.2
478 docutils = 0.5
479+# Required by pydkim
480+dnspython = 1.7.1
481 elementtree = 1.2.6-20050316
482 epydoc = 3.0.1
483 feedvalidator = 0.0.0DEV-r1049
484@@ -47,6 +49,7 @@
485 PasteDeploy = 1.3.3
486 pyasn1 = 0.0.9a
487 pycrypto = 2.0.1
488+pydkim = 0.3-bug-587783-2
489 pyOpenSSL = 0.10
490 python-memcached = 1.45
491 python-openid = 2.2.1