summaryrefslogtreecommitdiff
path: root/src/leap/bitmask/mail/smtp/gateway.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/leap/bitmask/mail/smtp/gateway.py')
-rw-r--r--src/leap/bitmask/mail/smtp/gateway.py413
1 files changed, 413 insertions, 0 deletions
diff --git a/src/leap/bitmask/mail/smtp/gateway.py b/src/leap/bitmask/mail/smtp/gateway.py
new file mode 100644
index 00000000..e49bbe82
--- /dev/null
+++ b/src/leap/bitmask/mail/smtp/gateway.py
@@ -0,0 +1,413 @@
+# -*- 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.
+
+ * 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.mail.imap4 import LOGINCredentials, PLAINCredentials
+from twisted.internet import defer, protocol
+from twisted.python import log
+
+from leap.common.check import leap_assert_type
+from leap.common.events import emit_async, catalog
+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.errors import KeyNotFound
+
+# replace email generator with a RFC 3156 compliant one.
+from email import generator
+
+generator.Generator = RFC3156CompliantGenerator
+
+
+LOCAL_FQDN = "bitmask.local"
+
+
+@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):
+ 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.
+ """
+ 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)
+
+
+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.
+ """
+
+ # 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)
+
+
+# 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, soledad_sessions, keymanager_sessions, sendmail_opts,
+ deferred=None, retries=3):
+
+ self._soledad_sessions = soledad_sessions
+ self._keymanager_sessions = keymanager_sessions
+ self._sendmail_opts = sendmail_opts
+
+ def buildProtocol(self, addr):
+ 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.
+ """
+
+ def __init__(self, userid, keymanager, encrypted_only, outgoing_mail):
+ """
+ Initialize the SMTP delivery object.
+
+ :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
+ """
+ self._userid = userid
+ self._outgoing_mail = outgoing_mail
+ self._km = keymanager
+ 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 bitmask.local from %s with ESMTP ; %s" % (
+ clientIP, smtp.rfc822date())
+ # email.Header.Header used for automatic wrapping of long lines
+ return "Received: %s" % Header(s=headerValue, header_name='Received')
+
+ def validateTo(self, user):
+ """
+ Validate the address of a recipient of the message, possibly
+ rejecting it if the recipient key is not available.
+
+ This method is called once for each recipient, i.e. for each SMTP
+ protocol line beginning with "RCPT TO:", which includes all addresses
+ in "To", "Cc" and "Bcc" MUA fields.
+
+ The recipient's address is validated against the RFC 2822 definition.
+ If self._encrypted_only is True and no key is found for a recipient,
+ then that recipient is rejected.
+
+ The method returns an encrypted message object that is able to send
+ itself to the user's address.
+
+ :param user: The user whose address we wish to validate.
+ :type: twisted.mail.smtp.User
+
+ @return: A callable which takes no arguments and returns an
+ encryptedMessage.
+ @rtype: no-argument callable
+
+ @raise SMTPBadRcpt: Raised if messages to the address are not to be
+ accepted.
+ """
+ # try to find recipient's public key
+ address = validate_address(user.dest.addrstr)
+
+ # verify if recipient key is available in keyring
+ def found(_):
+ log.msg("Accepting mail for %s..." % user.dest.addrstr)
+ emit_async(catalog.SMTP_RECIPIENT_ACCEPTED_ENCRYPTED,
+ 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, 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,
+ self._userid, user.dest.addrstr)
+
+ def encrypt_func(_):
+ return lambda: EncryptedMessage(user, self._outgoing_mail)
+
+ d = self._km.get_key(address)
+ d.addCallbacks(found, not_found)
+ d.addCallback(encrypt_func)
+ return d
+
+ 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 EncryptedMessage(object):
+ """
+ Receive plaintext from client, encrypt it and send message to a
+ recipient.
+ """
+ implements(smtp.IMessage)
+
+ def __init__(self, user, outgoing_mail):
+ """
+ Initialize the encrypted message.
+
+ :param user: The recipient of this message.
+ :type user: twisted.mail.smtp.User
+ :param outgoing_mail: The outgoing mail to send the message
+ :type outgoing_mail: leap.mail.outgoing.service.OutgoingMail
+ """
+ # assert params
+ leap_assert_type(user, smtp.User)
+
+ self._user = user
+ self._lines = []
+ self._outgoing_mail = outgoing_mail
+
+ 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.
+
+ :returns: a deferred
+ """
+ log.msg("Message data complete.")
+ self._lines.append('') # add a trailing newline
+ raw_mail = '\r\n'.join(self._lines)
+
+ return self._outgoing_mail.send_message(raw_mail, self._user)
+
+ def connectionLost(self):
+ """
+ Log an error when the connection is lost.
+ """
+ log.msg("Connection lost unexpectedly!")
+ log.err()
+ emit_async(catalog.SMTP_CONNECTION_LOST, self._userid,
+ self._user.dest.addrstr)
+ # unexpected loss of connection; don't save
+
+ self._lines = []