summaryrefslogtreecommitdiff
path: root/client/src/leap/soledad
diff options
context:
space:
mode:
authordrebs <drebs@leap.se>2016-12-05 09:10:47 -0200
committerKali Kaneko <kali@leap.se>2017-02-09 17:41:26 +0100
commitff85c2a41fe933d9959fb84a0df2a13a6e199cec (patch)
tree6f52cae13589aa13675f662f40f5040056575ef1 /client/src/leap/soledad
parent8a463796bbaba3979234b0699d140947581421e7 (diff)
[refactor] improve secrets generation and storage code
Diffstat (limited to 'client/src/leap/soledad')
-rw-r--r--client/src/leap/soledad/client/_secrets/__init__.py132
-rw-r--r--client/src/leap/soledad/client/_secrets/crypto.py123
-rw-r--r--client/src/leap/soledad/client/_secrets/storage.py124
-rw-r--r--client/src/leap/soledad/client/_secrets/util.py46
-rw-r--r--client/src/leap/soledad/client/api.py100
-rw-r--r--client/src/leap/soledad/client/interfaces.py20
-rw-r--r--client/src/leap/soledad/client/secrets.py794
-rw-r--r--client/src/leap/soledad/client/shared_db.py12
8 files changed, 440 insertions, 911 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)