From 4e1112e4f93af2509f1ee1f0df996993b5f683e7 Mon Sep 17 00:00:00 2001 From: adust09 Date: Tue, 7 Jul 2026 00:32:52 +0900 Subject: [PATCH 1/2] fix(validator): reject a manifest that reuses one key for both signing roles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidatorEntry documents that the attestation and proposal keys must be separate so a validator can sign both a proposal and an attestation in the same slot without one-time-signature state reuse. Nothing enforced it: the registry loader assigned keys without comparing them, so a manifest carrying the same key in both fields loaded silently. Both signatures then advanced their own copy of the shared key from the pre-advance state, consuming overlapping XMSS one-time state — a key-compromise-class failure that stayed invisible because both loads and both signatures verify individually. Reject the misconfiguration at load time by comparing the manifest's two public keys, which leaves the secret bytes untouched. This turns the docstring invariant into a client-checkable rule and discharges the formal model's distinctness assumption at construction. Closes #1184 --- src/lean_spec/node/validator/registry.py | 11 +++++++ tests/node/validator/test_registry.py | 39 +++++++++++++++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) 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/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" From 4c9d640de090b2a1caf0782ff965b321450389b1 Mon Sep 17 00:00:00 2001 From: adust09 Date: Tue, 7 Jul 2026 00:43:03 +0900 Subject: [PATCH 2/2] test(bootstrap): give the fixture manifest distinct signing keys The one-validator fixture manifest reused one placeholder public key for both roles. The registry loader now rejects that, so make the two keys differ, as a real manifest does. --- tests/cli/test_bootstrap.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" )