From f65c028cf80d55d39cc03f6047458677b38b8539 Mon Sep 17 00:00:00 2001 From: drebs Date: Thu, 26 Sep 2013 13:53:27 -0300 Subject: Make SMTP relay RFC 3156 compliant. --- src/leap/mail/smtp/rfc3156.py | 379 ++++++++++++++++++++++++++++++++++++++++ src/leap/mail/smtp/smtprelay.py | 140 ++++++++++----- 2 files changed, 478 insertions(+), 41 deletions(-) create mode 100644 src/leap/mail/smtp/rfc3156.py (limited to 'src/leap') diff --git a/src/leap/mail/smtp/rfc3156.py b/src/leap/mail/smtp/rfc3156.py new file mode 100644 index 0000000..dd48475 --- /dev/null +++ b/src/leap/mail/smtp/rfc3156.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +# rfc3156.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Implements RFC 3156: MIME Security with OpenPGP. +""" + +import re +import base64 +from abc import ABCMeta, abstractmethod +from StringIO import StringIO + +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email import errors +from email.generator import ( + Generator, + fcre, + NL, + _make_boundary, +) + + +# +# A generator that solves http://bugs.python.org/issue14983 +# + +class RFC3156CompliantGenerator(Generator): + """ + An email generator that addresses Python's issue #14983 for multipart + messages. + + This is just a copy of email.generator.Generator which fixes the following + bug: http://bugs.python.org/issue14983 + """ + + def _handle_multipart(self, msg): + """ + A multipart handling implementation that addresses issue #14983. + + This is just a copy of the parent's method which fixes the following + bug: http://bugs.python.org/issue14983 (see the line marked with + "(***)"). + + :param msg: The multipart message to be handled. + :type msg: email.message.Message + """ + # The trick here is to write out each part separately, merge them all + # together, and then make sure that the boundary we've chosen isn't + # present in the payload. + msgtexts = [] + subparts = msg.get_payload() + if subparts is None: + subparts = [] + elif isinstance(subparts, basestring): + # e.g. a non-strict parse of a message with no starting boundary. + self._fp.write(subparts) + return + elif not isinstance(subparts, list): + # Scalar payload + subparts = [subparts] + for part in subparts: + s = StringIO() + g = self.clone(s) + g.flatten(part, unixfrom=False) + msgtexts.append(s.getvalue()) + # BAW: What about boundaries that are wrapped in double-quotes? + boundary = msg.get_boundary() + if not boundary: + # Create a boundary that doesn't appear in any of the + # message texts. + alltext = NL.join(msgtexts) + boundary = _make_boundary(alltext) + msg.set_boundary(boundary) + # If there's a preamble, write it out, with a trailing CRLF + if msg.preamble is not None: + preamble = msg.preamble + if self._mangle_from_: + preamble = fcre.sub('>From ', msg.preamble) + self._fp.write(preamble + '\n') + # dash-boundary transport-padding CRLF + self._fp.write('--' + boundary + '\n') + # body-part + if msgtexts: + self._fp.write(msgtexts.pop(0)) + # *encapsulation + # --> delimiter transport-padding + # --> CRLF body-part + for body_part in msgtexts: + # delimiter transport-padding CRLF + self._fp.write('\n--' + boundary + '\n') + # body-part + self._fp.write(body_part) + # close-delimiter transport-padding + self._fp.write('\n--' + boundary + '--' + '\n') # (***) Solve #14983 + if msg.epilogue is not None: + self._fp.write('\n') + epilogue = msg.epilogue + if self._mangle_from_: + epilogue = fcre.sub('>From ', msg.epilogue) + self._fp.write(epilogue) + + +# +# Base64 encoding: these are almost the same as python's email.encoder +# solution, but a bit modified. +# + +def _bencode(s): + """ + Encode C{s} in base64. + + :param s: The string to be encoded. + :type s: str + """ + # We can't quite use base64.encodestring() since it tacks on a "courtesy + # newline". Blech! + if not s: + return s + value = base64.encodestring(s) + return value[:-1] + + +def encode_base64(msg): + """ + Encode a non-multipart message's payload in Base64 (in place). + + This method modifies the message contents in place and adds or replaces an + appropriate Content-Transfer-Encoding header. + + :param msg: The non-multipart message to be encoded. + :type msg: email.message.Message + """ + orig = msg.get_payload() + encdata = _bencode(orig) + msg.set_payload(encdata) + # replace or set the Content-Transfer-Encoding header. + try: + msg.replace_header('Content-Transfer-Encoding', 'base64') + except KeyError: + msg['Content-Transfer-Encoding'] = 'base64' + + +def encode_base64_rec(msg): + """ + Encode (possibly multipart) messages in base64 (in place). + + This method modifies the message contents in place. + + :param msg: The non-multipart message to be encoded. + :type msg: email.message.Message + """ + if not msg.is_multipart(): + encode_base64(msg) + else: + for sub in msg.get_payload(): + encode_base64_rec(sub) + + +# +# RFC 1847: multipart/signed and multipart/encrypted +# + +class MultipartSigned(MIMEMultipart): + """ + Multipart/Signed MIME message according to RFC 1847. + + 2.1. Definition of Multipart/Signed + + (1) MIME type name: multipart + (2) MIME subtype name: signed + (3) Required parameters: boundary, protocol, and micalg + (4) Optional parameters: none + (5) Security considerations: Must be treated as opaque while in + transit + + The multipart/signed content type contains exactly two body parts. + The first body part is the body part over which the digital signature + was created, including its MIME headers. The second body part + contains the control information necessary to verify the digital + signature. The first body part may contain any valid MIME content + type, labeled accordingly. The second body part is labeled according + to the value of the protocol parameter. + + When the OpenPGP digital signature is generated: + + (1) The data to be signed MUST first be converted to its content- + type specific canonical form. For text/plain, this means + conversion to an appropriate character set and conversion of + line endings to the canonical sequence. + + (2) An appropriate Content-Transfer-Encoding is then applied; see + section 3. In particular, line endings in the encoded data + MUST use the canonical sequence where appropriate + (note that the canonical line ending may or may not be present + on the last line of encoded data and MUST NOT be included in + the signature if absent). + + (3) MIME content headers are then added to the body, each ending + with the canonical sequence. + + (4) As described in section 3 of this document, any trailing + whitespace MUST then be removed from the signed material. + + (5) As described in [2], the digital signature MUST be calculated + over both the data to be signed and its set of content headers. + + (6) The signature MUST be generated detached from the signed data + so that the process does not alter the signed data in any way. + """ + + def __init__(self, protocol, micalg, boundary=None, _subparts=None): + """ + Initialize the multipart/signed message. + + :param boundary: the multipart boundary string. By default it is + calculated as needed. + :type boundary: str + :param _subparts: a sequence of initial subparts for the payload. It + must be an iterable object, such as a list. You can always + attach new subparts to the message by using the attach() method. + :type _subparts: iterable + """ + MIMEMultipart.__init__( + self, _subtype='signed', boundary=boundary, + _subparts=_subparts) + self.set_param('protocol', protocol) + self.set_param('micalg', micalg) + + def attach(self, payload): + """ + Add the C{payload} to the current payload list. + + Also prevent from adding payloads with wrong Content-Type and from + exceeding a maximum of 2 payloads. + + :param payload: The payload to be attached. + :type payload: email.message.Message + """ + # second payload's content type must be equal to the protocol + # parameter given on object creation + if len(self.get_payload()) == 1: + if payload.get_content_type() != self.get_param('protocol'): + raise errors.MultipartConversionError( + 'Wrong content type %s.' % payload.get_content_type) + # prevent from adding more payloads + if len(self._payload) == 2: + raise errors.MultipartConversionError( + 'Cannot have more than two subparts.') + MIMEMultipart.attach(self, payload) + + +class MultipartEncrypted(MIMEMultipart): + """ + Multipart/encrypted MIME message according to RFC 1847. + + 2.2. Definition of Multipart/Encrypted + + (1) MIME type name: multipart + (2) MIME subtype name: encrypted + (3) Required parameters: boundary, protocol + (4) Optional parameters: none + (5) Security considerations: none + + The multipart/encrypted content type contains exactly two body parts. + The first body part contains the control information necessary to + decrypt the data in the second body part and is labeled according to + the value of the protocol parameter. The second body part contains + the data which was encrypted and is always labeled + application/octet-stream. + """ + + def __init__(self, protocol, boundary=None, _subparts=None): + """ + :param protocol: The encryption protocol to be added as a parameter to + the Content-Type header. + :type protocol: str + :param boundary: the multipart boundary string. By default it is + calculated as needed. + :type boundary: str + :param _subparts: a sequence of initial subparts for the payload. It + must be an iterable object, such as a list. You can always + attach new subparts to the message by using the attach() method. + :type _subparts: iterable + """ + MIMEMultipart.__init__( + self, _subtype='encrypted', boundary=boundary, + _subparts=_subparts) + self.set_param('protocol', protocol) + + def attach(self, payload): + """ + Add the C{payload} to the current payload list. + + Also prevent from adding payloads with wrong Content-Type and from + exceeding a maximum of 2 payloads. + + :param payload: The payload to be attached. + :type payload: email.message.Message + """ + # first payload's content type must be equal to the protocol parameter + # given on object creation + if len(self._payload) == 0: + if payload.get_content_type() != self.get_param('protocol'): + raise errors.MultipartConversionError( + 'Wrong content type.') + # second payload is always application/octet-stream + if len(self._payload) == 1: + if payload.get_content_type() != 'application/octet-stream': + raise errors.MultipartConversionError( + 'Wrong content type %s.' % payload.get_content_type) + # prevent from adding more payloads + if len(self._payload) == 2: + raise errors.MultipartConversionError( + 'Cannot have more than two subparts.') + MIMEMultipart.attach(self, payload) + + +# +# RFC 3156: application/pgp-encrypted, application/pgp-signed and +# application-pgp-signature. +# + +class PGPEncrypted(MIMEApplication): + """ + Application/pgp-encrypted MIME media type according to RFC 3156. + + * MIME media type name: application + * MIME subtype name: pgp-encrypted + * Required parameters: none + * Optional parameters: none + """ + + def __init__(self, version=1): + data = "Version: %d" % version + MIMEApplication.__init__(self, data, 'pgp-encrypted') + + +class PGPSignature(MIMEApplication): + """ + Application/pgp-signature MIME media type according to RFC 3156. + + * MIME media type name: application + * MIME subtype name: pgp-signature + * Required parameters: none + * Optional parameters: none + """ + def __init__(self, _data, name='signature.asc'): + MIMEApplication.__init__(self, _data, 'pgp-signature', + _encoder=lambda x: x, name=name) + self.add_header('Content-Description', 'OpenPGP Digital Signature') + + +class PGPKeys(MIMEApplication): + """ + Application/pgp-keys MIME media type according to RFC 3156. + + * MIME media type name: application + * MIME subtype name: pgp-keys + * Required parameters: none + * Optional parameters: none + """ + + def __init__(self, _data): + MIMEApplication.__init__(self, _data, 'pgp-keys') diff --git a/src/leap/mail/smtp/smtprelay.py b/src/leap/mail/smtp/smtprelay.py index 96eaa31..d9bbbf9 100644 --- a/src/leap/mail/smtp/smtprelay.py +++ b/src/leap/mail/smtp/smtprelay.py @@ -19,24 +19,38 @@ LEAP SMTP encrypted relay. """ -from zope.interface import implements +import re from StringIO import StringIO +from email.Header import Header +from email.utils import parseaddr +from email.parser import Parser +from email.mime.application import MIMEApplication + +from zope.interface import implements from OpenSSL import SSL from twisted.mail import smtp from twisted.internet.protocol import ServerFactory from twisted.internet import reactor, ssl from twisted.internet import defer from twisted.python import log -from email.Header import Header -from email.utils import parseaddr -from email.parser import Parser - from leap.common.check import leap_assert, leap_assert_type from leap.common.events import proto, signal from leap.keymanager import KeyManager from leap.keymanager.openpgp import OpenPGPKey from leap.keymanager.errors import KeyNotFound +from leap.mail.smtp.rfc3156 import ( + MultipartSigned, + MultipartEncrypted, + PGPEncrypted, + PGPSignature, + RFC3156CompliantGenerator, + encode_base64_rec, +) + +# replace email generator with a RFC 3156 compliant one. +from email import generator +generator.Generator = RFC3156CompliantGenerator # @@ -260,7 +274,8 @@ class SMTPDelivery(object): raise smtp.SMTPBadRcpt(user.dest.addrstr) log.msg("Warning: will send an unencrypted message (because " "encrypted_only' is set to False).") - signal(proto.SMTP_RECIPIENT_ACCEPTED_UNENCRYPTED, user.dest.addrstr) + signal( + proto.SMTP_RECIPIENT_ACCEPTED_UNENCRYPTED, user.dest.addrstr) return lambda: EncryptedMessage( self._origin, user, self._km, self._config) @@ -303,6 +318,16 @@ class CtxFactory(ssl.ClientContextFactory): return ctx +def move_headers(origmsg, newmsg): + headers = origmsg.items() + unwanted_headers = ['content-type', 'mime-version', 'content-disposition', + 'content-transfer-encoding'] + headers = filter(lambda x: x[0].lower() not in unwanted_headers, headers) + for hkey, hval in headers: + newmsg.add_header(hkey, hval) + del(origmsg[hkey]) + + class EncryptedMessage(object): """ Receive plaintext from client, encrypt it and send message to a @@ -343,6 +368,10 @@ class EncryptedMessage(object): # initialize list for message's lines self.lines = [] + # + # methods from smtp.IMessage + # + def lineReceived(self, line): """ Handle another line. @@ -360,9 +389,8 @@ class EncryptedMessage(object): """ log.msg("Message data complete.") self.lines.append('') # add a trailing newline - self.parseMessage() try: - self._encrypt_and_sign() + self._maybe_encrypt_and_sign() return self.sendMessage() except KeyNotFound: return None @@ -372,7 +400,7 @@ class EncryptedMessage(object): Separate message headers from body. """ parser = Parser() - self._message = parser.parsestr('\r\n'.join(self.lines)) + return parser.parsestr('\r\n'.join(self.lines)) def connectionLost(self): """ @@ -416,7 +444,7 @@ class EncryptedMessage(object): message send. @rtype: twisted.internet.defer.Deferred """ - msg = self._message.as_string(False) + msg = self._msg.as_string(False) log.msg("Connecting to SMTP server %s:%s" % (self._config[HOST_KEY], self._config[PORT_KEY])) @@ -442,46 +470,76 @@ class EncryptedMessage(object): d.addErrback(self.sendError) return d - def _encrypt_and_sign_payload_rec(self, message, pubkey, signkey): + # + # encryption methods + # + + def _encrypt_and_sign(self, pubkey, signkey): """ - Recursivelly descend in C{message}'s payload encrypting to C{pubkey} - and signing with C{signkey}. + Create an RFC 3156 compliang PGP encrypted and signed message using + C{pubkey} to encrypt and C{signkey} to sign. - @param message: The message whose payload we want to encrypt. - @type message: email.message.Message @param pubkey: The public key used to encrypt the message. @type pubkey: leap.common.keymanager.openpgp.OpenPGPKey @param signkey: The private key used to sign the message. @type signkey: leap.common.keymanager.openpgp.OpenPGPKey """ - if message.is_multipart() is False: - message.set_payload( - self._km.encrypt( - message.get_payload(), pubkey, sign=signkey)) - else: - for msg in message.get_payload(): - self._encrypt_and_sign_payload_rec(msg, pubkey, signkey) + # parse original message from received lines + origmsg = self.parseMessage() + # create new multipart/encrypted message with 'pgp-encrypted' protocol + newmsg = MultipartEncrypted('application/pgp-encrypted') + # move (almost) all headers from original message to the new message + move_headers(origmsg, newmsg) + # create 'application/octet-stream' encrypted message + encmsg = MIMEApplication( + self._km.encrypt(origmsg.as_string(unixfrom=False), pubkey, + sign=signkey), + _subtype='octet-stream', _encoder=lambda x: x) + encmsg.add_header('content-disposition', 'attachment', + filename='msg.asc') + # create meta message + metamsg = PGPEncrypted() + metamsg.add_header('Content-Disposition', 'attachment') + # attach pgp message parts to new message + newmsg.attach(metamsg) + newmsg.attach(encmsg) + self._msg = newmsg + + def _sign(self, signkey): + """ + Create an RFC 3156 compliant PGP signed MIME message using C{signkey}. - def _sign_payload_rec(self, message, signkey): - """ - Recursivelly descend in C{message}'s payload signing with C{signkey}. - - @param message: The message whose payload we want to encrypt. - @type message: email.message.Message - @param pubkey: The public key used to encrypt the message. - @type pubkey: leap.common.keymanager.openpgp.OpenPGPKey @param signkey: The private key used to sign the message. @type signkey: leap.common.keymanager.openpgp.OpenPGPKey """ - if message.is_multipart() is False: - message.set_payload( - self._km.sign( - message.get_payload(), signkey)) - else: - for msg in message.get_payload(): - self._sign_payload_rec(msg, signkey) - - def _encrypt_and_sign(self): + # parse original message from received lines + origmsg = self.parseMessage() + # create new multipart/signed message + newmsg = MultipartSigned('application/pgp-signature', 'pgp-sha512') + # move (almost) all headers from original message to the new message + move_headers(origmsg, newmsg) + # apply base64 content-transfer-encoding + encode_base64_rec(origmsg) + # get message text with headers and replace \n for \r\n + fp = StringIO() + g = RFC3156CompliantGenerator( + fp, mangle_from_=False, maxheaderlen=76) + g.flatten(origmsg) + msgtext = re.sub('\r?\n', '\r\n', fp.getvalue()) + # make sure signed message ends with \r\n as per OpenPGP stantard. + if origmsg.is_multipart(): + if not msgtext.endswith("\r\n"): + msgtext += "\r\n" + # calculate signature + signature = self._km.sign(msgtext, signkey, digest_algo='SHA512', + clearsign=False, detach=True, binary=False) + sigmsg = PGPSignature(signature) + # attach original message and signature to new message + newmsg.attach(origmsg) + newmsg.attach(sigmsg) + self._msg = newmsg + + def _maybe_encrypt_and_sign(self): """ Encrypt the message body. @@ -504,7 +562,7 @@ class EncryptedMessage(object): log.msg("Will encrypt the message to %s." % pubkey.fingerprint) signal(proto.SMTP_START_ENCRYPT_AND_SIGN, "%s,%s" % (self._fromAddress.addrstr, to_address)) - self._encrypt_and_sign_payload_rec(self._message, pubkey, signkey) + self._encrypt_and_sign(pubkey, signkey) signal(proto.SMTP_END_ENCRYPT_AND_SIGN, "%s,%s" % (self._fromAddress.addrstr, to_address)) except KeyNotFound: @@ -513,5 +571,5 @@ class EncryptedMessage(object): # rejected in SMTPDelivery.validateTo(). log.msg('Will send unencrypted message to %s.' % to_address) signal(proto.SMTP_START_SIGN, self._fromAddress.addrstr) - self._sign_payload_rec(self._message, signkey) + self._sign(signkey) signal(proto.SMTP_END_SIGN, self._fromAddress.addrstr) -- cgit v1.2.3