diff --git a/newsfragments/2431.misc.rst b/newsfragments/2431.misc.rst new file mode 100644 index 000000000..8d1d8c6bb --- /dev/null +++ b/newsfragments/2431.misc.rst @@ -0,0 +1 @@ +Introduces a probationary period for policy creation in the network, until 2021-02-28 23:59:59 UTC. diff --git a/newsfragments/2460.feature.rst b/newsfragments/2460.feature.rst new file mode 100644 index 000000000..9c1233c38 --- /dev/null +++ b/newsfragments/2460.feature.rst @@ -0,0 +1 @@ +Complete interactive collection of policy parameters via alice grant CLI. diff --git a/nucypher/characters/control/specifications/alice.py b/nucypher/characters/control/specifications/alice.py index 221c04044..6c61548c6 100644 --- a/nucypher/characters/control/specifications/alice.py +++ b/nucypher/characters/control/specifications/alice.py @@ -15,14 +15,14 @@ along with nucypher. If not, see . """ + import click from marshmallow import validates_schema +from nucypher.cli import options, types from nucypher.characters.control.specifications import fields from nucypher.characters.control.specifications.base import BaseSchema -from nucypher.characters.control.specifications.exceptions import ( - InvalidArgumentCombo) -from nucypher.cli import options, types +from nucypher.characters.control.specifications.exceptions import InvalidArgumentCombo class PolicyBaseSchema(BaseSchema): @@ -52,7 +52,8 @@ class PolicyBaseSchema(BaseSchema): click=click.option( '--expiration', help="Expiration Datetime of a policy", - type=click.STRING)) + type=click.DateTime()) + ) # optional input value = fields.Wei( @@ -96,7 +97,7 @@ class GrantPolicy(PolicyBaseSchema): label = fields.Label( load_only=True, required=True, - click=options.option_label(required=True)) + click=options.option_label(required=False)) # output fields treasure_map = fields.TreasureMap(dump_only=True) diff --git a/nucypher/characters/lawful.py b/nucypher/characters/lawful.py index a9b2dd6d1..b019b1c1c 100644 --- a/nucypher/characters/lawful.py +++ b/nucypher/characters/lawful.py @@ -14,56 +14,68 @@ 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 contextlib import json +from collections import OrderedDict + import maya import random import time from base64 import b64decode, b64encode -from collections import OrderedDict -from datetime import datetime -from functools import partial -from json.decoder import JSONDecodeError -from queue import Queue -from random import shuffle -from typing import Dict, Iterable, List, Optional, Set, Tuple, Union - +from bytestring_splitter import ( + BytestringKwargifier, + BytestringSplitter, + BytestringSplittingError, + VariableLengthBytestring +) +from constant_sorrow import constants +from constant_sorrow.constants import ( + INCLUDED_IN_BYTESTRING, + PUBLIC_ONLY, + STRANGER_ALICE, + UNKNOWN_VERSION, + READY, + INVALIDATED +) from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurve from cryptography.hazmat.primitives.serialization import Encoding from cryptography.x509 import Certificate, NameOID, load_pem_x509_certificate +from datetime import datetime from eth_utils import to_checksum_address from flask import Response, request +from functools import partial +from json.decoder import JSONDecodeError +from queue import Queue +from random import shuffle from twisted.internet import reactor, stdio, threads from twisted.internet.task import LoopingCall +from typing import Dict, Iterable, List, Optional, Tuple, Union +from umbral import pre +from umbral.keys import UmbralPublicKey +from umbral.kfrags import KFrag +from umbral.pre import UmbralCorrectnessError +from umbral.signing import Signature import nucypher -from bytestring_splitter import BytestringKwargifier, BytestringSplitter, BytestringSplittingError, \ - VariableLengthBytestring -from constant_sorrow import constants -from constant_sorrow.constants import (INCLUDED_IN_BYTESTRING, - PUBLIC_ONLY, - STRANGER_ALICE, - UNKNOWN_VERSION, - READY, - INVALIDATED) from nucypher.acumen.nicknames import Nickname from nucypher.acumen.perception import FleetSensor from nucypher.blockchain.eth.actors import BlockchainPolicyAuthor, Worker from nucypher.blockchain.eth.agents import ContractAgency, StakingEscrowAgent -from nucypher.blockchain.eth.constants import LENGTH_ECDSA_SIGNATURE_WITH_RECOVERY, ETH_ADDRESS_BYTE_LENGTH +from nucypher.blockchain.eth.constants import ETH_ADDRESS_BYTE_LENGTH from nucypher.blockchain.eth.interfaces import BlockchainInterfaceFactory from nucypher.blockchain.eth.registry import BaseContractRegistry from nucypher.blockchain.eth.signers.software import Web3Signer from nucypher.blockchain.eth.token import WorkTracker from nucypher.characters.banners import ALICE_BANNER, BOB_BANNER, ENRICO_BANNER, URSULA_BANNER from nucypher.characters.base import Character, Learner -from nucypher.characters.control.controllers import ( - WebController -) +from nucypher.characters.control.controllers import WebController from nucypher.characters.control.emitters import StdoutEmitter from nucypher.characters.control.interfaces import AliceInterface, BobInterface, EnricoInterface from nucypher.cli.processes import UrsulaCommandProtocol +from nucypher.config.constants import END_OF_POLICIES_PROBATIONARY_PERIOD from nucypher.config.storages import ForgetfulNodeStorage, NodeStorage from nucypher.crypto.api import encrypt_and_sign, keccak_digest from nucypher.crypto.constants import HRAC_LENGTH, PUBLIC_KEY_LENGTH @@ -80,11 +92,6 @@ from nucypher.network.protocols import InterfaceInfo, parse_node_uri from nucypher.network.server import ProxyRESTServer, TLSHostingPower, make_rest_app from nucypher.network.trackers import AvailabilityTracker from nucypher.utilities.logging import Logger -from umbral import pre -from umbral.keys import UmbralPublicKey -from umbral.kfrags import KFrag -from umbral.pre import UmbralCorrectnessError -from umbral.signing import Signature class Alice(Character, BlockchainPolicyAuthor): @@ -288,7 +295,13 @@ class Alice(Character, BlockchainPolicyAuthor): self.remember_node(node=handpicked_ursula) policy = self.create_policy(bob=bob, label=label, **policy_params) - self.log.debug(f"Successfully created {policy} ... ") + + # TODO: Remove when the time is right. + if policy.expiration > END_OF_POLICIES_PROBATIONARY_PERIOD: + raise self.ActorError(f"The requested duration for this policy (until {policy.expiration}) exceeds the " + f"probationary period ({END_OF_POLICIES_PROBATIONARY_PERIOD}).") + + self.log.debug(f"Generated new policy proposal {policy} ... ") # # We'll find n Ursulas by default. It's possible to "play the field" by trying different diff --git a/nucypher/cli/actions/auth.py b/nucypher/cli/actions/auth.py index 44d656ae1..894acc110 100644 --- a/nucypher/cli/actions/auth.py +++ b/nucypher/cli/actions/auth.py @@ -65,7 +65,7 @@ def get_nucypher_password(confirm: bool = False, envvar=NUCYPHER_ENVVAR_KEYRING_ def unlock_nucypher_keyring(emitter: StdoutEmitter, password: str, character_configuration: CharacterConfiguration) -> bool: """Unlocks a nucypher keyring and attaches it to the supplied configuration if successful.""" - emitter.message(DECRYPTING_CHARACTER_KEYRING.format(name=character_configuration.NAME), color='yellow') + emitter.message(DECRYPTING_CHARACTER_KEYRING.format(name=character_configuration.NAME.capitalize()), color='yellow') # precondition if character_configuration.dev_mode: diff --git a/nucypher/cli/actions/confirm.py b/nucypher/cli/actions/confirm.py index ed8d65273..9047988f2 100644 --- a/nucypher/cli/actions/confirm.py +++ b/nucypher/cli/actions/confirm.py @@ -14,7 +14,8 @@ You should have received a copy of the GNU Affero General Public License along with nucypher. If not, see . """ -from typing import Type, Union +from tabulate import tabulate +from typing import Type, Union, Dict import click from constant_sorrow.constants import UNKNOWN_DEVELOPMENT_CHAIN_ID @@ -144,3 +145,11 @@ def verify_upgrade_details(blockchain: Union[BlockchainDeployerInterface, Blockc click.confirm(CONFIRM_VERSIONED_UPGRADE.format(contract_name=deployer.contract_name, old_version=old_contract.version, new_version=new_version), abort=True) + + +def confirm_staged_grant(emitter, grant_request: Dict) -> None: + # TODO: Expand and detail + emitter.echo("Successfully staged grant. Please review the details:\n", color='green') + table = ([field, value] for field, value in grant_request.items()) + emitter.echo(tabulate(table, tablefmt="simple")) + click.confirm('\nGrant access and sign transaction?', abort=True) diff --git a/nucypher/cli/commands/alice.py b/nucypher/cli/commands/alice.py index b239c104c..7814a4061 100644 --- a/nucypher/cli/commands/alice.py +++ b/nucypher/cli/commands/alice.py @@ -15,9 +15,13 @@ You should have received a copy of the GNU Affero General Public License along with nucypher. If not, see . """ + import click +import maya import os from constant_sorrow.constants import NO_BLOCKCHAIN_CONNECTION, NO_PASSWORD +from datetime import timedelta +from web3.main import Web3 from nucypher.blockchain.eth.signers.software import ClefSigner from nucypher.characters.control.emitters import StdoutEmitter @@ -25,8 +29,10 @@ from nucypher.characters.control.interfaces import AliceInterface from nucypher.cli.actions.auth import get_client_password, get_nucypher_password from nucypher.cli.actions.configure import ( destroy_configuration, - handle_missing_configuration_file, get_or_update_configuration + handle_missing_configuration_file, + get_or_update_configuration ) +from nucypher.cli.actions.confirm import confirm_staged_grant from nucypher.cli.actions.select import select_client_account, select_config_file from nucypher.cli.commands.deploy import option_gas_strategy from nucypher.cli.config import group_general_config @@ -55,12 +61,19 @@ from nucypher.cli.options import ( option_teacher_uri, option_lonely ) -from nucypher.cli.painting.help import paint_new_installation_help +from nucypher.cli.painting.help import ( + paint_new_installation_help, + paint_probationary_period_disclaimer, + enforce_probationary_period +) from nucypher.cli.processes import get_geth_provider_process -from nucypher.cli.types import EIP55_CHECKSUM_ADDRESS +from nucypher.cli.types import EIP55_CHECKSUM_ADDRESS, GWEI from nucypher.cli.utils import make_cli_character, setup_emitter from nucypher.config.characters import AliceConfiguration -from nucypher.config.constants import NUCYPHER_ENVVAR_ALICE_ETH_PASSWORD, TEMPORARY_DOMAIN +from nucypher.config.constants import ( + NUCYPHER_ENVVAR_ALICE_ETH_PASSWORD, + TEMPORARY_DOMAIN, +) from nucypher.config.keyring import NucypherKeyring from nucypher.network.middleware import RestMiddleware @@ -428,6 +441,7 @@ def derive_policy_pubkey(general_config, label, character_options, config_file): @option_config_file @group_general_config @group_character_options +@option_force def grant(general_config, bob_encrypting_key, bob_verifying_key, @@ -437,7 +451,8 @@ def grant(general_config, expiration, m, n, character_options, - config_file): + config_file, + force): """Create and enact an access policy for some Bob. """ # Setup @@ -447,13 +462,62 @@ def grant(general_config, # Input validation if ALICE.federated_only: if any((value, rate)): - raise click.BadOptionUsage(option_name="--value, --rate", - message="Can't use --value or --rate with a federated Alice.") + message = "Can't use --value or --rate with a federated Alice." + raise click.BadOptionUsage(option_name="--value, --rate", message=message) elif bool(value) and bool(rate): raise click.BadOptionUsage(option_name="--rate", message="Can't use --value if using --rate") - elif not (bool(value) or bool(rate)): - rate = ALICE.default_rate # TODO #1709 - click.confirm(f"Confirm default rate {rate}?", abort=True) + + # Interactive collection follows: + # TODO: Extricate to support modules + # - Disclaimer + # - Label + # - Expiration Date & Time + # - M of N + # - Policy Value (ETH) + + # Policy Expiration + # TODO: Remove this line when the time is right. + paint_probationary_period_disclaimer(emitter) + + # Label + if not label: + label = click.prompt(f'Enter label to grant Bob {bob_verifying_key[:8]}', type=click.STRING) + + if not force and not expiration: + if ALICE.duration_periods: + # TODO: use a default in days or periods? + expiration = maya.now() + timedelta(days=ALICE.duration_periods) # default + if not click.confirm(f'Use default policy duration (expires {expiration})?'): + expiration = click.prompt('Enter policy expiration datetime', type=click.DateTime()) + else: + # No policy duration default default available; Go interactive + expiration = click.prompt('Enter policy expiration datetime', type=click.DateTime()) + + # TODO: Remove this line when the time is right. + enforce_probationary_period(emitter=emitter, expiration=expiration) + + # Policy Threshold and Shares + if not n: + n = ALICE.n + if not force and not click.confirm(f'Use default value for N ({n})?', default=True): + n = click.prompt('Enter total number of shares (N)', type=click.INT) + if not m: + m = ALICE.m + if not force and not click.confirm(f'Use default value for M ({m})?', default=True): + m = click.prompt('Enter threshold (M)', type=click.IntRange(1, n)) + + # Policy Value + policy_value_provided = bool(value) or bool(rate) + if not ALICE.federated_only and not policy_value_provided: + rate = ALICE.default_rate # TODO #1709 - Fine tuning and selection of default rates + if not force: + default_gwei = Web3.fromWei(rate, 'gwei') + prompt = "Confirm rate of {node_rate} gwei ({total_rate} gwei per period)?" + if not click.confirm(prompt.format(node_rate=default_gwei, total_rate=default_gwei*n), default=True): + interactive_rate = click.prompt('Enter rate per period in gwei', type=GWEI) + # TODO: Validate interactively collected rate (#1709) + click.confirm(prompt.format(node_rate=rate, total_rate=rate*n), default=True, abort=True) + rate = Web3.toWei(interactive_rate, 'gwei') # Request grant_request = { @@ -468,7 +532,11 @@ def grant(general_config, if value: grant_request['value'] = value elif rate: - grant_request['rate'] = rate + grant_request['rate'] = rate # in wei + + if not force and not general_config.json_ipc: + confirm_staged_grant(emitter=emitter, grant_request=grant_request) + emitter.echo(f'Granting Access to {bob_verifying_key[:8]}', color='yellow') return ALICE.controller.grant(request=grant_request) diff --git a/nucypher/cli/commands/stake.py b/nucypher/cli/commands/stake.py index d2a3a7344..9fb6211cf 100644 --- a/nucypher/cli/commands/stake.py +++ b/nucypher/cli/commands/stake.py @@ -454,6 +454,10 @@ def unbond_worker(general_config: GroupGeneralConfig, blockchain=blockchain, client_account=client_account, hw_wallet=transacting_staker_options.hw_wallet) + + if not force: + click.confirm("Are you sure you want to unbond your worker?", abort=True) + STAKEHOLDER.assimilate(password=password) receipt = STAKEHOLDER.unbond_worker() diff --git a/nucypher/cli/literature.py b/nucypher/cli/literature.py index 42bb5bd8a..e45c80f91 100644 --- a/nucypher/cli/literature.py +++ b/nucypher/cli/literature.py @@ -355,7 +355,7 @@ COLLECT_NUCYPHER_PASSWORD = "Enter NuCypher keyring password" GENERIC_PASSWORD_PROMPT = "Enter password" -DECRYPTING_CHARACTER_KEYRING = 'Decrypting {name} keyring...' +DECRYPTING_CHARACTER_KEYRING = 'Authenticating {name}' # diff --git a/nucypher/cli/painting/help.py b/nucypher/cli/painting/help.py index cf11f9c8a..dad3dd1dc 100644 --- a/nucypher/cli/painting/help.py +++ b/nucypher/cli/painting/help.py @@ -16,10 +16,11 @@ along with nucypher. If not, see . """ import click +import maya from nucypher.blockchain.eth.sol.__conf__ import SOLIDITY_COMPILER_VERSION from nucypher.characters.banners import NUCYPHER_BANNER -from nucypher.config.constants import DEFAULT_CONFIG_ROOT, USER_LOG_DIR +from nucypher.config.constants import DEFAULT_CONFIG_ROOT, USER_LOG_DIR, END_OF_POLICIES_PROBATIONARY_PERIOD def echo_version(ctx, param, value): @@ -74,6 +75,38 @@ def paint_new_installation_help(emitter, new_configuration): character_name_starts_with_vowel = character_name[0].lower() in vowels adjective = 'an' if character_name_starts_with_vowel else 'a' suggested_command = f'nucypher {character_name} run' - how_to_run_message = f"\nTo run {adjective} {character_name.capitalize()} node from the default configuration filepath run: \n\n'{suggested_command}'\n" + how_to_run_message = f"\nTo run {adjective} {character_name.capitalize()} node from the default configuration " \ + f"filepath run: \n\n'{suggested_command}'\n" emitter.echo(how_to_run_message.format(suggested_command), color='green') + + +def paint_probationary_period_disclaimer(emitter): + width = 60 + import textwrap + disclaimer_title = " DISCLAIMER ".center(width, "=") + paragraph = f""" +Some areas of the NuCypher network are still under active development; +as a consequence, we have established a probationary period for policies in the network. +Currently the creation of sharing policies with durations beyond {END_OF_POLICIES_PROBATIONARY_PERIOD} are prevented. +After this date the probationary period will be over, and you will be able to create policies with any duration +as supported by nodes on the network. +""" + + text = ( + "\n", + disclaimer_title, + *[line.center(width) for line in textwrap.wrap(paragraph, width - 2)], + "=" * len(disclaimer_title), + "\n" + ) + for sentence in text: + emitter.echo(sentence, color='yellow') + + +def enforce_probationary_period(emitter, expiration): + """Used during CLI grant to prevent publication of a policy outside the probationary period.""" + if maya.MayaDT.from_datetime(expiration) > END_OF_POLICIES_PROBATIONARY_PERIOD: + emitter.echo(f"The requested duration for this policy (until {expiration}) exceeds the probationary period" + f" ({END_OF_POLICIES_PROBATIONARY_PERIOD}).", color="red") + raise click.Abort() diff --git a/nucypher/config/constants.py b/nucypher/config/constants.py index e87410e10..d6a5762fa 100644 --- a/nucypher/config/constants.py +++ b/nucypher/config/constants.py @@ -22,6 +22,8 @@ from pathlib import Path import os from appdirs import AppDirs +from maya import MayaDT + import nucypher # Environment variables @@ -69,3 +71,6 @@ TEMPORARY_DOMAIN = ":temporary-domain:" # for use with `--dev` node runtimes # Event Blocks Throttling NUCYPHER_EVENTS_THROTTLE_MAX_BLOCKS = 'NUCYPHER_EVENTS_THROTTLE_MAX_BLOCKS' + +# Probationary period (see #2353) +END_OF_POLICIES_PROBATIONARY_PERIOD = MayaDT.from_iso8601('2021-02-28T23:59:59.0Z') diff --git a/tests/acceptance/cli/lifecycle.py b/tests/acceptance/cli/lifecycle.py index 115cda448..1bb50d33d 100644 --- a/tests/acceptance/cli/lifecycle.py +++ b/tests/acceptance/cli/lifecycle.py @@ -294,6 +294,7 @@ def run_entire_cli_lifecycle(click_runner, bob_keys = side_channel.fetch_bob_public_keys() bob_encrypting_key = bob_keys.bob_encrypting_key bob_verifying_key = bob_keys.bob_verifying_key + expiration = (maya.now() + datetime.timedelta(days=3)).datetime().strftime("%Y-%m-%d %H:%M:%S") grant_args = ('alice', 'grant', '--mock-networking', @@ -303,7 +304,7 @@ def run_entire_cli_lifecycle(click_runner, '--config-file', alice_configuration_file_location, '--m', 2, '--n', 3, - '--expiration', (maya.now() + datetime.timedelta(days=3)).iso8601(), + '--expiration', expiration, '--label', random_label, '--bob-encrypting-key', bob_encrypting_key, '--bob-verifying-key', bob_verifying_key) @@ -314,9 +315,8 @@ def run_entire_cli_lifecycle(click_runner, grant_args += ('--provider', TEST_PROVIDER_URI, '--rate', Web3.toWei(9, 'gwei')) - # TODO: Stop. grant_result = click_runner.invoke(nucypher_cli, grant_args, catch_exceptions=False, env=envvars) - assert grant_result.exit_code == 0 + assert grant_result.exit_code == 0, grant_result.output grant_result = json.loads(grant_result.output) diff --git a/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py b/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py index 159861597..500ab31b2 100644 --- a/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py +++ b/tests/acceptance/cli/ursula/test_stakeholder_and_ursula.py @@ -755,6 +755,7 @@ def test_stake_unbond_worker(click_runner, init_args = ('stake', 'unbond-worker', '--config-file', stakeholder_configuration_file_location, '--staking-address', manual_staker, + '--force' ) user_input = f'{INSECURE_DEVELOPMENT_PASSWORD}' diff --git a/tests/integration/cli/actions/test_auth_actions.py b/tests/integration/cli/actions/test_auth_actions.py index 99eedbbac..c0dd54701 100644 --- a/tests/integration/cli/actions/test_auth_actions.py +++ b/tests/integration/cli/actions/test_auth_actions.py @@ -118,7 +118,7 @@ def test_unlock_nucypher_keyring_invalid_password(mocker, test_emitter, alice_bl keyring_attach_spy.assert_called_once() captured = capsys.readouterr() - assert DECRYPTING_CHARACTER_KEYRING.format(name='alice') in captured.out + assert DECRYPTING_CHARACTER_KEYRING.format(name=alice_blockchain_test_config.NAME.capitalize()) in captured.out def test_unlock_nucypher_keyring_dev_mode(mocker, test_emitter, capsys, alice_blockchain_test_config): @@ -137,7 +137,7 @@ def test_unlock_nucypher_keyring_dev_mode(mocker, test_emitter, capsys, alice_bl assert result output = capsys.readouterr().out - message = DECRYPTING_CHARACTER_KEYRING.format(name=alice_blockchain_test_config.NAME) + message = DECRYPTING_CHARACTER_KEYRING.format(name=alice_blockchain_test_config.NAME.capitalize()) assert message in output unlock_spy.assert_not_called() @@ -166,7 +166,7 @@ def test_unlock_nucypher_keyring(mocker, assert result captured = capsys.readouterr() - message = DECRYPTING_CHARACTER_KEYRING.format(name=alice_blockchain_test_config.NAME) + message = DECRYPTING_CHARACTER_KEYRING.format(name=alice_blockchain_test_config.NAME.capitalize()) assert message in captured.out unlock_spy.assert_called_once_with(password=INSECURE_DEVELOPMENT_PASSWORD)