diff --git a/src/_zkapauthorizer/_plugin.py b/src/_zkapauthorizer/_plugin.py index e83f3166663e3a9a9e849be57dbc77f458257d3c..8b16a7751eec14dc61e1751101839724440ea346 100644 --- a/src/_zkapauthorizer/_plugin.py +++ b/src/_zkapauthorizer/_plugin.py @@ -17,6 +17,14 @@ The Twisted plugin that glues the Zero-Knowledge Access Pass system into Tahoe-LAFS. """ +from weakref import ( + WeakValueDictionary, +) + +from functools import ( + partial, +) + import attr from zope.interface import ( @@ -37,14 +45,18 @@ from .api import ( ZKAPAuthorizerStorageClient, ) -from ._storage_server import ( - TOKEN_LENGTH, +from .model import ( + VoucherStore, ) from .resource import ( from_configuration as resource_from_configuration, ) +from .controller import ( + DummyRedeemer, +) + @implementer(IAnnounceableStorageServer) @attr.s class AnnounceableStorageServer(object): @@ -52,14 +64,37 @@ class AnnounceableStorageServer(object): storage_server = attr.ib() - +@attr.s @implementer(IFoolscapStoragePlugin) class ZKAPAuthorizer(object): """ A storage plugin which provides a token-based access control mechanism on top of the Tahoe-LAFS built-in storage server interface. + + :ivar WeakValueDictionary _stores: A mapping from node directories to this + plugin's database connections for those nodes. The existence of any + kind of attribute to reference database connections (not so much the + fact that it is a WeakValueDictionary; if it were just a weakref the + same would be true) probably reflects an error in the interface which + forces different methods to use instance state to share a database + connection. """ - name = u"privatestorageio-zkapauthz-v1" + name = attr.ib(default=u"privatestorageio-zkapauthz-v1") + _stores = attr.ib(default=attr.Factory(WeakValueDictionary)) + + def _get_store(self, node_config): + """ + :return VoucherStore: The database for the given node. At most one + connection is made to the database per ``ZKAPAuthorizer`` instance. + """ + key = node_config.get_config_path() + try: + s = self._stores[key] + except KeyError: + s = VoucherStore.from_node_config(node_config) + self._stores[key] = s + return s + def get_storage_server(self, configuration, get_anonymous_storage_server): announcement = {} @@ -75,14 +110,24 @@ class ZKAPAuthorizer(object): ) - def get_storage_client(self, configuration, announcement, get_rref): - return succeed( - ZKAPAuthorizerStorageClient( - get_rref, - lambda: [b"x" * TOKEN_LENGTH], - ) + def get_storage_client(self, node_config, announcement, get_rref): + """ + Create an ``IStorageClient`` that submits ZKAPs with certain requests in + order to authorize them. The ZKAPs are extracted from the database + managed by this plugin in the node directory that goes along with + ``node_config``. + """ + 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), ) def get_client_resource(self, node_config): - return resource_from_configuration(node_config) + return resource_from_configuration( + node_config, + store=self._get_store(node_config), + redeemer=DummyRedeemer(), + ) diff --git a/src/_zkapauthorizer/_storage_client.py b/src/_zkapauthorizer/_storage_client.py index 7615bbaf1b2fb08a8f9509e596bacedffc9d67dc..e0dc41ddca2693016c17785a9dc4b655b7f2b585 100644 --- a/src/_zkapauthorizer/_storage_client.py +++ b/src/_zkapauthorizer/_storage_client.py @@ -49,16 +49,27 @@ class ZKAPAuthorizerStorageClient(object): valid ``RemoteReference`` corresponding to the server-side object for this scheme. - :ivar _get_tokens: A no-argument callable which retrieves some tokens + :ivar _get_passes: A no-argument callable which retrieves some passes which can be used to authorize an operation. """ _get_rref = attr.ib() - _get_tokens = attr.ib() + _get_passes = attr.ib() @property def _rref(self): return self._get_rref() + def _get_encoded_passes(self): + """ + :return: A list of passes from ``_get_passes`` encoded into their + ``bytes`` representation. + """ + return list( + t.text.encode("ascii") + for t + in self._get_passes() + ) + def get_version(self): return self._rref.callRemote( "get_version", @@ -75,7 +86,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "allocate_buckets", - self._get_tokens(), + self._get_encoded_passes(), storage_index, renew_secret, cancel_secret, @@ -101,7 +112,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "add_lease", - self._get_tokens(), + self._get_encoded_passes(), storage_index, renew_secret, cancel_secret, @@ -114,7 +125,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "renew_lease", - self._get_tokens(), + self._get_encoded_passes(), storage_index, renew_secret, ) @@ -143,7 +154,7 @@ class ZKAPAuthorizerStorageClient(object): ): return self._rref.callRemote( "slot_testv_and_readv_and_writev", - self._get_tokens(), + self._get_encoded_passes(), storage_index, secrets, tw_vectors, diff --git a/src/_zkapauthorizer/_storage_server.py b/src/_zkapauthorizer/_storage_server.py index e917100e209e0cf9877fcf0e519623e2e1208ff3..3f9aec7c698a18913c45c86a1b8924111a3ab110 100644 --- a/src/_zkapauthorizer/_storage_server.py +++ b/src/_zkapauthorizer/_storage_server.py @@ -20,6 +20,10 @@ This is the server part of a storage access protocol. The client part is implemented in ``_storage_client.py``. """ +from __future__ import ( + absolute_import, +) + import attr from attr.validators import ( provides, @@ -29,127 +33,20 @@ from zope.interface import ( implementer_only, ) -from foolscap.constraint import ( - ByteStringConstraint, -) from foolscap.api import ( - ListOf, Referenceable, ) from foolscap.ipb import ( IReferenceable, IRemotelyCallable, ) -from foolscap.remoteinterface import ( - RemoteMethodSchema, - RemoteInterface, -) - from allmydata.interfaces import ( RIStorageServer, ) -# The Foolscap convention seems to be to try to constrain inputs to valid -# values. So we'll try to limit the number of tokens a client can supply. -# Foolscap may be moving away from this so we may eventually drop this as -# well. Though it may still make sense on a non-Foolscap protocol (eg HTTP) -# which Tahoe-LAFS may eventually support. -# -# In any case, for now, pick some fairly arbitrary value. I am deliberately -# picking a small number here and expect to have to raise. However, ideally, -# a client could accomplish a lot with a few tokens while also not wasting a -# 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 - -# Take those values and turn them into the appropriate Foolscap constraint -# objects. Foolscap seems to have a convention of representing these as -# CamelCase module-level values so I replicate that here. -Token = ByteStringConstraint(maxLength=TOKEN_LENGTH, minLength=TOKEN_LENGTH) -TokenList = ListOf(Token, maxLength=MAXIMUM_TOKENS_PER_CALL) - - -def add_tokens(schema): - """ - Add a ``tokens`` parameter to the given method schema. - - :param foolscap.remoteinterface.RemoteMethodSchema schema: An existing - method schema to modify. - - :return foolscap.remoteinterface.RemoteMethodSchema: A schema like - ``schema`` but with one additional required argument. - """ - return add_arguments(schema, [(b"tokens", TokenList)]) - - -def add_arguments(schema, kwargs): - """ - Create a new schema like ``schema`` but with the arguments given by - ``kwargs`` prepended to the signature. - - :param foolscap.remoteinterface.RemoteMethodSchema schema: The existing - schema. - - :param list[(bytes, foolscap.IConstraint)] kwargs: The arguments to - prepend to the signature of ``schema``. - - :return foolscap.remoteinterface.RemoteMethodSchema: The new schema - object. - """ - new_kwargs = dict(schema.argConstraints) - new_kwargs.update(kwargs) - modified_schema = RemoteMethodSchema(**new_kwargs) - # Initialized from **new_kwargs, RemoteMethodSchema.argumentNames is in - # some arbitrary, probably-incorrect order. This breaks user code which - # tries to use positional arguments. Put them back in the order they were - # in originally (in the input ``schema``), prepended with the newly added - # arguments. - modified_schema.argumentNames = ( - # The new arguments - list(argName for (argName, _) in kwargs) + - # The original arguments in the original order - schema.argumentNames - ) - return modified_schema - - - -class RITokenAuthorizedStorageServer(RemoteInterface): - """ - An object which can store and retrieve shares, subject to token-based - authorization. - - This is much the same as ``allmydata.interfaces.RIStorageServer`` but - several of its methods take an additional ``tokens`` parameter. Clients - are expected to supply suitable tokens and only after the tokens have been - validated is service provided. - """ - __remote_name__ = ( - "RITokenAuthorizedStorageServer.tahoe.privatestorage.io" - ) - - get_version = RIStorageServer["get_version"] - - allocate_buckets = add_tokens(RIStorageServer["allocate_buckets"]) - - add_lease = add_tokens(RIStorageServer["add_lease"]) - - renew_lease = add_tokens(RIStorageServer["renew_lease"]) - - get_buckets = RIStorageServer["get_buckets"] - - slot_readv = RIStorageServer["slot_readv"] - - slot_testv_and_readv_and_writev = add_tokens( - RIStorageServer["slot_testv_and_readv_and_writev"], - ) - - advise_corrupt_share = RIStorageServer["advise_corrupt_share"] - - +from .foolscap import ( + RITokenAuthorizedStorageServer, +) @implementer_only(RITokenAuthorizedStorageServer, IReferenceable, IRemotelyCallable) # It would be great to use `frozen=True` (value-based hashing) instead of @@ -220,7 +117,14 @@ class ZKAPAuthorizerStorageServer(Referenceable): """ return self._original.remote_advise_corrupt_share(*a, **kw) - def remote_slot_testv_and_readv_and_writev(self, tokens, *a, **kw): + def remote_slot_testv_and_readv_and_writev( + self, + tokens, + storage_index, + secrets, + tw_vectors, + r_vector, + ): """ Pass through after a token check to ensure clients can only allocate storage for mutable shares if they present valid tokens. @@ -235,8 +139,13 @@ class ZKAPAuthorizerStorageServer(Referenceable): # about (and don't expose over the network): renew_leases. We always # pass False for this because we want to manage leases completely # separately from writes. - kw["renew_leases"] = False - return self._original.slot_testv_and_readv_and_writev(*a, **kw) + return self._original.slot_testv_and_readv_and_writev( + storage_index, + secrets, + tw_vectors, + r_vector, + renew_leases=False, + ) def remote_slot_readv(self, *a, **kw): """ diff --git a/src/_zkapauthorizer/controller.py b/src/_zkapauthorizer/controller.py index 7e3302d7953be1cc1e462f7caab63253a4f0b4c7..c0d5536009d07976a85e74109cd3ec5347d5e302 100644 --- a/src/_zkapauthorizer/controller.py +++ b/src/_zkapauthorizer/controller.py @@ -17,11 +17,172 @@ This module implements controllers (in the MVC sense) for the web interface for the client side of the storage plugin. """ +from functools import ( + partial, +) + import attr +from zope.interface import ( + Interface, + implementer, +) + +from twisted.internet.defer import ( + Deferred, + succeed, +) + +from .foolscap import ( + TOKEN_LENGTH, +) +from .model import ( + Pass, + RandomToken, +) + + +class IRedeemer(Interface): + """ + An ``IRedeemer`` can exchange a voucher for one or more passes. + """ + def random_tokens_for_voucher(voucher, count): + """ + Generate a number of random tokens to use in the redemption process for + the given voucher. + + :param Voucher voucher: The voucher the tokens will be associated + with. + + :param int count: The number of random tokens to generate. + + :return list[RandomToken]: The generated tokens. Random tokens must + be unique over the lifetime of the Tahoe-LAFS node where this + plugin is being used but the same tokens *may* be generated for + the same voucher. The tokens must be kept secret to preserve the + anonymity property of the system. + """ + + def redeem(voucher, random_tokens): + """ + Redeem a voucher for passes. + + Implementations of this method do not need to be fault tolerant. If a + redemption attempt is interrupted before it completes, it is the + caller's responsibility to call this method again with the same + arguments. + + :param Voucher voucher: The voucher to redeem. + + :param list[RandomToken] random_tokens: The random tokens to use in + the redemption process. + + :return: A ``Deferred`` which fires with a list of ``Pass`` instances + on successful redemption or which fails with + ``TransientRedemptionError`` on any error which may be resolved by + simply trying again later or which fails with + ``PermanentRedemptionError`` on any error which is definitive and + final. + """ + + +@implementer(IRedeemer) +class NonRedeemer(object): + """ + A ``NonRedeemer`` never tries to redeem vouchers for ZKAPs. + """ + def random_tokens_for_voucher(self, voucher, count): + # It doesn't matter because we're never going to try to redeem them. + return list( + RandomToken(u"{}-{}".format(voucher, n)) + for n + in range(count) + ) + + def redeem(self, voucher, random_tokens): + # Don't try to redeem them. + return Deferred() + + +@implementer(IRedeemer) +@attr.s +class DummyRedeemer(object): + """ + A ``DummyRedeemer`` pretends to redeem vouchers for ZKAPs. Instead of + really redeeming them, it makes up some fake ZKAPs and pretends those are + the result. + """ + def random_tokens_for_voucher(self, voucher, count): + """ + Generate some number of random tokens to submit along with a voucher for + redemption. + """ + # Dummy token generation. + return list( + RandomToken(u"{}-{}".format(voucher, n)) + for n + in range(count) + ) + + def redeem(self, voucher, random_tokens): + """ + :return: An already-fired ``Deferred`` that has a list of ``Pass`` + instances wrapping meaningless values. + """ + return succeed( + list( + Pass((u"pass-" + token.token_value).zfill(TOKEN_LENGTH)) + for token + in random_tokens + ), + ) + + @attr.s class PaymentController(object): + """ + The ``PaymentController`` coordinates the process of turning a voucher + into a collection of ZKAPs: + + 1. A voucher to be consumed is handed to the controller. + Once a voucher is handed over to the controller the controller takes all responsibility for it. + + 2. The controller tells the data store to remember the voucher. + The data store provides durability for the voucher which represents an investment (ie, a purchase) on the part of the client. + + 3. The controller 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. + """ store = attr.ib() + redeemer = attr.ib() def redeem(self, voucher): - self.store.add(voucher) + # Pre-generate the random tokens to use when redeeming the voucher. + # These are persisted with the voucher so the redemption can be made + # idempotent. We don't want to lose the value if we fail after the + # server deems the voucher redeemed but before we persist the result. + # With a stable set of tokens, we can re-submit them and the server + # can re-sign them without fear of issuing excess passes. Whether the + # server signs a given set of random tokens once or many times, the + # number of passes that can be constructed is still only the size of + # the set of random tokens. + tokens = self.redeemer.random_tokens_for_voucher(voucher, 100) + + # Persist the voucher and tokens so they're available if we fail. + self.store.add(voucher, tokens) + + # Ask the redeemer to do the real task of redemption. + d = self.redeemer.redeem(voucher, tokens) + d.addCallback( + partial(self._redeemSuccess, voucher), + ) + + def _redeemSuccess(self, voucher, passes): + """ + Update the database state to reflect that a voucher was redeemed and to + store the resulting passes. + """ + self.store.insert_passes_for_voucher(voucher, passes) diff --git a/src/_zkapauthorizer/foolscap.py b/src/_zkapauthorizer/foolscap.py new file mode 100644 index 0000000000000000000000000000000000000000..224614dc7649ee80d3d2bec04eacee90ce749112 --- /dev/null +++ b/src/_zkapauthorizer/foolscap.py @@ -0,0 +1,118 @@ +from __future__ import ( + absolute_import, +) + +from foolscap.constraint import ( + ByteStringConstraint, +) +from foolscap.api import ( + ListOf, +) +from foolscap.remoteinterface import ( + RemoteMethodSchema, + RemoteInterface, +) + +from allmydata.interfaces import ( + RIStorageServer, +) + +# The Foolscap convention seems to be to try to constrain inputs to valid +# values. So we'll try to limit the number of tokens a client can supply. +# Foolscap may be moving away from this so we may eventually drop this as +# well. Though it may still make sense on a non-Foolscap protocol (eg HTTP) +# which Tahoe-LAFS may eventually support. +# +# In any case, for now, pick some fairly arbitrary value. I am deliberately +# picking a small number here and expect to have to raise. However, ideally, +# a client could accomplish a lot with a few tokens while also not wasting a +# 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 + +# Take those values and turn them into the appropriate Foolscap constraint +# objects. Foolscap seems to have a convention of representing these as +# CamelCase module-level values so I replicate that here. +Token = ByteStringConstraint(maxLength=TOKEN_LENGTH, minLength=TOKEN_LENGTH) +TokenList = ListOf(Token, maxLength=MAXIMUM_TOKENS_PER_CALL) + + +def add_tokens(schema): + """ + Add a ``tokens`` parameter to the given method schema. + + :param foolscap.remoteinterface.RemoteMethodSchema schema: An existing + method schema to modify. + + :return foolscap.remoteinterface.RemoteMethodSchema: A schema like + ``schema`` but with one additional required argument. + """ + return add_arguments(schema, [(b"tokens", TokenList)]) + + +def add_arguments(schema, kwargs): + """ + Create a new schema like ``schema`` but with the arguments given by + ``kwargs`` prepended to the signature. + + :param foolscap.remoteinterface.RemoteMethodSchema schema: The existing + schema. + + :param list[(bytes, foolscap.IConstraint)] kwargs: The arguments to + prepend to the signature of ``schema``. + + :return foolscap.remoteinterface.RemoteMethodSchema: The new schema + object. + """ + new_kwargs = dict(schema.argConstraints) + new_kwargs.update(kwargs) + modified_schema = RemoteMethodSchema(**new_kwargs) + # Initialized from **new_kwargs, RemoteMethodSchema.argumentNames is in + # some arbitrary, probably-incorrect order. This breaks user code which + # tries to use positional arguments. Put them back in the order they were + # in originally (in the input ``schema``), prepended with the newly added + # arguments. + modified_schema.argumentNames = ( + # The new arguments + list(argName for (argName, _) in kwargs) + + # The original arguments in the original order + schema.argumentNames + ) + return modified_schema + + + +class RITokenAuthorizedStorageServer(RemoteInterface): + """ + An object which can store and retrieve shares, subject to token-based + authorization. + + This is much the same as ``allmydata.interfaces.RIStorageServer`` but + several of its methods take an additional ``tokens`` parameter. Clients + are expected to supply suitable tokens and only after the tokens have been + validated is service provided. + """ + __remote_name__ = ( + "RITokenAuthorizedStorageServer.tahoe.privatestorage.io" + ) + + get_version = RIStorageServer["get_version"] + + allocate_buckets = add_tokens(RIStorageServer["allocate_buckets"]) + + add_lease = add_tokens(RIStorageServer["add_lease"]) + + renew_lease = add_tokens(RIStorageServer["renew_lease"]) + + get_buckets = RIStorageServer["get_buckets"] + + slot_readv = RIStorageServer["slot_readv"] + + slot_testv_and_readv_and_writev = add_tokens( + RIStorageServer["slot_testv_and_readv_and_writev"], + ) + + advise_corrupt_share = RIStorageServer["advise_corrupt_share"] diff --git a/src/_zkapauthorizer/model.py b/src/_zkapauthorizer/model.py index 58357cb237a36e9d1ac300529ccb0d9bdad0c03b..c2e56063489167c5e41ace8f3526751213e0a4ef 100644 --- a/src/_zkapauthorizer/model.py +++ b/src/_zkapauthorizer/model.py @@ -85,6 +85,11 @@ def open_and_initialize(path, required_schema_version, connect=None): except OperationalError as e: raise StoreOpenError(e) + # 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() cursor.execute( @@ -112,11 +117,32 @@ def open_and_initialize(path, required_schema_version, connect=None): """ CREATE TABLE IF NOT EXISTS [vouchers] ( [number] text, + [redeemed] num DEFAULT 0, PRIMARY KEY([number]) ) """, ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS [tokens] ( + [text] text, -- The random string that defines the token. + [voucher] text, -- Reference to the voucher these tokens go with. + + PRIMARY KEY([text]) + FOREIGN KEY([voucher]) REFERENCES [vouchers]([number]) + ) + """, + ) + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS [passes] ( + [text] text, -- The string that defines the pass. + + PRIMARY KEY([text]) + ) + """, + ) return conn @@ -143,11 +169,21 @@ class VoucherStore(object): :ivar allmydata.node._Config node_config: The Tahoe-LAFS node configuration object for the node that owns the persisted vouchers. """ - database_path = attr.ib(type=FilePath) + database_path = attr.ib(validator=attr.validators.instance_of(FilePath)) _connection = attr.ib() @classmethod def from_node_config(cls, node_config, connect=None): + """ + 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 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, @@ -161,10 +197,15 @@ class VoucherStore(object): @with_cursor def get(self, cursor, voucher): + """ + :param unicode voucher: The text value of a voucher to retrieve. + + :return Voucher: The voucher object that matches the given value. + """ cursor.execute( """ SELECT - ([number]) + [number], [redeemed] FROM [vouchers] WHERE @@ -175,36 +216,150 @@ class VoucherStore(object): refs = cursor.fetchall() if len(refs) == 0: raise KeyError(voucher) - return Voucher(refs[0][0]) + return Voucher(refs[0][0], bool(refs[0][1])) @with_cursor - def add(self, cursor, voucher): + def add(self, cursor, voucher, tokens): + """ + Add a new voucher and associated random tokens to the database. If a + voucher with the given text value is already present, do nothing. + + :param unicode voucher: The text value of a voucher to add. + + :param list[RandomToken]: The tokens to add alongside the voucher. + """ cursor.execute( """ - INSERT OR IGNORE INTO [vouchers] VALUES (?) + INSERT OR IGNORE INTO [vouchers] ([number]) VALUES (?) """, (voucher,) ) + if cursor.rowcount: + # Something was inserted. Insert the tokens, too. It's okay to + # drop the tokens in the other case. They've never been used. + # What's *already* in the database, on the other hand, may already + # have been submitted in a redeem attempt and must not change. + cursor.executemany( + """ + INSERT INTO [tokens] ([voucher], [text]) VALUES (?, ?) + """, + list( + (voucher, token.token_value) + for token + in tokens + ), + ) @with_cursor def list(self, cursor): + """ + Get all known vouchers. + + :return list[Voucher]: All vouchers known to the store. + """ cursor.execute( """ - SELECT ([number]) FROM [vouchers] + SELECT [number], [redeemed] FROM [vouchers] """, ) refs = cursor.fetchall() return list( - Voucher(number) - for (number,) + Voucher(number, bool(redeemed)) + for (number, redeemed) in refs ) + @with_cursor + def insert_passes_for_voucher(self, cursor, voucher, passes): + """ + Store some passes. + + :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 list[Pass] passes: The passes to store. + """ + cursor.executemany( + """ + INSERT INTO [passes] VALUES (?) + """, + list((p.text,) for p in passes), + ) + cursor.execute( + """ + UPDATE [vouchers] SET [redeemed] = 1 WHERE [number] = ? + """, + (voucher,), + ) + + @with_cursor + def extract_passes(self, cursor, count): + """ + Remove and return some passes. + + :param int count: The maximum number of passes to remove and return. + If fewer passes than this are available, only as many as are + available are returned. + + :return list[Pass]: The removed passes. + """ + cursor.execute( + """ + CREATE TEMPORARY TABLE [extracting-passes] + AS + SELECT [text] FROM [passes] LIMIT ? + """, + (count,), + ) + cursor.execute( + """ + DELETE FROM [passes] WHERE [text] IN [extracting-passes] + """, + ) + cursor.execute( + """ + SELECT [text] FROM [extracting-passes] + """, + ) + texts = cursor.fetchall() + cursor.execute( + """ + DROP TABLE [extracting-passes] + """, + ) + return list( + Pass(t) + for (t,) + in texts + ) + + +@attr.s(frozen=True) +class Pass(object): + """ + A ``Pass`` instance completely represents a single Zero-Knowledge Access Pass. + + :ivar unicode 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. + """ + text = attr.ib(validator=attr.validators.instance_of(unicode)) + + +@attr.s(frozen=True) +class RandomToken(object): + token_value = attr.ib(validator=attr.validators.instance_of(unicode)) + @attr.s class Voucher(object): number = attr.ib() + redeemed = attr.ib(default=False, validator=attr.validators.instance_of(bool)) @classmethod def from_json(cls, json): diff --git a/src/_zkapauthorizer/resource.py b/src/_zkapauthorizer/resource.py index e13b81b2260ded02c90aa496eab06158ef452f10..7f78a0b41415370ebe9ca4f18e9fdb1b12da0233 100644 --- a/src/_zkapauthorizer/resource.py +++ b/src/_zkapauthorizer/resource.py @@ -37,14 +37,12 @@ from ._base64 import ( urlsafe_b64decode, ) -from .model import ( - VoucherStore, -) from .controller import ( PaymentController, + NonRedeemer, ) -def from_configuration(node_config, store=None): +def from_configuration(node_config, store, redeemer=None): """ Instantiate the plugin root resource using data from its configuration section in the Tahoe-LAFS configuration file:: @@ -57,15 +55,17 @@ def from_configuration(node_config, store=None): This is also used to read and write files in the private storage area of the node's persistent state location. - :param VoucherStore store: The store to use. If ``None`` a sensible one - is constructed. + :param VoucherStore store: The store to use. + + :param IRedeemer redeemer: The voucher redeemer to use. If ``None`` a + sensible one is constructed. :return IResource: The root of the resource hierarchy presented by the client side of the plugin. """ - if store is None: - store = VoucherStore.from_node_config(node_config) - controller = PaymentController(store) + if redeemer is None: + redeemer = NonRedeemer() + controller = PaymentController(store, redeemer) root = Resource() root.putChild( b"voucher", @@ -97,12 +97,12 @@ class _VoucherCollection(Resource): try: payload = loads(request.content.read()) except Exception: - return bad_request().render(request) + return bad_request(u"json request body required").render(request) if payload.keys() != [u"voucher"]: - return bad_request().render(request) + return bad_request(u"request object must have exactly one key: 'voucher'").render(request) voucher = payload[u"voucher"] if not is_syntactic_voucher(voucher): - return bad_request().render(request) + return bad_request(u"submitted voucher is syntactically invalid").render(request) self._controller.redeem(voucher) return b"" @@ -170,11 +170,11 @@ class VoucherView(Resource): return self._voucher.to_json() -def bad_request(): +def bad_request(reason=u"Bad Request"): """ :return IResource: A resource which can be rendered to produce a **BAD REQUEST** response. """ return ErrorPage( - BAD_REQUEST, b"Bad Request", b"Bad Request", + BAD_REQUEST, b"Bad Request", reason.encode("utf-8"), ) diff --git a/src/_zkapauthorizer/tests/strategies.py b/src/_zkapauthorizer/tests/strategies.py index e615137fd615201776e7ccfa46ba1d908a8f883b..8f6e74ed6c4a71cff35f75630b7b26fb3232c543 100644 --- a/src/_zkapauthorizer/tests/strategies.py +++ b/src/_zkapauthorizer/tests/strategies.py @@ -52,6 +52,11 @@ from allmydata.client import ( config_from_string, ) +from ..model import ( + Pass, + RandomToken, +) + def _merge_dictionaries(dictionaries): result = {} @@ -175,6 +180,34 @@ def vouchers(): ) +def random_tokens(): + """ + Build random tokens as unicode strings. + """ + return binary( + min_size=32, + max_size=32, + ).map( + urlsafe_b64encode, + ).map( + lambda token: RandomToken(token.decode("ascii")), + ) + + +def zkaps(): + """ + Build random ZKAPs as ``Pass` instances. + """ + return binary( + min_size=32, + max_size=32, + ).map( + urlsafe_b64encode, + ).map( + lambda zkap: Pass(zkap.decode("ascii")), + ) + + def request_paths(): """ Build lists of unicode strings that represent the path component of an diff --git a/src/_zkapauthorizer/tests/test_client_resource.py b/src/_zkapauthorizer/tests/test_client_resource.py index dad75bb63b094f71201f99a8dfdbf1cee3888b13..203e44ca3ca557788daefd667fbbea3dc73de4e5 100644 --- a/src/_zkapauthorizer/tests/test_client_resource.py +++ b/src/_zkapauthorizer/tests/test_client_resource.py @@ -92,6 +92,7 @@ from treq.testing import ( ) from ..model import ( + Voucher, VoucherStore, memory_connect, ) @@ -390,10 +391,7 @@ class VoucherTests(TestCase): AfterPreprocessing( json_content, succeeded( - Equals({ - u"version": 1, - u"number": voucher, - }), + Equals(Voucher(voucher).marshal()), ), ), ), @@ -449,7 +447,7 @@ class VoucherTests(TestCase): succeeded( Equals({ u"vouchers": list( - {u"version": 1, u"number": voucher} + Voucher(voucher).marshal() for voucher in vouchers ), diff --git a/src/_zkapauthorizer/tests/test_controller.py b/src/_zkapauthorizer/tests/test_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..2969a01187eb56dfa3cec435846f4d021c40f7a6 --- /dev/null +++ b/src/_zkapauthorizer/tests/test_controller.py @@ -0,0 +1,100 @@ +# 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. + +""" +Tests for ``_zkapauthorizer.controller``. +""" + +from testtools import ( + TestCase, +) +from testtools.matchers import ( + Equals, +) + +from fixtures import ( + TempDir, +) + +from hypothesis import ( + given, +) + +from ..controller import ( + NonRedeemer, + DummyRedeemer, + PaymentController, +) + +from ..model import ( + memory_connect, + VoucherStore, +) + +from .strategies import ( + tahoe_configs, + vouchers, +) + +class PaymentControllerTests(TestCase): + """ + Tests for ``PaymentController``. + """ + @given(tahoe_configs(), vouchers()) + def test_not_redeemed_while_redeeming(self, get_config, voucher): + """ + A ``Voucher`` is not marked redeemed before ``IRedeemer.redeem`` + completes. + """ + tempdir = self.useFixture(TempDir()) + store = VoucherStore.from_node_config( + get_config( + tempdir.join(b"node"), + b"tub.port", + ), + connect=memory_connect, + ) + controller = PaymentController( + store, + NonRedeemer(), + ) + controller.redeem(voucher) + + persisted_voucher = store.get(voucher) + self.assertThat( + persisted_voucher.redeemed, + Equals(False), + ) + + @given(tahoe_configs(), vouchers()) + def test_redeemed_after_redeeming(self, get_config, voucher): + tempdir = self.useFixture(TempDir()) + store = VoucherStore.from_node_config( + get_config( + tempdir.join(b"node"), + b"tub.port", + ), + connect=memory_connect, + ) + controller = PaymentController( + store, + DummyRedeemer(), + ) + controller.redeem(voucher) + + persisted_voucher = store.get(voucher) + self.assertThat( + persisted_voucher.redeemed, + Equals(True), + ) diff --git a/src/_zkapauthorizer/tests/test_model.py b/src/_zkapauthorizer/tests/test_model.py index 7732e5e06f40460bf1acf00665ab1e4501298afa..1a6ac3cf944dc53b3b6ad554ea85addd170b9aaa 100644 --- a/src/_zkapauthorizer/tests/test_model.py +++ b/src/_zkapauthorizer/tests/test_model.py @@ -64,6 +64,8 @@ from ..model import ( from .strategies import ( tahoe_configs, vouchers, + random_tokens, + zkaps, ) @@ -104,8 +106,8 @@ class VoucherStoreTests(TestCase): raises(KeyError), ) - @given(tahoe_configs(), vouchers()) - def test_add(self, get_config, voucher): + @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True)) + def test_add(self, get_config, voucher, tokens): """ ``VoucherStore.get`` returns a ``Voucher`` representing a voucher previously added to the store with ``VoucherStore.add``. @@ -116,16 +118,17 @@ class VoucherStoreTests(TestCase): config, memory_connect, ) - store.add(voucher) + store.add(voucher, tokens) self.assertThat( store.get(voucher), MatchesStructure( number=Equals(voucher), + redeemed=Equals(False), ), ) - @given(tahoe_configs(), vouchers()) - def test_add_idempotent(self, get_config, voucher): + @given(tahoe_configs(), vouchers(), lists(random_tokens(), unique=True)) + def test_add_idempotent(self, get_config, voucher, tokens): """ More than one call to ``VoucherStore.add`` with the same argument results in the same state as a single call. @@ -136,8 +139,8 @@ class VoucherStoreTests(TestCase): config, memory_connect, ) - store.add(voucher) - store.add(voucher) + store.add(voucher, tokens) + store.add(voucher, []) self.assertThat( store.get(voucher), MatchesStructure( @@ -146,7 +149,7 @@ class VoucherStoreTests(TestCase): ) - @given(tahoe_configs(), lists(vouchers())) + @given(tahoe_configs(), lists(vouchers(), unique=True)) def test_list(self, get_config, vouchers): """ ``VoucherStore.list`` returns a ``list`` containing a ``Voucher`` object @@ -161,17 +164,17 @@ class VoucherStoreTests(TestCase): ) for voucher in vouchers: - store.add(voucher) + store.add(voucher, []) self.assertThat( store.list(), - AfterPreprocessing( - lambda refs: set(ref.number for ref in refs), - Equals(set(vouchers)), - ), + Equals(list( + Voucher(number) + for number + in vouchers + )), ) - @given(tahoe_configs()) def test_uncreateable_store_directory(self, get_config): """ @@ -250,3 +253,43 @@ class VoucherTests(TestCase): Voucher.from_json(ref.to_json()), Equals(ref), ) + + +class ZKAPStoreTests(TestCase): + """ + Tests for ZKAP-related functionality of ``VoucherStore``. + """ + @given(tahoe_configs(), vouchers(), lists(zkaps(), unique=True)) + def test_zkaps_round_trip(self, get_config, voucher_value, passes): + """ + ZKAPs that are added to the store can later be retrieved. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = VoucherStore.from_node_config( + config, + memory_connect, + ) + store.insert_passes_for_voucher(voucher_value, passes) + retrieved_passes = store.extract_passes(len(passes)) + self.expectThat(passes, Equals(retrieved_passes)) + + # After extraction, the passes are no longer available. + more_passes = store.extract_passes(1) + self.expectThat([], Equals(more_passes)) + + @given(tahoe_configs(), vouchers(), random_tokens(), zkaps()) + def test_mark_vouchers_redeemed(self, get_config, voucher_value, token, one_pass): + """ + The voucher for ZKAPs that are added to the store are marked as redeemed. + """ + tempdir = self.useFixture(TempDir()) + config = get_config(tempdir.join(b"node"), b"tub.port") + store = VoucherStore.from_node_config( + config, + memory_connect, + ) + store.add(voucher_value, [token]) + store.insert_passes_for_voucher(voucher_value, [one_pass]) + loaded_voucher = store.get(voucher_value) + self.assertThat(loaded_voucher.redeemed, Equals(True)) diff --git a/src/_zkapauthorizer/tests/test_plugin.py b/src/_zkapauthorizer/tests/test_plugin.py index 72c017ace8af653dfac8198446e3aa98753265ed..2587904e68bf78928d8b4344a653de7db4d4a4cd 100644 --- a/src/_zkapauthorizer/tests/test_plugin.py +++ b/src/_zkapauthorizer/tests/test_plugin.py @@ -31,6 +31,7 @@ from testtools.matchers import ( Always, Contains, AfterPreprocessing, + Equals, ) from testtools.twistedsupport import ( succeeded, @@ -47,6 +48,9 @@ from foolscap.ipb import ( IReferenceable, IRemotelyCallable, ) +from foolscap.referenceable import ( + LocalReferenceable, +) from allmydata.interfaces import ( IFoolscapStoragePlugin, @@ -68,10 +72,19 @@ from twisted.plugins.zkapauthorizer import ( storage_server, ) +from ..model import ( + VoucherStore, +) + from .strategies import ( tahoe_configs, configurations, announcements, + vouchers, + random_tokens, + zkaps, + storage_indexes, + lease_renew_secrets, ) from .matchers import ( Provides, @@ -88,7 +101,7 @@ def get_anonymous_storage_server(): def get_rref(): - return None + return LocalReferenceable(None) class PluginTests(TestCase): @@ -214,21 +227,82 @@ class ClientPluginTests(TestCase): Tests for the plugin's implementation of ``IFoolscapStoragePlugin.get_storage_client``. """ - @given(configurations(), announcements()) - def test_interface(self, configuration, announcement): + @given(tahoe_configs(), announcements()) + def test_interface(self, get_config, announcement): """ - ``get_storage_client`` returns a ``Deferred`` that fires with an object - which provides ``IStorageServer``. + ``get_storage_client`` returns an object which provides + ``IStorageServer``. """ - storage_client_deferred = storage_server.get_storage_client( - configuration, + tempdir = self.useFixture(TempDir()) + node_config = get_config( + tempdir.join(b"node"), + b"tub.port", + ) + + storage_client = storage_server.get_storage_client( + node_config, + announcement, + get_rref, + ) + + self.assertThat( + storage_client, + Provides([IStorageServer]), + ) + + + @given( + tahoe_configs(), + announcements(), + vouchers(), + random_tokens(), + zkaps(), + storage_indexes(), + lease_renew_secrets(), + ) + def test_passes_extracted( + self, + get_config, + announcement, + voucher, + token, + zkap, + storage_index, + renew_secret, + ): + """ + The ``ZKAPAuthorizerStorageServer`` returned by ``get_storage_client`` + extracts passes from the plugin database. + """ + tempdir = self.useFixture(TempDir()) + node_config = get_config( + tempdir.join(b"node"), + b"tub.port", + ) + + store = VoucherStore.from_node_config(node_config) + store.add(voucher, [token]) + store.insert_passes_for_voucher(voucher, [zkap]) + + storage_client = storage_server.get_storage_client( + node_config, announcement, get_rref, ) + # This is hooked up to a garbage reference. We don't care about its + # _result_, anyway, right now. + d = storage_client.renew_lease( + storage_index, + renew_secret, + ) + d.addBoth(lambda ignored: None) + + # There should be no passes left to extract. + remaining = store.extract_passes(1) self.assertThat( - storage_client_deferred, - succeeded(Provides([IStorageServer])), + remaining, + Equals([]), ) diff --git a/src/_zkapauthorizer/tests/test_storage_protocol.py b/src/_zkapauthorizer/tests/test_storage_protocol.py index 202d87e52d056bf9cabe58e481dfda4bdef0a54c..51cac393c395cdd3dc520aacc864dc81028e6d3b 100644 --- a/src/_zkapauthorizer/tests/test_storage_protocol.py +++ b/src/_zkapauthorizer/tests/test_storage_protocol.py @@ -29,7 +29,8 @@ from testtools import ( from testtools.matchers import ( Equals, HasLength, - Always, + IsInstance, + AfterPreprocessing, ) from testtools.twistedsupport import ( succeeded, @@ -84,9 +85,12 @@ from ..api import ( ZKAPAuthorizerStorageServer, ZKAPAuthorizerStorageClient, ) -from .._storage_server import ( +from ..foolscap import ( TOKEN_LENGTH, ) +from ..model import ( + Pass, +) class AnonymousStorageServer(Fixture): """ @@ -156,8 +160,8 @@ class ShareTests(TestCase): self.canary = LocalReferenceable(None) self.anonymous_storage_server = self.useFixture(AnonymousStorageServer()).storage_server - def get_tokens(): - return [b"x" * TOKEN_LENGTH] + def get_passes(): + return [Pass(u"x" * TOKEN_LENGTH)] self.server = ZKAPAuthorizerStorageServer( self.anonymous_storage_server, @@ -165,7 +169,7 @@ class ShareTests(TestCase): self.local_remote_server = LocalRemote(self.server) self.client = ZKAPAuthorizerStorageClient( get_rref=lambda: self.local_remote_server, - get_tokens=get_tokens, + get_passes=get_passes, ) def test_get_version(self): @@ -509,7 +513,7 @@ class ShareTests(TestCase): d = self.client._rref.callRemote( "slot_testv_and_readv_and_writev", # tokens - self.client._get_tokens(), + self.client._get_encoded_passes(), # storage_index storage_index, # secrets @@ -526,11 +530,15 @@ class ShareTests(TestCase): True, ) - # The operation should fail. I'm not that concerned with how just - # yet. + # The operation should fail. self.expectThat( d, - failed(Always()), + failed( + AfterPreprocessing( + lambda f: f.value, + IsInstance(TypeError), + ), + ), ) # There should be no shares at the given storage index. diff --git a/tahoe-lafs.nix b/tahoe-lafs.nix index fd34d16edbefc955cb27e03e3cd117d82fd28621..f9460c420f2e79a5d4daa43736aa7e8c9d95d9a4 100644 --- a/tahoe-lafs.nix +++ b/tahoe-lafs.nix @@ -11,9 +11,9 @@ buildPythonPackage rec { owner = "LeastAuthority"; repo = "tahoe-lafs"; # HEAD of an integration branch for all of the storage plugin stuff. Last - # updated August 15th 2019. - rev = "b35a8908f4096ccae35da78b0e7dde96d6cf1667"; - sha256 = "0n289hzx2s1jvspmpz2c5iwl0dvnfc8qbiqfmpbl88ymrjp7p6rr"; + # updated August 20th 2019. + rev = "ba2f31f5f3719c7cf9f621852571e89ab445bf61"; + sha256 = "02c3zghx1951zw1912c2qf9s7n41wsbz8ld5700myak69nvkh0gs"; }; postPatch = '' diff --git a/zkapauthorizer.nix b/zkapauthorizer.nix index 282bdc8036b1aee7a1792c88343220e4cf66d90c..8265f890df9f900dcdea5b87d159ae117ceaf33d 100644 --- a/zkapauthorizer.nix +++ b/zkapauthorizer.nix @@ -8,6 +8,8 @@ buildPythonPackage rec { name = "${pname}-${version}"; src = ./.; + outputs = [ "out" "doc" ]; + depsBuildBuild = [ sphinx circleci-cli @@ -30,7 +32,15 @@ buildPythonPackage rec { ]; checkPhase = '' - ${pyflakes}/bin/pyflakes src/_zkapauthorizer - python -m coverage run --source _zkapauthorizer,twisted.plugins.zkapauthorizer --module twisted.trial _zkapauthorizer + runHook preCheck + "${pyflakes}/bin/pyflakes" src/_zkapauthorizer + python -m coverage run --branch --source _zkapauthorizer,twisted.plugins.zkapauthorizer --module twisted.trial _zkapauthorizer + runHook postCheck + ''; + + postCheck = '' + python -m coverage html + mkdir -p "$doc/share/doc/${name}" + cp -vr .coverage htmlcov "$doc/share/doc/${name}" ''; }