diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index a9fa0a494..5b96e87ff 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -17,11 +17,12 @@ COPY ./nucypher/blockchain/eth/sol/__conf__.py /install/nucypher/blockchain/eth/ COPY scripts/installation/install_solc.py /install/scripts/installation/ COPY dev-requirements.txt /install COPY requirements.txt /install +COPY docs-requirements.txt /install COPY dev/docker/scripts/install/entrypoint.sh /install # install reqs and solc RUN pip install --upgrade pip -RUN pip3 install -r /install/dev-requirements.txt --src /usr/local/src +RUN pip3 install .[dev] --src /usr/local/src RUN pip3 install ipdb # puts the nucypher executable in bin path diff --git a/docs/source/guides/development/getting_started.rst b/docs/source/guides/development/getting_started.rst index 3dfd16656..a06d01986 100644 --- a/docs/source/guides/development/getting_started.rst +++ b/docs/source/guides/development/getting_started.rst @@ -29,7 +29,7 @@ The NuCypher network does not store or handle an application's data; instead - i Management of encrypted secrets and public keys tends to be highly domain-specific - The surrounding architecture will vary greatly depending on the throughput, sensitivity, and sharing cadence of application secrets. In all cases, NuCypher must be integrated with a storage and transport layer in order to function properly. -Along with the transport of ciphertexts, a nucypher application also needs to include channels for Alice and Bob +Along with the transport of ciphertexts, a nucypher application also needs to include channels for Alice and Bob to discover each other's public keys, and provide policy encrypting information to Bob and Enrico. Side Channel Application Data @@ -93,7 +93,7 @@ Connecting Nucypher to an Ethereum Provider Ursula: Untrusted Re-Encryption Proxies ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -When initializing an ``Alice``\ , ``Bob``\ , or ``Ursula``\ , an initial "Stranger-\ ``Ursula``\ " is needed to perform +When initializing an ``Alice``\ , ``Bob``\ , or ``Ursula``\ , an initial "Stranger-\ ``Ursula``\ " is needed to perform the role of a ``Teacher``\ , or "seednode": .. code-block:: python @@ -151,7 +151,7 @@ Create a NuCypher Keyring alice.start_learning_loop(now=True) -Alice needs to know about Bob in order to grant access by acquiring Bob's public key's through +Alice needs to know about Bob in order to grant access by acquiring Bob's public key's through the application side channel: .. code-block:: python @@ -201,7 +201,7 @@ Encrypt from nucypher.characters.lawful import Enrico enrico = Enrico(policy_encrypting_key=policy_encrypting_key) - ciphertext, signature = enrico.encrypt_message(message=b'Peace at dawn.') + ciphertext, signature = enrico.encrypt_message(plaintext=b'Peace at dawn.') The ciphertext can then be sent to Bob via the application side channel. diff --git a/examples/populate_mario_box.py b/examples/populate_mario_box.py index 4522983b2..3d8297e3f 100644 --- a/examples/populate_mario_box.py +++ b/examples/populate_mario_box.py @@ -55,7 +55,7 @@ def mario_box_cli(plaintext_dir, alice_config, label, outfile): encoded_plaintext = base64.b64encode(plaintext) enrico = Enrico(policy_encrypting_key=policy_encrypting_key) - message_kit, _signature = enrico.encrypt_message(message=encoded_plaintext) + message_kit, _signature = enrico.encrypt_message(plaintext=encoded_plaintext) base64_message_kit = base64.b64encode(bytes(message_kit)).decode() # Collect Bob Retrieve JSON Requests diff --git a/nucypher/characters/control/controllers.py b/nucypher/characters/control/controllers.py index d92bbd121..151c1f928 100644 --- a/nucypher/characters/control/controllers.py +++ b/nucypher/characters/control/controllers.py @@ -60,7 +60,6 @@ class CharacterControllerBase(ABC): method = getattr(self.interface, action, None) serializer = method._schema params = serializer.load(request) # input validation will occur here. - response = method(**params) # < ---- INLET response_data = serializer.dump(response) @@ -139,10 +138,11 @@ class CLIController(CharacterControlServer): def test_client(self): return - def handle_request(self, method_name, request): + def handle_request(self, method_name, request) -> dict: start = maya.now() response = self._perform_action(action=method_name, request=request) - return self.emitter.ipc(response=response, request_id=start.epoch, duration=maya.now() - start) + self.emitter.ipc(response=response, request_id=start.epoch, duration=maya.now() - start) + return response class JSONRPCController(CharacterControlServer): diff --git a/nucypher/characters/control/interfaces.py b/nucypher/characters/control/interfaces.py index 565d41ccb..65f3cc8d1 100644 --- a/nucypher/characters/control/interfaces.py +++ b/nucypher/characters/control/interfaces.py @@ -24,6 +24,7 @@ from nucypher.characters.control.specifications import alice, bob, enrico from nucypher.crypto.kits import UmbralMessageKit from nucypher.crypto.powers import DecryptingPower, SigningPower from nucypher.crypto.utils import construct_policy_id +from nucypher.datastore.datastore import NotFound def attach_schema(schema): @@ -241,11 +242,11 @@ class BobInterface(CharacterPublicInterface): class EnricoInterface(CharacterPublicInterface): @attach_schema(enrico.EncryptMessage) - def encrypt_message(self, message: str): + def encrypt_message(self, plaintext: Union[str, bytes]): """ Character control endpoint for encrypting data for a policy and receiving the messagekit (and signature) to give to Bob. """ - message_kit, signature = self.character.encrypt_message(bytes(message, encoding='utf-8')) + message_kit, signature = self.character.encrypt_message(plaintext=plaintext) response_data = {'message_kit': message_kit, 'signature': signature} return response_data diff --git a/nucypher/characters/control/specifications/enrico.py b/nucypher/characters/control/specifications/enrico.py index 3992acc8c..e19a6b172 100644 --- a/nucypher/characters/control/specifications/enrico.py +++ b/nucypher/characters/control/specifications/enrico.py @@ -16,25 +16,52 @@ """ import click +from marshmallow import post_load -from nucypher.characters.control.specifications import fields -from nucypher.characters.control.specifications.base import BaseSchema +from nucypher.characters.control.specifications import fields, exceptions from nucypher.cli import options +from nucypher.cli.types import EXISTING_READABLE_FILE +from nucypher.characters.control.specifications.base import BaseSchema class EncryptMessage(BaseSchema): # input message = fields.Cleartext( - required=True, load_only=True, + load_only=True, + allow_none=True, click=click.option('--message', help="A unicode message to encrypt for a policy") ) + file = fields.FileField( + load_only=True, + allow_none=True, + click=click.option('--file', help="Filepath to plaintext file to encrypt", type=EXISTING_READABLE_FILE) + ) + policy_encrypting_key = fields.Key( required=False, load_only=True, - click=options.option_policy_encrypting_key()) + click=options.option_policy_encrypting_key() + ) + + @post_load() + def format_method_arguments(self, data, **kwargs): + """ + input can be through either the file input or a raw message, + we output one of them as the "plaintext" arg to enrico.encrypt_message + """ + + if data.get('message') and data.get('file'): + raise exceptions.InvalidArgumentCombo("choose either a message or a filepath but not both.") + + if data.get('message'): + data = bytes(data['message'], encoding='utf-8') + else: + data = data['file'] + + return {"plaintext": data} # output message_kit = fields.UmbralMessageKit(dump_only=True) - signature = fields.String(dump_only=True) # maybe we need a signature field? + signature = fields.UmbralSignature(dump_only=True) diff --git a/nucypher/characters/control/specifications/fields/__init__.py b/nucypher/characters/control/specifications/fields/__init__.py index 89ace9086..17912d924 100644 --- a/nucypher/characters/control/specifications/fields/__init__.py +++ b/nucypher/characters/control/specifications/fields/__init__.py @@ -22,3 +22,5 @@ from nucypher.characters.control.specifications.fields.datetime import * from nucypher.characters.control.specifications.fields.label import * from nucypher.characters.control.specifications.fields.cleartext import * from nucypher.characters.control.specifications.fields.misc import * +from nucypher.characters.control.specifications.fields.file import * +from nucypher.characters.control.specifications.fields.signature import * diff --git a/nucypher/characters/control/specifications/fields/file.py b/nucypher/characters/control/specifications/fields/file.py new file mode 100644 index 000000000..ed551eeed --- /dev/null +++ b/nucypher/characters/control/specifications/fields/file.py @@ -0,0 +1,33 @@ +""" + 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 . +""" + +import os +from marshmallow import fields + +from nucypher.characters.control.specifications.exceptions import InvalidInputData, InvalidNativeDataTypes +from nucypher.characters.control.specifications.fields.base import BaseField + + +class FileField(BaseField, fields.String): + + def _deserialize(self, value, attr, data, **kwargs): + with open(value, 'rb') as plaintext_file: + plaintext = plaintext_file.read() # TODO: #2106 Handle large files + return plaintext + + def _validate(self, value): + return os.path.exists(value) and os.path.isfile(value) diff --git a/nucypher/characters/control/specifications/fields/signature.py b/nucypher/characters/control/specifications/fields/signature.py new file mode 100644 index 000000000..77adf1b3a --- /dev/null +++ b/nucypher/characters/control/specifications/fields/signature.py @@ -0,0 +1,44 @@ +""" + 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 base64 import b64decode, b64encode + +from marshmallow import fields +from umbral.signing import Signature + +from nucypher.characters.control.specifications.exceptions import InvalidInputData, InvalidNativeDataTypes +from nucypher.characters.control.specifications.fields.base import BaseField + + +class UmbralSignature(BaseField, fields.Field): + + def _serialize(self, value: Signature, attr, obj, **kwargs): + return b64encode(bytes(value)).decode() + + def _deserialize(self, value, attr, data, **kwargs): + if isinstance(value, bytes): + return value + try: + return Signature.from_bytes(b64decode(value)) + except InvalidNativeDataTypes as e: + raise InvalidInputData(f"Could not parse {self.name}: {e}") + + def _validate(self, value): + try: + Signature.from_bytes(value) + except InvalidNativeDataTypes as e: + raise InvalidInputData(f"Could not parse {self.name}: {e}") diff --git a/nucypher/characters/lawful.py b/nucypher/characters/lawful.py index 5a6fa6372..38e199e2e 100644 --- a/nucypher/characters/lawful.py +++ b/nucypher/characters/lawful.py @@ -791,15 +791,15 @@ class Bob(Character): self.get_reencrypted_cfrags(work_order, retain_cfrags=retain_cfrags) except NodeSeemsToBeDown as e: # TODO: What to do here? Ursula isn't supposed to be down. NRN - self.log.info( - f"Ursula ({work_order.ursula}) seems to be down while trying to complete WorkOrder: {work_order}") + self.log.info(f"Ursula ({work_order.ursula}) seems to be down while trying to complete WorkOrder: {work_order}") continue except self.network_middleware.NotFound: # 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 - self.log.warn( - f"Ursula ({work_order.ursula}) claims not to have the KFrag to complete WorkOrder: {work_order}. Has accessed been revoked?") + self.log.warn(f"Ursula ({work_order.ursula}) claims not to have the KFrag to complete WorkOrder: {work_order}. Has accessed been revoked?") continue + except self.network_middleware.UnexpectedResponse: + raise # TODO: Handle this for capsule, pre_task in work_order.tasks.items(): try: @@ -1551,11 +1551,10 @@ class Enrico(Character): self.log = Logger(f'{self.__class__.__name__}-{bytes(self.public_keys(SigningPower)).hex()[:6]}') self.log.info(self.banner.format(policy_encrypting_key)) - def encrypt_message(self, - message: bytes - ) -> Tuple[UmbralMessageKit, Signature]: + def encrypt_message(self, plaintext: bytes) -> Tuple[UmbralMessageKit, Signature]: + # TODO: #2107 Rename to "encrypt" message_kit, signature = encrypt_and_sign(self.policy_pubkey, - plaintext=message, + plaintext=plaintext, signer=self.stamp) message_kit.policy_pubkey = self.policy_pubkey # TODO: We can probably do better here. NRN return message_kit, signature diff --git a/nucypher/cli/commands/bob.py b/nucypher/cli/commands/bob.py index 0d1d87ec1..c67b1dd2a 100644 --- a/nucypher/cli/commands/bob.py +++ b/nucypher/cli/commands/bob.py @@ -14,8 +14,10 @@ You should have received a copy of the GNU Affero General Public License along with nucypher. If not, see . """ +import base64 import click +import ipfshttpclient from nucypher.characters.control.emitters import StdoutEmitter from nucypher.characters.control.interfaces import BobInterface @@ -286,13 +288,15 @@ def public_keys(general_config, character_options, config_file): @option_config_file @BobInterface.connect_cli('retrieve') @group_general_config +@click.option('--ipfs', help="Download an encrypted message from IPFS at the specified gateway URI") def retrieve(general_config, character_options, config_file, label, policy_encrypting_key, alice_verifying_key, - message_kit): + message_kit, + ipfs): """Obtain plaintext from encrypted data, if access was granted.""" # Setup @@ -305,6 +309,15 @@ def retrieve(general_config, required_fields = ', '.join(input_specification) raise click.BadArgumentUsage(f'{required_fields} are required flags to retrieve') + if ipfs: + # TODO: #2108 + emitter.message(f"Connecting to IPFS Gateway {ipfs}") + ipfs_client = ipfshttpclient.connect(ipfs) + cid = message_kit # Understand the message kit value as an IPFS hash. + raw_message_kit = ipfs_client.cat(cid) # cat the contents at the hash reference + emitter.message(f"Downloaded message kit from IPFS (CID {cid})", color='green') + message_kit = raw_message_kit.decode() # cast to utf-8 + # Request bob_request_data = { 'label': label, diff --git a/nucypher/cli/commands/enrico.py b/nucypher/cli/commands/enrico.py index a670623bf..bd47b6921 100644 --- a/nucypher/cli/commands/enrico.py +++ b/nucypher/cli/commands/enrico.py @@ -17,6 +17,7 @@ along with nucypher. If not, see . import click +import ipfshttpclient from umbral.keys import UmbralPublicKey from nucypher.characters.control.interfaces import EnricoInterface @@ -58,13 +59,29 @@ def run(general_config, policy_encrypting_key, dry_run, http_port): @enrico.command() @EnricoInterface.connect_cli('encrypt_message') +@click.option('--ipfs', help="Upload the encrypted message to IPFS at the specified gateway URI") @group_general_config -def encrypt(general_config, policy_encrypting_key, message): +def encrypt(general_config, policy_encrypting_key, message, file, ipfs): """Encrypt a message under a given policy public key.""" + + # Setup emitter = setup_emitter(general_config=general_config, banner=policy_encrypting_key) ENRICO = _create_enrico(emitter, policy_encrypting_key) - encryption_request = {'message': message} + if not (bool(message) ^ bool(file)): + emitter.error(f'Pass either --message or --file. Got {message}, {file}.') + raise click.Abort + + # Encryption Request + encryption_request = {'policy_encrypting_key': policy_encrypting_key, 'message': message, 'file': file} response = ENRICO.controller.encrypt_message(request=encryption_request) + + # Handle Ciphertext + # TODO: This might be crossing the bridge of being application code + if ipfs: + emitter.message(f"Connecting to IPFS Gateway {ipfs}") + ipfs_client = ipfshttpclient.connect(ipfs) + cid = ipfs_client.add_str(response['message_kit']) + emitter.message(f"Uploaded message kit to IPFS (CID {cid})", color='green') return response diff --git a/nucypher/network/middleware.py b/nucypher/network/middleware.py index b9393bf7a..e8a96c921 100644 --- a/nucypher/network/middleware.py +++ b/nucypher/network/middleware.py @@ -235,10 +235,13 @@ class RestMiddleware: def send_work_order_payload_to_ursula(self, work_order): payload = work_order.payload() id_as_hex = work_order.arrangement_id.hex() - return self.client.post( + response = self.client.post( node_or_sprout=work_order.ursula, path=f"kFrag/{id_as_hex}/reencrypt", - data=payload, timeout=2) + data=payload, + timeout=2 + ) + return response def check_rest_availability(self, initiator, responder): response = self.client.post(node_or_sprout=responder, diff --git a/nucypher/utilities/prometheus/metrics.py b/nucypher/utilities/prometheus/metrics.py index b66033b54..00af4b7c4 100644 --- a/nucypher/utilities/prometheus/metrics.py +++ b/nucypher/utilities/prometheus/metrics.py @@ -36,9 +36,12 @@ from nucypher.utilities.prometheus.collector import ( import json from typing import List -from prometheus_client.core import Timestamp -from prometheus_client.registry import CollectorRegistry, REGISTRY -from prometheus_client.utils import floatToGoString +try: + from prometheus_client.core import Timestamp + from prometheus_client.registry import CollectorRegistry, REGISTRY + from prometheus_client.utils import floatToGoString +except ImportError: + raise DevelopmentInstallationRequired(importable_name='prometheus_client') from twisted.internet import reactor, task from twisted.web.resource import Resource diff --git a/tests/acceptance/characters/control/test_rpc_control_blockchain.py b/tests/acceptance/characters/control/test_rpc_control_blockchain.py index ecc0df863..6cce8a0d7 100644 --- a/tests/acceptance/characters/control/test_rpc_control_blockchain.py +++ b/tests/acceptance/characters/control/test_rpc_control_blockchain.py @@ -39,11 +39,9 @@ def get_fields(interface, method_name): def validate_json_rpc_response_data(response, method_name, interface): - - required_output_fileds = get_fields(interface, method_name)[-1] - + required_output_fields = get_fields(interface, method_name)[-1] assert 'jsonrpc' in response.data - for output_field in required_output_fileds: + for output_field in required_output_fields: assert output_field in response.content return True diff --git a/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py b/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py index 2c95e4949..6843fcb53 100644 --- a/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py +++ b/tests/acceptance/cli/ursula/test_stake_via_allocation_contract.py @@ -571,7 +571,7 @@ def test_collect_rewards_integration(click_runner, # Encrypt random_data = os.urandom(random.randrange(20, 100)) - message_kit, signature = enrico.encrypt_message(message=random_data) + message_kit, signature = enrico.encrypt_message(plaintext=random_data) # Decrypt cleartexts = blockchain_bob.retrieve(message_kit, diff --git a/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py b/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py index 250aca789..51e31c775 100644 --- a/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py +++ b/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py @@ -473,7 +473,7 @@ def test_collect_rewards_integration(click_runner, # Encrypt random_data = os.urandom(random.randrange(20, 100)) - ciphertext, signature = enrico.encrypt_message(message=random_data) + ciphertext, signature = enrico.encrypt_message(plaintext=random_data) # Decrypt cleartexts = blockchain_bob.retrieve(ciphertext, diff --git a/tests/integration/characters/control/test_web_control_federated.py b/tests/integration/characters/control/test_web_control_federated.py index cd6f6e49f..d79053d09 100644 --- a/tests/integration/characters/control/test_web_control_federated.py +++ b/tests/integration/characters/control/test_web_control_federated.py @@ -230,7 +230,6 @@ def test_bob_web_character_control_retrieve_again(bob_web_controller_test_client def test_enrico_web_character_control_encrypt_message(enrico_web_controller_test_client, encrypt_control_request): method_name, params = encrypt_control_request endpoint = f'/{method_name}' - response = enrico_web_controller_test_client.post(endpoint, data=json.dumps(params)) assert response.status_code == 200 diff --git a/tests/integration/characters/federated_encrypt_and_decrypt.py b/tests/integration/characters/federated_encrypt_and_decrypt.py index 51557d550..b2149cffd 100644 --- a/tests/integration/characters/federated_encrypt_and_decrypt.py +++ b/tests/integration/characters/federated_encrypt_and_decrypt.py @@ -107,7 +107,7 @@ def test_alice_can_decrypt(federated_alice): enrico = Enrico(policy_encrypting_key=policy_pubkey) message = b"boring test message" - message_kit, signature = enrico.encrypt_message(message=message) + message_kit, signature = enrico.encrypt_message(plaintext=message) # Interesting thing: if Alice wants to decrypt, she needs to provide the label directly. cleartext = federated_alice.verify_from(stranger=enrico, diff --git a/tests/unit/test_bytestring_types.py b/tests/unit/test_bytestring_types.py index b8f739f96..123921f23 100644 --- a/tests/unit/test_bytestring_types.py +++ b/tests/unit/test_bytestring_types.py @@ -65,7 +65,7 @@ def test_message_kit_serialization_via_enrico(enacted_federated_policy, federate plaintext_bytes = bytes(message, encoding='utf-8') # Create - message_kit, signature = enrico.encrypt_message(message=plaintext_bytes) + message_kit, signature = enrico.encrypt_message(plaintext=plaintext_bytes) # Serialize message_kit_bytes = message_kit.to_bytes()