From 8bc316756370f982ab2159289e278b02953b330f Mon Sep 17 00:00:00 2001
From: Jean-Paul Calderone <exarkun@twistedmatrix.com>
Date: Thu, 22 Jul 2021 09:11:10 -0400
Subject: [PATCH] Sequester unblinded tokens received with a disallowed public
 key

---
 src/_zkapauthorizer/controller.py            |   1 +
 src/_zkapauthorizer/model.py                 |  34 +++--
 src/_zkapauthorizer/schema.py                |   6 +
 src/_zkapauthorizer/tests/test_controller.py | 150 +++++++++++++++++++
 src/_zkapauthorizer/tests/test_model.py      |   9 +-
 5 files changed, 187 insertions(+), 13 deletions(-)

diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index fa1cecd..59bb54e 100644
--- a/src/_zkapauthorizer/controller.py
+++ b/src/_zkapauthorizer/controller.py
@@ -950,6 +950,7 @@ class PaymentController(object):
             result.public_key,
             result.unblinded_tokens,
             completed=(counter + 1 == self.num_redemption_groups),
+            spendable=result.public_key in self.allowed_public_keys,
         )
         return True
 
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index 4b2dd85..764be81 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -419,7 +419,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, completed):
+    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, public_key, unblinded_tokens, completed, spendable):
         """
         Store some unblinded tokens received from redemption of a voucher.
 
@@ -435,17 +435,28 @@ class VoucherStore(object):
 
         :param bool completed: ``True`` if redemption of this voucher is now
             complete, ``False`` otherwise.
+
+        :param bool spendable: ``True`` if it should be possible to spend the
+            inserted tokens, ``False`` otherwise.
         """
         if  completed:
             voucher_state = u"redeemed"
         else:
             voucher_state = u"pending"
 
