diff options
| author | drebs <drebs@leap.se> | 2016-12-05 09:10:47 -0200 | 
|---|---|---|
| committer | Kali Kaneko <kali@leap.se> | 2017-02-09 17:41:26 +0100 | 
| commit | ff85c2a41fe933d9959fb84a0df2a13a6e199cec (patch) | |
| tree | 6f52cae13589aa13675f662f40f5040056575ef1 | |
| parent | 8a463796bbaba3979234b0699d140947581421e7 (diff) | |
[refactor] improve secrets generation and storage code
| -rw-r--r-- | client/src/leap/soledad/client/_secrets/__init__.py | 132 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/_secrets/crypto.py | 123 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/_secrets/storage.py | 124 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/_secrets/util.py | 46 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/api.py | 100 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/interfaces.py | 20 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/secrets.py | 794 | ||||
| -rw-r--r-- | client/src/leap/soledad/client/shared_db.py | 12 | ||||
| -rw-r--r-- | testing/tests/client/test_aux_methods.py | 27 | ||||
| -rw-r--r-- | testing/tests/client/test_crypto.py | 227 | ||||
| -rw-r--r-- | testing/tests/client/test_deprecated_crypto.py | 6 | ||||
| -rw-r--r-- | testing/tests/client/test_shared_db.py | 38 | ||||
| -rw-r--r-- | testing/tests/client/test_signals.py | 98 | ||||
| -rw-r--r-- | testing/tests/sync/test_sync_target.py | 2 | 
14 files changed, 643 insertions, 1106 deletions
| diff --git a/client/src/leap/soledad/client/_secrets/__init__.py b/client/src/leap/soledad/client/_secrets/__init__.py new file mode 100644 index 00000000..f9da8423 --- /dev/null +++ b/client/src/leap/soledad/client/_secrets/__init__.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# _secrets/__init__.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/>. + +import os + +from collections import namedtuple + +from leap.soledad.common.log import getLogger + +from leap.soledad.client._secrets.storage import SecretsStorage +from leap.soledad.client._secrets.crypto import SecretsCrypto +from leap.soledad.client._secrets.util import emit + + +logger = getLogger(__name__) + + +SecretLength = namedtuple('SecretLength', 'name length') + + +class Secrets(object): + +    lengths = { +        'remote': 512, +        'salt': 64, +        'local': 448, +    } + +    def __init__(self, uuid, passphrase, url, local_path, creds, userid, +                 shared_db=None): +        self._passphrase = passphrase +        self._secrets = {} +        self._user_data = {'uuid': uuid, 'userid': userid} +        self.crypto = SecretsCrypto(self.get_passphrase) +        self.storage = SecretsStorage( +            uuid, self.get_passphrase, url, local_path, creds, userid, +            shared_db=shared_db) +        self._bootstrap() + +    # +    # bootstrap +    # + +    def _bootstrap(self): +        force_storage = False + +        # attempt to load secrets from local storage +        encrypted = self.storage.load_local() + +        # if not found, attempt to load secrets from remote storage +        if not encrypted: +            encrypted = self.storage.load_remote() + +        if not encrypted: +            # if not found, generate new secrets +            secrets = self._generate() +            encrypted = self.crypto.encrypt(secrets) +            force_storage = True +        else: +            # decrypt secrets found either in local or remote storage +            secrets = self.crypto.decrypt(encrypted) + +        self._secrets = secrets + +        if encrypted['version'] < self.crypto.VERSION or force_storage: +            self.storage.save_local(encrypted) +            self.storage.save_remote(encrypted) + +    # +    # generation +    # + +    @emit('creating') +    def _generate(self): +        logger.info("generating new set of secrets...") +        secrets = {} +        for name, length in self.lengths.iteritems(): +            secret = os.urandom(length) +            secrets[name] = secret +        logger.info("new set of secrets successfully generated") +        return secrets + +    # +    # crypto +    # + +    def _encrypt(self): +        # encrypt secrets +        secrets = self._secrets +        encrypted = self.crypto.encrypt(secrets) +        # create the recovery document +        data = {'secret': encrypted, 'version': 2} +        return data + +    def get_passphrase(self): +        return self._passphrase.encode('utf-8') + +    @property +    def passphrase(self): +        return self.get_passphrase() + +    def change_passphrase(self, new_passphrase): +        self._passphrase = new_passphrase +        encrypted = self.crypto.encrypt(self._secrets) +        self.storage.save_local(encrypted) +        self.storage.save_remote(encrypted) + +    @property +    def remote(self): +        return self._secrets.get('remote') + +    @property +    def salt(self): +        return self._secrets.get('salt') + +    @property +    def local(self): +        return self._secrets.get('local') diff --git a/client/src/leap/soledad/client/_secrets/crypto.py b/client/src/leap/soledad/client/_secrets/crypto.py new file mode 100644 index 00000000..76e80222 --- /dev/null +++ b/client/src/leap/soledad/client/_secrets/crypto.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# _secrets/crypto.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/>. + +import binascii +import json +import os +import scrypt + +from leap.soledad.common import soledad_assert +from leap.soledad.common.log import getLogger + +from leap.soledad.client._crypto import encrypt_sym, decrypt_sym, ENC_METHOD +from leap.soledad.client._secrets.util import SecretsError + + +logger = getLogger(__name__) + + +class SecretsCrypto(object): + +    VERSION = 2 + +    def __init__(self, get_pass): +        self._get_pass = get_pass + +    def _get_key(self, salt): +        key = scrypt.hash(self._get_pass(), salt, buflen=32) +        return key + +    # +    # encryption +    # + +    def encrypt(self, secrets): +        encoded = {} +        for name, value in secrets.iteritems(): +            encoded[name] = binascii.b2a_base64(value) +        plaintext = json.dumps(encoded) +        salt = os.urandom(64)  # TODO: get salt length from somewhere else +        key = self._get_key(salt) +        iv, ciphertext = encrypt_sym(plaintext, key, +                                     method=ENC_METHOD.aes_256_gcm) +        encrypted = { +            'version': self.VERSION, +            'kdf': 'scrypt', +            'kdf_salt': binascii.b2a_base64(salt), +            'kdf_length': len(key), +            'cipher': 'aes_256_gcm', +            'length': len(plaintext), +            'iv': str(iv), +            'secrets': binascii.b2a_base64(ciphertext), +        } +        return encrypted + +    # +    # decryption +    # + +    def decrypt(self, data): +        version = data.get('version') +        method = getattr(self, '_decrypt_v%d' % version) +        try: +            return method(data) +        except Exception as e: +            logger.error('error decrypting secrets: %r' % e) +            raise SecretsError(e) + +    def _decrypt_v1(self, data): +        secret_id = data['active_secret'] +        encrypted = data['storage_secrets'][secret_id] +        soledad_assert(encrypted['cipher'] == 'aes256') + +        salt = binascii.a2b_base64(encrypted['kdf_salt']) +        key = self._get_key(salt) +        separator = ':' +        iv, ciphertext = encrypted['secret'].split(separator, 1) +        ciphertext = binascii.a2b_base64(ciphertext) +        plaintext = self._decrypt( +            key, iv, ciphertext, encrypted, ENC_METHOD.aes_256_ctr) +        secrets = { +            'remote': plaintext[0:512], +            'salt': plaintext[512:576], +            'local': plaintext[576:1024], +        } +        return secrets + +    def _decrypt_v2(self, encrypted): +        soledad_assert(encrypted['cipher'] == 'aes_256_gcm') + +        salt = binascii.a2b_base64(encrypted['kdf_salt']) +        key = self._get_key(salt) +        iv = encrypted['iv'] +        ciphertext = binascii.a2b_base64(encrypted['secrets']) +        plaintext = self._decrypt( +            key, iv, ciphertext, encrypted, ENC_METHOD.aes_256_gcm) +        encoded = json.loads(plaintext) +        secrets = {} +        for name, value in encoded.iteritems(): +            secrets[name] = binascii.a2b_base64(value) +        return secrets + +    def _decrypt(self, key, iv, ciphertext, encrypted, method): +        # assert some properties of the stored secret +        soledad_assert(encrypted['kdf'] == 'scrypt') +        soledad_assert(encrypted['kdf_length'] == len(key)) +        # decrypt +        plaintext = decrypt_sym(ciphertext, key, iv, method) +        soledad_assert(encrypted['length'] == len(plaintext)) +        return plaintext diff --git a/client/src/leap/soledad/client/_secrets/storage.py b/client/src/leap/soledad/client/_secrets/storage.py new file mode 100644 index 00000000..da3aa9d7 --- /dev/null +++ b/client/src/leap/soledad/client/_secrets/storage.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# _secrets/storage.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/>. + +import json +import urlparse + +from hashlib import sha256 + +from leap.soledad.common import SHARED_DB_NAME +from leap.soledad.common.log import getLogger + +from leap.soledad.common.document import SoledadDocument +from leap.soledad.client.shared_db import SoledadSharedDatabase +from leap.soledad.client._secrets.util import emit + + +logger = getLogger(__name__) + + +class SecretsStorage(object): + +    def __init__(self, uuid, get_pass, url, local_path, creds, userid, +                 shared_db=None): +        self._uuid = uuid +        self._get_pass = get_pass +        self._local_path = local_path +        self._userid = userid + +        self._shared_db = shared_db or self._init_shared_db(url, creds) +        self.__remote_doc = None + +    # +    # properties +    # + +    @property +    def _user_data(self): +        return {'uuid': self._uuid, 'userid': self._userid} + +    # +    # local storage +    # + +    def load_local(self): +        logger.info("trying to load secrets from disk: %s" % self._local_path) +        try: +            with open(self._local_path, 'r') as f: +                encrypted = json.loads(f.read()) +            logger.info("secrets loaded successfully from disk") +            return encrypted +        except IOError: +            logger.warn("secrets not found in disk") +        return None + +    def save_local(self, encrypted): +        json_data = json.dumps(encrypted) +        with open(self._local_path, 'w') as f: +            f.write(json_data) + +    # +    # remote storage +    # + +    def _init_shared_db(self, url, creds): +        url = urlparse.urljoin(url, SHARED_DB_NAME) +        db = SoledadSharedDatabase.open_database( +            url, self._uuid, creds=creds) +        self._shared_db = db + +    def _remote_doc_id(self): +        passphrase = self._get_pass() +        text = '%s%s' % (passphrase, self._uuid) +        digest = sha256(text).hexdigest() +        return digest + +    @property +    def _remote_doc(self): +        if not self.__remote_doc and self._shared_db: +            doc = self._get_remote_doc() +            self.__remote_doc = doc +        return self.__remote_doc + +    @emit('downloading') +    def _get_remote_doc(self): +        logger.info('trying to load secrets from server...') +        doc = self._shared_db.get_doc(self._remote_doc_id()) +        if doc: +            logger.info('secrets loaded successfully from server') +        else: +            logger.warn('secrets not found in server') +        return doc + +    def load_remote(self): +        doc = self._remote_doc +        if not doc: +            return None +        encrypted = doc.content +        return encrypted + +    @emit('uploading') +    def save_remote(self, encrypted): +        doc = self._remote_doc +        if not doc: +            doc = SoledadDocument(doc_id=self._remote_doc_id()) +        doc.content = encrypted +        db = self._shared_db +        if not db: +            logger.warn('no shared db found') +            return +        db.put_doc(doc) diff --git a/client/src/leap/soledad/client/_secrets/util.py b/client/src/leap/soledad/client/_secrets/util.py new file mode 100644 index 00000000..f75b2bb6 --- /dev/null +++ b/client/src/leap/soledad/client/_secrets/util.py @@ -0,0 +1,46 @@ +# -*- coding:utf-8 -*- +# _secrets/util.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/>. + + +from leap.soledad.client import events + + +class SecretsError(Exception): +    pass + + +def emit(verb): +    def _decorator(method): +        def _decorated(self, *args, **kwargs): + +            # emit starting event +            user_data = self._user_data +            name = 'SOLEDAD_' + verb.upper() + '_KEYS' +            event = getattr(events, name) +            events.emit_async(event, user_data) + +            # run the method +            result = method(self, *args, **kwargs) + +            # emit a finished event +            name = 'SOLEDAD_DONE_' + verb.upper() + '_KEYS' +            event = getattr(events, name) +            events.emit_async(event, user_data) + +            return result +        return _decorated +    return _decorator diff --git a/client/src/leap/soledad/client/api.py b/client/src/leap/soledad/client/api.py index da6eec66..2e1d1cd3 100644 --- a/client/src/leap/soledad/client/api.py +++ b/client/src/leap/soledad/client/api.py @@ -45,7 +45,6 @@ from zope.interface import implements  from leap.common.config import get_path_prefix  from leap.common.plugins import collect_plugins -from leap.soledad.common import SHARED_DB_NAME  from leap.soledad.common import soledad_assert  from leap.soledad.common import soledad_assert_type  from leap.soledad.common.log import getLogger @@ -57,8 +56,7 @@ from leap.soledad.client import adbapi  from leap.soledad.client import events as soledad_events  from leap.soledad.client import interfaces as soledad_interfaces  from leap.soledad.client import sqlcipher -from leap.soledad.client.secrets import SoledadSecrets -from leap.soledad.client.shared_db import SoledadSharedDatabase +from leap.soledad.client._secrets import Secrets  from leap.soledad.client._crypto import SoledadCrypto  logger = getLogger(__name__) @@ -130,7 +128,7 @@ class Soledad(object):      def __init__(self, uuid, passphrase, secrets_path, local_db_path,                   server_url, cert_file, shared_db=None, -                 auth_token=None, syncable=True): +                 auth_token=None):          """          Initialize configuration, cryptographic keys and dbs. @@ -185,8 +183,6 @@ class Soledad(object):          self._secrets_path = None          self._dbsyncer = None -        self.shared_db = shared_db -          # configure SSL certificate          global SOLEDAD_CERT          SOLEDAD_CERT = cert_file @@ -198,21 +194,15 @@ class Soledad(object):          self._secrets_path = secrets_path -        # Initialize shared recovery database -        self.init_shared_db(server_url, uuid, self._creds, syncable=syncable) - -        # The following can raise BootstrapSequenceError, that will be -        # propagated upwards. -        self._init_secrets() +        self._init_secrets(shared_db=shared_db) -        self._crypto = SoledadCrypto(self._secrets.remote_storage_secret) +        self._crypto = SoledadCrypto(self._secrets.remote)          try:              # initialize database access, trap any problems so we can shutdown              # smoothly.              self._init_u1db_sqlcipher_backend() -            if syncable: -                self._init_u1db_syncer() +            self._init_u1db_syncer()          except DatabaseAccessError:              # oops! something went wrong with backend initialization. We              # have to close any thread-related stuff we have already opened @@ -255,14 +245,13 @@ class Soledad(object):          for path in paths:              create_path_if_not_exists(path) -    def _init_secrets(self): +    def _init_secrets(self, shared_db=None):          """          Initialize Soledad secrets.          """ -        self._secrets = SoledadSecrets( -            self.uuid, self._passphrase, self._secrets_path, -            self.shared_db, userid=self.userid) -        self._secrets.bootstrap() +        self._secrets = Secrets( +            self._uuid, self._passphrase, self._server_url, self._secrets_path, +            self._creds, self.userid, shared_db=shared_db)      def _init_u1db_sqlcipher_backend(self):          """ @@ -279,7 +268,7 @@ class Soledad(object):          """          tohex = binascii.b2a_hex          # sqlcipher only accepts the hex version -        key = tohex(self._secrets.get_local_storage_key()) +        key = tohex(self._secrets.local)          opts = sqlcipher.SQLCipherOptions(              self._local_db_path, key, @@ -659,21 +648,6 @@ class Soledad(object):      # ISyncableStorage      # -    def set_syncable(self, syncable): -        """ -        Toggle the syncable state for this database. - -        This can be used to start a database with offline state and switch it -        online afterwards. Or the opposite: stop syncs when connection is lost. - -        :param syncable: new status for syncable. -        :type syncable: bool -        """ -        # TODO should check that we've got a token! -        self.shared_db.syncable = syncable -        if syncable and not self._dbsyncer: -            self._init_u1db_syncer() -      def sync(self):          """          Synchronize documents with the server replica. @@ -760,13 +734,6 @@ class Soledad(object):          """          return self.sync_lock.locked -    @property -    def syncable(self): -        if self.shared_db: -            return self.shared_db.syncable -        else: -            return False -      def _set_token(self, token):          """          Set the authentication token for remote database access. @@ -803,58 +770,13 @@ class Soledad(object):      # ISecretsStorage      # -    def init_shared_db(self, server_url, uuid, creds, syncable=True): -        """ -        Initialize the shared database. - -        :param server_url: URL of the remote database. -        :type server_url: str -        :param uuid: The user's unique id. -        :type uuid: str -        :param creds: A tuple containing the authentication method and -            credentials. -        :type creds: tuple -        :param syncable: -            If syncable is False, the database will not attempt to sync against -            a remote replica. -        :type syncable: bool -        """ -        # only case this is False is for testing purposes -        if self.shared_db is None: -            shared_db_url = urlparse.urljoin(server_url, SHARED_DB_NAME) -            self.shared_db = SoledadSharedDatabase.open_database( -                shared_db_url, -                uuid, -                creds=creds, -                syncable=syncable) - -    @property -    def storage_secret(self): -        """ -        Return the secret used for local storage encryption. - -        :return: The secret used for local storage encryption. -        :rtype: str -        """ -        return self._secrets.storage_secret - -    @property -    def remote_storage_secret(self): -        """ -        Return the secret used for encryption of remotely stored data. - -        :return: The secret used for remote storage  encryption. -        :rtype: str -        """ -        return self._secrets.remote_storage_secret -      @property      def secrets(self):          """          Return the secrets object.          :return: The secrets object. -        :rtype: SoledadSecrets +        :rtype: Secrets          """          return self._secrets diff --git a/client/src/leap/soledad/client/interfaces.py b/client/src/leap/soledad/client/interfaces.py index 82927ff4..1be47df7 100644 --- a/client/src/leap/soledad/client/interfaces.py +++ b/client/src/leap/soledad/client/interfaces.py @@ -351,28 +351,12 @@ class ISecretsStorage(Interface):      secrets_file_name = Attribute(          "The name of the file where the storage secrets will be stored") -    storage_secret = Attribute("") -    remote_storage_secret = Attribute("") -    shared_db = Attribute("The shared db object") -      # XXX this used internally from secrets, so it might be good to preserve      # as a public boundary with other components.      # We should also probably document its interface.      secrets = Attribute("A SoledadSecrets object containing access to secrets") -    def init_shared_db(self, server_url, uuid, creds): -        """ -        Initialize the shared recovery database. - -        :param server_url: -        :type server_url: -        :param uuid: -        :type uuid: -        :param creds: -        :type creds: -        """ -      def change_passphrase(self, new_passphrase):          """          Change the passphrase that encrypts the storage secret. @@ -382,7 +366,3 @@ class ISecretsStorage(Interface):          :raise NoStorageSecret: Raised if there's no storage secret available.          """ - -    # XXX not in use. Uncomment if we ever decide to allow -    # multiple secrets. -    # secret_id = Attribute("The id of the storage secret to be used") diff --git a/client/src/leap/soledad/client/secrets.py b/client/src/leap/soledad/client/secrets.py deleted file mode 100644 index 3fe98c64..00000000 --- a/client/src/leap/soledad/client/secrets.py +++ /dev/null @@ -1,794 +0,0 @@ -# -*- coding: utf-8 -*- -# secrets.py -# Copyright (C) 2014 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/>. - - -""" -Soledad secrets handling. -""" - - -import os -import scrypt -import binascii -import errno -import json - -from hashlib import sha256 - -from leap.soledad.common import soledad_assert -from leap.soledad.common import soledad_assert_type -from leap.soledad.common import document -from leap.soledad.common.log import getLogger -from leap.soledad.client import events -from leap.soledad.client import _crypto - - -logger = getLogger(__name__) - - -# -# Exceptions -# - - -class SecretsException(Exception): - -    """ -    Generic exception type raised by this module. -    """ - - -class NoStorageSecret(SecretsException): - -    """ -    Raised when trying to use a storage secret but none is available. -    """ -    pass - - -class PassphraseTooShort(SecretsException): - -    """ -    Raised when trying to change the passphrase but the provided passphrase is -    too short. -    """ - - -class BootstrapSequenceError(SecretsException): - -    """ -    Raised when an attempt to generate a secret and store it in a recovery -    document on server failed. -    """ - - -# -# Secrets handler -# - - -class SoledadSecrets(object): - -    """ -    Soledad secrets handler. - -    The first C{self.REMOTE_STORAGE_SECRET_LENGTH} bytes of the storage -    secret are used for remote storage encryption. We use the next -    C{self.LOCAL_STORAGE_SECRET} bytes to derive a key for local storage. -    From these bytes, the first C{self.SALT_LENGTH} bytes are used as the -    salt and the rest as the password for the scrypt hashing. -    """ - -    LOCAL_STORAGE_SECRET_LENGTH = 512 -    """ -    The length, in bytes, of the secret used to derive a passphrase for the -    SQLCipher database. -    """ - -    REMOTE_STORAGE_SECRET_LENGTH = 512 -    """ -    The length, in bytes, of the secret used to derive an encryption key for -    remote storage. -    """ - -    SALT_LENGTH = 64 -    """ -    The length, in bytes, of the salt used to derive the key for the storage -    secret encryption. -    """ - -    GEN_SECRET_LENGTH = LOCAL_STORAGE_SECRET_LENGTH \ -        + REMOTE_STORAGE_SECRET_LENGTH \ -        + SALT_LENGTH  # for sync db -    """ -    The length, in bytes, of the secret to be generated. This includes local -    and remote secrets, and the salt for deriving the sync db secret. -    """ - -    MINIMUM_PASSPHRASE_LENGTH = 6 -    """ -    The minimum length, in bytes, for a passphrase. The passphrase length is -    only checked when the user changes her passphrase, not when she -    instantiates Soledad. -    """ - -    SEPARATOR = ":" -    """ -    A separator used for storing the encryption initial value prepended to the -    ciphertext. -    """ - -    UUID_KEY = 'uuid' -    STORAGE_SECRETS_KEY = 'storage_secrets' -    ACTIVE_SECRET_KEY = 'active_secret' -    SECRET_KEY = 'secret' -    CIPHER_KEY = 'cipher' -    LENGTH_KEY = 'length' -    KDF_KEY = 'kdf' -    KDF_SALT_KEY = 'kdf_salt' -    KDF_LENGTH_KEY = 'kdf_length' -    KDF_SCRYPT = 'scrypt' -    CIPHER_AES256 = 'aes256'  # deprecated, AES-CTR -    CIPHER_AES256_GCM = _crypto.ENC_METHOD.aes_256_gcm -    RECOVERY_DOC_VERSION_KEY = 'version' -    RECOVERY_DOC_VERSION = 1 -    """ -    Keys used to access storage secrets in recovery documents. -    """ - -    def __init__(self, uuid, passphrase, secrets_path, shared_db, userid=None): -        """ -        Initialize the secrets manager. - -        :param uuid: User's unique id. -        :type uuid: str -        :param passphrase: The passphrase for locking and unlocking encryption -                           secrets for local and remote storage. -        :type passphrase: unicode -        :param secrets_path: Path for storing encrypted key used for -                             symmetric encryption. -        :type secrets_path: str -        :param shared_db: The shared database that stores user secrets. -        :type shared_db: leap.soledad.client.shared_db.SoledadSharedDatabase -        """ -        self._uuid = uuid -        self._userid = userid -        self._passphrase = passphrase -        self._secrets_path = secrets_path -        self._shared_db = shared_db -        self._secrets = {} -        self._secret_id = None - -    def bootstrap(self): -        """ -        Bootstrap secrets. - -        Soledad secrets bootstrap is the following sequence of stages: - -        * stage 1 - local secret loading: -            - if secrets exist locally, load them. -        * stage 2 - remote secret loading: -            - else, if secrets exist in server, download them. -        * stage 3 - secret generation: -            - else, generate a new secret and store in server. - -        This method decides which bootstrap stages have already been performed -        and performs the missing ones in order. - -        :raise BootstrapSequenceError: Raised when the secret generation and -            storage on server sequence has failed for some reason. -        """ -        # STAGE 1 - verify if secrets exist locally -        try: -            logger.info("trying to load secrets from local storage...") -            version = self._load_secrets_from_local_file() -            # eventually migrate local and remote stored documents from old -            # format version -            if version < self.RECOVERY_DOC_VERSION: -                self._store_secrets() -                self._upload_crypto_secrets() -            logger.info("found secrets in local storage") -            return - -        except NoStorageSecret: -            logger.info("could not find secrets in local storage") - -        # STAGE 2 - there are no secrets in local storage and this is the -        #           first time we are running soledad with the specified -        #           secrets_path. Try to fetch encrypted secrets from -        #           server. -        try: -            logger.info('trying to fetch secrets from remote storage...') -            version = self._download_crypto_secrets() -            self._store_secrets() -            # eventually migrate remote stored document from old format -            # version -            if version < self.RECOVERY_DOC_VERSION: -                self._upload_crypto_secrets() -            logger.info('found secrets in remote storage.') -            return -        except NoStorageSecret: -            logger.info("could not find secrets in remote storage.") - -        # STAGE 3 - there are no secrets in server also, so we want to -        #           generate the secrets and store them in the remote -        #           db. -        logger.info("generating secrets...") -        self._gen_crypto_secrets() -        logger.info("uploading secrets...") -        self._upload_crypto_secrets() - -    def _has_secret(self): -        """ -        Return whether there is a storage secret available for use or not. - -        :return: Whether there's a storage secret for symmetric encryption. -        :rtype: bool -        """ -        return self.storage_secret is not None - -    def _maybe_set_active_secret(self, active_secret): -        """ -        If no secret_id is already set, choose the passed active secret, or -        just choose first secret available if none. -        """ -        if not self._secret_id: -            if not active_secret: -                active_secret = self._secrets.items()[0][0] -            self.set_secret_id(active_secret) - -    def _load_secrets_from_local_file(self): -        """ -        Load storage secrets from local file. - -        :return version: The version of the locally stored recovery document. - -        :raise NoStorageSecret: Raised if there are no secrets available in -                                local storage. -        """ -        # check if secrets file exists and we can read it -        if not os.path.isfile(self._secrets_path): -            raise NoStorageSecret - -        # read storage secrets from file -        content = None -        with open(self._secrets_path, 'r') as f: -            content = json.loads(f.read()) -        _, active_secret, version = self._import_recovery_document(content) - -        self._maybe_set_active_secret(active_secret) - -        return version - -    def _download_crypto_secrets(self): -        """ -        Download crypto secrets. - -        :return version: The version of the remotelly stored recovery document. - -        :raise NoStorageSecret: Raised if there are no secrets available in -                                remote storage. -        """ -        doc = None -        if self._shared_db.syncable: -            doc = self._get_secrets_from_shared_db() - -        if doc is None: -            raise NoStorageSecret - -        _, active_secret, version = self._import_recovery_document(doc.content) -        self._maybe_set_active_secret(active_secret) - -        return version - -    def _gen_crypto_secrets(self): -        """ -        Generate the crypto secrets. -        """ -        logger.info('no cryptographic secrets found, creating new secrets...') -        secret_id = self._gen_secret() -        self.set_secret_id(secret_id) - -    def _upload_crypto_secrets(self): -        """ -        Send crypto secrets to shared db. - -        :raises BootstrapSequenceError: Raised when unable to store secrets in -                                        shared database. -        """ -        if self._shared_db.syncable: -            try: -                self._put_secrets_in_shared_db() -            except Exception as ex: -                # storing generated secret in shared db failed for -                # some reason, so we erase the generated secret and -                # raise. -                try: -                    os.unlink(self._secrets_path) -                except OSError as e: -                    if e.errno != errno.ENOENT: -                        # no such file or directory -                        logger.exception(e) -                logger.exception(ex) -                raise BootstrapSequenceError( -                    'Could not store generated secret in the shared ' -                    'database, bailing out...') - -    # -    # Shared DB related methods -    # - -    def _shared_db_doc_id(self): -        """ -        Calculate the doc_id of the document in the shared db that stores key -        material. - -        :return: the hash -        :rtype: str -        """ -        return sha256( -            '%s%s' % -            (self._passphrase_as_string(), self._uuid)).hexdigest() - -    def _export_recovery_document(self, cipher=None): -        """ -        Export the storage secrets. - -        Current format of recovery document has the following structure: - -            { -                'storage_secrets': { -                    '<storage_secret id>': { -                        'cipher': 'aes256', -                        'length': <secret length>, -                        'secret': '<encrypted storage_secret>', -                    }, -                }, -                'active_secret': '<secret_id>', -                'version': '<recovery document format version>', -            } - -        Note that multiple storage secrets might be stored in one recovery -        document. - -        :param cipher: (Optional) The ciper to use. Defaults to AES256 -        :type cipher: str - -        :return: The recovery document. -        :rtype: dict -        """ -        # encrypt secrets -        encrypted_secrets = {} -        for secret_id in self._secrets: -            encrypted_secrets[secret_id] = self._encrypt_storage_secret( -                self._secrets[secret_id], doc_cipher=cipher) -        # create the recovery document -        data = { -            self.STORAGE_SECRETS_KEY: encrypted_secrets, -            self.ACTIVE_SECRET_KEY: self._secret_id, -            self.RECOVERY_DOC_VERSION_KEY: self.RECOVERY_DOC_VERSION, -        } -        return data - -    def _import_recovery_document(self, data): -        """ -        Import storage secrets for symmetric encryption from a recovery -        document. - -        Note that this method does not store the imported data on disk. For -        that, use C{self._store_secrets()}. - -        :param data: The recovery document. -        :type data: dict - -        :return: A tuple containing the number of imported secrets, the -                 secret_id of the last active secret, and the recovery -                 document format version. -        :rtype: (int, str, int) -        """ -        soledad_assert(self.STORAGE_SECRETS_KEY in data) -        version = data.get(self.RECOVERY_DOC_VERSION_KEY, 1) -        meth = getattr(self, '_import_recovery_document_version_%d' % version) -        secret_count, active_secret = meth(data) -        return secret_count, active_secret, version - -    def _import_recovery_document_version_1(self, data): -        """ -        Import storage secrets for symmetric encryption from a recovery -        document with format version 1. - -        Version 1 of recovery document has the following structure: - -            { -                'storage_secrets': { -                    '<storage_secret id>': { -                        'cipher': 'aes256', -                        'length': <secret length>, -                        'secret': '<encrypted storage_secret>', -                    }, -                }, -                'active_secret': '<secret_id>', -                'version': '<recovery document format version>', -            } - -        :param data: The recovery document. -        :type data: dict - -        :return: A tuple containing the number of imported secrets, the -                 secret_id of the last active secret, and the recovery -                 document format version. -        :rtype: (int, str, int) -        """ -        # include secrets in the secret pool. -        secret_count = 0 -        secrets = data[self.STORAGE_SECRETS_KEY].items() -        active_secret = None -        # XXX remove check for existence of key (included for backwards -        # compatibility) -        if self.ACTIVE_SECRET_KEY in data: -            active_secret = data[self.ACTIVE_SECRET_KEY] -        for secret_id, encrypted_secret in secrets: -            if secret_id not in self._secrets: -                try: -                    self._secrets[secret_id] = \ -                        self._decrypt_storage_secret_version_1( -                            encrypted_secret) -                    secret_count += 1 -                except SecretsException as e: -                    logger.error("failed to decrypt storage secret: %s" -                                 % str(e)) -                    raise e -        return secret_count, active_secret - -    def _get_secrets_from_shared_db(self): -        """ -        Retrieve the document with encrypted key material from the shared -        database. - -        :return: a document with encrypted key material in its contents -        :rtype: document.SoledadDocument -        """ -        user_data = self._get_user_data() -        events.emit_async(events.SOLEDAD_DOWNLOADING_KEYS, user_data) -        db = self._shared_db -        if not db: -            logger.warn('no shared db found') -            return -        doc = db.get_doc(self._shared_db_doc_id()) -        user_data = {'userid': self._userid, 'uuid': self._uuid} -        events.emit_async(events.SOLEDAD_DONE_DOWNLOADING_KEYS, user_data) -        return doc - -    def _put_secrets_in_shared_db(self): -        """ -        Assert local keys are the same as shared db's ones. - -        Try to fetch keys from shared recovery database. If they already exist -        in the remote db, assert that that data is the same as local data. -        Otherwise, upload keys to shared recovery database. -        """ -        soledad_assert( -            self._has_secret(), -            'Tried to send keys to server but they don\'t exist in local ' -            'storage.') -        # try to get secrets doc from server, otherwise create it -        doc = self._get_secrets_from_shared_db() -        if doc is None: -            doc = document.SoledadDocument( -                doc_id=self._shared_db_doc_id()) -        # fill doc with encrypted secrets -        doc.content = self._export_recovery_document() -        # upload secrets to server -        user_data = self._get_user_data() -        events.emit_async(events.SOLEDAD_UPLOADING_KEYS, user_data) -        db = self._shared_db -        if not db: -            logger.warn('no shared db found') -            return -        db.put_doc(doc) -        events.emit_async(events.SOLEDAD_DONE_UPLOADING_KEYS, user_data) - -    # -    # Management of secret for symmetric encryption. -    # - -    def _decrypt_storage_secret_version_1(self, encrypted_secret_dict): -        """ -        Decrypt the storage secret. - -        Storage secret is encrypted before being stored. This method decrypts -        and returns the decrypted storage secret. - -        Version 1 of storage secret format has the following structure: - -            '<storage_secret id>': { -                'cipher': 'aes256', -                'length': <secret length>, -                'secret': '<encrypted storage_secret>', -            }, - -        :param encrypted_secret_dict: The encrypted storage secret. -        :type encrypted_secret_dict:  dict - -        :return: The decrypted storage secret. -        :rtype: str - -        :raise SecretsException: Raised in case the decryption of the storage -                                 secret fails for some reason. -        """ -        # calculate the encryption key -        if encrypted_secret_dict[self.KDF_KEY] != self.KDF_SCRYPT: -            raise SecretsException("Unknown KDF in stored secret.") -        key = scrypt.hash( -            self._passphrase_as_string(), -            # the salt is stored base64 encoded -            binascii.a2b_base64( -                encrypted_secret_dict[self.KDF_SALT_KEY]), -            buflen=32,  # we need a key with 256 bits (32 bytes). -        ) -        if encrypted_secret_dict[self.KDF_LENGTH_KEY] != len(key): -            raise SecretsException("Wrong length of decryption key.") -        supported_ciphers = [self.CIPHER_AES256, self.CIPHER_AES256_GCM] -        doc_cipher = encrypted_secret_dict[self.CIPHER_KEY] -        if doc_cipher not in supported_ciphers: -            raise SecretsException("Unknown cipher in stored secret.") -        # recover the initial value and ciphertext -        iv, ciphertext = encrypted_secret_dict[self.SECRET_KEY].split( -            self.SEPARATOR, 1) -        ciphertext = binascii.a2b_base64(ciphertext) -        try: -            decrypted_secret = _crypto.decrypt_sym( -                ciphertext, key, iv, doc_cipher) -        except Exception as e: -            logger.error(e) -            raise SecretsException("Unable to decrypt secret.") -        if encrypted_secret_dict[self.LENGTH_KEY] != len(decrypted_secret): -            raise SecretsException("Wrong length of decrypted secret.") -        return decrypted_secret - -    def _encrypt_storage_secret(self, decrypted_secret, doc_cipher=None): -        """ -        Encrypt the storage secret. - -        An encrypted secret has the following structure: - -            { -                '<secret_id>': { -                        'kdf': 'scrypt', -                        'kdf_salt': '<b64 repr of salt>' -                        'kdf_length': <key length> -                        'cipher': 'aes256', -                        'length': <secret length>, -                        'secret': '<encrypted b64 repr of storage_secret>', -                } -            } - -        :param decrypted_secret: The decrypted storage secret. -        :type decrypted_secret: str -        :param cipher: (Optional) The ciper to use. Defaults to AES256 -        :type cipher: str - -        :return: The encrypted storage secret. -        :rtype: dict -        """ -        # generate random salt -        salt = os.urandom(self.SALT_LENGTH) -        # get a 256-bit key -        key = scrypt.hash(self._passphrase_as_string(), salt, buflen=32) -        doc_cipher = doc_cipher or self.CIPHER_AES256_GCM -        iv, ciphertext = _crypto.encrypt_sym(decrypted_secret, key, doc_cipher) -        ciphertext = binascii.b2a_base64(ciphertext) -        encrypted_secret_dict = { -            # leap.soledad.crypto submodule uses AES256 for symmetric -            # encryption. -            self.KDF_KEY: self.KDF_SCRYPT, -            self.KDF_SALT_KEY: binascii.b2a_base64(salt), -            self.KDF_LENGTH_KEY: len(key), -            self.CIPHER_KEY: doc_cipher, -            self.LENGTH_KEY: len(decrypted_secret), -            self.SECRET_KEY: self.SEPARATOR.join([str(iv), ciphertext]) -        } -        return encrypted_secret_dict - -    @property -    def storage_secret(self): -        """ -        Return the storage secret. - -        :return: The decrypted storage secret. -        :rtype: str -        """ -        return self._secrets.get(self._secret_id) - -    def set_secret_id(self, secret_id): -        """ -        Define the id of the storage secret to be used. - -        This method will also replace the secret in the crypto object. - -        :param secret_id: The id of the storage secret to be used. -        :type secret_id: str -        """ -        self._secret_id = secret_id - -    def _gen_secret(self): -        """ -        Generate a secret for symmetric encryption and store in a local -        encrypted file. - -        This method emits the following events.signals: - -            * SOLEDAD_CREATING_KEYS -            * SOLEDAD_DONE_CREATING_KEYS - -        :return: The id of the generated secret. -        :rtype: str -        """ -        user_data = self._get_user_data() -        events.emit_async(events.SOLEDAD_CREATING_KEYS, user_data) -        # generate random secret -        secret = os.urandom(self.GEN_SECRET_LENGTH) -        secret_id = sha256(secret).hexdigest() -        self._secrets[secret_id] = secret -        self._store_secrets() -        events.emit_async(events.SOLEDAD_DONE_CREATING_KEYS, user_data) -        return secret_id - -    def _store_secrets(self): -        """ -        Store secrets in C{Soledad.STORAGE_SECRETS_FILE_PATH}. -        """ -        with open(self._secrets_path, 'w') as f: -            f.write( -                json.dumps( -                    self._export_recovery_document())) - -    def change_passphrase(self, new_passphrase): -        """ -        Change the passphrase that encrypts the storage secret. - -        :param new_passphrase: The new passphrase. -        :type new_passphrase: unicode - -        :raise NoStorageSecret: Raised if there's no storage secret available. -        """ -        # TODO: maybe we want to add more checks to guarantee passphrase is -        # reasonable? -        soledad_assert_type(new_passphrase, unicode) -        if len(new_passphrase) < self.MINIMUM_PASSPHRASE_LENGTH: -            raise PassphraseTooShort( -                'Passphrase must be at least %d characters long!' % -                self.MINIMUM_PASSPHRASE_LENGTH) -        # ensure there's a secret for which the passphrase will be changed. -        if not self._has_secret(): -            raise NoStorageSecret() -        self._passphrase = new_passphrase -        self._store_secrets() -        self._put_secrets_in_shared_db() - -    # -    # Setters and getters -    # - -    @property -    def secret_id(self): -        return self._secret_id - -    def _get_secrets_path(self): -        return self._secrets_path - -    def _set_secrets_path(self, secrets_path): -        self._secrets_path = secrets_path - -    secrets_path = property( -        _get_secrets_path, -        _set_secrets_path, -        doc='The path for the file containing the encrypted symmetric secret.') - -    @property -    def passphrase(self): -        """ -        Return the passphrase for locking and unlocking encryption secrets for -        local and remote storage. -        """ -        return self._passphrase - -    def _passphrase_as_string(self): -        return self._passphrase.encode('utf-8') - -    # -    # remote storage secret -    # - -    @property -    def remote_storage_secret(self): -        """ -        Return the secret for remote storage. -        """ -        key_start = 0 -        key_end = self.REMOTE_STORAGE_SECRET_LENGTH -        return self.storage_secret[key_start:key_end] - -    # -    # local storage key -    # - -    def _get_local_storage_secret(self): -        """ -        Return the local storage secret. - -        :return: The local storage secret. -        :rtype: str -        """ -        secret_len = self.REMOTE_STORAGE_SECRET_LENGTH -        lsecret_len = self.LOCAL_STORAGE_SECRET_LENGTH -        pwd_start = secret_len + self.SALT_LENGTH -        pwd_end = secret_len + lsecret_len -        return self.storage_secret[pwd_start:pwd_end] - -    def _get_local_storage_salt(self): -        """ -        Return the local storage salt. - -        :return: The local storage salt. -        :rtype: str -        """ -        salt_start = self.REMOTE_STORAGE_SECRET_LENGTH -        salt_end = salt_start + self.SALT_LENGTH -        return self.storage_secret[salt_start:salt_end] - -    def get_local_storage_key(self): -        """ -        Return the local storage key derived from the local storage secret. - -        :return: The key for protecting the local database. -        :rtype: str -        """ -        return scrypt.hash( -            password=self._get_local_storage_secret(), -            salt=self._get_local_storage_salt(), -            buflen=32,  # we need a key with 256 bits (32 bytes) -        ) - -    # -    # sync db key -    # - -    def _get_sync_db_salt(self): -        """ -        Return the salt for sync db. -        """ -        salt_start = self.LOCAL_STORAGE_SECRET_LENGTH \ -            + self.REMOTE_STORAGE_SECRET_LENGTH -        salt_end = salt_start + self.SALT_LENGTH -        return self.storage_secret[salt_start:salt_end] - -    def get_sync_db_key(self): -        """ -        Return the key for protecting the sync database. - -        :return: The key for protecting the sync database. -        :rtype: str -        """ -        return scrypt.hash( -            password=self._get_local_storage_secret(), -            salt=self._get_sync_db_salt(), -            buflen=32,  # we need a key with 256 bits (32 bytes) -        ) - -    def _get_user_data(self): -        return {'uuid': self._uuid, 'userid': self._userid} diff --git a/client/src/leap/soledad/client/shared_db.py b/client/src/leap/soledad/client/shared_db.py index d43db045..52b226b9 100644 --- a/client/src/leap/soledad/client/shared_db.py +++ b/client/src/leap/soledad/client/shared_db.py @@ -17,7 +17,7 @@  """  A shared database for storing/retrieving encrypted key material.  """ -from leap.soledad.common.l2db.remote import http_database +from leap.soledad.common.l2db.remote.http_database import HTTPDatabase  from leap.soledad.client.auth import TokenBasedAuth @@ -47,7 +47,7 @@ class ImproperlyConfiguredError(Exception):      """ -class SoledadSharedDatabase(http_database.HTTPDatabase, TokenBasedAuth): +class SoledadSharedDatabase(HTTPDatabase, TokenBasedAuth):      """      This is a shared recovery database that enables users to store their      encryption secrets in the server and retrieve them afterwards. @@ -134,20 +134,16 @@ class SoledadSharedDatabase(http_database.HTTPDatabase, TokenBasedAuth):          """          raise Unauthorized("Can't delete shared database.") -    def __init__(self, url, uuid, document_factory=None, creds=None): +    def __init__(self, url, document_factory=None, creds=None):          """          Initialize database with auth token and encryption powers.          :param url: URL of the remote database.          :type url: str -        :param uuid: The user's unique id. -        :type uuid: str          :param document_factory: A factory for U1BD documents.          :type document_factory: u1db.Document          :param creds: A tuple containing the authentication method and              credentials.          :type creds: tuple          """ -        http_database.HTTPDatabase.__init__(self, url, document_factory, -                                            creds) -        self._uuid = uuid +        HTTPDatabase.__init__(self, url, document_factory, creds) diff --git a/testing/tests/client/test_aux_methods.py b/testing/tests/client/test_aux_methods.py index 9b4a175f..a08f7d36 100644 --- a/testing/tests/client/test_aux_methods.py +++ b/testing/tests/client/test_aux_methods.py @@ -19,12 +19,11 @@ Tests for general Soledad functionality.  """  import os -from twisted.internet import defer +from pytest import inlineCallbacks  from leap.soledad.client import Soledad  from leap.soledad.client.adbapi import U1DBConnectionPool -from leap.soledad.client.secrets import PassphraseTooShort -from leap.soledad.client.secrets import SecretsException +from leap.soledad.client._secrets.util import SecretsError  from test_soledad.util import BaseSoledadTest @@ -34,7 +33,7 @@ class AuxMethodsTestCase(BaseSoledadTest):      def test__init_dirs(self):          sol = self._soledad_instance(prefix='_init_dirs')          local_db_dir = os.path.dirname(sol.local_db_path) -        secrets_path = os.path.dirname(sol.secrets.secrets_path) +        secrets_path = os.path.dirname(sol.secrets.storage._local_path)          self.assertTrue(os.path.isdir(local_db_dir))          self.assertTrue(os.path.isdir(secrets_path)) @@ -85,14 +84,14 @@ class AuxMethodsTestCase(BaseSoledadTest):              cert_file=None)          self.assertEqual(              os.path.join(self.tempdir, 'value_3'), -            sol.secrets.secrets_path) +            sol.secrets.storage._local_path)          self.assertEqual(              os.path.join(self.tempdir, 'value_2'),              sol.local_db_path)          self.assertEqual('value_1', sol._server_url)          sol.close() -    @defer.inlineCallbacks +    @inlineCallbacks      def test_change_passphrase(self):          """          Test if passphrase can be changed. @@ -108,7 +107,7 @@ class AuxMethodsTestCase(BaseSoledadTest):          sol.change_passphrase(u'654321')          sol.close() -        with self.assertRaises(SecretsException): +        with self.assertRaises(SecretsError):              self._soledad_instance(                  'leap@leap.se',                  passphrase=u'123', @@ -124,20 +123,6 @@ class AuxMethodsTestCase(BaseSoledadTest):          sol2.close() -    def test_change_passphrase_with_short_passphrase_raises(self): -        """ -        Test if attempt to change passphrase passing a short passphrase -        raises. -        """ -        sol = self._soledad_instance( -            'leap@leap.se', -            passphrase=u'123') -        # check that soledad complains about new passphrase length -        self.assertRaises( -            PassphraseTooShort, -            sol.change_passphrase, u'54321') -        sol.close() -      def test_get_passphrase(self):          """          Assert passphrase getter works fine. diff --git a/testing/tests/client/test_crypto.py b/testing/tests/client/test_crypto.py index 49a61438..379475cd 100644 --- a/testing/tests/client/test_crypto.py +++ b/testing/tests/client/test_crypto.py @@ -19,9 +19,9 @@ Tests for cryptographic related stuff.  """  import binascii  import base64 -import hashlib  import json  import os +import scrypt  from io import BytesIO @@ -34,6 +34,7 @@ from cryptography.exceptions import InvalidTag  from leap.soledad.common.document import SoledadDocument  from test_soledad.util import BaseSoledadTest  from leap.soledad.client import _crypto +from leap.soledad.client._secrets import SecretsCrypto  from twisted.trial import unittest  from twisted.internet import defer @@ -186,99 +187,145 @@ class BlobTestCase(unittest.TestCase):              yield crypto.decrypt_doc(doc2) -class RecoveryDocumentTestCase(BaseSoledadTest): - -    def test_export_recovery_document_raw(self): -        rd = self._soledad.secrets._export_recovery_document() -        secret_id = rd[self._soledad.secrets.STORAGE_SECRETS_KEY].items()[0][0] -        # assert exported secret is the same -        secret = self._soledad.secrets._decrypt_storage_secret_version_1( -            rd[self._soledad.secrets.STORAGE_SECRETS_KEY][secret_id]) -        self.assertEqual(secret_id, self._soledad.secrets._secret_id) -        self.assertEqual(secret, self._soledad.secrets._secrets[secret_id]) -        # assert recovery document structure -        encrypted_secret = rd[ -            self._soledad.secrets.STORAGE_SECRETS_KEY][secret_id] -        self.assertTrue(self._soledad.secrets.CIPHER_KEY in encrypted_secret) -        self.assertEquals( -            _crypto.ENC_METHOD.aes_256_gcm, -            encrypted_secret[self._soledad.secrets.CIPHER_KEY]) -        self.assertTrue(self._soledad.secrets.LENGTH_KEY in encrypted_secret) -        self.assertTrue(self._soledad.secrets.SECRET_KEY in encrypted_secret) - -    def test_import_recovery_document(self, cipher='aes256'): -        rd = self._soledad.secrets._export_recovery_document(cipher) -        s = self._soledad_instance() -        s.secrets._import_recovery_document(rd) -        s.secrets.set_secret_id(self._soledad.secrets._secret_id) -        self.assertEqual(self._soledad.storage_secret, -                         s.storage_secret, -                         'Failed settinng secret for symmetric encryption.') -        s.close() - -    def test_import_GCM_recovery_document(self): -        cipher = self._soledad.secrets.CIPHER_AES256_GCM -        self.test_import_recovery_document(cipher) - -    def test_import_legacy_CTR_recovery_document(self): -        cipher = self._soledad.secrets.CIPHER_AES256 -        self.test_import_recovery_document(cipher) +class SecretsCryptoTestCase(unittest.TestCase): + +    SECRETS = {'remote': 'a' * 512, 'salt': 'b' * 64, 'local': 'c' * 448} +    ENCRYPTED_V2 = { +        'cipher': 'aes_256_gcm', +        'length': 1417, +        'kdf_salt': '3DCkfecls0GcX2RadA04FAC2cqkI+vpGwwCLwffdRI6vpO5SPxaw/eM0/' +                    'z3GUADm3If3YCQBldKXNdqHQLsU1Q==\n', +        'iv': 'rRwCDw5Rbp5+J3QwjQ46Hw==', +        'secrets': 'lxf6yrGDcBr8XFWNDgsCoO2XPGfDJndviL9Y2GmHcSEBWnO2dm2sieuPoq' +                   'PwSHRSJSrzM4Ezgdaan7X8+ErnuRLUVqbPAqPl8xx8FdCjnid4vFyFYNFI' +                   '/dmo8SQAf8O9vdlVEPZ5Nk2DuWIrh+oPlrSUOmR6XzI0YVdoJDmGWowygU' +                   'MR0R9Bi9xFGlG135NVcNP8KGdnQDkI0V+U/3qm3tctbo4LRCxxJ60wdi0M' +                   'DA6iYFI/IMshxI/ZXFHp5/YPk0k2m0i6z71kMVksgjIMMgT5Kmz7WR54na' +                   'IkWbvNkbYRFR/Hbg9p6Bs7NjJlOLTjnwGJNYPbdyfJXKd1R/S8Mg7ZqsyQ' +                   'VbBqXHwEN7gYlMZ66D8wu8LOK70mN7LLiSz5J8tXO3rDT1mIIf3IvNhv/j' +                   'rEZHf1fTFPRp+ZVEt/hJKyPv71ua4p2lgdgNlCs2IsACk9ku/LQwXP6uZr' +                   'hMJsTvniTQoCVXFYVN/jKo7Pz/+uT5wOXOXtL7smpBE/2r3uoERNM+Zw11' +                   'SA8UzzMZQMxJQKVNwLmKtwvztN5dxVXhxCUyeLmeQc84VzV7NK0WMUOdfA' +                   '18I0HS6rHLKcdsvrPAdzvGim7tiE8TBdp8ITNQ8yMFNiGNyOVliTSTwQFf' +                   'sCj6m5nYcjvprNQ8RkeitvicrtI1Ylc8CfFK50xPV77XVmlgvNsfm54msN' +                   'tV0K5+XwaNgimlh/1m2bVEYj55gO0twVASwRuZj3sSY2z669iuXRk7EPyT' +                   'jcE2NnfW+lqOQkJ73N7pv73t6OjiEnrKx7VmH94zYlY8ZReVVn4RTZhare' +                   'D7rqCmGPhsPaCPaAfotfNBBa0w6p6L9ZlNxpIesnMObtyGob1g4Vcu8O6K' +                   '2Q1Ldj95+Q53tJDpx2NLP/5tfAUlbehD3whKwKOz/rGKEfhgE+Nx32RR0y' +                   'YM4aJ7CYI/U3YH82xqGoa1ufIJbSBt965CVIHSVJt/mYfilhMACV/wBlvL' +                   'ua08iKpHwc7suMc9DuFS4s/bAzc128L8wtfNvNiP6zhAV+UvfgUmyNKjgl' +                   '0be9Ke2pCNChEQmViNal3zbWNcBrXYQpFpX1lWNkx/OuQalxzSaqmZiOR5' +                   'eRwqRDZ3R9EpkOFj2ZXS1NlJg1kYXL/ibS8uvjKgJFPrZQzwaKmPNsZyGc' +                   'CnHupfgC2iRIu97wnvmDxWQ9Cs62NSynr0IYGkTLN5PZU6Z5gd1F7zV6uh' +                   'oFiHOYidj2EoUj7xnb8GHi5U6PQzaC97nSCR4CFnmcpfv+XcRIWe8nrM8G' +                   'AVdcUob8pofUlnyGV6GEGlO3mnb7ls5B6lvuZqB/x6UqZiNKwmZvxvS11X' +                   'AGkhfBGTfFZeqRlLwXvXWnOUOO0KJ8h3gSlc1gFVY+4HCbTOqjUASWw0mV' +                   'JP+U0anK9wu9B/icLDUZxM/NRdbTQFmcfvABjwdm2GTmwGpQek/H0wN3dO' +                   'terlTiS7arMUft7A6hkhkmLb0iDfWPWdN50V+XOMpdZtaJSGqwNHokc75p' +                   '3zYll0/ZpxTgmWXariOkKxr6KHHjml89QNQSBE2TJW/YnQ5SrkaHLHKdcy' +                   'PqQtcXDz/WxKquQfRF+fsvcwqaeqlAWOxUXHU77cBvDGPU5O3uvEIJnHr1' +                   'kuabqRQbJIV5Uzo4sEW828r2IWQnUd4Om79y+9yp/aT10DusEmvOgS3oSp' +                   '3eYkhvlVULeCQEJoI41t4nGLhHiiK4xBG8yFknuV7nF4k2O+EbyCXsJeeD' +                   'qlGok91zEhQl1MlQA8ZofRK7bDPcn97USiJMss81s5bwIv4yN8s0QL62Ha' +                   'vrIYG7C26DV6c0GxULu02H1YOnoPf6JsGC/2+zA+b7a+4O0EP0BXU3FYCb' +                   'iEDbDpB3dFe63ed+ml2HQjqzOLAtKVXzAQq5UNV4m2zY0/y7gV7qSrM=' +                   '\n', +        'version': 2, +        'kdf': 'scrypt', +        'kdf_length': 32 +    } + +    ENCRYPTED_V1 = { +        'version': 1, +        'active_secret': 'secret_id', +        'storage_secrets': { +            'secret_id': { +                'kdf': 'scrypt', +                'secret': 'u31ObvxNU8jB0HgMj3TVwQ==:JQwlYq6sAQmHYS3x2CJzObT9h' +                          'j1iiHthvrMh887qedNCcOfJyCA3jpRkc0vjd2Qk/2HSJ+JxM2F' +                          'MrPzzx5O34EHlgF2scen34guZRRIf42WpnMy+PrL4cnMlZLgCh' +                          'H1Jz6wcIMEpU9LQ8OaCShk1/yJ6qcVHOV4DDt3mTF7ttiqI5cp' +                          'msaVtxxYCcpxFiWSeSCEgr0h4/Ih1qHuM6vk+CQjf/zg1f/7HR' +                          'imIyNYXit9Fw3YTkxBen1wG3f5L7OAODRTuqnWpkQFOmclx050' +                          'k0frKRcX6UWhIOWpW2mqJXnvzDtQQVGzqIdSgGTGtUDGQ7Onnc' +                          'NkUlSnuVC7PkDNNRuwit3pCB9YWBWyPAQgs0kLqoV4YcuSctz6' +                          'SAf76ozdcK5/SrOzutOfyPag4V3AYKMv6rCKALJ10OnFJ61FL9' +                          'kd6JZam7WOlEUXyO7Gdgvz+eKiQMTZXbtO2kAKqel513MedPXC' +                          'dzajUe1U2JaGg86UdiDWoPYOiWxnAPwfNJk+1QuNy5NZ7PaMtF' +                          'IKT3/Xema2U8mufS0FbvJyK2flP1VUWcCzHKTSqX6+kU7UpoWa' +                          'hYa7PlO40El+putTQLBmNaEeaWFngO+XB4TReICHSiCdcAb3pw' +                          'sabjtxt+OpK4vbj3yBSfpiZTpVbEjt9U/tUpVp/T2M66lMi3ZC' +                          'oHLlhu45Zo0aEq3UmQ/WBXu6EkO2eLYz2br9YQwRbSJ6z5CHmu' +                          'hjKBQlpvGNfZYObx5lY4o6Ab4f/N8gyukskjmAFAf7Fr8cEog/' +                          'oxmbagoCtUGRYJp2paooqH8L6xXp0Y8+23g7WJaAIr1i4V4aKS' +                          'r9x7iUK6prcZTtMJZEHCswkLN/+DU6/FX3YZcOjseC+Qv3P+9v' +                          'zIDp/92KJzqVqITGwrsc6ZsglMW37qxs6albtw3lMWSHlkcLbj' +                          'Xf/iHPeKnb2WNLdkFNQ1J5OaTJR+E1CrXN+pm1JtB6XaUbaLGV' +                          'CGUo13lAPVDtXcPbo64kMrQtQu4m9m8X8t8tfuJmINfwBnrKzk' +                          'O6pl+LwimFaFEArV6wcaMxmwi0lM7mt4U1u9OIQjghQ/dEmOyV' +                          'dZBnvyG7T/oRuLdUyZ/QGXZMlPQ3lAZ0ONn1Mk4bmKToW8ToE8' +                          'ylld3rLlWDjjoQP8mP05Izg3mguLHXUhikUL8MD5NdYyeZJ1XZ' +                          '0OZ5S9uncurYj2ABWJoVaq/tFCdCEo9bbjWsePei26GZjaM3Fx' +                          'RkAICXe/bt6/uLgaPZtO+sdARDuU3DRKMIdgM9NBaIn0kC7Wk4' +                          'bnYShZ/rbhVt2/ds5XinnDBZsxSR3s553DixJ9v6w9Db++9Stw' +                          '4DgePd9lLy+6WuVBlKmcNflx9zg7US0AOarX2UNiQ==', +                'kdf_length': 32, +                'kdf_salt': 'MYH68QH48nRFMWH44piFWqBnKtU8KCz6Ajh24otrvzJlqPgB' +                            'v6bvFJjRvjRp/0/v1j2nt40RZ6H5hfoKmore0g==\n', +                'length': 1024, +                'cipher': 'aes256', +            } +        } +    } + +    def setUp(self): +        def _get_pass(): +            return '123' +        self._crypto = SecretsCrypto(_get_pass) + +    def test__get_pass(self): +        self.assertEqual(self._crypto._get_pass(), '123') + +    def test__get_key(self): +        salt = 'abc' +        expected = scrypt.hash('123', salt, buflen=32) +        key = self._crypto._get_key(salt) +        self.assertEqual(expected, key) + +    def test_encrypt(self): +        info = self._crypto.encrypt(self.SECRETS) +        self.assertEqual(8, len(info)) +        for key, value in [ +                ('kdf', 'scrypt'), +                ('kdf_salt', None), +                ('kdf_length', None), +                ('cipher', 'aes_256_gcm'), +                ('length', None), +                ('iv', None), +                ('secrets', None), +                ('version', 2)]: +            self.assertTrue(key in info) +            if value: +                self.assertEqual(info[key], value) + +    def test__decrypt_v2(self): +        encrypted = self.ENCRYPTED_V2 +        decrypted = self._crypto.decrypt(encrypted) +        self.assertEqual(decrypted, self.SECRETS) + +    def test__decrypt_v1(self): +        encrypted = self.ENCRYPTED_V1 +        decrypted = self._crypto.decrypt(encrypted) +        self.assertEqual(decrypted, self.SECRETS)  class SoledadSecretsTestCase(BaseSoledadTest): -    def test_new_soledad_instance_generates_one_secret(self): -        self.assertTrue( -            self._soledad.storage_secret is not None, -            "Expected secret to be something different than None") -        number_of_secrets = len(self._soledad.secrets._secrets) -        self.assertTrue( -            number_of_secrets == 1, -            "Expected exactly 1 secret, got %d instead." % number_of_secrets) - -    def test_generated_secret_is_of_correct_type(self): -        expected_type = str -        self.assertIsInstance( -            self._soledad.storage_secret, expected_type, -            "Expected secret to be of type %s" % expected_type) - -    def test_generated_secret_has_correct_lengt(self): -        expected_length = self._soledad.secrets.GEN_SECRET_LENGTH -        actual_length = len(self._soledad.storage_secret) -        self.assertTrue( -            expected_length == actual_length, -            "Expected secret with length %d, got %d instead." -            % (expected_length, actual_length)) - -    def test_generated_secret_id_is_sha256_hash_of_secret(self): -        generated = self._soledad.secrets.secret_id -        expected = hashlib.sha256(self._soledad.storage_secret).hexdigest() -        self.assertTrue( -            generated == expected, -            "Expeceted generated secret id to be sha256 hash, got something " -            "else instead.") - -    def test_generate_new_secret_generates_different_secret_id(self): -        # generate new secret -        secret_id_1 = self._soledad.secrets.secret_id -        secret_id_2 = self._soledad.secrets._gen_secret() -        self.assertTrue( -            len(self._soledad.secrets._secrets) == 2, -            "Expected exactly 2 secrets.") -        self.assertTrue( -            secret_id_1 != secret_id_2, -            "Expected IDs of secrets to be distinct.") -        self.assertTrue( -            secret_id_1 in self._soledad.secrets._secrets, -            "Expected to find ID of first secret in Soledad Secrets.") -        self.assertTrue( -            secret_id_2 in self._soledad.secrets._secrets, -            "Expected to find ID of second secret in Soledad Secrets.") - -    def test__has_secret(self): -        self.assertTrue( -            self._soledad._secrets._has_secret(), -            "Should have a secret at this point") +    def test_generated_secrets_have_correct_length(self): +        expected = self._soledad.secrets.lengths +        for name, length in expected.iteritems(): +            secret = getattr(self._soledad.secrets, name) +            self.assertEqual(length, len(secret))  class SoledadCryptoAESTestCase(BaseSoledadTest): diff --git a/testing/tests/client/test_deprecated_crypto.py b/testing/tests/client/test_deprecated_crypto.py index 8ee3735c..8c711c22 100644 --- a/testing/tests/client/test_deprecated_crypto.py +++ b/testing/tests/client/test_deprecated_crypto.py @@ -1,5 +1,7 @@  import json -from twisted.internet import defer + +from pytest import inlineCallbacks +  from uuid import uuid4  from urlparse import urljoin @@ -39,7 +41,7 @@ class DeprecatedCryptoTest(SoledadWithCouchServerMixin, TestCaseWithServer):      def make_app_with_state(state):          return make_token_soledad_app(state) -    @defer.inlineCallbacks +    @inlineCallbacks      def test_touch_updates_remote_representation(self):          self.startTwistedServer()          user = 'user-' + uuid4().hex diff --git a/testing/tests/client/test_shared_db.py b/testing/tests/client/test_shared_db.py index aac766c2..b045e524 100644 --- a/testing/tests/client/test_shared_db.py +++ b/testing/tests/client/test_shared_db.py @@ -2,7 +2,6 @@ from leap.soledad.common.document import SoledadDocument  from leap.soledad.client.shared_db import SoledadSharedDatabase  from test_soledad.util import BaseSoledadTest -from test_soledad.util import ADDRESS  class SoledadSharedDBTestCase(BaseSoledadTest): @@ -14,37 +13,28 @@ class SoledadSharedDBTestCase(BaseSoledadTest):      def setUp(self):          BaseSoledadTest.setUp(self)          self._shared_db = SoledadSharedDatabase( -            'https://provider/', ADDRESS, document_factory=SoledadDocument, +            'https://provider/', document_factory=SoledadDocument,              creds=None)      def tearDown(self):          BaseSoledadTest.tearDown(self) -    def test__get_secrets_from_shared_db(self): +    def test__get_remote_doc(self):          """          Ensure the shared db is queried with the correct doc_id.          """ -        doc_id = self._soledad.secrets._shared_db_doc_id() -        self._soledad.secrets._get_secrets_from_shared_db() -        self.assertTrue( -            self._soledad.shared_db.get_doc.assert_called_with( -                doc_id) is None, -            'Wrong doc_id when fetching recovery document.') - -    def test__put_secrets_in_shared_db(self): +        doc_id = self._soledad.secrets.storage._remote_doc_id() +        self._soledad.secrets.storage._get_remote_doc() +        self._soledad.secrets.storage._shared_db.get_doc.assert_called_with( +            doc_id) + +    def test_save_remote(self):          """          Ensure recovery document is put into shared recover db.          """ -        doc_id = self._soledad.secrets._shared_db_doc_id() -        self._soledad.secrets._put_secrets_in_shared_db() -        self.assertTrue( -            self._soledad.shared_db.get_doc.assert_called_with( -                doc_id) is None, -            'Wrong doc_id when fetching recovery document.') -        self.assertTrue( -            self._soledad.shared_db.put_doc.assert_called_with( -                self._doc_put) is None, -            'Wrong document when putting recovery document.') -        self.assertTrue( -            self._doc_put.doc_id == doc_id, -            'Wrong doc_id when putting recovery document.') +        doc_id = self._soledad.secrets.storage._remote_doc_id() +        storage = self._soledad.secrets.storage +        storage.save_remote({'content': 'blah'}) +        storage._shared_db.get_doc.assert_called_with(doc_id) +        storage._shared_db.put_doc.assert_called_with(self._doc_put) +        self.assertTrue(self._doc_put.doc_id == doc_id) diff --git a/testing/tests/client/test_signals.py b/testing/tests/client/test_signals.py index 4e9ebfd0..c7609a74 100644 --- a/testing/tests/client/test_signals.py +++ b/testing/tests/client/test_signals.py @@ -20,7 +20,7 @@ class SoledadSignalingTestCase(BaseSoledadTest):      def setUp(self):          # mock signaling          soledad.client.signal = Mock() -        soledad.client.secrets.events.emit_async = Mock() +        soledad.client._secrets.util.events.emit_async = Mock()          # run parent's setUp          BaseSoledadTest.setUp(self) @@ -42,55 +42,36 @@ class SoledadSignalingTestCase(BaseSoledadTest):            - downloading keys / done downloading keys.            - uploading keys / done uploading keys.          """ -        soledad.client.secrets.events.emit_async.reset_mock() +        soledad.client._secrets.util.events.emit_async.reset_mock()          # get a fresh instance so it emits all bootstrap signals          sol = self._soledad_instance(              secrets_path='alternative_stage3.json',              local_db_path='alternative_stage3.u1db')          # reverse call order so we can verify in the order the signals were          # expected -        soledad.client.secrets.events.emit_async.mock_calls.reverse() -        soledad.client.secrets.events.emit_async.call_args = \ -            soledad.client.secrets.events.emit_async.call_args_list[0] -        soledad.client.secrets.events.emit_async.call_args_list.reverse() +        soledad.client._secrets.util.events.emit_async.mock_calls.reverse() +        soledad.client._secrets.util.events.emit_async.call_args = \ +            soledad.client._secrets.util.events.emit_async.call_args_list[0] +        soledad.client._secrets.util.events.emit_async.call_args_list.reverse()          user_data = {'userid': ADDRESS, 'uuid': ADDRESS} -        # downloading keys signals -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DOWNLOADING_KEYS, user_data -        ) -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DONE_DOWNLOADING_KEYS, user_data -        ) -        # creating keys signals -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_CREATING_KEYS, user_data -        ) -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DONE_CREATING_KEYS, user_data -        ) -        # downloading once more (inside _put_keys_in_shared_db) -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DOWNLOADING_KEYS, user_data -        ) -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DONE_DOWNLOADING_KEYS, user_data -        ) -        # uploading keys signals -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_UPLOADING_KEYS, user_data -        ) -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DONE_UPLOADING_KEYS, user_data -        ) +        def _assert(*args, **kwargs): +            mocked = soledad.client._secrets.util.events.emit_async +            mocked.assert_called_with(*args) +            pop = kwargs.get('pop') +            if pop or pop is None: +                self._pop_mock_call(mocked) + +        _assert(catalog.SOLEDAD_DOWNLOADING_KEYS, user_data) +        _assert(catalog.SOLEDAD_DONE_DOWNLOADING_KEYS, user_data) +        _assert(catalog.SOLEDAD_CREATING_KEYS, user_data) +        _assert(catalog.SOLEDAD_DONE_CREATING_KEYS, user_data) +        _assert(catalog.SOLEDAD_UPLOADING_KEYS, user_data) +        _assert(catalog.SOLEDAD_DOWNLOADING_KEYS, user_data) +        _assert(catalog.SOLEDAD_DONE_DOWNLOADING_KEYS, user_data) +        _assert(catalog.SOLEDAD_DONE_UPLOADING_KEYS, user_data, pop=False) +          sol.close()      def test_stage2_bootstrap_signals(self): @@ -101,11 +82,11 @@ class SoledadSignalingTestCase(BaseSoledadTest):          # get existing instance so we have access to keys          sol = self._soledad_instance()          # create a document with secrets -        doc = SoledadDocument(doc_id=sol.secrets._shared_db_doc_id()) -        doc.content = sol.secrets._export_recovery_document() +        doc = SoledadDocument(doc_id=sol.secrets.storage._remote_doc_id()) +        doc.content = sol.secrets.crypto.encrypt(sol.secrets._secrets)          sol.close()          # reset mock -        soledad.client.secrets.events.emit_async.reset_mock() +        soledad.client._secrets.util.events.emit_async.reset_mock()          # get a fresh instance so it emits all bootstrap signals          shared_db = self.get_default_shared_mock(get_doc_return_value=doc)          sol = self._soledad_instance( @@ -114,20 +95,23 @@ class SoledadSignalingTestCase(BaseSoledadTest):              shared_db_class=shared_db)          # reverse call order so we can verify in the order the signals were          # expected -        soledad.client.secrets.events.emit_async.mock_calls.reverse() -        soledad.client.secrets.events.emit_async.call_args = \ -            soledad.client.secrets.events.emit_async.call_args_list[0] -        soledad.client.secrets.events.emit_async.call_args_list.reverse() +        mocked = soledad.client._secrets.util.events.emit_async +        mocked.mock_calls.reverse() +        mocked.call_args = mocked.call_args_list[0] +        mocked.call_args_list.reverse() + +        def _assert(*args, **kwargs): +            mocked = soledad.client._secrets.util.events.emit_async +            mocked.assert_called_with(*args) +            pop = kwargs.get('pop') +            if pop or pop is None: +                self._pop_mock_call(mocked) +          # assert download keys signals -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DOWNLOADING_KEYS, -            {'userid': ADDRESS, 'uuid': ADDRESS} -        ) -        self._pop_mock_call(soledad.client.secrets.events.emit_async) -        soledad.client.secrets.events.emit_async.assert_called_with( -            catalog.SOLEDAD_DONE_DOWNLOADING_KEYS, -            {'userid': ADDRESS, 'uuid': ADDRESS}, -        ) +        user_data = {'userid': ADDRESS, 'uuid': ADDRESS} +        _assert(catalog.SOLEDAD_DOWNLOADING_KEYS, user_data) +        _assert(catalog.SOLEDAD_DONE_DOWNLOADING_KEYS, user_data, pop=False) +          sol.close()      def test_stage1_bootstrap_signals(self): diff --git a/testing/tests/sync/test_sync_target.py b/testing/tests/sync/test_sync_target.py index 6ce9a5c5..302a16b8 100644 --- a/testing/tests/sync/test_sync_target.py +++ b/testing/tests/sync/test_sync_target.py @@ -838,7 +838,7 @@ class TestSoledadDbSync(          # already created on some setUp method.          import binascii          tohex = binascii.b2a_hex -        key = tohex(self._soledad.secrets.get_local_storage_key()) +        key = tohex(self._soledad.secrets.local)          dbpath = self._soledad._local_db_path          self.opts = SQLCipherOptions( | 
