diff --git a/src/lean_spec/node/validator/registry.py b/src/lean_spec/node/validator/registry.py index d3302ef9a..c33364372 100644 --- a/src/lean_spec/node/validator/registry.py +++ b/src/lean_spec/node/validator/registry.py @@ -214,6 +214,17 @@ def from_yaml( ) continue + # The attestation and proposal keys must be distinct. + # A validator signs a proposal and an attestation in the same slot. + # The signature scheme is a stateful one-time signature. + # Sharing one key across both roles reuses one-time state and breaks it. + # Compare the public keys so the secret bytes stay untouched. + if manifest_entry.attestation_public_key_hex == manifest_entry.proposal_public_key_hex: + raise ValueError( + "Attestation and proposal keys must differ for validator " + f"{validator_index}, but the manifest assigns the same key to both" + ) + # Decode the attestation key from its SSZ file. attestation_key_path = manifest_directory / manifest_entry.attestation_private_key_file try: diff --git a/tests/cli/test_bootstrap.py b/tests/cli/test_bootstrap.py index 8fe52b511..610bafa9d 100644 --- a/tests/cli/test_bootstrap.py +++ b/tests/cli/test_bootstrap.py @@ -118,6 +118,7 @@ def validator_keys_directory(tmp_path: Path) -> Path: # Manifest carries one validator with placeholder public keys. # The loader does not verify the public_keys against the secret keys here. + # The two keys still differ, as the loader requires distinct signing roles. manifest = hash_signature_directory / "validator-keys-manifest.yaml" manifest.write_text( "key_scheme: SIGTopLevelTargetSumLifetime32Dim64Base8\n" @@ -130,7 +131,7 @@ def validator_keys_directory(tmp_path: Path) -> Path: "validators:\n" " - index: 0\n" f" attestation_public_key_hex: '0x{'00' * 52}'\n" - f" proposal_public_key_hex: '0x{'00' * 52}'\n" + f" proposal_public_key_hex: '0x{'00' * 51}ee'\n" " attestation_private_key_file: att_key_0.ssz\n" " proposal_private_key_file: prop_key_0.ssz\n" ) diff --git a/tests/node/validator/test_registry.py b/tests/node/validator/test_registry.py index e51f286de..5ac8e77b2 100644 --- a/tests/node/validator/test_registry.py +++ b/tests/node/validator/test_registry.py @@ -60,11 +60,14 @@ def _minimal_manifest_dict( def _manifest_entry_dict(index: int, suffix: str = "") -> dict[str, object]: - """Return a manifest entry dict for a validator at the given index.""" + """Return a manifest entry dict for a validator at the given index. + + The attestation and proposal public keys differ, as the loader requires. + """ return { "index": index, "attestation_public_key_hex": "0x" + f"{index:02d}" * 52, - "proposal_public_key_hex": "0x" + f"{index:02d}" * 52, + "proposal_public_key_hex": "0x" + f"{index:02d}" * 51 + "ee", "attestation_private_key_file": f"att_key_{index}{suffix}.ssz", "proposal_private_key_file": f"prop_key_{index}{suffix}.ssz", } @@ -168,14 +171,14 @@ def test_from_yaml_file_parses_validators_list(self, tmp_path: Path) -> None: ValidatorManifestEntry( index=ValidatorIndex(0), attestation_public_key_hex=Bytes52("0x" + "00" * 52), - proposal_public_key_hex=Bytes52("0x" + "00" * 52), + proposal_public_key_hex=Bytes52("0x" + "00" * 51 + "ee"), attestation_private_key_file="att_key_0.ssz", proposal_private_key_file="prop_key_0.ssz", ), ValidatorManifestEntry( index=ValidatorIndex(1), attestation_public_key_hex=Bytes52("0x" + "01" * 52), - proposal_public_key_hex=Bytes52("0x" + "01" * 52), + proposal_public_key_hex=Bytes52("0x" + "01" * 51 + "ee"), attestation_private_key_file="att_key_1.ssz", proposal_private_key_file="prop_key_1.ssz", ), @@ -506,6 +509,34 @@ def test_corrupt_proposal_key_file_raises(self, tmp_path: Path, km: XmssKeyManag "Failed to load proposal key for validator 0: PRFKey: expected 32 bytes, got 13" ) + def test_same_key_for_both_roles_raises(self, tmp_path: Path) -> None: + """A manifest assigning one key to both roles is rejected before any file is read.""" + validators_file = tmp_path / "validators.yaml" + validators_file.write_text(yaml.dump({"node_0": [0]})) + + same_key_entry = { + "index": 0, + "attestation_public_key_hex": "0x" + "cd" * 52, + "proposal_public_key_hex": "0x" + "cd" * 52, + "attestation_private_key_file": "att_key_0.ssz", + "proposal_private_key_file": "prop_key_0.ssz", + } + manifest_file = tmp_path / "manifest.yaml" + _write_manifest(manifest_file, [same_key_entry]) + + # No key files are written on purpose. + # The check must fire before any decode is attempted. + with pytest.raises(ValueError) as exception_info: + ValidatorRegistry.from_yaml( + node_id="node_0", + validators_path=validators_file, + manifest_path=manifest_file, + ) + assert str(exception_info.value) == ( + "Attestation and proposal keys must differ for validator 0, " + "but the manifest assigns the same key to both" + ) + def test_only_assigned_node_keys_are_loaded(self, tmp_path: Path, km: XmssKeyManager) -> None: """Keys for validators belonging to other nodes are never touched.""" validators_file = tmp_path / "validators.yaml"