Skip to content

Commit 0ada636

Browse files
tcoratgerclaude
andauthored
fix(testing): pin and verify the key archive, stamp the key-set digest (leanEthereum#857)
The key archives were fetched from a mutable release tag with no integrity check. A re-cut release silently changes every signature and every state root, since validator public keys are merkleized into the state. That risk is live: the current release carries a different test key set than the one committed to this repository. Changes: - the download verifies the archive against a pinned SHA-256 before extraction; the release tag stays mutable, so the checksum is the real pin and a re-cut release fails loudly - extraction lands in a scratch directory and the extracted key set is verified against a pinned digest before replacing the live keys, so a rejected archive never destroys a working key set - a key-set digest function hashes every validator's public keys in index order; the fill startup re-downloads when the local keys disagree with the pin, and the digest pins encode today's reality: the committed test keys and the released prod keys - every fixture's info block now carries the key-set digest, letting consumers detect vectors generated from a different key set Verified live: downloading the drifted release archive passes the checksum, fails the digest verification, and leaves the committed keys untouched. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 273335e commit 0ada636

4 files changed

Lines changed: 118 additions & 11 deletions

File tree

packages/testing/src/consensus_testing/keys.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
from __future__ import annotations
2020

21+
import hashlib
2122
import json
2223
from collections.abc import Iterator, Mapping
2324
from pathlib import Path
@@ -121,6 +122,29 @@ def get_keys_directory(scheme_name: str) -> Path:
121122
return Path(__file__).parent / "test_keys" / f"{scheme_name}_scheme"
122123

123124

125+
def compute_key_set_digest(keys_directory: Path) -> str:
126+
"""
127+
Compute the SHA-256 digest identifying a directory's key set.
128+
129+
Hashes every validator's public keys in ascending index order.
130+
Two fills agree on vectors only if they agree on this digest.
131+
132+
Args:
133+
keys_directory: Directory holding per-validator key files.
134+
135+
Returns:
136+
Hex digest prefixed with 0x.
137+
"""
138+
digest = hashlib.sha256()
139+
key_files = sorted(keys_directory.glob("*.json"), key=lambda key_file: int(key_file.stem))
140+
for key_file in key_files:
141+
key_data = json.loads(key_file.read_text())
142+
digest.update(key_file.stem.encode())
143+
digest.update(bytes.fromhex(key_data["attestation_keypair"]["public_key"]))
144+
digest.update(bytes.fromhex(key_data["proposal_keypair"]["public_key"]))
145+
return f"0x{digest.hexdigest()}"
146+
147+
124148
class XmssKeyManager:
125149
"""
126150
Stateful manager for XMSS signing in tests.
@@ -146,6 +170,7 @@ class XmssKeyManager:
146170
"_public_cache",
147171
"_available_indices",
148172
"_secret_state",
173+
"_key_set_digest",
149174
)
150175

151176
_cache: ClassVar[dict[str, XmssKeyManager]] = {}
@@ -224,6 +249,9 @@ def __init__(
224249
# Advanced secret-key state held as live Python objects.
225250
self._secret_state: dict[tuple[ValidatorIndex, KeyRole], SecretKey] = {}
226251

252+
# Computed lazily on first request.
253+
self._key_set_digest: str | None = None
254+
227255
def _scan_indices(self) -> set[ValidatorIndex]:
228256
"""
229257
Discover which validator indices have key files on disk.
@@ -328,6 +356,16 @@ def __iter__(self) -> Iterator[ValidatorIndex]:
328356
"""Iterate over validator indices in ascending order."""
329357
return iter(sorted(self._scan_indices()))
330358

359+
def key_set_digest(self) -> str:
360+
"""
361+
Return the digest identifying this manager's on-disk key set.
362+
363+
Computed once and cached for the manager's lifetime.
364+
"""
365+
if self._key_set_digest is None:
366+
self._key_set_digest = compute_key_set_digest(self._keys_directory)
367+
return self._key_set_digest
368+
331369
def get_public_keys(self, index: ValidatorIndex) -> tuple[PublicKey, PublicKey]:
332370
"""
333371
Return attestation and proposal public keys without touching secrets.

packages/testing/src/consensus_testing/keys_cli.py

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from __future__ import annotations
1717

1818
import argparse
19+
import hashlib
1920
import os
2021
import shutil
2122
import sys
@@ -27,7 +28,11 @@
2728
from functools import partial
2829
from pathlib import Path
2930

30-
from consensus_testing.keys import LEAN_ENV_TO_SCHEMES, get_keys_directory
31+
from consensus_testing.keys import (
32+
LEAN_ENV_TO_SCHEMES,
33+
compute_key_set_digest,
34+
get_keys_directory,
35+
)
3136
from lean_spec.spec.crypto.xmss.containers import ValidatorKeyPair
3237
from lean_spec.spec.crypto.xmss.interface import GeneralizedXmssScheme
3338
from lean_spec.spec.forks import Slot
@@ -44,6 +49,31 @@
4449
Each URL points to a tar.gz containing per-validator JSON files.
4550
"""
4651

52+
PINNED_KEY_ARCHIVE_SHA256 = {
53+
"test": "2d616857f4936cde4e2720fa95a76f2644015390f4b8c188acbaa756f521dac8",
54+
"prod": "a40aa60fc0c0d1b4c761f19fb1678039400ae318da85f782dd57bc8cc0eb617d",
55+
}
56+
"""
57+
SHA-256 of each scheme's key archive.
58+
59+
The release tag is mutable, so these checksums are the real pin.
60+
A re-cut release fails the download instead of silently changing vectors.
61+
Update together with the key-set digests when adopting a new release.
62+
"""
63+
64+
PINNED_KEY_SET_DIGESTS = {
65+
"test": "0x49306dfdb6dddd72afe265ec3b20a1901834dde9b3dfe4fee6b4f7ca58c7aa43",
66+
"prod": "0xc2b5fc4c1f1fbc181ddf07db3df985f79e1dcedcfb7df732ef20863d7cbcf491",
67+
}
68+
"""
69+
Expected key-set digest per scheme.
70+
71+
Guards the on-disk keys, which the archive checksum cannot see.
72+
73+
- test: the key set committed to this repository.
74+
- prod: the key set served by the current release.
75+
"""
76+
4777
NUM_VALIDATORS: int = 8
4878
"""
4979
Default number of validator key pairs.
@@ -176,16 +206,46 @@ def download_keys(scheme: str) -> None:
176206
with urllib.request.urlopen(url) as response, tmp_path.open("wb") as out:
177207
shutil.copyfileobj(response, out)
178208

179-
# Remove any existing keys for this scheme before extracting.
180-
target_directory = base_directory / f"{scheme}_scheme"
181-
if target_directory.exists():
182-
shutil.rmtree(target_directory)
183-
base_directory.mkdir(parents=True, exist_ok=True)
209+
# Verify the archive against the pinned checksum before extracting.
210+
# The release tag is mutable, so the checksum is the real pin.
211+
with tmp_path.open("rb") as archive_handle:
212+
archive_sha256 = hashlib.file_digest(archive_handle, "sha256").hexdigest()
213+
if archive_sha256 != PINNED_KEY_ARCHIVE_SHA256[scheme]:
214+
raise RuntimeError(
215+
f"downloaded {scheme} key archive does not match the pinned checksum\n"
216+
f" expected: {PINNED_KEY_ARCHIVE_SHA256[scheme]}\n"
217+
f" actual: {archive_sha256}\n"
218+
"The release was re-cut upstream. Adopting the new key set requires "
219+
"updating the pinned checksum and key-set digest on purpose."
220+
)
184221

185-
# Extract the archive into the base directory.
222+
# Extract into a scratch directory and verify there first.
223+
# A rejected archive must never destroy working keys.
186224
# The archive root is the scheme directory itself.
187-
with tarfile.open(tmp_path, "r:gz") as tar:
188-
tar.extractall(path=base_directory, filter="data")
225+
with tempfile.TemporaryDirectory() as scratch_name:
226+
scratch_directory = Path(scratch_name)
227+
with tarfile.open(tmp_path, "r:gz") as tar:
228+
tar.extractall(path=scratch_directory, filter="data")
229+
230+
# Verify the extracted key set against the pinned digest.
231+
# Catches content drift the checksum pin alone would miss.
232+
extracted_directory = scratch_directory / f"{scheme}_scheme"
233+
extracted_digest = compute_key_set_digest(extracted_directory)
234+
if extracted_digest != PINNED_KEY_SET_DIGESTS[scheme]:
235+
raise RuntimeError(
236+
f"extracted {scheme} key set does not match the pinned digest\n"
237+
f" expected: {PINNED_KEY_SET_DIGESTS[scheme]}\n"
238+
f" actual: {extracted_digest}\n"
239+
"The archive carries a different key set than the canonical one. "
240+
"For the test scheme, restore the committed keys from version control."
241+
)
242+
243+
# Replace the live key set only after both verifications pass.
244+
target_directory = base_directory / f"{scheme}_scheme"
245+
if target_directory.exists():
246+
shutil.rmtree(target_directory)
247+
base_directory.mkdir(parents=True, exist_ok=True)
248+
shutil.move(str(extracted_directory), str(target_directory))
189249

190250
print(f"Extracted {scheme} keys to {target_directory}/")
191251
finally:

packages/testing/src/consensus_testing/test_fixtures/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from framework.forks import BaseFork
99
from pydantic import Field
1010

11+
from consensus_testing.keys import XmssKeyManager
1112
from lean_spec.base import CamelModel
1213
from lean_spec.config import LEAN_ENV
1314
from lean_spec.spec.forks import RejectionReason
@@ -156,5 +157,9 @@ def fill_info(
156157
self.info["testId"] = test_id
157158
self.info["description"] = description
158159
self.info["fixtureFormat"] = self.format_name
160+
161+
# Why: consumers can detect vectors generated from a different key set.
162+
self.info["keySetDigest"] = XmssKeyManager.shared().key_set_digest()
163+
159164
# Set network field on the fixture itself
160165
self.network = fork.name()

packages/testing/src/framework/cli/fill.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,19 @@ def fill(
6464

6565
# Check and download keys if needed
6666
# Import here to avoid loading leanSpec modules before LEAN_ENV is set
67-
from consensus_testing.keys import get_keys_directory
68-
from consensus_testing.keys_cli import download_keys
67+
from consensus_testing.keys import compute_key_set_digest, get_keys_directory
68+
from consensus_testing.keys_cli import PINNED_KEY_SET_DIGESTS, download_keys
6969

7070
keys_directory = get_keys_directory(scheme.lower())
7171

7272
# Check if keys already exist, if not, download them
7373
if not (keys_directory.exists() and any(keys_directory.glob("*.json"))):
7474
click.echo(f"Test keys for '{scheme}' scheme not found. Downloading...")
7575
download_keys(scheme.lower())
76+
# Why: stale or modified local keys would silently change every vector.
77+
elif compute_key_set_digest(keys_directory) != PINNED_KEY_SET_DIGESTS[scheme.lower()]:
78+
click.echo(f"Local '{scheme}' keys do not match the pinned key set. Re-downloading...")
79+
download_keys(scheme.lower())
7680

7781
config_path = Path(__file__).parent / "pytest_ini_files" / "pytest-fill.ini"
7882
# Find project root by looking for pyproject.toml with [tool.uv.workspace]

0 commit comments

Comments
 (0)