Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/lean_spec/node/validator/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion tests/cli/test_bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
)
Expand Down
39 changes: 35 additions & 4 deletions tests/node/validator/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Expand Down Expand Up @@ -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",
),
Expand Down Expand Up @@ -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"
Expand Down
Loading