From 1244f691b084b12463f88e5e0ba068432c17f621 Mon Sep 17 00:00:00 2001 From: drebs Date: Mon, 14 Oct 2013 10:54:24 -0300 Subject: Add shared db locking. * Improve bootstrap sequence: - stages are more organized. - there are less useless requests to server. * Improve shared db access: - instantiate the shared db only once. - also results in less requests to server. * Handle unicode passphrases. * Move some common functions and global variables to common. * Improve security of recovery document: - access to the recovery document now depends on the user password. * Improve documentation. --- common/src/leap/soledad/common/__init__.py | 16 +- common/src/leap/soledad/common/couch.py | 19 +-- common/src/leap/soledad/common/errors.py | 70 ++++++++ common/src/leap/soledad/common/tests/__init__.py | 10 +- .../src/leap/soledad/common/tests/test_server.py | 189 ++++++++++++++++++++- .../src/leap/soledad/common/tests/test_soledad.py | 94 ++++++---- 6 files changed, 345 insertions(+), 53 deletions(-) create mode 100644 common/src/leap/soledad/common/errors.py (limited to 'common/src/leap/soledad') diff --git a/common/src/leap/soledad/common/__init__.py b/common/src/leap/soledad/common/__init__.py index 26467740..23d28e76 100644 --- a/common/src/leap/soledad/common/__init__.py +++ b/common/src/leap/soledad/common/__init__.py @@ -21,8 +21,21 @@ Soledad routines common to client and server. """ +from hashlib import sha256 + + # -# Assert functions +# Global constants +# + + +SHARED_DB_NAME = 'shared' +SHARED_DB_LOCK_DOC_ID_PREFIX = 'lock-' +USER_DB_PREFIX = 'user-' + + +# +# Global functions # # we want to use leap.common.check.leap_assert in case it is available, @@ -63,6 +76,7 @@ except ImportError: "Expected type %r instead of %r" % (expectedType, type(var))) + from ._version import get_versions __version__ = get_versions()['version'] del get_versions diff --git a/common/src/leap/soledad/common/couch.py b/common/src/leap/soledad/common/couch.py index 187d3035..1396f4d7 100644 --- a/common/src/leap/soledad/common/couch.py +++ b/common/src/leap/soledad/common/couch.py @@ -33,6 +33,7 @@ from couchdb.client import Server, Document as CouchDocument from couchdb.http import ResourceNotFound, Unauthorized +from leap.soledad.common import USER_DB_PREFIX from leap.soledad.common.objectstore import ( ObjectStoreDatabase, ObjectStoreSyncTarget, @@ -61,7 +62,7 @@ def persistent_class(cls): dump_method_name, store): """ Create a persistent method to replace C{old_method_name}. - + The new method will load C{key} using C{load_method_name} and stores it using C{dump_method_name} depending on the value of C{store}. """ @@ -522,8 +523,7 @@ class CouchServerState(ServerState): Inteface of the WSGI server with the CouchDB backend. """ - def __init__(self, couch_url, shared_db_name, tokens_db_name, - user_db_prefix): + def __init__(self, couch_url, shared_db_name, tokens_db_name): """ Initialize the couch server state. @@ -533,13 +533,10 @@ class CouchServerState(ServerState): @type shared_db_name: str @param tokens_db_name: The name of the tokens database. @type tokens_db_name: str - @param user_db_prefix: The prefix for user database names. - @type user_db_prefix: str """ self._couch_url = couch_url self._shared_db_name = shared_db_name self._tokens_db_name = tokens_db_name - self._user_db_prefix = user_db_prefix try: self._check_couch_permissions() except NotEnoughCouchPermissions: @@ -553,8 +550,8 @@ class CouchServerState(ServerState): def _check_couch_permissions(self): """ - Assert that Soledad Server has enough permissions on the underlying couch - database. + Assert that Soledad Server has enough permissions on the underlying + couch database. Soledad Server has to be able to do the following in the couch server: @@ -563,8 +560,8 @@ class CouchServerState(ServerState): * Read from 'tokens' db. This function tries to perform the actions above using the "low level" - couch library to ensure that Soledad Server can do everything it needs on - the underlying couch database. + couch library to ensure that Soledad Server can do everything it needs + on the underlying couch database. @param couch_url: The URL of the couch database. @type couch_url: str @@ -593,7 +590,7 @@ class CouchServerState(ServerState): _open_couch_db(self._shared_db_name)) # test read/write auth for user- db _create_delete_test_doc( - _open_couch_db('%stest-db' % self._user_db_prefix)) + _open_couch_db('%stest-db' % USER_DB_PREFIX)) # test read auth for tokens db tokensdb = _open_couch_db(self._tokens_db_name) tokensdb.info() diff --git a/common/src/leap/soledad/common/errors.py b/common/src/leap/soledad/common/errors.py new file mode 100644 index 00000000..45433627 --- /dev/null +++ b/common/src/leap/soledad/common/errors.py @@ -0,0 +1,70 @@ +# -*- coding: utf-8 -*- +# errors.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 . + + +""" +Soledad errors. +""" + + +from u1db import errors +from u1db.remote import http_errors + + +# +# LockResource: a lock based on a document in the shared database. +# + +class InvalidTokenError(errors.U1DBError): + """ + Exception raised when trying to unlock shared database with invalid token. + """ + + wire_description = "unlock unauthorized" + status = 401 + + +class NotLockedError(errors.U1DBError): + """ + Exception raised when trying to unlock shared database when it is not + locked. + """ + + wire_description = "lock not found" + status = 404 + + +class AlreadyLockedError(errors.U1DBError): + """ + Exception raised when trying to lock shared database but it is already + locked. + """ + + wire_description = "lock is locked" + status = 403 + +# update u1db "wire description to status" and "wire description to exception" +# maps. +for e in [InvalidTokenError, NotLockedError, AlreadyLockedError]: + http_errors.wire_description_to_status.update({ + (e.wire_description, e.status)}) + errors.wire_description_to_exc.update({ + (e.wire_description, e)}) + +# u1db error statuses also have to be updated +http_errors.ERROR_STATUSES = set( + http_errors.wire_description_to_status.values()) diff --git a/common/src/leap/soledad/common/tests/__init__.py b/common/src/leap/soledad/common/tests/__init__.py index 9f47d74a..88f98272 100644 --- a/common/src/leap/soledad/common/tests/__init__.py +++ b/common/src/leap/soledad/common/tests/__init__.py @@ -60,11 +60,12 @@ class BaseSoledadTest(BaseLeapTest): if os.path.isfile(f): os.unlink(f) - def _soledad_instance(self, user=ADDRESS, passphrase='123', + def _soledad_instance(self, user=ADDRESS, passphrase=u'123', prefix='', secrets_path=Soledad.STORAGE_SECRETS_FILE_NAME, local_db_path='soledad.u1db', server_url='', - cert_file=None, secret_id=None): + cert_file=None, secret_id=None, + shared_db_class=None): def _put_doc_side_effect(doc): self._doc_put = doc @@ -73,10 +74,15 @@ class BaseSoledadTest(BaseLeapTest): get_doc = Mock(return_value=None) put_doc = Mock(side_effect=_put_doc_side_effect) + lock = Mock(return_value=('atoken', 300)) + unlock = Mock(return_value=True) def __call__(self): return self + if shared_db_class is not None: + MockSharedDB = shared_db_class + Soledad._shared_db = MockSharedDB() return Soledad( user, diff --git a/common/src/leap/soledad/common/tests/test_server.py b/common/src/leap/soledad/common/tests/test_server.py index 1ea4d615..83df192b 100644 --- a/common/src/leap/soledad/common/tests/test_server.py +++ b/common/src/leap/soledad/common/tests/test_server.py @@ -24,6 +24,7 @@ import os import tempfile import simplejson as json import mock +import time from leap.common.testing.basetest import BaseLeapTest @@ -45,7 +46,7 @@ from leap.soledad.client import ( Soledad, target, ) -from leap.soledad.server import SoledadApp +from leap.soledad.server import SoledadApp, LockResource from leap.soledad.server.auth import URLToAuthorization @@ -86,9 +87,8 @@ class ServerAuthorizationTestCase(BaseLeapTest): /user-db/sync-from/{source} | GET, PUT, POST """ uuid = 'myuuid' - authmap = URLToAuthorization( - uuid, SoledadApp.SHARED_DB_NAME, SoledadApp.USER_DB_PREFIX) - dbname = authmap._uuid_dbname(uuid) + authmap = URLToAuthorization(uuid,) + dbname = authmap._user_db_name # test global auth self.assertTrue( authmap.is_authorized(self._make_environ('/', 'GET'))) @@ -202,8 +202,7 @@ class ServerAuthorizationTestCase(BaseLeapTest): Test if authorization fails for a wrong dbname. """ uuid = 'myuuid' - authmap = URLToAuthorization( - uuid, SoledadApp.SHARED_DB_NAME, SoledadApp.USER_DB_PREFIX) + authmap = URLToAuthorization(uuid) dbname = 'somedb' # test wrong-db database resource auth self.assertFalse( @@ -273,7 +272,7 @@ class EncryptedSyncTestCase( sync_target = token_leap_sync_target - def _soledad_instance(self, user='user-uuid', passphrase='123', + def _soledad_instance(self, user='user-uuid', passphrase=u'123', prefix='', secrets_path=Soledad.STORAGE_SECRETS_FILE_NAME, local_db_path='soledad.u1db', server_url='', @@ -293,6 +292,8 @@ class EncryptedSyncTestCase( get_doc = mock.Mock(return_value=None) put_doc = mock.Mock(side_effect=_put_doc_side_effect) + lock = mock.Mock(return_value=('atoken', 300)) + unlock = mock.Mock() def __call__(self): return self @@ -310,8 +311,8 @@ class EncryptedSyncTestCase( secret_id=secret_id) def make_app(self): - self.request_state = CouchServerState( - self._couch_url, 'shared', 'tokens', 'user-') + self.request_state = CouchServerState(self._couch_url, 'shared', + 'tokens') return self.make_app_with_state(self.request_state) def setUp(self): @@ -375,3 +376,173 @@ class EncryptedSyncTestCase( doc2 = doclist[0] # assert incoming doc is equal to the first sent doc self.assertEqual(doc1, doc2) + + def test_encrypted_sym_sync_with_unicode_passphrase(self): + """ + Test the complete syncing chain between two soledad dbs using a + Soledad server backed by a couch database, using an unicode + passphrase. + """ + self.startServer() + # instantiate soledad and create a document + sol1 = self._soledad_instance( + # token is verified in test_target.make_token_soledad_app + auth_token='auth-token', + passphrase=u'ãáàäéàëíìïóòöõúùüñç', + ) + _, doclist = sol1.get_all_docs() + self.assertEqual([], doclist) + doc1 = sol1.create_doc(json.loads(simple_doc)) + # sync with server + sol1._server_url = self.getURL() + sol1.sync() + # assert doc was sent to couch db + db = CouchDatabase( + self._couch_url, + # the name of the user database is "user-". + 'user-user-uuid', + ) + _, doclist = db.get_all_docs() + self.assertEqual(1, len(doclist)) + couchdoc = doclist[0] + # assert document structure in couch server + self.assertEqual(doc1.doc_id, couchdoc.doc_id) + self.assertEqual(doc1.rev, couchdoc.rev) + self.assertEqual(6, len(couchdoc.content)) + self.assertTrue(target.ENC_JSON_KEY in couchdoc.content) + self.assertTrue(target.ENC_SCHEME_KEY in couchdoc.content) + self.assertTrue(target.ENC_METHOD_KEY in couchdoc.content) + self.assertTrue(target.ENC_IV_KEY in couchdoc.content) + self.assertTrue(target.MAC_KEY in couchdoc.content) + self.assertTrue(target.MAC_METHOD_KEY in couchdoc.content) + # instantiate soledad with empty db, but with same secrets path + sol2 = self._soledad_instance( + prefix='x', + auth_token='auth-token', + passphrase=u'ãáàäéàëíìïóòöõúùüñç', + ) + _, doclist = sol2.get_all_docs() + self.assertEqual([], doclist) + sol2._secrets_path = sol1.secrets_path + sol2._load_secrets() + sol2._set_secret_id(sol1._secret_id) + # sync the new instance + sol2._server_url = self.getURL() + sol2.sync() + _, doclist = sol2.get_all_docs() + self.assertEqual(1, len(doclist)) + doc2 = doclist[0] + # assert incoming doc is equal to the first sent doc + self.assertEqual(doc1, doc2) + + +class LockResourceTestCase( + CouchDBTestCase, TestCaseWithServer): + """ + Tests for use of PUT and DELETE on lock resource. + """ + + @staticmethod + def make_app_with_state(state): + return make_token_soledad_app(state) + + make_document_for_test = make_leap_document_for_test + + sync_target = token_leap_sync_target + + def setUp(self): + TestCaseWithServer.setUp(self) + CouchDBTestCase.setUp(self) + self.tempdir = tempfile.mkdtemp(prefix="leap_tests-") + self._couch_url = 'http://localhost:' + str(self.wrapper.port) + self._state = CouchServerState( + self._couch_url, 'shared', 'tokens') + + def tearDown(self): + CouchDBTestCase.tearDown(self) + TestCaseWithServer.tearDown(self) + + def test__try_obtain_filesystem_lock(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + self.assertFalse(lr._lock.locked) + self.assertTrue(lr._try_obtain_filesystem_lock()) + self.assertTrue(lr._lock.locked) + lr._try_release_filesystem_lock() + + def test__try_release_filesystem_lock(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + lr._try_obtain_filesystem_lock() + self.assertTrue(lr._lock.locked) + lr._try_release_filesystem_lock() + self.assertFalse(lr._lock.locked) + + def test_put(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + # lock! + lr.put({}, None) + # assert lock document was correctly written + lock_doc = lr._shared_db.get_doc('lock-uuid') + self.assertIsNotNone(lock_doc) + self.assertTrue(LockResource.TIMESTAMP_KEY in lock_doc.content) + self.assertTrue(LockResource.LOCK_TOKEN_KEY in lock_doc.content) + timestamp = lock_doc.content[LockResource.TIMESTAMP_KEY] + token = lock_doc.content[LockResource.LOCK_TOKEN_KEY] + self.assertTrue(timestamp < time.time()) + self.assertTrue(time.time() < timestamp + LockResource.TIMEOUT) + # assert response to user + responder.send_response_json.assert_called_with( + 201, token=token, + timeout=LockResource.TIMEOUT) + + def test_delete(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + # lock! + lr.put({}, None) + lock_doc = lr._shared_db.get_doc('lock-uuid') + token = lock_doc.content[LockResource.LOCK_TOKEN_KEY] + # unlock! + lr.delete({'token': token}, None) + self.assertFalse(lr._lock.locked) + self.assertIsNone(lr._shared_db.get_doc('lock-uuid')) + responder.send_response_json.assert_called_with(200) + + def test_put_while_locked_fails(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + # lock! + lr.put({}, None) + # try to lock again! + lr.put({}, None) + self.assertEqual( + len(responder.send_response_json.call_args), 2) + self.assertEqual( + responder.send_response_json.call_args[0], (403,)) + self.assertEqual( + len(responder.send_response_json.call_args[1]), 2) + self.assertTrue( + 'remaining' in responder.send_response_json.call_args[1]) + self.assertTrue( + responder.send_response_json.call_args[1]['remaining'] > 0) + + def test_unlock_unexisting_lock_fails(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + # unlock! + lr.delete({'token': 'anything'}, None) + responder.send_response_json.assert_called_with( + 404, error='lock not found') + + def test_unlock_with_wrong_token_fails(self): + responder = mock.Mock() + lr = LockResource('uuid', self._state, responder) + # lock! + lr.put({}, None) + # unlock! + lr.delete({'token': 'wrongtoken'}, None) + self.assertIsNotNone(lr._shared_db.get_doc('lock-uuid')) + responder.send_response_json.assert_called_with( + 401, error='unlock unauthorized') diff --git a/common/src/leap/soledad/common/tests/test_soledad.py b/common/src/leap/soledad/common/tests/test_soledad.py index 0b753647..8970a437 100644 --- a/common/src/leap/soledad/common/tests/test_soledad.py +++ b/common/src/leap/soledad/common/tests/test_soledad.py @@ -90,7 +90,7 @@ class AuxMethodsTestCase(BaseSoledadTest): """ sol = self._soledad_instance( 'leap@leap.se', - passphrase='123', + passphrase=u'123', secrets_path='value_3', local_db_path='value_2', server_url='value_1', @@ -109,25 +109,25 @@ class AuxMethodsTestCase(BaseSoledadTest): """ sol = self._soledad_instance( 'leap@leap.se', - passphrase='123', + passphrase=u'123', prefix=self.rand_prefix, ) doc = sol.create_doc({'simple': 'doc'}) doc_id = doc.doc_id # change the passphrase - sol.change_passphrase('654321') + sol.change_passphrase(u'654321') self.assertRaises( DatabaseError, self._soledad_instance, 'leap@leap.se', - passphrase='123', + passphrase=u'123', prefix=self.rand_prefix) # use new passphrase and retrieve doc sol2 = self._soledad_instance( 'leap@leap.se', - passphrase='654321', + passphrase=u'654321', prefix=self.rand_prefix) doc2 = sol2.get_doc(doc_id) self.assertEqual(doc, doc2) @@ -139,11 +139,11 @@ class AuxMethodsTestCase(BaseSoledadTest): """ sol = self._soledad_instance( 'leap@leap.se', - passphrase='123') + passphrase=u'123') # check that soledad complains about new passphrase length self.assertRaises( PassphraseTooShort, - sol.change_passphrase, '54321') + sol.change_passphrase, u'54321') def test_get_passphrase(self): """ @@ -161,13 +161,14 @@ class SoledadSharedDBTestCase(BaseSoledadTest): def setUp(self): BaseSoledadTest.setUp(self) self._shared_db = SoledadSharedDatabase( - 'https://provider/', SoledadDocument, None) + 'https://provider/', ADDRESS, document_factory=SoledadDocument, + creds=None) def test__get_secrets_from_shared_db(self): """ Ensure the shared db is queried with the correct doc_id. """ - doc_id = self._soledad._uuid_hash() + doc_id = self._soledad._shared_db_doc_id() self._soledad._get_secrets_from_shared_db() self.assertTrue( self._soledad._shared_db().get_doc.assert_called_with( @@ -178,7 +179,7 @@ class SoledadSharedDBTestCase(BaseSoledadTest): """ Ensure recovery document is put into shared recover db. """ - doc_id = self._soledad._uuid_hash() + doc_id = self._soledad._shared_db_doc_id() self._soledad._put_secrets_in_shared_db() self.assertTrue( self._soledad._shared_db().get_doc.assert_called_with( @@ -201,9 +202,10 @@ class SoledadSignalingTestCase(BaseSoledadTest): EVENTS_SERVER_PORT = 8090 def setUp(self): - BaseSoledadTest.setUp(self) # mock signaling soledad.client.signal = Mock() + # run parent's setUp + BaseSoledadTest.setUp(self) def tearDown(self): pass @@ -213,22 +215,28 @@ class SoledadSignalingTestCase(BaseSoledadTest): mocked.mock_calls.pop() mocked.call_args = mocked.call_args_list[-1] - def test_stage2_bootstrap_signals(self): + def test_stage3_bootstrap_signals(self): """ Test that a fresh soledad emits all bootstrap signals. + + Signals are: + - downloading keys / done downloading keys. + - creating keys / done creating keys. + - downloading keys / done downloading keys. + - uploading keys / done uploading keys. """ soledad.client.signal.reset_mock() # get a fresh instance so it emits all bootstrap signals sol = self._soledad_instance( - secrets_path='alternative.json', - local_db_path='alternative.u1db') + secrets_path='alternative_stage3.json', + local_db_path='alternative_stage3.u1db') # reverse call order so we can verify in the order the signals were # expected soledad.client.signal.mock_calls.reverse() soledad.client.signal.call_args = \ soledad.client.signal.call_args_list[0] soledad.client.signal.call_args_list.reverse() - # assert signals + # downloading keys signals soledad.client.signal.assert_called_with( proto.SOLEDAD_DOWNLOADING_KEYS, ADDRESS, @@ -238,6 +246,7 @@ class SoledadSignalingTestCase(BaseSoledadTest): proto.SOLEDAD_DONE_DOWNLOADING_KEYS, ADDRESS, ) + # creating keys signals self._pop_mock_call(soledad.client.signal) soledad.client.signal.assert_called_with( proto.SOLEDAD_CREATING_KEYS, @@ -248,6 +257,7 @@ class SoledadSignalingTestCase(BaseSoledadTest): proto.SOLEDAD_DONE_CREATING_KEYS, ADDRESS, ) + # downloading once more (inside _put_keys_in_shared_db) self._pop_mock_call(soledad.client.signal) soledad.client.signal.assert_called_with( proto.SOLEDAD_DOWNLOADING_KEYS, @@ -258,6 +268,7 @@ class SoledadSignalingTestCase(BaseSoledadTest): proto.SOLEDAD_DONE_DOWNLOADING_KEYS, ADDRESS, ) + # uploading keys signals self._pop_mock_call(soledad.client.signal) soledad.client.signal.assert_called_with( proto.SOLEDAD_UPLOADING_KEYS, @@ -268,21 +279,45 @@ class SoledadSignalingTestCase(BaseSoledadTest): proto.SOLEDAD_DONE_UPLOADING_KEYS, ADDRESS, ) + # assert db was locked and unlocked + sol._shared_db.lock.assert_called_with() + sol._shared_db.unlock.assert_called_with('atoken') - def test_stage1_bootstrap_signals(self): + def test_stage2_bootstrap_signals(self): """ - Test that an existent soledad emits some of the bootstrap signals. + Test that if there are keys in server, soledad will download them and + emit corresponding signals. """ - soledad.client.signal.reset_mock() - # get an existent instance so it emits only some of bootstrap signals + # get existing instance so we have access to keys sol = self._soledad_instance() + # create a document with secrets + doc = SoledadDocument(doc_id=sol._shared_db_doc_id()) + doc.content = sol.export_recovery_document(include_uuid=False) + + class Stage2MockSharedDB(object): + + get_doc = Mock(return_value=doc) + put_doc = Mock() + lock = Mock(return_value=('atoken', 300)) + unlock = Mock() + + def __call__(self): + return self + + # reset mock + soledad.client.signal.reset_mock() + # get a fresh instance so it emits all bootstrap signals + sol = self._soledad_instance( + secrets_path='alternative_stage2.json', + local_db_path='alternative_stage2.u1db', + shared_db_class=Stage2MockSharedDB) # reverse call order so we can verify in the order the signals were # expected soledad.client.signal.mock_calls.reverse() soledad.client.signal.call_args = \ soledad.client.signal.call_args_list[0] soledad.client.signal.call_args_list.reverse() - # assert signals + # assert download keys signals soledad.client.signal.assert_called_with( proto.SOLEDAD_DOWNLOADING_KEYS, ADDRESS, @@ -292,16 +327,15 @@ class SoledadSignalingTestCase(BaseSoledadTest): proto.SOLEDAD_DONE_DOWNLOADING_KEYS, ADDRESS, ) - self._pop_mock_call(soledad.client.signal) - soledad.client.signal.assert_called_with( - proto.SOLEDAD_UPLOADING_KEYS, - ADDRESS, - ) - self._pop_mock_call(soledad.client.signal) - soledad.client.signal.assert_called_with( - proto.SOLEDAD_DONE_UPLOADING_KEYS, - ADDRESS, - ) + + def test_stage1_bootstrap_signals(self): + """ + Test that if soledad already has a local secret, it emits no signals. + """ + soledad.client.signal.reset_mock() + # get an existent instance so it emits only some of bootstrap signals + sol = self._soledad_instance() + self.assertEqual([], soledad.client.signal.mock_calls) def test_sync_signals(self): """ -- cgit v1.2.3