summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKali Kaneko <kali@leap.se>2015-12-16 01:12:30 -0400
committerKali Kaneko <kali@leap.se>2015-12-18 11:13:50 -0400
commitea41bb44afee34e8ad6baf917ba461a7e95bf70d (patch)
tree13328c1e3a5114c6b02cd0838bf8a94e16052123
parentcb676ab65d41c8821683a10bb675e94ac59e06ff (diff)
[feat] cred authentication for SMTP service
-rw-r--r--src/leap/mail/cred.py80
-rw-r--r--src/leap/mail/errors.py27
-rw-r--r--src/leap/mail/imap/service/imap.py59
-rw-r--r--src/leap/mail/outgoing/service.py32
-rw-r--r--src/leap/mail/smtp/__init__.py50
-rw-r--r--src/leap/mail/smtp/gateway.py159
6 files changed, 257 insertions, 150 deletions
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 <http://www.gnu.org/licenses/>.
+"""
+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 <http://www.gnu.org/licenses/>.
+"""
+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 <http://www.gnu.org/licenses/>.
+
+"""
+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.