summaryrefslogtreecommitdiff
path: root/src/leap/bitmask/core
diff options
context:
space:
mode:
authorKali Kaneko <kali@leap.se>2016-03-04 12:02:26 -0400
committerKali Kaneko <kali@leap.se>2016-04-19 09:59:25 -0400
commitf4503842fdc288fb6336211099ad0a8d01165df3 (patch)
tree05bb4d2a2a2be4fb02d8048df5c426f2b0e20243 /src/leap/bitmask/core
parent6fd1c73db49c5e1e08cf7963017470511fef0059 (diff)
[feature] landing of bitmask.core
Diffstat (limited to 'src/leap/bitmask/core')
-rw-r--r--src/leap/bitmask/core/__init__.py2
-rw-r--r--src/leap/bitmask/core/_zmq.py68
-rw-r--r--src/leap/bitmask/core/bitmaskd.tac11
-rw-r--r--src/leap/bitmask/core/configurable.py262
-rw-r--r--src/leap/bitmask/core/dispatcher.py196
-rw-r--r--src/leap/bitmask/core/launcher.py37
-rw-r--r--src/leap/bitmask/core/mail_services.py672
-rw-r--r--src/leap/bitmask/core/service.py174
-rw-r--r--src/leap/bitmask/core/uuid_map.py115
-rw-r--r--src/leap/bitmask/core/web/__init__.py0
-rw-r--r--src/leap/bitmask/core/web/index.html70
-rw-r--r--src/leap/bitmask/core/web/root.py0
-rw-r--r--src/leap/bitmask/core/websocket.py98
13 files changed, 1705 insertions, 0 deletions
diff --git a/src/leap/bitmask/core/__init__.py b/src/leap/bitmask/core/__init__.py
new file mode 100644
index 00000000..bda4b8d0
--- /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 00000000..a656fc65
--- /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/bitmaskd.tac b/src/leap/bitmask/core/bitmaskd.tac
new file mode 100644
index 00000000..3c9b1d8b
--- /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 00000000..3b97916d
--- /dev/null
+++ b/src/leap/bitmask/core/configurable.py
@@ -0,0 +1,262 @@
+# -*- 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 locale
+import os
+import re
+import sys
+
+from twisted.application import service
+from twisted.python import log
+
+from leap.common import files
+
+
+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='~/.config/leap'):
+ 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 = os.path.join(self.basedir, self.config_file)
+ raise MissingConfigEntry("%s is missing the [%s]%s entry"
+ % (_quote_output(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)
+
+ try:
+ with open(bitmaskd_cfg, "rb") as f:
+ self.config.readfp(f)
+ except EnvironmentError:
+ if os.path.exists(bitmaskd_cfg):
+ raise
+
+ 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
+"""
+
+
+def canonical_encoding(encoding):
+ if encoding is None:
+ log.msg("Warning: falling back to UTF-8 encoding.", level=log.WEIRD)
+ encoding = 'utf-8'
+ encoding = encoding.lower()
+ if encoding == "cp65001":
+ encoding = 'utf-8'
+ elif (encoding == "us-ascii" or encoding == "646" or encoding ==
+ "ansi_x3.4-1968"):
+ encoding = 'ascii'
+
+ return encoding
+
+
+def check_encoding(encoding):
+ # sometimes Python returns an encoding name that it doesn't support for
+ # conversion fail early if this happens
+ try:
+ u"test".encode(encoding)
+ except (LookupError, AttributeError):
+ raise AssertionError(
+ "The character encoding '%s' is not supported for conversion." % (
+ encoding,))
+
+filesystem_encoding = None
+io_encoding = None
+is_unicode_platform = False
+
+
+def _reload():
+ global filesystem_encoding, io_encoding, is_unicode_platform
+
+ filesystem_encoding = canonical_encoding(sys.getfilesystemencoding())
+ check_encoding(filesystem_encoding)
+
+ if sys.platform == 'win32':
+ # On Windows we install UTF-8 stream wrappers for sys.stdout and
+ # sys.stderr, and reencode the arguments as UTF-8 (see
+ # scripts/runner.py).
+ io_encoding = 'utf-8'
+ else:
+ ioenc = None
+ if hasattr(sys.stdout, 'encoding'):
+ ioenc = sys.stdout.encoding
+ if ioenc is None:
+ try:
+ ioenc = locale.getpreferredencoding()
+ except Exception:
+ pass # work around <http://bugs.python.org/issue1443504>
+ io_encoding = canonical_encoding(ioenc)
+
+ check_encoding(io_encoding)
+
+ is_unicode_platform = sys.platform in ["win32", "darwin"]
+
+_reload()
+
+
+def _quote_output(s, quotemarks=True, quote_newlines=None, encoding=None):
+ """
+ Encode either a Unicode string or a UTF-8-encoded bytestring for
+ representation on stdout or stderr, tolerating errors. If 'quotemarks' is
+ True, the string is always quoted; otherwise, it is quoted only if
+ necessary to avoid ambiguity or control bytes in the output. (Newlines are
+ counted as control bytes iff quote_newlines is True.)
+
+ Quoting may use either single or double quotes. Within single quotes, all
+ characters stand for themselves, and ' will not appear. Within double
+ quotes, Python-compatible backslash escaping is used.
+
+ If not explicitly given, quote_newlines is True when quotemarks is True.
+ """
+ assert isinstance(s, (str, unicode))
+ if quote_newlines is None:
+ quote_newlines = quotemarks
+
+ if isinstance(s, str):
+ try:
+ s = s.decode('utf-8')
+ except UnicodeDecodeError:
+ return 'b"%s"' % (
+ ESCAPABLE_8BIT.sub(
+ lambda m: _str_escape(m, quote_newlines), s),)
+
+ must_double_quote = (quote_newlines and MUST_DOUBLE_QUOTE_NL or
+ MUST_DOUBLE_QUOTE)
+ if must_double_quote.search(s) is None:
+ try:
+ out = s.encode(encoding or io_encoding)
+ if quotemarks or out.startswith('"'):
+ return "'%s'" % (out,)
+ else:
+ return out
+ except (UnicodeDecodeError, UnicodeEncodeError):
+ pass
+
+ escaped = ESCAPABLE_UNICODE.sub(
+ lambda m: _unicode_escape(m, quote_newlines), s)
+ return '"%s"' % (
+ escaped.encode(encoding or io_encoding, 'backslashreplace'),)
+
+
+def _unicode_escape(m, quote_newlines):
+ u = m.group(0)
+ if u == u'"' or u == u'$' or u == u'`' or u == u'\\':
+ return u'\\' + u
+ elif u == u'\n' and not quote_newlines:
+ return u
+ if len(u) == 2:
+ codepoint = (
+ ord(u[0]) - 0xD800) * 0x400 + ord(u[1]) - 0xDC00 + 0x10000
+ else:
+ codepoint = ord(u)
+ if codepoint > 0xFFFF:
+ return u'\\U%08x' % (codepoint,)
+ elif codepoint > 0xFF:
+ return u'\\u%04x' % (codepoint,)
+ else:
+ return u'\\x%02x' % (codepoint,)
+
+
+def _str_escape(m, quote_newlines):
+ c = m.group(0)
+ if c == '"' or c == '$' or c == '`' or c == '\\':
+ return '\\' + c
+ elif c == '\n' and not quote_newlines:
+ return c
+ else:
+ return '\\x%02x' % (ord(c),)
+
+MUST_DOUBLE_QUOTE_NL = re.compile(
+ ur'[^\x20-\x26\x28-\x7E\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFC]',
+ re.DOTALL)
+MUST_DOUBLE_QUOTE = re.compile(
+ ur'[^\n\x20-\x26\x28-\x7E\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFC]',
+ re.DOTALL)
+
+ESCAPABLE_8BIT = re.compile(
+ r'[^ !#\x25-\x5B\x5D-\x5F\x61-\x7E]',
+ re.DOTALL)
+
+# if we must double-quote, then we have to escape ", $ and `, but need not
+# escape '
+
+ESCAPABLE_UNICODE = re.compile(
+ ur'([\uD800-\uDBFF][\uDC00-\uDFFF])|' # valid surrogate pairs
+ ur'[^ !#\x25-\x5B\x5D-\x5F\x61-\x7E\u00A0-\uD7FF'
+ ur'\uE000-\uFDCF\uFDF0-\uFFFC]',
+ re.DOTALL)
diff --git a/src/leap/bitmask/core/dispatcher.py b/src/leap/bitmask/core/dispatcher.py
new file mode 100644
index 00000000..4d7e1813
--- /dev/null
+++ b/src/leap/bitmask/core/dispatcher.py
@@ -0,0 +1,196 @@
+# -*- 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
+
+
+# TODO implement sub-classes to dispatch subcommands (user, mail).
+
+
+class CommandDispatcher(object):
+
+ def __init__(self, core):
+
+ self.core = core
+
+ def _get_service(self, name):
+
+ try:
+ return self.core.getServiceNamed(name)
+ except KeyError:
+ return None
+
+ 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 do_STATS(self, *parts):
+ return _format_result(self.core.do_stats())
+
+ def do_VERSION(self, *parts):
+ return _format_result(self.core.do_version())
+
+ def do_STATUS(self, *parts):
+ return _format_result(self.core.do_status())
+
+ def do_SHUTDOWN(self, *parts):
+ return _format_result(self.core.do_shutdown())
+
+ def do_USER(self, *parts):
+
+ subcmd = parts[1]
+ user, password = parts[2], parts[3]
+
+ bf = self._get_service('bonafide')
+
+ if subcmd == 'authenticate':
+ d = bf.do_authenticate(user, password)
+
+ elif subcmd == 'signup':
+ d = bf.do_signup(user, password)
+
+ elif subcmd == 'logout':
+ d = bf.do_logout(user, password)
+
+ elif subcmd == 'active':
+ d = bf.do_get_active_user()
+
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+ def do_EIP(self, *parts):
+ subcmd = parts[1]
+ eip_label = 'eip'
+
+ if subcmd == 'enable':
+ return _format_result(
+ self.core.do_enable_service(eip_label))
+
+ eip = self._get_service(eip_label)
+ if not eip:
+ return _format_result('eip: disabled')
+
+ if subcmd == 'status':
+ return _format_result(eip.do_status())
+
+ elif subcmd == 'disable':
+ return _format_result(
+ self.core.do_disable_service(eip_label))
+
+ elif subcmd == 'start':
+ # TODO --- attempt to get active provider
+ # TODO or catch the exception and send error
+ provider = parts[2]
+ d = eip.do_start(provider)
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+ elif subcmd == 'stop':
+ d = eip.do_stop()
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+ def do_MAIL(self, *parts):
+
+ subcmd = parts[1]
+ mail_label = 'mail'
+
+ if subcmd == 'enable':
+ return _format_result(
+ self.core.do_enable_service(mail_label))
+
+ m = self._get_service(mail_label)
+ bf = self._get_service('bonafide')
+
+ if not m:
+ return _format_result('mail: disabled')
+
+ if subcmd == 'status':
+ return _format_result(m.do_status())
+
+ elif subcmd == 'disable':
+ return _format_result(self.core.do_disable_service(mail_label))
+
+ elif subcmd == 'get_imap_token':
+ d = m.get_imap_token()
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+ elif subcmd == 'get_smtp_token':
+ d = m.get_smtp_token()
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+ elif subcmd == 'get_smtp_certificate':
+ # 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 m.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)
+
+ d = bf.do_get_smtp_cert()
+ d.addCallback(save_cert)
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+ def do_KEYS(self, *parts):
+ subcmd = parts[1]
+
+ keymanager_label = 'keymanager'
+ km = self._get_service(keymanager_label)
+ bf = self._get_service('bonafide')
+
+ if not km:
+ return _format_result('keymanager: disabled')
+
+ if subcmd == 'list_keys':
+ d = bf.do_get_active_user()
+ d.addCallback(km.do_list_keys)
+ d.addCallbacks(_format_result, _format_error)
+ return d
+
+
+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/launcher.py b/src/leap/bitmask/core/launcher.py
new file mode 100644
index 00000000..7d658017
--- /dev/null
+++ b/src/leap/bitmask/core/launcher.py
@@ -0,0 +1,37 @@
+# -*- 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 twisted.scripts.twistd import run
+from os.path import join, dirname
+from sys import argv
+
+from leap.bitmask import core
+
+
+def run_bitmaskd():
+ # TODO --- configure where to put the logs... (get --logfile, --logdir
+ # from the bitmask_cli
+ argv[1:] = [
+ '-y', join(dirname(core.__file__), "bitmaskd.tac"),
+ '--pidfile', '/tmp/bitmaskd.pid',
+ '--logfile', '/tmp/bitmaskd.log',
+ '--umask=0022',
+ ]
+ print '[+] launching bitmaskd...'
+ run()
diff --git a/src/leap/bitmask/core/mail_services.py b/src/leap/bitmask/core/mail_services.py
new file mode 100644
index 00000000..9858d498
--- /dev/null
+++ b/src/leap/bitmask/core/mail_services.py
@@ -0,0 +1,672 @@
+"""
+Mail services.
+
+This is quite moving work still.
+This should be moved to the different packages when it stabilizes.
+"""
+import json
+import os
+from glob import glob
+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, openpgp
+from leap.keymanager.errors import KeyNotFound
+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
+
+
+class Container(object):
+
+ def __init__(self):
+ self._instances = defaultdict(None)
+
+ def get_instance(self, key):
+ return self._instances.get(key, None)
+
+
+class ImproperlyConfigured(Exception):
+ pass
+
+
+def get_all_soledad_uuids():
+ return [os.path.split(p)[-1].split('.db')[0] for p in
+ glob(os.path.expanduser('~/.config/leap/soledad/*.db'))]
+ # FIXME do not hardcode basedir
+
+
+class SoledadContainer(Container):
+
+ def __init__(self, basedir='~/.config/leap'):
+ # FIXME do not hardcode basedir
+ self._basedir = os.path.expanduser(basedir)
+ self._usermap = UserMap()
+ super(SoledadContainer, self).__init__()
+
+ 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)
+
+ self._instances[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, basedir, server_url,
+ cert_file, token):
+ # setup soledad info
+ secrets_path = os.path.join(
+ basedir, '%s.secret' % uuid)
+ local_db_path = os.path.join(
+ basedir, '%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(service.Service, HookableService):
+
+ subscribed_to_hooks = ('on_bonafide_auth', 'on_passphrase_entry')
+
+ def __init__(self, basedir):
+ service.Service.__init__(self)
+ self._basedir = basedir
+
+ def startService(self):
+ log.msg('Starting Soledad Service')
+ self._container = SoledadContainer()
+ self._container.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, basedir):
+ self._basedir = os.path.expanduser(basedir)
+ super(KeymanagerContainer, self).__init__()
+
+ 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
+ self._instances[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(openpgp.OpenPGPKey)
+ 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(openpgp.OpenPGPKey)
+ 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, openpgp.OpenPGPKey, 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)
+ 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(service.Service, HookableService):
+
+ subscribed_to_hooks = ('on_new_soledad_instance', 'on_bonafide_auth')
+
+ def __init__(self, basedir='~/.config/leap'):
+ 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...')
+ self.tokens[userid] = token
+
+ # commands
+
+ def do_list_keys(self, userid):
+ km = self._container.get_instance(userid)
+ d = km.get_all_keys()
+ d.addCallback(
+ lambda keys: [
+ (key.uids, key.fingerprint) for key in keys])
+ return d
+
+
+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._imap_tokens = {}
+ self._smtp_tokens = {}
+ self._active_user = None
+ super(StandardMailService, self).__init__()
+ self.initializeChildrenServices()
+
+ def initializeChildrenServices(self):
+ self.addService(IMAPService(self._soledad_sessions))
+ self.addService(IncomingMailService(self))
+ self.addService(SMTPService(
+ self._soledad_sessions, self._keymanager_sessions,
+ self._sendmail_opts))
+
+ 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 registerIMAPToken(token):
+ self._imap_tokens[userid] = token
+ self._active_user = userid
+ return token
+
+ def registerSMTPToken(token):
+ self._smtp_tokens[userid] = token
+ return token
+
+ d = soledad.get_or_create_service_token('imap')
+ d.addCallback(registerIMAPToken)
+ d.addCallback(
+ lambda _: soledad.get_or_create_service_token('smtp'))
+ d.addCallback(registerSMTPToken)
+ 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):
+ return 'mail: %s' % 'running' if self.running else 'disabled'
+
+ def get_imap_token(self):
+ active_user = self._active_user
+ if not active_user:
+ return defer.succeed('NO ACTIVE USER')
+ token = self._imap_tokens.get(active_user)
+ return defer.succeed("IMAP TOKEN (%s): %s" % (active_user, token))
+
+ def get_smtp_token(self):
+ active_user = self._active_user
+ if not active_user:
+ return defer.succeed('NO ACTIVE USER')
+ token = self._smtp_tokens.get(active_user)
+ return defer.succeed("SMTP TOKEN (%s): %s" % (active_user, 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='~/.config/leap'):
+
+ 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)
+ 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 00000000..4c18ab5d
--- /dev/null
+++ b/src/leap/bitmask/core/service.py
@@ -0,0 +1,174 @@
+# -*- 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 resource
+
+from twisted.internet import reactor
+from twisted.python import log
+
+from leap.bonafide.service import BonafideService
+
+from leap.bitmask.core import configurable
+from leap.bitmask.core import mail_services
+from leap.bitmask.core import _zmq
+from leap.bitmask.core import websocket
+from leap.bitmask.core._version import get_versions
+
+from leap.common.events import server as event_server
+from leap.vpn import EIPService
+
+
+class BitmaskBackend(configurable.ConfigurableService):
+
+ def __init__(self, basedir='~/.config/leap'):
+
+ configurable.ConfigurableService.__init__(self, basedir)
+
+ 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):
+ self._maybe_start_service('eip', EIPService)
+
+ def init_zmq(self):
+ zs = _zmq.ZMQServerService(self)
+ zs.setServiceParent(self)
+
+ def init_web(self):
+ 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
+
+ # General commands for the BitmaskBackend Core Service
+
+ def do_stats(self):
+ log.msg('BitmaskCore Service STATS')
+ mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
+ return 'BitmaskCore: [Mem usage: %s KB]' % (mem / 1024)
+
+ def do_status(self):
+ # we may want to make this tuple a class member
+ services = ('soledad', 'keymanager', 'mail', 'eip')
+
+ status_messages = []
+ for name in services:
+ status = 'stopped'
+ try:
+ if self.getServiceNamed(name).running:
+ status = "running"
+ except KeyError:
+ pass
+ status_messages.append("[{}: {}]".format(name, status))
+
+ return " ".join(status_messages)
+
+ def do_version(self):
+ version = get_versions()['version']
+ return 'BitmaskCore: %s' % version
+
+ def do_shutdown(self):
+ self.stopService()
+ reactor.callLater(1, reactor.stop)
+ return 'shutting down...'
+
+ 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'
diff --git a/src/leap/bitmask/core/uuid_map.py b/src/leap/bitmask/core/uuid_map.py
new file mode 100644
index 00000000..5edc7216
--- /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 00000000..e69de29b
--- /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 00000000..9490eca8
--- /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 00000000..e69de29b
--- /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 00000000..5569c6c7
--- /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)