Newer
Older
# 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 controllers (in the MVC sense) for the web interface
for the client side of the storage plugin.
"""
from __future__ import (
absolute_import,
)
from sys import (
exc_info,
)
from operator import (
setitem,
delitem,
)
from functools import (
partial,
)
from json import (
dumps,
from datetime import (
timedelta,
)
from base64 import (
b64encode,
from hashlib import (
sha256,
)
from zope.interface import (
Interface,
implementer,
)
from twisted.python.reflect import (
namedAny,
)
from twisted.logger import (
Logger,
)
from twisted.python.url import (
URL,
)
from twisted.internet.defer import (
Deferred,
succeed,
inlineCallbacks,
returnValue,
)
from twisted.internet.task import (
LoopingCall,
)
from twisted.web.client import (
Agent,
)
from treq.client import (
HTTPClient,
)
from ._base64 import (
urlsafe_b64decode,
)
from ._stack import (
less_limited_stack,
)
from .model import (
RandomToken,
UnblindedToken,
Pass,
Error as model_Error,
Jean-Paul Calderone
committed
RETRY_INTERVAL = timedelta(milliseconds=1)
@attr.s
class UnexpectedResponse(Exception):
"""
The issuer responded in an unexpected and unhandled way.
"""
code = attr.ib()
body = attr.ib()
class AlreadySpent(Exception):
"""
An attempt was made to redeem a voucher which has already been redeemed.
The redemption cannot succeed and should not be retried automatically.
"""
class Unpaid(Exception):
"""
An attempt was made to redeem a voucher which has not yet been paid for.
The redemption attempt may be automatically retried at some point.
"""
@attr.s
class RedemptionResult(object):
"""
Contain the results of an attempt to redeem a voucher for ZKAP material.
:ivar list[UnblindedToken] unblinded_tokens: The tokens which resulted
from the redemption.
:ivar unicode public_key: The public key which the server proved was
involved in the redemption process.
"""
unblinded_tokens = attr.ib()
public_key = attr.ib()
class IRedeemer(Interface):
"""
An ``IRedeemer`` can exchange a voucher for one or more passes.
"""
def random_tokens_for_voucher(voucher, counter, 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 counter: See ``redeemWithCounter``.
: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 redeemWithCounter(voucher, counter, random_tokens):
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
caller's responsibility to call this method again with the same
arguments.
:param Voucher voucher: The voucher to redeem.
:param int counter: The counter to use in this redemption attempt. To
support vouchers which can be redeemed for a larger number of
tokens than is practical to handle at once, one voucher can be
partially redeemed repeatedly until the complete set of tokens has
been received. Each partial redemption must have a distinct
counter value.
:param list[RandomToken] random_tokens: The random tokens to use in
the redemption process.
:return: A ``Deferred`` which fires with a ``RedemptionResult``
instance or which fails with any error to allow a retry to be made
at some future point. It may also fail with an ``AlreadySpent``
error to indicate the redemption server considers the voucher to
have been redeemed already and will not allow it to be redeemed.
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``.
"""
@attr.s
@implementer(IRedeemer)
class IndexedRedeemer(object):
"""
A ``IndexedRedeemer`` delegates redemption to a redeemer chosen to
correspond to the redemption counter given.
"""
_log = Logger()
redeemers = attr.ib()
def random_tokens_for_voucher(self, voucher, counter, count):
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
self._log.info(
"IndexedRedeemer redeeming {voucher}[{counter}] using {delegate}.",
voucher=voucher,
counter=counter,
delegate=self.redeemers[counter],
)
return self.redeemers[counter].redeemWithCounter(
voucher,
counter,
random_tokens,
)
@implementer(IRedeemer)
class NonRedeemer(object):
"""
A ``NonRedeemer`` never tries to redeem vouchers for ZKAPs.
"""
@classmethod
def make(cls, section_name, node_config, announcement, reactor):
return cls()
def random_tokens_for_voucher(self, voucher, counter, count):
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
# 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(frozen=True)
class ErrorRedeemer(object):
"""
An ``ErrorRedeemer`` immediately locally fails voucher redemption with a
configured error.
"""
details = attr.ib(validator=attr.validators.instance_of(unicode))
@classmethod
def make(cls, section_name, node_config, announcement, reactor):
details = node_config.get_config(
section=section_name,
option=u"details",
).decode("ascii")
return cls(details)
def random_tokens_for_voucher(self, voucher, counter, count):
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
return fail(Exception(self.details))
def tokens_to_passes(self, message, unblinded_tokens):
raise Exception(
"Cannot be called because no unblinded tokens are ever returned."
)
@implementer(IRedeemer)
@attr.s
class DoubleSpendRedeemer(object):
A ``DoubleSpendRedeemer`` pretends to try to redeem vouchers for ZKAPs but
always fails with an error indicating the voucher has already been spent.
"""
@classmethod
def make(cls, section_name, node_config, announcement, reactor):
return cls()
def random_tokens_for_voucher(self, voucher, counter, count):
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
return fail(AlreadySpent(voucher))
@implementer(IRedeemer)
@attr.s
class UnpaidRedeemer(object):
"""
An ``UnpaidRedeemer`` pretends to try to redeem vouchers for ZKAPs but
always fails with an error indicating the voucher has not been paid for.
"""
@classmethod
def make(cls, section_name, node_config, announcement, reactor):
return cls()
def random_tokens_for_voucher(self, voucher, counter, count):
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
return fail(Unpaid(voucher))
@implementer(IRedeemer)
@attr.s
class RecordingRedeemer(object):
"""
A ``CountingRedeemer`` delegates redemption logic to another object but
records all redemption attempts.
"""
original = attr.ib()
redemptions = attr.ib(default=attr.Factory(list))
def random_tokens_for_voucher(self, voucher, counter, count):
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
self.redemptions.append((voucher, counter, random_tokens))
return self.original.redeemWithCounter(voucher, counter, random_tokens)
def dummy_random_tokens(voucher, counter, count):
v = urlsafe_b64decode(voucher.number.encode("ascii"))
def dummy_random_token(n):
return RandomToken(
# Padding is 96 (random token length) - 32 (decoded voucher
# length) - 4 (fixed-width counter)
v + u"{:0>4}{:0>60}".format(counter, n).encode("ascii"),
return list(
for n
in range(count)
)
@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.
:ivar unicode _public_key: The base64-encoded public key to return with
all successful redemption results. As with the tokens returned by
this redeemer, chances are this is not actually a valid public key.
Its corresponding private key certainly has not been used to sign
anything.
_public_key = attr.ib(
validator=attr.validators.instance_of(unicode),
)
@classmethod
def make(cls, section_name, node_config, announcement, reactor):
return cls(
node_config.get_config(
section=section_name,
option=u"issuer-public-key",
).decode(u"utf-8"),
)
def random_tokens_for_voucher(self, voucher, counter, count):
"""
Generate some number of random tokens to submit along with a voucher for
redemption.
"""
return dummy_random_tokens(voucher, counter, count)
def redeemWithCounter(self, voucher, counter, random_tokens):
:return: An already-fired ``Deferred`` that has a list of
``UnblindedToken`` instances wrapping meaningless values.
if not isinstance(voucher, Voucher):
raise TypeError(
"Got {}, expected instance of Voucher".format(
voucher,
),
)
def dummy_unblinded_token(random_token):
random_value = b64decode(random_token.token_value.encode("ascii"))
unblinded_value = random_value + b"x" * (96 - len(random_value))
return UnblindedToken(b64encode(unblinded_value).decode("ascii"))
RedemptionResult(
list(
dummy_unblinded_token(token)
for token
in random_tokens
),
self._public_key,
def tokens_to_passes(self, message, unblinded_tokens):
def token_to_pass(token):
# Generate distinct strings based on the unblinded token which we
# can include in the resulting Pass. This ensures the pass values
# will be unique if and only if the unblinded tokens were unique
# (barring improbable hash collisions).
token_digest = sha256(
token.unblinded_token.encode("ascii")
).hexdigest().encode("ascii")
preimage = b"preimage-" + token_digest[len(b"preimage-"):]
signature = b"signature-" + token_digest[len(b"signature-"):]
return Pass(
b64encode(preimage).decode("ascii"),
b64encode(signature).decode("ascii"),
)
return list(
token_to_pass(token)
for token
in unblinded_tokens
)
Loading
Loading full blame...