summaryrefslogtreecommitdiff
path: root/bonafide/src/leap
diff options
context:
space:
mode:
authorKali Kaneko (leap communications) <kali@leap.se>2016-08-29 22:58:18 -0400
committerKali Kaneko (leap communications) <kali@leap.se>2016-08-29 22:58:18 -0400
commit52abf28677a90780505228dbd6bcba54780766b8 (patch)
treee272e268333f8294022670a84e7c6dbfc3fc4021 /bonafide/src/leap
parent4401e9b2e14cba93adb2534ddda94b657caa58e4 (diff)
parent15394a9cefdf6f8ca9cbb6604c6f68425577ae90 (diff)
Merge bonafide repo into unified bitmask repo
Diffstat (limited to 'bonafide/src/leap')
-rw-r--r--bonafide/src/leap/__init__.py6
-rw-r--r--bonafide/src/leap/bonafide/__init__.py4
-rw-r--r--bonafide/src/leap/bonafide/_http.py95
-rw-r--r--bonafide/src/leap/bonafide/_protocol.py174
-rw-r--r--bonafide/src/leap/bonafide/_srp.py147
-rw-r--r--bonafide/src/leap/bonafide/_version.py460
-rw-r--r--bonafide/src/leap/bonafide/bootstrap.py0
-rw-r--r--bonafide/src/leap/bonafide/config.py508
-rw-r--r--bonafide/src/leap/bonafide/cred_srp.py157
-rw-r--r--bonafide/src/leap/bonafide/provider.py186
-rw-r--r--bonafide/src/leap/bonafide/service.py120
-rw-r--r--bonafide/src/leap/bonafide/services/TODO1
-rw-r--r--bonafide/src/leap/bonafide/services/__init__.py0
-rw-r--r--bonafide/src/leap/bonafide/services/eip.py0
-rw-r--r--bonafide/src/leap/bonafide/services/mail.py0
-rw-r--r--bonafide/src/leap/bonafide/services/soledad.py0
-rw-r--r--bonafide/src/leap/bonafide/session.py219
-rw-r--r--bonafide/src/leap/bonafide/ssh_service.py2
-rw-r--r--bonafide/src/leap/bonafide/tests/__init__.py0
19 files changed, 2079 insertions, 0 deletions
diff --git a/bonafide/src/leap/__init__.py b/bonafide/src/leap/__init__.py
new file mode 100644
index 00000000..f48ad105
--- /dev/null
+++ b/bonafide/src/leap/__init__.py
@@ -0,0 +1,6 @@
+# See http://peak.telecommunity.com/DevCenter/setuptools#namespace-packages
+try:
+ __import__('pkg_resources').declare_namespace(__name__)
+except ImportError:
+ from pkgutil import extend_path
+ __path__ = extend_path(__path__, __name__)
diff --git a/bonafide/src/leap/bonafide/__init__.py b/bonafide/src/leap/bonafide/__init__.py
new file mode 100644
index 00000000..74f4e668
--- /dev/null
+++ b/bonafide/src/leap/bonafide/__init__.py
@@ -0,0 +1,4 @@
+
+from ._version import get_versions
+__version__ = get_versions()['version']
+del get_versions
diff --git a/bonafide/src/leap/bonafide/_http.py b/bonafide/src/leap/bonafide/_http.py
new file mode 100644
index 00000000..8f05b421
--- /dev/null
+++ b/bonafide/src/leap/bonafide/_http.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# _http.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/>.
+
+"""
+twisted.web utils for bonafide.
+"""
+import base64
+import cookielib
+import urllib
+
+from twisted.internet import defer, protocol, reactor
+from twisted.internet.ssl import Certificate
+from twisted.python.filepath import FilePath
+from twisted.web.client import Agent, CookieAgent
+from twisted.web.client import BrowserLikePolicyForHTTPS
+from twisted.web.http_headers import Headers
+from twisted.web.iweb import IBodyProducer
+from zope.interface import implements
+
+
+def cookieAgentFactory(verify_path, connectTimeout=30):
+ customPolicy = BrowserLikePolicyForHTTPS(
+ Certificate.loadPEM(FilePath(verify_path).getContent()))
+ agent = Agent(reactor, customPolicy, connectTimeout=connectTimeout)
+ cookiejar = cookielib.CookieJar()
+ return CookieAgent(agent, cookiejar)
+
+
+def httpRequest(agent, url, values={}, headers={}, method='POST', token=None):
+ data = ''
+ if values:
+ data = urllib.urlencode(values)
+ headers['Content-Type'] = ['application/x-www-form-urlencoded']
+
+ if token:
+ headers['Authorization'] = ['Token token="%s"' % (bytes(token))]
+
+ def handle_response(response):
+ # print "RESPONSE CODE", response.code
+ if response.code == 204:
+ d = defer.succeed('')
+ else:
+ class SimpleReceiver(protocol.Protocol):
+ def __init__(s, d):
+ s.buf = ''
+ s.d = d
+
+ def dataReceived(s, data):
+ s.buf += data
+
+ def connectionLost(s, reason):
+ # TODO: test if reason is twisted.web.client.ResponseDone,
+ # if not, do an errback
+ s.d.callback(s.buf)
+ d = defer.Deferred()
+ response.deliverBody(SimpleReceiver(d))
+ return d
+
+ d = agent.request(method, url, Headers(headers),
+ StringProducer(data) if data else None)
+ d.addCallback(handle_response)
+ return d
+
+
+class StringProducer(object):
+
+ implements(IBodyProducer)
+
+ def __init__(self, body):
+ self.body = body
+ self.length = len(body)
+
+ def startProducing(self, consumer):
+ consumer.write(self.body)
+ return defer.succeed(None)
+
+ def pauseProducing(self):
+ pass
+
+ def stopProducing(self):
+ pass
diff --git a/bonafide/src/leap/bonafide/_protocol.py b/bonafide/src/leap/bonafide/_protocol.py
new file mode 100644
index 00000000..726185ea
--- /dev/null
+++ b/bonafide/src/leap/bonafide/_protocol.py
@@ -0,0 +1,174 @@
+# -*- coding: utf-8 -*-
+# _protocol.py
+# Copyright (C) 2014-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/>.
+"""
+Bonafide protocol.
+"""
+import os
+import resource
+from collections import defaultdict
+
+from leap.bonafide import config
+from leap.bonafide.provider import Api
+from leap.bonafide.session import Session, OK
+from leap.common.config import get_path_prefix
+
+from twisted.cred.credentials import UsernamePassword
+from twisted.internet.defer import fail
+from twisted.python import log
+
+
+# TODO [ ] enable-disable services
+# TODO [ ] read provider info
+
+COMMANDS = 'signup', 'authenticate', 'logout', 'stats'
+_preffix = get_path_prefix()
+
+
+class BonafideProtocol(object):
+ """
+ Expose the protocol that interacts with the Bonafide Service API.
+ """
+
+ _apis = defaultdict(None)
+ _sessions = defaultdict(None)
+
+ def _get_api(self, provider):
+ # TODO should get deferred
+ if provider.domain in self._apis:
+ return self._apis[provider.domain]
+
+ # TODO defer the autoconfig for the provider if needed...
+ api = Api(provider.api_uri, provider.version)
+ self._apis[provider.domain] = api
+ return api
+
+ def _get_session(self, provider, full_id, password=""):
+ if full_id in self._sessions:
+ return self._sessions[full_id]
+
+ # TODO if password/username null, then pass AnonymousCreds
+ # TODO use twisted.cred instead
+ username, provider_id = config.get_username_and_provider(full_id)
+ credentials = UsernamePassword(username, password)
+ api = self._get_api(provider)
+ provider_pem = _get_provider_ca_path(provider_id)
+ session = Session(credentials, api, provider_pem)
+ self._sessions[full_id] = session
+ return session
+
+ def _del_session_errback(self, failure, full_id):
+ if full_id in self._sessions:
+ del self._sessions[full_id]
+ return failure
+
+ # Service public methods
+
+ def do_signup(self, full_id, password):
+ log.msg('SIGNUP for %s' % full_id)
+ _, provider_id = config.get_username_and_provider(full_id)
+
+ provider = config.Provider(provider_id)
+ d = provider.callWhenReady(
+ self._do_signup, provider, full_id, password)
+ return d
+
+ def _do_signup(self, provider, full_id, password):
+
+ # XXX check it's unauthenticated
+ def return_user(result, _session):
+ return_code, user = result
+ if return_code == OK:
+ return user
+
+ username, _ = config.get_username_and_provider(full_id)
+ # XXX get deferred?
+ session = self._get_session(provider, full_id, password)
+ d = session.signup(username, password)
+ d.addCallback(return_user, session)
+ d.addErrback(self._del_session_errback, full_id)
+ return d
+
+ def do_authenticate(self, full_id, password):
+ _, provider_id = config.get_username_and_provider(full_id)
+
+ provider = config.Provider(provider_id)
+
+ def maybe_finish_provider_bootstrap(result, provider):
+ session = self._get_session(provider, full_id, password)
+ d = provider.download_services_config_with_auth(session)
+ d.addCallback(lambda _: result)
+ return d
+
+ d = provider.callWhenReady(
+ self._do_authenticate, provider, full_id, password)
+ d.addCallback(maybe_finish_provider_bootstrap, provider)
+ return d
+
+ def _do_authenticate(self, provider, full_id, password):
+
+ def return_token_and_uuid(result, _session):
+ if result == OK:
+ # TODO -- turn this into JSON response
+ return str(_session.token), str(_session.uuid)
+
+ log.msg('AUTH for %s' % full_id)
+
+ # XXX get deferred?
+ session = self._get_session(provider, full_id, password)
+ d = session.authenticate()
+ d.addCallback(return_token_and_uuid, session)
+ d.addErrback(self._del_session_errback, full_id)
+ return d
+
+ def do_logout(self, full_id):
+ # XXX use the AVATAR here
+ log.msg('LOGOUT for %s' % full_id)
+ if (full_id not in self._sessions or
+ not self._sessions[full_id].is_authenticated):
+ return fail(RuntimeError("There is no session for such user"))
+ session = self._sessions[full_id]
+
+ d = session.logout()
+ d.addCallback(lambda _: self._sessions.pop(full_id))
+ d.addCallback(lambda _: '%s logged out' % full_id)
+ return d
+
+ def do_get_smtp_cert(self, full_id):
+ if (full_id not in self._sessions or
+ not self._sessions[full_id].is_authenticated):
+ return fail(RuntimeError("There is no session for such user"))
+ d = self._sessions[full_id].get_smtp_cert()
+ return d
+
+ def do_get_vpn_cert(self):
+ # FIXME to be implemented
+ pass
+
+ def do_update_user(self):
+ # FIXME to be implemented
+ pass
+
+ def do_stats(self):
+ log.msg('Calculating Bonafide Service STATS')
+ mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
+ return {'sessions': len(self._sessions),
+ 'mem': '%s KB' % (mem / 1024)}
+
+
+def _get_provider_ca_path(provider_id):
+ return os.path.join(
+ _preffix, 'leap', 'providers', provider_id, 'keys', 'ca', 'cacert.pem')
diff --git a/bonafide/src/leap/bonafide/_srp.py b/bonafide/src/leap/bonafide/_srp.py
new file mode 100644
index 00000000..38f657b3
--- /dev/null
+++ b/bonafide/src/leap/bonafide/_srp.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+# _srp.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/>.
+
+"""
+SRP Authentication.
+"""
+
+import binascii
+import json
+
+import srp
+
+
+class SRPAuthMechanism(object):
+
+ """
+ Implement a protocol-agnostic SRP Authentication mechanism.
+ """
+
+ def __init__(self, username, password):
+ self.username = username
+ self.srp_user = srp.User(username, password,
+ srp.SHA256, srp.NG_1024)
+ _, A = self.srp_user.start_authentication()
+ self.A = A
+ self.M = None
+ self.M2 = None
+
+ def get_handshake_params(self):
+ return {'login': bytes(self.username),
+ 'A': binascii.hexlify(self.A)}
+
+ def process_handshake(self, handshake_response):
+ challenge = json.loads(handshake_response)
+ self._check_for_errors(challenge)
+ salt = challenge.get('salt', None)
+ B = challenge.get('B', None)
+ unhex_salt, unhex_B = self._unhex_salt_B(salt, B)
+ self.M = self.srp_user.process_challenge(unhex_salt, unhex_B)
+
+ def get_authentication_params(self):
+ # It looks A is not used server side
+ return {'client_auth': binascii.hexlify(self.M),
+ 'A': binascii.hexlify(self.A)}
+
+ def process_authentication(self, authentication_response):
+ auth = json.loads(authentication_response)
+ self._check_for_errors(auth)
+ uuid = auth.get('id', None)
+ token = auth.get('token', None)
+ self.M2 = auth.get('M2', None)
+ self._check_auth_params(uuid, token, self.M2)
+ return uuid, token
+
+ def verify_authentication(self):
+ unhex_M2 = _safe_unhexlify(self.M2)
+ self.srp_user.verify_session(unhex_M2)
+ assert self.srp_user.authenticated()
+
+ def _check_for_errors(self, response):
+ if 'errors' in response:
+ msg = response['errors']['base']
+ raise SRPAuthError(unicode(msg).encode('utf-8'))
+
+ def _unhex_salt_B(self, salt, B):
+ if salt is None:
+ raise SRPAuthNoSalt()
+ if B is None:
+ raise SRPAuthNoB()
+ try:
+ unhex_salt = _safe_unhexlify(salt)
+ unhex_B = _safe_unhexlify(B)
+ except (TypeError, ValueError) as e:
+ raise SRPAuthBadDataFromServer(str(e))
+ return unhex_salt, unhex_B
+
+ def _check_auth_params(self, uuid, token, M2):
+ if not all((uuid, token, M2)):
+ msg = '%s' % str((M2, uuid, token))
+ raise SRPAuthBadDataFromServer(msg)
+
+
+class SRPSignupMechanism(object):
+
+ """
+ Implement a protocol-agnostic SRP Registration mechanism.
+ """
+
+ def get_signup_params(self, username, password):
+ salt, verifier = srp.create_salted_verification_key(
+ bytes(username), bytes(password),
+ srp.SHA256, srp.NG_1024)
+ user_data = {
+ 'user[login]': username,
+ 'user[password_salt]': binascii.hexlify(salt),
+ 'user[password_verifier]': binascii.hexlify(verifier)}
+ return user_data
+
+ def process_signup(self, signup_response):
+ signup = json.loads(signup_response)
+ errors = signup.get('errors')
+ if errors:
+ msg = 'username ' + errors.get('login')[0]
+ raise SRPRegistrationError(msg)
+ else:
+ username = signup.get('login')
+ return username
+
+
+def _safe_unhexlify(val):
+ return binascii.unhexlify(val) \
+ if (len(val) % 2 == 0) else binascii.unhexlify('0' + val)
+
+
+class SRPAuthError(Exception):
+ """
+ Base exception for srp authentication errors
+ """
+
+
+class SRPAuthNoSalt(SRPAuthError):
+ message = 'The server didn\'t send the salt parameter'
+
+
+class SRPAuthNoB(SRPAuthError):
+ message = 'The server didn\'t send the B parameter'
+
+
+class SRPAuthBadDataFromServer(SRPAuthError):
+ pass
+
+class SRPRegistrationError(Exception):
+ pass
diff --git a/bonafide/src/leap/bonafide/_version.py b/bonafide/src/leap/bonafide/_version.py
new file mode 100644
index 00000000..91fb65cc
--- /dev/null
+++ b/bonafide/src/leap/bonafide/_version.py
@@ -0,0 +1,460 @@
+
+# This file helps to compute a version number in source trees obtained from
+# git-archive tarball (such as those provided by githubs download-from-tag
+# feature). Distribution tarballs (built by setup.py sdist) and build
+# directories (produced by setup.py build) will contain a much shorter file
+# that just contains the computed version number.
+
+# This file is released into the public domain. Generated by
+# versioneer-0.15 (https://github.com/warner/python-versioneer)
+
+import errno
+import os
+import re
+import subprocess
+import sys
+
+
+def get_keywords():
+ # these strings will be replaced by git during git-archive.
+ # setup.py/versioneer.py will grep for the variable names, so they must
+ # each be defined on a line of their own. _version.py will just call
+ # get_keywords().
+ git_refnames = "$Format:%d$"
+ git_full = "$Format:%H$"
+ keywords = {"refnames": git_refnames, "full": git_full}
+ return keywords
+
+
+class VersioneerConfig:
+ pass
+
+
+def get_config():
+ # these strings are filled in when 'setup.py versioneer' creates
+ # _version.py
+ cfg = VersioneerConfig()
+ cfg.VCS = "git"
+ cfg.style = "pep440"
+ cfg.tag_prefix = "None"
+ cfg.parentdir_prefix = "None"
+ cfg.versionfile_source = "src/leap/bonafide/_version.py"
+ cfg.verbose = False
+ return cfg
+
+
+class NotThisMethod(Exception):
+ pass
+
+
+LONG_VERSION_PY = {}
+HANDLERS = {}
+
+
+def register_vcs_handler(vcs, method): # decorator
+ def decorate(f):
+ if vcs not in HANDLERS:
+ HANDLERS[vcs] = {}
+ HANDLERS[vcs][method] = f
+ return f
+ return decorate
+
+
+def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False):
+ assert isinstance(commands, list)
+ p = None
+ for c in commands:
+ try:
+ dispcmd = str([c] + args)
+ # remember shell=False, so use git.cmd on windows, not just git
+ p = subprocess.Popen([c] + args, cwd=cwd, stdout=subprocess.PIPE,
+ stderr=(subprocess.PIPE if hide_stderr
+ else None))
+ break
+ except EnvironmentError:
+ e = sys.exc_info()[1]
+ if e.errno == errno.ENOENT:
+ continue
+ if verbose:
+ print("unable to run %s" % dispcmd)
+ print(e)
+ return None
+ else:
+ if verbose:
+ print("unable to find command, tried %s" % (commands,))
+ return None
+ stdout = p.communicate()[0].strip()
+ if sys.version_info[0] >= 3:
+ stdout = stdout.decode()
+ if p.returncode != 0:
+ if verbose:
+ print("unable to run %s (error)" % dispcmd)
+ return None
+ return stdout
+
+
+def versions_from_parentdir(parentdir_prefix, root, verbose):
+ # Source tarballs conventionally unpack into a directory that includes
+ # both the project name and a version string.
+ dirname = os.path.basename(root)
+ if not dirname.startswith(parentdir_prefix):
+ if verbose:
+ print("guessing rootdir is '%s', but '%s' doesn't start with "
+ "prefix '%s'" % (root, dirname, parentdir_prefix))
+ raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
+ return {"version": dirname[len(parentdir_prefix):],
+ "full-revisionid": None,
+ "dirty": False, "error": None}
+
+
+@register_vcs_handler("git", "get_keywords")
+def git_get_keywords(versionfile_abs):
+ # the code embedded in _version.py can just fetch the value of these
+ # keywords. When used from setup.py, we don't want to import _version.py,
+ # so we do it with a regexp instead. This function is not used from
+ # _version.py.
+ keywords = {}
+ try:
+ f = open(versionfile_abs, "r")
+ for line in f.readlines():
+ if line.strip().startswith("git_refnames ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["refnames"] = mo.group(1)
+ if line.strip().startswith("git_full ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ keywords["full"] = mo.group(1)
+ f.close()
+ except EnvironmentError:
+ pass
+ return keywords
+
+
+@register_vcs_handler("git", "keywords")
+def git_versions_from_keywords(keywords, tag_prefix, verbose):
+ if not keywords:
+ raise NotThisMethod("no keywords at all, weird")
+ refnames = keywords["refnames"].strip()
+ if refnames.startswith("$Format"):
+ if verbose:
+ print("keywords are unexpanded, not using")
+ raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
+ refs = set([r.strip() for r in refnames.strip("()").split(",")])
+ # starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
+ # just "foo-1.0". If we see a "tag: " prefix, prefer those.
+ TAG = "tag: "
+ tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
+ if not tags:
+ # Either we're using git < 1.8.3, or there really are no tags. We use
+ # a heuristic: assume all version tags have a digit. The old git %d
+ # expansion behaves like git log --decorate=short and strips out the
+ # refs/heads/ and refs/tags/ prefixes that would let us distinguish
+ # between branches and tags. By ignoring refnames without digits, we
+ # filter out many common branch names like "release" and
+ # "stabilization", as well as "HEAD" and "master".
+ tags = set([r for r in refs if re.search(r'\d', r)])
+ if verbose:
+ print("discarding '%s', no digits" % ",".join(refs-tags))
+ if verbose:
+ print("likely tags: %s" % ",".join(sorted(tags)))
+ for ref in sorted(tags):
+ # sorting will prefer e.g. "2.0" over "2.0rc1"
+ if ref.startswith(tag_prefix):
+ r = ref[len(tag_prefix):]
+ if verbose:
+ print("picking %s" % r)
+ return {"version": r,
+ "full-revisionid": keywords["full"].strip(),
+ "dirty": False, "error": None
+ }
+ # no suitable tags, so version is "0+unknown", but full hex is still there
+ if verbose:
+ print("no suitable tags, using unknown + full revision id")
+ return {"version": "0+unknown",
+ "full-revisionid": keywords["full"].strip(),
+ "dirty": False, "error": "no suitable tags"}
+
+
+@register_vcs_handler("git", "pieces_from_vcs")
+def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
+ # this runs 'git' from the root of the source tree. This only gets called
+ # if the git-archive 'subst' keywords were *not* expanded, and
+ # _version.py hasn't already been rewritten with a short version string,
+ # meaning we're inside a checked out source tree.
+
+ if not os.path.exists(os.path.join(root, ".git")):
+ if verbose:
+ print("no .git in %s" % root)
+ raise NotThisMethod("no .git directory")
+
+ GITS = ["git"]
+ if sys.platform == "win32":
+ GITS = ["git.cmd", "git.exe"]
+ # if there is a tag, this yields TAG-NUM-gHEX[-dirty]
+ # if there are no tags, this yields HEX[-dirty] (no NUM)
+ describe_out = run_command(GITS, ["describe", "--tags", "--dirty",
+ "--always", "--long"],
+ cwd=root)
+ # --long was added in git-1.5.5
+ if describe_out is None:
+ raise NotThisMethod("'git describe' failed")
+ describe_out = describe_out.strip()
+ full_out = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
+ if full_out is None:
+ raise NotThisMethod("'git rev-parse' failed")
+ full_out = full_out.strip()
+
+ pieces = {}
+ pieces["long"] = full_out
+ pieces["short"] = full_out[:7] # maybe improved later
+ pieces["error"] = None
+
+ # parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
+ # TAG might have hyphens.
+ git_describe = describe_out
+
+ # look for -dirty suffix
+ dirty = git_describe.endswith("-dirty")
+ pieces["dirty"] = dirty
+ if dirty:
+ git_describe = git_describe[:git_describe.rindex("-dirty")]
+
+ # now we have TAG-NUM-gHEX or HEX
+
+ if "-" in git_describe:
+ # TAG-NUM-gHEX
+ mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
+ if not mo:
+ # unparseable. Maybe git-describe is misbehaving?
+ pieces["error"] = ("unable to parse git-describe output: '%s'"
+ % describe_out)
+ return pieces
+
+ # tag
+ full_tag = mo.group(1)
+ if not full_tag.startswith(tag_prefix):
+ if verbose:
+ fmt = "tag '%s' doesn't start with prefix '%s'"
+ print(fmt % (full_tag, tag_prefix))
+ pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
+ % (full_tag, tag_prefix))
+ return pieces
+ pieces["closest-tag"] = full_tag[len(tag_prefix):]
+
+ # distance: number of commits since tag
+ pieces["distance"] = int(mo.group(2))
+
+ # commit: short hex revision ID
+ pieces["short"] = mo.group(3)
+
+ else:
+ # HEX: no tags
+ pieces["closest-tag"] = None
+ count_out = run_command(GITS, ["rev-list", "HEAD", "--count"],
+ cwd=root)
+ pieces["distance"] = int(count_out) # total number of commits
+
+ return pieces
+
+
+def plus_or_dot(pieces):
+ if "+" in pieces.get("closest-tag", ""):
+ return "."
+ return "+"
+
+
+def render_pep440(pieces):
+ # now build up version string, with post-release "local version
+ # identifier". Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
+ # get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
+
+ # exceptions:
+ # 1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
+
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += plus_or_dot(pieces)
+ rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ else:
+ # exception #1
+ rendered = "0+untagged.%d.g%s" % (pieces["distance"],
+ pieces["short"])
+ if pieces["dirty"]:
+ rendered += ".dirty"
+ return rendered
+
+
+def render_pep440_pre(pieces):
+ # TAG[.post.devDISTANCE] . No -dirty
+
+ # exceptions:
+ # 1: no tags. 0.post.devDISTANCE
+
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"]:
+ rendered += ".post.dev%d" % pieces["distance"]
+ else:
+ # exception #1
+ rendered = "0.post.dev%d" % pieces["distance"]
+ return rendered
+
+
+def render_pep440_post(pieces):
+ # TAG[.postDISTANCE[.dev0]+gHEX] . The ".dev0" means dirty. Note that
+ # .dev0 sorts backwards (a dirty tree will appear "older" than the
+ # corresponding clean one), but you shouldn't be releasing software with
+ # -dirty anyways.
+
+ # exceptions:
+ # 1: no tags. 0.postDISTANCE[.dev0]
+
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += ".post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ rendered += plus_or_dot(pieces)
+ rendered += "g%s" % pieces["short"]
+ else:
+ # exception #1
+ rendered = "0.post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ rendered += "+g%s" % pieces["short"]
+ return rendered
+
+
+def render_pep440_old(pieces):
+ # TAG[.postDISTANCE[.dev0]] . The ".dev0" means dirty.
+
+ # exceptions:
+ # 1: no tags. 0.postDISTANCE[.dev0]
+
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"] or pieces["dirty"]:
+ rendered += ".post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ else:
+ # exception #1
+ rendered = "0.post%d" % pieces["distance"]
+ if pieces["dirty"]:
+ rendered += ".dev0"
+ return rendered
+
+
+def render_git_describe(pieces):
+ # TAG[-DISTANCE-gHEX][-dirty], like 'git describe --tags --dirty
+ # --always'
+
+ # exceptions:
+ # 1: no tags. HEX[-dirty] (note: no 'g' prefix)
+
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ if pieces["distance"]:
+ rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+ else:
+ # exception #1
+ rendered = pieces["short"]
+ if pieces["dirty"]:
+ rendered += "-dirty"
+ return rendered
+
+
+def render_git_describe_long(pieces):
+ # TAG-DISTANCE-gHEX[-dirty], like 'git describe --tags --dirty
+ # --always -long'. The distance/hash is unconditional.
+
+ # exceptions:
+ # 1: no tags. HEX[-dirty] (note: no 'g' prefix)
+
+ if pieces["closest-tag"]:
+ rendered = pieces["closest-tag"]
+ rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
+ else:
+ # exception #1
+ rendered = pieces["short"]
+ if pieces["dirty"]:
+ rendered += "-dirty"
+ return rendered
+
+
+def render(pieces, style):
+ if pieces["error"]:
+ return {"version": "unknown",
+ "full-revisionid": pieces.get("long"),
+ "dirty": None,
+ "error": pieces["error"]}
+
+ if not style or style == "default":
+ style = "pep440" # the default
+
+ if style == "pep440":
+ rendered = render_pep440(pieces)
+ elif style == "pep440-pre":
+ rendered = render_pep440_pre(pieces)
+ elif style == "pep440-post":
+ rendered = render_pep440_post(pieces)
+ elif style == "pep440-old":
+ rendered = render_pep440_old(pieces)
+ elif style == "git-describe":
+ rendered = render_git_describe(pieces)
+ elif style == "git-describe-long":
+ rendered = render_git_describe_long(pieces)
+ else:
+ raise ValueError("unknown style '%s'" % style)
+
+ return {"version": rendered, "full-revisionid": pieces["long"],
+ "dirty": pieces["dirty"], "error": None}
+
+
+def get_versions():
+ # I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
+ # __file__, we can work backwards from there to the root. Some
+ # py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
+ # case we can only use expanded keywords.
+
+ cfg = get_config()
+ verbose = cfg.verbose
+
+ try:
+ return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
+ verbose)
+ except NotThisMethod:
+ pass
+
+ try:
+ root = os.path.realpath(__file__)
+ # versionfile_source is the relative path from the top of the source
+ # tree (where the .git directory might live) to this file. Invert
+ # this to find the root from __file__.
+ for i in cfg.versionfile_source.split('/'):
+ root = os.path.dirname(root)
+ except NameError:
+ return {"version": "0+unknown", "full-revisionid": None,
+ "dirty": None,
+ "error": "unable to find root of source tree"}
+
+ try:
+ pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
+ return render(pieces, cfg.style)
+ except NotThisMethod:
+ pass
+
+ try:
+ if cfg.parentdir_prefix:
+ return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
+ except NotThisMethod:
+ pass
+
+ return {"version": "0+unknown", "full-revisionid": None,
+ "dirty": None,
+ "error": "unable to compute version"}
diff --git a/bonafide/src/leap/bonafide/bootstrap.py b/bonafide/src/leap/bonafide/bootstrap.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bonafide/src/leap/bonafide/bootstrap.py
diff --git a/bonafide/src/leap/bonafide/config.py b/bonafide/src/leap/bonafide/config.py
new file mode 100644
index 00000000..ae66a0e8
--- /dev/null
+++ b/bonafide/src/leap/bonafide/config.py
@@ -0,0 +1,508 @@
+# -*- coding: utf-8 -*-
+# config.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/>.
+"""
+Configuration for a LEAP provider.
+"""
+import datetime
+import json
+import os
+import sys
+
+from collections import defaultdict
+from urlparse import urlparse
+
+from twisted.internet import defer, reactor
+from twisted.internet.ssl import ClientContextFactory
+from twisted.python import log
+from twisted.web.client import Agent, downloadPage
+
+from leap.bonafide._http import httpRequest
+from leap.bonafide.provider import Discovery
+
+from leap.common.check import leap_assert
+from leap.common.config import get_path_prefix as common_get_path_prefix
+from leap.common.files import mkdir_p
+# check_and_fix_urw_only, get_mtime
+
+
+APPNAME = "bonafide"
+ENDPOINT = "ipc:///tmp/%s.sock" % APPNAME
+
+
+def get_path_prefix(standalone=False):
+ return common_get_path_prefix(standalone)
+
+
+_preffix = get_path_prefix()
+
+
+def get_provider_path(domain, config='provider.json'):
+ """
+ Returns relative path for provider configs.
+
+ :param domain: the domain to which this providerconfig belongs to.
+ :type domain: str
+ :returns: the path
+ :rtype: str
+ """
+ # TODO sanitize domain
+ leap_assert(domain is not None, 'get_provider_path: We need a domain')
+ return os.path.join('providers', domain, config)
+
+
+def get_ca_cert_path(domain):
+ # TODO sanitize domain
+ leap_assert(domain is not None, 'get_provider_path: We need a domain')
+ return os.path.join('providers', domain, 'keys', 'ca', 'cacert.pem')
+
+
+def get_modification_ts(path):
+ """
+ Gets modification time of a file.
+
+ :param path: the path to get ts from
+ :type path: str
+ :returns: modification time
+ :rtype: datetime object
+ """
+ ts = os.path.getmtime(path)
+ return datetime.datetime.fromtimestamp(ts)
+
+
+def update_modification_ts(path):
+ """
+ Sets modification time of a file to current time.
+
+ :param path: the path to set ts to.
+ :type path: str
+ :returns: modification time
+ :rtype: datetime object
+ """
+ os.utime(path, None)
+ return get_modification_ts(path)
+
+
+def is_file(path):
+ """
+ Returns True if the path exists and is a file.
+ """
+ return os.path.isfile(path)
+
+
+def is_empty_file(path):
+ """
+ Returns True if the file at path is empty.
+ """
+ return os.stat(path).st_size is 0
+
+
+def make_address(user, provider):
+ """
+ Return a full identifier for an user, as a email-like
+ identifier.
+
+ :param user: the username
+ :type user: basestring
+ :param provider: the provider domain
+ :type provider: basestring
+ """
+ return '%s@%s' % (user, provider)
+
+
+def get_username_and_provider(full_id):
+ return full_id.split('@')
+
+
+class Provider(object):
+ # TODO add validation
+
+ SERVICES_MAP = {
+ 'openvpn': ['eip'],
+ 'mx': ['soledad', 'smtp']}
+
+ first_bootstrap = defaultdict(None)
+ ongoing_bootstrap = defaultdict(None)
+ stuck_bootstrap = defaultdict(None)
+
+ def __init__(self, domain, autoconf=True, basedir=None,
+ check_certificate=True):
+ if not basedir:
+ basedir = os.path.join(_preffix, 'leap')
+ self._basedir = os.path.expanduser(basedir)
+ self._domain = domain
+ self._disco = Discovery('https://%s' % domain)
+ self._provider_config = None
+
+ is_configured = self.is_configured()
+ if not is_configured:
+ check_certificate = False
+
+ if check_certificate:
+ self.contextFactory = None
+ else:
+ # XXX we should do this only for the FIRST provider download.
+ # For the rest, we should pass the ca cert to the agent.
+ # That means that RIGHT AFTER DOWNLOADING provider_info,
+ # we should instantiate a new Agent...
+ self.contextFactory = WebClientContextFactory()
+ self._agent = Agent(reactor, self.contextFactory)
+
+ self._load_provider_json()
+
+ if not is_configured and autoconf:
+ log.msg('provider %s not configured: downloading files...' %
+ domain)
+ self.bootstrap()
+ else:
+ log.msg('Provider already initialized')
+ self.first_bootstrap[self._domain] = defer.succeed(
+ 'already_initialized')
+ self.ongoing_bootstrap[self._domain] = defer.succeed(
+ 'already_initialized')
+
+ @property
+ def domain(self):
+ return self._domain
+
+ @property
+ def api_uri(self):
+ if not self._provider_config:
+ return 'https://api.%s:4430' % self._domain
+ return self._provider_config.api_uri
+
+ @property
+ def version(self):
+ if not self._provider_config:
+ return 1
+ return int(self._provider_config.api_version)
+
+ def is_configured(self):
+ provider_json = self._get_provider_json_path()
+ # XXX check if all the services are there
+ if not is_file(provider_json):
+ return False
+ if not is_file(self._get_ca_cert_path()):
+ return False
+ if not self.has_config_for_all_services():
+ return False
+ return True
+
+ def bootstrap(self):
+ domain = self._domain
+ log.msg("Bootstrapping provider %s" % domain)
+ ongoing = self.ongoing_bootstrap.get(domain)
+ if ongoing:
+ log.msg('already bootstrapping this provider...')
+ return
+
+ self.first_bootstrap[self._domain] = defer.Deferred()
+
+ def first_bootstrap_done(ignored):
+ try:
+ self.first_bootstrap[domain].callback('got config')
+ except defer.AlreadyCalledError:
+ pass
+
+ d = self.maybe_download_provider_info()
+ d.addCallback(self.maybe_download_ca_cert)
+ d.addCallback(self.validate_ca_cert)
+ d.addCallback(first_bootstrap_done)
+ d.addCallback(self.maybe_download_services_config)
+ self.ongoing_bootstrap[domain] = d
+
+ def callWhenMainConfigReady(self, cb, *args, **kw):
+ d = self.first_bootstrap[self._domain]
+ d.addCallback(lambda _: cb(*args, **kw))
+ return d
+
+ def callWhenReady(self, cb, *args, **kw):
+ d = self.ongoing_bootstrap[self._domain]
+ d.addCallback(lambda _: cb(*args, **kw))
+ return d
+
+ def has_valid_certificate(self):
+ pass
+
+ def maybe_download_provider_info(self, replace=False):
+ """
+ Download the provider.json info from the main domain.
+ This SHOULD only be used once with the DOMAIN url.
+ """
+ # TODO handle pre-seeded providers?
+ # or let client handle that? We could move them to bonafide.
+ provider_json = self._get_provider_json_path()
+ if is_file(provider_json) and not replace:
+ return defer.succeed('provider_info_already_exists')
+
+ folders, f = os.path.split(provider_json)
+ mkdir_p(folders)
+
+ uri = self._disco.get_provider_info_uri()
+ met = self._disco.get_provider_info_method()
+
+ d = downloadPage(uri, provider_json, method=met)
+ d.addCallback(lambda _: self._load_provider_json())
+ d.addErrback(log.err)
+ return d
+
+ def update_provider_info(self):
+ """
+ Get more recent copy of provider.json from the api URL.
+ """
+ pass
+
+ def maybe_download_ca_cert(self, ignored):
+ """
+ :rtype: deferred
+ """
+ path = self._get_ca_cert_path()
+ if is_file(path):
+ return defer.succeed('ca_cert_path_already_exists')
+
+ uri = self._get_ca_cert_uri()
+ mkdir_p(os.path.split(path)[0])
+ d = downloadPage(uri, path)
+ d.addErrback(log.err)
+ return d
+
+ def validate_ca_cert(self, ignored):
+ # TODO Need to verify fingerprint against the one in provider.json
+ expected = self._get_expected_ca_cert_fingerprint()
+ print "EXPECTED FINGERPRINT:", expected
+
+ def _get_expected_ca_cert_fingerprint(self):
+ try:
+ fgp = self._provider_config.ca_cert_fingerprint
+ except AttributeError:
+ fgp = None
+ return fgp
+
+ # Services config files
+
+ def has_fetched_services_config(self):
+ return os.path.isfile(self._get_configs_path())
+
+ def maybe_download_services_config(self, ignored):
+
+ # TODO --- currently, some providers (mail.bitmask.net) raise 401
+ # UNAUTHENTICATED if we try to get the services
+ # See: # https://leap.se/code/issues/7906
+
+ def further_bootstrap_needs_auth(ignored):
+ log.err('cannot download services config yet, need auth')
+ pending_deferred = defer.Deferred()
+ self.stuck_bootstrap[self._domain] = pending_deferred
+ return pending_deferred
+
+ uri, met, path = self._get_configs_download_params()
+
+ d = downloadPage(uri, path, method=met)
+ d.addCallback(lambda _: self._load_provider_json())
+ d.addCallback(
+ lambda _: self._get_config_for_all_services(session=None))
+ d.addErrback(further_bootstrap_needs_auth)
+ return d
+
+ def download_services_config_with_auth(self, session):
+
+ def verify_provider_configs(ignored):
+ self._load_provider_configs()
+ return True
+
+ def workaround_for_config_fetch(failure):
+ # FIXME --- configs.json raises 500, see #7914.
+ # This is a workaround until that's fixed.
+ log.err(failure)
+ log.msg(
+ "COULD NOT VERIFY CONFIGS.JSON, WORKAROUND: DIRECT DOWNLOAD")
+
+ if 'mx' in self._provider_config.services:
+ soledad_uri = '/1/config/soledad-service.json'
+ smtp_uri = '/1/config/smtp-service.json'
+ base = self._disco.netloc
+
+ fetch = self._fetch_provider_configs_unauthenticated
+ get_path = self._get_service_config_path
+
+ d1 = fetch(
+ 'https://' + str(base + soledad_uri), get_path('soledad'))
+ d2 = fetch(
+ 'https://' + str(base + smtp_uri), get_path('smtp'))
+ d = defer.gatherResults([d1, d2])
+ d.addCallback(lambda _: finish_stuck_after_workaround())
+ return d
+
+ def finish_stuck_after_workaround():
+ stuck = self.stuck_bootstrap.get(self._domain, None)
+ if stuck:
+ stuck.callback('continue!')
+
+ def complete_bootstrapping(ignored):
+ stuck = self.stuck_bootstrap.get(self._domain, None)
+ if stuck:
+ d = self._get_config_for_all_services(session)
+ d.addCallback(lambda _: stuck.callback('continue!'))
+ d.addErrback(log.err)
+ return d
+
+ if not self.has_fetched_services_config():
+ self._load_provider_json()
+ uri, met, path = self._get_configs_download_params()
+ d = session.fetch_provider_configs(uri, path)
+ d.addCallback(verify_provider_configs)
+ d.addCallback(complete_bootstrapping)
+ d.addErrback(workaround_for_config_fetch)
+ return d
+ else:
+ d = defer.succeed('already downloaded')
+ d.addCallback(complete_bootstrapping)
+ return d
+
+ def _get_configs_download_params(self):
+ uri = self._disco.get_configs_uri()
+ met = self._disco.get_configs_method()
+ path = self._get_configs_path()
+ return uri, met, path
+
+ def offers_service(self, service):
+ if service not in self.SERVICES_MAP.keys():
+ raise RuntimeError('Unknown service: %s' % service)
+ return service in self._provider_config.services
+
+ def is_service_enabled(self, service):
+ # TODO implement on some config file
+ return True
+
+ def has_config_for_service(self, service):
+ has_file = os.path.isfile
+ path = self._get_service_config_path
+ smap = self.SERVICES_MAP
+
+ result = all([has_file(path(subservice)) for
+ subservice in smap[service]])
+ return result
+
+ def has_config_for_all_services(self):
+ self._load_provider_json()
+ if not self._provider_config:
+ return False
+ all_services = self._provider_config.services
+ has_all = all(
+ [self.has_config_for_service(service) for service in
+ all_services])
+ return has_all
+
+ def _get_provider_json_path(self):
+ domain = self._domain.encode(sys.getfilesystemencoding())
+ provider_json_path = os.path.join(
+ self._basedir, get_provider_path(domain, config='provider.json'))
+ return provider_json_path
+
+ def _get_configs_path(self):
+ domain = self._domain.encode(sys.getfilesystemencoding())
+ configs_path = os.path.join(
+ self._basedir, get_provider_path(domain, config='configs.json'))
+ return configs_path
+
+ def _get_service_config_path(self, service):
+ domain = self._domain.encode(sys.getfilesystemencoding())
+ configs_path = os.path.join(
+ self._basedir, get_provider_path(
+ domain, config='%s-service.json' % service))
+ return configs_path
+
+ def _get_ca_cert_path(self):
+ domain = self._domain.encode(sys.getfilesystemencoding())
+ cert_path = os.path.join(self._basedir, get_ca_cert_path(domain))
+ return cert_path
+
+ def _get_ca_cert_uri(self):
+ try:
+ uri = self._provider_config.ca_cert_uri
+ uri = str(uri)
+ except Exception:
+ uri = None
+ return uri
+
+ def _load_provider_json(self):
+ path = self._get_provider_json_path()
+ if not is_file(path):
+ log.msg("Cannot LOAD provider config path %s" % path)
+ return
+
+ with open(path, 'r') as config:
+ self._provider_config = Record(**json.load(config))
+
+ api_uri = self._provider_config.api_uri
+ if api_uri:
+ parsed = urlparse(api_uri)
+ self._disco.netloc = parsed.netloc
+
+ def _get_config_for_all_services(self, session):
+ services_dict = self._load_provider_configs()
+ configs_path = self._get_configs_path()
+ with open(configs_path) as jsonf:
+ services_dict = Record(**json.load(jsonf)).services
+ pending = []
+ base = self._disco.get_base_uri()
+ for service in self._provider_config.services:
+ if service in self.SERVICES_MAP.keys():
+ for subservice in self.SERVICES_MAP[service]:
+ uri = base + str(services_dict[subservice])
+ path = self._get_service_config_path(subservice)
+ if session:
+ d = session.fetch_provider_configs(uri, path)
+ else:
+ d = self._fetch_provider_configs_unauthenticated(
+ uri, path)
+ pending.append(d)
+ return defer.gatherResults(pending)
+
+ def _load_provider_configs(self):
+ configs_path = self._get_configs_path()
+ with open(configs_path) as jsonf:
+ services_dict = Record(**json.load(jsonf)).services
+ return services_dict
+
+ def _fetch_provider_configs_unauthenticated(self, uri, path):
+ log.msg('Downloading config for %s...' % uri)
+ d = downloadPage(uri, path, method='GET')
+ return d
+
+ def _http_request(self, *args, **kw):
+ # XXX pass if-modified-since header
+ return httpRequest(self._agent, *args, **kw)
+
+
+class Record(object):
+ def __init__(self, **kw):
+ self.__dict__.update(kw)
+
+
+class WebClientContextFactory(ClientContextFactory):
+ def getContext(self, hostname, port):
+ return ClientContextFactory.getContext(self)
+
+
+if __name__ == '__main__':
+
+ def print_done():
+ print '>>> bootstrapping done!!!'
+
+ provider = Provider('cdev.bitmask.net')
+ provider.callWhenReady(print_done)
+ reactor.run()
diff --git a/bonafide/src/leap/bonafide/cred_srp.py b/bonafide/src/leap/bonafide/cred_srp.py
new file mode 100644
index 00000000..9fcb97bc
--- /dev/null
+++ b/bonafide/src/leap/bonafide/cred_srp.py
@@ -0,0 +1,157 @@
+# -*- coding: utf-8 -*-
+# srp_cred.py
+# Copyright (C) 2015 LEAP
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+"""
+Credential module for authenticating SRP requests against the LEAP platform.
+"""
+
+# ----------------- DOC ------------------------------------------------------
+# See examples of cred modules:
+# https://github.com/oubiwann-unsupported/txBrowserID/blob/master/browserid/checker.py
+# http://stackoverflow.com/questions/19171686/book-twisted-network-programming-essentials-example-9-1-does-not-work
+# ----------------- DOC ------------------------------------------------------
+
+from zope.interface import implements, implementer, Interface, Attribute
+
+from twisted.cred import portal, credentials, error as credError
+from twisted.cred.checkers import ICredentialsChecker
+from twisted.internet import defer, reactor
+
+from leap.bonafide.session import Session
+
+
+@implementer(ICredentialsChecker)
+class SRPCredentialsChecker(object):
+
+ # TODO need to decide if the credentials that we pass here are per provider
+ # or not.
+ # I think it's better to have the credentials passed with the full user_id,
+ # and here split user/provider.
+ # XXX then we need to check if the provider is properly configured, to get
+ # the right api info AND the needed certificates.
+ # XXX might need to initialize credential checker with a ProviderAPI
+
+ credentialInterfaces = (credentials.IUsernamePassword,)
+
+ def requestAvatarId(self, credentials):
+ # TODO If we are already authenticated, we should just
+ # return the session object, somehow.
+ # XXX If not authenticated (ie, no cached credentials?)
+ # we pass credentials to srpauth.authenticate method
+ # another srpauth class should interface with the blocking srpauth
+ # library, and chain all the calls needed to do the handshake.
+ # Therefore:
+ # should keep reference to the srpauth instances somewhere
+ # TODO If we want to return an anonymous user (useful for getting the
+ # anon-vpn cert), we should return an empty tuple from here.
+
+ return defer.maybeDeferred(_get_leap_session(credentials)).addCallback(
+ self._check_srp_auth)
+
+ def _check_srp_auth(session, username):
+ if session.is_authenticated:
+ # is ok! --- should add it to some global cache?
+ return defer.succeed(username)
+ else:
+ return defer.fail(credError.UnauthorizedLogin(
+ "Bad username/password combination"))
+
+
+def _get_leap_session(credentials):
+ session = Session(credentials)
+ d = session.authenticate()
+ d.addCallback(lambda _: session)
+ return d
+
+
+class ILeapUserAvatar(Interface):
+
+ # TODO add attributes for username, uuid, token, session_id
+
+ def logout():
+ """
+ Clean up per-login resource allocated to this avatar.
+ """
+
+
+@implementer(ILeapUserAvatar)
+class LeapUserAvatar(object):
+
+ # TODO initialize with: username, uuid, token, session_id
+ # TODO initialize provider data (for api)
+ # TODO how does this relate to LeapSession? maybe we should get one passed?
+
+ def logout(self):
+
+ # TODO reset the (global?) srpauth object.
+ # https://leap.se/en/docs/design/bonafide#logout
+ # DELETE API_BASE/logout(.json)
+ pass
+
+
+class LeapAuthRealm(object):
+ """
+ The realm corresponds to an application domain and is in charge of avatars,
+ which are network-accessible business logic objects.
+ """
+
+ # TODO should be initialized with provider API objects.
+
+ implements(portal.IRealm)
+
+ def requestAvatar(self, avatarId, mind, *interfaces):
+
+ if ILeapUserAvatar in interfaces:
+ # XXX how should we get the details for the requested avatar?
+ avatar = LeapUserAvatar()
+ return ILeapUserAvatar, avatar, avatar.logout
+
+ raise NotImplementedError(
+ "This realm only supports the ILeapUserAvatar interface.")
+
+
+if __name__ == '__main__':
+
+ # from the browser-id implementation
+ #import sys
+ #def _done(res):
+ #print res
+ #reactor.stop()
+ #assertion = sys.argv[1]
+ #d = defer.Deferred()
+ #reactor.callLater(0, d.callback, "http://localhost:8081")
+ #d.addCallback(SRPCredentialsChecker)
+ #d.addCallback(lambda c: c.requestAvatarId(assertion))
+ #d.addBoth(_done)
+ #reactor.run()
+
+ # XXX move boilerplate to some bitmask-core template.
+
+ leap_realm = LeapAuthRealm()
+ # XXX should pass a provider mapping to realm too?
+
+ leap_portal = portal.Portal(leap_realm)
+
+ # XXX should we add an offline credentials checker, that's able
+ # to unlock local soledad sqlcipher backend?
+ # XXX should pass a provider mapping to credentials checker too?
+ srp_checker = SRPCredentialsChecker()
+ leap_portal.registerChecker(srp_checker)
+
+ # XXX tie this to some sample server...
+ reactor.listenTCP(8000, EchoFactory(leap_portal))
+ reactor.run()
diff --git a/bonafide/src/leap/bonafide/provider.py b/bonafide/src/leap/bonafide/provider.py
new file mode 100644
index 00000000..b44dae2e
--- /dev/null
+++ b/bonafide/src/leap/bonafide/provider.py
@@ -0,0 +1,186 @@
+# -*- coding: utf-8 -*-
+# provier.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/>.
+
+"""
+LEAP Provider API.
+"""
+
+from copy import deepcopy
+import re
+from urlparse import urlparse
+
+
+"""
+Maximum API version number supported by bonafide
+"""
+MAX_API_VERSION = 1
+
+
+class _MetaActionDispatcher(type):
+
+ """
+ A metaclass that will create dispatcher methods dynamically for each
+ action made available by the LEAP provider API.
+
+ The new methods will be created according to the values contained in an
+ `_actions` dictionary, with the following format::
+
+ {'action_name': (uri_template, method)}
+
+ where `uri_template` is a string that will be formatted with an arbitrary
+ number of keyword arguments.
+
+ Any class that uses this one as its metaclass needs to implement two
+ private methods::
+
+ _get_uri(self, action_name, **extra_params)
+ _get_method(self, action_name)
+
+ Beware that currently they cannot be inherited from bases.
+ """
+
+ def __new__(meta, name, bases, dct):
+
+ def _generate_action_funs(dct):
+ _get_uri = dct['_get_uri']
+ _get_method = dct['_get_method']
+ newdct = deepcopy(dct)
+ actions = dct['_actions']
+
+ def create_uri_fun(action_name):
+ return lambda self, **kw: _get_uri(
+ self, action_name=action_name, **kw)
+
+ def create_met_fun(action_name):
+ return lambda self: _get_method(
+ self, action_name=action_name)
+
+ for action in actions:
+ uri, method = actions[action]
+ _action_uri = 'get_%s_uri' % action
+ _action_met = 'get_%s_method' % action
+ newdct[_action_uri] = create_uri_fun(action)
+ newdct[_action_met] = create_met_fun(action)
+ return newdct
+
+ newdct = _generate_action_funs(dct)
+ return super(_MetaActionDispatcher, meta).__new__(
+ meta, name, bases, newdct)
+
+
+class BaseProvider(object):
+
+ def __init__(self, netloc, version=1):
+ parsed = urlparse(netloc)
+ if parsed.scheme != 'https':
+ raise ValueError(
+ 'ProviderApi needs to be passed a url with https scheme')
+ self.netloc = parsed.netloc
+
+ self.version = version
+ if version > MAX_API_VERSION:
+ self.version = MAX_API_VERSION
+
+ def get_hostname(self):
+ return urlparse(self._get_base_url()).hostname
+
+ def _get_base_url(self):
+ return "https://{0}/{1}".format(self.netloc, self.version)
+
+
+class Api(BaseProvider):
+ """
+ An object that has all the information that a client needs to communicate
+ with the remote methods exposed by the web API of a LEAP provider.
+
+ The actions are described in https://leap.se/bonafide
+
+ By using the _MetaActionDispatcher as a metaclass, the _actions dict will
+ be translated dynamically into a set of instance methods that will allow
+ getting the uri and method for each action.
+
+ The keyword arguments specified in the format string will automatically
+ raise a KeyError if the needed keyword arguments are not passed to the
+ dynamically created methods.
+ """
+
+ # TODO when should the provider-api object be created?
+ # TODO pass a Provider object to constructor, with autoconf flag.
+ # TODO make the actions attribute depend on the api version
+ # TODO missing UPDATE USER RECORD
+
+ __metaclass__ = _MetaActionDispatcher
+ _actions = {
+ 'signup': ('users', 'POST'),
+ 'update_user': ('users/{uid}', 'PUT'),
+ 'handshake': ('sessions', 'POST'),
+ 'authenticate': ('sessions/{login}', 'PUT'),
+ 'logout': ('logout', 'DELETE'),
+ 'vpn_cert': ('cert', 'POST'),
+ 'smtp_cert': ('smtp_cert', 'POST'),
+ }
+
+ # Methods expected by the dispatcher metaclass
+
+ def _get_uri(self, action_name, **extra_params):
+ resource, _ = self._actions.get(action_name)
+ uri = '{0}/{1}'.format(
+ bytes(self._get_base_url()),
+ bytes(resource)).format(**extra_params)
+ return uri
+
+ def _get_method(self, action_name):
+ _, method = self._actions.get(action_name)
+ return method
+
+
+class Discovery(BaseProvider):
+ """
+ Discover basic information about a provider, including the provided
+ services.
+ """
+
+ __metaclass__ = _MetaActionDispatcher
+ _actions = {
+ 'provider_info': ('provider.json', 'GET'),
+ 'configs': ('1/configs.json', 'GET'),
+ }
+
+ def _get_base_url(self):
+ return "https://{0}".format(self.netloc)
+
+ def get_base_uri(self):
+ return self._get_base_url()
+
+ # Methods expected by the dispatcher metaclass
+
+ def _get_uri(self, action_name, **extra_params):
+ resource, _ = self._actions.get(action_name)
+ uri = '{0}/{1}'.format(
+ bytes(self._get_base_url()),
+ bytes(resource)).format(**extra_params)
+ return uri
+
+ def _get_method(self, action_name):
+ _, method = self._actions.get(action_name)
+ return method
+
+
+def validate_username(username):
+ accepted_characters = '^[a-z0-9\-\_\.]*$'
+ if not re.match(accepted_characters, username):
+ raise ValueError('Only lowercase letters, digits, . - and _ allowed.')
diff --git a/bonafide/src/leap/bonafide/service.py b/bonafide/src/leap/bonafide/service.py
new file mode 100644
index 00000000..14585eff
--- /dev/null
+++ b/bonafide/src/leap/bonafide/service.py
@@ -0,0 +1,120 @@
+# -*- 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/>.
+
+"""
+Bonafide Service.
+"""
+import os
+from collections import defaultdict
+
+from leap.common.config import get_path_prefix
+from leap.common.service_hooks import HookableService
+from leap.bonafide._protocol import BonafideProtocol
+
+from twisted.internet import defer
+from twisted.python import log
+
+
+_preffix = get_path_prefix()
+
+
+class BonafideService(HookableService):
+
+ def __init__(self, basedir=None):
+ if not basedir:
+ basedir = os.path.join(_preffix, 'leap')
+ self._basedir = os.path.expanduser(basedir)
+ self._bonafide = BonafideProtocol()
+ self.service_hooks = defaultdict(list)
+
+ # XXX this is a quick hack to get a ref
+ # to the latest authenticated user.
+ self._active_user = None
+
+ def startService(self):
+ log.msg('Starting Bonafide Service')
+ super(BonafideService, self).startService()
+
+ # Commands
+
+ def do_authenticate(self, username, password):
+
+ def notify_passphrase_entry(username, password):
+ data = dict(username=username, password=password)
+ self.trigger_hook('on_passphrase_entry', **data)
+
+ def notify_bonafide_auth(result):
+ if not result:
+ msg = "Authentication hook did not return anything"
+ log.msg(msg)
+ raise RuntimeError(msg)
+
+ token, uuid = result
+ data = dict(username=username, token=token, uuid=uuid,
+ password=password)
+ self.trigger_hook('on_bonafide_auth', **data)
+
+ self._active_user = username
+ return result
+
+ # XXX I still have doubts from where it's best to trigger this.
+ # We probably should wait for BOTH deferreds and
+ # handle local and remote authentication success together
+ # (and fail if either one fails). Going with fire-and-forget for
+ # now, but needs needs improvement.
+
+ notify_passphrase_entry(username, password)
+
+ d = self._bonafide.do_authenticate(username, password)
+ d.addCallback(notify_bonafide_auth)
+ d.addCallback(lambda response: {
+ 'srp_token': response[0], 'uuid': response[1]})
+ return d
+
+ def do_signup(self, username, password):
+ d = self._bonafide.do_signup(username, password)
+ d.addCallback(lambda response: {'signup': 'ok', 'user': response})
+ return d
+
+ def do_logout(self, username):
+ if not username:
+ username = self._active_user
+
+ def reset_active(passthrough):
+ self._active_user = None
+ return passthrough
+
+ d = self._bonafide.do_logout(username)
+ d.addCallback(reset_active)
+ d.addCallback(lambda response: {'logout': 'ok'})
+ return d
+
+ def do_get_smtp_cert(self, username=None):
+ if not username:
+ username = self._active_user
+ if not username:
+ return defer.fail(
+ RuntimeError('No active user, cannot get SMTP cert.'))
+
+ d = self._bonafide.do_get_smtp_cert(username)
+ d.addCallback(lambda response: (username, response))
+ return d
+
+ def do_get_active_user(self):
+ user = self._active_user or '<none>'
+ info = {'user': user}
+ return defer.succeed(info)
diff --git a/bonafide/src/leap/bonafide/services/TODO b/bonafide/src/leap/bonafide/services/TODO
new file mode 100644
index 00000000..97dc639a
--- /dev/null
+++ b/bonafide/src/leap/bonafide/services/TODO
@@ -0,0 +1 @@
+# XXX this should be discoverable through the config.
diff --git a/bonafide/src/leap/bonafide/services/__init__.py b/bonafide/src/leap/bonafide/services/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bonafide/src/leap/bonafide/services/__init__.py
diff --git a/bonafide/src/leap/bonafide/services/eip.py b/bonafide/src/leap/bonafide/services/eip.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bonafide/src/leap/bonafide/services/eip.py
diff --git a/bonafide/src/leap/bonafide/services/mail.py b/bonafide/src/leap/bonafide/services/mail.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bonafide/src/leap/bonafide/services/mail.py
diff --git a/bonafide/src/leap/bonafide/services/soledad.py b/bonafide/src/leap/bonafide/services/soledad.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bonafide/src/leap/bonafide/services/soledad.py
diff --git a/bonafide/src/leap/bonafide/session.py b/bonafide/src/leap/bonafide/session.py
new file mode 100644
index 00000000..4180041a
--- /dev/null
+++ b/bonafide/src/leap/bonafide/session.py
@@ -0,0 +1,219 @@
+# -*- coding: utf-8 -*-
+# session.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/>.
+"""
+LEAP Session management.
+"""
+from twisted.internet import defer, reactor
+from twisted.python import log
+
+from leap.bonafide import _srp
+from leap.bonafide import provider
+from leap.bonafide._http import httpRequest, cookieAgentFactory
+
+OK = 'ok'
+
+
+def _auth_required(func):
+ """
+ Decorate a method so that it will not be called if the instance
+ attribute `is_authenticated` does not evaluate to True.
+ """
+ def decorated(*args, **kwargs):
+ instance = args[0]
+ allowed = getattr(instance, 'is_authenticated')
+ if not allowed:
+ raise RuntimeError('This method requires authentication')
+ return func(*args, **kwargs)
+ return decorated
+
+
+class Session(object):
+
+ def __init__(self, credentials, api, provider_cert):
+ # TODO check if an anonymous credentials is passed.
+ # TODO move provider_cert to api object.
+ # On creation, it should be able to retrieve all the info it needs
+ # (calling bootstrap).
+ # TODO could get a "provider" object instead.
+ # this provider can have an api attribute,
+ # and a "autoconfig" attribute passed on initialization.
+ # TODO get a file-descriptor for password if not in credentials
+ # TODO merge self._request with config.Provider._http_request ?
+
+ self.username = credentials.username
+ self.password = credentials.password
+ self._provider_cert = provider_cert
+ self._api = api
+ self._initialize_session()
+
+ def _initialize_session(self):
+ self._agent = cookieAgentFactory(self._provider_cert)
+ username = self.username or ''
+ password = self.password or ''
+ self._srp_auth = _srp.SRPAuthMechanism(username, password)
+ self._srp_signup = _srp.SRPSignupMechanism()
+ self._token = None
+ self._uuid = None
+
+ # Session
+
+ @property
+ def token(self):
+ return self._token
+
+ @property
+ def uuid(self):
+ return self._uuid
+
+ @property
+ def is_authenticated(self):
+ return self._srp_auth.srp_user.authenticated()
+
+ @defer.inlineCallbacks
+ def authenticate(self):
+ uri = self._api.get_handshake_uri()
+ met = self._api.get_handshake_method()
+ log.msg("%s to %s" % (met, uri))
+ params = self._srp_auth.get_handshake_params()
+
+ handshake = yield self._request(self._agent, uri, values=params,
+ method=met)
+
+ self._srp_auth.process_handshake(handshake)
+ uri = self._api.get_authenticate_uri(login=self.username)
+ met = self._api.get_authenticate_method()
+
+ log.msg("%s to %s" % (met, uri))
+ params = self._srp_auth.get_authentication_params()
+
+ auth = yield self._request(self._agent, uri, values=params,
+ method=met)
+
+ uuid, token = self._srp_auth.process_authentication(auth)
+ self._srp_auth.verify_authentication()
+
+ self._uuid = uuid
+ self._token = token
+ defer.returnValue(OK)
+
+ @_auth_required
+ @defer.inlineCallbacks
+ def logout(self):
+ uri = self._api.get_logout_uri()
+ met = self._api.get_logout_method()
+ auth = yield self._request(self._agent, uri, method=met)
+ print 'AUTH', auth
+ print 'resetting user/pass'
+ self.username = None
+ self.password = None
+ self._initialize_session()
+ defer.returnValue(OK)
+
+ # User certificates
+
+ def get_vpn_cert(self):
+ # TODO pass it to the provider object so that it can save it in the
+ # right path.
+ uri = self._api.get_vpn_cert_uri()
+ met = self._api.get_vpn_cert_method()
+ return self._request(self._agent, uri, method=met)
+
+ @_auth_required
+ def get_smtp_cert(self):
+ # TODO pass it to the provider object so that it can save it in the
+ # right path.
+ uri = self._api.get_smtp_cert_uri()
+ met = self._api.get_smtp_cert_method()
+ print met, "to", uri
+ return self._request(self._agent, uri, method=met)
+
+ def _request(self, *args, **kw):
+ kw['token'] = self._token
+ return httpRequest(*args, **kw)
+
+ # User management
+
+ @defer.inlineCallbacks
+ def signup(self, username, password):
+ # XXX should check that it_IS_NOT_authenticated
+ provider.validate_username(username)
+ uri = self._api.get_signup_uri()
+ met = self._api.get_signup_method()
+ params = self._srp_signup.get_signup_params(
+ username, password)
+
+ signup = yield self._request(self._agent, uri, values=params,
+ method=met)
+ registered_user = self._srp_signup.process_signup(signup)
+ self.username = username
+ self.password = password
+ defer.returnValue((OK, registered_user))
+
+ @_auth_required
+ def update_user_record(self):
+ # FIXME to be implemented
+ pass
+
+ # Authentication-protected configuration
+
+ @defer.inlineCallbacks
+ def fetch_provider_configs(self, uri, path):
+ config = yield self._request(self._agent, uri)
+ with open(path, 'w') as cf:
+ cf.write(config)
+ defer.returnValue('ok')
+
+
+if __name__ == "__main__":
+ import os
+ import sys
+ from twisted.cred.credentials import UsernamePassword
+
+ if len(sys.argv) != 4:
+ print "Usage:", sys.argv[0], "provider", "username", "password"
+ sys.exit()
+ _provider, username, password = sys.argv[1], sys.argv[2], sys.argv[3]
+ api = provider.Api('https://api.%s:4430' % _provider)
+ credentials = UsernamePassword(username, password)
+ cdev_pem = os.path.expanduser(
+ '~/.config/leap/providers/%s/keys/ca/cacert.pem' % _provider)
+ session = Session(credentials, api, cdev_pem)
+
+ def print_result(result):
+ print result
+ return result
+
+ def cbShutDown(ignored):
+ reactor.stop()
+
+ def auth_eb(failure):
+ print "[ERROR!]", failure.getErrorMessage()
+ log.err(failure)
+
+ d = session.authenticate()
+ d.addCallback(print_result)
+ d.addErrback(auth_eb)
+
+ d.addCallback(lambda _: session.get_smtp_cert())
+ #d.addCallback(lambda _: session.get_vpn_cert())
+ d.addCallback(print_result)
+ d.addErrback(auth_eb)
+
+ d.addCallback(lambda _: session.logout())
+ d.addErrback(auth_eb)
+ d.addBoth(cbShutDown)
+ reactor.run()
diff --git a/bonafide/src/leap/bonafide/ssh_service.py b/bonafide/src/leap/bonafide/ssh_service.py
new file mode 100644
index 00000000..3777b10f
--- /dev/null
+++ b/bonafide/src/leap/bonafide/ssh_service.py
@@ -0,0 +1,2 @@
+# TODO expose bonafide protocol through ssh, accessible through a hidden
+# service
diff --git a/bonafide/src/leap/bonafide/tests/__init__.py b/bonafide/src/leap/bonafide/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bonafide/src/leap/bonafide/tests/__init__.py