diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 1bc586e1d65f7ba536a0dd0f7e96c33facaa2650..5768b34bf8e9bf3e26aef80c027c6d589bf64e3d 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -150,7 +150,7 @@ class IRedeemer(Interface): anonymity property of the system. """ - def redeem(voucher, random_tokens): + def redeemWithCounter(voucher, counter, random_tokens): """ Redeem a voucher for unblinded tokens which can be used to construct passes. @@ -162,6 +162,13 @@ class IRedeemer(Interface): :param Voucher voucher: The voucher to redeem. + :param int counter: The counter to use in this redemption attempt. To + support vouchers which can be redeemed for a larger number of + tokens than is practical to handle at once, one voucher can be + partially redeemed repeatedly until the complete set of tokens has + been received. Each partial redemption must have a distinct + counter value. + :param list[RandomToken] random_tokens: The random tokens to use in the redemption process. @@ -204,7 +211,7 @@ class NonRedeemer(object): def random_tokens_for_voucher(self, voucher, count): return dummy_random_tokens(voucher, count) - def redeem(self, voucher, random_tokens): + def redeemWithCounter(self, voucher, counter, random_tokens): # Don't try to redeem them. return Deferred() @@ -234,7 +241,7 @@ class ErrorRedeemer(object): def random_tokens_for_voucher(self, voucher, count): return dummy_random_tokens(voucher, count) - def redeem(self, voucher, random_tokens): + def redeemWithCounter(self, voucher, counter, random_tokens): return fail(Exception(self.details)) def tokens_to_passes(self, message, unblinded_tokens): @@ -257,7 +264,7 @@ class DoubleSpendRedeemer(object): def random_tokens_for_voucher(self, voucher, count): return dummy_random_tokens(voucher, count) - def redeem(self, voucher, random_tokens): + def redeemWithCounter(self, voucher, counter, random_tokens): return fail(AlreadySpent(voucher)) @@ -275,7 +282,7 @@ class UnpaidRedeemer(object): def random_tokens_for_voucher(self, voucher, count): return dummy_random_tokens(voucher, count) - def redeem(self, voucher, random_tokens): + def redeemWithCounter(self, voucher, counter, random_tokens): return fail(Unpaid(voucher)) @@ -317,7 +324,7 @@ class DummyRedeemer(object): """ return dummy_random_tokens(voucher, count) - def redeem(self, voucher, random_tokens): + def redeemWithCounter(self, voucher, counter, random_tokens): """ :return: An already-fired ``Deferred`` that has a list of ``UnblindedToken`` instances wrapping meaningless values. @@ -434,7 +441,7 @@ class RistrettoRedeemer(object): ) @inlineCallbacks - def redeem(self, voucher, encoded_random_tokens): + def redeemWithCounter(self, voucher, counter, encoded_random_tokens): random_tokens = list( challenge_bypass_ristretto.RandomToken.decode_base64(token.token_value.encode("ascii")) for token @@ -445,6 +452,7 @@ class RistrettoRedeemer(object): self._api_root.child(u"v1", u"redeem").to_text(), dumps({ u"redeemVoucher": voucher.number, + u"redeemCounter": counter, u"redeemTokens": list( token.encode_base64() for token @@ -649,7 +657,7 @@ class PaymentController(object): voucher=voucher.number, ) - def _perform_redeem(self, voucher, random_tokens): + def _perform_redeem(self, voucher, counter, random_tokens): """ Use the redeemer to redeem the given voucher and random tokens. @@ -661,7 +669,7 @@ class PaymentController(object): d = bracket( lambda: setitem(self._active, voucher, self.store.now()), lambda: delitem(self._active, voucher), - lambda: self.redeemer.redeem(Voucher(voucher), random_tokens), + lambda: self.redeemer.redeemWithCounter(Voucher(voucher), counter, random_tokens), ) d.addCallbacks( partial(self._redeemSuccess, voucher), @@ -698,7 +706,14 @@ class PaymentController(object): if num_tokens is None: num_tokens = self.default_token_count tokens = self._get_random_tokens_for_voucher(voucher, num_tokens) - return self._perform_redeem(voucher, 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. + # + # https://github.com/PrivateStorageio/ZKAPAuthorizer/issues/124 + return self._perform_redeem(voucher, 0, tokens) def _redeemSuccess(self, voucher, result): """ diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index aa66032df93a5a6984c5d5edd6c601e234d5c2ed..b648c5c5778e9d7d3d44572ecf5f05657e85c228 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -343,6 +343,19 @@ def voucher_objects(states=voucher_states()): ) +def voucher_counters(): + """ + Build integers usable as counters in the voucher redemption process. + """ + return integers( + min_value=0, + # This may or may not be the actual maximum value accepted by a + # PaymentServer. If it is not exactly the maximum, it's probably at + # least in the same ballpark. + max_value=256, + ) + + def byte_strings(label, length, entropy): """ Build byte strings of the given length with at most the given amount of diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 1c1af180ec01737e5e699da40f4575047273de27..608ef72679232b29a3681b224d2eb61f98f2fe2e 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -60,6 +60,8 @@ from hypothesis import ( from hypothesis.strategies import ( integers, datetimes, + lists, + sampled_from, ) from twisted.python.url import ( URL, @@ -74,6 +76,9 @@ from twisted.web.resource import ( ErrorPage, Resource, ) +from twisted.web.http_headers import ( + Headers, +) from twisted.web.http import ( UNSUPPORTED_MEDIA_TYPE, BAD_REQUEST, @@ -116,6 +121,7 @@ from .strategies import ( tahoe_configs, vouchers, voucher_objects, + voucher_counters, dummy_ristretto_keys, clocks, ) @@ -302,8 +308,8 @@ class RistrettoRedeemerTests(TestCase): Provides([IRedeemer]), ) - @given(voucher_objects(), integers(min_value=1, max_value=100)) - def test_good_ristretto_redemption(self, voucher, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) + def test_good_ristretto_redemption(self, voucher, counter, num_tokens): """ If the issuer returns a successful result then ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a @@ -314,8 +320,9 @@ class RistrettoRedeemerTests(TestCase): treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) - d = redeemer.redeem( + d = redeemer.redeemWithCounter( voucher, + counter, random_tokens, ) self.assertThat( @@ -335,8 +342,8 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), integers(min_value=1, max_value=100)) - def test_redemption_denied_alreadyspent(self, voucher, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) + def test_redemption_denied_alreadyspent(self, voucher, counter, num_tokens): """ If the issuer declines to allow the voucher to be redeemed and gives a reason that the voucher has already been spent, ``RistrettoRedeem`` @@ -347,8 +354,9 @@ class RistrettoRedeemerTests(TestCase): treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) - d = redeemer.redeem( + d = redeemer.redeemWithCounter( voucher, + counter, random_tokens, ) self.assertThat( @@ -361,8 +369,8 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), integers(min_value=1, max_value=100)) - def test_redemption_denied_unpaid(self, voucher, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) + def test_redemption_denied_unpaid(self, voucher, counter, num_tokens): """ If the issuer declines to allow the voucher to be redeemed and gives a reason that the voucher has not been paid for, ``RistrettoRedeem`` @@ -373,8 +381,9 @@ class RistrettoRedeemerTests(TestCase): treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) - d = redeemer.redeem( + d = redeemer.redeemWithCounter( voucher, + counter, random_tokens, ) self.assertThat( @@ -387,8 +396,8 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), integers(min_value=1, max_value=100)) - def test_bad_ristretto_redemption(self, voucher, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) + def test_bad_ristretto_redemption(self, voucher, counter, num_tokens): """ If the issuer returns a successful result with an invalid proof then ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a @@ -405,8 +414,9 @@ class RistrettoRedeemerTests(TestCase): treq = treq_for_loopback_ristretto(issuer) redeemer = RistrettoRedeemer(treq, NOWHERE) random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) - d = redeemer.redeem( + d = redeemer.redeemWithCounter( voucher, + counter, random_tokens, ) self.addDetail(u"redeem Deferred", text_content(str(d))) @@ -420,8 +430,8 @@ class RistrettoRedeemerTests(TestCase): ), ) - @given(voucher_objects(), integers(min_value=1, max_value=100)) - def test_ristretto_pass_construction(self, voucher, num_tokens): + @given(voucher_objects(), voucher_counters(), integers(min_value=1, max_value=100)) + def test_ristretto_pass_construction(self, voucher, counter, num_tokens): """ The passes constructed using unblinded tokens and messages pass the Ristretto verification check. @@ -434,8 +444,9 @@ class RistrettoRedeemerTests(TestCase): redeemer = RistrettoRedeemer(treq, NOWHERE) random_tokens = redeemer.random_tokens_for_voucher(voucher, num_tokens) - d = redeemer.redeem( + d = redeemer.redeemWithCounter( voucher, + counter, random_tokens, ) def unblinded_tokens_to_passes(result): @@ -541,8 +552,9 @@ class AlreadySpentRedemption(Resource): that the voucher has already been redeemed. """ def render_POST(self, request): - if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]: - return bad_content_type(request) + request_error = check_redemption_request(request) + if request_error is not None: + return request_error return bad_request(request, {u"success": False, u"reason": u"double-spend"}) @@ -554,8 +566,9 @@ class UnpaidRedemption(Resource): the voucher has not been paid for. """ def render_POST(self, request): - if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]: - return bad_content_type(request) + request_error = check_redemption_request(request) + if request_error is not None: + return request_error return bad_request(request, {u"success": False, u"reason": u"unpaid"}) @@ -567,8 +580,9 @@ class RistrettoRedemption(Resource): 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_error = check_redemption_request(request) + if request_error is not None: + return request_error request_body = loads(request.content.read()) marshaled_blinded_tokens = request_body[u"redeemTokens"] @@ -605,6 +619,118 @@ class RistrettoRedemption(Resource): }) +class CheckRedemptionRequestTests(TestCase): + """ + Tests for ``check_redemption_request``. + """ + def test_content_type(self): + """ + If the request content-type is not application/json, the response is + **Unsupported Media Type**. + """ + issuer = UnpaidRedemption() + treq = treq_for_loopback_ristretto(issuer) + d = treq.post( + NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"), + b"{}", + ) + self.assertThat( + d, + succeeded( + AfterPreprocessing( + lambda response: response.code, + Equals(UNSUPPORTED_MEDIA_TYPE), + ), + ), + ) + + def test_not_json(self): + """ + If the request body cannot be decoded as json, the response is **Bad + Request**. + """ + issuer = UnpaidRedemption() + treq = treq_for_loopback_ristretto(issuer) + d = treq.post( + NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"), + b"foo", + headers=Headers({u"content-type": [u"application/json"]}), + ) + self.assertThat( + d, + succeeded( + AfterPreprocessing( + lambda response: response.code, + Equals(BAD_REQUEST), + ), + ), + ) + + @given( + lists( + sampled_from( + [u"redeemVoucher", u"redeemCounter", u"redeemTokens"], + ), + # Something must be missing if the length is no longer than 2 + # because there are 3 required properties. + max_size=2, + unique=True, + ), + ) + def test_missing_properties(self, properties): + """ + If the JSON object in the request body does not include all the necessary + properties, the response is **Bad Request**. + """ + issuer = UnpaidRedemption() + treq = treq_for_loopback_ristretto(issuer) + d = treq.post( + NOWHERE.child(u"v1", u"redeem").to_text().encode("ascii"), + dumps(dict.fromkeys(properties)), + headers=Headers({u"content-type": [u"application/json"]}), + ) + self.assertThat( + d, + succeeded( + AfterPreprocessing( + lambda response: response.code, + Equals(BAD_REQUEST), + ), + ), + ) + +def check_redemption_request(request): + """ + Verify that the given request conforms to the redemption server's public + interface. + """ + if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]: + return bad_content_type(request) + + p = request.content.tell() + content = request.content.read() + request.content.seek(p) + + try: + request_body = loads(content) + except ValueError: + return bad_request(request, None) + + expected_keys = {u"redeemVoucher", u"redeemCounter", u"redeemTokens"} + actual_keys = set(request_body.keys()) + if expected_keys != actual_keys: + return bad_request( + request, { + u"success": False, + u"reason": u"{} != {}".format( + expected_keys, + actual_keys, + ), + }, + ) + return None + + def bad_request(request, body_object): request.setResponseCode(BAD_REQUEST) request.setHeader(b"content-type", b"application/json")