diff options
| author | Tomás Touceda <chiiph@leap.se> | 2013-11-11 12:07:53 -0300 | 
|---|---|---|
| committer | Tomás Touceda <chiiph@leap.se> | 2013-11-11 12:07:53 -0300 | 
| commit | 9d89cc58ad8ddd10a198b168cf1005f41e93ca58 (patch) | |
| tree | 7ab1b60885b460ba9b824aad42a147b5619c05aa /src/leap/mail/smtp/gateway.py | |
| parent | 9112713112a05f0de480e43ff5ebc2a17e95b45e (diff) | |
| parent | 275c435ee45463ed6011f8988d5c7f90cb42fd01 (diff) | |
Merge remote-tracking branch 'drebs/feature/4324_prevent-double-encryption-when-relaying-2' into develop
Diffstat (limited to 'src/leap/mail/smtp/gateway.py')
| -rw-r--r-- | src/leap/mail/smtp/gateway.py | 585 | 
1 files changed, 585 insertions, 0 deletions
| diff --git a/src/leap/mail/smtp/gateway.py b/src/leap/mail/smtp/gateway.py new file mode 100644 index 0000000..06405b4 --- /dev/null +++ b/src/leap/mail/smtp/gateway.py @@ -0,0 +1,585 @@ +# -*- coding: utf-8 -*- +# gateway.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 <http://www.gnu.org/licenses/>. + +""" +LEAP SMTP encrypted gateway. + +The following classes comprise the SMTP gateway service: + +    * SMTPFactory - A twisted.internet.protocol.ServerFactory that provides +      the SMTPDelivery protocol. +    * SMTPDelivery - A twisted.mail.smtp.IMessageDelivery implementation. It +      knows how to validate sender and receiver of messages and it generates +      an EncryptedMessage for each recipient. +    * SSLContextFactory - Contains the relevant ssl information for the +      connection. +    * EncryptedMessage - An implementation of twisted.mail.smtp.IMessage that +      knows how to encrypt/sign itself before sending. + + +""" + +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 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 + + +# +# Helper utilities +# + +def validate_address(address): +    """ +    Validate C{address} as defined in RFC 2822. + +    :param address: The address to be validated. +    :type address: str + +    @return: A valid address. +    @rtype: str + +    @raise smtp.SMTPBadRcpt: Raised if C{address} is invalid. +    """ +    leap_assert_type(address, str) +    # in the following, the address is parsed as described in RFC 2822 and +    # ('', '') is returned if the parse fails. +    _, address = parseaddr(address) +    if address == '': +        raise smtp.SMTPBadRcpt(address) +    return address + + +# +# SMTPFactory +# + +class SMTPFactory(ServerFactory): +    """ +    Factory for an SMTP server with encrypted gatewaying capabilities. +    """ + +    def __init__(self, userid, keymanager, host, port, cert, key, +                 encrypted_only): +        """ +        Initialize the SMTP factory. + +        :param userid: The user currently logged in +        :type userid: unicode +        :param keymanager: A KeyManager for retrieving recipient's keys. +        :type keymanager: leap.common.keymanager.KeyManager +        :param host: The hostname of the remote SMTP server. +        :type host: str +        :param port: The port of the remote SMTP server. +        :type port: int +        :param cert: The client certificate for authentication. +        :type cert: str +        :param key: The client key for authentication. +        :type key: str +        :param encrypted_only: Whether the SMTP gateway should send unencrypted +                               mail or not. +        :type encrypted_only: bool +        """ +        # assert params +        leap_assert_type(keymanager, KeyManager) +        leap_assert_type(host, str) +        leap_assert(host != '') +        leap_assert_type(port, int) +        leap_assert(port is not 0) +        leap_assert_type(cert, unicode) +        leap_assert(cert != '') +        leap_assert_type(key, unicode) +        leap_assert(key != '') +        leap_assert_type(encrypted_only, bool) +        # and store them +        self._userid = userid +        self._km = keymanager +        self._host = host +        self._port = port +        self._cert = cert +        self._key = key +        self._encrypted_only = encrypted_only + +    def buildProtocol(self, addr): +        """ +        Return a protocol suitable for the job. + +        :param addr: An address, e.g. a TCP (host, port). +        :type addr:  twisted.internet.interfaces.IAddress + +        @return: The protocol. +        @rtype: SMTPDelivery +        """ +        smtpProtocol = smtp.SMTP(SMTPDelivery( +            self._userid, self._km, self._host, self._port, self._cert, +            self._key, self._encrypted_only)) +        smtpProtocol.factory = self +        return smtpProtocol + + +# +# SMTPDelivery +# + +class SMTPDelivery(object): +    """ +    Validate email addresses and handle message delivery. +    """ + +    implements(smtp.IMessageDelivery) + +    def __init__(self, userid, keymanager, host, port, cert, key, +                 encrypted_only): +        """ +        Initialize the SMTP delivery object. + +        :param userid: The user currently logged in +        :type userid: unicode +        :param keymanager: A KeyManager for retrieving recipient's keys. +        :type keymanager: leap.common.keymanager.KeyManager +        :param host: The hostname of the remote SMTP server. +        :type host: str +        :param port: The port of the remote SMTP server. +        :type port: int +        :param cert: The client certificate for authentication. +        :type cert: str +        :param key: The client key for authentication. +        :type key: str +        :param encrypted_only: Whether the SMTP gateway should send unencrypted +                               mail or not. +        :type encrypted_only: bool +        """ +        self._userid = userid +        self._km = keymanager +        self._userid = userid +        self._km = keymanager +        self._host = host +        self._port = port +        self._cert = cert +        self._key = key +        self._encrypted_only = encrypted_only +        self._origin = None + +    def receivedHeader(self, helo, origin, recipients): +        """ +        Generate the 'Received:' header for a message. + +        :param helo: The argument to the HELO command and the client's IP +            address. +        :type helo: (str, str) +        :param origin: The address the message is from. +        :type origin: twisted.mail.smtp.Address +        :param recipients: A list of the addresses for which this message is +            bound. +        :type: list of twisted.mail.smtp.User + +        @return: The full "Received" header string. +        :type: str +        """ +        myHostname, clientIP = helo +        headerValue = "by %s from %s with ESMTP ; %s" % ( +            myHostname, clientIP, smtp.rfc822date()) +        # email.Header.Header used for automatic wrapping of long lines +        return "Received: %s" % Header(headerValue) + +    def validateTo(self, user): +        """ +        Validate the address of C{user}, a recipient of the message. + +        This method is called once for each recipient and validates the +        C{user}'s address against the RFC 2822 definition. If the +        configuration option ENCRYPTED_ONLY_KEY is True, it also asserts the +        existence of the user's key. + +        In the end, it returns an encrypted message object that is able to +        send itself to the C{user}'s address. + +        :param user: The user whose address we wish to validate. +        :type: twisted.mail.smtp.User + +        @return: A Deferred which becomes, or a callable which takes no +            arguments and returns an object implementing IMessage. This will +            be called and the returned object used to deliver the message when +            it arrives. +        @rtype: no-argument callable + +        @raise SMTPBadRcpt: Raised if messages to the address are not to be +            accepted. +        """ +        # try to find recipient's public key +        try: +            address = validate_address(user.dest.addrstr) +            # verify if recipient key is available in keyring +            self._km.get_key(address, OpenPGPKey)  # might raise KeyNotFound +            log.msg("Accepting mail for %s..." % user.dest.addrstr) +            signal(proto.SMTP_RECIPIENT_ACCEPTED_ENCRYPTED, user.dest.addrstr) +        except KeyNotFound: +            # if key was not found, check config to see if will send anyway. +            if self._encrypted_only: +                signal(proto.SMTP_RECIPIENT_REJECTED, user.dest.addrstr) +                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) +        return lambda: EncryptedMessage( +            self._origin, user, self._km, self._host, self._port, self._cert, +            self._key) + +    def validateFrom(self, helo, origin): +        """ +        Validate the address from which the message originates. + +        :param helo: The argument to the HELO command and the client's IP +            address. +        :type: (str, str) +        :param origin: The address the message is from. +        :type origin: twisted.mail.smtp.Address + +        @return: origin or a Deferred whose callback will be passed origin. +        @rtype: Deferred or Address + +        @raise twisted.mail.smtp.SMTPBadSender: Raised if messages from this +            address are not to be accepted. +        """ +        # accept mail from anywhere. To reject an address, raise +        # smtp.SMTPBadSender here. +        if str(origin) != str(self._userid): +            log.msg("Rejecting sender {0}, expected {1}".format(origin, +                                                                self._userid)) +            raise smtp.SMTPBadSender(origin) +        self._origin = origin +        return origin + + +# +# EncryptedMessage +# + +class SSLContextFactory(ssl.ClientContextFactory): +    def __init__(self, cert, key): +        self.cert = cert +        self.key = key + +    def getContext(self): +        self.method = SSL.TLSv1_METHOD  # SSLv23_METHOD +        ctx = ssl.ClientContextFactory.getContext(self) +        ctx.use_certificate_file(self.cert) +        ctx.use_privatekey_file(self.key) +        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 +    recipient. +    """ +    implements(smtp.IMessage) + +    def __init__(self, fromAddress, user, keymanager, host, port, cert, key): +        """ +        Initialize the encrypted message. + +        :param fromAddress: The address of the sender. +        :type fromAddress: twisted.mail.smtp.Address +        :param user: The recipient of this message. +        :type user: twisted.mail.smtp.User +        :param keymanager: A KeyManager for retrieving recipient's keys. +        :type keymanager: leap.common.keymanager.KeyManager +        :param host: The hostname of the remote SMTP server. +        :type host: str +        :param port: The port of the remote SMTP server. +        :type port: int +        :param cert: The client certificate for authentication. +        :type cert: str +        :param key: The client key for authentication. +        :type key: str +        """ +        # assert params +        leap_assert_type(user, smtp.User) +        leap_assert_type(keymanager, KeyManager) +        # and store them +        self._fromAddress = fromAddress +        self._user = user +        self._km = keymanager +        self._host = host +        self._port = port +        self._cert = cert +        self._key = key +        # initialize list for message's lines +        self.lines = [] + +    # +    # methods from smtp.IMessage +    # + +    def lineReceived(self, line): +        """ +        Handle another line. + +        :param line: The received line. +        :type line: str +        """ +        self.lines.append(line) + +    def eomReceived(self): +        """ +        Handle end of message. + +        This method will encrypt and send the message. +        """ +        log.msg("Message data complete.") +        self.lines.append('')  # add a trailing newline +        try: +            self._maybe_encrypt_and_sign() +            return self.sendMessage() +        except KeyNotFound: +            return None + +    def parseMessage(self): +        """ +        Separate message headers from body. +        """ +        parser = Parser() +        return parser.parsestr('\r\n'.join(self.lines)) + +    def connectionLost(self): +        """ +        Log an error when the connection is lost. +        """ +        log.msg("Connection lost unexpectedly!") +        log.err() +        signal(proto.SMTP_CONNECTION_LOST, self._user.dest.addrstr) +        # unexpected loss of connection; don't save +        self.lines = [] + +    def sendSuccess(self, r): +        """ +        Callback for a successful send. + +        :param r: The result from the last previous callback in the chain. +        :type r: anything +        """ +        log.msg(r) +        signal(proto.SMTP_SEND_MESSAGE_SUCCESS, self._user.dest.addrstr) + +    def sendError(self, e): +        """ +        Callback for an unsuccessfull send. + +        :param e: The result from the last errback. +        :type e: anything +        """ +        log.msg(e) +        log.err() +        signal(proto.SMTP_SEND_MESSAGE_ERROR, self._user.dest.addrstr) + +    def sendMessage(self): +        """ +        Send the message. + +        This method will prepare the message (headers and possibly encrypted +        body) and send it using the ESMTPSenderFactory. + +        @return: A deferred with callbacks for error and success of this +            message send. +        @rtype: twisted.internet.defer.Deferred +        """ +        msg = self._msg.as_string(False) + +        log.msg("Connecting to SMTP server %s:%s" % (self._host, self._port)) + +        d = defer.Deferred() +        # we don't pass an ssl context factory to the ESMTPSenderFactory +        # because ssl will be handled by reactor.connectSSL() below. +        factory = smtp.ESMTPSenderFactory( +            "",  # username is blank because server does not use auth. +            "",  # password is blank because server does not use auth. +            self._fromAddress.addrstr, +            self._user.dest.addrstr, +            StringIO(msg), +            d, +            requireAuthentication=False, +            requireTransportSecurity=True) +        signal(proto.SMTP_SEND_MESSAGE_START, self._user.dest.addrstr) +        reactor.connectSSL( +            self._host, self._port, factory, +            contextFactory=SSLContextFactory(self._cert, self._key)) +        d.addCallback(self.sendSuccess) +        d.addErrback(self.sendError) +        return d + +    # +    # encryption methods +    # + +    def _encrypt_and_sign(self, pubkey, signkey): +        """ +        Create an RFC 3156 compliang PGP encrypted and signed message using +        C{pubkey} to encrypt and C{signkey} to sign. + +        :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 +        """ +        # 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(self._origmsg, newmsg) +        # create 'application/octet-stream' encrypted message +        encmsg = MIMEApplication( +            self._km.encrypt(self._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}. + +        :param signkey: The private key used to sign the message. +        :type signkey: leap.common.keymanager.openpgp.OpenPGPKey +        """ +        # 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(self._origmsg, newmsg) +        # apply base64 content-transfer-encoding +        encode_base64_rec(self._origmsg) +        # get message text with headers and replace \n for \r\n +        fp = StringIO() +        g = RFC3156CompliantGenerator( +            fp, mangle_from_=False, maxheaderlen=76) +        g.flatten(self._origmsg) +        msgtext = re.sub('\r?\n', '\r\n', fp.getvalue()) +        # make sure signed message ends with \r\n as per OpenPGP stantard. +        if self._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(self._origmsg) +        newmsg.attach(sigmsg) +        self._msg = newmsg + +    def _maybe_encrypt_and_sign(self): +        """ +        Attempt to encrypt and sign the outgoing message. + +        The behaviour of this method depends on: + +            1. the original message's content-type, and +            2. the availability of the recipient's public key. + +        If the original message's content-type is "multipart/encrypted", then +        the original message is not altered. For any other content-type, the +        method attempts to fetch the recipient's public key. If the +        recipient's public key is available, the message is encrypted and +        signed; otherwise it is only signed. + +        Note that, if the C{encrypted_only} configuration is set to True and +        the recipient's public key is not available, then the recipient +        address would have been rejected in SMTPDelivery.validateTo(). + +        The following table summarizes the overall behaviour of the gateway: + +        +---------------------------------------------------+----------------+ +        | content-type        | rcpt pubkey | enforce encr. | action         | +        +---------------------+-------------+---------------+----------------+ +        | multipart/encrypted | any         | any           | pass           | +        | other               | available   | any           | encrypt + sign | +        | other               | unavailable | yes           | reject         | +        | other               | unavailable | no            | sign           | +        +---------------------+-------------+---------------+----------------+ +        """ +        # pass if the original message's content-type is "multipart/encrypted" +        self._origmsg = self.parseMessage() +        if self._origmsg.get_content_type() == 'multipart/encrypted': +            self._msg = self._origmsg +            return + +        from_address = validate_address(self._fromAddress.addrstr) +        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) +        try: +            # try to get the recipient pubkey +            pubkey = self._km.get_key(to_address, OpenPGPKey) +            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(pubkey, signkey) +            signal(proto.SMTP_END_ENCRYPT_AND_SIGN, +                   "%s,%s" % (self._fromAddress.addrstr, to_address)) +        except KeyNotFound: +            # at this point we _can_ send unencrypted mail, because if the +            # configuration said the opposite the address would have been +            # rejected in SMTPDelivery.validateTo(). +            log.msg('Will send unencrypted message to %s.' % to_address) +            signal(proto.SMTP_START_SIGN, self._fromAddress.addrstr) +            self._sign(signkey) +            signal(proto.SMTP_END_SIGN, self._fromAddress.addrstr) | 
