diff options
Diffstat (limited to 'src/leap/soledad/__init__.py')
-rw-r--r-- | src/leap/soledad/__init__.py | 451 |
1 files changed, 451 insertions, 0 deletions
diff --git a/src/leap/soledad/__init__.py b/src/leap/soledad/__init__.py new file mode 100644 index 00000000..86eb762e --- /dev/null +++ b/src/leap/soledad/__init__.py @@ -0,0 +1,451 @@ +# -*- coding: utf-8 -*- +""" +Soledad - Synchronization Of Locally Encrypted Data Among Devices. + +Soledad is the part of LEAP that manages storage and synchronization of +application data. It is built on top of U1DB reference Python API and +implements (1) a SQLCipher backend for local storage in the client, (2) a +SyncTarget that encrypts data to the user's private OpenPGP key before +syncing, and (3) a CouchDB backend for remote storage in the server side. +""" + +import os +import string +import random +import hmac +import configparser +import re +try: + import simplejson as json +except ImportError: + import json # noqa +from leap.soledad.backends import sqlcipher +from leap.soledad.util import GPGWrapper +from leap.soledad.backends.leap_backend import ( + LeapDocument, + DocumentNotEncrypted, +) +from leap.soledad.shared_db import SoledadSharedDatabase + + +class KeyDoesNotExist(Exception): + """ + Soledad attempted to find a key that does not exist locally. + """ + + +class KeyAlreadyExists(Exception): + """ + Soledad attempted to create a key that already exists locally. + """ + + +#----------------------------------------------------------------------------- +# Soledad: local encrypted storage and remote encrypted sync. +#----------------------------------------------------------------------------- + +class Soledad(object): + """ + Soledad provides encrypted data storage and sync. + + A Soledad instance is used to store and retrieve data in a local encrypted + database and synchronize this database with Soledad server. + + This class is also responsible for bootstrapping users' account by + creating OpenPGP keys and other cryptographic secrets and/or + storing/fetching them on Soledad server. + """ + + # other configs + SECRET_LENGTH = 50 + DEFAULT_CONF = { + 'gnupg_home': '%s/gnupg', + 'secret_path': '%s/secret.gpg', + 'local_db_path': '%s/soledad.u1db', + 'config_file': '%s/soledad.ini', + 'shared_db_url': '', + } + + def __init__(self, user_email, prefix=None, gnupg_home=None, + secret_path=None, local_db_path=None, + config_file=None, shared_db_url=None, auth_token=None, + initialize=True): + """ + Bootstrap Soledad, initialize cryptographic material and open + underlying U1DB database. + """ + self._user_email = user_email + self._auth_token = auth_token + self._init_config( + {'prefix': prefix, + 'gnupg_home': gnupg_home, + 'secret_path': secret_path, + 'local_db_path': local_db_path, + 'config_file': config_file, + 'shared_db_url': shared_db_url, + } + ) + if self.shared_db_url: + # TODO: eliminate need to create db here. + self._shared_db = SoledadSharedDatabase.open_database( + shared_db_url, + True, + token=auth_token) + if initialize: + self._bootstrap() + + def _bootstrap(self): + """ + Bootstrap local Soledad instance. + + There are 3 stages for Soledad Client bootstrap: + + 1. No key material has been generated, so we need to generate and + upload to the server. + + 2. Key material has already been generated and uploaded to the + server, but has not been downloaded to this device/installation + yet. + + 3. Key material has already been generated and uploaded, and is + also stored locally, so we just need to load it from disk. + + This method decides which bootstrap stage has to be performed and + performs it. + """ + # TODO: make sure key storage always happens (even if this method is + # interrupted). + # TODO: write tests for bootstrap stages. + self._init_dirs() + self._gpg = GPGWrapper(gnupghome=self.gnupg_home) + if not self._has_keys(): + try: + # stage 2 bootstrap + self._retrieve_keys() + except Exception: + # stage 1 bootstrap + self._init_keys() + # TODO: change key below + self._send_keys(self._secret) + # stage 3 bootstrap + self._load_keys() + self._send_keys(self._secret) + self._init_db() + + def _init_config(self, param_conf): + """ + Initialize configuration, with precedence order give by: instance + parameters > config file > default values. + """ + # TODO: write tests for _init_config() + self.prefix = param_conf['prefix'] or \ + os.environ['HOME'] + '/.config/leap/soledad' + m = re.compile('.*%s.*') + for key, default_value in self.DEFAULT_CONF.iteritems(): + val = param_conf[key] or default_value + if m.match(val): + val = val % self.prefix + setattr(self, key, val) + # get config from file + # TODO: sanitize options from config file. + config = configparser.ConfigParser() + config.read(self.config_file) + if 'soledad-client' in config: + for key in default_conf: + if key in config['soledad-client'] and not param_conf[key]: + setattr(self, key, config['soledad-client'][key]) + + def _init_dirs(self): + """ + Create work directories. + """ + if not os.path.isdir(self.prefix): + os.makedirs(self.prefix) + + def _init_keys(self): + """ + Generate (if needed) and load OpenPGP keypair and secret for symmetric + encryption. + """ + # TODO: write tests for methods below. + # load/generate OpenPGP keypair + if not self._has_openpgp_keypair(): + self._gen_openpgp_keypair() + self._load_openpgp_keypair() + # load/generate secret + if not self._has_secret(): + self._gen_secret() + self._load_secret() + + def _init_db(self): + """ + Initialize the database for local storage . + """ + # instantiate u1db + # TODO: verify if secret for sqlcipher should be the same as the + # one for symmetric encryption. + self._db = sqlcipher.open( + self.local_db_path, + self._secret, + create=True, + document_factory=LeapDocument, + soledad=self) + + def close(self): + """ + Close underlying U1DB database. + """ + self._db.close() + + #------------------------------------------------------------------------- + # Management of secret for symmetric encryption + #------------------------------------------------------------------------- + + # TODO: refactor the following methods to somewhere out of here + # (SoledadCrypto, maybe?) + + def _has_secret(self): + """ + Verify if secret for symmetric encryption exists in a local encrypted + file. + """ + # does the file exist in disk? + if not os.path.isfile(self.secret_path): + return False + # is it asymmetrically encrypted? + f = open(self.secret_path, 'r') + content = f.read() + if not self.is_encrypted_asym(content): + raise DocumentNotEncrypted( + "File %s is not encrypted!" % self.secret_path) + # can we decrypt it? + fp = self._gpg.encrypted_to(content)['fingerprint'] + if fp != self._fingerprint: + raise KeyDoesNotExist("Secret for symmetric encryption is " + "encrypted to key with fingerprint '%s' " + "which we don't have." % fp) + return True + + def _load_secret(self): + """ + Load secret for symmetric encryption from local encrypted file. + """ + if not self._has_secret(): + raise KeyDoesNotExist("Tried to load key for symmetric " + "encryption but it does not exist on disk.") + try: + with open(self.secret_path) as f: + self._secret = str(self._gpg.decrypt(f.read())) + except IOError: + raise IOError('Failed to open secret file %s.' % self.secret_path) + + def _gen_secret(self): + """ + Generate a secret for symmetric encryption and store in a local + encrypted file. + """ + if self._has_secret(): + raise KeyAlreadyExists("Tried to generate secret for symmetric " + "encryption but it already exists on " + "disk.") + self._secret = ''.join( + random.choice( + string.ascii_letters + + string.digits) for x in range(self.SECRET_LENGTH)) + ciphertext = self._gpg.encrypt(self._secret, self._fingerprint, + self._fingerprint) + f = open(self.secret_path, 'w') + f.write(str(ciphertext)) + f.close() + + #------------------------------------------------------------------------- + # Management of OpenPGP keypair + #------------------------------------------------------------------------- + + def _has_openpgp_keypair(self): + """ + Verify if there exists an OpenPGP keypair for this user. + """ + try: + self._load_openpgp_keypair() + return True + except: + return False + + def _gen_openpgp_keypair(self): + """ + Generate an OpenPGP keypair for this user. + """ + if self._has_openpgp_keypair(): + raise KeyAlreadyExists("Tried to generate OpenPGP keypair but it " + "already exists on disk.") + params = self._gpg.gen_key_input( + key_type='RSA', + key_length=4096, + name_real=self._user_email, + name_email=self._user_email, + name_comment='Generated by LEAP Soledad.') + self._gpg.gen_key(params) + + def _load_openpgp_keypair(self): + """ + Find fingerprint for this user's OpenPGP keypair. + """ + # TODO: verify if we have the corresponding private key. + try: + self._fingerprint = self._gpg.find_key_by_email( + self._user_email, + secret=True)['fingerprint'] + return self._fingerprint + except LookupError: + raise KeyDoesNotExist("Tried to load OpenPGP keypair but it does " + "not exist on disk.") + + def publish_pubkey(self, keyserver): + """ + Publish OpenPGP public key to a keyserver. + """ + # TODO: this has to talk to LEAP's Nickserver. + pass + + #------------------------------------------------------------------------- + # General crypto utility methods. + #------------------------------------------------------------------------- + + def _has_keys(self): + return self._has_openpgp_keypair() and self._has_secret() + + def _load_keys(self): + self._load_openpgp_keypair() + self._load_secret() + + def _gen_keys(self): + self._gen_openpgp_keypair() + self._gen_secret() + + def _user_hash(self): + return hmac.new(self._user_email, 'user').hexdigest() + + def _retrieve_keys(self): + return self._shared_db.get_doc_unauth(self._user_hash()) + # TODO: create corresponding error on server side + + def _send_keys(self, passphrase): + # TODO: change this method's name to something more meaningful. + privkey = self._gpg.export_keys(self._fingerprint, secret=True) + content = { + '_privkey': self.encrypt(privkey, passphrase=passphrase, + symmetric=True), + '_symkey': self.encrypt(self._secret), + } + doc = self._retrieve_keys() + if not doc: + doc = LeapDocument(doc_id=self._user_hash(), soledad=self) + doc.content = content + self._shared_db.put_doc(doc) + + #------------------------------------------------------------------------- + # Data encryption and decryption + #------------------------------------------------------------------------- + + def encrypt(self, data, sign=None, passphrase=None, symmetric=False): + """ + Encrypt data. + """ + return str(self._gpg.encrypt(data, self._fingerprint, sign=sign, + passphrase=passphrase, + symmetric=symmetric)) + + def encrypt_symmetric(self, doc_id, data, sign=None): + """ + Encrypt data using symmetric secret. + """ + return self.encrypt(data, sign=sign, + passphrase=self._hmac_passphrase(doc_id), + symmetric=True) + + def decrypt(self, data, passphrase=None, symmetric=False): + """ + Decrypt data. + """ + return str(self._gpg.decrypt(data, passphrase=passphrase)) + + def decrypt_symmetric(self, doc_id, data): + """ + Decrypt data using symmetric secret. + """ + return self.decrypt(data, passphrase=self._hmac_passphrase(doc_id)) + + def _hmac_passphrase(self, doc_id): + return hmac.new(self._secret, doc_id).hexdigest() + + def is_encrypted(self, data): + return self._gpg.is_encrypted(data) + + def is_encrypted_sym(self, data): + return self._gpg.is_encrypted_sym(data) + + def is_encrypted_asym(self, data): + return self._gpg.is_encrypted_asym(data) + + #------------------------------------------------------------------------- + # Document storage, retrieval and sync + #------------------------------------------------------------------------- + + # TODO: refactor the following methods to somewhere out of here + # (SoledadLocalDatabase, maybe?) + + def put_doc(self, doc): + """ + Update a document in the local encrypted database. + """ + return self._db.put_doc(doc) + + def delete_doc(self, doc): + """ + Delete a document from the local encrypted database. + """ + return self._db.delete_doc(doc) + + def get_doc(self, doc_id, include_deleted=False): + """ + Retrieve a document from the local encrypted database. + """ + return self._db.get_doc(doc_id, include_deleted=include_deleted) + + def get_docs(self, doc_ids, check_for_conflicts=True, + include_deleted=False): + """ + Get the content for many documents. + """ + return self._db.get_docs(doc_ids, + check_for_conflicts=check_for_conflicts, + include_deleted=include_deleted) + + def create_doc(self, content, doc_id=None): + """ + Create a new document in the local encrypted database. + """ + return self._db.create_doc(content, doc_id=doc_id) + + def get_doc_conflicts(self, doc_id): + """ + Get the list of conflicts for the given document. + """ + return self._db.get_doc_conflicts(doc_id) + + def resolve_doc(self, doc, conflicted_doc_revs): + """ + Mark a document as no longer conflicted. + """ + return self._db.resolve_doc(doc, conflicted_doc_revs) + + def sync(self, url): + """ + Synchronize the local encrypted database with LEAP server. + """ + # TODO: create authentication scheme for sync with server. + return self._db.sync(url, creds=None, autocreate=True) + + +__all__ = ['util', 'server', 'shared_db'] |