diff options
-rw-r--r-- | mail/changes/feature_3878_add-preference-to-openpgp-header | 2 | ||||
-rw-r--r-- | mail/changes/feature_4354_add-signature-header-to-incoming-email | 2 | ||||
-rw-r--r-- | mail/changes/feature_4526_add-footer-to-outgoing-email | 2 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/fetch.py | 268 | ||||
-rw-r--r-- | mail/src/leap/mail/imap/tests/test_imap.py | 18 | ||||
-rw-r--r-- | mail/src/leap/mail/smtp/gateway.py | 14 | ||||
-rw-r--r-- | mail/src/leap/mail/smtp/tests/__init__.py | 4 | ||||
-rw-r--r-- | mail/src/leap/mail/smtp/tests/test_gateway.py | 21 |
8 files changed, 233 insertions, 98 deletions
diff --git a/mail/changes/feature_3878_add-preference-to-openpgp-header b/mail/changes/feature_3878_add-preference-to-openpgp-header new file mode 100644 index 00000000..4513bf6a --- /dev/null +++ b/mail/changes/feature_3878_add-preference-to-openpgp-header @@ -0,0 +1,2 @@ + o Add 'signencrypt' preference to OpenPGP header on outgoing email. Closes + #3878". diff --git a/mail/changes/feature_4354_add-signature-header-to-incoming-email b/mail/changes/feature_4354_add-signature-header-to-incoming-email new file mode 100644 index 00000000..866b68ef --- /dev/null +++ b/mail/changes/feature_4354_add-signature-header-to-incoming-email @@ -0,0 +1,2 @@ + o Add a header to incoming emails that reflects if a valid signature was + found when decrypting. Closes #4354. diff --git a/mail/changes/feature_4526_add-footer-to-outgoing-email b/mail/changes/feature_4526_add-footer-to-outgoing-email new file mode 100644 index 00000000..60308add --- /dev/null +++ b/mail/changes/feature_4526_add-footer-to-outgoing-email @@ -0,0 +1,2 @@ + o Add a footer to outgoing email pointing to the address where sender keys + can be fetched. Closes #4526. diff --git a/mail/src/leap/mail/imap/fetch.py b/mail/src/leap/mail/imap/fetch.py index 3422ed50..38612e1e 100644 --- a/mail/src/leap/mail/imap/fetch.py +++ b/mail/src/leap/mail/imap/fetch.py @@ -22,8 +22,12 @@ import json import ssl import threading import time +import copy +from StringIO import StringIO from email.parser import Parser +from email.generator import Generator +from email.utils import parseaddr from twisted.python import log from twisted.internet.task import LoopingCall @@ -65,6 +69,16 @@ class LeapIncomingMail(object): INCOMING_KEY = "incoming" CONTENT_KEY = "content" + LEAP_SIGNATURE_HEADER = 'X-Leap-Signature' + """ + Header added to messages when they are decrypted by the IMAP fetcher, + which states the validity of an eventual signature that might be included + in the encrypted blob. + """ + LEAP_SIGNATURE_VALID = 'valid' + LEAP_SIGNATURE_INVALID = 'invalid' + LEAP_SIGNATURE_COULD_NOT_VERIFY = 'could not verify' + fetching_lock = threading.Lock() def __init__(self, keymanager, soledad, imap_account, @@ -260,11 +274,9 @@ class LeapIncomingMail(object): if self._is_msg(keys): # Ok, this looks like a legit msg. # Let's process it! - encdata = doc.content[ENC_JSON_KEY] - # Deferred chain for individual messages - d = deferToThread(self._decrypt_msg, doc, encdata) - d.addCallback(self._process_decrypted) + d = deferToThread(self._decrypt_doc, doc) + d.addCallback(self._process_decrypted_doc) d.addErrback(self._log_err) d.addCallback(self._add_message_locally) d.addErrback(self._log_err) @@ -293,32 +305,40 @@ class LeapIncomingMail(object): """ return ENC_SCHEME_KEY in keys and ENC_JSON_KEY in keys - def _decrypt_msg(self, doc, encdata): + def _decrypt_doc(self, doc): + """ + Decrypt the contents of a document. + + :param doc: A document containing an encrypted message. + :type doc: SoledadDocument + + :return: A tuple containing the document and the decrypted message. + :rtype: (SoledadDocument, str) + """ log.msg('decrypting msg') - key = self._pkey + success = False try: - decrdata = (self._keymanager.decrypt( - encdata, key, - passphrase=self._soledad.passphrase)) - ok = True + decrdata = self._keymanager.decrypt( + doc.content[ENC_JSON_KEY], + self._pkey) + success = True except Exception as exc: # XXX move this to errback !!! logger.warning("Error while decrypting msg: %r" % (exc,)) decrdata = "" - ok = False - leap_events.signal(IMAP_MSG_DECRYPTED, "1" if ok else "0") + leap_events.signal(IMAP_MSG_DECRYPTED, "1" if success else "0") return doc, decrdata - def _process_decrypted(self, msgtuple): + def _process_decrypted_doc(self, msgtuple): """ - Process a successfully decrypted message. + Process a document containing a succesfully decrypted message. :param msgtuple: a tuple consisting of a SoledadDocument instance containing the incoming message and data, the json-encoded, decrypted content of the incoming message :type msgtuple: (SoledadDocument, str) - :returns: a SoledadDocument and the processed data. + :return: a SoledadDocument and the processed data. :rtype: (doc, data) """ doc, data = msgtuple @@ -333,13 +353,13 @@ class LeapIncomingMail(object): if not rawmsg: return False try: - data = self._maybe_decrypt_gpg_msg(rawmsg) + data = self._maybe_decrypt_msg(rawmsg) return doc, data except keymanager_errors.EncryptionDecryptionFailed as exc: logger.error(exc) raise - def _maybe_decrypt_gpg_msg(self, data): + def _maybe_decrypt_msg(self, data): """ Tries to decrypt a gpg message if data looks like one. @@ -348,80 +368,168 @@ class LeapIncomingMail(object): :return: data, possibly descrypted. :rtype: str """ - # TODO split this method leap_assert_type(data, unicode) + # parse the original message parser = Parser() encoding = get_email_charset(data) data = data.encode(encoding) - origmsg = parser.parsestr(data) - - # handle multipart/encrypted messages - if origmsg.get_content_type() == 'multipart/encrypted': - # sanity check - payload = origmsg.get_payload() - if len(payload) != 2: - raise MalformedMessage( - 'Multipart/encrypted messages should have exactly 2 body ' - 'parts (instead of %d).' % len(payload)) - if payload[0].get_content_type() != 'application/pgp-encrypted': - raise MalformedMessage( - "Multipart/encrypted messages' first body part should " - "have content type equal to 'application/pgp-encrypted' " - "(instead of %s)." % payload[0].get_content_type()) - if payload[1].get_content_type() != 'application/octet-stream': - raise MalformedMessage( - "Multipart/encrypted messages' second body part should " - "have content type equal to 'octet-stream' (instead of " - "%s)." % payload[1].get_content_type()) - - # parse message and get encrypted content - pgpencmsg = origmsg.get_payload()[1] - encdata = pgpencmsg.get_payload() - - # decrypt and parse decrypted message - decrdata = self._keymanager.decrypt( - encdata, self._pkey, - passphrase=self._soledad.passphrase) + msg = parser.parsestr(data) + + # try to obtain sender public key + senderPubkey = None + fromHeader = msg.get('from', None) + if fromHeader is not None: + _, senderAddress = parseaddr(fromHeader) try: - decrdata = decrdata.encode(encoding) - except (UnicodeEncodeError, UnicodeDecodeError) as e: - logger.error("Unicode error {0}".format(e)) - decrdata = decrdata.encode(encoding, 'replace') - - decrmsg = parser.parsestr(decrdata) - # remove original message's multipart/encrypted content-type - del(origmsg['content-type']) - # replace headers back in original message - for hkey, hval in decrmsg.items(): - try: - # this will raise KeyError if header is not present - origmsg.replace_header(hkey, hval) - except KeyError: - origmsg[hkey] = hval - - # replace payload by unencrypted payload - origmsg.set_payload(decrmsg.get_payload()) - return origmsg.as_string(unixfrom=False) + senderPubkey = self._keymanager.get_key( + senderAddress, OpenPGPKey) + except keymanager_errors.KeyNotFound: + pass + + valid_sig = False # we will add a header saying if sig is valid + decrdata = '' + if msg.get_content_type() == 'multipart/encrypted': + decrmsg, valid_sig = self._decrypt_multipart_encrypted_msg( + msg, encoding, senderPubkey) + else: + decrmsg, valid_sig = self._maybe_decrypt_inline_encrypted_msg( + msg, encoding, senderPubkey) + + # add x-leap-signature header + if senderPubkey is None: + decrmsg.add_header( + self.LEAP_SIGNATURE_HEADER, + self.LEAP_SIGNATURE_COULD_NOT_VERIFY) else: - PGP_BEGIN = "-----BEGIN PGP MESSAGE-----" - PGP_END = "-----END PGP MESSAGE-----" - # handle inline PGP messages - if PGP_BEGIN in data: - begin = data.find(PGP_BEGIN) - end = data.rfind(PGP_END) - pgp_message = data[begin:begin+end] - decrdata = (self._keymanager.decrypt( - pgp_message, self._pkey, - passphrase=self._soledad.passphrase)) - # replace encrypted by decrypted content - data = data.replace(pgp_message, decrdata) + decrmsg.add_header( + self.LEAP_SIGNATURE_HEADER, + self.LEAP_SIGNATURE_VALID if valid_sig else \ + self.LEAP_SIGNATURE_INVALID, + pubkey=senderPubkey.key_id) + + return decrmsg.as_string() + + def _decrypt_multipart_encrypted_msg(self, msg, encoding, senderPubkey): + """ + Decrypt a message with content-type 'multipart/encrypted'. + + :param msg: The original encrypted message. + :type msg: Message + :param encoding: The encoding of the email message. + :type encoding: str + :param senderPubkey: The key of the sender of the message. + :type senderPubkey: OpenPGPKey + + :return: A unitary tuple containing a decrypted message. + :rtype: (Message) + """ + msg = copy.deepcopy(msg) + # sanity check + payload = msg.get_payload() + if len(payload) != 2: + raise MalformedMessage( + 'Multipart/encrypted messages should have exactly 2 body ' + 'parts (instead of %d).' % len(payload)) + if payload[0].get_content_type() != 'application/pgp-encrypted': + raise MalformedMessage( + "Multipart/encrypted messages' first body part should " + "have content type equal to 'application/pgp-encrypted' " + "(instead of %s)." % payload[0].get_content_type()) + if payload[1].get_content_type() != 'application/octet-stream': + raise MalformedMessage( + "Multipart/encrypted messages' second body part should " + "have content type equal to 'octet-stream' (instead of " + "%s)." % payload[1].get_content_type()) + # parse message and get encrypted content + pgpencmsg = msg.get_payload()[1] + encdata = pgpencmsg.get_payload() + # decrypt and parse decrypted message + decrdata, valid_sig = self._decrypt_and_verify_data( + encdata, senderPubkey) + try: + decrdata = decrdata.encode(encoding) + except (UnicodeEncodeError, UnicodeDecodeError) as e: + logger.error("Unicode error {0}".format(e)) + decrdata = decrdata.encode(encoding, 'replace') + parser = Parser() + decrmsg = parser.parsestr(decrdata) + # remove original message's multipart/encrypted content-type + del(msg['content-type']) + # replace headers back in original message + for hkey, hval in decrmsg.items(): + try: + # this will raise KeyError if header is not present + msg.replace_header(hkey, hval) + except KeyError: + msg[hkey] = hval + # replace payload by unencrypted payload + msg.set_payload(decrmsg.get_payload()) + return msg, valid_sig + + def _maybe_decrypt_inline_encrypted_msg(self, origmsg, encoding, + senderPubkey): + """ + Possibly decrypt an inline OpenPGP encrypted message. + + :param origmsg: The original, possibly encrypted message. + :type origmsg: Message + :param encoding: The encoding of the email message. + :type encoding: str + :param senderPubkey: The key of the sender of the message. + :type senderPubkey: OpenPGPKey + + :return: A unitary tuple containing a decrypted message. + :rtype: (Message) + """ + # serialize the original message + buf = StringIO() + g = Generator(buf) + g.flatten(origmsg) + data = buf.getvalue() + # handle exactly one inline PGP message + PGP_BEGIN = "-----BEGIN PGP MESSAGE-----" + PGP_END = "-----END PGP MESSAGE-----" + valid_sig = False + if PGP_BEGIN in data: + begin = data.find(PGP_BEGIN) + end = data.find(PGP_END) + pgp_message = data[begin:end+len(PGP_END)] + decrdata, valid_sig = self._decrypt_and_verify_data( + pgp_message, senderPubkey) + # replace encrypted by decrypted content + data = data.replace(pgp_message, decrdata) # if message is not encrypted, return raw data - if isinstance(data, unicode): data = data.encode(encoding, 'replace') + parser = Parser() + return parser.parsestr(data), valid_sig + + def _decrypt_and_verify_data(self, data, senderPubkey): + """ + Decrypt C{data} using our private key and attempt to verify a + signature using C{senderPubkey}. - return data + :param data: The text to be decrypted. + :type data: unicode + :param senderPubkey: The public key of the sender of the message. + :type senderPubkey: OpenPGPKey + + :return: The decrypted data and a boolean stating whether the + signature could be verified. + :rtype: (str, bool) + """ + valid_sig = False + try: + decrdata = self._keymanager.decrypt( + data, self._pkey, + verify=senderPubkey) + if senderPubkey is not None: + valid_sig = True + except keymanager_errors.InvalidSignature: + decrdata = self._keymanager.decrypt( + data, self._pkey) + return decrdata, valid_sig def _add_message_locally(self, msgtuple): """ diff --git a/mail/src/leap/mail/imap/tests/test_imap.py b/mail/src/leap/mail/imap/tests/test_imap.py index ad11315d..ca73a11c 100644 --- a/mail/src/leap/mail/imap/tests/test_imap.py +++ b/mail/src/leap/mail/imap/tests/test_imap.py @@ -923,7 +923,6 @@ class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): """ self.server.theAccount.addMailbox('test-mailbox-e', creation_ts=42) - #import ipdb; ipdb.set_trace() self.examinedArgs = None @@ -1108,16 +1107,15 @@ class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): mb = SimpleLEAPServer.theAccount.getMailbox('ROOT/SUBTHING') self.assertEqual(1, len(mb.messages)) - #import ipdb; ipdb.set_trace() self.assertEqual( ['\\SEEN', '\\DELETED'], - mb.messages[1]['flags']) + mb.messages[1].content['flags']) self.assertEqual( 'Tue, 17 Jun 2003 11:22:16 -0600 (MDT)', - mb.messages[1]['date']) + mb.messages[1].content['date']) - self.assertEqual(open(infile).read(), mb.messages[1]['raw']) + self.assertEqual(open(infile).read(), mb.messages[1].content['raw']) def testPartialAppend(self): """ @@ -1152,11 +1150,11 @@ class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): self.assertEqual(1, len(mb.messages)) self.assertEqual( ['\\SEEN',], - mb.messages[1]['flags'] + mb.messages[1].content['flags'] ) self.assertEqual( - 'Right now', mb.messages[1]['date']) - self.assertEqual(open(infile).read(), mb.messages[1]['raw']) + 'Right now', mb.messages[1].content['date']) + self.assertEqual(open(infile).read(), mb.messages[1].content['raw']) def testCheck(self): """ @@ -1214,7 +1212,7 @@ class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): def _cbTestClose(self, ignored, m): self.assertEqual(len(m.messages), 1) self.assertEqual( - m.messages[1]['subject'], + m.messages[1].content['subject'], 'Message 2') self.failUnless(m.closed) @@ -1257,7 +1255,7 @@ class LeapIMAP4ServerTestCase(IMAP4HelperMixin, unittest.TestCase): def _cbTestExpunge(self, ignored, m): self.assertEqual(len(m.messages), 1) self.assertEqual( - m.messages[1]['subject'], + m.messages[1].content['subject'], 'Message 2') self.assertEqual(self.results, [0, 1]) # XXX fix this thing with the indexes... diff --git a/mail/src/leap/mail/smtp/gateway.py b/mail/src/leap/mail/smtp/gateway.py index f09ee141..a78bd55b 100644 --- a/mail/src/leap/mail/smtp/gateway.py +++ b/mail/src/leap/mail/smtp/gateway.py @@ -333,6 +333,8 @@ class EncryptedMessage(object): """ implements(smtp.IMessage) + FOOTER_STRING = "I prefer encrypted email" + def __init__(self, fromAddress, user, keymanager, host, port, cert, key): """ Initialize the encrypted message. @@ -597,7 +599,16 @@ class EncryptedMessage(object): self._msg = self._origmsg return + # add a nice footer to the outgoing message from_address = validate_address(self._fromAddress.addrstr) + username, domain = from_address.split('@') + self.lines.append('--') + self.lines.append('%s - https://%s/key/%s.' % + (self.FOOTER_STRING, domain, username)) + self.lines.append('') + self._origmsg = self.parseMessage() + + # get sender and recipient data signkey = self._km.get_key(from_address, OpenPGPKey, private=True) log.msg("Will sign the message with %s." % signkey.fingerprint) to_address = validate_address(self._user.dest.addrstr) @@ -672,6 +683,7 @@ class EncryptedMessage(object): username, domain = signkey.address.split('@') newmsg.add_header( 'OpenPGP', 'id=%s' % signkey.key_id, - url='https://%s/openpgp/%s' % (domain, username)) + url='https://%s/key/%s' % (domain, username), + preference='signencrypt') # delete user-agent from origmsg del(origmsg['user-agent']) diff --git a/mail/src/leap/mail/smtp/tests/__init__.py b/mail/src/leap/mail/smtp/tests/__init__.py index 62b015f0..1459cea6 100644 --- a/mail/src/leap/mail/smtp/tests/__init__.py +++ b/mail/src/leap/mail/smtp/tests/__init__.py @@ -115,8 +115,8 @@ class TestCaseWithKeyManager(BaseLeapTest): 'username': address, 'password': '<password>', 'encrypted_only': True, - 'cert': 'src/leap/mail/smtp/tests/cert/server.crt', - 'key': 'src/leap/mail/smtp/tests/cert/server.key', + 'cert': u'src/leap/mail/smtp/tests/cert/server.crt', + 'key': u'src/leap/mail/smtp/tests/cert/server.key', } class Response(object): diff --git a/mail/src/leap/mail/smtp/tests/test_gateway.py b/mail/src/leap/mail/smtp/tests/test_gateway.py index f9ea027c..4c2f04f4 100644 --- a/mail/src/leap/mail/smtp/tests/test_gateway.py +++ b/mail/src/leap/mail/smtp/tests/test_gateway.py @@ -22,7 +22,6 @@ SMTP gateway tests. import re - from datetime import datetime from gnupg._util import _make_binary_stream from twisted.test import proto_helpers @@ -131,6 +130,9 @@ class TestSmtpGateway(TestCaseWithKeyManager): for line in self.EMAIL_DATA[4:12]: m.lineReceived(line) m.eomReceived() + # we need to call the following explicitelly because it was deferred + # inside the previous method + m._maybe_encrypt_and_sign() # assert structure of encrypted message self.assertTrue('Content-Type' in m._msg) self.assertEqual('multipart/encrypted', m._msg.get_content_type()) @@ -146,7 +148,8 @@ class TestSmtpGateway(TestCaseWithKeyManager): decrypted = self._km.decrypt( m._msg.get_payload(1).get_payload(), privkey) self.assertEqual( - '\n' + '\r\n'.join(self.EMAIL_DATA[9:12]) + '\r\n', + '\n' + '\r\n'.join(self.EMAIL_DATA[9:12]) + '\r\n\r\n--\r\n' + + 'I prefer encrypted email - https://leap.se/key/anotheruser.\r\n', decrypted, 'Decrypted text differs from plaintext.') @@ -168,6 +171,9 @@ class TestSmtpGateway(TestCaseWithKeyManager): m.lineReceived(line) # trigger encryption and signing m.eomReceived() + # we need to call the following explicitelly because it was deferred + # inside the previous method + m._maybe_encrypt_and_sign() # assert structure of encrypted message self.assertTrue('Content-Type' in m._msg) self.assertEqual('multipart/encrypted', m._msg.get_content_type()) @@ -185,7 +191,8 @@ class TestSmtpGateway(TestCaseWithKeyManager): decrypted = self._km.decrypt( m._msg.get_payload(1).get_payload(), privkey, verify=pubkey) self.assertEqual( - '\n' + '\r\n'.join(self.EMAIL_DATA[9:12]) + '\r\n', + '\n' + '\r\n'.join(self.EMAIL_DATA[9:12]) + '\r\n\r\n--\r\n' + + 'I prefer encrypted email - https://leap.se/key/anotheruser.\r\n', decrypted, 'Decrypted text differs from plaintext.') @@ -208,6 +215,9 @@ class TestSmtpGateway(TestCaseWithKeyManager): m.lineReceived(line) # trigger signing m.eomReceived() + # we need to call the following explicitelly because it was deferred + # inside the previous method + m._maybe_encrypt_and_sign() # assert structure of signed message self.assertTrue('Content-Type' in m._msg) self.assertEqual('multipart/signed', m._msg.get_content_type()) @@ -216,8 +226,9 @@ class TestSmtpGateway(TestCaseWithKeyManager): self.assertEqual('pgp-sha512', m._msg.get_param('micalg')) # assert content of message self.assertEqual( - m._msg.get_payload(0).get_payload(decode=True), - '\r\n'.join(self.EMAIL_DATA[9:13])) + '\r\n'.join(self.EMAIL_DATA[9:13])+'\r\n--\r\n' + + 'I prefer encrypted email - https://leap.se/key/anotheruser.\r\n', + m._msg.get_payload(0).get_payload(decode=True)) # assert content of signature self.assertTrue( m._msg.get_payload(1).get_payload().startswith( |