From ff85c2a41fe933d9959fb84a0df2a13a6e199cec Mon Sep 17 00:00:00 2001 From: drebs Date: Mon, 5 Dec 2016 09:10:47 -0200 Subject: [refactor] improve secrets generation and storage code --- .../src/leap/soledad/client/_secrets/__init__.py | 132 ++++ client/src/leap/soledad/client/_secrets/crypto.py | 123 ++++ client/src/leap/soledad/client/_secrets/storage.py | 124 ++++ client/src/leap/soledad/client/_secrets/util.py | 46 ++ client/src/leap/soledad/client/api.py | 100 +-- client/src/leap/soledad/client/interfaces.py | 20 - client/src/leap/soledad/client/secrets.py | 794 --------------------- client/src/leap/soledad/client/shared_db.py | 12 +- 8 files changed, 440 insertions(+), 911 deletions(-) create mode 100644 client/src/leap/soledad/client/_secrets/__init__.py create mode 100644 client/src/leap/soledad/client/_secrets/crypto.py create mode 100644 client/src/leap/soledad/client/_secrets/storage.py create mode 100644 client/src/leap/soledad/client/_secrets/util.py delete mode 100644 client/src/leap/soledad/client/secrets.py (limited to 'client/src/leap/soledad') 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 . + +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 . + +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 . + +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 . + + +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 . - - -""" -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': { - '': { - 'cipher': 'aes256', - 'length': , - 'secret': '', - }, - }, - 'active_secret': '', - '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': { - '': { - 'cipher': 'aes256', - 'length': , - 'secret': '', - }, - }, - 'active_secret': '', - '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: - - '': { - 'cipher': 'aes256', - 'length': , - '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: - - { - '': { - 'kdf': 'scrypt', - 'kdf_salt': '' - 'kdf_length': - 'cipher': 'aes256', - 'length': , - '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) -- cgit v1.2.3