diff options
author | Kali Kaneko <kali@leap.se> | 2016-04-18 11:53:18 -0400 |
---|---|---|
committer | Kali Kaneko <kali@leap.se> | 2016-04-18 11:53:18 -0400 |
commit | 6db937b20828bc39ea13836e8a70c056affa593e (patch) | |
tree | fea16d104b10dfc993079b763e7bbc64da37ed81 /src/leap/mail/smtp | |
parent | 9ba85bcc7724f1d9abc3ae200326e5f0a8597374 (diff) | |
parent | d6f260f85f8464c6db6b9e158ecc85cfc02761ac (diff) |
Merge tag '0.4.1'
Tag version 0.4.1
Diffstat (limited to 'src/leap/mail/smtp')
-rw-r--r-- | src/leap/mail/smtp/__init__.py | 50 | ||||
-rw-r--r-- | src/leap/mail/smtp/bounces.py | 90 | ||||
-rw-r--r-- | src/leap/mail/smtp/gateway.py | 218 | ||||
-rw-r--r-- | src/leap/mail/smtp/tests/test_gateway.py | 91 |
4 files changed, 332 insertions, 117 deletions
diff --git a/src/leap/mail/smtp/__init__.py b/src/leap/mail/smtp/__init__.py index 7b62808..9fab70a 100644 --- a/src/leap/mail/smtp/__init__.py +++ b/src/leap/mail/smtp/__init__.py @@ -23,47 +23,35 @@ import os from twisted.internet import reactor from twisted.internet.error import CannotListenError -from leap.mail.outgoing.service import OutgoingMail from leap.common.events import emit_async, catalog + from leap.mail.smtp.gateway import SMTPFactory logger = logging.getLogger(__name__) -def setup_smtp_gateway(port, userid, keymanager, smtp_host, smtp_port, - smtp_cert, smtp_key, encrypted_only): - """ - Setup SMTP gateway to run with Twisted. +SMTP_PORT = 2013 + - This function sets up the SMTP gateway configuration and the Twisted - reactor. +def run_service(soledad_sessions, keymanager_sessions, sendmail_opts, + port=SMTP_PORT): + """ + Main entry point to run the service from the client. - :param port: The port in which to run the server. - :type port: int - :param userid: The user currently logged in - :type userid: str - :param keymanager: A Key Manager from where to get recipients' public - keys. - :type keymanager: leap.common.keymanager.KeyManager - :param smtp_host: The hostname of the remote SMTP server. - :type smtp_host: str - :param smtp_port: The port of the remote SMTP server. - :type smtp_port: int - :param smtp_cert: The client certificate for authentication. - :type smtp_cert: str - :param smtp_key: The client key for authentication. - :type smtp_key: str - :param encrypted_only: Whether the SMTP gateway should send unencrypted - mail or not. - :type encrypted_only: bool + :param soledad_sessions: a dict-like object, containing instances + of a Store (soledad instances), indexed by userid. + :param keymanager_sessions: a dict-like object, containing instances + of Keymanager, indexed by userid. + :param sendmail_opts: a dict-like object of sendmailOptions. - :returns: tuple of SMTPFactory, twisted.internet.tcp.Port + :returns: the port as returned by the reactor when starts listening, and + the factory for the protocol. + :rtype: tuple """ - # configure the use of this service with twistd - outgoing_mail = OutgoingMail( - userid, keymanager, smtp_cert, smtp_key, smtp_host, smtp_port) - factory = SMTPFactory(userid, keymanager, encrypted_only, outgoing_mail) + factory = SMTPFactory(soledad_sessions, keymanager_sessions, + sendmail_opts) + try: interface = "localhost" # don't bind just to localhost if we are running on docker since we @@ -71,8 +59,10 @@ def setup_smtp_gateway(port, userid, keymanager, smtp_host, smtp_port, if os.environ.get("LEAP_DOCKERIZED"): interface = '' + # TODO Use Endpoints instead -------------------------------- tport = reactor.listenTCP(port, factory, interface=interface) emit_async(catalog.SMTP_SERVICE_STARTED, str(port)) + return factory, tport except CannotListenError: logger.error("STMP Service failed to start: " diff --git a/src/leap/mail/smtp/bounces.py b/src/leap/mail/smtp/bounces.py new file mode 100644 index 0000000..7a4674b --- /dev/null +++ b/src/leap/mail/smtp/bounces.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# bounces.py +# Copyright (C) 2016 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/>. +""" +Deliver bounces to the user Inbox. +""" +import time +from email.message import Message +from email.utils import formatdate + +from leap.mail.constants import INBOX_NAME +from leap.mail.mail import Account + + +# TODO implement localization for this template. + +BOUNCE_TEMPLATE = """This is your local Bitmask Mail Agent running at localhost. + +I'm sorry to have to inform you that your message could not be delivered to one +or more recipients. + +The reasons I got for the error are: + +{raw_error} + +If the problem persists and it's not a network connectivity issue, you might +want to contact your provider ({provider}) with this information (remove any +sensitive data before). + +--- Original message (*before* it was encrypted by bitmask) below ----: + +{orig}""" + + +class Bouncer(object): + """ + Implements a mechanism to deliver bounces to user inbox. + """ + # TODO this should follow RFC 6522, and compose a correct multipart + # attaching the report and the original message. Leaving this for a future + # iteration. + + def __init__(self, inbox_collection): + self._inbox_collection = inbox_collection + + def bounce_message(self, error_data, to, date=None, orig=''): + if not date: + date = formatdate(time.time()) + + raw_data = self._format_msg(error_data, to, date, orig) + d = self._inbox_collection.add_msg( + raw_data, ('\\Recent',), date=date) + return d + + def _format_msg(self, error_data, to, date, orig): + provider = to.split('@')[1] + + msg = Message() + msg.add_header( + 'From', 'bitmask-bouncer@localhost (Bitmask Local Agent)') + msg.add_header('To', to) + msg.add_header('Subject', 'Undelivered Message') + msg.add_header('Date', date) + msg.set_payload(BOUNCE_TEMPLATE.format( + raw_error=error_data, + provider=provider, + orig=orig)) + + return msg.as_string() + + +def bouncerFactory(soledad): + user_id = soledad.uuid + acc = Account(soledad, user_id) + d = acc.callWhenReady(lambda _: acc.get_collection_by_mailbox(INBOX_NAME)) + d.addCallback(lambda inbox: Bouncer(inbox)) + return d diff --git a/src/leap/mail/smtp/gateway.py b/src/leap/mail/smtp/gateway.py index 45560bf..bd0be6f 100644 --- a/src/leap/mail/smtp/gateway.py +++ b/src/leap/mail/smtp/gateway.py @@ -29,19 +29,27 @@ The following classes comprise the SMTP gateway service: * EncryptedMessage - An implementation of twisted.mail.smtp.IMessage that knows how to encrypt/sign itself before sending. """ +from email.Header import Header from zope.interface import implements +from zope.interface import implementer + +from twisted.cred.portal import Portal, IRealm from twisted.mail import smtp -from twisted.internet.protocol import ServerFactory +from twisted.mail.imap4 import LOGINCredentials, PLAINCredentials +from twisted.internet import defer, protocol from twisted.python import log -from email.Header import Header from leap.common.check import leap_assert_type from leap.common.events import emit_async, catalog -from leap.keymanager.openpgp import OpenPGPKey -from leap.keymanager.errors import KeyNotFound +from leap.mail import errors +from leap.mail.cred import LocalSoledadTokenChecker from leap.mail.utils import validate_address from leap.mail.rfc3156 import RFC3156CompliantGenerator +from leap.mail.outgoing.service import outgoingFactory +from leap.mail.smtp.bounces import bouncerFactory +from leap.keymanager.openpgp import OpenPGPKey +from leap.keymanager.errors import KeyNotFound # replace email generator with a RFC 3156 compliant one. from email import generator @@ -49,84 +57,176 @@ from email import generator generator.Generator = RFC3156CompliantGenerator -# -# Helper utilities -# - LOCAL_FQDN = "bitmask.local" -class SMTPHeloLocalhost(smtp.SMTP): +@implementer(IRealm) +class LocalSMTPRealm(object): + + _encoding = 'utf-8' + + def __init__(self, keymanager_sessions, soledad_sessions, sendmail_opts, + encrypted_only=False): + """ + :param keymanager_sessions: a dict-like object, containing instances + of a Keymanager objects, indexed by + userid. + """ + self._keymanager_sessions = keymanager_sessions + self._soledad_sessions = soledad_sessions + self._sendmail_opts = sendmail_opts + self.encrypted_only = encrypted_only + + def requestAvatar(self, avatarId, mind, *interfaces): + + if isinstance(avatarId, str): + avatarId = avatarId.decode(self._encoding) + + def gotKeymanagerAndSoledad(result): + keymanager, soledad = result + d = bouncerFactory(soledad) + d.addCallback(lambda bouncer: (keymanager, soledad, bouncer)) + return d + + def getMessageDelivery(result): + keymanager, soledad, bouncer = result + # TODO use IMessageDeliveryFactory instead ? + # it could reuse the connections. + if smtp.IMessageDelivery in interfaces: + userid = avatarId + opts = self.getSendingOpts(userid) + + outgoing = outgoingFactory( + userid, keymanager, opts, bouncer=bouncer) + avatar = SMTPDelivery(userid, keymanager, self.encrypted_only, + outgoing) + + return (smtp.IMessageDelivery, avatar, + getattr(avatar, 'logout', lambda: None)) + + raise NotImplementedError(self, interfaces) + + d1 = self.lookupKeymanagerInstance(avatarId) + d2 = self.lookupSoledadInstance(avatarId) + d = defer.gatherResults([d1, d2]) + d.addCallback(gotKeymanagerAndSoledad) + d.addCallback(getMessageDelivery) + return d + + def lookupKeymanagerInstance(self, userid): + print 'getting KM INSTNACE>>>' + try: + keymanager = self._keymanager_sessions[userid] + except: + raise errors.AuthenticationError( + 'No keymanager session found for user %s. Is it authenticated?' + % userid) + # XXX this should return the instance after whenReady callback + return defer.succeed(keymanager) + + def lookupSoledadInstance(self, userid): + try: + soledad = self._soledad_sessions[userid] + except: + raise errors.AuthenticationError( + 'No soledad session found for user %s. Is it authenticated?' + % userid) + # XXX this should return the instance after whenReady callback + return defer.succeed(soledad) + + def getSendingOpts(self, userid): + try: + opts = self._sendmail_opts[userid] + except KeyError: + raise errors.ConfigurationError( + 'No sendingMail options found for user %s' % userid) + return opts + + +class SMTPTokenChecker(LocalSoledadTokenChecker): + """A credentials checker that will lookup a token for the SMTP service. + For now it will be using the same identifier than IMAPTokenChecker""" + + service = 'mail_auth' + + # TODO besides checking for token credential, + # we could also verify the certificate here. + + +class LEAPInitMixin(object): + + """ + A Mixin that takes care of initialization of all the data needed to access + LEAP sessions. """ - An SMTP class that ensures a proper FQDN - for localhost. + def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts, + encrypted_only=False): + realm = LocalSMTPRealm( + keymanager_sessions, soledad_sessions, sendmail_opts, + encrypted_only) + portal = Portal(realm) + + checker = SMTPTokenChecker(soledad_sessions) + self.checker = checker + self.portal = portal + portal.registerChecker(checker) - This avoids a problem in which unproperly configured providers - would complain about the helo not being a fqdn. + +class LocalSMTPServer(smtp.ESMTP, LEAPInitMixin): + """ + The Production ESMTP Server: Authentication Needed. + Authenticates against SMTP Token stored in Local Soledad instance. + The Realm will produce a Delivery Object that handles encryption/signing. """ - def __init__(self, *args): - smtp.SMTP.__init__(self, *args) - self.host = LOCAL_FQDN + # TODO: implement Queue using twisted.mail.mail.MailService + + def __init__(self, soledads, keyms, sendmailopts, *args, **kw): + encrypted_only = kw.pop('encrypted_only', False) + + LEAPInitMixin.__init__(self, soledads, keyms, sendmailopts, + encrypted_only) + smtp.ESMTP.__init__(self, *args, **kw) -class SMTPFactory(ServerFactory): +# TODO implement retries -- see smtp.SenderMixin +class SMTPFactory(protocol.ServerFactory): """ Factory for an SMTP server with encrypted gatewaying capabilities. """ + + protocol = LocalSMTPServer domain = LOCAL_FQDN + timeout = 600 + encrypted_only = False - def __init__(self, userid, keymanager, encrypted_only, outgoing_mail): - """ - Initialize the SMTP factory. + def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts, + deferred=None, retries=3): - :param userid: The user currently logged in - :type userid: unicode - :param keymanager: A Key Manager from where to get recipients' public - keys. - :param encrypted_only: Whether the SMTP gateway should send unencrypted - mail or not. - :type encrypted_only: bool - :param outgoing_mail: The outgoing mail to send the message - :type outgoing_mail: leap.mail.outgoing.service.OutgoingMail - """ - - leap_assert_type(encrypted_only, bool) - # and store them - self._userid = userid - self._km = keymanager - self._outgoing_mail = outgoing_mail - self._encrypted_only = encrypted_only + self._soledad_sessions = soledad_sessions + self._keymanager_sessions = keymanager_sessions + self._sendmail_opts = sendmail_opts 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 = SMTPHeloLocalhost( - SMTPDelivery( - self._userid, self._km, self._encrypted_only, - self._outgoing_mail)) - smtpProtocol.factory = self - return smtpProtocol + p = self.protocol( + self._soledad_sessions, self._keymanager_sessions, + self._sendmail_opts, encrypted_only=self.encrypted_only) + p.factory = self + p.host = LOCAL_FQDN + p.challengers = {"LOGIN": LOGINCredentials, "PLAIN": PLAINCredentials} + return p # # SMTPDelivery # +@implementer(smtp.IMessageDelivery) class SMTPDelivery(object): """ Validate email addresses and handle message delivery. """ - implements(smtp.IMessageDelivery) - def __init__(self, userid, keymanager, encrypted_only, outgoing_mail): """ Initialize the SMTP delivery object. @@ -202,20 +302,21 @@ class SMTPDelivery(object): def found(_): log.msg("Accepting mail for %s..." % user.dest.addrstr) emit_async(catalog.SMTP_RECIPIENT_ACCEPTED_ENCRYPTED, - user.dest.addrstr) + self._userid, user.dest.addrstr) def not_found(failure): failure.trap(KeyNotFound) # if key was not found, check config to see if will send anyway if self._encrypted_only: - emit_async(catalog.SMTP_RECIPIENT_REJECTED, user.dest.addrstr) + emit_async(catalog.SMTP_RECIPIENT_REJECTED, self._userid, + user.dest.addrstr) raise smtp.SMTPBadRcpt(user.dest.addrstr) log.msg("Warning: will send an unencrypted message (because " "encrypted_only' is set to False).") emit_async( catalog.SMTP_RECIPIENT_ACCEPTED_UNENCRYPTED, - user.dest.addrstr) + self._userid, user.dest.addrstr) def encrypt_func(_): return lambda: EncryptedMessage(user, self._outgoing_mail) @@ -307,7 +408,8 @@ class EncryptedMessage(object): """ log.msg("Connection lost unexpectedly!") log.err() - emit_async(catalog.SMTP_CONNECTION_LOST, self._user.dest.addrstr) + emit_async(catalog.SMTP_CONNECTION_LOST, self._userid, + self._user.dest.addrstr) # unexpected loss of connection; don't save self._lines = [] diff --git a/src/leap/mail/smtp/tests/test_gateway.py b/src/leap/mail/smtp/tests/test_gateway.py index 0b9a364..df83cf0 100644 --- a/src/leap/mail/smtp/tests/test_gateway.py +++ b/src/leap/mail/smtp/tests/test_gateway.py @@ -15,7 +15,6 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. - """ SMTP gateway tests. """ @@ -23,19 +22,18 @@ SMTP gateway tests. import re from datetime import datetime +from twisted.mail import smtp from twisted.internet import reactor from twisted.internet.defer import inlineCallbacks, fail, succeed, Deferred from twisted.test import proto_helpers from mock import Mock -from leap.mail.smtp.gateway import ( - SMTPFactory -) -from leap.mail.tests import ( - TestCaseWithKeyManager, - ADDRESS, - ADDRESS_2, -) +from leap.mail.smtp.gateway import SMTPFactory, LOCAL_FQDN +from leap.mail.smtp.gateway import SMTPDelivery + +from leap.mail.outgoing.service import outgoingFactory +from leap.mail.tests import TestCaseWithKeyManager +from leap.mail.tests import ADDRESS, ADDRESS_2 from leap.keymanager import openpgp, errors @@ -46,6 +44,52 @@ HOSTNAME_REGEX = "(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*" + \ "([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])" IP_OR_HOST_REGEX = '(' + IP_REGEX + '|' + HOSTNAME_REGEX + ')' +TEST_USER = u'anotheruser@leap.se' + + +def getSMTPFactory(soledad_s, keymanager_s, sendmail_opts, + encrypted_only=False): + factory = UnauthenticatedSMTPFactory + factory.encrypted_only = encrypted_only + proto = factory( + soledad_s, keymanager_s, sendmail_opts).buildProtocol(('127.0.0.1', 0)) + return proto + + +class UnauthenticatedSMTPServer(smtp.SMTP): + + encrypted_only = False + + def __init__(self, soledads, keyms, opts, encrypted_only=False): + smtp.SMTP.__init__(self) + + userid = TEST_USER + keym = keyms[userid] + + class Opts: + cert = '/tmp/cert' + key = '/tmp/cert' + hostname = 'remote' + port = 666 + + outgoing = outgoingFactory( + userid, keym, Opts, check_cert=False) + avatar = SMTPDelivery(userid, keym, encrypted_only, outgoing) + self.delivery = avatar + + def validateFrom(self, helo, origin): + return origin + + +class UnauthenticatedSMTPFactory(SMTPFactory): + """ + A Factory that produces a SMTP server that does not authenticate user. + Only for tests! + """ + protocol = UnauthenticatedSMTPServer + domain = LOCAL_FQDN + encrypted_only = False + class TestSmtpGateway(TestCaseWithKeyManager): @@ -85,14 +129,8 @@ class TestSmtpGateway(TestCaseWithKeyManager): '250 Recipient address accepted', '354 Continue'] - # XXX this bit can be refactored away in a helper - # method... - proto = SMTPFactory( - u'anotheruser@leap.se', - self._km, - self._config['encrypted_only'], - outgoing_mail=Mock()).buildProtocol(('127.0.0.1', 0)) - # snip... + user = TEST_USER + proto = getSMTPFactory({user: None}, {user: self._km}, {user: None}) transport = proto_helpers.StringTransport() proto.makeConnection(transport) reply = "" @@ -116,12 +154,10 @@ class TestSmtpGateway(TestCaseWithKeyManager): # mock the key fetching self._km._fetch_keys_from_server = Mock( return_value=fail(errors.KeyNotFound())) - # prepare the SMTP factory - proto = SMTPFactory( - u'anotheruser@leap.se', - self._km, - self._config['encrypted_only'], - outgoing_mail=Mock()).buildProtocol(('127.0.0.1', 0)) + user = TEST_USER + proto = getSMTPFactory( + {user: None}, {user: self._km}, {user: None}, + encrypted_only=True) transport = proto_helpers.StringTransport() proto.makeConnection(transport) yield self.getReply(self.EMAIL_DATA[0] + '\r\n', proto, transport) @@ -132,7 +168,7 @@ class TestSmtpGateway(TestCaseWithKeyManager): self.assertEqual( '550 Cannot receive for specified address\r\n', reply, - 'Address should have been rejecetd with appropriate message.') + 'Address should have been rejected with appropriate message.') proto.setTimeout(None) @inlineCallbacks @@ -149,11 +185,8 @@ class TestSmtpGateway(TestCaseWithKeyManager): # mock the key fetching self._km._fetch_keys_from_server = Mock( return_value=fail(errors.KeyNotFound())) - # prepare the SMTP factory with encrypted only equal to false - proto = SMTPFactory( - u'anotheruser@leap.se', - self._km, - False, outgoing_mail=Mock()).buildProtocol(('127.0.0.1', 0)) + user = TEST_USER + proto = getSMTPFactory({user: None}, {user: self._km}, {user: None}) transport = proto_helpers.StringTransport() proto.makeConnection(transport) yield self.getReply(self.EMAIL_DATA[0] + '\r\n', proto, transport) |