diff options
author | drebs <drebs@leap.se> | 2013-11-07 17:09:56 -0200 |
---|---|---|
committer | drebs <drebs@leap.se> | 2013-11-11 12:25:16 -0200 |
commit | 275c435ee45463ed6011f8988d5c7f90cb42fd01 (patch) | |
tree | 7ab1b60885b460ba9b824aad42a147b5619c05aa /src/leap/mail/smtp/gateway.py | |
parent | 4df74038a89dccff213c07795795154831330ace (diff) |
Change SMTP "relay" to "gateway".
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) |