From a9cc13e825ffc196b6ec2b9276371f0ac6b6a99a Mon Sep 17 00:00:00 2001 From: Bogdan Opanchuk Date: Thu, 2 Sep 2021 22:22:24 -0700 Subject: [PATCH] Move retrieval machinery to network/retrieval and revocation to policy/revocation --- nucypher/characters/lawful.py | 155 +---------- .../orders.py => network/retrieval.py} | 241 ++++++++++-------- nucypher/network/server.py | 4 +- nucypher/policy/kits.py | 65 +++-- nucypher/policy/policies.py | 2 +- nucypher/policy/revocation.py | 133 ++++++++++ .../test_federated_grant_and_revoke.py | 2 +- 7 files changed, 310 insertions(+), 292 deletions(-) rename nucypher/{policy/orders.py => network/retrieval.py} (58%) create mode 100644 nucypher/policy/revocation.py diff --git a/nucypher/characters/lawful.py b/nucypher/characters/lawful.py index 89bafc4d9..f4c65413f 100644 --- a/nucypher/characters/lawful.py +++ b/nucypher/characters/lawful.py @@ -20,9 +20,7 @@ import contextlib import json import time from base64 import b64encode -from collections import OrderedDict, defaultdict, namedtuple from datetime import datetime -from functools import partial from json.decoder import JSONDecodeError from pathlib import Path from queue import Queue @@ -84,15 +82,12 @@ from nucypher.crypto.powers import ( TLSHostingPower, ) from nucypher.crypto.signing import InvalidSignature -from nucypher.crypto.splitters import key_splitter, signature_splitter, cfrag_splitter +from nucypher.crypto.splitters import key_splitter, signature_splitter from nucypher.crypto.umbral_adapter import ( - Capsule, PublicKey, VerificationError, reencrypt, - KeyFrag, VerifiedKeyFrag, - Signature ) from nucypher.datastore.datastore import DatastoreTransactionError, RecordNotFound from nucypher.datastore.queries import find_expired_policies, find_expired_treasure_maps @@ -101,18 +96,12 @@ from nucypher.network.exceptions import NodeSeemsToBeDown from nucypher.network.middleware import RestMiddleware from nucypher.network.nodes import NodeSprout, TEACHER_NODES, Teacher from nucypher.network.protocols import InterfaceInfo, parse_node_uri +from nucypher.network.retrieval import RetrievalClient, ReencryptionResponse from nucypher.network.server import ProxyRESTServer, make_rest_app from nucypher.network.trackers import AvailabilityTracker from nucypher.policy.hrac import HRAC -from nucypher.policy.kits import MessageKit, PolicyMessageKit, RetrievalKit +from nucypher.policy.kits import MessageKit, PolicyMessageKit from nucypher.policy.maps import TreasureMap, EncryptedTreasureMap, AuthorizedKeyFrag -from nucypher.policy.orders import ( - RetrievalWorkOrder, - ReencryptionRequest, - ReencryptionResponse, - RetrievalPlan, - RetrievalResult, - ) from nucypher.policy.policies import Policy from nucypher.utilities.logging import Logger from nucypher.utilities.networking import validate_worker_ip @@ -745,144 +734,6 @@ class Bob(Character): return controller -class RetrievalClient: - """ - Capsule frag retrieval machinery shared between Bob and Porter. - """ - - def __init__(self, learner: Learner): - self._learner = learner - self.log = Logger(self.__class__.__name__) - - def _request_reencryption(self, - ursula: 'Ursula', - reencryption_request: 'ReencryptionRequest', - delegating_key: PublicKey, - receiving_key: PublicKey, - ) -> Dict['Capsule', 'VerifiedCapsuleFrag']: - """ - Sends a reencryption request to a single Ursula and processes the results. - - Returns reencrypted capsule frags matched to corresponding capsules. - """ - - middleware = self._learner.network_middleware - - try: - response = middleware.reencrypt(ursula, bytes(reencryption_request)) - except NodeSeemsToBeDown as e: - # TODO: What to do here? Ursula isn't supposed to be down. NRN - message = (f"Ursula ({ursula}) seems to be down " - f"while trying to complete ReencryptionRequest: {reencryption_request}") - self.log.info(message) - raise RuntimeError(message) from e - except middleware.NotFound as e: - # This Ursula claims not to have a matching KFrag. Maybe this has been revoked? - # TODO: What's the thing to do here? - # Do we want to track these Ursulas in some way in case they're lying? #567 - message = (f"Ursula ({ursula}) claims not to have the KFrag " - f"to complete ReencryptionRequest: {reencryption_request}. " - f"Has access been revoked?") - self.log.warn(message) - raise RuntimeError(message) from e - except middleware.UnexpectedResponse: - raise # TODO: Handle this - - try: - reencryption_response = ReencryptionResponse.from_bytes(response.content) - except Exception as e: - message = f"Ursula ({ursula}) returned an invalid response: {e}." - self.log.warn(message) - raise RuntimeError(message) - - if len(reencryption_request.capsules) != len(reencryption_response.cfrags): - message = (f"Ursula ({ursula}) gave back the wrong number of cfrags. " - "She's up to something.") - self.log.warn(message) - raise RuntimeError(message) - - ursula_verifying_key = ursula.stamp.as_umbral_pubkey() - capsules_bytes = b''.join(bytes(capsule) for capsule in reencryption_request.capsules) - cfrags_bytes = b''.join(bytes(cfrag) for cfrag in reencryption_response.cfrags) - - # Validate re-encryption signature - if not reencryption_response.signature.verify(ursula_verifying_key, capsules_bytes + cfrags_bytes): - message = (f"{reencryption_request.capsules} and {reencryption_response.cfrags} " - "are not properly signed by Ursula.") - self.log.warn(message) - # TODO: Instead of raising, we should do something (#957) - raise InvalidSignature(message) - - verified_cfrags = {} - - for capsule, cfrag in zip(reencryption_request.capsules, reencryption_response.cfrags): - - # TODO: should we allow partially valid responses? - - # Verify cfrags - try: - verified_cfrag = cfrag.verify(capsule, - verifying_pk=reencryption_request._alice_verifying_key, - delegating_pk=delegating_key, - receiving_pk=receiving_key) - except VerificationError: - # In future we may want to remember this Ursula and do something about it - raise - - verified_cfrags[capsule] = verified_cfrag - - return verified_cfrags - - def retrieve_cfrags( - self, - treasure_map: TreasureMap, - retrieval_kits: Sequence[RetrievalKit], - alice_verifying_key: PublicKey, # KeyFrag signer's key - bob_encrypting_key: PublicKey, # User's public key (reencryption target) - bob_verifying_key: PublicKey, - policy_encrypting_key: PublicKey, # Key used to create the policy - ) -> List[RetrievalResult]: - - # TODO: why is it here? This code shouldn't know about these details. - # OK, so we're going to need to do some network activity for this retrieval. - # Let's make sure we've seeded. - if not self._learner.done_seeding: - self._learner.learn_from_teacher_node() - - retrieval_plan = RetrievalPlan(treasure_map=treasure_map, retrieval_kits=retrieval_kits) - - while not retrieval_plan.is_complete(): - # TODO (#2789): Currently we'll only query one Ursula once during the retrieval. - # Alternatively we may re-query Ursulas that were offline until the timeout expires. - - work_order = retrieval_plan.get_work_order() - - if work_order.ursula_address not in self._learner.known_nodes: - continue - - ursula = self._learner.known_nodes[work_order.ursula_address] - reencryption_request = ReencryptionRequest.from_work_order( - work_order=work_order, - treasure_map=treasure_map, - alice_verifying_key=alice_verifying_key, - bob_verifying_key=bob_verifying_key) - - try: - cfrags = self._request_reencryption(ursula=ursula, - reencryption_request=reencryption_request, - delegating_key=policy_encrypting_key, - receiving_key=bob_encrypting_key) - except Exception as e: - # TODO (#2789): at this point we can separate the exceptions to "acceptable" - # (Ursula is not reachable) and "unacceptable" (Ursula provided bad results). - self.log.warn(f"Ursula {ursula} failed to reencrypt: {e}") - continue - - retrieval_plan.update(work_order, cfrags) - - return retrieval_plan.results() - - class Ursula(Teacher, Character, Worker): banner = URSULA_BANNER diff --git a/nucypher/policy/orders.py b/nucypher/network/retrieval.py similarity index 58% rename from nucypher/policy/orders.py rename to nucypher/network/retrieval.py index 2c67ad58b..2f2a1e71d 100644 --- a/nucypher/policy/orders.py +++ b/nucypher/network/retrieval.py @@ -15,24 +15,20 @@ You should have received a copy of the GNU Affero General Public License along with nucypher. If not, see . """ - -from collections import OrderedDict, defaultdict +from collections import defaultdict import random -from typing import Optional, Dict, Sequence, Union, List, Set +from typing import Dict, Sequence, List -import maya from bytestring_splitter import BytestringSplitter, VariableLengthBytestring -from constant_sorrow.constants import CFRAG_NOT_RETAINED from eth_typing.evm import ChecksumAddress -from eth_utils.address import to_canonical_address, to_checksum_address +from twisted.logger import Logger from nucypher.crypto.signing import SignatureStamp, InvalidSignature from nucypher.crypto.splitters import ( - key_splitter, capsule_splitter, cfrag_splitter, + key_splitter, signature_splitter, - kfrag_splitter ) from nucypher.crypto.umbral_adapter import ( Capsule, @@ -40,10 +36,13 @@ from nucypher.crypto.umbral_adapter import ( PublicKey, Signature, VerifiedCapsuleFrag, + VerificationError, ) +from nucypher.network.exceptions import NodeSeemsToBeDown +from nucypher.network.nodes import Learner from nucypher.policy.hrac import HRAC, hrac_splitter -from nucypher.policy.kits import MessageKit -from nucypher.policy.maps import AuthorizedKeyFrag, TreasureMap +from nucypher.policy.kits import MessageKit, RetrievalKit, RetrievalResult +from nucypher.policy.maps import TreasureMap class RetrievalPlan: @@ -52,7 +51,7 @@ class RetrievalPlan: during retrieval. """ - def __init__(self, treasure_map: TreasureMap, retrieval_kits: Sequence['RetrievalKit']): + def __init__(self, treasure_map: TreasureMap, retrieval_kits: Sequence[RetrievalKit]): # Record the retrieval kits order self._capsules = [retrieval_kit.capsule for retrieval_kit in retrieval_kits] @@ -129,36 +128,6 @@ class RetrievalPlan: return [RetrievalResult(self._results[capsule]) for capsule in self._capsules] -class RetrievalResult: - """ - An object representing retrieval results for a single capsule. - """ - - @classmethod - def empty(cls): - return cls({}) - - def __init__(self, cfrags: Dict[ChecksumAddress, VerifiedCapsuleFrag]): - self.cfrags = cfrags - - def addresses(self) -> Set[ChecksumAddress]: - return set(self.cfrags) - - def with_result(self, result: 'RetrievalResult') -> 'RetrievalResult': - """ - Joins two RetrievalResult objects. - - If both objects contain cfrags from the same Ursula, - the one from `result` will be kept. - """ - # TODO: would `+` or `|` operator be more suitable here? - - # TODO: check for overlap? - new_cfrags = dict(self.cfrags) - new_cfrags.update(result.cfrags) - return RetrievalResult(cfrags=new_cfrags) - - class RetrievalWorkOrder: """ A work order issued by a retrieval plan to request reencryption from an Ursula @@ -273,69 +242,139 @@ class ReencryptionResponse: return bytes(self.signature) + b''.join(bytes(cfrag) for cfrag in self.cfrags) - -class Revocation: +class RetrievalClient: """ - Represents a string used by characters to perform a revocation on a specific - Ursula. It's a bytestring made of the following format: - REVOKE- - This is sent as a payload in a DELETE method to the /KFrag/ endpoint. + Capsule frag retrieval machinery shared between Bob and Porter. """ - PREFIX = b'REVOKE-' - revocation_splitter = BytestringSplitter( - (bytes, len(PREFIX)), - (bytes, 20), # ursula canonical address - (bytes, AuthorizedKeyFrag.ENCRYPTED_SIZE), # encrypted kfrag payload (includes writ) - signature_splitter - ) + def __init__(self, learner: Learner): + self._learner = learner + self.log = Logger(self.__class__.__name__) - def __init__(self, - ursula_checksum_address: ChecksumAddress, # TODO: Use staker address instead (what if the staker rebonds)? - encrypted_kfrag: bytes, - signer: 'SignatureStamp' = None, - signature: Signature = None): - - self.ursula_checksum_address = ursula_checksum_address - self.encrypted_kfrag = encrypted_kfrag - - if not (bool(signer) ^ bool(signature)): - raise ValueError("Either pass a signer or a signature; not both.") - elif signer: - self.signature = signer(self.payload) - elif signature: - self.signature = signature - - def __bytes__(self): - return self.payload + bytes(self.signature) - - def __repr__(self): - return bytes(self) - - def __len__(self): - return len(bytes(self)) - - def __eq__(self, other): - return bytes(self) == bytes(other) - - @property - def payload(self): - return self.PREFIX \ - + to_canonical_address(self.ursula_checksum_address) \ - + bytes(self.encrypted_kfrag) \ - - @classmethod - def from_bytes(cls, revocation_bytes): - prefix, ursula_canonical_address, ekfrag, signature = cls.revocation_splitter(revocation_bytes) - ursula_checksum_address = to_checksum_address(ursula_canonical_address) - return cls(ursula_checksum_address=ursula_checksum_address, - encrypted_kfrag=ekfrag, - signature=signature) - - def verify_signature(self, alice_verifying_key: 'PublicKey') -> bool: + def _request_reencryption(self, + ursula: 'Ursula', + reencryption_request: 'ReencryptionRequest', + delegating_key: PublicKey, + receiving_key: PublicKey, + ) -> Dict['Capsule', 'VerifiedCapsuleFrag']: """ - Verifies the revocation was from the provided pubkey. + Sends a reencryption request to a single Ursula and processes the results. + + Returns reencrypted capsule frags matched to corresponding capsules. """ - if not self.signature.verify(self.payload, alice_verifying_key): - raise InvalidSignature(f"Revocation has an invalid signature: {self.signature}") - return True + + middleware = self._learner.network_middleware + + try: + response = middleware.reencrypt(ursula, bytes(reencryption_request)) + except NodeSeemsToBeDown as e: + # TODO: What to do here? Ursula isn't supposed to be down. NRN + message = (f"Ursula ({ursula}) seems to be down " + f"while trying to complete ReencryptionRequest: {reencryption_request}") + self.log.info(message) + raise RuntimeError(message) from e + except middleware.NotFound as e: + # This Ursula claims not to have a matching KFrag. Maybe this has been revoked? + # TODO: What's the thing to do here? + # Do we want to track these Ursulas in some way in case they're lying? #567 + message = (f"Ursula ({ursula}) claims not to have the KFrag " + f"to complete ReencryptionRequest: {reencryption_request}. " + f"Has access been revoked?") + self.log.warn(message) + raise RuntimeError(message) from e + except middleware.UnexpectedResponse: + raise # TODO: Handle this + + try: + reencryption_response = ReencryptionResponse.from_bytes(response.content) + except Exception as e: + message = f"Ursula ({ursula}) returned an invalid response: {e}." + self.log.warn(message) + raise RuntimeError(message) + + if len(reencryption_request.capsules) != len(reencryption_response.cfrags): + message = (f"Ursula ({ursula}) gave back the wrong number of cfrags. " + "She's up to something.") + self.log.warn(message) + raise RuntimeError(message) + + ursula_verifying_key = ursula.stamp.as_umbral_pubkey() + capsules_bytes = b''.join(bytes(capsule) for capsule in reencryption_request.capsules) + cfrags_bytes = b''.join(bytes(cfrag) for cfrag in reencryption_response.cfrags) + + # Validate re-encryption signature + if not reencryption_response.signature.verify(ursula_verifying_key, capsules_bytes + cfrags_bytes): + message = (f"{reencryption_request.capsules} and {reencryption_response.cfrags} " + "are not properly signed by Ursula.") + self.log.warn(message) + # TODO: Instead of raising, we should do something (#957) + raise InvalidSignature(message) + + verified_cfrags = {} + + for capsule, cfrag in zip(reencryption_request.capsules, reencryption_response.cfrags): + + # TODO: should we allow partially valid responses? + + # Verify cfrags + try: + verified_cfrag = cfrag.verify(capsule, + verifying_pk=reencryption_request._alice_verifying_key, + delegating_pk=delegating_key, + receiving_pk=receiving_key) + except VerificationError: + # In future we may want to remember this Ursula and do something about it + raise + + verified_cfrags[capsule] = verified_cfrag + + return verified_cfrags + + def retrieve_cfrags( + self, + treasure_map: TreasureMap, + retrieval_kits: Sequence[RetrievalKit], + alice_verifying_key: PublicKey, # KeyFrag signer's key + bob_encrypting_key: PublicKey, # User's public key (reencryption target) + bob_verifying_key: PublicKey, + policy_encrypting_key: PublicKey, # Key used to create the policy + ) -> List[RetrievalResult]: + + # TODO: why is it here? This code shouldn't know about these details. + # OK, so we're going to need to do some network activity for this retrieval. + # Let's make sure we've seeded. + if not self._learner.done_seeding: + self._learner.learn_from_teacher_node() + + retrieval_plan = RetrievalPlan(treasure_map=treasure_map, retrieval_kits=retrieval_kits) + + while not retrieval_plan.is_complete(): + # TODO (#2789): Currently we'll only query one Ursula once during the retrieval. + # Alternatively we may re-query Ursulas that were offline until the timeout expires. + + work_order = retrieval_plan.get_work_order() + + if work_order.ursula_address not in self._learner.known_nodes: + continue + + ursula = self._learner.known_nodes[work_order.ursula_address] + reencryption_request = ReencryptionRequest.from_work_order( + work_order=work_order, + treasure_map=treasure_map, + alice_verifying_key=alice_verifying_key, + bob_verifying_key=bob_verifying_key) + + try: + cfrags = self._request_reencryption(ursula=ursula, + reencryption_request=reencryption_request, + delegating_key=policy_encrypting_key, + receiving_key=bob_encrypting_key) + except Exception as e: + # TODO (#2789): at this point we can separate the exceptions to "acceptable" + # (Ursula is not reachable) and "unacceptable" (Ursula provided bad results). + self.log.warn(f"Ursula {ursula} failed to reencrypt: {e}") + continue + + retrieval_plan.update(work_order, cfrags) + + return retrieval_plan.results() diff --git a/nucypher/network/server.py b/nucypher/network/server.py index 857e6ec93..72d45ac9c 100644 --- a/nucypher/network/server.py +++ b/nucypher/network/server.py @@ -34,12 +34,13 @@ from nucypher.crypto.powers import KeyPairBasedPower, PowerUpError from nucypher.crypto.signing import InvalidSignature from nucypher.datastore.datastore import Datastore from nucypher.datastore.models import ReencryptionRequest as ReencryptionRequestModel -from nucypher.policy.orders import ReencryptionRequest, ReencryptionResponse from nucypher.network import LEARNING_LOOP_VERSION from nucypher.network.exceptions import NodeSeemsToBeDown from nucypher.network.protocols import InterfaceInfo +from nucypher.network.retrieval import ReencryptionRequest, ReencryptionResponse from nucypher.policy.hrac import HRAC from nucypher.policy.kits import MessageKit +from nucypher.policy.revocation import Revocation from nucypher.utilities.logging import Logger HERE = BASE_DIR = Path(__file__).parent @@ -271,7 +272,6 @@ def _make_rest_app(datastore: Datastore, this_node, domain: str, log: Logger) -> @rest_app.route('/revoke', methods=['POST']) def revoke(): - from nucypher.policy.orders import Revocation revocation = Revocation.from_bytes(request.data) # TODO: Implement offchain revocation. return Response(status=200) diff --git a/nucypher/policy/kits.py b/nucypher/policy/kits.py index e18de292b..b4c69c32e 100644 --- a/nucypher/policy/kits.py +++ b/nucypher/policy/kits.py @@ -16,12 +16,11 @@ along with nucypher. If not, see . """ -from typing import Dict, NamedTuple, Optional, Tuple, List, Iterable +from typing import Dict, Optional, Iterable, Set -from bytestring_splitter import BytestringSplitter, BytestringKwargifier, VariableLengthBytestring +from bytestring_splitter import BytestringSplitter, VariableLengthBytestring from constant_sorrow.constants import ( NOT_SIGNED, - UNKNOWN_SENDER, DO_NOT_SIGN, SIGNATURE_TO_FOLLOW, SIGNATURE_IS_ON_CIPHERTEXT, @@ -30,7 +29,12 @@ from constant_sorrow.constants import ( from eth_typing import ChecksumAddress from eth_utils import to_checksum_address, to_canonical_address -from nucypher.crypto.splitters import capsule_splitter, key_splitter, signature_splitter, checksum_address_splitter +from nucypher.crypto.splitters import ( + capsule_splitter, + key_splitter, + signature_splitter, + checksum_address_splitter, + ) import nucypher.crypto.umbral_adapter as umbral # need it to mock `umbral.encrypt` from nucypher.crypto.umbral_adapter import PublicKey, VerifiedCapsuleFrag, Capsule, Signature @@ -171,8 +175,6 @@ class PolicyMessageKit: policy_key: PublicKey, threshold: int ) -> 'PolicyMessageKit': - # TODO: can we get rid of circular dependency? - from nucypher.policy.orders import RetrievalResult return cls(policy_key, threshold, RetrievalResult.empty(), message_kit) def as_retrieval_kit(self) -> RetrievalKit: @@ -216,39 +218,32 @@ class PolicyMessageKit: message_kit=self.message_kit) -class RevocationKit: +# TODO: a better name? +class RetrievalResult: + """ + An object representing retrieval results for a single capsule. + """ - def __init__(self, treasure_map, signer: 'SignatureStamp'): - from nucypher.policy.orders import Revocation - self.revocations = dict() - for node_id, encrypted_kfrag in treasure_map: - self.revocations[node_id] = Revocation(ursula_checksum_address=node_id, - encrypted_kfrag=encrypted_kfrag, - signer=signer) + @classmethod + def empty(cls): + return cls({}) - def __iter__(self): - return iter(self.revocations.values()) + def __init__(self, cfrags: Dict[ChecksumAddress, VerifiedCapsuleFrag]): + self.cfrags = cfrags - def __getitem__(self, node_id): - return self.revocations[node_id] + def addresses(self) -> Set[ChecksumAddress]: + return set(self.cfrags) - def __len__(self): - return len(self.revocations) - - def __eq__(self, other): - return self.revocations == other.revocations - - @property - def revokable_addresses(self): + def with_result(self, result: 'RetrievalResult') -> 'RetrievalResult': """ - Returns a Set of revokable addresses in the checksum address formatting - """ - return set(self.revocations.keys()) + Joins two RetrievalResult objects. - def add_confirmation(self, node_id, signed_receipt): + If both objects contain cfrags from the same Ursula, + the one from `result` will be kept. """ - Adds a signed confirmation of Ursula's ability to revoke the arrangement. - """ - # TODO: Verify Ursula's signature - # TODO: Implement receipts - raise NotImplementedError + # TODO: would `+` or `|` operator be more suitable here? + + # TODO: check for overlap? + new_cfrags = dict(self.cfrags) + new_cfrags.update(result.cfrags) + return RetrievalResult(cfrags=new_cfrags) diff --git a/nucypher/policy/policies.py b/nucypher/policy/policies.py index fb4967682..977cd03b9 100644 --- a/nucypher/policy/policies.py +++ b/nucypher/policy/policies.py @@ -28,7 +28,6 @@ from nucypher.crypto.splitters import key_splitter from nucypher.crypto.umbral_adapter import PublicKey, VerifiedKeyFrag, Signature from nucypher.network.middleware import RestMiddleware from nucypher.policy.hrac import HRAC -from nucypher.policy.kits import RevocationKit from nucypher.policy.maps import TreasureMap from nucypher.policy.reservoir import ( make_federated_staker_reservoir, @@ -36,6 +35,7 @@ from nucypher.policy.reservoir import ( PrefetchStrategy, make_decentralized_staker_reservoir ) +from nucypher.policy.revocation import RevocationKit from nucypher.utilities.concurrency import WorkerPool from nucypher.utilities.logging import Logger diff --git a/nucypher/policy/revocation.py b/nucypher/policy/revocation.py new file mode 100644 index 000000000..0fce16b08 --- /dev/null +++ b/nucypher/policy/revocation.py @@ -0,0 +1,133 @@ +""" +This file is part of nucypher. + +nucypher is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +nucypher is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with nucypher. If not, see . +""" + + +from typing import Optional + +from bytestring_splitter import BytestringSplitter +from eth_typing.evm import ChecksumAddress +from eth_utils.address import to_canonical_address, to_checksum_address + +from nucypher.crypto.signing import SignatureStamp, InvalidSignature +from nucypher.crypto.splitters import signature_splitter, checksum_address_splitter +from nucypher.crypto.umbral_adapter import Signature, PublicKey +from nucypher.policy.kits import MessageKit +from nucypher.policy.maps import AuthorizedKeyFrag + + +class Revocation: + """ + Represents a string used by characters to perform a revocation on a specific + Ursula. It's a bytestring made of the following format: + REVOKE- + This is sent as a payload in a DELETE method to the /KFrag/ endpoint. + """ + + PREFIX = b'REVOKE-' + revocation_splitter = BytestringSplitter( + (bytes, len(PREFIX)), + checksum_address_splitter, # ursula canonical address + (bytes, AuthorizedKeyFrag.ENCRYPTED_SIZE), # encrypted kfrag payload (includes writ) + signature_splitter + ) + + def __init__(self, + ursula_checksum_address: ChecksumAddress, # TODO: Use staker address instead (what if the staker rebonds)? + encrypted_kfrag: MessageKit, + signer: Optional[SignatureStamp] = None, + signature: Optional[Signature] = None): + + self.ursula_checksum_address = ursula_checksum_address + self.encrypted_kfrag = encrypted_kfrag + + if not (bool(signer) ^ bool(signature)): + raise ValueError("Either pass a signer or a signature; not both.") + elif signer: + self.signature = signer(self.payload) + elif signature: + self.signature = signature + + def __bytes__(self): + return self.payload + bytes(self.signature) + + def __repr__(self): + return bytes(self) + + def __len__(self): + return len(bytes(self)) + + def __eq__(self, other): + return bytes(self) == bytes(other) + + @property + def payload(self): + return self.PREFIX \ + + to_canonical_address(self.ursula_checksum_address) \ + + bytes(self.encrypted_kfrag) \ + + @classmethod + def from_bytes(cls, revocation_bytes): + prefix, ursula_canonical_address, ekfrag, signature = cls.revocation_splitter(revocation_bytes) + ursula_checksum_address = to_checksum_address(ursula_canonical_address) + return cls(ursula_checksum_address=ursula_checksum_address, + encrypted_kfrag=ekfrag, + signature=signature) + + def verify_signature(self, alice_verifying_key: PublicKey) -> bool: + """ + Verifies the revocation was from the provided pubkey. + """ + if not self.signature.verify(self.payload, alice_verifying_key): + raise InvalidSignature(f"Revocation has an invalid signature: {self.signature}") + return True + + +class RevocationKit: + + def __init__(self, treasure_map, signer: SignatureStamp): + self.revocations = dict() + for node_id, encrypted_kfrag in treasure_map: + self.revocations[node_id] = Revocation(ursula_checksum_address=node_id, + encrypted_kfrag=encrypted_kfrag, + signer=signer) + + def __iter__(self): + return iter(self.revocations.values()) + + def __getitem__(self, node_id): + return self.revocations[node_id] + + def __len__(self): + return len(self.revocations) + + def __eq__(self, other): + return self.revocations == other.revocations + + @property + def revokable_addresses(self): + """ + Returns a Set of revokable addresses in the checksum address formatting + """ + return set(self.revocations.keys()) + + def add_confirmation(self, node_id, signed_receipt): + """ + Adds a signed confirmation of Ursula's ability to revoke the arrangement. + """ + # TODO: Verify Ursula's signature + # TODO: Implement receipts + raise NotImplementedError diff --git a/tests/integration/characters/test_federated_grant_and_revoke.py b/tests/integration/characters/test_federated_grant_and_revoke.py index 82e79744f..cb77361cb 100644 --- a/tests/integration/characters/test_federated_grant_and_revoke.py +++ b/tests/integration/characters/test_federated_grant_and_revoke.py @@ -24,7 +24,7 @@ import pytest from nucypher.characters.lawful import Enrico from nucypher.crypto.utils import keccak_digest from nucypher.policy.kits import MessageKit -from nucypher.policy.orders import Revocation +from nucypher.policy.revocation import Revocation def test_federated_grant(federated_alice, federated_bob, federated_ursulas):