mirror of https://github.com/nucypher/nucypher.git
Merge pull request #3529 from manumonti/bad-cohort
Local/Onchain Ferveo Key Mismatch Mitigationspull/3524/head
commit
4229fcdd41
|
@ -0,0 +1 @@
|
|||
Prevents nodes from starting up or participating in DKGs if there is a local vs. onchain ferveo key mismatch. This will assist in alerting node operators who need to relocate or recover their hosts about the correct procedure.
|
|
@ -57,6 +57,7 @@ from nucypher.blockchain.eth.utils import (
|
|||
rpc_endpoint_health_check,
|
||||
truncate_checksum_address,
|
||||
)
|
||||
from nucypher.crypto.ferveo.exceptions import FerveoKeyMismatch
|
||||
from nucypher.crypto.powers import (
|
||||
CryptoPower,
|
||||
RitualisticPower,
|
||||
|
@ -70,6 +71,7 @@ from nucypher.policy.payment import ContractPayment
|
|||
from nucypher.types import PhaseId
|
||||
from nucypher.utilities.emitters import StdoutEmitter
|
||||
from nucypher.utilities.logging import Logger
|
||||
from nucypher.utilities.warnings import render_ferveo_key_mismatch_warning
|
||||
|
||||
|
||||
class BaseActor:
|
||||
|
@ -570,6 +572,14 @@ class Operator(BaseActor):
|
|||
Errors raised by this method are not explicitly caught and are expected
|
||||
to be handled by the EventActuator.
|
||||
"""
|
||||
|
||||
try:
|
||||
self.check_ferveo_public_key_match()
|
||||
except FerveoKeyMismatch:
|
||||
# crash this node
|
||||
self.stop(halt_reactor=True)
|
||||
return
|
||||
|
||||
if self.checksum_address not in participants:
|
||||
message = (
|
||||
f"{self.checksum_address}|{self.wallet_address} "
|
||||
|
@ -997,13 +1007,31 @@ class Operator(BaseActor):
|
|||
f" for {self.staking_provider_address} on {taco_child_pretty_chain_name} with txhash {txhash})",
|
||||
color="green",
|
||||
)
|
||||
|
||||
else:
|
||||
# this node's ferveo public key is already published
|
||||
self.check_ferveo_public_key_match()
|
||||
emitter.message(
|
||||
f"✓ Provider's DKG participation public key already set for "
|
||||
f"{self.staking_provider_address} on {taco_child_pretty_chain_name} at Coordinator {coordinator_address}",
|
||||
f"{self.staking_provider_address} on Coordinator {coordinator_address}",
|
||||
color="green",
|
||||
)
|
||||
|
||||
def check_ferveo_public_key_match(self):
|
||||
latest_ritual_id = self.coordinator_agent.number_of_rituals()
|
||||
local_ferveo_key = self.ritual_power.public_key()
|
||||
onchain_ferveo_key = self.coordinator_agent.get_provider_public_key(
|
||||
ritual_id=latest_ritual_id, provider=self.staking_provider_address
|
||||
)
|
||||
|
||||
if bytes(local_ferveo_key) != bytes(onchain_ferveo_key):
|
||||
message = render_ferveo_key_mismatch_warning(
|
||||
local_key=local_ferveo_key,
|
||||
onchain_key=onchain_ferveo_key,
|
||||
)
|
||||
self.log.critical(message)
|
||||
raise FerveoKeyMismatch(message)
|
||||
|
||||
|
||||
class PolicyAuthor(NucypherTokenActor):
|
||||
"""Alice base class for blockchain operations, mocking up new policies!"""
|
||||
|
|
|
@ -994,7 +994,11 @@ class Ursula(Teacher, Character, Operator):
|
|||
if self._prometheus_metrics_tracker:
|
||||
self._prometheus_metrics_tracker.stop()
|
||||
if halt_reactor:
|
||||
reactor.stop()
|
||||
self.halt_reactor()
|
||||
|
||||
@staticmethod
|
||||
def halt_reactor() -> None:
|
||||
reactor.stop()
|
||||
|
||||
def _finalize(self):
|
||||
"""
|
||||
|
@ -1252,6 +1256,7 @@ class Ursula(Teacher, Character, Operator):
|
|||
known_nodes=known_nodes_info,
|
||||
balance_eth=balance_eth,
|
||||
block_height=self.ritual_tracker.scanner.get_last_scanned_block(),
|
||||
ferveo_public_key=bytes(self.public_keys(RitualisticPower)).hex(),
|
||||
)
|
||||
|
||||
def as_external_validator(self) -> Validator:
|
||||
|
@ -1289,6 +1294,7 @@ class LocalUrsulaStatus(NamedTuple):
|
|||
known_nodes: Optional[List[RemoteUrsulaStatus]]
|
||||
balance_eth: float
|
||||
block_height: int
|
||||
ferveo_public_key: str
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
if self.known_nodes is None:
|
||||
|
@ -1310,6 +1316,7 @@ class LocalUrsulaStatus(NamedTuple):
|
|||
known_nodes=known_nodes_json,
|
||||
balance_eth=self.balance_eth,
|
||||
block_height=self.block_height,
|
||||
ferveo_public_key=self.ferveo_public_key,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
class FerveoKeyMismatch(Exception):
|
||||
"""
|
||||
Raised when a local ferveo public key does not match the
|
||||
public key published to the Coordinator.
|
||||
"""
|
|
@ -0,0 +1,22 @@
|
|||
def render_ferveo_key_mismatch_warning(local_key, onchain_key):
|
||||
message = f"""
|
||||
|
||||
ERROR: The local Ferveo public key {bytes(local_key).hex()[:8]} does not match the on-chain public key {bytes(onchain_key).hex()[:8]}!
|
||||
|
||||
This is a critical error. Without the original private keys, your node cannot service existing DKGs.
|
||||
|
||||
IMPORTANT: Running `nucypher ursula init` will generate new private keys, which is not the correct procedure
|
||||
for relocating or restoring a TACo node.
|
||||
|
||||
To relocate your node to a new host copy the keystore directory (~/.local/share/nucypher) to the new host.
|
||||
If you do not have a backup of the original keystore or have lost your password, you will need to recover your
|
||||
node using the recovery phrase assigned during the initial setup by running:
|
||||
|
||||
nucypher ursula recover
|
||||
|
||||
If you have lost your recovery phrase: Open a support ticket in the Threshold Discord server (#taco).
|
||||
Disclose the loss immediately to minimize penalties. Your stake may be slashed, but the punishment will be significantly
|
||||
reduced if a key material handover is completed quickly, ensuring the node's service is not disrupted.
|
||||
|
||||
"""
|
||||
return message
|
|
@ -0,0 +1,135 @@
|
|||
import pytest
|
||||
import pytest_twisted
|
||||
from twisted.logger import globalLogPublisher
|
||||
|
||||
from nucypher.blockchain.eth.signers import InMemorySigner
|
||||
from nucypher.crypto.keypairs import RitualisticKeypair
|
||||
from nucypher.crypto.powers import RitualisticPower
|
||||
from nucypher.utilities.warnings import render_ferveo_key_mismatch_warning
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ritual_id():
|
||||
return 0
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def dkg_size():
|
||||
return 4
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def duration():
|
||||
return 48 * 60 * 60
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plaintext():
|
||||
return "peace at dawn"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def interval(testerchain):
|
||||
return testerchain.tx_machine._task.interval
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def signer():
|
||||
return InMemorySigner()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cohort(testerchain, clock, coordinator_agent, ursulas, dkg_size):
|
||||
nodes = list(sorted(ursulas[:dkg_size], key=lambda x: int(x.checksum_address, 16)))
|
||||
assert len(nodes) == dkg_size
|
||||
for node in nodes:
|
||||
node.ritual_tracker.task._task.clock = clock
|
||||
node.ritual_tracker.start()
|
||||
return nodes
|
||||
|
||||
|
||||
@pytest_twisted.inlineCallbacks
|
||||
def test_dkg_failure_with_ferveo_key_mismatch(
|
||||
coordinator_agent,
|
||||
ritual_id,
|
||||
cohort,
|
||||
clock,
|
||||
interval,
|
||||
testerchain,
|
||||
initiator,
|
||||
global_allow_list,
|
||||
duration,
|
||||
accounts,
|
||||
ritual_token,
|
||||
):
|
||||
|
||||
bad_ursula = cohort[0]
|
||||
old_public_key = bad_ursula.public_keys(RitualisticPower)
|
||||
new_keypair = RitualisticKeypair()
|
||||
new_public_key = new_keypair.pubkey
|
||||
|
||||
bad_ursula._crypto_power._CryptoPower__power_ups[RitualisticPower].keypair = (
|
||||
new_keypair
|
||||
)
|
||||
|
||||
assert bytes(old_public_key) != bytes(new_public_key)
|
||||
assert bytes(old_public_key) != bytes(bad_ursula.public_keys(RitualisticPower))
|
||||
assert bytes(new_public_key) == bytes(bad_ursula.public_keys(RitualisticPower))
|
||||
|
||||
onchain_public_key = coordinator_agent.get_provider_public_key(
|
||||
ritual_id=ritual_id, provider=bad_ursula.checksum_address
|
||||
)
|
||||
|
||||
assert bytes(onchain_public_key) == bytes(old_public_key)
|
||||
assert bytes(onchain_public_key) != bytes(new_public_key)
|
||||
assert bytes(onchain_public_key) != bytes(bad_ursula.public_keys(RitualisticPower))
|
||||
print(f"BAD URSULA: {bad_ursula.checksum_address}")
|
||||
|
||||
print("==================== INITIALIZING ====================")
|
||||
|
||||
cohort_staking_provider_addresses = list(u.checksum_address for u in cohort)
|
||||
|
||||
# Approve the ritual token for the coordinator agent to spend
|
||||
amount = coordinator_agent.get_ritual_initiation_cost(
|
||||
providers=cohort_staking_provider_addresses, duration=duration
|
||||
)
|
||||
ritual_token.approve(
|
||||
coordinator_agent.contract_address,
|
||||
amount,
|
||||
sender=accounts[initiator.transacting_power.account],
|
||||
)
|
||||
|
||||
receipt = coordinator_agent.initiate_ritual(
|
||||
providers=cohort_staking_provider_addresses,
|
||||
authority=initiator.transacting_power.account,
|
||||
duration=duration,
|
||||
access_controller=global_allow_list.address,
|
||||
transacting_power=initiator.transacting_power,
|
||||
)
|
||||
|
||||
testerchain.time_travel(seconds=1)
|
||||
testerchain.wait_for_receipt(receipt["transactionHash"])
|
||||
|
||||
log_messages = []
|
||||
|
||||
def log_trapper(event):
|
||||
log_messages.append(event["log_format"])
|
||||
|
||||
globalLogPublisher.addObserver(log_trapper)
|
||||
|
||||
print("==================== AWAITING DKG FAILURE ====================")
|
||||
while len(log_messages) == 0:
|
||||
|
||||
yield clock.advance(interval)
|
||||
yield testerchain.time_travel(seconds=1)
|
||||
|
||||
assert (
|
||||
render_ferveo_key_mismatch_warning(
|
||||
bytes(new_public_key), bytes(onchain_public_key)
|
||||
)
|
||||
in log_messages
|
||||
)
|
||||
|
||||
testerchain.tx_machine.stop()
|
||||
assert not testerchain.tx_machine.running
|
||||
globalLogPublisher.removeObserver(log_trapper)
|
|
@ -6,6 +6,7 @@ import pytest_twisted as pt
|
|||
from eth_account import Account
|
||||
from twisted.internet import threads
|
||||
|
||||
from nucypher.blockchain.eth.actors import Operator
|
||||
from nucypher.characters.base import Learner
|
||||
from nucypher.cli.literature import NO_CONFIGURATIONS_ON_DISK
|
||||
from nucypher.cli.main import nucypher_cli
|
||||
|
@ -35,6 +36,10 @@ def test_missing_configuration_file(_default_filepath_mock, click_runner):
|
|||
def test_run_lone_default_development_ursula(click_runner, mocker, ursulas, accounts):
|
||||
deploy_port = select_test_port()
|
||||
operator_address = ursulas[0].operator_address
|
||||
|
||||
# mock key mismatch detection
|
||||
mocker.patch.object(Operator, "check_ferveo_public_key_match", return_value=None)
|
||||
|
||||
args = (
|
||||
"ursula",
|
||||
"run", # Stat Ursula Command
|
||||
|
|
|
@ -836,3 +836,8 @@ def mock_async_hooks(mocker):
|
|||
)
|
||||
|
||||
return hooks
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def mock_halt_reactor(session_mocker):
|
||||
session_mocker.patch.object(Ursula, "halt_reactor")
|
||||
|
|
|
@ -6,6 +6,7 @@ from web3 import Web3
|
|||
from nucypher.blockchain.eth.actors import BaseActor, Operator
|
||||
from nucypher.blockchain.eth.clients import EthereumClient
|
||||
from nucypher.blockchain.eth.constants import NULL_ADDRESS
|
||||
from nucypher.crypto.powers import RitualisticPower
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
|
@ -117,6 +118,13 @@ def test_operator_block_until_ready_success(
|
|||
ursula.checksum_address,
|
||||
]
|
||||
|
||||
# mock key commitment
|
||||
mocker.patch.object(
|
||||
ursula.coordinator_agent,
|
||||
"get_provider_public_key",
|
||||
return_value=bytes(ursula.public_keys(RitualisticPower)),
|
||||
)
|
||||
|
||||
log_messages = []
|
||||
|
||||
def log_trapper(event):
|
||||
|
|
|
@ -13,6 +13,7 @@ from nucypher.blockchain.eth.agents import CoordinatorAgent
|
|||
from nucypher.blockchain.eth.models import Coordinator
|
||||
from nucypher.blockchain.eth.signers.software import InMemorySigner
|
||||
from nucypher.characters.lawful import Enrico, Ursula
|
||||
from nucypher.crypto.keypairs import RitualisticKeypair
|
||||
from nucypher.crypto.powers import RitualisticPower
|
||||
from nucypher.policy.conditions.lingo import ConditionLingo, ConditionType
|
||||
from tests.constants import TESTERCHAIN_CHAIN_ID
|
||||
|
@ -37,24 +38,19 @@ ROUND_1_EVENT_NAME = "StartRitual"
|
|||
ROUND_2_EVENT_NAME = "StartAggregationRound"
|
||||
|
||||
PARAMS = [ # dkg_size, ritual_id, variant
|
||||
(2, 0, FerveoVariant.Precomputed),
|
||||
(5, 1, FerveoVariant.Precomputed),
|
||||
(8, 2, FerveoVariant.Precomputed),
|
||||
(2, 3, FerveoVariant.Simple),
|
||||
(5, 4, FerveoVariant.Simple),
|
||||
(8, 5, FerveoVariant.Simple),
|
||||
(2, 0, FerveoVariant.Simple),
|
||||
(5, 1, FerveoVariant.Simple),
|
||||
(8, 2, FerveoVariant.Simple),
|
||||
# TODO: slow and need additional accounts for testing
|
||||
# (16, 6, FerveoVariant.Precomputed),
|
||||
# (16, 7, FerveoVariant.Simple),
|
||||
# (32, 8, FerveoVariant.Precomputed),
|
||||
# (32, 9, FerveoVariant.Simple),
|
||||
# (16, 3, FerveoVariant.Simple),
|
||||
# (32, 4, FerveoVariant.Simple),
|
||||
]
|
||||
|
||||
BLOCKS = list(reversed(range(1, 1000)))
|
||||
COORDINATOR = MockCoordinatorAgent(MockBlockchain())
|
||||
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
@pytest.fixture(scope="function")
|
||||
def mock_coordinator_agent(testerchain, mock_contract_agency):
|
||||
mock_contract_agency._MockContractAgency__agents[CoordinatorAgent] = COORDINATOR
|
||||
|
||||
|
@ -64,7 +60,7 @@ def mock_coordinator_agent(testerchain, mock_contract_agency):
|
|||
|
||||
@pytest.fixture(scope="function")
|
||||
def cohort(ursulas, mock_coordinator_agent):
|
||||
"""Creates a cohort of Ursulas"""
|
||||
|
||||
for u in ursulas:
|
||||
# set mapping in coordinator agent
|
||||
mock_coordinator_agent._add_operator_to_staking_provider_mapping(
|
||||
|
@ -73,6 +69,7 @@ def cohort(ursulas, mock_coordinator_agent):
|
|||
mock_coordinator_agent.set_provider_public_key(
|
||||
u.public_keys(RitualisticPower), u.transacting_power
|
||||
)
|
||||
|
||||
u.coordinator_agent = mock_coordinator_agent
|
||||
u.ritual_tracker.coordinator_agent = mock_coordinator_agent
|
||||
|
||||
|
@ -123,12 +120,9 @@ def execute_round_2(ritual_id: int, cohort: List[Ursula]):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dkg_size, ritual_id, variant", PARAMS)
|
||||
@pytest_twisted.inlineCallbacks()
|
||||
def test_ursula_ritualist(
|
||||
testerchain,
|
||||
def run_test(
|
||||
mock_coordinator_agent,
|
||||
cohort,
|
||||
bad_cohort,
|
||||
alice,
|
||||
bob,
|
||||
dkg_size,
|
||||
|
@ -137,14 +131,10 @@ def test_ursula_ritualist(
|
|||
get_random_checksum_address,
|
||||
):
|
||||
"""Tests the DKG and the encryption/decryption of a message"""
|
||||
cohort = cohort[:dkg_size]
|
||||
cohort = bad_cohort[:dkg_size]
|
||||
|
||||
# adjust threshold since we are testing with pre-computed (simple is the default)
|
||||
threshold = mock_coordinator_agent.get_threshold_for_ritual_size(
|
||||
dkg_size
|
||||
) # default is simple
|
||||
if variant == FerveoVariant.Precomputed:
|
||||
threshold = dkg_size
|
||||
threshold = mock_coordinator_agent.get_threshold_for_ritual_size(dkg_size)
|
||||
|
||||
with patch.object(
|
||||
mock_coordinator_agent, "get_threshold_for_ritual_size", return_value=threshold
|
||||
|
@ -152,7 +142,10 @@ def test_ursula_ritualist(
|
|||
|
||||
def initialize():
|
||||
"""Initiates the ritual"""
|
||||
print("==================== INITIALIZING ====================")
|
||||
print(
|
||||
f"==================== INITIALIZING {dkg_size} {variant} ===================="
|
||||
)
|
||||
|
||||
cohort_staking_provider_addresses = list(u.checksum_address for u in cohort)
|
||||
mock_coordinator_agent.initiate_ritual(
|
||||
providers=cohort_staking_provider_addresses,
|
||||
|
@ -161,6 +154,9 @@ def test_ursula_ritualist(
|
|||
access_controller=get_random_checksum_address(),
|
||||
transacting_power=alice.transacting_power,
|
||||
)
|
||||
print(
|
||||
f"cohort_staking_provider_addresses: {cohort_staking_provider_addresses}"
|
||||
)
|
||||
assert mock_coordinator_agent.number_of_rituals() == ritual_id + 1
|
||||
|
||||
def round_1(_):
|
||||
|
@ -356,3 +352,66 @@ def test_ursula_ritualist(
|
|||
d.addCallback(callback)
|
||||
d.addErrback(error_handler)
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dkg_size, ritual_id, variant", PARAMS)
|
||||
@pytest_twisted.inlineCallbacks()
|
||||
def test_ursula_ritualist_good_cohort(
|
||||
testerchain,
|
||||
mock_coordinator_agent,
|
||||
cohort,
|
||||
alice,
|
||||
bob,
|
||||
dkg_size,
|
||||
ritual_id,
|
||||
variant,
|
||||
get_random_checksum_address,
|
||||
):
|
||||
yield from run_test(
|
||||
mock_coordinator_agent,
|
||||
cohort,
|
||||
alice,
|
||||
bob,
|
||||
dkg_size,
|
||||
ritual_id,
|
||||
variant,
|
||||
get_random_checksum_address,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(reason="This is not fixed yet")
|
||||
@pytest_twisted.inlineCallbacks()
|
||||
def test_ursula_ritualist_bad_cohort(
|
||||
mock_coordinator_agent,
|
||||
cohort,
|
||||
alice,
|
||||
bob,
|
||||
get_random_checksum_address,
|
||||
):
|
||||
"""Modify the first Ursula's keystore to be different"""
|
||||
|
||||
bad_ursula = cohort[0]
|
||||
old_public_key = bad_ursula.public_keys(RitualisticPower)
|
||||
new_keypair = RitualisticKeypair()
|
||||
new_public_key = new_keypair.pubkey
|
||||
|
||||
# Modify the first Ursula's keystore to be different
|
||||
bad_ursula._crypto_power._CryptoPower__power_ups[RitualisticPower].keypair = (
|
||||
new_keypair
|
||||
)
|
||||
|
||||
assert old_public_key != new_public_key
|
||||
assert old_public_key != bad_ursula.public_keys(RitualisticPower)
|
||||
assert new_public_key == bad_ursula.public_keys(RitualisticPower)
|
||||
print(f"BAD URSULA: {bad_ursula.checksum_address}")
|
||||
|
||||
yield from run_test(
|
||||
mock_coordinator_agent,
|
||||
cohort,
|
||||
alice,
|
||||
bob,
|
||||
2,
|
||||
3,
|
||||
FerveoVariant.Precomputed,
|
||||
get_random_checksum_address,
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue