From ea41bb44afee34e8ad6baf917ba461a7e95bf70d Mon Sep 17 00:00:00 2001 From: Kali Kaneko Date: Wed, 16 Dec 2015 01:12:30 -0400 Subject: [feat] cred authentication for SMTP service --- src/leap/mail/cred.py | 80 +++++++++++++++++++ src/leap/mail/errors.py | 27 +++++++ src/leap/mail/imap/service/imap.py | 59 +------------- src/leap/mail/outgoing/service.py | 32 +++++++- src/leap/mail/smtp/__init__.py | 50 +++++------- src/leap/mail/smtp/gateway.py | 159 +++++++++++++++++++++++-------------- 6 files changed, 257 insertions(+), 150 deletions(-) create mode 100644 src/leap/mail/cred.py create mode 100644 src/leap/mail/errors.py diff --git a/src/leap/mail/cred.py b/src/leap/mail/cred.py new file mode 100644 index 0000000..7eab1f0 --- /dev/null +++ b/src/leap/mail/cred.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# cred.py +# Copyright (C) 2015 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 . +""" +Credentials handling. +""" + +from zope.interface import implementer +from twisted.cred.checkers import ICredentialsChecker +from twisted.cred.credentials import IUsernamePassword +from twisted.cred.error import UnauthorizedLogin +from twisted.internet import defer + + +@implementer(ICredentialsChecker) +class LocalSoledadTokenChecker(object): + + """ + A Credentials Checker for a LocalSoledad store. + + It checks that: + + 1) The Local SoledadStorage has been correctly unlocked for the given + user. This currently means that the right passphrase has been passed + to the Local SoledadStorage. + + 2) The password passed in the credentials matches whatever token has + been stored in the local encrypted SoledadStorage, associated to the + Protocol that is requesting the authentication. + """ + + credentialInterfaces = (IUsernamePassword,) + service = None + + def __init__(self, soledad_sessions): + """ + :param soledad_sessions: a dict-like object, containing instances + of a Store (soledad instances), indexed by + userid. + """ + self._soledad_sessions = soledad_sessions + + def requestAvatarId(self, credentials): + if self.service is None: + raise NotImplementedError( + "this checker has not defined its service name") + username, password = credentials.username, credentials.password + d = self.checkSoledadToken(username, password, self.service) + d.addErrback(lambda f: defer.fail(UnauthorizedLogin())) + return d + + def checkSoledadToken(self, username, password, service): + soledad = self._soledad_sessions.get(username) + if not soledad: + return defer.fail(Exception("No soledad")) + + def match_token(token): + if token is None: + raise RuntimeError('no token') + if token == password: + return username + else: + raise RuntimeError('bad token') + + d = soledad.get_or_create_service_token(service) + d.addCallback(match_token) + return d diff --git a/src/leap/mail/errors.py b/src/leap/mail/errors.py new file mode 100644 index 0000000..2f18e87 --- /dev/null +++ b/src/leap/mail/errors.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# errors.py +# Copyright (C) 2015 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 . +""" +Exceptions for leap.mail +""" + + +class AuthenticationError(Exception): + pass + + +class ConfigurationError(Exception): + pass diff --git a/src/leap/mail/imap/service/imap.py b/src/leap/mail/imap/service/imap.py index 24fa865..9e34454 100644 --- a/src/leap/mail/imap/service/imap.py +++ b/src/leap/mail/imap/service/imap.py @@ -23,9 +23,6 @@ import os from collections import defaultdict from twisted.cred.portal import Portal, IRealm -from twisted.cred.credentials import IUsernamePassword -from twisted.cred.checkers import ICredentialsChecker -from twisted.cred.error import UnauthorizedLogin from twisted.mail.imap4 import IAccount from twisted.internet import defer from twisted.internet import reactor @@ -35,6 +32,7 @@ from twisted.python import log from zope.interface import implementer from leap.common.events import emit_async, catalog +from leap.mail.cred import LocalSoledadTokenChecker from leap.mail.imap.account import IMAPAccount from leap.mail.imap.server import LEAPIMAPServer @@ -88,61 +86,6 @@ class LocalSoledadIMAPRealm(object): return defer.succeed(soledad) -@implementer(ICredentialsChecker) -class LocalSoledadTokenChecker(object): - - """ - A Credentials Checker for a LocalSoledad store. - - It checks that: - - 1) The Local SoledadStorage has been correctly unlocked for the given - user. This currently means that the right passphrase has been passed - to the Local SoledadStorage. - - 2) The password passed in the credentials matches whatever token has - been stored in the local encrypted SoledadStorage, associated to the - Protocol that is requesting the authentication. - """ - - credentialInterfaces = (IUsernamePassword,) - service = None - - def __init__(self, soledad_sessions): - """ - :param soledad_sessions: a dict-like object, containing instances - of a Store (soledad instances), indexed by - userid. - """ - self._soledad_sessions = soledad_sessions - - def requestAvatarId(self, credentials): - if self.service is None: - raise NotImplementedError( - "this checker has not defined its service name") - username, password = credentials.username, credentials.password - d = self.checkSoledadToken(username, password, self.service) - d.addErrback(lambda f: defer.fail(UnauthorizedLogin())) - return d - - def checkSoledadToken(self, username, password, service): - soledad = self._soledad_sessions.get(username) - if not soledad: - return defer.fail(Exception("No soledad")) - - def match_token(token): - if token is None: - raise RuntimeError('no token') - if token == password: - return username - else: - raise RuntimeError('bad token') - - d = soledad.get_or_create_service_token(service) - d.addCallback(match_token) - return d - - class IMAPTokenChecker(LocalSoledadTokenChecker): """A credentials checker that will lookup a token for the IMAP service.""" service = 'imap' diff --git a/src/leap/mail/outgoing/service.py b/src/leap/mail/outgoing/service.py index 7943c12..3e14fbd 100644 --- a/src/leap/mail/outgoing/service.py +++ b/src/leap/mail/outgoing/service.py @@ -14,6 +14,14 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . + +""" +OutgoingMail module. + +The OutgoingMail class allows to send mail, and encrypts/signs it if needed. +""" + +import os.path import re from StringIO import StringIO from copy import deepcopy @@ -35,6 +43,7 @@ from leap.common.events import emit_async, catalog from leap.keymanager.openpgp import OpenPGPKey from leap.keymanager.errors import KeyNotFound, KeyAddressMismatch from leap.mail import __version__ +from leap.mail import errors from leap.mail.utils import validate_address from leap.mail.rfc3156 import MultipartEncrypted from leap.mail.rfc3156 import MultipartSigned @@ -64,9 +73,23 @@ class SSLContextFactory(ssl.ClientContextFactory): return ctx -class OutgoingMail: +def outgoingFactory(userid, keymanager, opts): + + cert = unicode(opts.cert) + key = unicode(opts.key) + hostname = str(opts.hostname) + port = opts.port + + if not os.path.isfile(cert): + raise errors.ConfigurationError( + 'No valid SMTP certificate could be found for %s!' % userid) + + return OutgoingMail(str(userid), keymanager, cert, key, hostname, port) + + +class OutgoingMail(object): """ - A service for handling encrypted outgoing mail. + Sends Outgoing Mail, encrypting and signing if needed. """ def __init__(self, from_address, keymanager, cert, key, host, port): @@ -134,9 +157,10 @@ class OutgoingMail: :type smtp_sender_result: tuple(int, list(tuple)) """ dest_addrstr = smtp_sender_result[1][0][0] - log.msg('Message sent to %s' % dest_addrstr) + fromaddr = self._from_address + log.msg('Message sent from %s to %s' % (fromaddr, dest_addrstr)) emit_async(catalog.SMTP_SEND_MESSAGE_SUCCESS, - self._from_address, dest_addrstr) + fromaddr, dest_addrstr) def sendError(self, failure): """ 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/gateway.py b/src/leap/mail/smtp/gateway.py index 3c86d7e..85b1560 100644 --- a/src/leap/mail/smtp/gateway.py +++ b/src/leap/mail/smtp/gateway.py @@ -30,18 +30,26 @@ The following classes comprise the SMTP gateway service: 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.keymanager.openpgp import OpenPGPKey +from leap.keymanager.errors import KeyNotFound # replace email generator with a RFC 3156 compliant one. from email import generator @@ -49,87 +57,122 @@ from email import generator generator.Generator = RFC3156CompliantGenerator -# TODO -- implement Queue using twisted.mail.mail.MailService +LOCAL_FQDN = "bitmask.local" -# -# Helper utilities -# +@implementer(IRealm) +class LocalSMTPRealm(object): -LOCAL_FQDN = "bitmask.local" + _encoding = 'utf-8' + + def __init__(self, keymanager_sessions, sendmail_opts): + """ + :param keymanager_sessions: a dict-like object, containing instances + of a Keymanager objects, indexed by + userid. + """ + self._keymanager_sessions = keymanager_sessions + self._sendmail_opts = sendmail_opts + def requestAvatar(self, avatarId, mind, *interfaces): + if isinstance(avatarId, str): + avatarId = avatarId.decode(self._encoding) -class SMTPHeloLocalhost(smtp.SMTP): - """ - An SMTP class that ensures a proper FQDN - for localhost. + def gotKeymanager(keymanager): - This avoids a problem in which unproperly configured providers - would complain about the helo not being a fqdn. - """ + # 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) + avatar = SMTPDelivery(userid, keymanager, False, outgoing) + + return (smtp.IMessageDelivery, avatar, + getattr(avatar, 'logout', lambda: None)) + + raise NotImplementedError(self, interfaces) + + return self.lookupKeymanagerInstance(avatarId).addCallback( + gotKeymanager) - def __init__(self, *args): - smtp.SMTP.__init__(self, *args) - self.host = LOCAL_FQDN + 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 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.""" + service = 'smtp' + + # TODO besides checking for token credential, + # we could also verify the certificate here. + + +# TODO -- implement Queue using twisted.mail.mail.MailService +class LocalSMTPServer(smtp.ESMTP): + def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts, + *args, **kw): -class SMTPFactory(ServerFactory): + smtp.ESMTP.__init__(self, *args, **kw) + + realm = LocalSMTPRealm(keymanager_sessions, sendmail_opts) + portal = Portal(realm) + checker = SMTPTokenChecker(soledad_sessions) + self.checker = checker + self.portal = portal + portal.registerChecker(checker) + + +class SMTPFactory(protocol.ServerFactory): """ Factory for an SMTP server with encrypted gatewaying capabilities. """ - domain = LOCAL_FQDN - - def __init__(self, userid, keymanager, encrypted_only, outgoing_mail): - """ - Initialize the SMTP factory. - :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 - """ + protocol = LocalSMTPServer + domain = LOCAL_FQDN + timeout = 600 - 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 + def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts): + 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) + 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. -- cgit v1.2.3