diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index b3e672fe52b76bc335481ad7adfe4e76d39cb974..5f27eb1a7b5915dbe01785bf60f2fc8360655f91 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -42,6 +42,13 @@ The client can also be configured with the value of a single pass:: The value given here must agree with the value servers use in their configuration or the storage service will be unusable. +The client can also be configured with the number of passes to expect in exchange for one voucher:: + + [storageclient.plugins.privatestorageio-zkapauthz-v1] + default-token-count = 32768 + +The value given here must agree with the value the issuer uses in its configuration or redemption may fail. + Server ------ diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py index 1b8874e4a7a8353c7112906b2459a85f710efa60..1ce34fed2774c545296ecf5ad330a32c8c3e2281 100644 --- a/src/_zkapauthorizer/_plugin.py +++ b/src/_zkapauthorizer/_plugin.py @@ -185,17 +185,12 @@ class ZKAPAuthorizer(object): ) - def get_client_resource(self, node_config, default_token_count=None, reactor=None): + def get_client_resource(self, node_config, reactor=None): """ Get an ``IZKAPRoot`` for the given node configuration. :param allmydata.node._Config node_config: The configuration object for the relevant node. - - :param int default_token_count: Configure the payment controller with - a default number of tokens to request during voucher redemption. - This is only used if a number of tokens isn't specified at the - point of redemption. """ if reactor is None: from twisted.internet import reactor @@ -203,7 +198,6 @@ class ZKAPAuthorizer(object): node_config, store=self._get_store(node_config), redeemer=self._get_redeemer(node_config, None, reactor), - default_token_count=default_token_count, clock=reactor, ) diff --git a/src/_zkapauthorizer/configutil.py b/src/_zkapauthorizer/configutil.py new file mode 100644 index 0000000000000000000000000000000000000000..d87772d9052f63157a0bb83235a1bb5004eb5e9d --- /dev/null +++ b/src/_zkapauthorizer/configutil.py @@ -0,0 +1,75 @@ +# Copyright 2021 PrivateStorage.io, LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Basic utilities related to the Tahoe configuration file. +""" + +from __future__ import ( + division, + absolute_import, + print_function, + unicode_literals, +) + + +def _merge_dictionaries(dictionaries): + """ + Collapse a sequence of dictionaries into one, with collisions resolved by + taking the value from later dictionaries in the sequence. + + :param [dict] dictionaries: The dictionaries to collapse. + + :return dict: The collapsed dictionary. + """ + result = {} + for d in dictionaries: + result.update(d) + return result + + +def _tahoe_config_quote(text): + """ + Quote **%** in a unicode string. + + :param unicode text: The string on which to perform quoting. + + :return unicode: The string with ``%%`` replacing ``%``. + """ + return text.replace("%", "%%") + + +def config_string_from_sections(divided_sections): + """ + Get the .ini-syntax unicode string representing the given configuration + values. + + :param [dict] divided_sections: The configuration to use to generate the + string. Each ``dict`` maps a top-level section name to a ``dict`` of + key/value pairs. Dictionaries may have overlapping top-level + sections, in which case the section items are merged (for collisions, + last value wins). + """ + sections = _merge_dictionaries(divided_sections) + return "".join(list( + "[{name}]\n{items}\n".format( + name=name, + items="\n".join( + "{key} = {value}".format(key=key, value=_tahoe_config_quote(value)) + for (key, value) + in contents.items() + ) + ) + for (name, contents) in sections.items() + )) diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index f8fa7a9dab92f86bba84ece5a21a0e1cfeb603e4..e5e31eae14212a1b9d9f9ad716e96ae27460d786 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -91,11 +91,33 @@ class IZKAPRoot(IResource): controller = Attribute("The ``PaymentController`` used by this resource tree.") +def get_token_count( + plugin_name, + node_config, +): + """ + Retrieve the configured voucher value, in number of tokens, from the given + configuration. + + :param unicode plugin_name: The plugin name to use to choose a + configuration section. + + :param _Config node_config: See ``from_configuration``. + + :param int default: The value to return if none is configured. + """ + section_name = u"storageclient.plugins.{}".format(plugin_name) + return int(node_config.get_config( + section=section_name, + option=u"default-token-count", + default=NUM_TOKENS, + )) + + def from_configuration( node_config, store, redeemer=None, - default_token_count=None, clock=None, ): """ @@ -114,22 +136,23 @@ def from_configuration( :param IRedeemer redeemer: The voucher redeemer to use. If ``None`` a sensible one is constructed. - :param default_token_count: See ``PaymentController.default_token_count``. - :param clock: See ``PaymentController._clock``. :return IZKAPRoot: The root of the resource hierarchy presented by the client side of the plugin. """ + plugin_name = u"privatestorageio-zkapauthz-v1" if redeemer is None: redeemer = get_redeemer( - u"privatestorageio-zkapauthz-v1", + plugin_name, node_config, None, None, ) - if default_token_count is None: - default_token_count = NUM_TOKENS + default_token_count = get_token_count( + plugin_name, + node_config, + ) controller = PaymentController( store, redeemer, diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index a54282ecf2f3ed812e7635fb36af59ec90b678db..a1a24b10e9f1b4f8967e65b7825816594e17166e 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -84,6 +84,10 @@ from ..model import ( Redeemed, ) +from ..configutil import ( + config_string_from_sections, +) + # Sizes informed by # https://github.com/brave-intl/challenge-bypass-ristretto/blob/2f98b057d7f353c12b2b12d0f5ae9ad115f1d0ba/src/oprf.rs#L18-L33 @@ -96,32 +100,6 @@ _UNBLINDED_TOKEN_LENGTH = 96 # The length of a `VerificationSignature`, in bytes. _VERIFICATION_SIGNATURE_LENGTH = 64 -def _merge_dictionaries(dictionaries): - result = {} - for d in dictionaries: - result.update(d) - return result - - -def _tahoe_config_quote(text): - return text.replace(u"%", u"%%") - - -def _config_string_from_sections(divided_sections): - sections = _merge_dictionaries(divided_sections) - return u"".join(list( - u"[{name}]\n{items}\n".format( - name=name, - items=u"\n".join( - u"{key} = {value}".format(key=key, value=_tahoe_config_quote(value)) - for (key, value) - in contents.items() - ) - ) - for (name, contents) in sections.items() - )) - - def tahoe_config_texts(storage_client_plugins, shares): """ Build the text of complete Tahoe-LAFS configurations for a node. @@ -153,7 +131,7 @@ def tahoe_config_texts(storage_client_plugins, shares): ) return builds( - lambda *sections: _config_string_from_sections( + lambda *sections: config_string_from_sections( sections, ), fixed_dictionaries( @@ -270,12 +248,21 @@ def client_dummyredeemer_configurations(): }) -def client_doublespendredeemer_configurations(): +def token_counts(): + """ + Build integers that are plausible as a number of tokens to receive in + exchange for a voucher. + """ + return integers(min_value=16, max_value=2 ** 16) + + +def client_doublespendredeemer_configurations(default_token_counts=token_counts()): """ Build DoubleSpendRedeemer-using configuration values for the client-side plugin. """ - return just({ - u"redeemer": u"double-spend", + return fixed_dictionaries({ + u"redeemer": just(u"double-spend"), + u"default-token-count": default_token_counts.map(str), }) diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index 6b38da748c5e7ce8f541b72698f79c974cfab177..233f404b2668696edb0380b2d4d45c81f5415aa8 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -80,6 +80,7 @@ from hypothesis import ( ) from hypothesis.strategies import ( one_of, + none, just, fixed_dictionaries, sampled_from, @@ -128,6 +129,10 @@ from treq.testing import ( RequestTraversalAgent, ) +from allmydata.client import ( + config_from_string, +) + from .. import ( __version__ as zkapauthorizer_version, ) @@ -143,12 +148,17 @@ from ..model import ( memory_connect, ) from ..resource import ( + NUM_TOKENS, from_configuration, + get_token_count, ) from ..pricecalculator import ( PriceCalculator, ) +from ..configutil import ( + config_string_from_sections, +) from ..storage_common import ( required_passes, @@ -157,6 +167,7 @@ from ..storage_common import ( ) from .strategies import ( + direct_tahoe_configs, tahoe_configs, client_unpaidredeemer_configurations, client_doublespendredeemer_configurations, @@ -179,9 +190,6 @@ from .json import ( loads, ) -# A small number of tokens to work with in the tests. -NUM_TOKENS = 100 - TRANSIENT_ERROR = u"something went wrong, who knows what" # Helper to work-around https://github.com/twisted/treq/issues/161 @@ -278,7 +286,6 @@ def root_from_config(config, now): now, memory_connect, ), - default_token_count=NUM_TOKENS, clock=Clock(), ) @@ -337,12 +344,60 @@ def get_config_with_api_token(tempdir, get_config, api_auth_token): :param bytes api_auth_token: The HTTP API authorization token to write to the node directory. """ - FilePath(tempdir.join(b"tahoe", b"private")).makedirs() - config = get_config(tempdir.join(b"tahoe"), b"tub.port") - config.write_private_config(b"api_auth_token", api_auth_token) + basedir = tempdir.join(b"tahoe") + config = get_config(basedir, b"tub.port") + add_api_token_to_config( + basedir, + config, + api_auth_token, + ) return config +def add_api_token_to_config(basedir, config, api_auth_token): + """ + Create a private directory beneath the given base directory, point the + given config at it, and write the given API auth token to it. + """ + FilePath(basedir).child(b"private").makedirs() + config._basedir = basedir + config.write_private_config(b"api_auth_token", api_auth_token) + + +class GetTokenCountTests(TestCase): + """ + Tests for ``get_token_count``. + """ + @given(one_of(none(), integers(min_value=16))) + def test_get_token_count(self, token_count): + """ + ``get_token_count`` returns the integer value of the + ``default-token-count`` item from the given configuration object. + """ + plugin_name = u"hello-world" + if token_count is None: + expected_count = NUM_TOKENS + token_config = {} + else: + expected_count = token_count + token_config = { + u"default-token-count": u"{}".format(expected_count) + } + + config_text = _config_string_from_sections([{ + u"storageclient.plugins." + plugin_name: token_config, + }]) + node_config = config_from_string( + self.useFixture(TempDir()).join(b"tahoe"), + u"tub.port", + config_text.encode("utf-8"), + ) + self.assertThat( + get_token_count(plugin_name, node_config), + Equals(expected_count), + ) + + class ResourceTests(TestCase): """ General tests for the resources exposed by the plugin. @@ -1010,26 +1065,27 @@ class VoucherTests(TestCase): ) @given( - tahoe_configs(client_nonredeemer_configurations()), + direct_tahoe_configs(client_nonredeemer_configurations()), api_auth_tokens(), datetimes(), vouchers(), ) - def test_get_known_voucher_redeeming(self, get_config, api_auth_token, now, voucher): + def test_get_known_voucher_redeeming(self, config, api_auth_token, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the same voucher then the response code is **OK** and details, including those relevant to a voucher which is actively being redeemed, about the voucher are included in a json-encoded response body. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_get_known_voucher( - get_config, + config, api_auth_token, now, voucher, MatchesStructure( number=Equals(voucher), - expected_tokens=Equals(NUM_TOKENS), + expected_tokens=Equals(count), created=Equals(now), state=Equals(Redeeming( started=now, @@ -1039,42 +1095,43 @@ class VoucherTests(TestCase): ) @given( - tahoe_configs(client_dummyredeemer_configurations()), + direct_tahoe_configs(client_dummyredeemer_configurations()), api_auth_tokens(), datetimes(), vouchers(), ) - def test_get_known_voucher_redeemed(self, get_config, api_auth_token, now, voucher): + def test_get_known_voucher_redeemed(self, config, api_auth_token, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the same voucher then the response code is **OK** and details, including those relevant to a voucher which has been redeemed, about the voucher are included in a json-encoded response body. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_get_known_voucher( - get_config, + config, api_auth_token, now, voucher, MatchesStructure( number=Equals(voucher), - expected_tokens=Equals(NUM_TOKENS), + expected_tokens=Equals(count), created=Equals(now), state=Equals(Redeemed( finished=now, - token_count=NUM_TOKENS, + token_count=count, public_key=None, )), ), ) @given( - tahoe_configs(client_doublespendredeemer_configurations()), + direct_tahoe_configs(client_doublespendredeemer_configurations()), api_auth_tokens(), datetimes(), vouchers(), ) - def test_get_known_voucher_doublespend(self, get_config, api_auth_token, now, voucher): + def test_get_known_voucher_doublespend(self, config, api_auth_token, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the same voucher then the response code is **OK** and details, including @@ -1082,14 +1139,15 @@ class VoucherTests(TestCase): already redeemed, about the voucher are included in a json-encoded response body. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_get_known_voucher( - get_config, + config, api_auth_token, now, voucher, MatchesStructure( number=Equals(voucher), - expected_tokens=Equals(NUM_TOKENS), + expected_tokens=Equals(count), created=Equals(now), state=Equals(DoubleSpend( finished=now, @@ -1098,12 +1156,12 @@ class VoucherTests(TestCase): ) @given( - tahoe_configs(client_unpaidredeemer_configurations()), + direct_tahoe_configs(client_unpaidredeemer_configurations()), api_auth_tokens(), datetimes(), vouchers(), ) - def test_get_known_voucher_unpaid(self, get_config, api_auth_token, now, voucher): + def test_get_known_voucher_unpaid(self, config, api_auth_token, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the same voucher then the response code is **OK** and details, including @@ -1111,14 +1169,15 @@ class VoucherTests(TestCase): not been paid for yet, about the voucher are included in a json-encoded response body. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_get_known_voucher( - get_config, + config, api_auth_token, now, voucher, MatchesStructure( number=Equals(voucher), - expected_tokens=Equals(NUM_TOKENS), + expected_tokens=Equals(count), created=Equals(now), state=Equals(Unpaid( finished=now, @@ -1127,12 +1186,12 @@ class VoucherTests(TestCase): ) @given( - tahoe_configs(client_errorredeemer_configurations(TRANSIENT_ERROR)), + direct_tahoe_configs(client_errorredeemer_configurations(TRANSIENT_ERROR)), api_auth_tokens(), datetimes(), vouchers(), ) - def test_get_known_voucher_error(self, get_config, api_auth_token, now, voucher): + def test_get_known_voucher_error(self, config, api_auth_token, now, voucher): """ When a voucher is first ``PUT`` and then later a ``GET`` is issued for the same voucher then the response code is **OK** and details, including @@ -1140,14 +1199,15 @@ class VoucherTests(TestCase): kind of transient conditions, about the voucher are included in a json-encoded response body. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_get_known_voucher( - get_config, + config, api_auth_token, now, voucher, MatchesStructure( number=Equals(voucher), - expected_tokens=Equals(NUM_TOKENS), + expected_tokens=Equals(count), created=Equals(now), state=Equals(Error( finished=now, @@ -1156,7 +1216,7 @@ class VoucherTests(TestCase): ), ) - def _test_get_known_voucher(self, get_config, api_auth_token, now, voucher, voucher_matcher): + def _test_get_known_voucher(self, config, api_auth_token, now, voucher, voucher_matcher): """ Assert that a voucher that is ``PUT`` and then ``GET`` is represented in the JSON response. @@ -1164,9 +1224,9 @@ class VoucherTests(TestCase): :param voucher_matcher: A matcher which matches the voucher expected to be returned by the ``GET``. """ - config = get_config_with_api_token( - self.useFixture(TempDir()), - get_config, + add_api_token_to_config( + self.useFixture(TempDir()).join(b"tahoe"), + config, api_auth_token, ) root = root_from_config(config, lambda: now) @@ -1215,18 +1275,19 @@ class VoucherTests(TestCase): ) @given( - tahoe_configs(), + direct_tahoe_configs(), api_auth_tokens(), datetimes(), lists(vouchers(), unique=True), ) - def test_list_vouchers(self, get_config, api_auth_token, now, vouchers): + def test_list_vouchers(self, config, api_auth_token, now, vouchers): """ A ``GET`` to the ``VoucherCollection`` itself returns a list of existing vouchers. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_list_vouchers( - get_config, + config, api_auth_token, now, vouchers, @@ -1234,11 +1295,11 @@ class VoucherTests(TestCase): u"vouchers": list( Voucher( number=voucher, - expected_tokens=NUM_TOKENS, + expected_tokens=count, created=now, state=Redeemed( finished=now, - token_count=NUM_TOKENS, + token_count=count, public_key=None, ), ).marshal() @@ -1249,18 +1310,19 @@ class VoucherTests(TestCase): ) @given( - tahoe_configs(client_unpaidredeemer_configurations()), + direct_tahoe_configs(client_unpaidredeemer_configurations()), api_auth_tokens(), datetimes(), lists(vouchers(), unique=True), ) - def test_list_vouchers_transient_states(self, get_config, api_auth_token, now, vouchers): + def test_list_vouchers_transient_states(self, config, api_auth_token, now, vouchers): """ A ``GET`` to the ``VoucherCollection`` itself returns a list of existing vouchers including state information that reflects transient states. """ + count = get_token_count("privatestorageio-zkapauthz-v1", config) return self._test_list_vouchers( - get_config, + config, api_auth_token, now, vouchers, @@ -1268,7 +1330,7 @@ class VoucherTests(TestCase): u"vouchers": list( Voucher( number=voucher, - expected_tokens=NUM_TOKENS, + expected_tokens=count, created=now, state=Unpaid( finished=now, @@ -1280,14 +1342,14 @@ class VoucherTests(TestCase): }), ) - def _test_list_vouchers(self, get_config, api_auth_token, now, vouchers, match_response_object): - config = get_config_with_api_token( + def _test_list_vouchers(self, config, api_auth_token, now, vouchers, match_response_object): + add_api_token_to_config( # Hypothesis causes our test case instances to be re-used many # times between setUp and tearDown. Avoid re-using the same # temporary directory for every Hypothesis iteration because this # test leaves state behind that invalidates future iterations. - self.useFixture(TempDir()), - get_config, + self.useFixture(TempDir()).join(b"tahoe"), + config, api_auth_token, ) root = root_from_config(config, lambda: now) diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index e0c4e8caae5a98f1f1d123e917044b79d0b321ad..0349dde406b8b78cc06b452b57ac8647d6a7b2dd 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -551,7 +551,6 @@ class ClientResourceTests(TestCase): self.assertThat( storage_server.get_client_resource( config, - default_token_count=10, reactor=Clock(), ), Provides([IResource]),