Merge pull request #3529 from manumonti/bad-cohort

Local/Onchain Ferveo Key Mismatch Mitigations
pull/3524/head
Derek Pierre 2024-07-29 15:42:33 -04:00 committed by GitHub
commit 4229fcdd41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 301 additions and 26 deletions

View File

@ -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.

View File

@ -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!"""

View File

@ -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,
)

View File

@ -0,0 +1,5 @@
class FerveoKeyMismatch(Exception):
"""
Raised when a local ferveo public key does not match the
public key published to the Coordinator.
"""

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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")

View File

@ -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):

View File

@ -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,
)