diff options
| -rw-r--r-- | setup.py | 1 | ||||
| -rw-r--r-- | src/leap/soledad/__init__.py | 88 | ||||
| -rw-r--r-- | src/leap/soledad/backends/couch.py | 17 | ||||
| -rw-r--r-- | src/leap/soledad/backends/leap_backend.py | 45 | ||||
| -rw-r--r-- | src/leap/soledad/shared_db.py | 97 | ||||
| -rw-r--r-- | src/leap/soledad/tests/test_soledad.py | 57 | 
6 files changed, 171 insertions, 134 deletions
| @@ -53,6 +53,7 @@ dependency_links = [  tests_requirements = [ +    'mock',      'nose2',      'testscenarios',  ] 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': '<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.') | 
