diff --git a/default.nix b/default.nix index 04af35d91986bd58cdc708b9bddc31c6aa1d50d4..27401736371276c58a0df8b8112c496998aaa4b0 100644 --- a/default.nix +++ b/default.nix @@ -1,2 +1,2 @@ { pkgs ? import ./nixpkgs.nix { }, hypothesisProfile ? null, collectCoverage ? false }: -pkgs.python27Packages.callPackage ./zkapauthorizer.nix { inherit hypothesisProfile collectCoverage; } +pkgs.python27Packages.zkapauthorizer.override { inherit hypothesisProfile collectCoverage; } diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst new file mode 100644 index 0000000000000000000000000000000000000000..b403363d6d25b7e8c6d4c84c871c3c8b11865ecf --- /dev/null +++ b/docs/source/configuration.rst @@ -0,0 +1,50 @@ +Configuration +============= + +Client +------ + +To enable the plugin at all, add its name to the list of storage plugins in the Tahoe-LAFS configuration +(``tahoe.cfg`` in the relevant node directory):: + + [client] + storage.plugins = privatestorageio-zkapauthz-v1 + +Then configure the plugin as desired in the ``storageclient.plugins.privatestorageio-zkapauthz-v1`` section. + +redeemer +~~~~~~~~ + +This item configures the voucher redeemer the client will use to redeem vouchers submitted to it. +The ``dummy`` value is useful for testing purposes only. + +For example:: + + [storageclient.plugins.privatestorageio-zkapauthz-v1] + redeemer = dummy + +A value of ``ristretto`` causes the client to speak Ristretto-flavored PrivacyPass to an issuer server. +In this case, the ``ristretto-issuer-root-url`` item is also required. + +For example:: + + [storageclient.plugins.privatestorageio-zkapauthz-v1] + redeemer = ristretto + ristretto-issuer-root-url = https://issuer.example.invalid/ + +Note that ``ristretto-issuer-root-url`` must agree with whichever storage servers the client will be configured to interact with. +If the values are not the same, the client will decline to use the storage servers. + +Server +------ + +To enable the plugin at all, add its name to the list of storage plugins in the Tahoe-LAFS configuration +(``tahoe.cfg`` in the relevant node directory):: + + [storage] + plugins = privatestorageio-zkapauthz-v1 + +Then also configure the Ristretto-flavored PrivacyPass issuer the server will announce to clients:: + + [storageserver.plugins.privatestorageio-zkapauthz-v1] + ristretto-issuer-root-url = https://issuer.example.invalid/ diff --git a/overlays.nix b/overlays.nix index 9a50dadb40152a238832c1402b3b9944ee45c4a1..6475c6bdd1524e2e9d2e3edb66eb54b1ae4ac554 100644 --- a/overlays.nix +++ b/overlays.nix @@ -1,5 +1,5 @@ self: super: { - openssl = self.openssl_1_1; + openssl = super.openssl_1_1; ristretto = super.callPackage ./ristretto.nix { }; diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py index 8b16a7751eec14dc61e1751101839724440ea346..e5c59e23456605c95c36ed24839aa1a439957520 100644 --- a/src/_zkapauthorizer/_plugin.py +++ b/src/_zkapauthorizer/_plugin.py @@ -21,10 +21,6 @@ from weakref import ( WeakValueDictionary, ) -from functools import ( - partial, -) - import attr from zope.interface import ( @@ -54,7 +50,7 @@ from .resource import ( ) from .controller import ( - DummyRedeemer, + get_redeemer, ) @implementer(IAnnounceableStorageServer) @@ -96,11 +92,24 @@ class ZKAPAuthorizer(object): return s + def _get_redeemer(self, node_config, announcement, reactor): + """ + :return IRedeemer: The voucher redeemer indicated by the given + configuration. A new instance is returned on every call because + the redeemer interface is stateless. + """ + return get_redeemer(self.name, node_config, announcement, reactor) + + def get_storage_server(self, configuration, get_anonymous_storage_server): - announcement = {} + kwargs = configuration.copy() + root_url = kwargs.pop(u"ristretto-issuer-root-url") + announcement = { + u"ristretto-issuer-root-url": root_url, + } storage_server = ZKAPAuthorizerStorageServer( get_anonymous_storage_server(), - **configuration + **kwargs ) return succeed( AnnounceableStorageServer( @@ -117,17 +126,23 @@ class ZKAPAuthorizer(object): managed by this plugin in the node directory that goes along with ``node_config``. """ + from twisted.internet import reactor + redeemer = self._get_redeemer(node_config, announcement, reactor) + extract_unblinded_tokens = self._get_store(node_config).extract_unblinded_tokens + def get_passes(message, count): + unblinded_tokens = extract_unblinded_tokens(count) + return redeemer.tokens_to_passes(message, unblinded_tokens) + return ZKAPAuthorizerStorageClient( get_rref, - # TODO: Make the caller figure out the correct number of - # passes to extract. - partial(self._get_store(node_config).extract_passes, 1), + get_passes, ) def get_client_resource(self, node_config): + from twisted.internet import reactor return resource_from_configuration( node_config, store=self._get_store(node_config), - redeemer=DummyRedeemer(), + redeemer=self._get_redeemer(node_config, None, reactor), ) diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py index e0dc41ddca2693016c17785a9dc4b655b7f2b585..6b1c1acf3babf0d303802f75320dc0734e6cdde1 100644 --- a/src/_zkapauthorizer/_storage_client.py +++ b/src/_zkapauthorizer/_storage_client.py @@ -49,8 +49,11 @@ class ZKAPAuthorizerStorageClient(object): valid ``RemoteReference`` corresponding to the server-side object for this scheme. - :ivar _get_passes: A no-argument callable which retrieves some passes - which can be used to authorize an operation. + :ivar _get_passes: A two-argument callable which retrieves some passes + which can be used to authorize an operation. The first argument is a + bytes (valid utf-8) message binding the passes to the request for + which they will be used. The second is an integer giving the number + of passes to request. """ _get_rref = attr.ib() _get_passes = attr.ib() @@ -59,7 +62,7 @@ class ZKAPAuthorizerStorageClient(object): def _rref(self): return self._get_rref() - def _get_encoded_passes(self): + def _get_encoded_passes(self, message, count): """ :return: A list of passes from ``_get_passes`` encoded into their ``bytes`` representation. @@ -67,7 +70,7 @@ class ZKAPAuthorizerStorageClient(object): return list( t.text.encode("ascii") for t - in self._get_passes() + in self._get_passes(message.encode("hex"), count) ) def get_version(self): @@ -86,7 +89,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "allocate_buckets", - self._get_encoded_passes(), + self._get_encoded_passes(storage_index, 1), storage_index, renew_secret, cancel_secret, @@ -112,7 +115,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "add_lease", - self._get_encoded_passes(), + self._get_encoded_passes(storage_index, 1), storage_index, renew_secret, cancel_secret, @@ -125,7 +128,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "renew_lease", - self._get_encoded_passes(), + self._get_encoded_passes(storage_index, 1), storage_index, renew_secret, ) @@ -154,7 +157,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "slot_testv_and_readv_and_writev", - self._get_encoded_passes(), + self._get_encoded_passes(storage_index, 1), storage_index, secrets, tw_vectors, diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index c0d5536009d07976a85e74109cd3ec5347d5e302..3d78b9ddaae7491b0e00cf153855c7d4157d0498 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -20,7 +20,9 @@ for the client side of the storage plugin. from functools import ( partial, ) - +from json import ( + dumps, +) import attr from zope.interface import ( @@ -28,17 +30,35 @@ from zope.interface import ( implementer, ) +from twisted.logger import ( + Logger, +) +from twisted.python.url import ( + URL, +) from twisted.internet.defer import ( Deferred, succeed, + inlineCallbacks, + returnValue, ) - -from .foolscap import ( - TOKEN_LENGTH, +from twisted.web.client import ( + Agent, ) +from treq import ( + json_content, +) +from treq.client import ( + HTTPClient, +) + +import privacypass + from .model import ( - Pass, RandomToken, + UnblindedToken, + Voucher, + Pass, ) @@ -65,7 +85,8 @@ class IRedeemer(Interface): def redeem(voucher, random_tokens): """ - Redeem a voucher for passes. + Redeem a voucher for unblinded tokens which can be used to construct + passes. Implementations of this method do not need to be fault tolerant. If a redemption attempt is interrupted before it completes, it is the @@ -77,12 +98,28 @@ class IRedeemer(Interface): :param list[RandomToken] random_tokens: The random tokens to use in the redemption process. - :return: A ``Deferred`` which fires with a list of ``Pass`` instances - on successful redemption or which fails with - ``TransientRedemptionError`` on any error which may be resolved by - simply trying again later or which fails with - ``PermanentRedemptionError`` on any error which is definitive and - final. + :return: A ``Deferred`` which fires with a list of ``UnblindedToken`` + instances on successful redemption or which fails with any error + to allow a retry to be made at some future point. + """ + + def tokens_to_passes(message, unblinded_tokens): + """ + Construct passes from unblinded tokens which are suitable for use with a + given message. + + :param bytes message: A valid utf-8-encoded byte sequence which serves + to protect the resulting passes from replay usage. It is + preferable if every use of passes is associated with a unique + message. + + :param list[UnblindedToken] unblinded_tokens: Unblinded tokens, + previously returned by a call to this implementation's ``redeem`` + method. + + :return list[Pass]: Passes constructed from the message and unblinded + tokens. There is one pass in the resulting list for each unblinded + token in ``unblinded_tokens``. """ @@ -94,7 +131,7 @@ class NonRedeemer(object): def random_tokens_for_voucher(self, voucher, count): # It doesn't matter because we're never going to try to redeem them. return list( - RandomToken(u"{}-{}".format(voucher, n)) + RandomToken(u"{}-{}".format(voucher.number, n)) for n in range(count) ) @@ -103,6 +140,11 @@ class NonRedeemer(object): # Don't try to redeem them. return Deferred() + def tokens_to_passes(self, message, unblinded_tokens): + raise Exception( + "Cannot be called because no unblinded tokens are ever returned." + ) + @implementer(IRedeemer) @attr.s @@ -112,6 +154,10 @@ class DummyRedeemer(object): really redeeming them, it makes up some fake ZKAPs and pretends those are the result. """ + @classmethod + def make(cls, section_name, node_config, announcement, reactor): + return cls() + def random_tokens_for_voucher(self, voucher, count): """ Generate some number of random tokens to submit along with a voucher for @@ -119,24 +165,200 @@ class DummyRedeemer(object): """ # Dummy token generation. return list( - RandomToken(u"{}-{}".format(voucher, n)) + RandomToken(u"{}-{}".format(voucher.number, n)) for n in range(count) ) def redeem(self, voucher, random_tokens): """ - :return: An already-fired ``Deferred`` that has a list of ``Pass`` - instances wrapping meaningless values. + :return: An already-fired ``Deferred`` that has a list of + ``UnblindedToken`` instances wrapping meaningless values. """ return succeed( list( - Pass((u"pass-" + token.token_value).zfill(TOKEN_LENGTH)) + UnblindedToken(token.token_value) for token in random_tokens ), ) + def tokens_to_passes(self, message, unblinded_tokens): + return list( + Pass(token.text) + for token + in unblinded_tokens + ) + + +class IssuerConfigurationMismatch(Exception): + """ + The Ristretto issuer address in the local client configuration does not + match the Ristretto issuer address received in a storage server + announcement. + + If these values do not match then there is no reason to expect that ZKAPs + will be accepted by the storage server because ZKAPs are bound to the + issuer's signing key. + + This mismatch must be corrected before the storage server can be used. + Either the storage server needs to be reconfigured to respect the + authority of a different issuer (the same one the client is configured to + use), the client needs to select a different storage server to talk to, or + the client needs to be reconfigured to respect the authority of a + different issuer (the same one the storage server is announcing). + + Note that issued ZKAPs cannot be exchanged between issues except through + some ad hoc, out-of-band means. That is, if the client already has some + ZKAPs and chooses to change its configured issuer address, those existing + ZKAPs will not be usable and new ones must be obtained. + """ + def __str__(self): + return "Announced issuer ({}) disagrees with configured issuer ({}).".format(self.args) + + +@implementer(IRedeemer) +@attr.s +class RistrettoRedeemer(object): + """ + An ``IRedeemer`` which speaks the Ristretto-flavored PrivacyPass protocol + described at + https://docs.rs/challenge-bypass-ristretto/1.0.0-pre.0/challenge_bypass_ristretto/#cryptographic-protocol + + :ivar treq.client.HTTPClient _treq: An HTTP client to use to make calls to + the issuer. + + :ivar URL _api_root: The root of the issuer HTTP API. + """ + _log = Logger() + + _treq = attr.ib() + _api_root = attr.ib(validator=attr.validators.instance_of(URL)) + + @classmethod + def make(cls, section_name, node_config, announcement, reactor): + configured_issuer = node_config.get_config( + section=section_name, + option=u"ristretto-issuer-root-url", + ).decode("ascii") + if announcement is not None: + # Don't let us talk to a storage server that has a different idea + # about who issues ZKAPs. We should lift this limitation (that is, we + # should support as many different issuers as the user likes) in the + # future but doing so requires changing how the web interface works + # and possibly also the interface for voucher submission. + # + # If we aren't given an announcement then we're not being used in + # the context of a specific storage server so the check is + # unnecessary and impossible. + announced_issuer = announcement[u"ristretto-issuer-root-url"] + if announced_issuer != configured_issuer: + raise IssuerConfigurationMismatch(announced_issuer, configured_issuer) + + return cls( + HTTPClient(Agent(reactor)), + URL.from_text(configured_issuer), + ) + + def random_tokens_for_voucher(self, voucher, count): + return list( + RandomToken(privacypass.RandomToken.create().encode_base64().decode("ascii")) + for n + in range(count) + ) + + @inlineCallbacks + def redeem(self, voucher, encoded_random_tokens): + random_tokens = list( + privacypass.RandomToken.decode_base64(token.token_value.encode("ascii")) + for token + in encoded_random_tokens + ) + blinded_tokens = list(token.blind() for token in random_tokens) + response = yield self._treq.post( + self._api_root.child(u"v1", u"redeem").to_text(), + dumps({ + u"redeemVoucher": voucher.number, + u"redeemTokens": list( + token.encode_base64() + for token + in blinded_tokens + ), + }), + headers={b"content-type": b"application/json"}, + ) + try: + result = yield json_content(response) + except ValueError: + self._log.failure("Parsing redeem response failed", response=response) + raise + + self._log.info("Redeemed: {public-key} {proof} {signatures}", **result) + + marshaled_signed_tokens = result[u"signatures"] + marshaled_proof = result[u"proof"] + marshaled_public_key = result[u"public-key"] + + public_key = privacypass.PublicKey.decode_base64( + marshaled_public_key.encode("ascii"), + ) + clients_signed_tokens = list( + privacypass.SignedToken.decode_base64( + marshaled_signed_token.encode("ascii"), + ) + for marshaled_signed_token + in marshaled_signed_tokens + ) + clients_proof = privacypass.BatchDLEQProof.decode_base64( + marshaled_proof.encode("ascii"), + ) + clients_unblinded_tokens = clients_proof.invalid_or_unblind( + random_tokens, + blinded_tokens, + clients_signed_tokens, + public_key, + ) + returnValue(list( + UnblindedToken(token.encode_base64().decode("ascii")) + for token + in clients_unblinded_tokens + )) + + def tokens_to_passes(self, message, unblinded_tokens): + assert isinstance(message, bytes) + assert isinstance(unblinded_tokens, list) + assert all(isinstance(element, UnblindedToken) for element in unblinded_tokens) + unblinded_tokens = list( + privacypass.UnblindedToken.decode_base64(token.text.encode("ascii")) + for token + in unblinded_tokens + ) + clients_verification_keys = list( + token.derive_verification_key_sha512() + for token + in unblinded_tokens + ) + clients_signatures = list( + verification_key.sign_sha512(message) + for verification_key + in clients_verification_keys + ) + clients_preimages = list( + token.preimage() + for token + in unblinded_tokens + ) + marshaled_passes = list( + preimage.encode_base64() + b" " + signature.encode_base64() + for (preimage, signature) + in zip(clients_preimages, clients_signatures) + ) + return list( + Pass(p.decode("ascii")) + for p + in marshaled_passes + ) + @attr.s class PaymentController(object): @@ -153,13 +375,20 @@ class PaymentController(object): 3. The controller hands the voucher and some random tokens to a redeemer. In the future, this step will need to be retried in the case of failures. - 4. When the voucher has been redeemed for passes, the controller hands them to the data store with the voucher. - The data store marks the voucher as redeemed and stores the passes for use by the storage client. + 4. When the voucher has been redeemed for unblinded tokens (inputs to + pass construction), the controller hands them to the data store with + the voucher. The data store marks the voucher as redeemed and stores + the unblinded tokens for use by the storage client. """ + _log = Logger() + store = attr.ib() redeemer = attr.ib() def redeem(self, voucher): + """ + :param unicode voucher: A voucher to redeem. + """ # Pre-generate the random tokens to use when redeeming the voucher. # These are persisted with the voucher so the redemption can be made # idempotent. We don't want to lose the value if we fail after the @@ -169,20 +398,51 @@ class PaymentController(object): # server signs a given set of random tokens once or many times, the # number of passes that can be constructed is still only the size of # the set of random tokens. - tokens = self.redeemer.random_tokens_for_voucher(voucher, 100) + self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher) + tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), 100) # Persist the voucher and tokens so they're available if we fail. + self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher) self.store.add(voucher, tokens) # Ask the redeemer to do the real task of redemption. - d = self.redeemer.redeem(voucher, tokens) - d.addCallback( + self._log.info("Redeeming random tokens for a voucher ({voucher}).", voucher=voucher) + d = self.redeemer.redeem(Voucher(voucher), tokens) + d.addCallbacks( partial(self._redeemSuccess, voucher), + partial(self._redeemFailure, voucher), ) + d.addErrback(partial(self._finalRedeemError, voucher)) - def _redeemSuccess(self, voucher, passes): + def _redeemSuccess(self, voucher, unblinded_tokens): """ Update the database state to reflect that a voucher was redeemed and to - store the resulting passes. + store the resulting unblinded tokens (which can be used to construct + passes later). """ - self.store.insert_passes_for_voucher(voucher, passes) + self._log.info("Inserting redeemed unblinded tokens for a voucher ({voucher}).", voucher=voucher) + self.store.insert_unblinded_tokens_for_voucher(voucher, unblinded_tokens) + + def _redeemFailure(self, voucher, reason): + self._log.failure("Redeeming random tokens for a voucher ({voucher}) failed.", reason, voucher=voucher) + return None + + def _finalRedeemError(self, voucher, reason): + self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher) + return None + + +def get_redeemer(plugin_name, node_config, announcement, reactor): + section_name = u"storageclient.plugins.{}".format(plugin_name) + redeemer_kind = node_config.get_config( + section=section_name, + option=u"redeemer", + default=u"ristretto", + ) + return _REDEEMERS[redeemer_kind](section_name, node_config, announcement, reactor) + + +_REDEEMERS = { + u"dummy": DummyRedeemer.make, + u"ristretto": RistrettoRedeemer.make, +} diff --git a/src/_zkapauthorizer/foolscap.py b/src/_zkapauthorizer/foolscap.py index 224614dc7649ee80d3d2bec04eacee90ce749112..213eca4476aed796fa4ba166014a7e4fcd6a75ac 100644 --- a/src/_zkapauthorizer/foolscap.py +++ b/src/_zkapauthorizer/foolscap.py @@ -29,9 +29,12 @@ from allmydata.interfaces import ( # lot of value. MAXIMUM_TOKENS_PER_CALL = 10 -# This is the length of a serialized PrivacyPass pass (there's a lot of -# confusion between "tokens" and "passes" here, sadly). -TOKEN_LENGTH = 97 +# This is the length of a serialized Ristretto-flavored PrivacyPass pass +# (there's a lot of confusion between "tokens" and "passes" here, sadly). +# +# The pass is a combination of base64-encoded token preimages and unblinded +# token signatures. +TOKEN_LENGTH = 177 # Take those values and turn them into the appropriate Foolscap constraint # objects. Foolscap seems to have a convention of representing these as diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index c2e56063489167c5e41ace8f3526751213e0a4ef..76a659da860617fd26dc39afaebe3b25be8cad3a 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -136,10 +136,10 @@ def open_and_initialize(path, required_schema_version, connect=None): ) cursor.execute( """ - CREATE TABLE IF NOT EXISTS [passes] ( - [text] text, -- The string that defines the pass. + CREATE TABLE IF NOT EXISTS [unblinded-tokens] ( + [token] text, -- The base64 encoded unblinded token. - PRIMARY KEY([text]) + PRIMARY KEY([token]) ) """, ) @@ -271,21 +271,26 @@ class VoucherStore(object): ) @with_cursor - def insert_passes_for_voucher(self, cursor, voucher, passes): + def insert_unblinded_tokens_for_voucher(self, cursor, voucher, unblinded_tokens): """ - Store some passes. + Store some unblinded tokens. - :param unicode voucher: The voucher associated with the passes. This - voucher will be marked as redeemed to indicate it has fulfilled - its purpose and has no further use for us. + :param unicode voucher: The voucher associated with the unblinded + tokens. This voucher will be marked as redeemed to indicate it + has fulfilled its purpose and has no further use for us. - :param list[Pass] passes: The passes to store. + :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to + store. """ cursor.executemany( """ - INSERT INTO [passes] VALUES (?) + INSERT INTO [unblinded-tokens] VALUES (?) """, - list((p.text,) for p in passes), + list( + (t.text,) + for t + in unblinded_tokens + ), ) cursor.execute( """ @@ -295,47 +300,63 @@ class VoucherStore(object): ) @with_cursor - def extract_passes(self, cursor, count): + def extract_unblinded_tokens(self, cursor, count): """ - Remove and return some passes. + Remove and return some unblinded tokens. - :param int count: The maximum number of passes to remove and return. - If fewer passes than this are available, only as many as are + :param int count: The maximum number of unblinded tokens to remove and + return. If fewer than this are available, only as many as are available are returned. - :return list[Pass]: The removed passes. + :return list[UnblindedTokens]: The removed unblinded tokens. """ cursor.execute( """ - CREATE TEMPORARY TABLE [extracting-passes] + CREATE TEMPORARY TABLE [extracting] AS - SELECT [text] FROM [passes] LIMIT ? + SELECT [token] FROM [unblinded-tokens] LIMIT ? """, (count,), ) cursor.execute( """ - DELETE FROM [passes] WHERE [text] IN [extracting-passes] + DELETE FROM [unblinded-tokens] WHERE [token] IN [extracting] """, ) cursor.execute( """ - SELECT [text] FROM [extracting-passes] + SELECT [token] FROM [extracting] """, ) texts = cursor.fetchall() cursor.execute( """ - DROP TABLE [extracting-passes] + DROP TABLE [extracting] """, ) return list( - Pass(t) + UnblindedToken(t) for (t,) in texts ) +@attr.s(frozen=True) +class UnblindedToken(object): + """ + An ``UnblindedToken`` instance represents cryptographic proof of a voucher + redemption. It is an intermediate artifact in the PrivacyPass protocol + and can be used to construct a privacy-preserving pass which can be + exchanged for service. + + :ivar unicode text: The base64 encoded serialized form of the unblinded + token. This can be used to reconstruct a + ``privacypass.UnblindedToken`` using that class's ``decode_base64`` + method. + """ + text = attr.ib(validator=attr.validators.instance_of(unicode)) + + @attr.s(frozen=True) class Pass(object): """ diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index 7f78a0b41415370ebe9ca4f18e9fdb1b12da0233..c724fbca870dabb0457688c2ce162b91290161fd 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -24,6 +24,9 @@ from json import ( loads, dumps, ) +from twisted.logger import ( + Logger, +) from twisted.web.http import ( BAD_REQUEST, ) @@ -84,6 +87,8 @@ class _VoucherCollection(Resource): redemption controller. Child resources of this resource can also be retrieved to monitor the status of previously submitted vouchers. """ + _log = Logger() + def __init__(self, store, controller): self._store = store self._controller = controller @@ -104,6 +109,7 @@ class _VoucherCollection(Resource): if not is_syntactic_voucher(voucher): return bad_request(u"submitted voucher is syntactically invalid").render(request) + self._log.info("Accepting a voucher ({voucher}) for redemption.", voucher=voucher) self._controller.redeem(voucher) return b"" diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 8f6e74ed6c4a71cff35f75630b7b26fb3232c543..0af3d1f86dc76db4b2e2627eedde8972e85a2338 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -55,6 +55,7 @@ from allmydata.client import ( from ..model import ( Pass, RandomToken, + UnblindedToken ) @@ -118,7 +119,7 @@ def tahoe_config_texts(storage_client_plugins): ) -def tahoe_configs(storage_client_plugins=None): +def minimal_tahoe_configs(storage_client_plugins=None): """ Build complete Tahoe-LAFS configurations for a node. """ @@ -134,6 +135,17 @@ def tahoe_configs(storage_client_plugins=None): ), ) + +def tahoe_configs(): + """ + Build complete Tahoe-LAFS configurations including the zkapauthorizer + client plugin section. + """ + return minimal_tahoe_configs({ + u"privatestorageio-zkapauthz-v1": client_configurations(), + }) + + def node_nicknames(): """ Builds Tahoe-LAFS node nicknames. @@ -152,18 +164,22 @@ def node_nicknames(): ) -def configurations(): +def server_configurations(): """ Build configuration values for the server-side plugin. """ - return just({}) + return just({ + u"ristretto-issuer-root-url": u"https://issuer.example.invalid/", + }) def client_configurations(): """ Build configuration values for the client-side plugin. """ - return just({}) + return just({ + u"ristretto-issuer-root-url": u"https://issuer.example.invalid/", + }) def vouchers(): @@ -208,6 +224,22 @@ def zkaps(): ) +def unblinded_tokens(): + """ + Builds random ``_zkapauthorizer.model.UnblindedToken`` wrapping invalid + base64 encode data. You cannot use these in the PrivacyPass cryptographic + protocol but you can put them into the database and take them out again. + """ + return binary( + min_size=32, + max_size=32, + ).map( + urlsafe_b64encode, + ).map( + lambda zkap: UnblindedToken(zkap.decode("ascii")), + ) + + def request_paths(): """ Build lists of unicode strings that represent the path component of an @@ -418,4 +450,6 @@ def announcements(): """ Build announcements for the ZKAPAuthorizer plugin. """ - return just({}) + return just({ + u"ristretto-issuer-root-url": u"https://issuer.example.invalid/", + }) diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 203e44ca3ca557788daefd667fbbea3dc73de4e5..77b5bb38228a73044f7c08f98d460a024138c8b2 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -102,7 +102,6 @@ from ..resource import ( from .strategies import ( tahoe_configs, - client_configurations, vouchers, requests, ) @@ -121,10 +120,6 @@ def uncooperator(started=True): -tahoe_configs_with_client_config = tahoe_configs(storage_client_plugins={ - u"privatestorageio-zkapauthz-v1": client_configurations(), -}) - def is_not_json(bytestring): """ :param bytes bytestring: A candidate byte string to inspect. @@ -217,7 +212,7 @@ class VoucherTests(TestCase): self.useFixture(CaptureTwistedLogs()) - @given(tahoe_configs_with_client_config, requests(just([u"voucher"]))) + @given(tahoe_configs(), requests(just([u"voucher"]))) def test_reachable(self, get_config, request): """ A resource is reachable at the ``voucher`` child of a the resource @@ -232,7 +227,7 @@ class VoucherTests(TestCase): ) - @given(tahoe_configs_with_client_config, vouchers()) + @given(tahoe_configs(), vouchers()) def test_put_voucher(self, get_config, voucher): """ When a voucher is ``PUT`` to ``VoucherCollection`` it is passed in to the @@ -263,7 +258,7 @@ class VoucherTests(TestCase): ), ) - @given(tahoe_configs_with_client_config, invalid_bodies()) + @given(tahoe_configs(), invalid_bodies()) def test_put_invalid_body(self, get_config, body): """ If the body of a ``PUT`` to ``VoucherCollection`` does not consist of an @@ -294,7 +289,7 @@ class VoucherTests(TestCase): ), ) - @given(tahoe_configs_with_client_config, not_vouchers()) + @given(tahoe_configs(), not_vouchers()) def test_get_invalid_voucher(self, get_config, not_voucher): """ When a syntactically invalid voucher is requested with a ``GET`` to a @@ -322,7 +317,7 @@ class VoucherTests(TestCase): ) - @given(tahoe_configs_with_client_config, vouchers()) + @given(tahoe_configs(), vouchers()) def test_get_unknown_voucher(self, get_config, voucher): """ When a voucher is requested with a ``GET`` to a child of @@ -345,7 +340,7 @@ class VoucherTests(TestCase): ) - @given(tahoe_configs_with_client_config, vouchers()) + @given(tahoe_configs(), vouchers()) def test_get_known_voucher(self, get_config, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the @@ -398,7 +393,7 @@ class VoucherTests(TestCase): ), ) - @given(tahoe_configs_with_client_config, lists(vouchers(), unique=True)) + @given(tahoe_configs(), lists(vouchers(), unique=True)) def test_list_vouchers(self, get_config, vouchers): """ A ``GET`` to the ``VoucherCollection`` itself returns a list of existing diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 2969a01187eb56dfa3cec435846f4d021c40f7a6..4e3f264ce22a05d32535c87888d19d2af93d0147 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -16,11 +16,33 @@ Tests for ``_zkapauthorizer.controller``. """ +from json import ( + loads, + dumps, +) +from functools import ( + partial, +) +from zope.interface import ( + implementer, +) from testtools import ( TestCase, ) +from testtools.content import ( + text_content, +) from testtools.matchers import ( Equals, + MatchesAll, + AllMatch, + IsInstance, + HasLength, + AfterPreprocessing, +) +from testtools.twistedsupport import ( + succeeded, + failed, ) from fixtures import ( @@ -30,22 +52,61 @@ from fixtures import ( from hypothesis import ( given, ) +from hypothesis.strategies import ( + integers, +) +from twisted.python.url import ( + URL, +) +from twisted.internet.defer import ( + fail, +) +from twisted.web.iweb import ( + IAgent, +) +from twisted.web.resource import ( + ErrorPage, + Resource, +) +from twisted.web.http import ( + UNSUPPORTED_MEDIA_TYPE, +) +from treq.testing import ( + StubTreq, +) + +from privacypass import ( + SecurityException, + PublicKey, + BlindedToken, + BatchDLEQProof, + TokenPreimage, + VerificationSignature, + random_signing_key, +) from ..controller import ( + IRedeemer, NonRedeemer, DummyRedeemer, + RistrettoRedeemer, PaymentController, ) from ..model import ( memory_connect, VoucherStore, + Voucher, + UnblindedToken, ) from .strategies import ( tahoe_configs, vouchers, ) +from .matchers import ( + Provides, +) class PaymentControllerTests(TestCase): """ @@ -98,3 +159,247 @@ class PaymentControllerTests(TestCase): persisted_voucher.redeemed, Equals(True), ) + + +NOWHERE = URL.from_text(u"https://127.0.0.1/") + +class RistrettoRedeemerTests(TestCase): + """ + Tests for ``RistrettoRedeemer``. + """ + def test_interface(self): + """ + An ``RistrettoRedeemer`` instance provides ``IRedeemer``. + """ + redeemer = RistrettoRedeemer(stub_agent(), NOWHERE) + self.assertThat( + redeemer, + Provides([IRedeemer]), + ) + + @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + def test_good_ristretto_redemption(self, voucher, num_tokens): + """ + If the issuer returns a successful result then + ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a + list of ``UnblindedToken`` instances. + """ + signing_key = random_signing_key() + issuer = RistrettoRedemption(signing_key) + treq = treq_for_loopback_ristretto(issuer) + redeemer = RistrettoRedeemer(treq, NOWHERE) + random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + d = redeemer.redeem( + voucher, + random_tokens, + ) + self.assertThat( + d, + succeeded( + MatchesAll( + AllMatch( + IsInstance(UnblindedToken), + ), + HasLength(num_tokens), + ), + ), + ) + + @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + def test_bad_ristretto_redemption(self, voucher, num_tokens): + """ + If the issuer returns a successful result with an invalid proof then + ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a + ``Failure`` wrapping ``SecurityException``. + """ + signing_key = random_signing_key() + issuer = RistrettoRedemption(signing_key) + + # Make it lie about the public key it is using. This causes the proof + # to be invalid since it proves the signature was made with a + # different key than reported in the response. + issuer.public_key = PublicKey.from_signing_key(random_signing_key()) + + treq = treq_for_loopback_ristretto(issuer) + redeemer = RistrettoRedeemer(treq, NOWHERE) + random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + d = redeemer.redeem( + voucher, + random_tokens, + ) + self.addDetail(u"redeem Deferred", text_content(str(d))) + self.assertThat( + d, + failed( + AfterPreprocessing( + lambda f: f.value, + IsInstance(SecurityException), + ), + ), + ) + + @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + def test_ristretto_pass_construction(self, voucher, num_tokens): + """ + The passes constructed using unblinded tokens and messages pass the + Ristretto verification check. + """ + message = b"hello world" + + signing_key = random_signing_key() + issuer = RistrettoRedemption(signing_key) + treq = treq_for_loopback_ristretto(issuer) + redeemer = RistrettoRedeemer(treq, NOWHERE) + + random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) + d = redeemer.redeem( + voucher, + random_tokens, + ) + def unblinded_tokens_to_passes(unblinded_tokens): + passes = redeemer.tokens_to_passes(message, unblinded_tokens) + return passes + d.addCallback(unblinded_tokens_to_passes) + + self.assertThat( + d, + succeeded( + AfterPreprocessing( + partial(ristretto_verify, signing_key, message), + Equals(True), + ), + ), + ) + + +def ristretto_verify(signing_key, message, marshaled_passes): + """ + Verify that the given passes were generated in a process that involved a + signature from the given signing key and using the given message. + + :param privacypass.SigningKey signing_key: A signing key which should have + signed some random blinded tokens earlier in the lifecycle of the + passes to verify. + + :param bytes message: Request binding data which is involved in the + generation of the passes to verify. + + :param list[bytes] marshaled_passes: Token preimages and corresponding + message signatures to verify. Each element contains two + space-separated base64 encoded values, the first representing the + preimage and the second representing the signature. + + :return bool: ``True`` if and only if all of the passes represented by + ``marshaled_passes`` pass the Ristretto-defined verification for an + exchange using the given signing key and message. + """ + def decode(marshaled_pass): + t, s = marshaled_pass.split(u" ") + return ( + TokenPreimage.decode_base64(t.encode("ascii")), + VerificationSignature.decode_base64(s.encode("ascii")), + ) + servers_passes = list( + decode(marshaled_pass.text) + for marshaled_pass + in marshaled_passes + ) + servers_unblinded_tokens = list( + signing_key.rederive_unblinded_token(token_preimage) + for (token_preimage, sig) + in servers_passes + ) + servers_verification_sigs = list( + sig + for (token_preimage, sig) + in servers_passes + ) + servers_verification_keys = list( + unblinded_token.derive_verification_key_sha512() + for unblinded_token + in servers_unblinded_tokens + ) + invalid_passes = list( + key.invalid_sha512( + sig, + message, + ) + for (key, sig) + in zip(servers_verification_keys, servers_verification_sigs) + ) + + return not any(invalid_passes) + + +def treq_for_loopback_ristretto(local_issuer): + """ + Create a ``treq``-alike which can dispatch to a local issuer. + """ + v1 = Resource() + v1.putChild(b"redeem", local_issuer) + root = Resource() + root.putChild(b"v1", v1) + return StubTreq(root) + + +@implementer(IAgent) +class _StubAgent(object): + def request(self, method, uri, headers=None, bodyProducer=None): + return fail(Exception("It's only a model.")) + + +def stub_agent(): + return _StubAgent() + + +class RistrettoRedemption(Resource): + def __init__(self, signing_key): + Resource.__init__(self) + self.signing_key = signing_key + self.public_key = PublicKey.from_signing_key(signing_key) + + def render_POST(self, request): + if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]: + return bad_content_type(request) + + request_body = loads(request.content.read()) + marshaled_blinded_tokens = request_body[u"redeemTokens"] + servers_blinded_tokens = list( + BlindedToken.decode_base64(marshaled_blinded_token.encode("ascii")) + for marshaled_blinded_token + in marshaled_blinded_tokens + ) + servers_signed_tokens = list( + self.signing_key.sign(blinded_token) + for blinded_token + in servers_blinded_tokens + ) + marshaled_signed_tokens = list( + signed_token.encode_base64() + for signed_token + in servers_signed_tokens + ) + servers_proof = BatchDLEQProof.create( + self.signing_key, + servers_blinded_tokens, + servers_signed_tokens, + ) + try: + marshaled_proof = servers_proof.encode_base64() + finally: + servers_proof.destroy() + + return dumps({ + u"success": True, + u"public-key": self.public_key.encode_base64(), + u"signatures": marshaled_signed_tokens, + u"proof": marshaled_proof, + }) + + +def bad_content_type(request): + return ErrorPage( + UNSUPPORTED_MEDIA_TYPE, + b"Unsupported media type", + b"Unsupported media type", + ).render(request) diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 1a6ac3cf944dc53b3b6ad554ea85addd170b9aaa..f614c1c912823cec16cf9db27a546c46d27a7b9b 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -65,7 +65,7 @@ from .strategies import ( tahoe_configs, vouchers, random_tokens, - zkaps, + unblinded_tokens, ) @@ -255,14 +255,14 @@ class VoucherTests(TestCase): ) -class ZKAPStoreTests(TestCase): +class UnblindedTokenStoreTests(TestCase): """ - Tests for ZKAP-related functionality of ``VoucherStore``. + Tests for ``UnblindedToken``-related functionality of ``VoucherStore``. """ - @given(tahoe_configs(), vouchers(), lists(zkaps(), unique=True)) - def test_zkaps_round_trip(self, get_config, voucher_value, passes): + @given(tahoe_configs(), vouchers(), lists(unblinded_tokens(), unique=True)) + def test_unblinded_tokens_round_trip(self, get_config, voucher_value, tokens): """ - ZKAPs that are added to the store can later be retrieved. + Unblinded tokens that are added to the store can later be retrieved. """ tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"node"), b"tub.port") @@ -270,18 +270,19 @@ class ZKAPStoreTests(TestCase): config, memory_connect, ) - store.insert_passes_for_voucher(voucher_value, passes) - retrieved_passes = store.extract_passes(len(passes)) - self.expectThat(passes, Equals(retrieved_passes)) + store.insert_unblinded_tokens_for_voucher(voucher_value, tokens) + retrieved_tokens = store.extract_unblinded_tokens(len(tokens)) + self.expectThat(tokens, Equals(retrieved_tokens)) - # After extraction, the passes are no longer available. - more_passes = store.extract_passes(1) - self.expectThat([], Equals(more_passes)) + # After extraction, the unblinded tokens are no longer available. + more_unblinded_tokens = store.extract_unblinded_tokens(1) + self.expectThat([], Equals(more_unblinded_tokens)) - @given(tahoe_configs(), vouchers(), random_tokens(), zkaps()) - def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_pass): + @given(tahoe_configs(), vouchers(), random_tokens(), unblinded_tokens()) + def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_token): """ - The voucher for ZKAPs that are added to the store are marked as redeemed. + The voucher for unblinded tokens that are added to the store is marked as + redeemed. """ tempdir = self.useFixture(TempDir()) config = get_config(tempdir.join(b"node"), b"tub.port") @@ -290,6 +291,6 @@ class ZKAPStoreTests(TestCase): memory_connect, ) store.add(voucher_value, [token]) - store.insert_passes_for_voucher(voucher_value, [one_pass]) + store.insert_unblinded_tokens_for_voucher(voucher_value, [one_token]) loaded_voucher = store.get(voucher_value) self.assertThat(loaded_voucher.redeemed, Equals(True)) diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index 2587904e68bf78928d8b4344a653de7db4d4a4cd..d497c724877505caf6ae95cbc451e429fbb2d384 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -16,6 +16,10 @@ Tests for the Tahoe-LAFS plugin. """ +from io import ( + BytesIO, +) + from zope.interface import ( implementer, ) @@ -36,11 +40,15 @@ from testtools.matchers import ( from testtools.twistedsupport import ( succeeded, ) - +from testtools.content import ( + text_content, +) from hypothesis import ( given, ) - +from hypothesis.strategies import ( + just, +) from foolscap.broker import ( Broker, ) @@ -75,14 +83,18 @@ from twisted.plugins.zkapauthorizer import ( from ..model import ( VoucherStore, ) +from ..controller import ( + IssuerConfigurationMismatch, +) from .strategies import ( + minimal_tahoe_configs, tahoe_configs, - configurations, + server_configurations, announcements, vouchers, random_tokens, - zkaps, + unblinded_tokens, storage_indexes, lease_renew_secrets, ) @@ -134,7 +146,7 @@ class ServerPluginTests(TestCase): Tests for the plugin's implementation of ``IFoolscapStoragePlugin.get_storage_server``. """ - @given(configurations()) + @given(server_configurations()) def test_returns_announceable(self, configuration): """ ``storage_server.get_storage_server`` returns an instance which provides @@ -150,7 +162,7 @@ class ServerPluginTests(TestCase): ) - @given(configurations()) + @given(server_configurations()) def test_returns_referenceable(self, configuration): """ The storage server attached to the result of @@ -171,7 +183,7 @@ class ServerPluginTests(TestCase): ), ) - @given(configurations()) + @given(server_configurations()) def test_returns_serializable(self, configuration): """ The storage server attached to the result of @@ -195,7 +207,7 @@ class ServerPluginTests(TestCase): ) - @given(configurations()) + @given(server_configurations()) def test_returns_hashable(self, configuration): """ The storage server attached to the result of @@ -221,6 +233,13 @@ class ServerPluginTests(TestCase): ) +tahoe_configs_with_dummy_redeemer = minimal_tahoe_configs({ + u"privatestorageio-zkapauthz-v1": just({u"redeemer": u"dummy"}), +}) + +tahoe_configs_with_mismatched_issuer = minimal_tahoe_configs({ + u"privatestorageio-zkapauthz-v1": just({u"ristretto-issuer-root-url": u"https://another-issuer.example.invalid/"}), +}) class ClientPluginTests(TestCase): """ @@ -251,28 +270,53 @@ class ClientPluginTests(TestCase): ) + @given(tahoe_configs_with_mismatched_issuer, announcements()) + def test_mismatched_ristretto_issuer(self, get_config, announcement): + """ + ``get_storage_client`` raises an exception when called with an + announcement and local configuration which specify different issuers. + """ + tempdir = self.useFixture(TempDir()) + node_config = get_config( + tempdir.join(b"node"), + b"tub.port", + ) + config_text = BytesIO() + node_config.config.write(config_text) + self.addDetail(u"config", text_content(config_text.getvalue())) + self.addDetail(u"announcement", text_content(unicode(announcement))) + try: + result = storage_server.get_storage_client(node_config, announcement, get_rref) + except IssuerConfigurationMismatch: + pass + except Exception as e: + self.fail("get_storage_client raised the wrong exception: {}".format(e)) + else: + self.fail("get_storage_client didn't raise, returned: {}".format(result)) + + @given( - tahoe_configs(), + tahoe_configs_with_dummy_redeemer, announcements(), vouchers(), random_tokens(), - zkaps(), + unblinded_tokens(), storage_indexes(), lease_renew_secrets(), ) - def test_passes_extracted( + def test_unblinded_tokens_extracted( self, get_config, announcement, voucher, token, - zkap, + unblinded_token, storage_index, renew_secret, ): """ The ``ZKAPAuthorizerStorageServer`` returned by ``get_storage_client`` - extracts passes from the plugin database. + extracts unblinded tokens from the plugin database. """ tempdir = self.useFixture(TempDir()) node_config = get_config( @@ -282,7 +326,7 @@ class ClientPluginTests(TestCase): store = VoucherStore.from_node_config(node_config) store.add(voucher, [token]) - store.insert_passes_for_voucher(voucher, [zkap]) + store.insert_unblinded_tokens_for_voucher(voucher, [unblinded_token]) storage_client = storage_server.get_storage_client( node_config, @@ -298,8 +342,8 @@ class ClientPluginTests(TestCase): ) d.addBoth(lambda ignored: None) - # There should be no passes left to extract. - remaining = store.extract_passes(1) + # There should be no unblinded tokens left to extract. + remaining = store.extract_unblinded_tokens(1) self.assertThat( remaining, Equals([]), diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py index 51cac393c395cdd3dc520aacc864dc81028e6d3b..0bd6cb80835986f16bee3f4eacfa72f635608bec 100644 --- a/src/_zkapauthorizer/tests/test_storage_protocol.py +++ b/src/_zkapauthorizer/tests/test_storage_protocol.py @@ -160,8 +160,15 @@ class ShareTests(TestCase): self.canary = LocalReferenceable(None) self.anonymous_storage_server = self.useFixture(AnonymousStorageServer()).storage_server - def get_passes(): - return [Pass(u"x" * TOKEN_LENGTH)] + def get_passes(message, count): + if not isinstance(message, bytes): + raise TypeError("message must be bytes") + try: + message.decode("utf-8") + except UnicodeDecodeError: + raise TypeError("message must be valid utf-8") + + return [Pass(u"x" * TOKEN_LENGTH)] * count self.server = ZKAPAuthorizerStorageServer( self.anonymous_storage_server, @@ -512,8 +519,8 @@ class ShareTests(TestCase): # transit the network differently from keyword arguments. Yay. d = self.client._rref.callRemote( "slot_testv_and_readv_and_writev", - # tokens - self.client._get_encoded_passes(), + # passes + self.client._get_encoded_passes(storage_index, 1), # storage_index storage_index, # secrets