+        if spendable:
+            token_count_increase = len(unblinded_tokens)
+            sequestered_count_increase = 0
+        else:
+            token_count_increase = 0
+            sequestered_count_increase = len(unblinded_tokens)
+
         cursor.execute(
             """
             UPDATE [vouchers]
             SET [state] = ?
               , [token-count] = COALESCE([token-count], 0) + ?
+              , [sequestered-count] = COALESCE([sequestered-count], 0) + ?
               , [finished] = ?
               , [public-key] = ?
               , [counter] = [counter] + 1
@@ -453,7 +464,8 @@ class VoucherStore(object):
             """,
             (
                 voucher_state,
-                len(unblinded_tokens),
+                token_count_increase,
+                sequestered_count_increase,
                 self.now(),
                 public_key,
                 voucher,
@@ -461,14 +473,16 @@ class VoucherStore(object):
         )
         if cursor.rowcount == 0:
             raise ValueError("Cannot insert tokens for unknown voucher; add voucher first")
-        self._insert_unblinded_tokens(
-            cursor,
-            list(
-                t.unblinded_token
-                for t
-                in unblinded_tokens
-            ),
-        )
+
+        if spendable:
+            self._insert_unblinded_tokens(
+                cursor,
+                list(
+                    t.unblinded_token
+                    for t
+                    in unblinded_tokens
+                ),
+            )
 
     @with_cursor
     def mark_voucher_double_spent(self, cursor, voucher):
diff --git a/src/_zkapauthorizer/schema.py b/src/_zkapauthorizer/schema.py
index 5044153..426ca1a 100644
--- a/src/_zkapauthorizer/schema.py
+++ b/src/_zkapauthorizer/schema.py
@@ -167,4 +167,10 @@ _UPGRADES = {
         )
         """,
     ],
+
+    5: [
+        """
+        ALTER TABLE [vouchers] ADD COLUMN [sequestered-count] integer NOT NULL DEFAULT 0
+        """,
+    ],
 }
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index e96aafb..be05ec1 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -66,6 +66,7 @@ from hypothesis.strategies import (
     datetimes,
     lists,
     sampled_from,
+    randoms,
 )
 from twisted.python.url import (
     URL,
@@ -137,6 +138,7 @@ from .strategies import (
     voucher_counters,
     redemption_group_counts,
     dummy_ristretto_keys,
+    token_counts,
     clocks,
 )
 from .matchers import (
@@ -146,6 +148,7 @@ from .matchers import (
 )
 from .fixtures import (
     TemporaryVoucherStore,
+    ConfiglessMemoryVoucherStore,
 )
 
 
@@ -597,6 +600,153 @@ class PaymentControllerTests(TestCase):
             ),
         )
 
+    @given(
+        # Get a random object so we can shuffle allowed and disallowed keys
+        # together in an unpredictable but Hypothesis-deterministicway.
+        randoms(),
+        # Control time just to control it.  Nothing particularly interesting
+        # relating to time happens in this test.
+        clocks(),
+        # Build a voucher number to use with the attempted redemption.
+        vouchers(),
+        # Build a number of redemption groups.
+        integers(min_value=1, max_value=16).flatmap(
+            # Build a number of groups to have an allowed key
+            lambda num_groups: integers(min_value=0, max_value=num_groups).flatmap(
+                # Build distinct public keys
+                lambda num_allowed_key_groups: lists(
+                    dummy_ristretto_keys(),
+                    min_size=num_groups,
+                    max_size=num_groups,
+                    unique=True,
+                ).map(
+                    # Split the keys into allowed and disallowed groups
+                    lambda public_keys: (public_keys[:num_allowed_key_groups], public_keys[num_allowed_key_groups:]),
+                ),
+            ),
+        ),
+        # Build a number of extra tokens to request beyond the minimum number
+        # required by the number of redemption groups we have.
+        integers(min_value=0, max_value=32),
+    )
+    def test_sequester_tokens_for_untrusted_key(self, random, clock, voucher, public_keys, extra_token_count):
+        """
+        All unblinded tokens which are returned from the redemption process
+        associated with a public key that the controller has not been
+        configured to trust are not made available to be spent.  The
+        corresponding voucher still reaches the redeemed state but with the
+        number of sequestered tokens subtracted from its ``token_count``.
+        """
+        # The controller will be configured to allow one group of keys but not
+        # the other.
+        allowed_public_keys, disallowed_public_keys = public_keys
+        all_public_keys = allowed_public_keys + disallowed_public_keys
+
+        # Compute the total number of tokens we'll request, spread across all
+        # redemption groups.
+        token_count = len(all_public_keys) + extra_token_count
+
+        # Mix them up so they're not always presented to the controller in the
+        # same order - and in particular so they're not always presented such
+        # that all allowed keys come before all disallowed keys.
+        random.shuffle(all_public_keys)
+
+        # Redeem the voucher in enough groups so that each key can be
+        # presented once.
+        num_redemption_groups = len(all_public_keys)
+
+        datetime_now = lambda: datetime.utcfromtimestamp(clock.seconds())
+        store = self.useFixture(ConfiglessMemoryVoucherStore(datetime_now)).store
+
+        redeemers = list(
+            DummyRedeemer(public_key)
+            for public_key
+            in all_public_keys
+        )
+
+        controller = PaymentController(
+            store,
+            IndexedRedeemer(redeemers),
+            default_token_count=token_count,
+            num_redemption_groups=num_redemption_groups,
+            allowed_public_keys=set(allowed_public_keys),
+            clock=clock,
+        )
+
+        # Even with disallowed public keys, the *redemption* is considered
+        # successful.
+        self.assertThat(
+            controller.redeem(voucher),
+            succeeded(Always()),
+        )
+
+        def count_in_group(public_keys, key_group):
+            return sum((
+                token_count_for_group(num_redemption_groups, token_count, n)
+                for n, public_key
+                in enumerate(public_keys)
+                if public_key in key_group
+            ), 0)
+
+        allowed_token_count = count_in_group(all_public_keys, allowed_public_keys)
+        disallowed_token_count = count_in_group(all_public_keys, disallowed_public_keys)
+
+        # As a sanity check: allowed + disallowed should equal total or we've
+        # screwed up the test logic.
+        self.assertThat(
+            allowed_token_count + disallowed_token_count,
+            Equals(token_count),
+        )
+
+        # The counts on the voucher object should reflect what was allowed and
+        # what was disallowed.
+        self.expectThat(
+            store.get(voucher),
+            MatchesStructure(
+                expected_tokens=Equals(token_count),
+                state=Equals(
+                    model_Redeemed(
+                        finished=datetime_now(),
+                        token_count=allowed_token_count,
+                        public_key=all_public_keys[-1],
+                    ),
+                ),
+            ),
+        )
+
+        # Also the actual number of tokens available should agree.
+        self.expectThat(
+            store.count_unblinded_tokens(),
+            Equals(allowed_token_count),
+        )
+
+        # And finally only tokens from the groups using an allowed key should
+        # be made available to be spent.
+        voucher_obj = store.get(voucher)
+        allowed_tokens = list(
+            unblinded_token
+            for counter, redeemer in enumerate(redeemers)
+            if redeemer._public_key in allowed_public_keys
+            for unblinded_token
+            in redeemer.redeemWithCounter(
+                voucher_obj,
+                counter,
+                redeemer.random_tokens_for_voucher(
+                    voucher_obj,
+                    counter,
+                    token_count_for_group(
+                        num_redemption_groups,
+                        token_count,
+                        counter,
+                    ),
+                ),
+            ).result.unblinded_tokens
+        )
+        self.expectThat(
+            store.get_unblinded_tokens(store.count_unblinded_tokens()),
+            Equals(allowed_tokens),
+        )
+
 
 NOWHERE = URL.from_text(u"https://127.0.0.1/")
 
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index a2a8e5a..3ff7b56 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -392,6 +392,7 @@ class VoucherStoreTests(TestCase):
             public_key,
             unblinded_tokens,
             completed=data.draw(booleans()),
+            spendable=True,
         )
 
         backed_up_tokens = store.backup()[u"unblinded-tokens"]
@@ -725,6 +726,7 @@ class UnblindedTokenStoreTests(TestCase):
                 public_key,
                 unblinded_tokens,
                 completed,
+                spendable=True,
             ),
             raises(ValueError),
         )
@@ -744,7 +746,7 @@ class UnblindedTokenStoreTests(TestCase):
         random_tokens, unblinded_tokens = paired_tokens(data)
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, len(random_tokens), 0, lambda: random_tokens)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded_tokens, completed, spendable=True)
 
         # All the tokens just inserted should be counted.
         self.expectThat(
@@ -796,7 +798,7 @@ class UnblindedTokenStoreTests(TestCase):
 
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, len(random), 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True, spendable=True)
         loaded_voucher = store.get(voucher_value)
         self.assertThat(
             loaded_voucher,
@@ -864,7 +866,7 @@ class UnblindedTokenStoreTests(TestCase):
         )
         store = self.useFixture(TemporaryVoucherStore(get_config, lambda: now)).store
         store.add(voucher_value, len(random), 0, lambda: random)
-        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True)
+        store.insert_unblinded_tokens_for_voucher(voucher_value, public_key, unblinded, completed=True, spendable=True)
         self.assertThat(
             lambda: store.mark_voucher_double_spent(voucher_value),
             raises(ValueError),
@@ -908,6 +910,7 @@ class UnblindedTokenStoreTests(TestCase):
             public_key,
             unblinded,
             completed,
+            spendable=True,
         )
         self.assertThat(
             lambda: store.get_unblinded_tokens(num_tokens + extra),
-- 
GitLab