diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 7e3302d7953be1cc1e462f7caab63253a4f0b4c7..f6af30064c04fcf777152c7634d78b0830299040 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -17,11 +17,164 @@ This module implements controllers (in the MVC sense) for the web interface for the client side of the storage plugin. """ +from functools import ( + partial, +) + import attr +from zope.interface import ( + Interface, + implementer, +) + +from twisted.internet.defer import ( + Deferred, + succeed, +) + +from .model import ( + Pass, + RandomToken, +) + + +class IRedeemer(Interface): + """ + An ``IRedeemer`` can exchange a voucher for one or more passes. + """ + def random_tokens_for_voucher(voucher, count): + """ + Generate a number of random tokens to use in the redemption process for + the given voucher. + + :param Voucher voucher: The voucher the tokens will be associated + with. + + :param int count: The number of random tokens to generate. + + :return list[RandomToken]: The generated tokens. Random tokens must + be unique over the lifetime of the Tahoe-LAFS node where this + plugin is being used but the same tokens *may* be generated for + the same voucher. The tokens must be kept secret to preserve the + anonymity property of the system. + """ + + def redeem(voucher, random_tokens): + """ + Redeem a voucher for passes. + + Implementations of this method do not need to be fault tolerant. If a + redemption attempt is interrupted before it completes, it is the + caller's responsibility to call this method again with the same + arguments. + + :param Voucher voucher: The voucher to redeem. + + :param list[RandomToken] random_tokens: The random tokens to use in + the redemption process. + + :return: A ``Deferred`` which fires with a list of ``Pass`` instances + on successful redemption or which fails with + ``TransientRedemptionError`` on any error which may be resolved by + simply trying again later or which fails with + ``PermanentRedemptionError`` on any error which is definitive and + final. + """ + + +@implementer(IRedeemer) +class NonRedeemer(object): + """ + A ``NonRedeemer`` never tries to redeem vouchers for ZKAPs. + """ + 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)) + for n + in range(count) + ) + + def redeem(self, voucher, random_tokens): + # Don't try to redeem them. + return Deferred() + + +@implementer(IRedeemer) +@attr.s +class DummyRedeemer(object): + """ + A ``DummyRedeemer`` pretends to redeem vouchers for ZKAPs. Instead of + really redeeming them, it makes up some fake ZKAPs and pretends those are + the result. + """ + def random_tokens_for_voucher(self, voucher, count): + """ + Generate some number of random tokens to submit along with a voucher for + redemption. + """ + # Dummy token generation. + return list( + RandomToken(u"{}-{}".format(voucher, n)) + for n + in range(count) + ) + + def redeem(self, voucher, random_tokens): + """ + :return: An already-fired ``Deferred`` that has a list of ``Pass`` + instances wrapping meaningless values. + """ + return succeed( + list( + Pass(u"pass-" + token.token_value) + for token + in random_tokens + ), + ) + + @attr.s class PaymentController(object): + """ + The ``PaymentController`` coordinates the process of turning a voucher + into a collection of ZKAPs: + + 1. A voucher to be consumed is handed to the controller. + Once a voucher is handed over to the controller the controller takes all responsibility for it. + + 2. The controller tells the data store to remember the voucher. + The data store provides durability for the voucher which represents an investment (ie, a purchase) on the part of the client. + + 3. The controller tells the store to hand all currently idle vouchers to a redeemer. + In normal operation, only the newly added voucher will be idle. + + + """ store = attr.ib() + redeemer = attr.ib() def redeem(self, voucher): - self.store.add(voucher) + # 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. + tokens = self.redeemer.random_tokens_for_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.addCallback( + partial(self._redeemSuccess, voucher), + ) + + def _redeemSuccess(self, voucher, passes): + self.store.insert_passes_for_voucher(voucher, passes) diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index ec534fd4c4801cffc072c6af5f7149bd7724a253..2921a68ff6cceb8d01f808e715f5e27211c1433d 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -85,6 +85,8 @@ def open_and_initialize(path, required_schema_version, connect=None): except OperationalError as e: raise StoreOpenError(e) + conn.execute("PRAGMA foreign_keys = ON") + with conn: cursor = conn.cursor() cursor.execute( @@ -112,11 +114,23 @@ def open_and_initialize(path, required_schema_version, connect=None): """ CREATE TABLE IF NOT EXISTS [vouchers] ( [number] text, + [redeemed] num DEFAULT 0, PRIMARY KEY([number]) ) """, ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS [tokens] ( + [text] text, -- The random string that defines the token. + [voucher] text, -- Reference to the voucher these tokens go with. + + PRIMARY KEY([text]) + FOREIGN KEY([voucher]) REFERENCES [vouchers]([number]) + ) + """, + ) cursor.execute( """ CREATE TABLE IF NOT EXISTS [passes] ( @@ -152,7 +166,7 @@ class VoucherStore(object): :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for the node that owns the persisted vouchers. """ - database_path = attr.ib(type=FilePath) + database_path = attr.ib(validator=attr.validators.instance_of(FilePath)) _connection = attr.ib() @classmethod @@ -173,7 +187,7 @@ class VoucherStore(object): cursor.execute( """ SELECT - ([number]) + [number], [redeemed] FROM [vouchers] WHERE @@ -184,37 +198,54 @@ class VoucherStore(object): refs = cursor.fetchall() if len(refs) == 0: raise KeyError(voucher) - return Voucher(refs[0][0]) + return Voucher(refs[0][0], bool(refs[0][1])) @with_cursor - def add(self, cursor, voucher): + def add(self, cursor, voucher, tokens): cursor.execute( """ - INSERT OR IGNORE INTO [vouchers] VALUES (?) + INSERT OR IGNORE INTO [vouchers] ([number]) VALUES (?) """, (voucher,) ) + if cursor.rowcount: + # Something was inserted. Insert the tokens, too. It's okay to + # drop the tokens in the other case. They've never been used. + # What's *already* in the database, on the other hand, may already + # have been submitted in a redeem attempt and must not change. + cursor.executemany( + """ + INSERT INTO [tokens] ([voucher], [text]) VALUES (?, ?) + """, + list( + (voucher, token.token_value) + for token + in tokens + ), + ) @with_cursor def list(self, cursor): cursor.execute( """ - SELECT ([number]) FROM [vouchers] + SELECT [number], [redeemed] FROM [vouchers] """, ) refs = cursor.fetchall() return list( - Voucher(number) - for (number,) + Voucher(number, bool(redeemed)) + for (number, redeemed) in refs ) @with_cursor - def insert_passes(self, cursor, passes): + def insert_passes_for_voucher(self, cursor, voucher, passes): """ Store some passes. + :param unicode voucher: The voucher associated with the passes. + :param list[Pass] passes: The passes to store. """ cursor.executemany( @@ -223,6 +254,12 @@ class VoucherStore(object): """, list((p.text,) for p in passes), ) + cursor.execute( + """ + UPDATE [vouchers] SET [redeemed] = 1 WHERE [number] = ? + """, + (voucher,), + ) @with_cursor def extract_passes(self, cursor, count): @@ -250,7 +287,7 @@ class VoucherStore(object): ) cursor.execute( """ - SELECT ([text]) FROM [extracting-passes] + SELECT [text] FROM [extracting-passes] """, ) texts = cursor.fetchall() @@ -278,12 +315,18 @@ class Pass(object): text should be kept secret. If pass text is divulged to third-parties the anonymity property may be compromised. """ - text = attr.ib(type=unicode) + text = attr.ib(validator=attr.validators.instance_of(unicode)) + + +@attr.s(frozen=True) +class RandomToken(object): + token_value = attr.ib(validator=attr.validators.instance_of(unicode)) @attr.s class Voucher(object): number = attr.ib() + redeemed = attr.ib(default=False, validator=attr.validators.instance_of(bool)) @classmethod def from_json(cls, json): diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index e13b81b2260ded02c90aa496eab06158ef452f10..979e40f04f8d760ba2bb39caffd73433d6d612b0 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -42,9 +42,10 @@ from .model import ( ) from .controller import ( PaymentController, + NonRedeemer, ) -def from_configuration(node_config, store=None): +def from_configuration(node_config, store=None, redeemer=None): """ Instantiate the plugin root resource using data from its configuration section in the Tahoe-LAFS configuration file:: @@ -60,12 +61,17 @@ def from_configuration(node_config, store=None): :param VoucherStore store: The store to use. If ``None`` a sensible one is constructed. + :param IRedeemer redeemer: The voucher redeemer to use. If ``None`` a + sensible one is constructed. + :return IResource: The root of the resource hierarchy presented by the client side of the plugin. """ if store is None: store = VoucherStore.from_node_config(node_config) - controller = PaymentController(store) + if redeemer is None: + redeemer = NonRedeemer() + controller = PaymentController(store, redeemer) root = Resource() root.putChild( b"voucher", diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index 3b6fc63deeab705795a5d7555b5a453dd6e16035..8f6e74ed6c4a71cff35f75630b7b26fb3232c543 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -54,6 +54,7 @@ from allmydata.client import ( from ..model import ( Pass, + RandomToken, ) @@ -179,6 +180,20 @@ def vouchers(): ) +def random_tokens(): + """ + Build random tokens as unicode strings. + """ + return binary( + min_size=32, + max_size=32, + ).map( + urlsafe_b64encode, + ).map( + lambda token: RandomToken(token.decode("ascii")), + ) + + def zkaps(): """ Build random ZKAPs as ``Pass` instances. diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index dad75bb63b094f71201f99a8dfdbf1cee3888b13..203e44ca3ca557788daefd667fbbea3dc73de4e5 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -92,6 +92,7 @@ from treq.testing import ( ) from ..model import ( + Voucher, VoucherStore, memory_connect, ) @@ -390,10 +391,7 @@ class VoucherTests(TestCase): AfterPreprocessing( json_content, succeeded( - Equals({ - u"version": 1, - u"number": voucher, - }), + Equals(Voucher(voucher).marshal()), ), ), ), @@ -449,7 +447,7 @@ class VoucherTests(TestCase): succeeded( Equals({ u"vouchers": list( - {u"version": 1, u"number": voucher} + Voucher(voucher).marshal() for voucher in vouchers ), diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 11d730440ebb90a09a2a9559766f9fe90442bb1d..1a6ac3cf944dc53b3b6ad554ea85addd170b9aaa 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -64,6 +64,7 @@ from ..model import ( from .strategies import ( tahoe_configs, vouchers, + random_tokens, zkaps, ) @@ -105,8 +106,8 @@ class VoucherStoreTests(TestCase): raises(KeyError), ) - @given(tahoe_configs(), vouchers()) - def test_add(self, get_config, voucher): + @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True)) + def test_add(self, get_config, voucher, tokens): """ ``VoucherStore.get`` returns a ``Voucher`` representing a voucher previously added to the store with ``VoucherStore.add``. @@ -117,16 +118,17 @@ class VoucherStoreTests(TestCase): config, memory_connect, ) - store.add(voucher) + store.add(voucher, tokens) self.assertThat( store.get(voucher), MatchesStructure( number=Equals(voucher), + redeemed=Equals(False), ), ) - @given(tahoe_configs(), vouchers()) - def test_add_idempotent(self, get_config, voucher): + @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True)) + def test_add_idempotent(self, get_config, voucher, tokens): """ More than one call to ``VoucherStore.add`` with the same argument results in the same state as a single call. @@ -137,8 +139,8 @@ class VoucherStoreTests(TestCase): config, memory_connect, ) - store.add(voucher) - store.add(voucher) + store.add(voucher, tokens) + store.add(voucher, []) self.assertThat( store.get(voucher), MatchesStructure( @@ -162,17 +164,17 @@ class VoucherStoreTests(TestCase): ) for voucher in vouchers: - store.add(voucher) + store.add(voucher, []) self.assertThat( store.list(), - AfterPreprocessing( - lambda refs: set(ref.number for ref in refs), - Equals(set(vouchers)), - ), + Equals(list( + Voucher(number) + for number + in vouchers + )), ) - @given(tahoe_configs()) def test_uncreateable_store_directory(self, get_config): """ @@ -257,8 +259,8 @@ class ZKAPStoreTests(TestCase): """ Tests for ZKAP-related functionality of ``VoucherStore``. """ - @given(tahoe_configs(), lists(zkaps(), unique=True)) - def test_zkaps_round_trip(self, get_config, passes): + @given(tahoe_configs(), vouchers(), lists(zkaps(), unique=True)) + def test_zkaps_round_trip(self, get_config, voucher_value, passes): """ ZKAPs that are added to the store can later be retrieved. """ @@ -268,10 +270,26 @@ class ZKAPStoreTests(TestCase): config, memory_connect, ) - store.insert_passes(passes) + store.insert_passes_for_voucher(voucher_value, passes) retrieved_passes = store.extract_passes(len(passes)) self.expectThat(passes, Equals(retrieved_passes)) # After extraction, the passes are no longer available. more_passes = store.extract_passes(1) self.expectThat([], Equals(more_passes)) + + @given(tahoe_configs(), vouchers(), random_tokens(), zkaps()) + def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_pass): + """ + The voucher for ZKAPs that are added to the store are marked as redeemed. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = VoucherStore.from_node_config( + config, + memory_connect, + ) + store.add(voucher_value, [token]) + store.insert_passes_for_voucher(voucher_value, [one_pass]) + loaded_voucher = store.get(voucher_value) + self.assertThat(loaded_voucher.redeemed, Equals(True))