diff --git a/default.nix b/default.nix
index 04af35d91986bd58cdc708b9bddc31c6aa1d50d4..27401736371276c58a0df8b8112c496998aaa4b0 100644
--- a/default.nix
+++ b/default.nix
@@ -1,2 +1,2 @@
 { pkgs ? import ./nixpkgs.nix { }, hypothesisProfile ? null, collectCoverage ? false }:
-pkgs.python27Packages.callPackage ./zkapauthorizer.nix { inherit hypothesisProfile collectCoverage; }
+pkgs.python27Packages.zkapauthorizer.override { inherit hypothesisProfile collectCoverage; }
diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst
new file mode 100644
index 0000000000000000000000000000000000000000..b403363d6d25b7e8c6d4c84c871c3c8b11865ecf
--- /dev/null
+++ b/docs/source/configuration.rst
@@ -0,0 +1,50 @@
+Configuration
+=============
+
+Client
+------
+
+To enable the plugin at all, add its name to the list of storage plugins in the Tahoe-LAFS configuration
+(``tahoe.cfg`` in the relevant node directory)::
+
+  [client]
+  storage.plugins = privatestorageio-zkapauthz-v1
+
+Then configure the plugin as desired in the ``storageclient.plugins.privatestorageio-zkapauthz-v1`` section.
+
+redeemer
+~~~~~~~~
+
+This item configures the voucher redeemer the client will use to redeem vouchers submitted to it.
+The ``dummy`` value is useful for testing purposes only.
+
+For example::
+
+  [storageclient.plugins.privatestorageio-zkapauthz-v1]
+  redeemer = dummy
+
+A value of ``ristretto`` causes the client to speak Ristretto-flavored PrivacyPass to an issuer server.
+In this case, the ``ristretto-issuer-root-url`` item is also required.
+
+For example::
+
+  [storageclient.plugins.privatestorageio-zkapauthz-v1]
+  redeemer = ristretto
+  ristretto-issuer-root-url = https://issuer.example.invalid/
+
+Note that ``ristretto-issuer-root-url`` must agree with whichever storage servers the client will be configured to interact with.
+If the values are not the same, the client will decline to use the storage servers.
+
+Server
+------
+
+To enable the plugin at all, add its name to the list of storage plugins in the Tahoe-LAFS configuration
+(``tahoe.cfg`` in the relevant node directory)::
+
+  [storage]
+  plugins = privatestorageio-zkapauthz-v1
+
+Then also configure the Ristretto-flavored PrivacyPass issuer the server will announce to clients::
+
+  [storageserver.plugins.privatestorageio-zkapauthz-v1]
+  ristretto-issuer-root-url = https://issuer.example.invalid/
diff --git a/overlays.nix b/overlays.nix
index 9a50dadb40152a238832c1402b3b9944ee45c4a1..6475c6bdd1524e2e9d2e3edb66eb54b1ae4ac554 100644
--- a/overlays.nix
+++ b/overlays.nix
@@ -1,5 +1,5 @@
 self: super: {
-  openssl = self.openssl_1_1;
+  openssl = super.openssl_1_1;
 
   ristretto = super.callPackage ./ristretto.nix { };
 
diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py
index 8b16a7751eec14dc61e1751101839724440ea346..e5c59e23456605c95c36ed24839aa1a439957520 100644
--- a/src/_zkapauthorizer/_plugin.py
+++ b/src/_zkapauthorizer/_plugin.py
@@ -21,10 +21,6 @@ from weakref import (
     WeakValueDictionary,
 )
 
-from functools import (
-    partial,
-)
-
 import attr
 
 from zope.interface import (
@@ -54,7 +50,7 @@ from .resource import (
 )
 
 from .controller import (
-    DummyRedeemer,
+    get_redeemer,
 )
 
 @implementer(IAnnounceableStorageServer)
@@ -96,11 +92,24 @@ class ZKAPAuthorizer(object):
         return s
 
 
+    def _get_redeemer(self, node_config, announcement, reactor):
+        """
+        :return IRedeemer: The voucher redeemer indicated by the given
+            configuration.  A new instance is returned on every call because
+            the redeemer interface is stateless.
+        """
+        return get_redeemer(self.name, node_config, announcement, reactor)
+
+
     def get_storage_server(self, configuration, get_anonymous_storage_server):
-        announcement = {}
+        kwargs = configuration.copy()
+        root_url = kwargs.pop(u"ristretto-issuer-root-url")
+        announcement = {
+            u"ristretto-issuer-root-url": root_url,
+        }
         storage_server = ZKAPAuthorizerStorageServer(
             get_anonymous_storage_server(),
-            **configuration
+            **kwargs
         )
         return succeed(
             AnnounceableStorageServer(
@@ -117,17 +126,23 @@ class ZKAPAuthorizer(object):
         managed by this plugin in the node directory that goes along with
         ``node_config``.
         """
+        from twisted.internet import reactor
+        redeemer = self._get_redeemer(node_config, announcement, reactor)
+        extract_unblinded_tokens = self._get_store(node_config).extract_unblinded_tokens
+        def get_passes(message, count):
+            unblinded_tokens = extract_unblinded_tokens(count)
+            return redeemer.tokens_to_passes(message, unblinded_tokens)
+
         return ZKAPAuthorizerStorageClient(
             get_rref,
-            # TODO: Make the caller figure out the correct number of
-            # passes to extract.
-            partial(self._get_store(node_config).extract_passes, 1),
+            get_passes,
         )
 
 
     def get_client_resource(self, node_config):
+        from twisted.internet import reactor
         return resource_from_configuration(
             node_config,
             store=self._get_store(node_config),
-            redeemer=DummyRedeemer(),
+            redeemer=self._get_redeemer(node_config, None, reactor),
         )
diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py
index e0dc41ddca2693016c17785a9dc4b655b7f2b585..6b1c1acf3babf0d303802f75320dc0734e6cdde1 100644
--- a/src/_zkapauthorizer/_storage_client.py
+++ b/src/_zkapauthorizer/_storage_client.py
@@ -49,8 +49,11 @@ class ZKAPAuthorizerStorageClient(object):
         valid ``RemoteReference`` corresponding to the server-side object for
         this scheme.
 
-    :ivar _get_passes: A no-argument callable which retrieves some passes
-        which can be used to authorize an operation.
+    :ivar _get_passes: A two-argument callable which retrieves some passes
+        which can be used to authorize an operation.  The first argument is a
+        bytes (valid utf-8) message binding the passes to the request for
+        which they will be used.  The second is an integer giving the number
+        of passes to request.
     """
     _get_rref = attr.ib()
     _get_passes = attr.ib()
@@ -59,7 +62,7 @@ class ZKAPAuthorizerStorageClient(object):
     def _rref(self):
         return self._get_rref()
 
-    def _get_encoded_passes(self):
+    def _get_encoded_passes(self, message, count):
         """
         :return: A list of passes from ``_get_passes`` encoded into their
             ``bytes`` representation.
@@ -67,7 +70,7 @@ class ZKAPAuthorizerStorageClient(object):
         return list(
             t.text.encode("ascii")
             for t
-            in self._get_passes()
+            in self._get_passes(message.encode("hex"), count)
         )
 
     def get_version(self):
@@ -86,7 +89,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "allocate_buckets",
-            self._get_encoded_passes(),
+            self._get_encoded_passes(storage_index, 1),
             storage_index,
             renew_secret,
             cancel_secret,
@@ -112,7 +115,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "add_lease",
-            self._get_encoded_passes(),
+            self._get_encoded_passes(storage_index, 1),
             storage_index,
             renew_secret,
             cancel_secret,
@@ -125,7 +128,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "renew_lease",
-            self._get_encoded_passes(),
+            self._get_encoded_passes(storage_index, 1),
             storage_index,
             renew_secret,
         )
@@ -154,7 +157,7 @@ class ZKAPAuthorizerStorageClient(object):
     ):
         return self._rref.callRemote(
             "slot_testv_and_readv_and_writev",
-            self._get_encoded_passes(),
+            self._get_encoded_passes(storage_index, 1),
             storage_index,
             secrets,
             tw_vectors,
diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py
index c0d5536009d07976a85e74109cd3ec5347d5e302..3d78b9ddaae7491b0e00cf153855c7d4157d0498 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,17 +30,35 @@ from zope.interface import (
     implementer,
 )
 
+from twisted.logger import (
+    Logger,
+)
+from twisted.python.url import (
+    URL,
+)
 from twisted.internet.defer import (
     Deferred,
     succeed,
+    inlineCallbacks,
+    returnValue,
 )
-
-from .foolscap import (
-    TOKEN_LENGTH,
+from twisted.web.client import (
+    Agent,
 )
+from treq import (
+    json_content,
+)
+from treq.client import (
+    HTTPClient,
+)
+
+import privacypass
+
 from .model import (
-    Pass,
     RandomToken,
+    UnblindedToken,
+    Voucher,
+    Pass,
 )
 
 
@@ -65,7 +85,8 @@ class IRedeemer(Interface):
 
     def redeem(voucher, random_tokens):
         """
-        Redeem a voucher for passes.
+        Redeem a voucher for unblinded tokens which can be used to construct
+        passes.
 
         Implementations of this method do not need to be fault tolerant.  If a
         redemption attempt is interrupted before it completes, it is the
@@ -77,12 +98,28 @@ class IRedeemer(Interface):
         :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.
+        :return: A ``Deferred`` which fires with a list of ``UnblindedToken``
+            instances on successful redemption or which fails with any error
+            to allow a retry to be made at some future point.
+        """
+
+    def tokens_to_passes(message, unblinded_tokens):
+        """
+        Construct passes from unblinded tokens which are suitable for use with a
+        given message.
+
+        :param bytes message: A valid utf-8-encoded byte sequence which serves
+            to protect the resulting passes from replay usage.  It is
+            preferable if every use of passes is associated with a unique
+            message.
+
+        :param list[UnblindedToken] unblinded_tokens: Unblinded tokens,
+            previously returned by a call to this implementation's ``redeem``
+            method.
+
+        :return list[Pass]: Passes constructed from the message and unblinded
+            tokens.  There is one pass in the resulting list for each unblinded
+            token in ``unblinded_tokens``.
         """
 
 
@@ -94,7 +131,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)
         )
@@ -103,6 +140,11 @@ class NonRedeemer(object):
         # Don't try to redeem them.
         return Deferred()
 
+    def tokens_to_passes(self, message, unblinded_tokens):
+        raise Exception(
+            "Cannot be called because no unblinded tokens are ever returned."
+        )
+
 
 @implementer(IRedeemer)
 @attr.s
@@ -112,6 +154,10 @@ class DummyRedeemer(object):
     really redeeming them, it makes up some fake ZKAPs and pretends those are
     the result.
     """
+    @classmethod
+    def make(cls, section_name, node_config, announcement, reactor):
+        return cls()
+
     def random_tokens_for_voucher(self, voucher, count):
         """
         Generate some number of random tokens to submit along with a voucher for
@@ -119,24 +165,200 @@ class DummyRedeemer(object):
         """
         # Dummy token generation.
         return list(
-            RandomToken(u"{}-{}".format(voucher, n))
+            RandomToken(u"{}-{}".format(voucher.number, 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: An already-fired ``Deferred`` that has a list of
+          ``UnblindedToken`` instances wrapping meaningless values.
         """
         return succeed(
             list(
-                Pass((u"pass-" + token.token_value).zfill(TOKEN_LENGTH))
+                UnblindedToken(token.token_value)
                 for token
                 in random_tokens
             ),
         )
 
+    def tokens_to_passes(self, message, unblinded_tokens):
+        return list(
+            Pass(token.text)
+            for token
+            in unblinded_tokens
+        )
+
+
+class IssuerConfigurationMismatch(Exception):
+    """
+    The Ristretto issuer address in the local client configuration does not
+    match the Ristretto issuer address received in a storage server
+    announcement.
+
+    If these values do not match then there is no reason to expect that ZKAPs
+    will be accepted by the storage server because ZKAPs are bound to the
+    issuer's signing key.
+
+    This mismatch must be corrected before the storage server can be used.
+    Either the storage server needs to be reconfigured to respect the
+    authority of a different issuer (the same one the client is configured to
+    use), the client needs to select a different storage server to talk to, or
+    the client needs to be reconfigured to respect the authority of a
+    different issuer (the same one the storage server is announcing).
+
+    Note that issued ZKAPs cannot be exchanged between issues except through
+    some ad hoc, out-of-band means.  That is, if the client already has some
+    ZKAPs and chooses to change its configured issuer address, those existing
+    ZKAPs will not be usable and new ones must be obtained.
+    """
+    def __str__(self):
+        return "Announced issuer ({}) disagrees with configured issuer ({}).".format(self.args)
+
+
+@implementer(IRedeemer)
+@attr.s
+class RistrettoRedeemer(object):
+    """
+    An ``IRedeemer`` which speaks the Ristretto-flavored PrivacyPass protocol
+    described at
+    https://docs.rs/challenge-bypass-ristretto/1.0.0-pre.0/challenge_bypass_ristretto/#cryptographic-protocol
+
+    :ivar treq.client.HTTPClient _treq: An HTTP client to use to make calls to
+        the issuer.
+
+    :ivar URL _api_root: The root of the issuer HTTP API.
+    """
+    _log = Logger()
+
+    _treq = attr.ib()
+    _api_root = attr.ib(validator=attr.validators.instance_of(URL))
+
+    @classmethod
+    def make(cls, section_name, node_config, announcement, reactor):
+        configured_issuer = node_config.get_config(
+            section=section_name,
+            option=u"ristretto-issuer-root-url",
+        ).decode("ascii")
+        if announcement is not None:
+            # Don't let us talk to a storage server that has a different idea
+            # about who issues ZKAPs.  We should lift this limitation (that is, we
+            # should support as many different issuers as the user likes) in the
+            # future but doing so requires changing how the web interface works
+            # and possibly also the interface for voucher submission.
+            #
+            # If we aren't given an announcement then we're not being used in
+            # the context of a specific storage server so the check is
+            # unnecessary and impossible.
+            announced_issuer = announcement[u"ristretto-issuer-root-url"]
+            if announced_issuer != configured_issuer:
+                raise IssuerConfigurationMismatch(announced_issuer, configured_issuer)
+
+        return cls(
+            HTTPClient(Agent(reactor)),
+            URL.from_text(configured_issuer),
+        )
+
+    def random_tokens_for_voucher(self, voucher, count):
+        return list(
+            RandomToken(privacypass.RandomToken.create().encode_base64().decode("ascii"))
+            for n
+            in range(count)
+        )
+
+    @inlineCallbacks
+    def redeem(self, voucher, encoded_random_tokens):
+        random_tokens = list(
+            privacypass.RandomToken.decode_base64(token.token_value.encode("ascii"))
+            for token
+            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
+                ),
+            }),
+            headers={b"content-type": b"application/json"},
+        )
+        try:
+            result = yield json_content(response)
+        except ValueError:
+            self._log.failure("Parsing redeem response failed", response=response)
+            raise
+
+        self._log.info("Redeemed: {public-key} {proof} {signatures}", **result)
+
+        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(
+            UnblindedToken(token.encode_base64().decode("ascii"))
+            for token
+            in clients_unblinded_tokens
+        ))
+
+    def tokens_to_passes(self, message, unblinded_tokens):
+        assert isinstance(message, bytes)
+        assert isinstance(unblinded_tokens, list)
+        assert all(isinstance(element, UnblindedToken) for element in unblinded_tokens)
+        unblinded_tokens = list(
+            privacypass.UnblindedToken.decode_base64(token.text.encode("ascii"))
+            for token
+            in unblinded_tokens
+        )
+        clients_verification_keys = list(
+            token.derive_verification_key_sha512()
+            for token
+            in unblinded_tokens
+        )
+        clients_signatures = list(
+            verification_key.sign_sha512(message)
+            for verification_key
+            in clients_verification_keys
+        )
+        clients_preimages = list(
+            token.preimage()
+            for token
+            in unblinded_tokens
+        )
+        marshaled_passes = list(
+            preimage.encode_base64() + b" " + signature.encode_base64()
+            for (preimage, signature)
+            in zip(clients_preimages, clients_signatures)
+        )
+        return list(
+            Pass(p.decode("ascii"))
+            for p
+            in marshaled_passes
+        )
+
 
 @attr.s
 class PaymentController(object):
@@ -153,13 +375,20 @@ class PaymentController(object):
       3. The controller hands the voucher and some random tokens to a redeemer.
          In the future, this step will need to be retried in the case of failures.
 
-      4. When the voucher has been redeemed for passes, the controller hands them to the data store with the voucher.
-        The data store marks the voucher as redeemed and stores the passes for use by the storage client.
+      4. When the voucher has been redeemed for unblinded tokens (inputs to
+         pass construction), the controller hands them to the data store with
+         the voucher.  The data store marks the voucher as redeemed and stores
+         the unblinded tokens for use by the storage client.
     """
+    _log = Logger()
+
     store = attr.ib()
     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
@@ -169,20 +398,51 @@ 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)
+        self._log.info("Generating random tokens for a voucher ({voucher}).", voucher=voucher)
+        tokens = self.redeemer.random_tokens_for_voucher(Voucher(voucher), 100)
 
         # Persist the voucher and tokens so they're available if we fail.
+        self._log.info("Persistenting random tokens for a voucher ({voucher}).", voucher=voucher)
         self.store.add(voucher, tokens)
 
         # Ask the redeemer to do the real task of redemption.
-        d = self.redeemer.redeem(voucher, tokens)
-        d.addCallback(
+        self._log.info("Redeeming random tokens for a voucher ({voucher}).", voucher=voucher)
+        d = self.redeemer.redeem(Voucher(voucher), tokens)
+        d.addCallbacks(
             partial(self._redeemSuccess, voucher),
+            partial(self._redeemFailure, voucher),
         )
+        d.addErrback(partial(self._finalRedeemError, voucher))
 
-    def _redeemSuccess(self, voucher, passes):
+    def _redeemSuccess(self, voucher, unblinded_tokens):
         """
         Update the database state to reflect that a voucher was redeemed and to
-        store the resulting passes.
+        store the resulting unblinded tokens (which can be used to construct
+        passes later).
         """
-        self.store.insert_passes_for_voucher(voucher, passes)
+        self._log.info("Inserting redeemed unblinded tokens for a voucher ({voucher}).", voucher=voucher)
+        self.store.insert_unblinded_tokens_for_voucher(voucher, unblinded_tokens)
+
+    def _redeemFailure(self, voucher, reason):
+        self._log.failure("Redeeming random tokens for a voucher ({voucher}) failed.", reason, voucher=voucher)
+        return None
+
+    def _finalRedeemError(self, voucher, reason):
+        self._log.failure("Redeeming random tokens for a voucher ({voucher}) encountered error.", reason, voucher=voucher)
+        return None
+
+
+def get_redeemer(plugin_name, node_config, announcement, reactor):
+    section_name = u"storageclient.plugins.{}".format(plugin_name)
+    redeemer_kind = node_config.get_config(
+        section=section_name,
+        option=u"redeemer",
+        default=u"ristretto",
+    )
+    return _REDEEMERS[redeemer_kind](section_name, node_config, announcement, reactor)
+
+
+_REDEEMERS = {
+    u"dummy": DummyRedeemer.make,
+    u"ristretto": RistrettoRedeemer.make,
+}
diff --git a/src/_zkapauthorizer/foolscap.py b/src/_zkapauthorizer/foolscap.py
index 224614dc7649ee80d3d2bec04eacee90ce749112..213eca4476aed796fa4ba166014a7e4fcd6a75ac 100644
--- a/src/_zkapauthorizer/foolscap.py
+++ b/src/_zkapauthorizer/foolscap.py
@@ -29,9 +29,12 @@ from allmydata.interfaces import (
 # lot of value.
 MAXIMUM_TOKENS_PER_CALL = 10
 
-# This is the length of a serialized PrivacyPass pass (there's a lot of
-# confusion between "tokens" and "passes" here, sadly).
-TOKEN_LENGTH = 97
+# This is the length of a serialized Ristretto-flavored PrivacyPass pass
+# (there's a lot of confusion between "tokens" and "passes" here, sadly).
+#
+# The pass is a combination of base64-encoded token preimages and unblinded
+# token signatures.
+TOKEN_LENGTH = 177
 
 # Take those values and turn them into the appropriate Foolscap constraint
 # objects.  Foolscap seems to have a convention of representing these as
diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py
index c2e56063489167c5e41ace8f3526751213e0a4ef..76a659da860617fd26dc39afaebe3b25be8cad3a 100644
--- a/src/_zkapauthorizer/model.py
+++ b/src/_zkapauthorizer/model.py
@@ -136,10 +136,10 @@ def open_and_initialize(path, required_schema_version, connect=None):
         )
         cursor.execute(
             """
-            CREATE TABLE IF NOT EXISTS [passes] (
-                [text] text, -- The string that defines the pass.
+            CREATE TABLE IF NOT EXISTS [unblinded-tokens] (
+                [token] text, -- The base64 encoded unblinded token.
 
-                PRIMARY KEY([text])
+                PRIMARY KEY([token])
             )
             """,
         )
@@ -271,21 +271,26 @@ class VoucherStore(object):
         )
 
     @with_cursor
-    def insert_passes_for_voucher(self, cursor, voucher, passes):
+    def insert_unblinded_tokens_for_voucher(self, cursor, voucher, unblinded_tokens):
         """
-        Store some passes.
+        Store some unblinded tokens.
 
-        :param unicode voucher: The voucher associated with the passes.  This
-            voucher will be marked as redeemed to indicate it has fulfilled
-            its purpose and has no further use for us.
+        :param unicode voucher: The voucher associated with the unblinded
+            tokens.  This voucher will be marked as redeemed to indicate it
+            has fulfilled its purpose and has no further use for us.
 
-        :param list[Pass] passes: The passes to store.
+        :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
+            store.
         """
         cursor.executemany(
             """
-            INSERT INTO [passes] VALUES (?)
+            INSERT INTO [unblinded-tokens] VALUES (?)
             """,
-            list((p.text,) for p in passes),
+            list(
+                (t.text,)
+                for t
+                in unblinded_tokens
+            ),
         )
         cursor.execute(
             """
@@ -295,47 +300,63 @@ class VoucherStore(object):
         )
 
     @with_cursor
-    def extract_passes(self, cursor, count):
+    def extract_unblinded_tokens(self, cursor, count):
         """
-        Remove and return some passes.
+        Remove and return some unblinded tokens.
 
-        :param int count: The maximum number of passes to remove and return.
-            If fewer passes than this are available, only as many as are
+        :param int count: The maximum number of unblinded tokens to remove and
+            return.  If fewer than this are available, only as many as are
             available are returned.
 
-        :return list[Pass]: The removed passes.
+        :return list[UnblindedTokens]: The removed unblinded tokens.
         """
         cursor.execute(
             """
-            CREATE TEMPORARY TABLE [extracting-passes]
+            CREATE TEMPORARY TABLE [extracting]
             AS
-            SELECT [text] FROM [passes] LIMIT ?
+            SELECT [token] FROM [unblinded-tokens] LIMIT ?
             """,
             (count,),
         )
         cursor.execute(
             """
-            DELETE FROM [passes] WHERE [text] IN [extracting-passes]
+            DELETE FROM [unblinded-tokens] WHERE [token] IN [extracting]
             """,
         )
         cursor.execute(
             """
-            SELECT [text] FROM [extracting-passes]
+            SELECT [token] FROM [extracting]
             """,
         )
         texts = cursor.fetchall()
         cursor.execute(
             """
-            DROP TABLE [extracting-passes]
+            DROP TABLE [extracting]
             """,
         )
         return list(
-            Pass(t)
+            UnblindedToken(t)
             for (t,)
             in texts
         )
 
 
+@attr.s(frozen=True)
+class UnblindedToken(object):
+    """
+    An ``UnblindedToken`` instance represents cryptographic proof of a voucher
+    redemption.  It is an intermediate artifact in the PrivacyPass protocol
+    and can be used to construct a privacy-preserving pass which can be
+    exchanged for service.
+
+    :ivar unicode text: The base64 encoded serialized form of the unblinded
+        token.  This can be used to reconstruct a
+        ``privacypass.UnblindedToken`` using that class's ``decode_base64``
+        method.
+    """
+    text = attr.ib(validator=attr.validators.instance_of(unicode))
+
+
 @attr.s(frozen=True)
 class Pass(object):
     """
diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py
index 7f78a0b41415370ebe9ca4f18e9fdb1b12da0233..c724fbca870dabb0457688c2ce162b91290161fd 100644
--- a/src/_zkapauthorizer/resource.py
+++ b/src/_zkapauthorizer/resource.py
@@ -24,6 +24,9 @@ from json import (
     loads, dumps,
 )
 
+from twisted.logger import (
+    Logger,
+)
 from twisted.web.http import (
     BAD_REQUEST,
 )
@@ -84,6 +87,8 @@ class _VoucherCollection(Resource):
     redemption controller.  Child resources of this resource can also be
     retrieved to monitor the status of previously submitted vouchers.
     """
+    _log = Logger()
+
     def __init__(self, store, controller):
         self._store = store
         self._controller = controller
@@ -104,6 +109,7 @@ class _VoucherCollection(Resource):
         if not is_syntactic_voucher(voucher):
             return bad_request(u"submitted voucher is syntactically invalid").render(request)
 
+        self._log.info("Accepting a voucher ({voucher}) for redemption.", voucher=voucher)
         self._controller.redeem(voucher)
         return b""
 
diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py
index 8f6e74ed6c4a71cff35f75630b7b26fb3232c543..0af3d1f86dc76db4b2e2627eedde8972e85a2338 100644
--- a/src/_zkapauthorizer/tests/strategies.py
+++ b/src/_zkapauthorizer/tests/strategies.py
@@ -55,6 +55,7 @@ from allmydata.client import (
 from ..model import (
     Pass,
     RandomToken,
+    UnblindedToken
 )
 
 
@@ -118,7 +119,7 @@ def tahoe_config_texts(storage_client_plugins):
     )
 
 
-def tahoe_configs(storage_client_plugins=None):
+def minimal_tahoe_configs(storage_client_plugins=None):
     """
     Build complete Tahoe-LAFS configurations for a node.
     """
@@ -134,6 +135,17 @@ def tahoe_configs(storage_client_plugins=None):
         ),
     )
 
+
+def tahoe_configs():
+    """
+    Build complete Tahoe-LAFS configurations including the zkapauthorizer
+    client plugin section.
+    """
+    return minimal_tahoe_configs({
+        u"privatestorageio-zkapauthz-v1": client_configurations(),
+    })
+
+
 def node_nicknames():
     """
     Builds Tahoe-LAFS node nicknames.
@@ -152,18 +164,22 @@ def node_nicknames():
     )
 
 
-def configurations():
+def server_configurations():
     """
     Build configuration values for the server-side plugin.
     """
-    return just({})
+    return just({
+        u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
+    })
 
 
 def client_configurations():
     """
     Build configuration values for the client-side plugin.
     """
-    return just({})
+    return just({
+        u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
+    })
 
 
 def vouchers():
@@ -208,6 +224,22 @@ def zkaps():
     )
 
 
+def unblinded_tokens():
+    """
+    Builds random ``_zkapauthorizer.model.UnblindedToken`` wrapping invalid
+    base64 encode data.  You cannot use these in the PrivacyPass cryptographic
+    protocol but you can put them into the database and take them out again.
+    """
+    return binary(
+        min_size=32,
+        max_size=32,
+    ).map(
+        urlsafe_b64encode,
+    ).map(
+        lambda zkap: UnblindedToken(zkap.decode("ascii")),
+    )
+
+
 def request_paths():
     """
     Build lists of unicode strings that represent the path component of an
@@ -418,4 +450,6 @@ def announcements():
     """
     Build announcements for the ZKAPAuthorizer plugin.
     """
-    return just({})
+    return just({
+        u"ristretto-issuer-root-url": u"https://issuer.example.invalid/",
+    })
diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py
index 203e44ca3ca557788daefd667fbbea3dc73de4e5..77b5bb38228a73044f7c08f98d460a024138c8b2 100644
--- a/src/_zkapauthorizer/tests/test_client_resource.py
+++ b/src/_zkapauthorizer/tests/test_client_resource.py
@@ -102,7 +102,6 @@ from ..resource import (
 
 from .strategies import (
     tahoe_configs,
-    client_configurations,
     vouchers,
     requests,
 )
@@ -121,10 +120,6 @@ def uncooperator(started=True):
 
 
 
-tahoe_configs_with_client_config = tahoe_configs(storage_client_plugins={
-    u"privatestorageio-zkapauthz-v1": client_configurations(),
-})
-
 def is_not_json(bytestring):
     """
     :param bytes bytestring: A candidate byte string to inspect.
@@ -217,7 +212,7 @@ class VoucherTests(TestCase):
         self.useFixture(CaptureTwistedLogs())
 
 
-    @given(tahoe_configs_with_client_config, requests(just([u"voucher"])))
+    @given(tahoe_configs(), requests(just([u"voucher"])))
     def test_reachable(self, get_config, request):
         """
         A resource is reachable at the ``voucher`` child of a the resource
@@ -232,7 +227,7 @@ class VoucherTests(TestCase):
         )
 
 
-    @given(tahoe_configs_with_client_config, vouchers())
+    @given(tahoe_configs(), vouchers())
     def test_put_voucher(self, get_config, voucher):
         """
         When a voucher is ``PUT`` to ``VoucherCollection`` it is passed in to the
@@ -263,7 +258,7 @@ class VoucherTests(TestCase):
             ),
         )
 
-    @given(tahoe_configs_with_client_config, invalid_bodies())
+    @given(tahoe_configs(), invalid_bodies())
     def test_put_invalid_body(self, get_config, body):
         """
         If the body of a ``PUT`` to ``VoucherCollection`` does not consist of an
@@ -294,7 +289,7 @@ class VoucherTests(TestCase):
             ),
         )
 
-    @given(tahoe_configs_with_client_config, not_vouchers())
+    @given(tahoe_configs(), not_vouchers())
     def test_get_invalid_voucher(self, get_config, not_voucher):
         """
         When a syntactically invalid voucher is requested with a ``GET`` to a
@@ -322,7 +317,7 @@ class VoucherTests(TestCase):
         )
 
 
-    @given(tahoe_configs_with_client_config, vouchers())
+    @given(tahoe_configs(), vouchers())
     def test_get_unknown_voucher(self, get_config, voucher):
         """
         When a voucher is requested with a ``GET`` to a child of
@@ -345,7 +340,7 @@ class VoucherTests(TestCase):
         )
 
 
-    @given(tahoe_configs_with_client_config, vouchers())
+    @given(tahoe_configs(), vouchers())
     def test_get_known_voucher(self, get_config, voucher):
         """
         When a voucher is first ``PUT`` and then later a ``GET`` is issued for the
@@ -398,7 +393,7 @@ class VoucherTests(TestCase):
             ),
         )
 
