From 4c74a91ad905e7d59260ccec789930c16b29a62d Mon Sep 17 00:00:00 2001 From: drebs Date: Tue, 23 Apr 2013 12:52:06 -0300 Subject: Add encryption_scheme property to LeapDocument. --- src/leap/soledad/backends/couch.py | 9 +- src/leap/soledad/backends/leap_backend.py | 72 ++++++++++- src/leap/soledad/backends/sqlcipher.py | 41 +++--- src/leap/soledad/crypto.py | 186 ++++++++++++++++++++++++++++ src/leap/soledad/tests/__init__.py | 2 + src/leap/soledad/tests/test_couch.py | 35 +++++- src/leap/soledad/tests/test_leap_backend.py | 2 + src/leap/soledad/tests/test_sqlcipher.py | 30 ++++- 8 files changed, 350 insertions(+), 27 deletions(-) create mode 100644 src/leap/soledad/crypto.py (limited to 'src') diff --git a/src/leap/soledad/backends/couch.py b/src/leap/soledad/backends/couch.py index 6440232d..13b6733c 100644 --- a/src/leap/soledad/backends/couch.py +++ b/src/leap/soledad/backends/couch.py @@ -35,6 +35,8 @@ from u1db.remote.server_state import ServerState from u1db.errors import DatabaseDoesNotExist from couchdb.client import Server, Document as CouchDocument from couchdb.http import ResourceNotFound + + from leap.soledad.backends.objectstore import ( ObjectStoreDatabase, ObjectStoreSyncTarget, @@ -142,7 +144,8 @@ class CouchDatabase(ObjectStoreDatabase): doc = self._factory( doc_id=doc_id, rev=cdoc['u1db_rev'], - has_conflicts=has_conflicts) + has_conflicts=has_conflicts, + encryption_scheme=cdoc['encryption_scheme']) contents = self._database.get_attachment(cdoc, 'u1db_json') if contents: doc.content = json.loads(contents.read()) @@ -192,12 +195,14 @@ class CouchDatabase(ObjectStoreDatabase): # prepare couch's Document cdoc = CouchDocument() cdoc['_id'] = doc.doc_id - # we have to guarantee that couch's _rev is cosistent + # we have to guarantee that couch's _rev is consistent old_cdoc = self._database.get(doc.doc_id) if old_cdoc is not None: cdoc['_rev'] = old_cdoc['_rev'] # store u1db's rev cdoc['u1db_rev'] = doc.rev + # store document's encryption scheme + cdoc['encryption_scheme'] = doc.encryption_scheme # save doc in db self._database.save(cdoc) # store u1db's content as json string diff --git a/src/leap/soledad/backends/leap_backend.py b/src/leap/soledad/backends/leap_backend.py index 687b59ef..dfec9e85 100644 --- a/src/leap/soledad/backends/leap_backend.py +++ b/src/leap/soledad/backends/leap_backend.py @@ -56,6 +56,16 @@ class DocumentNotEncrypted(Exception): pass +class EncryptionSchemes(object): + """ + Representation of encryption schemes used to encrypt documents. + """ + + NONE = 'none' + SYMKEY = 'symkey' + PUBKEY = 'pubkey' + + class LeapDocument(Document): """ Encryptable and syncable document. @@ -69,7 +79,8 @@ class LeapDocument(Document): """ def __init__(self, doc_id=None, rev=None, json='{}', has_conflicts=False, - encrypted_json=None, crypto=None, syncable=True): + encrypted_json=None, crypto=None, syncable=True, + encryption_scheme=EncryptionSchemes.NONE): """ Container for handling an encryptable document. @@ -89,10 +100,14 @@ class LeapDocument(Document): @type crypto: soledad.Soledad @param syncable: Should this document be synced with remote replicas? @type syncable: bool + @param encryption_scheme: The encryption scheme for this objects' + contents. + @type encryption_scheme: str """ Document.__init__(self, doc_id, rev, json, has_conflicts) self._crypto = crypto self._syncable = syncable + self._encryption_scheme = encryption_scheme if encrypted_json: self.set_encrypted_json(encrypted_json) @@ -118,6 +133,7 @@ class LeapDocument(Document): cyphertext, self._crypto._hash_passphrase(self.doc_id)) self.set_json(plaintext) + self.encryption_scheme = EncryptionSchemes.NONE def get_encrypted_json(self): """ @@ -163,6 +179,31 @@ class LeapDocument(Document): doc="Determine if document should be synced with server." ) + def _get_encryption_scheme(self): + """ + Return the encryption scheme used to encrypt this document's contents. + + @return: The encryption scheme used to encrypt this document's + contents. + @rtype: str + """ + return self._encryption_scheme + + def _set_encryption_scheme(self, encryption_scheme=True): + """ + Set the encryption scheme used to encrypt this document's contents. + + @param encryption_scheme: The encryption scheme. + @type encryption_scheme: str + """ + self._encryption_scheme = encryption_scheme + + encryption_scheme = property( + _get_encryption_scheme, + _set_encryption_scheme, + doc="The encryption scheme used to encrypt this document's contents." + ) + def _get_rev(self): """ Get the document revision. @@ -249,13 +290,31 @@ class LeapSyncTarget(HTTPSyncTarget): # decrypt after receiving from server. if not self._crypto: raise NoSoledadCryptoInstance() + # all content arriving should be encrypted either with the + # user's symmetric key or with the user's public key. enc_json = json.loads(entry['content'])['_encrypted_json'] - if not self._crypto.is_encrypted_sym(enc_json): + plain_json = None + if entry['encryption_scheme'] == EncryptionScheme.SYMKEY: + if not self._crypto.is_encrypted_sym(enc_json): + raise DocumentNotEncrypted( + 'Incoming document\'s contents should be ' + 'encrypted with a symmetric key.') + plain_json = self._crypto.decrypt_symmetric( + enc_json, self._crypto._symkey) + elif entry['encryption_scheme'] == EncryptionScheme.PUBKEY: + if not self._crypto.is_encrypted_asym(enc_json): + raise DocumentNotEncrypted( + 'Incoming document\'s contents should be ' + 'encrypted to the user\'s public key.') + plain_json = self._crypto.decrypt(enc_json) + else: raise DocumentNotEncrypted( "Incoming document from sync is not encrypted.") + # if decryption was OK, then create the document. doc = LeapDocument(entry['id'], entry['rev'], - encrypted_json=entry['content'], - crypto=self._crypto) + json=plain_json, + crypto=self._crypto, + encryption_scheme=EncryptionScheme.NONE) return_doc_cb(doc, entry['gen'], entry['trans_id']) if parts[-1] != ']': try: @@ -307,8 +366,9 @@ class LeapSyncTarget(HTTPSyncTarget): raise DocumentNotEncrypted( "Could not encrypt document before sync.") size += prepare(id=doc.doc_id, rev=doc.rev, - content=doc.get_encrypted_json(), - gen=gen, trans_id=trans_id) + content=enc_json, + gen=gen, trans_id=trans_id, + encryption_scheme=EncryptionSchemes.SYMKEY) entries.append('\r\n]') size += len(entries[-1]) self._conn.putheader('content-length', str(size)) diff --git a/src/leap/soledad/backends/sqlcipher.py b/src/leap/soledad/backends/sqlcipher.py index 288680d4..fb5c3e79 100644 --- a/src/leap/soledad/backends/sqlcipher.py +++ b/src/leap/soledad/backends/sqlcipher.py @@ -19,23 +19,22 @@ """A U1DB backend that uses SQLCipher as its persistence layer.""" import os -from pysqlcipher import dbapi2 import time -# TODO: uncomment imports below after solving circular dependency issue -# between leap_client and soledad. -#from leap import util -from u1db.backends import sqlite_backend -#util.logger.debug( -# "Monkey-patching u1db.backends.sqlite_backend with pysqlcipher.dbapi2..." -#) -sqlite_backend.dbapi2 = dbapi2 +from u1db.backends import sqlite_backend +from pysqlcipher import dbapi2 from u1db import ( errors, ) +from leap.soledad.backends.leap_backend import ( + LeapDocument, + EncryptionSchemes, +) + -from leap.soledad.backends.leap_backend import LeapDocument +# Monkey-patch u1db.backends.sqlite_backend with pysqlcipher.dbapi2 +sqlite_backend.dbapi2 = dbapi2 def open(path, password, create=True, document_factory=None, crypto=None): @@ -101,12 +100,14 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase): self._crypto = crypto def factory(doc_id=None, rev=None, json='{}', has_conflicts=False, - encrypted_json=None, syncable=True): + encrypted_json=None, syncable=True, + encryption_scheme=EncryptionSchemes.NONE): return LeapDocument(doc_id=doc_id, rev=rev, json=json, has_conflicts=has_conflicts, encrypted_json=encrypted_json, + crypto=self._crypto, syncable=syncable, - crypto=self._crypto) + encryption_scheme=encryption_scheme) self.set_document_factory(factory) def _check_if_db_is_encrypted(self, sqlcipher_file): @@ -247,6 +248,10 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase): c.execute( 'ALTER TABLE document ' 'ADD COLUMN syncable BOOL NOT NULL DEFAULT TRUE') + c.execute( + 'ALTER TABLE document ' + 'ADD COLUMN encryption_scheme TEXT NOT NULL DEFAULT \'%s\'' % + EncryptionSchemes.NONE) def _put_and_update_indexes(self, old_doc, doc): """ @@ -260,8 +265,9 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase): sqlite_backend.SQLitePartialExpandDatabase._put_and_update_indexes( self, old_doc, doc) c = self._db_handle.cursor() - c.execute('UPDATE document SET syncable=? WHERE doc_id=?', - (doc.syncable, doc.doc_id)) + c.execute('UPDATE document SET syncable=?, encryption_scheme=? ' + 'WHERE doc_id=?', + (doc.syncable, doc.encryption_scheme, doc.doc_id)) def _get_doc(self, doc_id, check_for_conflicts=False): """ @@ -281,9 +287,12 @@ class SQLCipherDatabase(sqlite_backend.SQLitePartialExpandDatabase): self, doc_id, check_for_conflicts) if doc: c = self._db_handle.cursor() - c.execute('SELECT syncable FROM document WHERE doc_id=?', + c.execute('SELECT syncable, encryption_scheme FROM document ' + 'WHERE doc_id=?', (doc.doc_id,)) - doc.syncable = bool(c.fetchone()[0]) + result = c.fetchone() + doc.syncable = bool(result[0]) + doc.encryption_scheme = result[1] return doc sqlite_backend.SQLiteDatabase.register_implementation(SQLCipherDatabase) diff --git a/src/leap/soledad/crypto.py b/src/leap/soledad/crypto.py new file mode 100644 index 00000000..471b35e2 --- /dev/null +++ b/src/leap/soledad/crypto.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# crypto.py +# Copyright (C) 2013 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 . + + +""" +Cryptographic utilities for Soledad. +""" + + +from binascii import b2a_base64 +from hashlib import sha256 + + +from leap.common.keymanager import KeyManager +from leap.soledad.util import GPGWrapper + + +class NoSymmetricSecret(Exception): + """ + Raised when trying to get a hashed passphrase. + """ + + +class SoledadCrypto(object): + """ + General cryptographic functionality. + """ + + def __init__(self, gnupg_home, symkey=None): + """ + Initialize the crypto object. + + @param gnupg_home: Home of the gpg instance. + @type gnupg_home: str + @param symkey: A key to use for symmetric encryption. + @type symkey: str + """ + self._gpg = GPGWrapper(gnupghome=gnupg_home) + self._symkey = symkey + + def encrypt(self, data, recipients=None, sign=None, passphrase=None, + symmetric=False): + """ + Encrypt data. + + @param data: the data to be encrypted + @type data: str + @param recipients: to whom C{data} should be encrypted + @type recipients: list or str + @param sign: the fingerprint of key to be used for signature + @type sign: str + @param passphrase: the passphrase to be used for encryption + @type passphrase: str + @param symmetric: whether the encryption scheme should be symmetric + @type symmetric: bool + + @return: the encrypted data + @rtype: str + """ + return str(self._gpg.encrypt(data, recipients, sign=sign, + passphrase=passphrase, + symmetric=symmetric)) + + def encrypt_symmetric(self, data, passphrase, sign=None): + """ + Encrypt C{data} using a {password}. + + @param data: the data to be encrypted + @type data: str + @param passphrase: the passphrase to use for encryption + @type passphrase: str + @param data: the data to be encrypted + @param sign: the fingerprint of key to be used for signature + @type sign: str + + @return: the encrypted data + @rtype: str + """ + return self.encrypt(data, sign=sign, + passphrase=passphrase, + symmetric=True) + + def decrypt(self, data, passphrase=None): + """ + Decrypt data. + + @param data: the data to be decrypted + @type data: str + @param passphrase: the passphrase to be used for decryption + @type passphrase: str + + @return: the decrypted data + @rtype: str + """ + return str(self._gpg.decrypt(data, passphrase=passphrase)) + + def decrypt_symmetric(self, data, passphrase): + """ + Decrypt data using symmetric secret. + + @param data: the data to be decrypted + @type data: str + @param passphrase: the passphrase to use for decryption + @type passphrase: str + + @return: the decrypted data + @rtype: str + """ + return self.decrypt(data, passphrase=passphrase) + + def is_encrypted(self, data): + """ + Test whether some chunk of data is a cyphertext. + + @param data: the data to be tested + @type data: str + + @return: whether the data is a cyphertext + @rtype: bool + """ + return self._gpg.is_encrypted(data) + + def is_encrypted_sym(self, data): + """ + Test whether some chunk of data was encrypted with a symmetric key. + + @return: whether data is encrypted to a symmetric key + @rtype: bool + """ + return self._gpg.is_encrypted_sym(data) + + def is_encrypted_asym(self, data): + """ + Test whether some chunk of data was encrypted to an OpenPGP private + key. + + @return: whether data is encrypted to an OpenPGP private key + @rtype: bool + """ + return self._gpg.is_encrypted_asym(data) + + def _hash_passphrase(self, suffix): + """ + Generate a passphrase for symmetric encryption. + + The password is derived from C{suffix} and the secret for + symmetric encryption previously loaded. + + @param suffix: Will be appended to the symmetric key before hashing. + @type suffix: str + + @return: the passphrase + @rtype: str + @raise NoSymmetricSecret: if no symmetric secret was supplied. + """ + if self._symkey is None: + raise NoSymmetricSecret() + return b2a_base64( + sha256('%s%s' % (self._symkey, suffix)).digest())[:-1] + + # + # symkey setters/getters + # + + def _get_symkey(self): + return self._symkey + + def _set_symkey(self, symkey): + self._symkey = symkey + + symkey = property(_get_symkey, _set_symkey, + doc='The key used for symmetric encryption') diff --git a/src/leap/soledad/tests/__init__.py b/src/leap/soledad/tests/__init__.py index 396b2775..28114391 100644 --- a/src/leap/soledad/tests/__init__.py +++ b/src/leap/soledad/tests/__init__.py @@ -3,6 +3,8 @@ Tests to make sure Soledad provides U1DB functionality and more. """ import u1db + + from leap.soledad import Soledad from leap.soledad.crypto import SoledadCrypto from leap.soledad.backends.leap_backend import LeapDocument diff --git a/src/leap/soledad/tests/test_couch.py b/src/leap/soledad/tests/test_couch.py index 008c3ca4..cdf9c9ff 100644 --- a/src/leap/soledad/tests/test_couch.py +++ b/src/leap/soledad/tests/test_couch.py @@ -15,6 +15,10 @@ try: import simplejson as json except ImportError: import json # noqa +from leap.soledad.backends.leap_backend import ( + LeapDocument, + EncryptionSchemes, +) #----------------------------------------------------------------------------- @@ -164,10 +168,14 @@ def copy_couch_database_for_test(test, db): return new_db +def make_document_for_test(test, doc_id, rev, content, has_conflicts=False): + return LeapDocument(doc_id, rev, content, has_conflicts=has_conflicts) + + COUCH_SCENARIOS = [ ('couch', {'make_database_for_test': make_couch_database_for_test, 'copy_database_for_test': copy_couch_database_for_test, - 'make_document_for_test': tests.make_document_for_test, }), + 'make_document_for_test': make_document_for_test, }), ] @@ -404,5 +412,30 @@ class CouchDatabaseStorageTests(CouchDBTestCase): content = self._fetch_u1db_data(db) self.assertEqual(db._replica_uid, content['replica_uid']) + def test_store_encryption_scheme(self): + db = couch.CouchDatabase('http://localhost:' + str(self.wrapper.port), + 'u1db_tests') + doc = db.create_doc_from_json(tests.simple_doc) + # assert that docs have no encryption_scheme by default + self.assertEqual(EncryptionSchemes.NONE, doc.encryption_scheme) + # assert that we can store a different value + doc.encryption_scheme = EncryptionSchemes.PUBKEY + db.put_doc(doc) + self.assertEqual( + EncryptionSchemes.PUBKEY, + db.get_doc(doc.doc_id).encryption_scheme) + # assert that we can store another different value + doc.encryption_scheme = EncryptionSchemes.SYMKEY + db.put_doc(doc) + self.assertEqual( + EncryptionSchemes.SYMKEY, + db.get_doc(doc.doc_id).encryption_scheme) + # assert that we can store the default value + doc.encryption_scheme = EncryptionSchemes.NONE + db.put_doc(doc) + self.assertEqual( + EncryptionSchemes.NONE, + db.get_doc(doc.doc_id).encryption_scheme) + load_tests = tests.load_with_scenarios diff --git a/src/leap/soledad/tests/test_leap_backend.py b/src/leap/soledad/tests/test_leap_backend.py index fd9ef85d..0bf70f9e 100644 --- a/src/leap/soledad/tests/test_leap_backend.py +++ b/src/leap/soledad/tests/test_leap_backend.py @@ -5,6 +5,8 @@ For these tests to run, a leap server has to be running on (default) port """ import u1db + + from leap.soledad.backends import leap_backend from leap.soledad.tests import u1db_tests as tests from leap.soledad.tests.u1db_tests.test_remote_sync_target import ( diff --git a/src/leap/soledad/tests/test_sqlcipher.py b/src/leap/soledad/tests/test_sqlcipher.py index b71478ac..337ab4ee 100644 --- a/src/leap/soledad/tests/test_sqlcipher.py +++ b/src/leap/soledad/tests/test_sqlcipher.py @@ -21,7 +21,10 @@ from leap.soledad.backends.sqlcipher import ( DatabaseIsNotEncrypted, ) from leap.soledad.backends.sqlcipher import open as u1db_open -from leap.soledad.backends.leap_backend import LeapDocument +from leap.soledad.backends.leap_backend import ( + LeapDocument, + EncryptionSchemes, +) # u1db tests stuff. from leap.soledad.tests import u1db_tests as tests @@ -342,6 +345,29 @@ class TestSQLCipherPartialExpandDatabase( self.db.put_doc(doc) self.assertEqual(True, self.db.get_doc(doc.doc_id).syncable) + def test_store_encryption_scheme(self): + doc = self.db.create_doc_from_json(tests.simple_doc) + # assert that docs have no encryption_scheme by default + self.assertEqual(EncryptionSchemes.NONE, doc.encryption_scheme) + # assert that we can store a different value + doc.encryption_scheme = EncryptionSchemes.PUBKEY + self.db.put_doc(doc) + self.assertEqual( + EncryptionSchemes.PUBKEY, + self.db.get_doc(doc.doc_id).encryption_scheme) + # assert that we can store another different value + doc.encryption_scheme = EncryptionSchemes.SYMKEY + self.db.put_doc(doc) + self.assertEqual( + EncryptionSchemes.SYMKEY, + self.db.get_doc(doc.doc_id).encryption_scheme) + # assert that we can store the default value + doc.encryption_scheme = EncryptionSchemes.NONE + self.db.put_doc(doc) + self.assertEqual( + EncryptionSchemes.NONE, + self.db.get_doc(doc.doc_id).encryption_scheme) + #----------------------------------------------------------------------------- # The following tests come from `u1db.tests.test_open`. @@ -413,7 +439,7 @@ def sync_via_synchronizer_and_leap(test, db_source, db_target, sync_scenarios.append(('pyleap', { 'make_database_for_test': test_sync.make_database_for_http_test, 'copy_database_for_test': test_sync.copy_database_for_http_test, - 'make_document_for_test': tests.make_document_for_test, + 'make_document_for_test': make_document_for_test, 'make_app_with_state': tests.test_remote_sync_target.make_http_app, 'do_sync': sync_via_synchronizer_and_leap, })) -- cgit v1.2.3