Skip to content

Commit ae104fa

Browse files
authored
fix(validator): reject a manifest that reuses one key for both signing roles (#1185)
* fix(validator): reject a manifest that reuses one key for both signing roles 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 * 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.
1 parent 57d4339 commit ae104fa

3 files changed

Lines changed: 48 additions & 5 deletions

File tree

src/lean_spec/node/validator/registry.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,17 @@ def from_yaml(
214214
)
215215
continue
216216

217+
# The attestation and proposal keys must be distinct.
218+
# A validator signs a proposal and an attestation in the same slot.
219+
# The signature scheme is a stateful one-time signature.
220+
# Sharing one key across both roles reuses one-time state and breaks it.
221+
# Compare the public keys so the secret bytes stay untouched.
222+
if manifest_entry.attestation_public_key_hex == manifest_entry.proposal_public_key_hex:
223+
raise ValueError(
224+
"Attestation and proposal keys must differ for validator "
225+
f"{validator_index}, but the manifest assigns the same key to both"
226+
)
227+
217228
# Decode the attestation key from its SSZ file.
218229
attestation_key_path = manifest_directory / manifest_entry.attestation_private_key_file
219230
try:

tests/cli/test_bootstrap.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ def validator_keys_directory(tmp_path: Path) -> Path:
118118

119119
# Manifest carries one validator with placeholder public keys.
120120
# The loader does not verify the public_keys against the secret keys here.
121+
# The two keys still differ, as the loader requires distinct signing roles.
121122
manifest = hash_signature_directory / "validator-keys-manifest.yaml"
122123
manifest.write_text(
123124
"key_scheme: SIGTopLevelTargetSumLifetime32Dim64Base8\n"
@@ -130,7 +131,7 @@ def validator_keys_directory(tmp_path: Path) -> Path:
130131
"validators:\n"
131132
" - index: 0\n"
132133
f" attestation_public_key_hex: '0x{'00' * 52}'\n"
133-
f" proposal_public_key_hex: '0x{'00' * 52}'\n"
134+
f" proposal_public_key_hex: '0x{'00' * 51}ee'\n"
134135
" attestation_private_key_file: att_key_0.ssz\n"
135136
" proposal_private_key_file: prop_key_0.ssz\n"
136137
)

tests/node/validator/test_registry.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,14 @@ def _minimal_manifest_dict(
6060

6161

6262
def _manifest_entry_dict(index: int, suffix: str = "") -> dict[str, object]:
63-
"""Return a manifest entry dict for a validator at the given index."""
63+
"""Return a manifest entry dict for a validator at the given index.
64+
65+
The attestation and proposal public keys differ, as the loader requires.
66+
"""
6467
return {
6568
"index": index,
6669
"attestation_public_key_hex": "0x" + f"{index:02d}" * 52,
67-
"proposal_public_key_hex": "0x" + f"{index:02d}" * 52,
70+
"proposal_public_key_hex": "0x" + f"{index:02d}" * 51 + "ee",
6871
"attestation_private_key_file": f"att_key_{index}{suffix}.ssz",
6972
"proposal_private_key_file": f"prop_key_{index}{suffix}.ssz",
7073
}
@@ -168,14 +171,14 @@ def test_from_yaml_file_parses_validators_list(self, tmp_path: Path) -> None:
168171
ValidatorManifestEntry(
169172
index=ValidatorIndex(0),
170173
attestation_public_key_hex=Bytes52("0x" + "00" * 52),
171-
proposal_public_key_hex=Bytes52("0x" + "00" * 52),
174+
proposal_public_key_hex=Bytes52("0x" + "00" * 51 + "ee"),
172175
attestation_private_key_file="att_key_0.ssz",
173176
proposal_private_key_file="prop_key_0.ssz",
174177
),
175178
ValidatorManifestEntry(
176179
index=ValidatorIndex(1),
177180
attestation_public_key_hex=Bytes52("0x" + "01" * 52),
178-
proposal_public_key_hex=Bytes52("0x" + "01" * 52),
181+
proposal_public_key_hex=Bytes52("0x" + "01" * 51 + "ee"),
179182
attestation_private_key_file="att_key_1.ssz",
180183
proposal_private_key_file="prop_key_1.ssz",
181184
),
@@ -506,6 +509,34 @@ def test_corrupt_proposal_key_file_raises(self, tmp_path: Path, km: XmssKeyManag
506509
"Failed to load proposal key for validator 0: PRFKey: expected 32 bytes, got 13"
507510
)
508511

512+
def test_same_key_for_both_roles_raises(self, tmp_path: Path) -> None:
513+
"""A manifest assigning one key to both roles is rejected before any file is read."""
514+
validators_file = tmp_path / "validators.yaml"
515+
validators_file.write_text(yaml.dump({"node_0": [0]}))
516+
517+
same_key_entry = {
518+
"index": 0,
519+
"attestation_public_key_hex": "0x" + "cd" * 52,
520+
"proposal_public_key_hex": "0x" + "cd" * 52,
521+
"attestation_private_key_file": "att_key_0.ssz",
522+
"proposal_private_key_file": "prop_key_0.ssz",
523+
}
524+
manifest_file = tmp_path / "manifest.yaml"
525+
_write_manifest(manifest_file, [same_key_entry])
526+
527+
# No key files are written on purpose.
528+
# The check must fire before any decode is attempted.
529+
with pytest.raises(ValueError) as exception_info:
530+
ValidatorRegistry.from_yaml(
531+
node_id="node_0",
532+
validators_path=validators_file,
533+
manifest_path=manifest_file,
534+
)
535+
assert str(exception_info.value) == (
536+
"Attestation and proposal keys must differ for validator 0, "
537+
"but the manifest assigns the same key to both"
538+
)
539+
509540
def test_only_assigned_node_keys_are_loaded(self, tmp_path: Path, km: XmssKeyManager) -> None:
510541
"""Keys for validators belonging to other nodes are never touched."""
511542
validators_file = tmp_path / "validators.yaml"

0 commit comments

Comments
 (0)