-    @given(tahoe_configs_with_client_config, lists(vouchers(), unique=True))
+    @given(tahoe_configs(), lists(vouchers(), unique=True))
     def test_list_vouchers(self, get_config, vouchers):
         """
         A ``GET`` to the ``VoucherCollection`` itself returns a list of existing
diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py
index 2969a01187eb56dfa3cec435846f4d021c40f7a6..4e3f264ce22a05d32535c87888d19d2af93d0147 100644
--- a/src/_zkapauthorizer/tests/test_controller.py
+++ b/src/_zkapauthorizer/tests/test_controller.py
@@ -16,11 +16,33 @@
 Tests for ``_zkapauthorizer.controller``.
 """
 
+from json import (
+    loads,
+    dumps,
+)
+from functools import (
+    partial,
+)
+from zope.interface import (
+    implementer,
+)
 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 (
@@ -30,22 +52,61 @@ from fixtures import (
 from hypothesis import (
     given,
 )
+from hypothesis.strategies import (
+    integers,
+)
+from twisted.python.url import (
+    URL,
+)
+from twisted.internet.defer import (
+    fail,
+)
+from twisted.web.iweb import (
+    IAgent,
+)
+from twisted.web.resource import (
+    ErrorPage,
+    Resource,
+)
+from twisted.web.http import (
+    UNSUPPORTED_MEDIA_TYPE,
+)
+from treq.testing import (
+    StubTreq,
+)
+
+from privacypass import (
+    SecurityException,
+    PublicKey,
+    BlindedToken,
+    BatchDLEQProof,
+    TokenPreimage,
+    VerificationSignature,
+    random_signing_key,
+)
 
 from ..controller import (
+    IRedeemer,
     NonRedeemer,
     DummyRedeemer,
+    RistrettoRedeemer,
     PaymentController,
 )
 
 from ..model import (
     memory_connect,
     VoucherStore,
+    Voucher,
+    UnblindedToken,
 )
 
 from .strategies import (
     tahoe_configs,
     vouchers,
 )
+from .matchers import (
+    Provides,
+)
 
 class PaymentControllerTests(TestCase):
     """
@@ -98,3 +159,247 @@ class PaymentControllerTests(TestCase):
             persisted_voucher.redeemed,
             Equals(True),
         )
+
+
+NOWHERE = URL.from_text(u"https://127.0.0.1/")
+
+class RistrettoRedeemerTests(TestCase):
+    """
+    Tests for ``RistrettoRedeemer``.
+    """
+    def test_interface(self):
+        """
+        An ``RistrettoRedeemer`` instance provides ``IRedeemer``.
+        """
+        redeemer = RistrettoRedeemer(stub_agent(), NOWHERE)
+        self.assertThat(
+            redeemer,
+            Provides([IRedeemer]),
+        )
+
+    @given(vouchers().map(Voucher), integers(min_value=1, max_value=100))
+    def test_good_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 ``UnblindedToken`` instances.
+        """
+        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)
+        d = redeemer.redeem(
+            voucher,
+            random_tokens,
+        )
+        self.assertThat(
+            d,
+            succeeded(
+                MatchesAll(
+                    AllMatch(
+                        IsInstance(UnblindedToken),
+                    ),
+                    HasLength(num_tokens),
+                ),
+            ),
+        )
+
+    @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 with an invalid proof then
+        ``RistrettoRedeemer.redeem`` returns a ``Deferred`` that fires with a
+        ``Failure`` wrapping ``SecurityException``.
+        """
+        signing_key = random_signing_key()
+        issuer = RistrettoRedemption(signing_key)
+
+        # Make it lie about the public key it is using.  This causes the proof
+        # to be invalid since it proves the signature was made with a
+        # different key than reported in the response.
+        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),
+                ),
+            ),
+        )
+
+    @given(vouchers().map(Voucher), integers(min_value=1, max_value=100))
+    def test_ristretto_pass_construction(self, voucher, num_tokens):
+        """
+        The passes constructed using unblinded tokens and messages pass the
+        Ristretto verification check.
+        """
+        message = b"hello world"
+
+        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)
+        d = redeemer.redeem(
+            voucher,
+            random_tokens,
+        )
+        def unblinded_tokens_to_passes(unblinded_tokens):
+            passes = redeemer.tokens_to_passes(message, unblinded_tokens)
+            return passes
+        d.addCallback(unblinded_tokens_to_passes)
+
+        self.assertThat(
+            d,
+            succeeded(
+                AfterPreprocessing(
+                    partial(ristretto_verify, signing_key, message),
+                    Equals(True),
+                ),
+            ),
+        )
+
+
+def ristretto_verify(signing_key, message, marshaled_passes):
+    """
+    Verify that the given passes were generated in a process that involved a
+    signature from the given signing key and using the given message.
+
+    :param privacypass.SigningKey signing_key: A signing key which should have
+        signed some random blinded tokens earlier in the lifecycle of the
+        passes to verify.
+
+    :param bytes message: Request binding data which is involved in the
+        generation of the passes to verify.
+
+    :param list[bytes] marshaled_passes: Token preimages and corresponding
+        message signatures to verify.  Each element contains two
+        space-separated base64 encoded values, the first representing the
+        preimage and the second representing the signature.
+
+    :return bool: ``True`` if and only if all of the passes represented by
+        ``marshaled_passes`` pass the Ristretto-defined verification for an
+        exchange using the given signing key and message.
+    """
+    def decode(marshaled_pass):
+        t, s = marshaled_pass.split(u" ")
+        return (
+            TokenPreimage.decode_base64(t.encode("ascii")),
+            VerificationSignature.decode_base64(s.encode("ascii")),
+        )
+    servers_passes = list(
+        decode(marshaled_pass.text)
+        for marshaled_pass
+        in marshaled_passes
+    )
+    servers_unblinded_tokens = list(
+        signing_key.rederive_unblinded_token(token_preimage)
+        for (token_preimage, sig)
+        in servers_passes
+    )
+    servers_verification_sigs = list(
+        sig
+        for (token_preimage, sig)
+        in servers_passes
+    )
+    servers_verification_keys = list(
+        unblinded_token.derive_verification_key_sha512()
+        for unblinded_token
+        in servers_unblinded_tokens
+    )
+    invalid_passes = list(
+        key.invalid_sha512(
+            sig,
+            message,
+        )
+        for (key, sig)
+        in zip(servers_verification_keys, servers_verification_sigs)
+    )
+
+    return not any(invalid_passes)
+
+
+def treq_for_loopback_ristretto(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 StubTreq(root)
+
+
+@implementer(IAgent)
+class _StubAgent(object):
+    def request(self, method, uri, headers=None, bodyProducer=None):
+        return fail(Exception("It's only a model."))
+
+
+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):
+        if request.requestHeaders.getRawHeaders(b"content-type") != ["application/json"]:
+            return bad_content_type(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,
+        })
+
+
+def bad_content_type(request):
+    return ErrorPage(
+        UNSUPPORTED_MEDIA_TYPE,
+        b"Unsupported media type",
+        b"Unsupported media type",
+    ).render(request)
diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py
index 1a6ac3cf944dc53b3b6ad554ea85addd170b9aaa..f614c1c912823cec16cf9db27a546c46d27a7b9b 100644
--- a/src/_zkapauthorizer/tests/test_model.py
+++ b/src/_zkapauthorizer/tests/test_model.py
@@ -65,7 +65,7 @@ from .strategies import (
     tahoe_configs,
     vouchers,
     random_tokens,
-    zkaps,
+    unblinded_tokens,
 )
 
 
@@ -255,14 +255,14 @@ class VoucherTests(TestCase):
         )
 
 
-class ZKAPStoreTests(TestCase):
+class UnblindedTokenStoreTests(TestCase):
     """
-    Tests for ZKAP-related functionality of ``VoucherStore``.
+    Tests for ``UnblindedToken``-related functionality of ``VoucherStore``.
     """
-    @given(tahoe_configs(), vouchers(), lists(zkaps(), unique=True))
-    def test_zkaps_round_trip(self, get_config, voucher_value, passes):
+    @given(tahoe_configs(), vouchers(), lists(unblinded_tokens(), unique=True))
+    def test_unblinded_tokens_round_trip(self, get_config, voucher_value, tokens):
         """
-        ZKAPs that are added to the store can later be retrieved.
+        Unblinded tokens that are added to the store can later be retrieved.
         """
         tempdir = self.useFixture(TempDir())
         config = get_config(tempdir.join(b"node"), b"tub.port")
@@ -270,18 +270,19 @@ class ZKAPStoreTests(TestCase):
             config,
             memory_connect,
         )
-        store.insert_passes_for_voucher(voucher_value, passes)
-        retrieved_passes = store.extract_passes(len(passes))
-        self.expectThat(passes, Equals(retrieved_passes))
+        store.insert_unblinded_tokens_for_voucher(voucher_value, tokens)
+        retrieved_tokens = store.extract_unblinded_tokens(len(tokens))
+        self.expectThat(tokens, Equals(retrieved_tokens))
 
-        # After extraction, the passes are no longer available.
-        more_passes = store.extract_passes(1)
-        self.expectThat([], Equals(more_passes))
+        # After extraction, the unblinded tokens are no longer available.
+        more_unblinded_tokens = store.extract_unblinded_tokens(1)
+        self.expectThat([], Equals(more_unblinded_tokens))
 
-    @given(tahoe_configs(), vouchers(), random_tokens(), zkaps())
-    def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_pass):
+    @given(tahoe_configs(), vouchers(), random_tokens(), unblinded_tokens())
+    def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_token):
         """
-        The voucher for ZKAPs that are added to the store are marked as redeemed.
+        The voucher for unblinded tokens that are added to the store is marked as
+        redeemed.
         """
         tempdir = self.useFixture(TempDir())
         config = get_config(tempdir.join(b"node"), b"tub.port")
@@ -290,6 +291,6 @@ class ZKAPStoreTests(TestCase):
             memory_connect,
         )
         store.add(voucher_value, [token])
-        store.insert_passes_for_voucher(voucher_value, [one_pass])
+        store.insert_unblinded_tokens_for_voucher(voucher_value, [one_token])
         loaded_voucher = store.get(voucher_value)
         self.assertThat(loaded_voucher.redeemed, Equals(True))
diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py
index 2587904e68bf78928d8b4344a653de7db4d4a4cd..d497c724877505caf6ae95cbc451e429fbb2d384 100644
--- a/src/_zkapauthorizer/tests/test_plugin.py
+++ b/src/_zkapauthorizer/tests/test_plugin.py
@@ -16,6 +16,10 @@
 Tests for the Tahoe-LAFS plugin.
 """
 
+from io import (
+    BytesIO,
+)
+
 from zope.interface import (
     implementer,
 )
@@ -36,11 +40,15 @@ from testtools.matchers import (
 from testtools.twistedsupport import (
     succeeded,
 )
-
+from testtools.content import (
+    text_content,
+)
 from hypothesis import (
     given,
 )
-
+from hypothesis.strategies import (
+    just,
+)
 from foolscap.broker import (
     Broker,
 )
@@ -75,14 +83,18 @@ from twisted.plugins.zkapauthorizer import (
 from ..model import (
     VoucherStore,
 )
+from ..controller import (
+    IssuerConfigurationMismatch,
+)
 
 from .strategies import (
+    minimal_tahoe_configs,
     tahoe_configs,
-    configurations,
+    server_configurations,
     announcements,
     vouchers,
     random_tokens,
-    zkaps,
+    unblinded_tokens,
     storage_indexes,
     lease_renew_secrets,
 )
@@ -134,7 +146,7 @@ class ServerPluginTests(TestCase):
     Tests for the plugin's implementation of
     ``IFoolscapStoragePlugin.get_storage_server``.
     """
-    @given(configurations())
+    @given(server_configurations())
     def test_returns_announceable(self, configuration):
         """
         ``storage_server.get_storage_server`` returns an instance which provides
@@ -150,7 +162,7 @@ class ServerPluginTests(TestCase):
         )
 
 
-    @given(configurations())
+    @given(server_configurations())
     def test_returns_referenceable(self, configuration):
         """
         The storage server attached to the result of
@@ -171,7 +183,7 @@ class ServerPluginTests(TestCase):
             ),
         )
 
-    @given(configurations())
+    @given(server_configurations())
     def test_returns_serializable(self, configuration):
         """
         The storage server attached to the result of
@@ -195,7 +207,7 @@ class ServerPluginTests(TestCase):
         )
 
 
-    @given(configurations())
+    @given(server_configurations())
     def test_returns_hashable(self, configuration):
         """
         The storage server attached to the result of
@@ -221,6 +233,13 @@ class ServerPluginTests(TestCase):
         )
 
 
+tahoe_configs_with_dummy_redeemer = minimal_tahoe_configs({
+    u"privatestorageio-zkapauthz-v1": just({u"redeemer": u"dummy"}),
+})
+
+tahoe_configs_with_mismatched_issuer = minimal_tahoe_configs({
+    u"privatestorageio-zkapauthz-v1": just({u"ristretto-issuer-root-url": u"https://another-issuer.example.invalid/"}),
+})
 
 class ClientPluginTests(TestCase):
     """
@@ -251,28 +270,53 @@ class ClientPluginTests(TestCase):
         )
 
 
