From f1cf6a4b262f202c141d0e7cdd90f0f8403f9f5d Mon Sep 17 00:00:00 2001 From: drebs Date: Tue, 30 Apr 2013 12:13:07 -0300 Subject: Fix shared db auth and add tests. --- src/leap/soledad/__init__.py | 88 ++++++++++++++++++++-------- src/leap/soledad/backends/couch.py | 17 +++--- src/leap/soledad/backends/leap_backend.py | 45 ++++++-------- src/leap/soledad/shared_db.py | 97 +++++++------------------------ src/leap/soledad/tests/test_soledad.py | 57 ++++++++++++++++++ 5 files changed, 170 insertions(+), 134 deletions(-) (limited to 'src') diff --git a/src/leap/soledad/__init__.py b/src/leap/soledad/__init__.py index 12de0bba..76b5656e 100644 --- a/src/leap/soledad/__init__.py +++ b/src/leap/soledad/__init__.py @@ -77,6 +77,12 @@ class NotADirectory(Exception): """ +class NoSharedDbUrl(Exception): + """ + Tried to get access to shared recovery database but there's no URL for it. + """ + + # # Soledad: local encrypted storage and remote encrypted sync. # @@ -155,7 +161,7 @@ class Soledad(object): # TODO: allow for fingerprint enforcing. self._address = address self._passphrase = passphrase - self._auth_token = auth_token + self._set_token(auth_token) self._init_config( config_path=config_path, secret_path=secret_path, @@ -194,22 +200,22 @@ class Soledad(object): # Stage 0 - Local environment setup self._init_dirs() self._crypto = SoledadCrypto(self) - if self._config.get_shared_db_url() and self._auth_token: - # TODO: eliminate need to create db here. - self._shared_db = SoledadSharedDatabase.open_database( - self._config.get_shared_db_url(), - True, - token=self._auth_token) - else: - self._shared_db = None # Stage 1 - Keys generation/loading if self._has_keys(): self._load_keys() else: + logger.info( + 'Trying to fetch cryptographic secrets from shared recovery ' + 'database...') doc = self._fetch_keys_from_shared_db() if not doc: + logger.info( + 'No cryptographic secrets found, creating new secrets...') self._init_keys() else: + logger.info( + 'Found cryptographic secrets in shared recovery ' + 'database.') self._set_symkey( self._crypto.decrypt_sym( doc.content[self.KEY_SYMKEY], @@ -219,6 +225,18 @@ class Soledad(object): # Stage 3 - Local database initialization self._init_db() + def _shared_db(self): + """ + Return an instance of the shared recovery database object. + """ + if self._config.get_shared_db_url(): + return SoledadSharedDatabase.open_database( + self._config.get_shared_db_url(), + False, # TODO: eliminate need to create db here. + creds=self._creds) + else: + raise NoSharedDbUrl() + def _init_config(self, config_path, secret_path, local_db_path, shared_db_url): """ @@ -382,6 +400,7 @@ class Soledad(object): """ Load the key for symmetric encryption from persistent storage. """ + logger.info('Loading cryptographic secrets from local storage...') self._load_symkey() def _gen_keys(self): @@ -410,10 +429,7 @@ class Soledad(object): """ events.signal( events.events_pb2.SOLEDAD_DOWNLOADING_KEYS, self._address) - # TODO: change below to raise appropriate exceptions - if not self._shared_db: - return None - doc = self._shared_db.get_doc_unauth(self._address_hash()) + doc = self._shared_db().get_doc_unauth(self._address_hash()) events.signal( events.events_pb2.SOLEDAD_DONE_DOWNLOADING_KEYS, self._address) return doc @@ -431,11 +447,9 @@ class Soledad(object): self._has_keys(), 'Tried to send keys to server but they don\'t exist in local ' 'storage.') - if not self._shared_db: - return doc = self._fetch_keys_from_shared_db() if doc: - remote_symkey = self.decrypt_sym( + remote_symkey = self._crypto.decrypt_sym( doc.content[self.SYMKEY_KEY], passphrase=self._address_hash()) leap_assert( @@ -445,12 +459,12 @@ class Soledad(object): events.signal( events.events_pb2.SOLEDAD_UPLOADING_KEYS, self._address) content = { - self.SYMKEY_KEY: self.encrypt_sym( + self.SYMKEY_KEY: self._crypto.encrypt_sym( self._symkey, self._passphrase), } doc = LeapDocument(doc_id=self._address_hash()) doc.content = content - self._shared_db.put_doc(doc) + self._shared_db().put_doc(doc) events.signal( events.events_pb2.SOLEDAD_DONE_UPLOADING_KEYS, self._address) @@ -694,7 +708,7 @@ class Soledad(object): """ return self._db.resolve_doc(doc, conflicted_doc_revs) - def sync(self, url, creds=None): + def sync(self, url): """ Synchronize the local encrypted replica with a remote replica. @@ -705,8 +719,7 @@ class Soledad(object): performed. @rtype: str """ - # TODO: create authentication scheme for sync with server. - local_gen = self._db.sync(url, creds=creds, autocreate=True) + local_gen = self._db.sync(url, creds=self._creds, autocreate=True) events.signal(events.events_pb2.SOLEDAD_DONE_DATA_SYNC, self._address) return local_gen @@ -720,8 +733,7 @@ class Soledad(object): @return: Whether remote replica and local replica differ. @rtype: bool """ - # TODO: create auth scheme for sync with server - target = LeapSyncTarget(url, creds=None, crypto=self._crypto) + target = LeapSyncTarget(url, creds=self._creds, crypto=self._crypto) info = target.get_sync_info(self._db._get_replica_uid()) # compare source generation with target's last known source generation if self._db._get_generation() != info[4]: @@ -730,6 +742,36 @@ class Soledad(object): return True return False + def _set_token(self, token): + """ + Set the authentication token for remote database access. + + Build the credentials dictionary with the following format: + + self._{ + 'token': { + 'address': 'user@provider', + 'token': '' + } + + @param token: The authentication token. + @type token: str + """ + self._creds = { + 'token': { + 'address': self._address, + 'token': token, + } + } + + def _get_token(self): + """ + Return current token from credentials dictionary. + """ + return self._creds['token']['token'] + + token = property(_get_token, _set_token, doc='The authentication Token.') + #------------------------------------------------------------------------- # Recovery document export and import #------------------------------------------------------------------------- diff --git a/src/leap/soledad/backends/couch.py b/src/leap/soledad/backends/couch.py index 95090510..4dcea3f8 100644 --- a/src/leap/soledad/backends/couch.py +++ b/src/leap/soledad/backends/couch.py @@ -387,7 +387,8 @@ class CouchDatabase(ObjectStoreDatabase): indexes = {} for name, idx in self._indexes.iteritems(): indexes[name] = {} - for attr in [INDEX_NAME_KEY, INDEX_DEFINITION_KEY, INDEX_VALUES_KEY]: + for attr in [self.INDEX_NAME_KEY, self.INDEX_DEFINITION_KEY, + self.INDEX_VALUES_KEY]: indexes[name][attr] = getattr(idx, '_' + attr) return json.dumps(indexes) @@ -404,8 +405,8 @@ class CouchDatabase(ObjectStoreDatabase): """ dict = {} for name, idx_dict in json.loads(indexes).iteritems(): - idx = InMemoryIndex(name, idx_dict[INDEX_DEFINITION_KEY]) - idx._values = idx_dict[INDEX_VALUES_KEY] + idx = InMemoryIndex(name, idx_dict[self.INDEX_DEFINITION_KEY]) + idx._values = idx_dict[self.INDEX_VALUES_KEY] dict[name] = idx return dict @@ -435,8 +436,9 @@ class CouchServerState(ServerState): @rtype: CouchDatabase """ # TODO: open couch - return CouchDatabase.open_database(self.couch_url + '/' + dbname, - create=False) + return CouchDatabase.open_database( + self.couch_url + '/' + dbname, + create=False) def ensure_database(self, dbname): """ @@ -448,8 +450,9 @@ class CouchServerState(ServerState): @return: The CouchDatabase object and the replica uid. @rtype: (CouchDatabase, str) """ - db = CouchDatabase.open_database(self.couch_url + '/' + dbname, - create=True) + db = CouchDatabase.open_database( + self.couch_url + '/' + dbname, + create=True) return db, db._replica_uid def delete_database(self, dbname): diff --git a/src/leap/soledad/backends/leap_backend.py b/src/leap/soledad/backends/leap_backend.py index 9750ffad..81f6c211 100644 --- a/src/leap/soledad/backends/leap_backend.py +++ b/src/leap/soledad/backends/leap_backend.py @@ -30,33 +30,22 @@ except ImportError: from u1db import Document from u1db.remote import utils -from u1db.remote.http_target import HTTPSyncTarget -from u1db.remote.http_database import HTTPDatabase from u1db.errors import BrokenSyncStream +from u1db.remote.http_target import HTTPSyncTarget from leap.common.keymanager import KeyManager from leap.common.check import leap_assert +from leap.soledad.auth import ( + set_token_credentials, + _sign_request, +) # # Exceptions # -class NoDefaultKey(Exception): - """ - Exception to signal that there's no default OpenPGP key configured. - """ - pass - - -class NoSoledadCryptoInstance(Exception): - """ - Exception to signal that no Soledad instance was found. - """ - pass - - class DocumentNotEncrypted(Exception): """ Raised for failures in document encryption. @@ -267,6 +256,18 @@ class LeapSyncTarget(HTTPSyncTarget): receiving. """ + # + # Token auth methods. + # + + set_token_credentials = set_token_credentials + + _sign_request = _sign_request + + # + # Modified HTTPSyncTarget methods. + # + @staticmethod def connect(url, crypto=None): return LeapSyncTarget(url, crypto=crypto) @@ -403,15 +404,3 @@ class LeapSyncTarget(HTTPSyncTarget): res = self._parse_sync_stream(data, return_doc_cb, ensure_callback) data = None return res['new_generation'], res['new_transaction_id'] - - def set_token_credentials(self, address, token): - self._creds = {'token': (address, token)} - - def _sign_request(self, method, url_query, params): - if 'token' in self._creds: - address, token = self._creds['token'] - auth = '%s:%s' % (address, token) - return [('Authorization', 'Token %s' % auth.encode('base64'))] - else: - return HTTPSyncTarget._sign_request( - self, method, url_query, params) diff --git a/src/leap/soledad/shared_db.py b/src/leap/soledad/shared_db.py index 01296885..dbb78d97 100644 --- a/src/leap/soledad/shared_db.py +++ b/src/leap/soledad/shared_db.py @@ -30,6 +30,12 @@ from u1db import errors from u1db.remote import http_database +from leap.soledad.auth import ( + set_token_credentials, + _sign_request, +) + + #----------------------------------------------------------------------------- # Soledad shared database #----------------------------------------------------------------------------- @@ -58,8 +64,20 @@ class SoledadSharedDatabase(http_database.HTTPDatabase): # TODO: prevent client from messing with the shared DB. # TODO: define and document API. + # + # Token auth methods. + # + + set_token_credentials = set_token_credentials + + _sign_request = _sign_request + + # + # Modified HTTPDatabase methods. + # + @staticmethod - def open_database(url, create, token=None): + def open_database(url, create, creds=None): # TODO: users should not be able to create the shared database, so we # have to remove this from here in the future. """ @@ -76,7 +94,7 @@ class SoledadSharedDatabase(http_database.HTTPDatabase): @return: The shared database in the given url. @rtype: SoledadSharedDatabase """ - db = SoledadSharedDatabase(url, token=token) + db = SoledadSharedDatabase(url, creds=creds) db.open(create) return db @@ -92,7 +110,7 @@ class SoledadSharedDatabase(http_database.HTTPDatabase): """ raise Unauthorized("Can't delete shared database.") - def __init__(self, url, document_factory=None, creds=None, token=None): + def __init__(self, url, document_factory=None, creds=None): """ Initialize database with auth token and encryption powers. @@ -103,83 +121,10 @@ class SoledadSharedDatabase(http_database.HTTPDatabase): @param creds: A tuple containing the authentication method and credentials. @type creds: tuple - @param token: An authentication token for accessing the shared db. - @type token: str """ - self._token = token http_database.HTTPDatabase.__init__(self, url, document_factory, creds) - def _request(self, method, url_parts, params=None, body=None, - content_type=None, auth=True): - """ - Perform token-based http request. - - @param method: The HTTP method for the request. - @type method: str - @param url_parts: A list with extra parts for the URL. - @type url_parts: list - @param params: Parameters to be added as query string. - @type params: dict - @param body: The body of the request (must be JSON serializable). - @type body: object - @param content_type: The content-type of the request. - @type content_type: str - @param auth: Should the request be authenticated? - @type auth: bool - - @raise u1db.errors.Unavailable: If response status is 503. - @raise u1db.errors.HTTPError: If response status is neither 200, 201 - or 503 - - @return: The headers and body of the HTTP response. - @rtype: tuple - """ - # add `auth-token` as a request parameter - if auth: - if not self._token: - raise NoTokenForAuth() - if not params: - params = {} - params['auth_token'] = self._token - return http_database.HTTPDatabase._request( - self, - method, url_parts, - params, - body, - content_type) - - def _request_json(self, method, url_parts, params=None, body=None, - content_type=None, auth=True): - """ - Perform token-based http request and deserialize the JSON results. - - @param method: The HTTP method for the request. - @type method: str - @param url_parts: A list with extra parts for the URL. - @type url_parts: list - @param params: Parameters to be added as query string. - @type params: dict - @param body: The body of the request (must be JSON serializable). - @type body: object - @param content_type: The content-type of the request. - @type content_type: str - @param auth: Should the request be authenticated? - @type auth: bool - - @raise u1db.errors.Unavailable: If response status is 503. - @raise u1db.errors.HTTPError: If response status is neither 200, 201 - or 503 - - @return: The headers and body of the HTTP response. - @rtype: tuple - """ - # allow for token-authenticated requests. - res, headers = self._request(method, url_parts, - params=params, body=body, - content_type=content_type, auth=auth) - return json.loads(res), headers - def get_doc_unauth(self, doc_id): """ Modified method to allow for unauth request. diff --git a/src/leap/soledad/tests/test_soledad.py b/src/leap/soledad/tests/test_soledad.py index caf9be44..1ddfb6a0 100644 --- a/src/leap/soledad/tests/test_soledad.py +++ b/src/leap/soledad/tests/test_soledad.py @@ -30,9 +30,13 @@ except ImportError: import json # noqa +from mock import Mock +from leap.common.testing.basetest import BaseLeapTest from leap.soledad.tests import BaseSoledadTest from leap.soledad import Soledad from leap.soledad.crypto import SoledadCrypto +from leap.soledad.shared_db import SoledadSharedDatabase +from leap.soledad.backends.leap_backend import LeapDocument class AuxMethodsTestCase(BaseSoledadTest): @@ -132,3 +136,56 @@ class AuxMethodsTestCase(BaseSoledadTest): self.assertEqual('value_3', sol._config.get_secret_path()) self.assertEqual('value_2', sol._config.get_local_db_path()) self.assertEqual('value_1', sol._config.get_shared_db_url()) + + +class SoledadSharedDBTestCase(BaseSoledadTest): + """ + These tests ensure the functionalities of the shared recovery database. + """ + + def setUp(self): + BaseSoledadTest.setUp(self) + self._shared_db = SoledadSharedDatabase( + 'https://provider/', LeapDocument, None) + + def test__fetch_keys_from_shared_db(self): + """ + Ensure the shared db is queried with the correct doc_id. + """ + self._soledad._shared_db = Mock() + doc_id = self._soledad._address_hash() + self._soledad._fetch_keys_from_shared_db() + self.assertTrue( + self._soledad._shared_db.get_doc_unauth.assert_called_once(doc_id), + 'Wrong doc_id when fetching recovery document.') + + def test__assert_keys_in_shared_db(self): + """ + Ensure recovery document is put into shared recover db. + """ + + def _put_doc_side_effect(doc): + self._doc_put = doc + + class MockSharedDB(object): + + get_doc_unauth = Mock(return_value=None) + put_doc = Mock(side_effect=_put_doc_side_effect) + + def __call__(self): + return self + + self._soledad._shared_db = MockSharedDB() + doc_id = self._soledad._address_hash() + self._soledad._assert_keys_in_shared_db() + self.assertTrue( + self._soledad._shared_db().get_doc_unauth.assert_called_once_with( + doc_id) is None, + 'Wrong doc_id when fetching recovery document.') + self.assertTrue( + self._soledad._shared_db.put_doc.assert_called_once_with( + self._doc_put) is None, + 'Wrong document when putting recovery document.') + self.assertTrue( + self._doc_put.doc_id == doc_id, + 'Wrong doc_id when putting recovery document.') -- cgit v1.2.3