Skip to content
Snippets Groups Projects
model.py 35.4 KiB
Newer Older
  • Learn to ignore specific revisions
  • # Copyright 2019 PrivateStorage.io, LLC
    #
    # Licensed under the Apache License, Version 2.0 (the "License");
    # you may not use this file except in compliance with the License.
    # You may obtain a copy of the License at
    #
    #     http://www.apache.org/licenses/LICENSE-2.0
    #
    # Unless required by applicable law or agreed to in writing, software
    # distributed under the License is distributed on an "AS IS" BASIS,
    # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    # See the License for the specific language governing permissions and
    # limitations under the License.
    
    """
    This module implements models (in the MVC sense) for the client side of
    the storage plugin.
    """
    
    
    Tom Prince's avatar
    Tom Prince committed
    from datetime import datetime
    from functools import wraps
    from json import dumps, loads
    from sqlite3 import OperationalError
    from sqlite3 import connect as _connect
    
    Tom Prince's avatar
    Tom Prince committed
    from aniso8601 import parse_datetime as _parse_datetime
    from twisted.logger import Logger
    from twisted.python.filepath import FilePath
    from zope.interface import Interface, implementer
    
    Tom Prince's avatar
    Tom Prince committed
    from ._base64 import urlsafe_b64decode
    from .schema import get_schema_upgrades, get_schema_version, run_schema_upgrades
    
        get_configured_pass_value,
    
    Tom Prince's avatar
    Tom Prince committed
        pass_value_attribute,
    
    Tom Prince's avatar
    Tom Prince committed
    from .validators import greater_than, has_length, is_base64_encoded
    
    def parse_datetime(s, **kw):
        """
        Like ``aniso8601.parse_datetime`` but accept unicode as well.
        """
        if isinstance(s, unicode):
            s = s.encode("utf-8")
        assert isinstance(s, bytes)
        if "delimiter" in kw and isinstance(kw["delimiter"], unicode):
            kw["delimiter"] = kw["delimiter"].encode("utf-8")
        return _parse_datetime(s, **kw)
    
    
    
    class ILeaseMaintenanceObserver(Interface):
        """
        An object which is interested in receiving events related to the progress
        of lease maintenance activity.
        """
    
    Tom Prince's avatar
    Tom Prince committed
    
    
        def observe(sizes):
            """
            Observe some shares encountered during lease maintenance.
    
            :param list[int] sizes: The sizes of the shares encountered.
            """
    
        def finish():
            """
            Observe that a run of lease maintenance has completed.
            """
    
    
    
    class StoreOpenError(Exception):
        """
        There was a problem opening the underlying data store.
        """
    
    Tom Prince's avatar
    Tom Prince committed
    
    
        def __init__(self, reason):
            self.reason = reason
    
    
    
    class NotEnoughTokens(Exception):
        """
        An attempt to extract tokens failed because the store does not contain as
        many tokens as were requested.
        """
    
    
    
    CONFIG_DB_NAME = u"privatestorageio-zkapauthz-v1.sqlite3"
    
    Tom Prince's avatar
    Tom Prince committed
    
    
    def open_and_initialize(path, connect=None):
    
        Open a SQLite3 database for use as a voucher store.
    
    
        Create the database and populate it with a schema, if it does not already
        exist.
    
        :param FilePath path: The location of the SQLite3 database file.
    
        :return: A SQLite3 connection object for the database at the given path.
        """
        if connect is None:
            connect = _connect
    
        try:
            path.parent().makedirs(ignoreExistingDirectory=True)
        except OSError as e:
    
            raise StoreOpenError(e)
    
        dbfile = path.asBytesMode().path
        try:
            conn = connect(
                dbfile,
                isolation_level="IMMEDIATE",
            )
        except OperationalError as e:
            raise StoreOpenError(e)
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
        # Enforcement of foreign key constraints is off by default.  It must be
        # enabled on a per-connection basis.  This is a helpful feature to ensure
        # consistency so we want it enforced and we use it in our schema.
    
        conn.execute("PRAGMA foreign_keys = ON")
    
    
        with conn:
            cursor = conn.cursor()
    
            actual_version = get_schema_version(cursor)
            schema_upgrades = list(get_schema_upgrades(actual_version))
            run_schema_upgrades(schema_upgrades, cursor)
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
        # Create some tables that only exist (along with their contents) for
    
        # this connection.  These are outside of the schema because they are not
        # persistent.  We can change them any time we like without worrying about
        # upgrade logic because we re-create them on every connection.
        conn.execute(
            """
    
            -- Track tokens in use by the process holding this connection.
    
            CREATE TEMPORARY TABLE [in-use] (
                [unblinded-token] text, -- The base64 encoded unblinded token.
    
                PRIMARY KEY([unblinded-token])
                -- A foreign key on unblinded-token to [unblinded-tokens]([token])
                -- would be alright - however SQLite3 foreign key constraints
                -- can't cross databases (and temporary tables are considered to
    
                -- be in a different database than normal tables).
            )
            """,
    
            -- Track tokens that we want to remove from the database.  Mainly just
            -- works around the awkward DB-API interface for dealing with deleting
            -- many rows.
    
            CREATE TEMPORARY TABLE [to-discard] (
                [unblinded-token] text
            )
            """,
        )
        conn.execute(
            """
    
            -- Track tokens that we want to remove from the [in-use] set.  Similar
            -- to [to-discard].
    
            CREATE TEMPORARY TABLE [to-reset] (
                [unblinded-token] text
            )
            """,
        )
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
        """
        Decorate a function so it is automatically passed a cursor with an active
        transaction as the first positional argument.  If the function returns
        normally then the transaction will be committed.  Otherwise, the
        transaction will be rolled back.
        """
    
        @wraps(f)
        def with_cursor(self, *a, **kw):
            with self._connection:
    
                cursor = self._connection.cursor()
                cursor.execute("BEGIN IMMEDIATE TRANSACTION")
                return f(self, cursor, *a, **kw)
    
    def memory_connect(path, *a, **kw):
        """
        Always connect to an in-memory SQLite3 database.
        """
        return _connect(":memory:", *a, **kw)
    
    
    
    # The largest integer SQLite3 can represent in an integer column.  Larger than
    # this an the representation loses precision as a floating point.
    _SQLITE3_INTEGER_MAX = 2 ** 63 - 1
    
    
    
        This class implements persistence for vouchers.
    
        :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for
    
            the node that owns the persisted vouchers.
    
    
        :ivar now: A no-argument callable that returns the time of the call as a
            ``datetime`` instance.
    
        pass_value = pass_value_attribute()
    
    
        database_path = attr.ib(validator=attr.validators.instance_of(FilePath))
    
        _connection = attr.ib()
    
        @classmethod
    
        def from_node_config(cls, node_config, now, connect=None):
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
            """
            Create or open the ``VoucherStore`` for a given node.
    
            :param allmydata.node._Config node_config: The Tahoe-LAFS
                configuration object for the node for which we want to open a
                store.
    
    
            :param now: See ``VoucherStore.now``.
    
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
            :param connect: An alternate database connection function.  This is
                primarily for the purposes of the test suite.
            """
    
            db_path = FilePath(node_config.get_private_path(CONFIG_DB_NAME))
            conn = open_and_initialize(
                db_path,
    
                connect=connect,
    
                get_configured_pass_value(node_config),
    
        def get(self, cursor, voucher):
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
            """
            :param unicode voucher: The text value of a voucher to retrieve.
    
            :return Voucher: The voucher object that matches the given value.
            """
    
                    [number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
    
                (voucher,),
    
            )
            refs = cursor.fetchall()
            if len(refs) == 0:
    
                raise KeyError(voucher)
    
            return Voucher.from_row(refs[0])
    
        def add(self, cursor, voucher, expected_tokens, counter, get_tokens):
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
            """
    
            Add random tokens associated with a voucher (possibly new, possibly
            existing) to the database.  If the (voucher, counter) pair is already
            present, do nothing.
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
    
    
            :param unicode voucher: The text value of a voucher with which to
                associate the tokens.
    
    
            :param int expected_tokens: The total number of tokens for which this
                voucher is expected to be redeemed.  This is only respected the
                first time a voucher is added.  Subsequent calls with the same
                voucher but a different count ignore the value because it is
                already known (and the database knows better than the caller what
                it should be).
    
                This probably means ``add`` is a broken interface for doing these
                two things.  Maybe it should be fixed someday.
    
    
            :param int counter: The redemption counter for the given voucher with
                which to associate the tokens.
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
    
            :param list[RandomToken]: The tokens to add alongside the voucher.
            """
    
            now = self.now()
            if not isinstance(now, datetime):
                raise TypeError("{} returned {}, expected datetime".format(self.now, now))
    
    
                WHERE [voucher] = ? AND [counter] = ?
    
            rows = cursor.fetchall()
            if len(rows) > 0:
                self._log.info(
    
                    "Loaded {count} random tokens for a voucher ({voucher}[{counter}]).",
    
                    count=len(rows),
                    voucher=voucher,
    
    Tom Prince's avatar
    Tom Prince committed
                tokens = list(RandomToken(token_value) for (token_value,) in rows)
    
            else:
                tokens = get_tokens()
                self._log.info(
    
                    "Persisting {count} random tokens for a voucher ({voucher}[{counter}]).",
    
                    count=len(tokens),
                    voucher=voucher,
    
                    INSERT OR IGNORE INTO [vouchers] ([number], [expected-tokens], [created]) VALUES (?, ?, ?)
    
    Tom Prince's avatar
    Tom Prince committed
                    (voucher, expected_tokens, self.now()),
    
                    INSERT INTO [tokens] ([voucher], [counter], [text]) VALUES (?, ?, ?)
    
    Tom Prince's avatar
    Tom Prince committed
                    list((voucher, counter, token.token_value) for token in tokens),
    
    
        @with_cursor
        def list(self, cursor):
    
    Jean-Paul Calderone's avatar
    Jean-Paul Calderone committed
            """
            Get all known vouchers.
    
            :return list[Voucher]: All vouchers known to the store.
            """
    
                    [number], [created], [expected-tokens], [state], [finished], [token-count], [public-key], [counter]
    
                """,
            )
            refs = cursor.fetchall()
    
    Tom Prince's avatar
    Tom Prince committed
            return list(Voucher.from_row(row) for row in refs)
    
        def _insert_unblinded_tokens(self, cursor, unblinded_tokens, group_id):
    
            Helper function to really insert unblinded tokens into the database.
    
            """
            cursor.executemany(
                """
    
                INSERT INTO [unblinded-tokens] ([token], [redemption-group]) VALUES (?, ?)
    
    Tom Prince's avatar
    Tom Prince committed
                list((token, group_id) for token in unblinded_tokens),
    
        def insert_unblinded_tokens(self, cursor, unblinded_tokens, group_id):
    
            """
            Store some unblinded tokens, for example as part of a backup-restore
            process.
    
            :param list[unicode] unblinded_tokens: The unblinded tokens to store.
    
    
            :param int group_id: The unique identifier of the redemption group to
                which these tokens belong.
    
            self._insert_unblinded_tokens(cursor, unblinded_tokens, group_id)
    
    Tom Prince's avatar
    Tom Prince committed
        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.
    
            :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 unicode public_key: The encoded public key for the private key
                which was used to sign these tokens.
    
    
            :param list[UnblindedToken] unblinded_tokens: The unblinded tokens to
                store.
    
    
            :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.
    
    Tom Prince's avatar
    Tom Prince committed
            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)
    
    
                INSERT INTO [redemption-groups] ([voucher], [public-key], [spendable]) VALUES (?, ?, ?)
    
                (voucher, public_key, spendable),
    
            self._log.info(
                "Recording {count} {unspendable}spendable unblinded tokens from public key {public_key}.",
                count=len(unblinded_tokens),
                unspendable="" if spendable else "un",
                public_key=public_key,
            )
    
    
            cursor.execute(
                """
                UPDATE [vouchers]
                SET [state] = ?
    
                  , [token-count] = COALESCE([token-count], 0) + ?
    
                  , [sequestered-count] = COALESCE([sequestered-count], 0) + ?
    
                    token_count_increase,
                    sequestered_count_increase,
    
            if cursor.rowcount == 0:
    
    Tom Prince's avatar
    Tom Prince committed
                raise ValueError(
                    "Cannot insert tokens for unknown voucher; add voucher first"
                )
    
            self._insert_unblinded_tokens(
                cursor,
    
    Tom Prince's avatar
    Tom Prince committed
                list(t.unblinded_token for t in unblinded_tokens),
    
        @with_cursor
        def mark_voucher_double_spent(self, cursor, voucher):
            """
            Mark a voucher as having failed redemption because it has already been
            spent.
            """
            cursor.execute(
                """
                UPDATE [vouchers]
                SET [state] = "double-spend"
    
                WHERE [number] = ?
                  AND [state] = "pending"
                """,
    
                (self.now(), voucher),
    
            )
            if cursor.rowcount == 0:
                # Was there no matching voucher or was it in the wrong state?
                cursor.execute(
                    """
                    SELECT [state]
                    FROM [vouchers]
                    WHERE [number] = ?
                    """,
    
    Tom Prince's avatar
    Tom Prince committed
                    (voucher,),
    
                )
                rows = cursor.fetchall()
                if len(rows) == 0:
                    raise ValueError("Voucher {} not found".format(voucher))
                else:
                    raise ValueError(
                        "Voucher {} in state {} cannot transition to double-spend".format(
                            voucher,
                            rows[0][0],
                        ),
                    )
    
    
        @with_cursor
        def get_unblinded_tokens(self, cursor, count):
            """
            Get some unblinded tokens.
    
            These tokens are not removed from the store but they will not be
            returned from a future call to ``get_unblinded_tokens`` *on this
            ``VoucherStore`` instance* unless ``reset_unblinded_tokens`` is used
            to reset their state.
    
            If the underlying storage is access via another ``VoucherStore``
            instance then the behavior of this method will be as if all tokens
            which have not had their state changed to invalid or spent have been
            reset.
    
    
            :raise NotEnoughTokens: If there are fewer than the requested number
                of tokens available to be spent.  In this case, all tokens remain
                available to future calls and do not need to be reset.
    
    
            :return list[UnblindedTokens]: The removed unblinded tokens.
            """
            if count > _SQLITE3_INTEGER_MAX:
                # An unreasonable number of tokens and also large enough to
                # provoke undesirable behavior from the database.
                raise NotEnoughTokens()
    
            cursor.execute(
                """
    
                SELECT T.[token]
                FROM   [unblinded-tokens] AS T, [redemption-groups] AS G
                WHERE  T.[redemption-group] = G.[rowid]
                AND    G.[spendable] = 1
                AND    T.[token] NOT IN [in-use]
    
            texts = cursor.fetchall()
            if len(texts) < count:
    
            cursor.executemany(
    
                INSERT INTO [in-use] VALUES (?)
    
    Tom Prince's avatar
    Tom Prince committed
            return list(UnblindedToken(t) for (t,) in texts)
    
        @with_cursor
        def count_unblinded_tokens(self, cursor):
            """
            Return the largest number of unblinded tokens that can be requested from
            ``get_unblinded_tokens`` without causing it to raise
            ``NotEnoughTokens``.
            """
            cursor.execute(
                """
                SELECT count(1)
    
                FROM   [unblinded-tokens] AS T, [redemption-groups] AS G
                WHERE  T.[redemption-group] = G.[rowid]
                AND    G.[spendable] = 1
                AND    T.[token] NOT IN [in-use]
    
                """,
            )
            (count,) = cursor.fetchone()
            return count
    
    
        @with_cursor
        def discard_unblinded_tokens(self, cursor, unblinded_tokens):
            """
            Get rid of some unblinded tokens.  The tokens will be completely removed
            from the system.  This is useful when the tokens have been
            successfully spent.
    
            :param list[UnblindedToken] unblinded_tokens: The tokens to discard.
    
            :return: ``None``
            """
            cursor.executemany(
                """
                INSERT INTO [to-discard] VALUES (?)
                """,
                list((token.unblinded_token,) for token in unblinded_tokens),
            )
            cursor.execute(
                """
    
                DELETE FROM [in-use]
    
                WHERE [unblinded-token] IN [to-discard]
    
                """,
            )
            cursor.execute(
                """
                DELETE FROM [unblinded-tokens]
    
                WHERE [token] IN [to-discard]
    
                DELETE FROM [to-discard]
    
                """,
            )
    
        @with_cursor
        def invalidate_unblinded_tokens(self, cursor, reason, unblinded_tokens):
            """
            Mark some unblinded tokens as invalid and unusable.  Some record of the
            tokens may be retained for future inspection.  These tokens will not
            be returned by any future ``get_unblinded_tokens`` call.  This is
            useful when an attempt to spend a token has met with rejection by the
            validator.
    
            :param list[UnblindedToken] unblinded_tokens: The tokens to mark.
    
            :return: ``None``
            """
            cursor.executemany(
                """
                INSERT INTO [invalid-unblinded-tokens] VALUES (?, ?)
                """,
    
    Tom Prince's avatar
    Tom Prince committed
                list((token.unblinded_token, reason) for token in unblinded_tokens),
    
                DELETE FROM [in-use]
    
                WHERE [unblinded-token] IN (SELECT [token] FROM [invalid-unblinded-tokens])
                """,
            )
            cursor.execute(
                """
                DELETE FROM [unblinded-tokens]
                WHERE [token] IN (SELECT [token] FROM [invalid-unblinded-tokens])
                """,
            )
    
        @with_cursor
        def reset_unblinded_tokens(self, cursor, unblinded_tokens):
            """
            Make some unblinded tokens available to be retrieved from the store again.
            This is useful if a spending operation has failed with a transient
            error.
            """
            cursor.executemany(
                """
                INSERT INTO [to-reset] VALUES (?)
                """,
                list((token.unblinded_token,) for token in unblinded_tokens),
            )
            cursor.execute(
                """
    
                DELETE FROM [in-use]
    
                WHERE [unblinded-token] IN [to-reset]
    
                DELETE FROM [to-reset]
    
        @with_cursor
        def backup(self, cursor):
            """
            Read out all state necessary to recreate this database in the event it is
            lost.
            """
            cursor.execute(
                """
    
                SELECT [token] FROM [unblinded-tokens] ORDER BY [rowid]
    
                """,
            )
            tokens = cursor.fetchall()
            return {
                u"unblinded-tokens": list(token for (token,) in tokens),
            }
    
    
        def start_lease_maintenance(self):
            """
            Get an object which can track a newly started round of lease maintenance
            activity.
    
            :return LeaseMaintenance: A new, started lease maintenance object.
            """
    
            m = LeaseMaintenance(self.pass_value, self.now, self._connection)
    
            m.start()
            return m
    
        @with_cursor
        def get_latest_lease_maintenance_activity(self, cursor):
            """
            Get a description of the most recently completed lease maintenance
            activity.
    
            :return LeaseMaintenanceActivity|None: If any lease maintenance has
                completed, an object describing its results.  Otherwise, None.
            """
            cursor.execute(
                """
                SELECT [started], [count], [finished]
                FROM [lease-maintenance-spending]
                WHERE [finished] IS NOT NULL
                ORDER BY [finished] DESC
                LIMIT 1
                """,
            )
            activity = cursor.fetchall()
            if len(activity) == 0:
                return None
            [(started, count, finished)] = activity
            return LeaseMaintenanceActivity(
                parse_datetime(started, delimiter=u" "),
                count,
                parse_datetime(finished, delimiter=u" "),
            )
    
    
    
    @implementer(ILeaseMaintenanceObserver)
    
    @attr.s
    class LeaseMaintenance(object):
        """
        A state-updating helper for recording pass usage during a lease
        maintenance run.
    
        Get one of these from ``VoucherStore.start_lease_maintenance``.  Then use
        the ``observe`` and ``finish`` methods to persist state about a lease
        maintenance run.
    
    
        :ivar int _pass_value: The value of a single ZKAP in byte-months.
    
    
        :ivar _now: A no-argument callable which returns a datetime giving a time
            to use as current.
    
        :ivar _connection: A SQLite3 connection object to use to persist observed
            information.
    
        :ivar _rowid: None for unstarted lease maintenance objects.  For started
            objects, the database row id that corresponds to the started run.
            This is used to make sure future updates go to the right row.
        """
    
        _pass_value = pass_value_attribute()
    
        _now = attr.ib()
        _connection = attr.ib()
        _rowid = attr.ib(default=None)
    
        @with_cursor
        def start(self, cursor):
            """
            Record the start of a lease maintenance run.
            """
            if self._rowid is not None:
                raise Exception("Cannot re-start a particular _LeaseMaintenance.")
    
            cursor.execute(
                """
                INSERT INTO [lease-maintenance-spending] ([started], [finished], [count])
                VALUES (?, ?, ?)
                """,
                (self._now(), None, 0),
            )
            self._rowid = cursor.lastrowid
    
        @with_cursor
    
        def observe(self, cursor, sizes):
    
            Record a storage shares of the given sizes.
    
            count = required_passes(self._pass_value, sizes)
    
            cursor.execute(
                """
                UPDATE [lease-maintenance-spending]
                SET [count] = [count] + ?
                WHERE [id] = ?
                """,
                (count, self._rowid),
            )
    
        @with_cursor
        def finish(self, cursor):
            """
            Record the completion of this lease maintenance run.
            """
            cursor.execute(
                """
                UPDATE [lease-maintenance-spending]
                SET [finished] = ?
                WHERE [id] = ?
                """,
                (self._now(), self._rowid),
            )
            self._rowid = None
    
    
    @attr.s
    class LeaseMaintenanceActivity(object):
        started = attr.ib()
        passes_required = attr.ib()
        finished = attr.ib()
    
    
    # store = ...
    # x = store.start_lease_maintenance()
    # x.observe(size=123)
    # x.observe(size=456)
    # ...
    # x.finish()
    #
    # x = store.get_latest_lease_maintenance_activity()
    # xs.started, xs.passes_required, xs.finished
    
    
    @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 unblinded_token: The base64 encoded serialized form of the
            unblinded token.  This can be used to reconstruct a
    
            ``challenge_bypass_ristretto.UnblindedToken`` using that class's
            ``decode_base64`` method.
    
        unblinded_token = attr.ib(
            validator=attr.validators.and_(
                attr.validators.instance_of(unicode),
                is_base64_encoded(),
                has_length(128),
            ),
        )
    
    @attr.s(frozen=True)
    class Pass(object):
        """
        A ``Pass`` instance completely represents a single Zero-Knowledge Access Pass.
    
    
        :ivar unicode pass_text: The text value of the pass.  This can be sent to
            a service provider one time to anonymously prove a prior voucher
    
            redemption.  If it is sent more than once the service provider may
            choose to reject it and the anonymity property is compromised.  Pass
            text should be kept secret.  If pass text is divulged to third-parties
            the anonymity property may be compromised.
        """
    
        preimage = attr.ib(
            validator=attr.validators.and_(
                attr.validators.instance_of(unicode),
                is_base64_encoded(),
                has_length(88),
            ),
        )
    
        signature = attr.ib(
            validator=attr.validators.and_(
                attr.validators.instance_of(unicode),
                is_base64_encoded(),
                has_length(88),
            ),
        )
    
        @property
        def pass_text(self):
            return u"{} {}".format(self.preimage, self.signature)
    
    
    
    @attr.s(frozen=True)
    class RandomToken(object):
    
        """
        :ivar unicode token_value: The base64-encoded representation of the random
            token.
        """
    
        token_value = attr.ib(
            validator=attr.validators.and_(
                attr.validators.instance_of(unicode),
                is_base64_encoded(),
                has_length(128),
            ),
        )
    
    def _counter_attribute():
        return attr.ib(
            validator=attr.validators.and_(
                attr.validators.instance_of((int, long)),
                greater_than(-1),
            ),
        )
    
    
    
    @attr.s(frozen=True)
    class Pending(object):
    
        """
        The voucher has not yet been completely redeemed for ZKAPs.
    
        :ivar int counter: The number of partial redemptions which have been
            successfully performed for the voucher.
        """
    
        counter = _counter_attribute()
    
        def to_json_v1(self):
            return {
                u"name": u"pending",
    
            }
    
    
    @attr.s(frozen=True)
    class Redeeming(object):
        """
        This is a non-persistent state in which a voucher exists when the database
        state is **pending** but for which there is a redemption operation in
        progress.
        """
    
        started = attr.ib(validator=attr.validators.instance_of(datetime))
    
        counter = _counter_attribute()
    
        def to_json_v1(self):
            return {
                u"name": u"redeeming",
                u"started": self.started.isoformat(),
    
                u"counter": self.counter,
    
    
    
    @attr.s(frozen=True)
    class Redeemed(object):
    
        """
        The voucher was successfully redeemed.  Associated tokens were retrieved
        and stored locally.
    
        :ivar datetime finished: The time when the redemption finished.
    
        :ivar int token_count: The number of tokens the voucher was redeemed for.
        """
    
        finished = attr.ib(validator=attr.validators.instance_of(datetime))
        token_count = attr.ib(validator=attr.validators.instance_of((int, long)))
    
    
        def to_json_v1(self):
            return {
                u"name": u"redeemed",
                u"finished": self.finished.isoformat(),
                u"token-count": self.token_count,
            }
    
    
    
    @attr.s(frozen=True)
    class DoubleSpend(object):
        finished = attr.ib(validator=attr.validators.instance_of(datetime))
    
    
        def to_json_v1(self):
            return {
                u"name": u"double-spend",
                u"finished": self.finished.isoformat(),
            }
    
    
    @attr.s(frozen=True)
    class Unpaid(object):
        """
        This is a non-persistent state in which a voucher exists when the database
        state is **pending** but the most recent redemption attempt has failed due
        to lack of payment.
        """
    
        finished = attr.ib(validator=attr.validators.instance_of(datetime))
    
    
        def to_json_v1(self):
            return {
                u"name": u"unpaid",
                u"finished": self.finished.isoformat(),
            }
    
    
    @attr.s(frozen=True)
    class Error(object):
        """
        This is a non-persistent state in which a voucher exists when the database
        state is **pending** but the most recent redemption attempt has failed due
        to an error that is not handled by any other part of the system.
        """
    
        finished = attr.ib(validator=attr.validators.instance_of(datetime))