+    @given(tahoe_configs_with_mismatched_issuer, announcements())
+    def test_mismatched_ristretto_issuer(self, get_config, announcement):
+        """
+        ``get_storage_client`` raises an exception when called with an
+        announcement and local configuration which specify different issuers.
+        """
+        tempdir = self.useFixture(TempDir())
+        node_config = get_config(
+            tempdir.join(b"node"),
+            b"tub.port",
+        )
+        config_text = BytesIO()
+        node_config.config.write(config_text)
+        self.addDetail(u"config", text_content(config_text.getvalue()))
+        self.addDetail(u"announcement", text_content(unicode(announcement)))
+        try:
+            result = storage_server.get_storage_client(node_config, announcement, get_rref)
+        except IssuerConfigurationMismatch:
+            pass
+        except Exception as e:
+            self.fail("get_storage_client raised the wrong exception: {}".format(e))
+        else:
+            self.fail("get_storage_client didn't raise, returned: {}".format(result))
+
+
     @given(
-        tahoe_configs(),
+        tahoe_configs_with_dummy_redeemer,
         announcements(),
         vouchers(),
         random_tokens(),
-        zkaps(),
+        unblinded_tokens(),
         storage_indexes(),
         lease_renew_secrets(),
     )
-    def test_passes_extracted(
+    def test_unblinded_tokens_extracted(
             self,
             get_config,
             announcement,
             voucher,
             token,
-            zkap,
+            unblinded_token,
             storage_index,
             renew_secret,
     ):
         """
         The ``ZKAPAuthorizerStorageServer`` returned by ``get_storage_client``
-        extracts passes from the plugin database.
+        extracts unblinded tokens from the plugin database.
         """
         tempdir = self.useFixture(TempDir())
         node_config = get_config(
@@ -282,7 +326,7 @@ class ClientPluginTests(TestCase):
 
         store = VoucherStore.from_node_config(node_config)
         store.add(voucher, [token])
-        store.insert_passes_for_voucher(voucher, [zkap])
+        store.insert_unblinded_tokens_for_voucher(voucher, [unblinded_token])
 
         storage_client = storage_server.get_storage_client(
             node_config,
@@ -298,8 +342,8 @@ class ClientPluginTests(TestCase):
         )
         d.addBoth(lambda ignored: None)
 
-        # There should be no passes left to extract.
-        remaining = store.extract_passes(1)
+        # There should be no unblinded tokens left to extract.
+        remaining = store.extract_unblinded_tokens(1)
         self.assertThat(
             remaining,
             Equals([]),
diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py
index 51cac393c395cdd3dc520aacc864dc81028e6d3b..0bd6cb80835986f16bee3f4eacfa72f635608bec 100644
--- a/src/_zkapauthorizer/tests/test_storage_protocol.py
+++ b/src/_zkapauthorizer/tests/test_storage_protocol.py
@@ -160,8 +160,15 @@ class ShareTests(TestCase):
         self.canary = LocalReferenceable(None)
         self.anonymous_storage_server = self.useFixture(AnonymousStorageServer()).storage_server
 
-        def get_passes():
-            return [Pass(u"x" * TOKEN_LENGTH)]
+        def get_passes(message, count):
+            if not isinstance(message, bytes):
+                raise TypeError("message must be bytes")
+            try:
+                message.decode("utf-8")
+            except UnicodeDecodeError:
+                raise TypeError("message must be valid utf-8")
+
+            return [Pass(u"x" * TOKEN_LENGTH)] * count
 
         self.server = ZKAPAuthorizerStorageServer(
             self.anonymous_storage_server,
@@ -512,8 +519,8 @@ class ShareTests(TestCase):
         # transit the network differently from keyword arguments.  Yay.
         d = self.client._rref.callRemote(
             "slot_testv_and_readv_and_writev",
-            # tokens
-            self.client._get_encoded_passes(),
+            # passes
+            self.client._get_encoded_passes(storage_index, 1),
             # storage_index
             storage_index,
             # secrets