diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index b403363d6d25b7e8c6d4c84c871c3c8b11865ecf..7701ffa1e4cfcab8542b276eda1f5c8e4f8b3e1a 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -48,3 +48,12 @@ Then also configure the Ristretto-flavored PrivacyPass issuer the server will an [storageserver.plugins.privatestorageio-zkapauthz-v1] ristretto-issuer-root-url = https://issuer.example.invalid/ + +The storage server must also be configured with the path to the Ristretto-flavored PrivacyPass signing key. +To avoid placing secret material in tahoe.cfg, +this configuration is done using a path:: + + [storageserver.plugins.privatestorageio-zkapauthz-v1] + ristretto-signing-key-path = /path/to/signing.key + +The signing key is the keystone secret to the entire system and must be managed with extreme care to prevent unintended disclosure. diff --git a/setup.py b/setup.py index 606849326a4002007fd42060b51e69a19c18675c..f61c0015abc4af74c07c67a1f6127d032867aa66 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,7 @@ from setuptools import setup -setup() +setup( + package_data={ + "": ["testing-signing.key"], + }, +) diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py index e5c59e23456605c95c36ed24839aa1a439957520..355642dbf1ae232099ae802fba3520b573b555a1 100644 --- a/src/_zkapauthorizer/_plugin.py +++ b/src/_zkapauthorizer/_plugin.py @@ -27,6 +27,9 @@ from zope.interface import ( implementer, ) +from twisted.python.filepath import ( + FilePath, +) from twisted.internet.defer import ( succeed, ) @@ -35,6 +38,9 @@ from allmydata.interfaces import ( IFoolscapStoragePlugin, IAnnounceableStorageServer, ) +from privacypass import ( + SigningKey, +) from .api import ( ZKAPAuthorizerStorageServer, @@ -104,11 +110,17 @@ class ZKAPAuthorizer(object): def get_storage_server(self, configuration, get_anonymous_storage_server): kwargs = configuration.copy() root_url = kwargs.pop(u"ristretto-issuer-root-url") + signing_key = SigningKey.decode_base64( + FilePath( + kwargs.pop(u"ristretto-signing-key-path"), + ).getContent(), + ) announcement = { u"ristretto-issuer-root-url": root_url, } storage_server = ZKAPAuthorizerStorageServer( get_anonymous_storage_server(), + signing_key, **kwargs ) return succeed( diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py index 7b200ba3789e98e3b38b64811986485aea2e4896..364aeac907f97ab94663cebf65401a10c94262ce 100644 --- a/src/_zkapauthorizer/_storage_client.py +++ b/src/_zkapauthorizer/_storage_client.py @@ -30,6 +30,13 @@ from allmydata.interfaces import ( IStorageServer, ) +from .storage_common import ( + allocate_buckets_message, + add_lease_message, + renew_lease_message, + slot_testv_and_readv_and_writev_message, +) + @implementer(IStorageServer) @attr.s class ZKAPAuthorizerStorageClient(object): @@ -64,13 +71,16 @@ class ZKAPAuthorizerStorageClient(object): def _get_encoded_passes(self, message, count): """ + :param unicode message: The message to which to bind the passes. + :return: A list of passes from ``_get_passes`` encoded into their ``bytes`` representation. """ + assert isinstance(message, unicode) return list( t.text.encode("ascii") for t - in self._get_passes(message.encode("hex"), count) + in self._get_passes(message.encode("utf-8"), count) ) def get_version(self): @@ -89,7 +99,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "allocate_buckets", - self._get_encoded_passes(storage_index, 1), + self._get_encoded_passes(allocate_buckets_message(storage_index), 1), storage_index, renew_secret, cancel_secret, @@ -115,7 +125,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "add_lease", - self._get_encoded_passes(storage_index, 1), + self._get_encoded_passes(add_lease_message(storage_index), 1), storage_index, renew_secret, cancel_secret, @@ -128,7 +138,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "renew_lease", - self._get_encoded_passes(storage_index, 1), + self._get_encoded_passes(renew_lease_message(storage_index), 1), storage_index, renew_secret, ) @@ -157,7 +167,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "slot_testv_and_readv_and_writev", - self._get_encoded_passes(storage_index, 1), + self._get_encoded_passes(slot_testv_and_readv_and_writev_message(storage_index), 1), storage_index, secrets, tw_vectors, diff --git a/src/_zkapauthorizer/_storage_server.py b/src/_zkapauthorizer/_storage_server.py index 313821647146fae8d4647384c5866455fa73ca73..e40a00d1ea6731a55cea4916048543e4dd843d99 100644 --- a/src/_zkapauthorizer/_storage_server.py +++ b/src/_zkapauthorizer/_storage_server.py @@ -27,12 +27,12 @@ from __future__ import ( import attr from attr.validators import ( provides, + instance_of, ) from zope.interface import ( implementer_only, ) - from foolscap.api import ( Referenceable, ) @@ -43,10 +43,20 @@ from foolscap.ipb import ( from allmydata.interfaces import ( RIStorageServer, ) - +from privacypass import ( + TokenPreimage, + VerificationSignature, + SigningKey, +) from .foolscap import ( RITokenAuthorizedStorageServer, ) +from .storage_common import ( + allocate_buckets_message, + add_lease_message, + renew_lease_message, + slot_testv_and_readv_and_writev_message, +) @implementer_only(RITokenAuthorizedStorageServer, IReferenceable, IRemotelyCallable) # It would be great to use `frozen=True` (value-based hashing) instead of @@ -59,18 +69,46 @@ class ZKAPAuthorizerStorageServer(Referenceable): before allowing certain functionality. """ _original = attr.ib(validator=provides(RIStorageServer)) + _signing_key = attr.ib(validator=instance_of(SigningKey)) - def _validate_passes(self, passes): + def _is_invalid_pass(self, message, pass_): """ - Check that all of the given passes are valid. + Check the validity of a single pass. - :raise InvalidPass: If any pass in ``passses`` is not valid. + :param unicode message: The shared message for pass validation. + :param bytes pass_: The encoded pass to validate. - :return NoneType: If all of the passes in ``passes`` are valid. + :return bool: ``True`` if the pass is invalid, ``False`` otherwise. + """ + assert isinstance(message, unicode), "message %r not unicode" % (message,) + assert isinstance(pass_, bytes), "pass %r not bytes" % (pass_,) + try: + preimage_base64, signature_base64 = pass_.split(b" ") + preimage = TokenPreimage.decode_base64(preimage_base64) + proposed_signature = VerificationSignature.decode_base64(signature_base64) + unblinded_token = self._signing_key.rederive_unblinded_token(preimage) + verification_key = unblinded_token.derive_verification_key_sha512() + invalid_pass = verification_key.invalid_sha512(proposed_signature, message.encode("utf-8")) + return invalid_pass + except Exception: + # It would be pretty nice to log something here, sometimes, I guess? + return True - :note: This is yet to be implemented so it always returns ``None``. + def _validate_passes(self, message, passes): """ - return None + Check all of the given passes for validity. + + :param unicode message: The shared message for pass validation. + :param list[bytes] passes: The encoded passes to validate. + + :return list[bytes]: The passes which are found to be valid. + """ + return list( + pass_ + for pass_ + in passes + if not self._is_invalid_pass(message, pass_) + ) def remote_get_version(self): """ @@ -79,13 +117,13 @@ class ZKAPAuthorizerStorageServer(Referenceable): """ return self._original.remote_get_version() - def remote_allocate_buckets(self, passes, *a, **kw): + def remote_allocate_buckets(self, passes, storage_index, *a, **kw): """ Pass-through after a pass check to ensure that clients can only allocate storage for immutable shares if they present valid passes. """ - self._validate_passes(passes) - return self._original.remote_allocate_buckets(*a, **kw) + self._validate_passes(allocate_buckets_message(storage_index), passes) + return self._original.remote_allocate_buckets(storage_index, *a, **kw) def remote_get_buckets(self, storage_index): """ @@ -94,21 +132,21 @@ class ZKAPAuthorizerStorageServer(Referenceable): """ return self._original.remote_get_buckets(storage_index) - def remote_add_lease(self, passes, *a, **kw): + def remote_add_lease(self, passes, storage_index, *a, **kw): """ Pass-through after a pass check to ensure clients can only extend the duration of share storage if they present valid passes. """ - self._validate_passes(passes) - return self._original.remote_add_lease(*a, **kw) + self._validate_passes(add_lease_message(storage_index), passes) + return self._original.remote_add_lease(storage_index, *a, **kw) - def remote_renew_lease(self, passes, *a, **kw): + def remote_renew_lease(self, passes, storage_index, *a, **kw): """ Pass-through after a pass check to ensure clients can only extend the duration of share storage if they present valid passes. """ - self._validate_passes(passes) - return self._original.remote_renew_lease(*a, **kw) + self._validate_passes(renew_lease_message(storage_index), passes) + return self._original.remote_renew_lease(storage_index, *a, **kw) def remote_advise_corrupt_share(self, *a, **kw): """ @@ -133,7 +171,7 @@ class ZKAPAuthorizerStorageServer(Referenceable): data in already-allocated storage. These cases may not be the same from the perspective of pass validation. """ - self._validate_passes(passes) + self._validate_passes(slot_testv_and_readv_and_writev_message(storage_index), passes) # Skip over the remotely exposed method and jump to the underlying # implementation which accepts one additional parameter that we know # about (and don't expose over the network): renew_leases. We always diff --git a/src/_zkapauthorizer/storage_common.py b/src/_zkapauthorizer/storage_common.py new file mode 100644 index 0000000000000000000000000000000000000000..c22bbc2bee6574a6eca394ae6fa0aae552a67089 --- /dev/null +++ b/src/_zkapauthorizer/storage_common.py @@ -0,0 +1,17 @@ + +from base64 import ( + b64encode, +) + +def _message_maker(label): + def make_message(storage_index): + return u"{label} {storage_index}".format( + label=label, + storage_index=b64encode(storage_index), + ) + return make_message + +allocate_buckets_message = _message_maker(u"allocate_buckets") +add_lease_message = _message_maker(u"add_lease") +renew_lease_message = _message_maker(u"renew_lease") +slot_testv_and_readv_and_writev_message = _message_maker(u"slot_testv_and_readv_and_writev") diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 0af3d1f86dc76db4b2e2627eedde8972e85a2338..5a0c531a6448bda2c59d40deeb9aa9e3523be77e 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -164,12 +164,16 @@ def node_nicknames(): ) -def server_configurations(): +def server_configurations(signing_key_path): """ Build configuration values for the server-side plugin. + + :param unicode signing_key_path: A value to insert for the + **ristretto-signing-key-path** item. """ return just({ u"ristretto-issuer-root-url": u"https://issuer.example.invalid/", + u"ristretto-signing-key-path": signing_key_path.path, }) @@ -214,13 +218,10 @@ def zkaps(): """ Build random ZKAPs as ``Pass` instances. """ - return binary( - min_size=32, - max_size=32, - ).map( - urlsafe_b64encode, - ).map( - lambda zkap: Pass(zkap.decode("ascii")), + return builds( + lambda preimage, signature: Pass(u"{} {}".format(preimage, signature)), + preimage=binary(min_size=66, max_size=66).map(urlsafe_b64encode), + signature=binary(min_size=66, max_size=66).map(urlsafe_b64encode), ) diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index b8acfc58a6c463198f1210d859d72d344d03bc5c..0257683da1813e697407d9336ffdd4880ad62147 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -71,6 +71,9 @@ from allmydata.interfaces import ( RIStorageServer, ) +from twisted.python.filepath import ( + FilePath, +) from twisted.plugin import ( getPlugins, ) @@ -107,6 +110,9 @@ from .matchers import ( ) +SIGNING_KEY_PATH = FilePath(__file__).sibling(u"testing-signing.key") + + @implementer(RIStorageServer) class StubStorageServer(object): pass @@ -150,7 +156,7 @@ class ServerPluginTests(TestCase): Tests for the plugin's implementation of ``IFoolscapStoragePlugin.get_storage_server``. """ - @given(server_configurations()) + @given(server_configurations(SIGNING_KEY_PATH)) def test_returns_announceable(self, configuration): """ ``storage_server.get_storage_server`` returns an instance which provides @@ -166,7 +172,7 @@ class ServerPluginTests(TestCase): ) - @given(server_configurations()) + @given(server_configurations(SIGNING_KEY_PATH)) def test_returns_referenceable(self, configuration): """ The storage server attached to the result of @@ -187,7 +193,7 @@ class ServerPluginTests(TestCase): ), ) - @given(server_configurations()) + @given(server_configurations(SIGNING_KEY_PATH)) def test_returns_serializable(self, configuration): """ The storage server attached to the result of @@ -211,7 +217,7 @@ class ServerPluginTests(TestCase): ) - @given(server_configurations()) + @given(server_configurations(SIGNING_KEY_PATH)) def test_returns_hashable(self, configuration): """ The storage server attached to the result of diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py index 0b3743db1ec1d642e9ef83148fb06fbf0eee1742..a32fa407129eee1288fc6cacbcaee44bbb0f5245 100644 --- a/src/_zkapauthorizer/tests/test_storage_protocol.py +++ b/src/_zkapauthorizer/tests/test_storage_protocol.py @@ -64,6 +64,10 @@ from foolscap.referenceable import ( LocalReferenceable, ) +from privacypass import ( + random_signing_key, +) + from .strategies import ( storage_indexes, lease_renew_secrets, @@ -92,6 +96,9 @@ from ..foolscap import ( from ..model import ( Pass, ) +from ..storage_common import ( + slot_testv_and_readv_and_writev_message, +) @attr.s class LocalRemote(object): @@ -141,6 +148,7 @@ class ShareTests(TestCase): super(ShareTests, self).setUp() self.canary = LocalReferenceable(None) self.anonymous_storage_server = self.useFixture(AnonymousStorageServer()).storage_server + self.signing_key = random_signing_key() def get_passes(message, count): if not isinstance(message, bytes): @@ -154,6 +162,7 @@ class ShareTests(TestCase): self.server = ZKAPAuthorizerStorageServer( self.anonymous_storage_server, + self.signing_key, ) self.local_remote_server = LocalRemote(self.server) self.client = ZKAPAuthorizerStorageClient( @@ -502,7 +511,10 @@ class ShareTests(TestCase): d = self.client._rref.callRemote( "slot_testv_and_readv_and_writev", # passes - self.client._get_encoded_passes(storage_index, 1), + self.client._get_encoded_passes( + slot_testv_and_readv_and_writev_message(storage_index), + 1, + ), # storage_index storage_index, # secrets diff --git a/src/_zkapauthorizer/tests/test_storage_server.py b/src/_zkapauthorizer/tests/test_storage_server.py new file mode 100644 index 0000000000000000000000000000000000000000..7be247f34efa305ee22e4d7b5120b7679592828a --- /dev/null +++ b/src/_zkapauthorizer/tests/test_storage_server.py @@ -0,0 +1,123 @@ +from __future__ import ( + absolute_import, +) + +from random import ( + shuffle, +) +from testtools import ( + TestCase, +) +from testtools.matchers import ( + Equals, + AfterPreprocessing, +) +from hypothesis import ( + given, +) +from hypothesis.strategies import ( + integers, + lists, +) +from privacypass import ( + BatchDLEQProof, + PublicKey, + RandomToken, + random_signing_key, +) + +from .strategies import ( + zkaps, +) +from .fixtures import ( + AnonymousStorageServer, +) +from ..api import ( + ZKAPAuthorizerStorageServer, +) + + +def make_passes(signing_key, for_message, random_tokens): + blinded_tokens = list( + token.blind() + for token + in random_tokens + ) + signatures = list( + signing_key.sign(blinded_token) + for blinded_token + in blinded_tokens + ) + proof = BatchDLEQProof.create( + signing_key, + blinded_tokens, + signatures, + ) + unblinded_signatures = proof.invalid_or_unblind( + random_tokens, + blinded_tokens, + signatures, + PublicKey.from_signing_key(signing_key), + ) + preimages = list( + unblinded_signature.preimage() + for unblinded_signature + in unblinded_signatures + ) + verification_keys = list( + unblinded_signature.derive_verification_key_sha512() + for unblinded_signature + in unblinded_signatures + ) + message_signatures = list( + verification_key.sign_sha512(for_message.encode("utf-8")) + for verification_key + in verification_keys + ) + passes = list( + u"{} {}".format( + preimage.encode_base64().decode("ascii"), + signature.encode_base64().decode("ascii"), + ).encode("ascii") + for (preimage, signature) + in zip(preimages, message_signatures) + ) + return passes + + + +class PassValidationTests(TestCase): + """ + Tests for pass validation performed by ``ZKAPAuthorizerStorageServer``. + """ + def setUp(self): + super(PassValidationTests, self).setUp() + self.anonymous_storage_server = self.useFixture(AnonymousStorageServer()).storage_server + self.signing_key = random_signing_key() + self.storage_server = ZKAPAuthorizerStorageServer( + self.anonymous_storage_server, + self.signing_key, + ) + + @given(integers(min_value=0, max_value=64), lists(zkaps(), max_size=64)) + def test_validation_result(self, valid_count, invalid_passes): + """ + ``_get_valid_passes`` returns the number of cryptographically valid passes + in the list passed to it. + """ + message = u"hello world" + valid_passes = make_passes( + self.signing_key, + message, + list(RandomToken.create() for i in range(valid_count)), + ) + all_passes = valid_passes + list(pass_.text.encode("ascii") for pass_ in invalid_passes) + shuffle(all_passes) + + self.assertThat( + self.storage_server._validate_passes(message, all_passes), + AfterPreprocessing( + set, + Equals(set(valid_passes)), + ), + ) diff --git a/src/_zkapauthorizer/tests/testing-signing.key b/src/_zkapauthorizer/tests/testing-signing.key new file mode 100644 index 0000000000000000000000000000000000000000..078e2134bd719832938b965b0288e4246c364d2d --- /dev/null +++ b/src/_zkapauthorizer/tests/testing-signing.key @@ -0,0 +1 @@ +mkQf85V2vyLQRUYuqRb+Ke6K+M9pOtXm4MslsuCdBgg= \ No newline at end of file