diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index ec72c8d740b6548facfd5a98f2a09e100ed03c5c..5348713dbd54d193312df82f7216be7bd1d93594 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,9 +30,17 @@ from zope.interface import ( implementer, ) +from twisted.python.url import ( + URL, +) from twisted.internet.defer import ( Deferred, succeed, + inlineCallbacks, + returnValue, +) +from treq import ( + json_content, ) import privacypass @@ -39,11 +49,20 @@ from .foolscap import ( TOKEN_LENGTH, ) from .model import ( - Pass, RandomToken, + Voucher, + Pass, ) +class TransientRedemptionError(Exception): + pass + + +class PermanentRedemptionError(Exception): + pass + + class IRedeemer(Interface): """ An ``IRedeemer`` can exchange a voucher for one or more passes. @@ -96,7 +115,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) ) @@ -121,7 +140,7 @@ class DummyRedeemer(object): """ # Dummy token generation. return list( - RandomToken(u"{}-{}".format(voucher, n)) + RandomToken(u"{}-{}".format(voucher.number, n)) for n in range(count) ) @@ -143,7 +162,8 @@ class DummyRedeemer(object): @implementer(IRedeemer) @attr.s class RistrettoRedeemer(object): - _agent = attr.ib() + _treq = attr.ib() + _api_root = attr.ib(validator=attr.validators.instance_of(URL)) def random_tokens_for_voucher(self, voucher, count): return list( @@ -152,14 +172,83 @@ class RistrettoRedeemer(object): in range(count) ) - def redeem(self, voucher, random_tokens): - # The wrong implementation, of course. - return succeed(list( - Pass(text=u"tok-" + token.token_value) + @inlineCallbacks + def redeem(self, voucher, encoded_random_tokens): + random_tokens = list( + privacypass.RandomToken.decode_base64(token.token_value.encode("ascii")) for token - in random_tokens + 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 + ), + }), + ) + result = yield json_content(response) + 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( + Pass(text=unblinded_token.encode_base64().decode("ascii")) + for unblinded_token + in clients_unblinded_tokens )) + def tokens_to_passes(self, message, unblinded_tokens): + clients_preimages = list( + token.preimage() + for token + in unblinded_tokens + ) + clients_verification_keys = list( + token.derive_verification_key_sha512() + for token + in unblinded_tokens + ) + clients_passes = zip( + clients_preimages, ( + verification_key.sign_sha512(message) + for verification_key + in clients_verification_keys + ), + ) + marshaled_passes = list( + ( + token_preimage.encode_base64(), + sig.encode_base64() + ) + for (token_preimage, sig) + in clients_passes + ) + return marshaled_passes + @attr.s class PaymentController(object): @@ -183,6 +272,9 @@ class PaymentController(object): 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 @@ -192,13 +284,13 @@ 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) + tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), 100) # Persist the voucher and tokens so they're available if we fail. self.store.add(voucher, tokens) # Ask the redeemer to do the real task of redemption. - d = self.redeemer.redeem(voucher, tokens) + d = self.redeemer.redeem(Voucher(voucher), tokens) d.addCallback( partial(self._redeemSuccess, voucher), ) diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py index 318d96426bbd2496fb36b11055a14a53b7fe0918..636a7e33cb9ddf77e660e4f89004d4845bfdc96a 100644 --- a/src/_zkapauthorizer/tests/test_controller.py +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -23,18 +23,24 @@ from json import ( from zope.interface import ( implementer, ) +import attr 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 ( @@ -47,18 +53,32 @@ from hypothesis import ( from hypothesis.strategies import ( integers, ) +from twisted.python.url import ( + URL, +) from twisted.internet.defer import ( fail, + succeed, ) from twisted.web.iweb import ( IAgent, + IBodyProducer, ) from twisted.web.resource import ( Resource, ) from treq.testing import ( - RequestTraversalAgent, + StubTreq, ) + +from privacypass import ( + SecurityException, + PublicKey, + BlindedToken, + BatchDLEQProof, + random_signing_key, +) + from ..controller import ( IRedeemer, NonRedeemer, @@ -70,6 +90,7 @@ from ..controller import ( from ..model import ( memory_connect, VoucherStore, + Voucher, Pass, ) @@ -134,6 +155,8 @@ class PaymentControllerTests(TestCase): ) +NOWHERE = URL.from_text(u"https://127.0.0.1/") + class RistrettoRedeemerTests(TestCase): """ Tests for ``RistrettoRedeemer``. @@ -142,39 +165,28 @@ class RistrettoRedeemerTests(TestCase): """ An ``RistrettoRedeemer`` instance provides ``IRedeemer``. """ - redeemer = RistrettoRedeemer(stub_agent()) + redeemer = RistrettoRedeemer(stub_agent(), NOWHERE) self.assertThat( redeemer, Provides([IRedeemer]), ) - @given(vouchers(), integers(min_value=1, max_value=100)) - def test_redemption(self, voucher, num_tokens): + @given(vouchers().map(Voucher), integers(min_value=1, max_value=100)) + def test_good_ristretto_redemption(self, voucher, num_tokens): """ - ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a list - of ``Pass`` instances. + If the issuer returns a successful result then + ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a + list of ``Pass`` instances. """ - public_key = u"pub foo-bar" - signatures = list(u"sig-{}".format(n) for n in range(num_tokens)) - proof = u"proof bar-foo" - - issuer = SuccessfulRedemption(public_key, signatures, proof) - agent = agent_for_loopback_ristretto(issuer) - redeemer = RistrettoRedeemer(agent) + 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) - # The redeemer gives back the requested number of tokens. - self.expectThat( - len(random_tokens), - Equals(num_tokens), - ) d = redeemer.redeem( voucher, random_tokens, ) - # Perform some very basic checks on the results. We won't verify the - # crypto here since we don't have a real Ristretto server. Such - # checks would fail. Some integration tests will verify that part of - # things. self.assertThat( d, succeeded( @@ -187,16 +199,46 @@ class RistrettoRedeemerTests(TestCase): ), ) + @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 then + ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a + list of ``Pass`` instances. + """ + signing_key = random_signing_key() + issuer = RistrettoRedemption(signing_key) + # Make it lie about the public key it is using. + 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), + ), + ), + ) + -def agent_for_loopback_ristretto(local_issuer): +def treq_for_loopback_ristretto(local_issuer): """ - Create an ``IAgent`` which can dispatch to a 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 RequestTraversalAgent(root) + return StubTreq(root) class SuccessfulRedemption(Resource): @@ -228,3 +270,45 @@ class _StubAgent(object): 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): + 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, + })