diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 9d9663b92b0b9a8f7c4b24a83db3653c27801ba1..df0c81dd3fabd08635220d44419dd1cf41177364 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -145,6 +145,8 @@ class IRedeemer(Interface): :param Voucher voucher: The voucher the tokens will be associated with. + :param int counter: See ``redeemWithCounter``. + :param int count: The number of random tokens to generate. :return list[RandomToken]: The generated tokens. Random tokens must @@ -203,6 +205,26 @@ class IRedeemer(Interface): """ +@attr.s +@implementer(IRedeemer) +class IndexedRedeemer(object): + """ + A ``IndexedRedeemer`` delegates redemption to a redeemer chosen to + correspond to the redemption counter given. + """ + redeemers = attr.ib() + + def random_tokens_for_voucher(self, voucher, counter, count): + return dummy_random_tokens(voucher, counter, count) + + def redeemWithCounter(self, voucher, counter, random_tokens): + return self.redeemers[counter].redeemWithCounter( + voucher, + counter, + random_tokens, + ) + + @implementer(IRedeemer) class NonRedeemer(object): """ @@ -636,6 +658,15 @@ class PaymentController(object): which have recently failed a redemption attempt due to an unpaid response from the redemption server to timestamps when the failure was observed. + + :ivar int num_redemption_groups: The number of groups into which to divide + tokens during the redemption process, with each group being redeemed + separately from the rest. This value needs to agree with the value + the PaymentServer is configured with. + + TODO: Retrieve this value from the PaymentServer or from the + ZKAPAuthorizer configuration instead of just hard-coding a duplicate + value in this implementation. """ _log = Logger() @@ -643,6 +674,8 @@ class PaymentController(object): redeemer = attr.ib() default_token_count = attr.ib() + num_redemption_groups = attr.ib(default=16) + _clock = attr.ib( default=attr.Factory(partial(namedAny, "twisted.internet.reactor")), ) @@ -730,17 +763,16 @@ class PaymentController(object): lambda: self.redeemer.redeemWithCounter(voucher.number, counter, random_tokens), ) d.addCallbacks( - partial(self._redeemSuccess, voucher.number), - partial(self._redeemFailure, voucher.number), + partial(self._redeem_success, voucher.number, counter), + partial(self._redeem_failure, voucher.number), ) - d.addErrback(partial(self._finalRedeemError, voucher.number)) + d.addErrback(partial(self._final_redeem_error, voucher.number)) return d - def _get_random_tokens_for_voucher(self, voucher, num_tokens): + def _get_random_tokens_for_voucher(self, voucher, counter, num_tokens): """ Generate or load random tokens for a redemption attempt of a voucher. """ - counter = 0 def get_tokens(): self._log.info( "Generating random tokens for a voucher ({voucher}).", @@ -754,34 +786,46 @@ class PaymentController(object): return self.store.add(voucher, counter, get_tokens) + @inlineCallbacks def redeem(self, voucher, num_tokens=None): """ :param unicode voucher: A voucher to redeem. :param int num_tokens: A number of tokens 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 - # server deems the voucher redeemed but before we persist the result. - # With a stable set of tokens, we can re-submit them and the server - # can re-sign them without fear of issuing excess passes. Whether the - # 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. if num_tokens is None: num_tokens = self.default_token_count - tokens = self._get_random_tokens_for_voucher(voucher, num_tokens) + # TODO: Actually count up from the voucher's current counter value to - # maxCounter instead of only passing 0 here. Starting at 0 is fine - # for a new voucher but if we partially redeemed a voucher on a - # previous run and this call comes from `_check_pending_vouchers` then - # we should skip any already-redeemed counter values. + # num_redemption_groups instead of only passing 0 here. Starting at 0 + # is fine for a new voucher but if we partially redeemed a voucher on + # a previous run and this call comes from `_check_pending_vouchers` + # then we should skip any already-redeemed counter values. # # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/124 - return self._perform_redeem(self.store.get(voucher), 0, tokens) - - def _redeemSuccess(self, voucher, result): + for counter in range(0, self.num_redemption_groups): + # 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 + # server deems the voucher redeemed but before we persist the result. + # With a stable set of tokens, we can re-submit them and the server + # can re-sign them without fear of issuing excess passes. Whether the + # 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. + token_count = token_count_for_group(self.num_redemption_groups, num_tokens, counter) + tokens = self._get_random_tokens_for_voucher(voucher, counter, token_count) + + # Reload state before each iteration. We expect it to change each time. + voucher_obj = self.store.get(voucher) + + if not voucher_obj.state.should_start_redemption(): + # An earlier iteration may have encountered a fatal error. + break + + yield self._perform_redeem(voucher_obj, counter, tokens) + + def _redeem_success(self, voucher, counter, result): """ Update the database state to reflect that a voucher was redeemed and to store the resulting unblinded tokens (which can be used to construct @@ -795,9 +839,10 @@ class PaymentController(object): voucher, result.public_key, result.unblinded_tokens, + completed=(counter + 1 == self.num_redemption_groups), ) - def _redeemFailure(self, voucher, reason): + def _redeem_failure(self, voucher, reason): if reason.check(AlreadySpent): self._log.error( "Voucher {voucher} reported as already spent during redemption.", @@ -822,7 +867,7 @@ class PaymentController(object): ) return None - def _finalRedeemError(self, voucher, reason): + def _final_redeem_error(self, voucher, reason): self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher) return None diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 0db7e74b7643d770727408fb7228eac226a539b9..8eb85ee47f3a645f110cd3d5de297a5576303c2f 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -341,7 +341,7 @@ class VoucherStore(object): self._insert_unblinded_tokens(cursor, unblinded_tokens) @with_cursor - def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens): + def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed): """ Store some unblinded tokens received from redemption of a voucher. @@ -354,15 +354,23 @@ class VoucherStore(object): :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to store. + + :param bool completed: ``True`` if redemption of this voucher is now + complete, ``False`` otherwise. """ - voucher_state = u"redeemed" + if completed: + voucher_state = u"redeemed" + else: + voucher_state = u"pending" + cursor.execute( """ UPDATE [vouchers] SET [state] = ? - , [token-count] = ? + , [token-count] = COALESCE([token-count], 0) + ? , [finished] = ? , [public-key] = ? + , [counter] = [counter] + 1 WHERE [number] = ? """, ( @@ -901,9 +909,7 @@ class Voucher(object): def from_row(cls, row): def state_from_row(state, row): if state == u"pending": - # TODO: The 0 here should be row[3] but I can't write a test - # to prove it yet. - return Pending(counter=0) + return Pending(counter=row[3]) if state == u"double-spend": return DoubleSpend( parse_datetime(row[0], delimiter=u" "), diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 678d0d7d6c25524413c248d9048cc0b948dd8409..2b3e8921f96aa535768bbe162ebb3cf806c9a4b6 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -52,6 +52,7 @@ from testtools.matchers import ( ) from testtools.twistedsupport import ( succeeded, + has_no_result, failed, ) @@ -106,6 +107,7 @@ from ..controller import ( DoubleSpendRedeemer, UnpaidRedeemer, RistrettoRedeemer, + IndexedRedeemer, PaymentController, AlreadySpent, Unpaid, @@ -230,26 +232,47 @@ class PaymentControllerTests(TestCase): Equals(model_Pending(counter=0)), ) - @given(tahoe_configs(), datetimes(), vouchers()) - def test_redeeming(self, get_config, now, voucher): + @given(tahoe_configs(), datetimes(), vouchers(), voucher_counters()) + def test_redeeming(self, get_config, now, voucher, num_successes): """ A ``Voucher`` is marked redeeming while ``IRedeemer.redeem`` is actively - working on redeeming it. + working on redeeming it with a counter value that reflects the number + of successful partial redemptions so far completed. """ + # The voucher counter can be zero (no tries yet succeeded). We want + # at least *one* run through so we'll bump this up to be sure we get + # that. + counter = num_successes + 1 + + success_redeemers = [DummyRedeemer()] * num_successes + hang_redeemers = [NonRedeemer()] + redeemers = success_redeemers + hang_redeemers + # A redeemer which will succeed `num_successes` times and then hang on + # the next attempt. + redeemer = IndexedRedeemer(redeemers) + store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store controller = PaymentController( store, - NonRedeemer(), - default_token_count=100, + redeemer, + # This will give us one ZKAP per attempt. + default_token_count=counter, + # Require more success than we're going to get so it doesn't + # finish. + num_redemption_groups=counter, + ) + + self.assertThat( + controller.redeem(voucher), + has_no_result(), ) - controller.redeem(voucher) controller_voucher = controller.get_voucher(voucher) self.assertThat( controller_voucher.state, Equals(model_Redeeming( started=now, - counter=0, + counter=num_successes, )), ) diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 3b0a1f52f86252d235b9b9520289fe7f17854a49..0e5ebd3e971de668d3ca3de36e356d943d62531f 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -57,6 +57,7 @@ from hypothesis import ( from hypothesis.strategies import ( data, + booleans, lists, tuples, datetimes, @@ -345,6 +346,7 @@ class VoucherStoreTests(TestCase): voucher_value, public_key, unblinded_tokens, + completed=data.draw(booleans()), ) backed_up_tokens = store.backup()[u"unblinded-tokens"] @@ -484,8 +486,9 @@ class UnblindedTokenStoreTests(TestCase): vouchers(), dummy_ristretto_keys(), lists(unblinded_tokens(), unique=True), + booleans(), ) - def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens): + def test_unblinded_tokens_without_voucher(self, get_config, now, voucher_value, public_key, unblinded_tokens, completed): """ Unblinded tokens for a voucher which has not been added to the store cannot be inserted. """ @@ -495,6 +498,7 @@ class UnblindedTokenStoreTests(TestCase): voucher_value, public_key, unblinded_tokens, + completed, ), raises(ValueError), ) @@ -504,16 +508,17 @@ class UnblindedTokenStoreTests(TestCase): datetimes(), vouchers(), dummy_ristretto_keys(), + booleans(), data(), ) - def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, data): + def test_unblinded_tokens_round_trip(self, get_config, now, voucher_value, public_key, completed, data): """ Unblinded tokens that are added to the store can later be retrieved. """ random_tokens, unblinded_tokens = paired_tokens(data) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, 0, lambda: random_tokens) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed) retrieved_tokens = store.extract_unblinded_tokens(len(random_tokens)) self.expectThat( @@ -559,7 +564,7 @@ class UnblindedTokenStoreTests(TestCase): store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, 0, lambda: random) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True) loaded_voucher = store.get(voucher_value) self.assertThat( loaded_voucher, @@ -626,7 +631,7 @@ class UnblindedTokenStoreTests(TestCase): ) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, 0, lambda: random) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True) self.assertThat( lambda: store.mark_voucher_double_spent(voucher_value), raises(ValueError), @@ -652,11 +657,12 @@ class UnblindedTokenStoreTests(TestCase): datetimes(), vouchers(), dummy_ristretto_keys(), + booleans(), integers(min_value=1, max_value=100), integers(min_value=1), data(), ) - def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, num_tokens, extra, data): + def test_not_enough_unblinded_tokens(self, get_config, now, voucher_value, public_key, completed, num_tokens, extra, data): """ ``extract_unblinded_tokens`` raises ``NotEnoughTokens`` if ``count`` is greater than the number of unblinded tokens in the store. @@ -679,7 +685,7 @@ class UnblindedTokenStoreTests(TestCase): ) store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store store.add(voucher_value, 0, lambda: random) - store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded) + store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed) self.assertThat( lambda: store.extract_unblinded_tokens(num_tokens + extra), diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index 48f8cfcee9ceed36b72dc9df7dd42953c1eee11f..18dcebc3ddaef76e26fbd04386acda5d66503bbe 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -418,12 +418,17 @@ class ClientPluginTests(TestCase): b"tub.port", ) + # Give it enough for the allocate_buckets call below. + token_count = required_passes(BYTES_PER_PASS, [size] * len(sharenums)) + # And few enough redemption groups given the number of tokens. + num_redemption_groups = token_count + store = VoucherStore.from_node_config(node_config, lambda: now) controller = PaymentController( store, DummyRedeemer(), - # Give it enough for the allocate_buckets call below. - required_passes(BYTES_PER_PASS, [size] * len(sharenums)), + default_token_count=token_count, + num_redemption_groups=num_redemption_groups, ) # Get a token inserted into the store. redeeming = controller.redeem(voucher)