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( |