diff options
Diffstat (limited to 'src/leap')
23 files changed, 2395 insertions, 0 deletions
diff --git a/src/leap/bitmask/cli/__init__.py b/src/leap/bitmask/cli/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/leap/bitmask/cli/__init__.py diff --git a/src/leap/bitmask/cli/bitmask_cli.py b/src/leap/bitmask/cli/bitmask_cli.py new file mode 100755 index 0000000..1f104c9 --- /dev/null +++ b/src/leap/bitmask/cli/bitmask_cli.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# bitmask_cli +# Copyright (C) 2015, 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/>. +""" +Bitmask Command Line interface: zmq client. +""" +import json +import sys + +from colorama import Fore +from twisted.internet import reactor, defer + +from leap.bitmask.cli.eip import Eip +from leap.bitmask.cli.keys import Keys +from leap.bitmask.cli.mail import Mail +from leap.bitmask.cli import command +from leap.bitmask.cli.user import User + + +class BitmaskCLI(command.Command): + usage = '''bitmaskctl <command> [<args>] + +Controls the Bitmask application. + +SERVICE COMMANDS: + + user Handles Bitmask accounts + mail Bitmask Encrypted Mail + eip Encrypted Internet Proxy + keys Bitmask Keymanager + +GENERAL COMMANDS: + + version prints version number and exit + launch launch the Bitmask backend daemon + shutdown shutdown Bitmask backend daemon + status displays general status about the running Bitmask services + stats show some debug info about bitmask-core + help show this help message + +''' + epilog = ("Use 'bitmaskctl <command> help' to learn more " + "about each command.") + commands = ['shutdown', 'stats'] + + def user(self, raw_args): + user = User() + return user.execute(raw_args) + + def mail(self, raw_args): + mail = Mail() + return mail.execute(raw_args) + + def eip(self, raw_args): + eip = Eip() + return eip.execute(raw_args) + + def keys(self, raw_args): + keys = Keys() + return keys.execute(raw_args) + + # Single commands + + def launch(self, raw_args): + # XXX careful! Should see if the process in PID is running, + # avoid launching again. + import commands + commands.getoutput('bitmaskd') + return defer.succeed(None) + + def version(self, raw_args): + print(Fore.GREEN + 'bitmaskctl: ' + Fore.RESET + '0.0.1') + self.data = ['version'] + return self._send(printer=self._print_version) + + def _print_version(self, version): + corever = version['version_core'] + print(Fore.GREEN + 'bitmask_core: ' + Fore.RESET + corever) + + def status(self, raw_args): + self.data = ['status'] + return self._send(printer=self._print_status) + + def _print_status(self, status): + statusdict = json.loads(status) + for key, value in statusdict.items(): + color = Fore.GREEN + if value == 'stopped': + color = Fore.RED + print(key.ljust(10) + ': ' + color + + value + Fore.RESET) + + +def execute(): + cli = BitmaskCLI() + d = cli.execute(sys.argv[1:]) + d.addCallback(lambda _: reactor.stop()) + + +def main(): + reactor.callWhenRunning(reactor.callLater, 0, execute) + reactor.run() + +if __name__ == "__main__": + main() diff --git a/src/leap/bitmask/cli/command.py b/src/leap/bitmask/cli/command.py new file mode 100644 index 0000000..36552c0 --- /dev/null +++ b/src/leap/bitmask/cli/command.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# sender +# 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/>. +""" +Bitmask Command Line interface: zmq sender. +""" +import argparse +import json +import sys + +from colorama import init as color_init +from colorama import Fore +from twisted.internet import defer +from txzmq import ZmqEndpoint, ZmqEndpointType +from txzmq import ZmqFactory, ZmqREQConnection +from txzmq import ZmqRequestTimeoutError + +from leap.bitmask.core import ENDPOINT + + +appname = 'bitmaskctl' + + +def _print_result(result): + print Fore.GREEN + '%s' % result + Fore.RESET + + +def default_dict_printer(result): + for key, value in result.items(): + if value is None: + value = str(value) + print(Fore.RESET + key.ljust(10) + Fore.GREEN + value + Fore.RESET) + + +class Command(object): + """A generic command dispatcher. + Any command in the class attribute `commands` will be dispached and + represented with a generic printer.""" + service = '' + usage = '''{name} <subcommand>'''.format(name=appname) + epilog = ("Use bitmaskctl <subcommand> --help' to learn more " + "about each command.") + commands = [] + + def __init__(self): + color_init() + zf = ZmqFactory() + e = ZmqEndpoint(ZmqEndpointType.connect, ENDPOINT) + self._conn = ZmqREQConnection(zf, e) + + self.data = [] + if self.service: + self.data = [self.service] + + def execute(self, raw_args): + self.parser = argparse.ArgumentParser(usage=self.usage, + epilog=self.epilog) + self.parser.add_argument('command', help='Subcommand to run') + try: + args = self.parser.parse_args(raw_args[0:1]) + except SystemExit: + return defer.succeed(None) + + # if command is in the default list, send the bare command + # and use the default printer + if args.command in self.commands: + self.data += [args.command] + return self._send(printer=default_dict_printer) + + elif (args.command == 'execute' or + args.command.startswith('_') or + not hasattr(self, args.command)): + print 'Unrecognized command' + return self.help([]) + + try: + # use dispatch pattern to invoke method with same name + return getattr(self, args.command)(raw_args[1:]) + except SystemExit: + return defer.succeed(None) + + def help(self, raw_args): + self.parser.print_help() + return defer.succeed(None) + + def _send(self, printer=_print_result): + d = self._conn.sendMsg(*self.data, timeout=60) + d.addCallback(self._check_err, printer) + d.addErrback(self._timeout_handler) + return d + + def _error(self, msg): + print Fore.RED + "[!] %s" % msg + Fore.RESET + sys.exit(1) + + def _check_err(self, stuff, printer): + obj = json.loads(stuff[0]) + if not obj['error']: + return printer(obj['result']) + else: + print Fore.RED + 'ERROR:' + '%s' % obj['error'] + Fore.RESET + + def _timeout_handler(self, failure): + # TODO ---- could try to launch the bitmask daemon here and retry + if failure.trap(ZmqRequestTimeoutError) == ZmqRequestTimeoutError: + print (Fore.RED + "[ERROR] Timeout contacting the bitmask daemon. " + "Is it running?" + Fore.RESET) diff --git a/src/leap/bitmask/cli/eip.py b/src/leap/bitmask/cli/eip.py new file mode 100644 index 0000000..eac8682 --- /dev/null +++ b/src/leap/bitmask/cli/eip.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# eip +# 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/>. +""" +Bitmask Command Line interface: eip +""" +from leap.bitmask.cli import command + + +class Eip(command.Command): + service = 'eip' + usage = '''{name} eip <subcommand> + +Bitmask Encrypted Internet Service + +SUBCOMMANDS: + + start Start service + stop Stop service + status Display status about service + +'''.format(name=command.appname) + + commands = ['start', 'stop', 'status'] diff --git a/src/leap/bitmask/cli/keys.py b/src/leap/bitmask/cli/keys.py new file mode 100644 index 0000000..d74c40a --- /dev/null +++ b/src/leap/bitmask/cli/keys.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# keys +# 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/>. +""" +Bitmask Command Line interface: keys +""" +import argparse +import sys + +from colorama import Fore + +from leap.bitmask.cli import command +from leap.keymanager.validation import ValidationLevels + + +class Keys(command.Command): + service = 'keys' + usage = '''{name} keys <subcommand> + +Bitmask Keymanager management service + +SUBCOMMANDS: + + list List all known keys + export Export a given key + insert Insert a key to the key storage + delete Delete a key from the key storage +'''.format(name=command.appname) + + def list(self, raw_args): + parser = argparse.ArgumentParser( + description='Bitmask list keys', + prog='%s %s %s' % tuple(sys.argv[:3])) + parser.add_argument('--private', action='store_true', + help='Use private keys (by default uses public)') + subargs = parser.parse_args(raw_args) + + self.data += ['list'] + if subargs.private: + self.data += ['private'] + else: + self.data += ['public'] + + return self._send(self._print_key_list) + + def export(self, raw_args): + parser = argparse.ArgumentParser( + description='Bitmask export key', + prog='%s %s %s' % tuple(sys.argv[:3])) + parser.add_argument('--private', action='store_true', + help='Use private keys (by default uses public)') + parser.add_argument('address', nargs=1, + help='email address of the key') + subargs = parser.parse_args(raw_args) + self.data += ['export', subargs.address[0]] + + return self._send(self._print_key) + + def insert(self, raw_args): + parser = argparse.ArgumentParser( + description='Bitmask import key', + prog='%s %s %s' % tuple(sys.argv[:3])) + parser.add_argument('--validation', choices=list(ValidationLevels), + default='Fingerprint', + help='Validation level for the key') + parser.add_argument('file', nargs=1, + help='file where the key is stored') + parser.add_argument('address', nargs=1, + help='email address of the key') + subargs = parser.parse_args(raw_args) + + with open(subargs.file[0], 'r') as keyfile: + rawkey = keyfile.read() + self.data += ['insert', subargs.address[0], subargs.validation, + rawkey] + + return self._send(self._print_key) + + def delete(self, raw_args): + parser = argparse.ArgumentParser( + description='Bitmask delete key', + prog='%s %s %s' % tuple(sys.argv[:3])) + parser.add_argument('--private', action='store_true', + help='Use private keys (by default uses public)') + parser.add_argument('address', nargs=1, + help='email address of the key') + subargs = parser.parse_args(raw_args) + self.data += ['delete', subargs.address[0]] + + return self._send() + + def _print_key_list(self, keys): + for key in keys: + print(Fore.GREEN + + key["fingerprint"] + " " + key['address'] + + Fore.RESET) + + def _print_key(self, key): + print(Fore.GREEN) + print("Uids: " + ', '.join(key['uids'])) + print("Fingerprint:" + key['fingerprint']) + print("Length: " + str(key['length'])) + print("Expiration: " + key['expiry_date']) + print("Validation: " + key['validation']) + print("Used: " + "sig:" + + str(key['sign_used']) + ", encr:" + + str(key['encr_used'])) + print("Refreshed: " + key['refreshed_at']) + print(Fore.RESET) + print("") + print(key['key_data']) diff --git a/src/leap/bitmask/cli/mail.py b/src/leap/bitmask/cli/mail.py new file mode 100644 index 0000000..f0fa972 --- /dev/null +++ b/src/leap/bitmask/cli/mail.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# mail +# 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/>. +""" +Bitmask Command Line interface: mail +""" +from leap.bitmask.cli import command + + +class Mail(command.Command): + service = 'mail' + usage = '''{name} mail <subcommand> + +Bitmask Encrypted Email Service + +SUBCOMMANDS: + + enable Start service + disable Stop service + status Display status about service + get_token Returns token for the mail service + get_smtp_certificate Downloads a new smtp certificate + +'''.format(name=command.appname) + + commands = ['enable', 'disable', 'status', 'get_token', + 'get_smtp_certificate'] diff --git a/src/leap/bitmask/cli/user.py b/src/leap/bitmask/cli/user.py new file mode 100644 index 0000000..dccfc7d --- /dev/null +++ b/src/leap/bitmask/cli/user.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# user +# 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/>. +""" +Bitmask Command Line interface: user +""" +import argparse +import getpass +import sys + +from leap.bitmask.cli import command + + +class User(command.Command): + service = 'user' + usage = '''{name} user <subcommand> + +Bitmask account service + +SUBCOMMANDS: + + create Registers new user, if possible + auth Logs in against the provider + logout Ends any active session with the provider + active Shows the active user, if any + +'''.format(name=command.appname) + + commands = ['active'] + + def create(self, raw_args): + username = self.username(raw_args) + passwd = getpass.getpass() + self.data += ['signup', username, passwd] + return self._send(printer=command.default_dict_printer) + + def auth(self, raw_args): + username = self.username(raw_args) + passwd = getpass.getpass() + self.data += ['authenticate', username, passwd] + return self._send(printer=command.default_dict_printer) + + def logout(self, raw_args): + username = self.username(raw_args) + self.data += ['logout', username] + return self._send(printer=command.default_dict_printer) + + def username(self, raw_args): + args = tuple([command.appname] + sys.argv[1:3]) + parser = argparse.ArgumentParser( + description='Bitmask user', + prog='%s %s %s' % args) + parser.add_argument('username', nargs=1, + help='username ID, in the form <user@example.org>') + subargs = parser.parse_args(raw_args) + + username = subargs.username[0] + if not username: + self._error("Missing username ID but needed for this command") + if '@' not in username: + self._error("Username ID must be in the form <user@example.org>") + + return username diff --git a/src/leap/bitmask/core/__init__.py b/src/leap/bitmask/core/__init__.py new file mode 100644 index 0000000..bda4b8d --- /dev/null +++ b/src/leap/bitmask/core/__init__.py @@ -0,0 +1,2 @@ +APPNAME = "bitmask.core" +ENDPOINT = "ipc:///tmp/%s.sock" % APPNAME diff --git a/src/leap/bitmask/core/_zmq.py b/src/leap/bitmask/core/_zmq.py new file mode 100644 index 0000000..a656fc6 --- /dev/null +++ b/src/leap/bitmask/core/_zmq.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# _zmq.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/>. +""" +ZMQ REQ-REP Dispatcher. +""" + +from twisted.application import service +from twisted.internet import reactor +from twisted.python import log + +from txzmq import ZmqEndpoint, ZmqEndpointType +from txzmq import ZmqFactory, ZmqREPConnection + +from leap.bitmask.core import ENDPOINT +from leap.bitmask.core.dispatcher import CommandDispatcher + + +class ZMQServerService(service.Service): + + def __init__(self, core): + self._core = core + + def startService(self): + zf = ZmqFactory() + e = ZmqEndpoint(ZmqEndpointType.bind, ENDPOINT) + + self._conn = _DispatcherREPConnection(zf, e, self._core) + reactor.callWhenRunning(self._conn.do_greet) + service.Service.startService(self) + + def stopService(self): + service.Service.stopService(self) + + +class _DispatcherREPConnection(ZmqREPConnection): + + def __init__(self, zf, e, core): + ZmqREPConnection.__init__(self, zf, e) + self.dispatcher = CommandDispatcher(core) + + def gotMessage(self, msgId, *parts): + + r = self.dispatcher.dispatch(parts) + r.addCallback(self.defer_reply, msgId) + + def defer_reply(self, response, msgId): + reactor.callLater(0, self.reply, msgId, str(response)) + + def log_err(self, failure, msgId): + log.err(failure) + self.defer_reply("ERROR: %r" % failure, msgId) + + def do_greet(self): + log.msg('starting ZMQ dispatcher') diff --git a/src/leap/bitmask/core/api.py b/src/leap/bitmask/core/api.py new file mode 100644 index 0000000..9f3725d --- /dev/null +++ b/src/leap/bitmask/core/api.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# api.py +# Copyright (C) 2016 LEAP Encryption Acess Project +# +# 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/>. +""" +Registry for the public API for the Bitmask Backend. +""" +from collections import OrderedDict + +registry = OrderedDict() + + +class APICommand(type): + """ + A metaclass to keep a global registry of all the methods that compose the + public API for the Bitmask Backend. + """ + def __init__(cls, name, bases, attrs): + for key, val in attrs.iteritems(): + properties = getattr(val, 'register', None) + label = getattr(cls, 'label', None) + if label: + name = label + if properties is not None: + registry['%s.%s' % (name, key)] = properties + + +def register_method(*args): + """ + This method gathers info about all the methods that are supposed to + compose the public API to communicate with the backend. + + It sets up a register property for any method that uses it. + A type annotation is supposed to be in this property. + The APICommand metaclass collects these properties of the methods and + stores them in the global api_registry object, where they can be + introspected at runtime. + """ + def decorator(f): + f.register = tuple(args) + return f + return decorator diff --git a/src/leap/bitmask/core/bitmaskd.tac b/src/leap/bitmask/core/bitmaskd.tac new file mode 100644 index 0000000..3c9b1d8 --- /dev/null +++ b/src/leap/bitmask/core/bitmaskd.tac @@ -0,0 +1,11 @@ +# Service composition for bitmask-core. +# Run as: twistd -n -y bitmaskd.tac +# +from twisted.application import service + +from leap.bitmask.core.service import BitmaskBackend + + +bb = BitmaskBackend() +application = service.Application("bitmaskd") +bb.setServiceParent(application) diff --git a/src/leap/bitmask/core/configurable.py b/src/leap/bitmask/core/configurable.py new file mode 100644 index 0000000..8e33de9 --- /dev/null +++ b/src/leap/bitmask/core/configurable.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# configurable.py +# Copyright (C) 2015, 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/>. +""" +Configurable Backend for Bitmask Service. +""" +import ConfigParser +import os + +from twisted.application import service + +from leap.common import files +from leap.common.config import get_path_prefix + + +DEFAULT_BASEDIR = os.path.join(get_path_prefix(), 'leap') + + +class MissingConfigEntry(Exception): + """ + A required config entry was not found. + """ + + +class ConfigurableService(service.MultiService): + + config_file = u"bitmaskd.cfg" + service_names = ('mail', 'eip', 'zmq', 'web') + + def __init__(self, basedir=DEFAULT_BASEDIR): + service.MultiService.__init__(self) + + path = os.path.abspath(os.path.expanduser(basedir)) + if not os.path.isdir(path): + files.mkdir_p(path) + self.basedir = path + + # creates self.config + self.read_config() + + def get_config(self, section, option, default=None, boolean=False): + try: + if boolean: + return self.config.getboolean(section, option) + + item = self.config.get(section, option) + return item + + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + if default is None: + fn = self._get_config_path() + raise MissingConfigEntry("%s is missing the [%s]%s entry" + % fn, section, option) + return default + + def set_config(self, section, option, value): + if not self.config.has_section(section): + self.config.add_section(section) + self.config.set(section, option, value) + self.save_config() + self.read_config() + assert self.config.get(section, option) == value + + def read_config(self): + self.config = ConfigParser.SafeConfigParser() + bitmaskd_cfg = self._get_config_path() + + if not os.path.isfile(bitmaskd_cfg): + self._create_default_config(bitmaskd_cfg) + + with open(bitmaskd_cfg, "rb") as f: + self.config.readfp(f) + + def save_config(self): + bitmaskd_cfg = self._get_config_path() + with open(bitmaskd_cfg, 'wb') as f: + self.config.write(f) + + def _create_default_config(self, path): + with open(path, 'w') as outf: + outf.write(DEFAULT_CONFIG) + + def _get_config_path(self): + return os.path.join(self.basedir, self.config_file) + + +DEFAULT_CONFIG = """ +[services] +mail = True +eip = True +zmq = True +web = False +""" diff --git a/src/leap/bitmask/core/dispatcher.py b/src/leap/bitmask/core/dispatcher.py new file mode 100644 index 0000000..e81cad6 --- /dev/null +++ b/src/leap/bitmask/core/dispatcher.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +# dispatcher.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/>. +""" +Command dispatcher. +""" +import json + +from twisted.internet import defer +from twisted.python import failure, log + +from .api import APICommand, register_method + + +class SubCommand(object): + + __metaclass__ = APICommand + + def dispatch(self, service, *parts, **kw): + subcmd = parts[1] + + _method = getattr(self, 'do_' + subcmd.upper(), None) + if not _method: + raise RuntimeError('No such subcommand') + return defer.maybeDeferred(_method, service, *parts, **kw) + + +class UserCmd(SubCommand): + + label = 'user' + + @register_method("{'srp_token': unicode, 'uuid': unicode}") + def do_AUTHENTICATE(self, bonafide, *parts): + user, password = parts[2], parts[3] + d = defer.maybeDeferred(bonafide.do_authenticate, user, password) + return d + + @register_method("{'signup': 'ok', 'user': str}") + def do_SIGNUP(self, bonafide, *parts): + user, password = parts[2], parts[3] + d = defer.maybeDeferred(bonafide.do_signup, user, password) + return d + + @register_method("{'logout': 'ok'}") + def do_LOGOUT(self, bonafide, *parts): + user = parts[2] + d = defer.maybeDeferred(bonafide.do_logout, user) + return d + + @register_method('str') + def do_ACTIVE(self, bonafide, *parts): + d = defer.maybeDeferred(bonafide.do_get_active_user) + return d + + +class EIPCmd(SubCommand): + + label = 'eip' + + @register_method('dict') + def do_ENABLE(self, service, *parts): + d = service.do_enable_service(self.label) + return d + + @register_method('dict') + def do_DISABLE(self, service, *parts): + d = service.do_disable_service(self.label) + return d + + @register_method('dict') + def do_STATUS(self, eip, *parts): + d = eip.do_status() + return d + + @register_method('dict') + def do_START(self, eip, *parts): + # TODO --- attempt to get active provider + # TODO or catch the exception and send error + provider = parts[2] + d = eip.do_start(provider) + return d + + @register_method('dict') + def do_STOP(self, eip, *parts): + d = eip.do_stop() + return d + + +class MailCmd(SubCommand): + + label = 'mail' + + @register_method('dict') + def do_ENABLE(self, service, *parts, **kw): + # FIXME -- service doesn't have this method + d = service.do_enable_service(self.label) + return d + + @register_method('dict') + def do_DISABLE(self, service, *parts, **kw): + d = service.do_disable_service(self.label) + return d + + @register_method('dict') + def do_STATUS(self, mail, *parts, **kw): + d = mail.do_status() + return d + + @register_method('dict') + def do_GET_TOKEN(self, mail, *parts, **kw): + d = mail.get_token() + return d + + @register_method('dict') + def do_GET_SMTP_CERTIFICATE(self, mail, *parts, **kw): + # TODO move to mail service + # TODO should ask for confirmation? like --force or something, + # if we already have a valid one. or better just refuse if cert + # exists. + # TODO how should we pass the userid?? + # - Keep an 'active' user in bonafide (last authenticated) + # (doing it now) + # - Get active user from Mail Service (maybe preferred?) + # - Have a command/method to set 'active' user. + + @defer.inlineCallbacks + def save_cert(cert_data): + userid, cert_str = cert_data + cert_path = yield mail.do_get_smtp_cert_path(userid) + with open(cert_path, 'w') as outf: + outf.write(cert_str) + defer.returnValue('certificate saved to %s' % cert_path) + + bonafide = kw['bonafide'] + d = bonafide.do_get_smtp_cert() + d.addCallback(save_cert) + return d + + +class KeysCmd(SubCommand): + + label = 'keys' + + @register_method("[dict]") + def do_LIST(self, service, *parts, **kw): + private = False + if parts[-1] == 'private': + private = True + + bonafide = kw['bonafide'] + d = bonafide.do_get_active_user() + d.addCallback(service.do_list_keys, private) + return d + + @register_method('dict') + def do_EXPORT(self, service, *parts, **kw): + if len(parts) < 3: + return defer.fail("An email address is needed") + address = parts[2] + + private = False + if parts[-1] == 'private': + private = True + + bonafide = kw['bonafide'] + d = bonafide.do_get_active_user() + d.addCallback(service.do_export, address, private) + return d + + @register_method('dict') + def do_INSERT(self, service, *parts, **kw): + if len(parts) < 5: + return defer.fail("An email address is needed") + address = parts[2] + validation = parts[3] + rawkey = parts[4] + + bonafide = kw['bonafide'] + d = bonafide.do_get_active_user() + d.addCallback(service.do_insert, address, rawkey, validation) + return d + + @register_method('str') + def do_DELETE(self, service, *parts, **kw): + if len(parts) < 3: + return defer.fail("An email address is needed") + address = parts[2] + + private = False + if parts[-1] == 'private': + private = True + + bonafide = kw['bonafide'] + d = bonafide.do_get_active_user() + d.addCallback(service.do_delete, address, private) + return d + + +class CommandDispatcher(object): + + __metaclass__ = APICommand + + label = 'core' + + def __init__(self, core): + + self.core = core + self.subcommand_user = UserCmd() + self.subcommand_eip = EIPCmd() + self.subcommand_mail = MailCmd() + self.subcommand_keys = KeysCmd() + + # XXX -------------------------------------------- + # TODO move general services to another subclass + + @register_method("{'mem_usage': str}") + def do_STATS(self, *parts): + return _format_result(self.core.do_stats()) + + @register_method("{version_core': '0.0.0'}") + def do_VERSION(self, *parts): + return _format_result(self.core.do_version()) + + @register_method("{'mail': 'running'}") + def do_STATUS(self, *parts): + return _format_result(self.core.do_status()) + + @register_method("{'shutdown': 'ok'}") + def do_SHUTDOWN(self, *parts): + return _format_result(self.core.do_shutdown()) + + # ----------------------------------------------- + + def do_USER(self, *parts): + bonafide = self._get_service('bonafide') + d = self.subcommand_user.dispatch(bonafide, *parts) + d.addCallbacks(_format_result, _format_error) + return d + + def do_EIP(self, *parts): + eip = self._get_service(self.subcommand_eip.label) + if not eip: + return _format_result('eip: disabled') + subcmd = parts[1] + + dispatch = self._subcommand_eip.dispatch + if subcmd in ('enable', 'disable'): + d = dispatch(self.core, *parts) + else: + d = dispatch(eip, *parts) + + d.addCallbacks(_format_result, _format_error) + return d + + def do_MAIL(self, *parts): + subcmd = parts[1] + dispatch = self.subcommand_mail.dispatch + + if subcmd == 'enable': + d = dispatch(self.core, *parts) + + mail = self._get_service(self.subcommand_mail.label) + bonafide = self._get_service('bonafide') + kw = {'bonafide': bonafide} + + if not mail: + return _format_result('mail: disabled') + + if subcmd == 'disable': + d = dispatch(self.core) + else: + d = dispatch(mail, *parts, **kw) + + d.addCallbacks(_format_result, _format_error) + return d + + def do_KEYS(self, *parts): + dispatch = self.subcommand_keys.dispatch + + keymanager_label = 'keymanager' + keymanager = self._get_service(keymanager_label) + bonafide = self._get_service('bonafide') + kw = {'bonafide': bonafide} + + if not keymanager: + return _format_result('keymanager: disabled') + + d = dispatch(keymanager, *parts, **kw) + d.addCallbacks(_format_result, _format_error) + return d + + def dispatch(self, msg): + cmd = msg[0] + + _method = getattr(self, 'do_' + cmd.upper(), None) + + if not _method: + return defer.fail(failure.Failure(RuntimeError('No such command'))) + + return defer.maybeDeferred(_method, *msg) + + def _get_service(self, name): + try: + return self.core.getServiceNamed(name) + except KeyError: + return None + + +def _format_result(result): + return json.dumps({'error': None, 'result': result}) + + +def _format_error(failure): + log.err(failure) + return json.dumps({'error': failure.value.message, 'result': None}) diff --git a/src/leap/bitmask/core/dummy.py b/src/leap/bitmask/core/dummy.py new file mode 100644 index 0000000..7b6be39 --- /dev/null +++ b/src/leap/bitmask/core/dummy.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# dummy.py +# Copyright (C) 2016 LEAP Encryption Acess Project +# +# 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/>. +""" +An authoritative dummy backend for tests. +""" +import json + +from leap.common.service_hooks import HookableService + + +class BackendCommands(object): + + """ + General commands for the BitmaskBackend Core Service. + """ + + def __init__(self, core): + self.core = core + + def do_status(self): + return json.dumps( + {'soledad': 'running', + 'keymanager': 'running', + 'mail': 'running', + 'eip': 'stopped', + 'backend': 'dummy'}) + + def do_version(self): + return {'version_core': '0.0.1'} + + def do_stats(self): + return {'mem_usage': '01 KB'} + + def do_shutdown(self): + return {'shutdown': 'ok'} + + +class mail_services(object): + + class SoledadService(HookableService): + pass + + class KeymanagerService(HookableService): + pass + + class StandardMailService(HookableService): + pass + + +class BonafideService(HookableService): + + def __init__(self, basedir): + pass + + def do_authenticate(self, user, password): + return {u'srp_token': u'deadbeef123456789012345678901234567890123', + u'uuid': u'01234567890abcde01234567890abcde'} + + def do_signup(self, user, password): + return {'signup': 'ok', 'user': 'dummyuser@provider.example.org'} + + def do_logout(self, user): + return {'logout': 'ok'} + + def do_get_active_user(self): + return 'dummyuser@provider.example.org' diff --git a/src/leap/bitmask/core/flags.py b/src/leap/bitmask/core/flags.py new file mode 100644 index 0000000..9a40c70 --- /dev/null +++ b/src/leap/bitmask/core/flags.py @@ -0,0 +1 @@ +BACKEND = 'default' diff --git a/src/leap/bitmask/core/launcher.py b/src/leap/bitmask/core/launcher.py new file mode 100644 index 0000000..b8916a1 --- /dev/null +++ b/src/leap/bitmask/core/launcher.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# launcher.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/>. +""" +Run bitmask daemon. +""" +from os.path import join +from sys import argv + +from twisted.scripts.twistd import run + +from leap.bitmask.util import here +from leap.bitmask import core +from leap.bitmask.core import flags + + +def run_bitmaskd(): + # TODO --- configure where to put the logs... (get --logfile, --logdir + # from the bitmask_cli + for (index, arg) in enumerate(argv): + if arg == '--backend': + flags.BACKEND = argv[index + 1] + argv[1:] = [ + '-y', join(here(core), "bitmaskd.tac"), + '--pidfile', '/tmp/bitmaskd.pid', + '--logfile', '/tmp/bitmaskd.log', + '--umask=0022', + ] + print '[+] launching bitmaskd...' + run() + + +if __name__ == "__main__": + run_bitmaskd() diff --git a/src/leap/bitmask/core/mail_services.py b/src/leap/bitmask/core/mail_services.py new file mode 100644 index 0000000..68cb444 --- /dev/null +++ b/src/leap/bitmask/core/mail_services.py @@ -0,0 +1,690 @@ +# -*- coding: utf-8 -*- +# mail_services.py +# Copyright (C) 2016 LEAP Encryption Acess Project +# +# 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/>. +""" +Mail services. + +This is quite moving work still. +This should be moved to the different packages when it stabilizes. +""" +import json +import os +from collections import defaultdict +from collections import namedtuple + +from twisted.application import service +from twisted.internet import defer +from twisted.python import log + +from leap.bonafide import config +from leap.common.service_hooks import HookableService +from leap.keymanager import KeyManager +from leap.keymanager.errors import KeyNotFound +from leap.keymanager.validation import ValidationLevels +from leap.soledad.client.api import Soledad +from leap.mail.constants import INBOX_NAME +from leap.mail.mail import Account +from leap.mail.imap.service import imap +from leap.mail.incoming.service import IncomingMail, INCOMING_CHECK_PERIOD +from leap.mail import smtp + +from leap.bitmask.core.uuid_map import UserMap +from leap.bitmask.core.configurable import DEFAULT_BASEDIR + + +class Container(object): + + def __init__(self, service=None): + self._instances = defaultdict(None) + if service is not None: + self.service = service + + def get_instance(self, key): + return self._instances.get(key, None) + + def add_instance(self, key, data): + self._instances[key] = data + + +class ImproperlyConfigured(Exception): + pass + + +class SoledadContainer(Container): + + def __init__(self, service=None, basedir=DEFAULT_BASEDIR): + self._basedir = os.path.expanduser(basedir) + self._usermap = UserMap() + super(SoledadContainer, self).__init__(service=service) + + def add_instance(self, userid, passphrase, uuid=None, token=None): + + if not uuid: + bootstrapped_uuid = self._usermap.lookup_uuid(userid, passphrase) + uuid = bootstrapped_uuid + if not uuid: + return + else: + self._usermap.add(userid, uuid, passphrase) + + user, provider = userid.split('@') + + soledad_path = os.path.join(self._basedir, 'soledad') + soledad_url = _get_soledad_uri(self._basedir, provider) + cert_path = _get_ca_cert_path(self._basedir, provider) + + soledad = self._create_soledad_instance( + uuid, passphrase, soledad_path, soledad_url, + cert_path, token) + + super(SoledadContainer, self).add_instance(userid, soledad) + + data = {'user': userid, 'uuid': uuid, 'token': token, + 'soledad': soledad} + self.service.trigger_hook('on_new_soledad_instance', **data) + + def _create_soledad_instance(self, uuid, passphrase, soledad_path, + server_url, cert_file, token): + # setup soledad info + secrets_path = os.path.join(soledad_path, '%s.secret' % uuid) + local_db_path = os.path.join(soledad_path, '%s.db' % uuid) + + if token is None: + syncable = False + token = '' + else: + syncable = True + + return Soledad( + uuid, + unicode(passphrase), + secrets_path=secrets_path, + local_db_path=local_db_path, + server_url=server_url, + cert_file=cert_file, + auth_token=token, + defer_encryption=True, + syncable=syncable) + + def set_remote_auth_token(self, userid, token): + self.get_instance(userid).token = token + + def set_syncable(self, userid, state): + # TODO should check that there's a token! + self.get_instance(userid).set_syncable(bool(state)) + + def sync(self, userid): + self.get_instance(userid).sync() + + +def _get_provider_from_full_userid(userid): + _, provider_id = config.get_username_and_provider(userid) + return config.Provider(provider_id) + + +def is_service_ready(service, provider): + """ + Returns True when the following conditions are met: + - Provider offers that service. + - We have the config files for the service. + - The service is enabled. + """ + has_service = provider.offers_service(service) + has_config = provider.has_config_for_service(service) + is_enabled = provider.is_service_enabled(service) + return has_service and has_config and is_enabled + + +class SoledadService(HookableService): + + def __init__(self, basedir): + service.Service.__init__(self) + self._basedir = basedir + + def startService(self): + log.msg('Starting Soledad Service') + self._container = SoledadContainer(service=self) + super(SoledadService, self).startService() + + # hooks + + def hook_on_passphrase_entry(self, **kw): + userid = kw.get('username') + provider = _get_provider_from_full_userid(userid) + provider.callWhenReady(self._hook_on_passphrase_entry, provider, **kw) + + def _hook_on_passphrase_entry(self, provider, **kw): + if is_service_ready('mx', provider): + userid = kw.get('username') + password = kw.get('password') + uuid = kw.get('uuid') + container = self._container + log.msg("on_passphrase_entry: New Soledad Instance: %s" % userid) + if not container.get_instance(userid): + container.add_instance(userid, password, uuid=uuid, token=None) + else: + log.msg('Service MX is not ready...') + + def hook_on_bonafide_auth(self, **kw): + userid = kw['username'] + provider = _get_provider_from_full_userid(userid) + provider.callWhenReady(self._hook_on_bonafide_auth, provider, **kw) + + def _hook_on_bonafide_auth(self, provider, **kw): + if provider.offers_service('mx'): + userid = kw['username'] + password = kw['password'] + token = kw['token'] + uuid = kw['uuid'] + + container = self._container + if container.get_instance(userid): + log.msg("Passing a new SRP Token to Soledad: %s" % userid) + container.set_remote_auth_token(userid, token) + container.set_syncable(userid, True) + else: + log.msg("Adding a new Soledad Instance: %s" % userid) + container.add_instance( + userid, password, uuid=uuid, token=token) + + +class KeymanagerContainer(Container): + + def __init__(self, service=None, basedir=DEFAULT_BASEDIR): + self._basedir = os.path.expanduser(basedir) + super(KeymanagerContainer, self).__init__(service=service) + + def add_instance(self, userid, token, uuid, soledad): + + keymanager = self._create_keymanager_instance( + userid, token, uuid, soledad) + + d = self._get_or_generate_keys(keymanager, userid) + d.addCallback(self._on_keymanager_ready_cb, userid, soledad) + return d + + def set_remote_auth_token(self, userid, token): + self.get_instance(userid)._token = token + + def _on_keymanager_ready_cb(self, keymanager, userid, soledad): + # TODO use onready-deferreds instead + super(KeymanagerContainer, self).add_instance(userid, keymanager) + + log.msg("Adding Keymanager instance for: %s" % userid) + data = {'userid': userid, 'soledad': soledad, 'keymanager': keymanager} + self.service.trigger_hook('on_new_keymanager_instance', **data) + + def _get_or_generate_keys(self, keymanager, userid): + + def if_not_found_generate(failure): + # TODO -------------- should ONLY generate if INITIAL_SYNC_DONE. + # ie: put callback on_soledad_first_sync_ready ----------------- + # -------------------------------------------------------------- + failure.trap(KeyNotFound) + log.msg("Core: Key not found. Generating key for %s" % (userid,)) + d = keymanager.gen_key() + d.addCallbacks(send_key, log_key_error("generating")) + return d + + def send_key(ignored): + # ---------------------------------------------------------------- + # It might be the case that we have generated a key-pair + # but this hasn't been successfully uploaded. How do we know that? + # XXX Should this be a method of bonafide instead? + # ----------------------------------------------------------------- + d = keymanager.send_key() + d.addCallbacks( + lambda _: log.msg( + "Key generated successfully for %s" % userid), + log_key_error("sending")) + return d + + def log_key_error(step): + def log_error(failure): + log.err("Error while %s key!" % step) + log.err(failure) + return failure + return log_error + + d = keymanager.get_key(userid, private=True, fetch_remote=False) + d.addErrback(if_not_found_generate) + d.addCallback(lambda _: keymanager) + return d + + def _create_keymanager_instance(self, userid, token, uuid, soledad): + user, provider = userid.split('@') + nickserver_uri = self._get_nicknym_uri(provider) + + cert_path = _get_ca_cert_path(self._basedir, provider) + api_uri = self._get_api_uri(provider) + + if not token: + token = self.service.tokens.get(userid) + + km_args = (userid, nickserver_uri, soledad) + + # TODO use the method in + # services.soledadbootstrapper._get_gpg_bin_path. + # That should probably live in keymanager package. + + km_kwargs = { + "token": token, "uid": uuid, + "api_uri": api_uri, "api_version": "1", + "ca_cert_path": cert_path, + "gpgbinary": "/usr/bin/gpg" + } + keymanager = KeyManager(*km_args, **km_kwargs) + return keymanager + + def _get_api_uri(self, provider): + # TODO get this from service.json (use bonafide service) + api_uri = "https://api.{provider}:4430".format( + provider=provider) + return api_uri + + def _get_nicknym_uri(self, provider): + return 'https://nicknym.{provider}:6425'.format( + provider=provider) + + +class KeymanagerService(HookableService): + + def __init__(self, basedir=DEFAULT_BASEDIR): + service.Service.__init__(self) + self._basedir = basedir + + def startService(self): + log.msg('Starting Keymanager Service') + self._container = KeymanagerContainer(self._basedir) + self._container.service = self + self.tokens = {} + super(KeymanagerService, self).startService() + + # hooks + + def hook_on_new_soledad_instance(self, **kw): + container = self._container + user = kw['user'] + token = kw['token'] + uuid = kw['uuid'] + soledad = kw['soledad'] + if not container.get_instance(user): + log.msg('Adding a new Keymanager instance for %s' % user) + if not token: + token = self.tokens.get(user) + container.add_instance(user, token, uuid, soledad) + + def hook_on_bonafide_auth(self, **kw): + userid = kw['username'] + provider = _get_provider_from_full_userid(userid) + provider.callWhenReady(self._hook_on_bonafide_auth, provider, **kw) + + def _hook_on_bonafide_auth(self, provider, **kw): + if provider.offers_service('mx'): + userid = kw['username'] + token = kw['token'] + + container = self._container + if container.get_instance(userid): + log.msg('Passing a new SRP Token to Keymanager: %s' % userid) + container.set_remote_auth_token(userid, token) + else: + log.msg('storing the keymanager token... %s ' % token) + self.tokens[userid] = token + + # commands + + def do_list_keys(self, userid, private=False): + km = self._container.get_instance(userid) + d = km.get_all_keys(private=private) + d.addCallback(lambda keys: [dict(key) for key in keys]) + return d + + def do_export(self, userid, address, private=False): + km = self._container.get_instance(userid) + d = km.get_key(address, private=private, fetch_remote=False) + d.addCallback(lambda key: dict(key)) + return d + + def do_insert(self, userid, address, rawkey, validation='Fingerprint'): + km = self._container.get_instance(userid) + validation = ValidationLevels.get(validation) + d = km.put_raw_key(rawkey, address, validation=validation) + d.addCallback(lambda _: km.get_key(address, fetch_remote=False)) + d.addCallback(lambda key: dict(key)) + return d + + @defer.inlineCallbacks + def do_delete(self, userid, address, private=False): + km = self._container.get_instance(userid) + key = yield km.get_key(address, private=private, fetch_remote=False) + km.delete_key(key) + defer.returnValue(key.fingerprint) + + +class StandardMailService(service.MultiService, HookableService): + """ + A collection of Services. + + This is the parent service, that launches 3 different services that expose + Encrypted Mail Capabilities on specific ports: + + - SMTP service, on port 2013 + - IMAP service, on port 1984 + - The IncomingMail Service, which doesn't listen on any port, but + watches and processes the Incoming Queue and saves the processed mail + into the matching INBOX. + """ + + name = 'mail' + + # TODO factor out Mail Service to inside mail package. + + subscribed_to_hooks = ('on_new_keymanager_instance',) + + def __init__(self, basedir): + self._basedir = basedir + self._soledad_sessions = {} + self._keymanager_sessions = {} + self._sendmail_opts = {} + self._service_tokens = {} + self._active_user = None + super(StandardMailService, self).__init__() + self.initializeChildrenServices() + + def initializeChildrenServices(self): + self.addService(IMAPService(self._soledad_sessions)) + self.addService(SMTPService( + self._soledad_sessions, self._keymanager_sessions, + self._sendmail_opts)) + # TODO adapt the service to receive soledad/keymanager sessions object. + # See also the TODO before IncomingMailService.startInstance + self.addService(IncomingMailService(self)) + + def startService(self): + log.msg('Starting Mail Service...') + super(StandardMailService, self).startService() + + def stopService(self): + super(StandardMailService, self).stopService() + + def startInstance(self, userid, soledad, keymanager): + username, provider = userid.split('@') + + self._soledad_sessions[userid] = soledad + self._keymanager_sessions[userid] = keymanager + + sendmail_opts = _get_sendmail_opts(self._basedir, provider, username) + self._sendmail_opts[userid] = sendmail_opts + + incoming = self.getServiceNamed('incoming_mail') + incoming.startInstance(userid) + + def registerToken(token): + self._service_tokens[userid] = token + self._active_user = userid + + d = soledad.get_or_create_service_token('mail_auth') + d.addCallback(registerToken) + return d + + def stopInstance(self): + pass + + # hooks + + def hook_on_new_keymanager_instance(self, **kw): + # XXX we can specify this as a waterfall, or just AND the two + # conditions. + userid = kw['userid'] + soledad = kw['soledad'] + keymanager = kw['keymanager'] + + # TODO --- only start instance if "autostart" is True. + self.startInstance(userid, soledad, keymanager) + + # commands + + def do_status(self): + status = 'running' if self.running else 'disabled' + return {'mail': status} + + def get_token(self): + active_user = self._active_user + if not active_user: + return defer.succeed({'user': None}) + token = self._service_tokens.get(active_user) + return defer.succeed({'user': active_user, 'token': token}) + + def do_get_smtp_cert_path(self, userid): + username, provider = userid.split('@') + return _get_smtp_client_cert_path(self._basedir, provider, username) + + # access to containers + + def get_soledad_session(self, userid): + return self._soledad_sessions.get(userid) + + def get_keymanager_session(self, userid): + return self._keymanager_sessions.get(userid) + + +class IMAPService(service.Service): + + name = 'imap' + + def __init__(self, soledad_sessions): + port, factory = imap.run_service(soledad_sessions) + + self._port = port + self._factory = factory + self._soledad_sessions = soledad_sessions + super(IMAPService, self).__init__() + + def startService(self): + log.msg('Starting IMAP Service') + super(IMAPService, self).startService() + + def stopService(self): + self._port.stopListening() + self._factory.doStop() + super(IMAPService, self).stopService() + + +class SMTPService(service.Service): + + name = 'smtp' + + def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts, + basedir=DEFAULT_BASEDIR): + + self._basedir = os.path.expanduser(basedir) + port, factory = smtp.run_service( + soledad_sessions, keymanager_sessions, sendmail_opts) + self._port = port + self._factory = factory + self._soledad_sessions = soledad_sessions + self._keymanager_sessions = keymanager_sessions + self._sendmail_opts = sendmail_opts + super(SMTPService, self).__init__() + + def startService(self): + log.msg('Starting SMTP Service') + super(SMTPService, self).startService() + + def stopService(self): + # TODO cleanup all instances + super(SMTPService, self).stopService() + + +class IncomingMailService(service.Service): + + name = 'incoming_mail' + + def __init__(self, mail_service): + super(IncomingMailService, self).__init__() + self._mail = mail_service + self._instances = {} + + def startService(self): + log.msg('Starting IncomingMail Service') + super(IncomingMailService, self).startService() + + def stopService(self): + super(IncomingMailService, self).stopService() + + # Individual accounts + + # TODO IncomingMail *IS* already a service. + # I think we should better model the current Service + # as a startInstance inside a container, and get this + # multi-tenant service inside the leap.mail.incoming.service. + # ... or just simply make it a multiService and set per-user + # instances as Child of this parent. + + def startInstance(self, userid): + soledad = self._mail.get_soledad_session(userid) + keymanager = self._mail.get_keymanager_session(userid) + + log.msg('Starting Incoming Mail instance for %s' % userid) + self._start_incoming_mail_instance( + keymanager, soledad, userid) + + def stopInstance(self, userid): + # TODO toggle offline! + pass + + def _start_incoming_mail_instance(self, keymanager, soledad, + userid, start_sync=True): + + def setUpIncomingMail(inbox): + incoming_mail = IncomingMail( + keymanager, soledad, + inbox, userid, + check_period=INCOMING_CHECK_PERIOD) + return incoming_mail + + def registerInstance(incoming_instance): + self._instances[userid] = incoming_instance + if start_sync: + incoming_instance.startService() + + acc = Account(soledad, userid) + d = acc.callWhenReady( + lambda _: acc.get_collection_by_mailbox(INBOX_NAME)) + d.addCallback(setUpIncomingMail) + d.addCallback(registerInstance) + d.addErrback(log.err) + return d + +# -------------------------------------------------------------------- +# +# config utilities. should be moved to bonafide +# + +SERVICES = ('soledad', 'smtp', 'eip') + + +Provider = namedtuple( + 'Provider', ['hostname', 'ip_address', 'location', 'port']) + +SendmailOpts = namedtuple( + 'SendmailOpts', ['cert', 'key', 'hostname', 'port']) + + +def _get_ca_cert_path(basedir, provider): + path = os.path.join( + basedir, 'providers', provider, 'keys', 'ca', 'cacert.pem') + return path + + +def _get_sendmail_opts(basedir, provider, username): + cert = _get_smtp_client_cert_path(basedir, provider, username) + key = cert + prov = _get_provider_for_service('smtp', basedir, provider) + hostname = prov.hostname + port = prov.port + opts = SendmailOpts(cert, key, hostname, port) + return opts + + +def _get_smtp_client_cert_path(basedir, provider, username): + path = os.path.join( + basedir, 'providers', provider, 'keys', 'client', 'stmp_%s.pem' % + username) + return path + + +def _get_config_for_service(service, basedir, provider): + if service not in SERVICES: + raise ImproperlyConfigured('Tried to use an unknown service') + + config_path = os.path.join( + basedir, 'providers', provider, '%s-service.json' % service) + try: + with open(config_path) as config: + config = json.loads(config.read()) + except IOError: + # FIXME might be that the provider DOES NOT offer this service! + raise ImproperlyConfigured( + 'could not open config file %s' % config_path) + else: + return config + + +def first(xs): + return xs[0] + + +def _pick_server(config, strategy=first): + """ + Picks a server from a list of possible choices. + The service files have a <describe>. + This implementation just picks the FIRST available server. + """ + servers = config['hosts'].keys() + choice = config['hosts'][strategy(servers)] + return choice + + +def _get_subdict(d, keys): + return {key: d.get(key) for key in keys} + + +def _get_provider_for_service(service, basedir, provider): + + if service not in SERVICES: + raise ImproperlyConfigured('Tried to use an unknown service') + + config = _get_config_for_service(service, basedir, provider) + p = _pick_server(config) + attrs = _get_subdict(p, ('hostname', 'ip_address', 'location', 'port')) + provider = Provider(**attrs) + return provider + + +def _get_smtp_uri(basedir, provider): + prov = _get_provider_for_service('smtp', basedir, provider) + url = 'https://{hostname}:{port}'.format( + hostname=prov.hostname, port=prov.port) + return url + + +def _get_soledad_uri(basedir, provider): + prov = _get_provider_for_service('soledad', basedir, provider) + url = 'https://{hostname}:{port}'.format( + hostname=prov.hostname, port=prov.port) + return url diff --git a/src/leap/bitmask/core/service.py b/src/leap/bitmask/core/service.py new file mode 100644 index 0000000..99132c2 --- /dev/null +++ b/src/leap/bitmask/core/service.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# service.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/>. +""" +Bitmask-core Service. +""" +import json +import resource + +from twisted.internet import reactor +from twisted.python import log + +from leap.bitmask import __version__ +from leap.bitmask.core import configurable +from leap.bitmask.core import _zmq +from leap.bitmask.core import flags +from leap.common.events import server as event_server +# from leap.vpn import EIPService + + +backend = flags.BACKEND + +if backend == 'default': + from leap.bitmask.core import mail_services + from leap.bonafide.service import BonafideService +elif backend == 'dummy': + from leap.bitmask.core.dummy import mail_services + from leap.bitmask.core.dummy import BonafideService +else: + raise RuntimeError('Backend not supported') + + +class BitmaskBackend(configurable.ConfigurableService): + + def __init__(self, basedir=configurable.DEFAULT_BASEDIR): + + configurable.ConfigurableService.__init__(self, basedir) + self.core_commands = BackendCommands(self) + + def enabled(service): + return self.get_config('services', service, False, boolean=True) + + on_start = reactor.callWhenRunning + + on_start(self.init_events) + on_start(self.init_bonafide) + + if enabled('mail'): + on_start(self.init_soledad) + on_start(self.init_keymanager) + on_start(self.init_mail) + + if enabled('eip'): + on_start(self.init_eip) + + if enabled('zmq'): + on_start(self.init_zmq) + + if enabled('web'): + on_start(self.init_web) + + def init_events(self): + event_server.ensure_server() + + def init_bonafide(self): + bf = BonafideService(self.basedir) + bf.setName("bonafide") + bf.setServiceParent(self) + # TODO ---- these hooks should be activated only if + # (1) we have enabled that service + # (2) provider offers this service + bf.register_hook('on_passphrase_entry', listener='soledad') + bf.register_hook('on_bonafide_auth', listener='soledad') + bf.register_hook('on_bonafide_auth', listener='keymanager') + + def init_soledad(self): + service = mail_services.SoledadService + sol = self._maybe_start_service( + 'soledad', service, self.basedir) + if sol: + sol.register_hook( + 'on_new_soledad_instance', listener='keymanager') + + def init_keymanager(self): + service = mail_services.KeymanagerService + km = self._maybe_start_service( + 'keymanager', service, self.basedir) + if km: + km.register_hook('on_new_keymanager_instance', listener='mail') + + def init_mail(self): + service = mail_services.StandardMailService + self._maybe_start_service('mail', service, self.basedir) + + def init_eip(self): + # FIXME -- land EIP into leap.vpn + pass + # self._maybe_start_service('eip', EIPService) + + def init_zmq(self): + zs = _zmq.ZMQServerService(self) + zs.setServiceParent(self) + + def init_web(self): + from leap.bitmask.core import websocket + ws = websocket.WebSocketsDispatcherService(self) + ws.setServiceParent(self) + + def _maybe_start_service(self, label, klass, *args, **kw): + try: + self.getServiceNamed(label) + except KeyError: + service = klass(*args, **kw) + service.setName(label) + service.setServiceParent(self) + return service + + def do_stats(self): + return self.core_commands.do_stats() + + def do_status(self): + return self.core_commands.do_status() + + def do_version(self): + return self.core_commands.do_version() + + def do_shutdown(self): + return self.core_commands.do_shutdown() + + # Service Toggling + + def do_enable_service(self, service): + assert service in self.service_names + self.set_config('services', service, 'True') + + if service == 'mail': + self.init_soledad() + self.init_keymanager() + self.init_mail() + + elif service == 'eip': + self.init_eip() + + elif service == 'zmq': + self.init_zmq() + + elif service == 'web': + self.init_web() + + return 'ok' + + def do_disable_service(self, service): + assert service in self.service_names + # TODO -- should stop also? + self.set_config('services', service, 'False') + return 'ok' + + +class BackendCommands(object): + + """ + General commands for the BitmaskBackend Core Service. + """ + + def __init__(self, core): + self.core = core + + def do_status(self): + # we may want to make this tuple a class member + services = ('soledad', 'keymanager', 'mail', 'eip') + + status = {} + for name in services: + _status = 'stopped' + try: + if self.core.getServiceNamed(name).running: + _status = 'running' + except KeyError: + pass + status[name] = _status + status['backend'] = flags.BACKEND + + return json.dumps(status) + + def do_version(self): + return {'version_core': __version__} + + def do_stats(self): + log.msg('BitmaskCore Service STATS') + mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return {'mem_usage': '%s KB' % (mem / 1024)} + + def do_shutdown(self): + self.core.stopService() + reactor.callLater(1, reactor.stop) + return {'shutdown': 'ok'} diff --git a/src/leap/bitmask/core/uuid_map.py b/src/leap/bitmask/core/uuid_map.py new file mode 100644 index 0000000..5edc721 --- /dev/null +++ b/src/leap/bitmask/core/uuid_map.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# uuid_map.py +# Copyright (C) 2015,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/>. +""" +UUID Map: a persistent mapping between user-ids and uuids. +""" + +import base64 +import os +import re + +import scrypt + +from leap.common.config import get_path_prefix + + +MAP_PATH = os.path.join(get_path_prefix(), 'leap', 'uuids') + + +class UserMap(object): + + """ + A persistent mapping between user-ids and uuids. + """ + + # TODO Add padding to the encrypted string + + def __init__(self): + self._d = {} + self._lines = set([]) + if os.path.isfile(MAP_PATH): + self.load() + + def add(self, userid, uuid, passwd): + """ + Add a new userid-uuid mapping, and encrypt the record with the user + password. + """ + self._add_to_cache(userid, uuid) + self._lines.add(_encode_uuid_map(userid, uuid, passwd)) + self.dump() + + def _add_to_cache(self, userid, uuid): + self._d[userid] = uuid + + def load(self): + """ + Load a mapping from a default file. + """ + with open(MAP_PATH, 'r') as infile: + lines = infile.readlines() + self._lines = set(lines) + + def dump(self): + """ + Dump the mapping to a default file. + """ + with open(MAP_PATH, 'w') as out: + out.write('\n'.join(self._lines)) + + def lookup_uuid(self, userid, passwd=None): + """ + Lookup the uuid for a given userid. + + If no password is given, try to lookup on cache. + Else, try to decrypt all the records that we know about with the + passed password. + """ + if not passwd: + return self._d.get(userid) + + for line in self._lines: + guess = _decode_uuid_line(line, passwd) + if guess: + record_userid, uuid = guess + if record_userid == userid: + self._add_to_cache(userid, uuid) + return uuid + + def lookup_userid(self, uuid): + """ + Get the userid for the given uuid from cache. + """ + rev_d = {v: k for (k, v) in self._d.items()} + return rev_d.get(uuid) + + +def _encode_uuid_map(userid, uuid, passwd): + data = 'userid:%s:uuid:%s' % (userid, uuid) + encrypted = scrypt.encrypt(data, passwd, maxtime=0.05) + return base64.encodestring(encrypted).replace('\n', '') + + +def _decode_uuid_line(line, passwd): + decoded = base64.decodestring(line) + try: + maybe_decrypted = scrypt.decrypt(decoded, passwd, maxtime=0.1) + except scrypt.error: + return None + match = re.findall("userid\:(.+)\:uuid\:(.+)", maybe_decrypted) + if match: + return match[0] diff --git a/src/leap/bitmask/core/web/__init__.py b/src/leap/bitmask/core/web/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/leap/bitmask/core/web/__init__.py diff --git a/src/leap/bitmask/core/web/index.html b/src/leap/bitmask/core/web/index.html new file mode 100644 index 0000000..9490eca --- /dev/null +++ b/src/leap/bitmask/core/web/index.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html> + <head> + <title>Bitmask WebSockets Endpoint</title> + <script type="text/javascript"> + var sock = null; + var ellog = null; + + window.onload = function() { + + ellog = document.getElementById('log'); + + var wsuri; + if (window.location.protocol === "file:") { + wsuri = "ws://127.0.0.1:8080/ws"; + } else { + wsuri = "ws://" + window.location.hostname + ":8080/bitmask"; + } + + if ("WebSocket" in window) { + sock = new WebSocket(wsuri); + } else if ("MozWebSocket" in window) { + sock = new MozWebSocket(wsuri); + } else { + log("Browser does not support WebSocket!"); + window.location = "http://autobahn.ws/unsupportedbrowser"; + } + + if (sock) { + sock.onopen = function() { + log("Connected to " + wsuri); + } + + sock.onclose = function(e) { + log("Connection closed (wasClean = " + e.wasClean + ", code = " + e.code + ", reason = '" + e.reason + "')"); + sock = null; + } + + sock.onmessage = function(e) { + log("[res] " + e.data + '\n'); + } + } + }; + + function send() { + var msg = document.getElementById('message').value; + if (sock) { + sock.send(msg); + log("[cmd] " + msg); + } else { + log("Not connected."); + } + }; + + function log(m) { + ellog.innerHTML += m + '\n'; + ellog.scrollTop = ellog.scrollHeight; + }; + </script> + </head> + <body> + <h1>Bitmask Control Panel</h1> + <noscript>You must enable JavaScript</noscript> + <form> + <p>Command: <input id="message" type="text" size="50" maxlength="50" value="status"></p> + </form> + <button onclick='send();'>Send Command</button> + <pre id="log" style="height: 20em; overflow-y: scroll; background-color: #faa;"></pre> + </body> +</html> diff --git a/src/leap/bitmask/core/web/root.py b/src/leap/bitmask/core/web/root.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/leap/bitmask/core/web/root.py diff --git a/src/leap/bitmask/core/websocket.py b/src/leap/bitmask/core/websocket.py new file mode 100644 index 0000000..5569c6c --- /dev/null +++ b/src/leap/bitmask/core/websocket.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# websocket.py +# Copyright (C) 2015, 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/>. + +""" +WebSockets Dispatcher Service. +""" + +import os +import pkg_resources + +from twisted.internet import reactor +from twisted.application import service + +from twisted.web.server import Site +from twisted.web.static import File + +from autobahn.twisted.resource import WebSocketResource +from autobahn.twisted.websocket import WebSocketServerFactory +from autobahn.twisted.websocket import WebSocketServerProtocol + +from leap.bitmask.core.dispatcher import CommandDispatcher + + +class WebSocketsDispatcherService(service.Service): + + """ + A Dispatcher for BitmaskCore exposing a WebSockets Endpoint. + """ + + def __init__(self, core, port=8080, debug=False): + self._core = core + self.port = port + self.debug = debug + + def startService(self): + + factory = WebSocketServerFactory(u"ws://127.0.0.1:%d" % self.port, + debug=self.debug) + factory.protocol = DispatcherProtocol + factory.protocol.dispatcher = CommandDispatcher(self._core) + + # FIXME: Site.start/stopFactory should start/stop factories wrapped as + # Resources + factory.startFactory() + + resource = WebSocketResource(factory) + + # we server static files under "/" .. + webdir = os.path.abspath( + pkg_resources.resource_filename("leap.bitmask.core", "web")) + root = File(webdir) + + # and our WebSocket server under "/ws" + root.putChild(u"bitmask", resource) + + # both under one Twisted Web Site + site = Site(root) + + self.site = site + self.factory = factory + + self.listener = reactor.listenTCP(self.port, site) + + def stopService(self): + self.factory.stopFactory() + self.site.stopFactory() + self.listener.stopListening() + + +class DispatcherProtocol(WebSocketServerProtocol): + + def onMessage(self, msg, binary): + parts = msg.split() + r = self.dispatcher.dispatch(parts) + r.addCallback(self.defer_reply, binary) + + def reply(self, response, binary): + self.sendMessage(response, binary) + + def defer_reply(self, response, binary): + reactor.callLater(0, self.reply, response, binary) + + def _get_service(self, name): + return self.core.getServiceNamed(name) |