diff options
Diffstat (limited to 'src/pixelated/bitmask_libraries')
-rw-r--r-- | src/pixelated/bitmask_libraries/__init__.py | 0 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/certs.py | 56 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/config.py | 46 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/nicknym.py | 63 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/provider.py | 171 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/session.py | 320 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/smtp.py | 24 | ||||
-rw-r--r-- | src/pixelated/bitmask_libraries/soledad.py | 48 |
8 files changed, 728 insertions, 0 deletions
diff --git a/src/pixelated/bitmask_libraries/__init__.py b/src/pixelated/bitmask_libraries/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/src/pixelated/bitmask_libraries/__init__.py diff --git a/src/pixelated/bitmask_libraries/certs.py b/src/pixelated/bitmask_libraries/certs.py new file mode 100644 index 00000000..f0681608 --- /dev/null +++ b/src/pixelated/bitmask_libraries/certs.py @@ -0,0 +1,56 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import os + + +class LeapCertificate(object): + + LEAP_CERT = None + LEAP_FINGERPRINT = None + + def __init__(self, provider): + self._config = provider.config + self._server_name = provider.server_name + self._provider = provider + + @staticmethod + def set_cert_and_fingerprint(cert_file=None, cert_fingerprint=None): + if cert_fingerprint is None: + LeapCertificate.LEAP_CERT = str(cert_file) if cert_file else True + LeapCertificate.LEAP_FINGERPRINT = None + else: + LeapCertificate.LEAP_FINGERPRINT = cert_fingerprint + LeapCertificate.LEAP_CERT = False + + @property + def provider_web_cert(self): + return self.LEAP_CERT + + @property + def provider_api_cert(self): + return str(os.path.join(self._provider.config.leap_home, 'providers', self._server_name, 'keys', 'client', 'api.pem')) + + def setup_ca_bundle(self): + path = os.path.join(self._provider.config.leap_home, + 'providers', self._server_name, 'keys', 'client') + if not os.path.isdir(path): + os.makedirs(path, 0700) + self._download_cert(self.provider_api_cert) + + def _download_cert(self, cert_file_name): + cert = self._provider.fetch_valid_certificate() + with open(cert_file_name, 'w') as file: + file.write(cert) diff --git a/src/pixelated/bitmask_libraries/config.py b/src/pixelated/bitmask_libraries/config.py new file mode 100644 index 00000000..c521a093 --- /dev/null +++ b/src/pixelated/bitmask_libraries/config.py @@ -0,0 +1,46 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + +import os +from distutils.spawn import find_executable + + +def discover_gpg_binary(): + path = find_executable('gpg') + if path is None: + raise Exception('Did not find a gpg executable!') + + if os.path.islink(path): + path = os.path.realpath(path) + + return path + + +SYSTEM_CA_BUNDLE = True + + +class LeapConfig(object): + + def __init__(self, + leap_home=None, + timeout_in_s=15, + start_background_jobs=False, + gpg_binary=discover_gpg_binary()): + + self.leap_home = leap_home + self.timeout_in_s = timeout_in_s + self.start_background_jobs = start_background_jobs + self.gpg_binary = gpg_binary diff --git a/src/pixelated/bitmask_libraries/nicknym.py b/src/pixelated/bitmask_libraries/nicknym.py new file mode 100644 index 00000000..7914c567 --- /dev/null +++ b/src/pixelated/bitmask_libraries/nicknym.py @@ -0,0 +1,63 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from leap.keymanager import KeyManager, openpgp, KeyNotFound +from .certs import LeapCertificate +from twisted.internet import defer +import logging + +logger = logging.getLogger(__name__) + + +class NickNym(object): + + def __init__(self, provider, config, soledad, email_address, token, uuid): + nicknym_url = _discover_nicknym_server(provider) + self._email = email_address + self.keymanager = KeyManager(self._email, nicknym_url, + soledad, + token=token, ca_cert_path=LeapCertificate( + provider).provider_api_cert, api_uri=provider.api_uri, + api_version=provider.api_version, + uid=uuid, gpgbinary=config.gpg_binary) + + @defer.inlineCallbacks + def generate_openpgp_key(self): + key_present = yield self._key_exists(self._email) + if not key_present: + logger.info("Generating keys - this could take a while...") + yield self._gen_key() + yield self._send_key_to_leap() + + @defer.inlineCallbacks + def _key_exists(self, email): + try: + yield self.fetch_key(email, private=True, fetch_remote=False) + defer.returnValue(True) + except KeyNotFound: + defer.returnValue(False) + + def fetch_key(self, email, private=False, fetch_remote=True): + return self.keymanager.get_key(email, openpgp.OpenPGPKey, private=private, fetch_remote=fetch_remote) + + def _gen_key(self): + return self.keymanager.gen_key(openpgp.OpenPGPKey) + + def _send_key_to_leap(self): + return self.keymanager.send_key(openpgp.OpenPGPKey) + + +def _discover_nicknym_server(provider): + return 'https://nicknym.%s:6425/' % provider.domain diff --git a/src/pixelated/bitmask_libraries/provider.py b/src/pixelated/bitmask_libraries/provider.py new file mode 100644 index 00000000..a42b7be9 --- /dev/null +++ b/src/pixelated/bitmask_libraries/provider.py @@ -0,0 +1,171 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import json +import os + +from leap.common.certs import get_digest +import requests +from .certs import LeapCertificate +from pixelated.support.tls_adapter import EnforceTLSv1Adapter +from pixelated.bitmask_libraries.soledad import SoledadDiscoverException + + +class LeapProvider(object): + + def __init__(self, server_name, config): + self.server_name = server_name + self.config = config + self.local_ca_crt = '%s/ca.crt' % self.config.leap_home + self.provider_json = self.fetch_provider_json() + + @property + def api_uri(self): + return self.provider_json.get('api_uri') + + @property + def ca_cert_fingerprint(self): + return self.provider_json.get('ca_cert_fingerprint') + + @property + def ca_cert_uri(self): + return self.provider_json.get('ca_cert_uri') + + @property + def api_version(self): + return self.provider_json.get('api_version') + + @property + def domain(self): + return self.provider_json.get('domain') + + @property + def services(self): + return self.provider_json.get('services') + + def __hash__(self): + return hash(self.server_name) + + def __eq__(self, other): + return self.server_name == other.server_name + + def ensure_supports_mx(self): + if 'mx' not in self.services: + raise Exception + + def download_certificate(self, filename=None): + """ + Downloads the server certificate, validates it against the provided fingerprint and stores it to file + """ + path = filename or self.local_ca_crt + + directory = self._extract_directory(path) + if not os.path.exists(directory): + os.makedirs(directory) + + cert = self.fetch_valid_certificate() + with open(path, 'w') as out: + out.write(cert) + + def _extract_directory(self, path): + splited = path.split('/') + splited.pop(-1) + directory = '/'.join(splited) + return directory + + def fetch_valid_certificate(self): + cert = self._fetch_certificate() + self.validate_certificate(cert) + return cert + + def _fetch_certificate(self): + cert_url = '%s/ca.crt' % self._provider_base_url() + response = self._validated_get(cert_url) + cert_data = response.content + return cert_data + + def validate_certificate(self, cert_data=None): + if cert_data is None: + cert_data = self._fetch_certificate() + + parts = str(self.ca_cert_fingerprint).split(':') + method = parts[0].strip() + fingerprint = parts[1].strip() + + digest = get_digest(cert_data, method) + + if fingerprint.strip() != digest: + raise Exception('Certificate fingerprints don\'t match! Expected [%s] but got [%s]' % ( + fingerprint.strip(), digest)) + + def smtp_info(self): + json_data = self.fetch_smtp_json() + hosts = json_data['hosts'] + hostname = hosts.keys()[0] + host = hosts[hostname] + return host['hostname'], host['port'] + + def _validated_get(self, url): + session = requests.session() + try: + session.mount( + 'https://', EnforceTLSv1Adapter(assert_fingerprint=LeapCertificate.LEAP_FINGERPRINT)) + response = session.get(url, verify=LeapCertificate( + self).provider_web_cert, timeout=self.config.timeout_in_s) + response.raise_for_status() + return response + finally: + session.close() + + def fetch_provider_json(self): + url = '%s/provider.json' % self._provider_base_url() + response = self._validated_get(url) + json_data = json.loads(response.content) + return json_data + + def fetch_soledad_json(self): + service_url = "%s/%s/config/soledad-service.json" % ( + self.api_uri, self.api_version) + response = requests.get(service_url, verify=LeapCertificate( + self).provider_api_cert, timeout=self.config.timeout_in_s) + response.raise_for_status() + return json.loads(response.content) + + def fetch_smtp_json(self): + service_url = '%s/%s/config/smtp-service.json' % ( + self.api_uri, self.api_version) + response = requests.get(service_url, verify=LeapCertificate( + self).provider_api_cert, timeout=self.config.timeout_in_s) + response.raise_for_status() + return json.loads(response.content) + + def _provider_base_url(self): + return 'https://%s' % self.server_name + + def address_for(self, username): + return '%s@%s' % (username, self.domain) + + def discover_soledad_server(self, user_uuid): + try: + json_data = self.fetch_soledad_json() + + hosts = json_data['hosts'] + host = hosts.keys()[0] + server_url = 'https://%s:%d/user-%s' % \ + (hosts[host]['hostname'], hosts[host]['port'], + user_uuid) + return server_url + except Exception, e: + raise SoledadDiscoverException(e) diff --git a/src/pixelated/bitmask_libraries/session.py b/src/pixelated/bitmask_libraries/session.py new file mode 100644 index 00000000..a8e1e6f1 --- /dev/null +++ b/src/pixelated/bitmask_libraries/session.py @@ -0,0 +1,320 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +import errno +import traceback +import sys +import os +import requests +import logging + +from twisted.internet import reactor, defer +from pixelated.bitmask_libraries.certs import LeapCertificate +from pixelated.adapter.mailstore import LeapMailStore +from leap.mail.incoming.service import IncomingMail +from leap.mail.mail import Account +from leap.auth import SRPAuth +from .nicknym import NickNym +from .smtp import LeapSMTPConfig +from .soledad import SoledadFactory +import leap.common.certs as leap_certs + +from leap.common.events import ( + register, unregister, + catalog as events +) + + +log = logging.getLogger(__name__) + + +class LeapSession(object): + + def __init__(self, provider, user_auth, mail_store, soledad, nicknym, smtp_config): + self.smtp_config = smtp_config + self.config = provider.config + self.provider = provider + self.user_auth = user_auth + self.mail_store = mail_store + self.soledad = soledad + self.nicknym = nicknym + self.fresh_account = False + self.incoming_mail_fetcher = None + self.account = None + self._has_been_initially_synced = False + self._sem_intial_sync = defer.DeferredLock() + self._is_closed = False + register(events.KEYMANAGER_FINISHED_KEY_GENERATION, + self._set_fresh_account, uid=self.account_email()) + + @defer.inlineCallbacks + def initial_sync(self): + yield self._sem_intial_sync.acquire() + try: + yield self.sync() + if not self._has_been_initially_synced: + yield self.after_first_sync() + self._has_been_initially_synced = True + finally: + yield self._sem_intial_sync.release() + defer.returnValue(self) + + @defer.inlineCallbacks + def after_first_sync(self): + yield self.nicknym.generate_openpgp_key() + yield self._create_account(self.soledad) + self.incoming_mail_fetcher = yield self._create_incoming_mail_fetcher( + self.nicknym, + self.soledad, + self.account, + self.account_email()) + reactor.callFromThread(self.incoming_mail_fetcher.startService) + + def _create_account(self, soledad): + self.account = Account(soledad) + return self.account.deferred_initialization + + def _set_fresh_account(self, event, email_address): + log.debug('Key for email %s has been generated' % email_address) + if email_address == self.account_email(): + self.fresh_account = True + + def account_email(self): + name = self.user_auth.username + return self.provider.address_for(name) + + def close(self): + self._is_closed = True + self.stop_background_jobs() + unregister(events.KEYMANAGER_FINISHED_KEY_GENERATION, + uid=self.account_email()) + self.soledad.close() + self.remove_from_cache() + self._close_account() + + @property + def is_closed(self): + return self._is_closed + + def _close_account(self): + if self.account: + self.account.end_session() + + def remove_from_cache(self): + key = SessionCache.session_key(self.provider, self.user_auth.username) + SessionCache.remove_session(key) + + @defer.inlineCallbacks + def _create_incoming_mail_fetcher(self, nicknym, soledad, account, user_mail): + inbox = yield account.callWhenReady(lambda _: account.get_collection_by_mailbox('INBOX')) + defer.returnValue(IncomingMail(nicknym.keymanager, + soledad, + inbox, + user_mail)) + + def stop_background_jobs(self): + if self.incoming_mail_fetcher: + reactor.callFromThread(self.incoming_mail_fetcher.stopService) + self.incoming_mail_fetcher = None + + def sync(self): + try: + return self.soledad.sync() + except: + traceback.print_exc(file=sys.stderr) + raise + + +class SmtpClientCertificate(object): + + def __init__(self, provider, auth, user_path): + self._provider = provider + self._auth = auth + self._user_path = user_path + + def cert_path(self): + if not self._is_cert_already_downloaded() or self._should_redownload(): + self._download_smtp_cert() + + return self._smtp_client_cert_path() + + def _is_cert_already_downloaded(self): + return os.path.exists(self._smtp_client_cert_path()) + + def _should_redownload(self): + return leap_certs.should_redownload(self._smtp_client_cert_path()) + + def _download_smtp_cert(self): + cert_path = self._smtp_client_cert_path() + + if not os.path.exists(os.path.dirname(cert_path)): + os.makedirs(os.path.dirname(cert_path)) + + SmtpCertDownloader(self._provider, self._auth).download_to(cert_path) + + def _smtp_client_cert_path(self): + return os.path.join( + self._user_path, + "providers", + self._provider.domain, + "keys", "client", "smtp.pem") + + +class SmtpCertDownloader(object): + + def __init__(self, provider, auth): + self._provider = provider + self._auth = auth + + def download(self): + cert_url = '%s/%s/smtp_cert' % (self._provider.api_uri, + self._provider.api_version) + cookies = {"_session_id": self._auth.session_id} + headers = {} + headers["Authorization"] = 'Token token="{0}"'.format(self._auth.token) + params = {'address': self._auth.username} + response = requests.post( + cert_url, + params=params, + data=params, + verify=LeapCertificate(self._provider).provider_api_cert, + cookies=cookies, + timeout=self._provider.config.timeout_in_s, + headers=headers) + response.raise_for_status() + + client_cert = response.content + + return client_cert + + def download_to(self, target_file): + client_cert = self.download() + + with open(target_file, 'w') as f: + f.write(client_cert) + + +class LeapSessionFactory(object): + + def __init__(self, provider): + self._provider = provider + self._config = provider.config + + def create(self, username, password, auth=None): + key = SessionCache.session_key(self._provider, username) + session = SessionCache.lookup_session(key) + if not session: + session = self._create_new_session(username, password, auth) + SessionCache.remember_session(key, session) + + return session + + def _auth_leap(self, username, password): + srp_auth = SRPAuth(self._provider.api_uri, self._provider.local_ca_crt) + return srp_auth.authenticate(username, password) + + def _create_new_session(self, username, password, auth=None): + self._create_dir(self._provider.config.leap_home) + self._provider.download_certificate() + + auth = auth or self._auth_leap(username, password) + account_email = self._provider.address_for(username) + + self._create_database_dir(auth.uuid) + + soledad = SoledadFactory.create(auth.token, + auth.uuid, + password, + self._secrets_path(auth.uuid), + self._local_db_path(auth.uuid), + self._provider.discover_soledad_server( + auth.uuid), + LeapCertificate(self._provider).provider_api_cert) + + mail_store = LeapMailStore(soledad) + nicknym = self._create_nicknym( + account_email, auth.token, auth.uuid, soledad) + + smtp_client_cert = self._download_smtp_cert(auth) + smtp_host, smtp_port = self._provider.smtp_info() + smtp_config = LeapSMTPConfig( + account_email, smtp_client_cert, smtp_host, smtp_port) + + return LeapSession(self._provider, auth, mail_store, soledad, nicknym, smtp_config) + + def _download_smtp_cert(self, auth): + cert = SmtpClientCertificate( + self._provider, auth, self._user_path(auth.uuid)) + return cert.cert_path() + + def _create_dir(self, path): + try: + os.makedirs(path) + except OSError as exc: + if exc.errno == errno.EEXIST and os.path.isdir(path): + pass + else: + raise + + def _create_nicknym(self, email_address, token, uuid, soledad): + return NickNym(self._provider, self._config, soledad, email_address, token, uuid) + + def _user_path(self, user_uuid): + return os.path.join(self._config.leap_home, user_uuid) + + def _soledad_path(self, user_uuid): + return os.path.join(self._config.leap_home, user_uuid, 'soledad') + + def _secrets_path(self, user_uuid): + return os.path.join(self._soledad_path(user_uuid), 'secrets') + + def _local_db_path(self, user_uuid): + return os.path.join(self._soledad_path(user_uuid), 'soledad.db') + + def _create_database_dir(self, user_uuid): + try: + os.makedirs(self._soledad_path(user_uuid)) + except OSError as exc: + if exc.errno == errno.EEXIST and os.path.isdir(self._soledad_path(user_uuid)): + pass + else: + raise + + +class SessionCache(object): + + sessions = {} + + @staticmethod + def lookup_session(key): + session = SessionCache.sessions.get(key, None) + if session is not None and session.is_closed: + SessionCache.remove_session(key) + return None + else: + return session + + @staticmethod + def remember_session(key, session): + SessionCache.sessions[key] = session + + @staticmethod + def remove_session(key): + if key in SessionCache.sessions: + del SessionCache.sessions[key] + + @staticmethod + def session_key(provider, username): + return hash((provider, username)) diff --git a/src/pixelated/bitmask_libraries/smtp.py b/src/pixelated/bitmask_libraries/smtp.py new file mode 100644 index 00000000..643d4d4a --- /dev/null +++ b/src/pixelated/bitmask_libraries/smtp.py @@ -0,0 +1,24 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. + + +class LeapSMTPConfig(object): + + def __init__(self, account_email, cert_path, remote_smtp_host, remote_smtp_port): + self.account_email = account_email + self.cert_path = cert_path + self.remote_smtp_host = remote_smtp_host + self.remote_smtp_port = remote_smtp_port diff --git a/src/pixelated/bitmask_libraries/soledad.py b/src/pixelated/bitmask_libraries/soledad.py new file mode 100644 index 00000000..e6a9efce --- /dev/null +++ b/src/pixelated/bitmask_libraries/soledad.py @@ -0,0 +1,48 @@ +# +# Copyright (c) 2014 ThoughtWorks, Inc. +# +# Pixelated is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Pixelated 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 Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Pixelated. If not, see <http://www.gnu.org/licenses/>. +from leap.soledad.client import Soledad +from leap.soledad.common.crypto import WrongMacError, UnknownMacMethodError + + +class SoledadDiscoverException(Exception): + + def __init__(self, *args, **kwargs): + super(SoledadDiscoverException, self).__init__(*args, **kwargs) + + +class SoledadWrongPassphraseException(Exception): + + def __init__(self, *args, **kwargs): + super(SoledadWrongPassphraseException, self).__init__(*args, **kwargs) + + +class SoledadFactory(object): + + @classmethod + def create(cls, user_token, user_uuid, encryption_passphrase, secrets, local_db, server_url, api_cert): + try: + return Soledad(user_uuid, + passphrase=unicode(encryption_passphrase), + secrets_path=secrets, + local_db_path=local_db, + server_url=server_url, + cert_file=api_cert, + shared_db=None, + auth_token=user_token, + defer_encryption=False) + + except (WrongMacError, UnknownMacMethodError), e: + raise SoledadWrongPassphraseException(e) |