diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 429805b5bb222830b5b3cd0b65bebe508712b74a..2b005112013de69ae1bbc6b47d9c44e53812d447 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -33,7 +33,9 @@ from json import ( from datetime import ( timedelta, ) - +from base64 import ( + b64encode, +) import attr from zope.interface import ( @@ -72,6 +74,10 @@ from treq.client import ( import privacypass +from ._base64 import ( + urlsafe_b64decode, +) + from .model import ( RandomToken, UnblindedToken, @@ -177,12 +183,7 @@ class NonRedeemer(object): return cls() 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.number, n)) - for n - in range(count) - ) + return dummy_random_tokens(voucher, count) def redeem(self, voucher, random_tokens): # Don't try to redeem them. @@ -260,8 +261,17 @@ class UnpaidRedeemer(object): def dummy_random_tokens(voucher, count): + v = urlsafe_b64decode(voucher.number.encode("ascii")) + def dummy_random_token(n): + return RandomToken( + # Padding is 96 (random token length) - 32 (decoded voucher + # length) + b64encode( + v + u"{:0>64}".format(n).encode("ascii"), + ).decode("ascii"), + ) return list( - RandomToken(u"{}-{}".format(voucher.number, n)) + dummy_random_token(n) for n in range(count) ) diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 50c84cdbf702a08bae984ae8402e215a2113c58a..79ffe1c01e10622eb8e295e9eb3567a9a0cc7592 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -667,7 +667,17 @@ class Pass(object): @attr.s(frozen=True) class RandomToken(object): - token_value = attr.ib(validator=attr.validators.instance_of(unicode)) + """ + :ivar unicode token_value: The base64-encoded representation of the random + token. + """ + token_value = attr.ib( + validator=attr.validators.and_( + attr.validators.instance_of(unicode), + is_base64_encoded(), + has_length(128), + ), + ) @attr.s(frozen=True) diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 4ba150cd8589c160bf895fc4dc6277100f93ca8e..c0953e40ac1a319b5852d80cd46a7344306d41f0 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -17,6 +17,7 @@ Hypothesis strategies for property testing. """ from base64 import ( + b64encode, urlsafe_b64encode, ) from datetime import ( @@ -77,6 +78,11 @@ from ..model import ( Redeemed, ) +# Sizes informed by +# https://github.com/brave-intl/challenge-bypass-ristretto/blob/2f98b057d7f353c12b2b12d0f5ae9ad115f1d0ba/src/oprf.rs#L18-L33 + +# The length of a `Token`, in bytes. +_TOKEN_LENGTH = 96 def _merge_dictionaries(dictionaries): result = {} @@ -296,15 +302,36 @@ def voucher_objects(): ) -def random_tokens(): +def byte_strings(label, length, entropy): """ - Build random tokens as unicode strings. + Build byte strings of the given length with at most the given amount of + entropy. + + These are cheaper for Hypothesis to construct than byte strings where + potentially the entire length is random. """ + if len(label) + entropy > length: + raise ValueError("Entropy and label don't fit into {} bytes".format( + length, + )) return binary( - min_size=32, - max_size=32, + min_size=entropy, + max_size=entropy, ).map( - urlsafe_b64encode, + lambda bs: label + b"x" * (length - entropy - len(label)) + bs, + ) + + +def random_tokens(): + """ + Build ``RandomToken`` instances. + """ + return byte_strings( + label=b"random-tokens", + length=_TOKEN_LENGTH, + entropy=4, + ).map( + b64encode, ).map( lambda token: RandomToken(token.decode("ascii")), )