Skip to content

Commit a084204

Browse files
authored
xmss: catchup with the latest updates (leanEthereum#152)
* xmss: catchup with the latest updates * more updates * more cleanup * fix linter * fix tests * fix tests * consensus testing: try a fix for the keys * test fix * revert * wip * some fix * fix test * touchups
1 parent 644605d commit a084204

10 files changed

Lines changed: 1478 additions & 96 deletions

File tree

packages/testing/src/consensus_testing/keys.py

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,28 @@
66
from lean_spec.subspecs.containers.slot import Slot
77
from lean_spec.subspecs.ssz.hash import hash_tree_root
88
from lean_spec.subspecs.xmss.containers import PublicKey, SecretKey
9-
from lean_spec.subspecs.xmss.interface import DEFAULT_SIGNATURE_SCHEME
9+
from lean_spec.subspecs.xmss.interface import (
10+
TEST_SIGNATURE_SCHEME as DEFAULT_SIGNATURE_SCHEME,
11+
)
1012
from lean_spec.types import ValidatorIndex
1113

1214

1315
class KeyPair(NamedTuple):
14-
"""A validators XMSS key pair."""
16+
"""A validator's XMSS key pair."""
1517

1618
public: PublicKey
17-
"""The validators public key (used for verification)."""
19+
"""The validator's public key (used for verification)."""
1820

1921
secret: SecretKey
20-
"""The validator’s secret key (used for signing)."""
22+
"""The validator's secret key (used for signing)."""
23+
24+
25+
_KEY_CACHE: dict[tuple[int, int], KeyPair] = {}
26+
"""
27+
Cache keys across tests to avoid regenerating them for the same validator/lifetime combo.
28+
29+
Key: (validator_index, num_active_epochs) -> KeyPair
30+
"""
2131

2232

2333
class XmssKeyManager:
@@ -76,6 +86,13 @@ def __getitem__(self, validator_index: ValidatorIndex) -> KeyPair:
7686
# - We include slot 0 (genesis) in the count
7787
num_active_epochs = self.max_slot.as_int() + 1
7888

89+
# Check global cache first (keys are reused across tests)
90+
cache_key = (int(validator_index), num_active_epochs)
91+
if cache_key in _KEY_CACHE:
92+
key_pair = _KEY_CACHE[cache_key]
93+
self._key_pairs[validator_index] = key_pair
94+
return key_pair
95+
7996
# Generate the key pair using the default XMSS scheme.
8097
#
8198
# The seed is set to 0 for deterministic test keys.
@@ -85,12 +102,13 @@ def __getitem__(self, validator_index: ValidatorIndex) -> KeyPair:
85102

86103
# Store as a cohesive unit and return.
87104
key_pair = KeyPair(public=pk, secret=sk)
105+
_KEY_CACHE[cache_key] = key_pair # Cache globally for reuse across tests
88106
self._key_pairs[validator_index] = key_pair
89107
return key_pair
90108

91109
def sign_attestation(self, attestation: Attestation) -> Signature:
92110
"""
93-
Sign an attestation with the validators XMSS key.
111+
Sign an attestation with the validator's XMSS key.
94112
95113
Parameters
96114
----------
@@ -113,22 +131,50 @@ def sign_attestation(self, attestation: Attestation) -> Signature:
113131

114132
# Lazy key retrieval: creates keys if first time seeing this validator.
115133
key_pair = self[validator_id]
116-
117-
# Compute the message digest from the attestation's SSZ tree root.
118-
#
119-
# This produces a cryptographic hash of the entire attestation structure.
120-
message = bytes(hash_tree_root(attestation))
134+
# Get the current secret key
135+
sk = key_pair.secret
121136

122137
# Map the attestation slot to an XMSS epoch.
123138
#
124139
# Each slot gets its own epoch to avoid key reuse.
125140
epoch = attestation.data.slot
126141

127-
# Generate the XMSS signature using the validator's secret key.
128-
xmss_sig = DEFAULT_SIGNATURE_SCHEME.sign(key_pair.secret, epoch, message)
142+
# Advance the key's prepared window until it covers the target epoch.
143+
#
144+
# We use the scheme that the key was generated with.
145+
scheme = DEFAULT_SIGNATURE_SCHEME
146+
147+
# Loop until the epoch is inside the prepared interval
148+
prepared_interval = scheme.get_prepared_interval(sk)
149+
while int(epoch) not in prepared_interval:
150+
# Check if we're advancing past the key's total lifetime
151+
activation_interval = scheme.get_activation_interval(sk)
152+
if prepared_interval.stop >= activation_interval.stop:
153+
raise ValueError(
154+
f"Cannot sign for epoch {epoch}: "
155+
f"it is beyond the key's max lifetime {activation_interval.stop}"
156+
)
157+
158+
# Advance the key and get the new key object
159+
sk = scheme.advance_preparation(sk)
160+
161+
# Update the prepared interval for the next loop check
162+
prepared_interval = scheme.get_prepared_interval(sk)
163+
164+
# Update the cached key pair with the new, advanced secret key.
165+
# This ensures the *next* call to sign() uses the advanced state.
166+
self._key_pairs[validator_id] = KeyPair(public=key_pair.public, secret=sk)
167+
168+
# Compute the message digest from the attestation's SSZ tree root.
169+
#
170+
# This produces a cryptographic hash of the entire attestation structure.
171+
message = bytes(hash_tree_root(attestation))
172+
173+
# Generate the XMSS signature using the validator's (now prepared) secret key.
174+
xmss_sig = scheme.sign(sk, epoch, message)
129175

130176
# Convert the signature to the wire format (byte array).
131-
signature_bytes = xmss_sig.to_bytes(DEFAULT_SIGNATURE_SCHEME.config)
177+
signature_bytes = xmss_sig.to_bytes(scheme.config)
132178

133179
# Ensure the signature meets the consensus spec length (3100 bytes).
134180
#

src/lean_spec/subspecs/xmss/constants.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,11 @@ def SIGNATURE_LEN_BYTES(self) -> int: # noqa: N802
127127
TEST_CONFIG: Final = XmssConfig(
128128
MESSAGE_LENGTH=32,
129129
LOG_LIFETIME=8,
130-
DIMENSION=16,
130+
DIMENSION=4,
131131
BASE=4,
132-
FINAL_LAYER=24,
133-
TARGET_SUM=24,
134-
MAX_TRIES=100_000,
132+
FINAL_LAYER=6,
133+
TARGET_SUM=6,
134+
MAX_TRIES=1_000,
135135
PARAMETER_LEN=5,
136136
TWEAK_LEN_FE=2,
137137
MSG_LEN_FE=9,

src/lean_spec/subspecs/xmss/containers.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
if TYPE_CHECKING:
1414
from .constants import XmssConfig
15+
from .subtree import HashSubTree
1516

1617
PRFKey = Annotated[bytes, Field(min_length=PRF_KEY_LENGTH, max_length=PRF_KEY_LENGTH)]
1718
"""
@@ -112,10 +113,11 @@ class HashTreeLayer(StrictBaseModel):
112113

113114
class HashTree(StrictBaseModel):
114115
"""
115-
The pre-computed, stored portion of the sparse Merkle tree.
116+
A simple representation of a sparse Merkle tree.
116117
117-
This structure is part of the `SecretKey` and contains all the necessary nodes
118-
to generate an authentication path for any signature within the key's active lifetime.
118+
This structure contains the necessary nodes to generate an authentication path
119+
for any signature within a key's active lifetime. For production use with
120+
long lifetimes, prefer `HashSubTree` with the top-bottom tree approach.
119121
"""
120122

121123
depth: int
@@ -370,11 +372,61 @@ class SecretKey(StrictBaseModel):
370372

371373
prf_key: PRFKey
372374
"""The master secret key used to derive all one-time secrets."""
373-
tree: HashTree
374-
"""The pre-computed sparse Merkle tree needed to generate authentication paths."""
375+
375376
parameter: Parameter
376377
"""The public parameter `P`, stored for convenience during signing."""
378+
377379
activation_epoch: int
378-
"""The first epoch for which this secret key is valid."""
380+
"""
381+
The first epoch for which this secret key is valid.
382+
383+
Note: With top-bottom trees, this is aligned to a multiple of `sqrt(LIFETIME)`
384+
to ensure efficient tree partitioning.
385+
"""
386+
379387
num_active_epochs: int
380-
"""The number of consecutive epochs this key can be used for."""
388+
"""
389+
The number of consecutive epochs this key can be used for.
390+
391+
Note: With top-bottom trees, this is rounded up to be a multiple of
392+
`sqrt(LIFETIME)`, with a minimum of `2 * sqrt(LIFETIME)`.
393+
"""
394+
395+
top_tree: HashSubTree | None = None
396+
"""
397+
The top tree containing the root and top `LOG_LIFETIME/2` layers.
398+
399+
This tree is always kept in memory and contains the roots of all bottom trees
400+
in its lowest layer. Its root is the public key's Merkle root.
401+
"""
402+
403+
left_bottom_tree_index: int | None = None
404+
"""
405+
The index of the left bottom tree in the sliding window.
406+
407+
Bottom trees are numbered 0, 1, 2, ... where tree `i` covers epochs
408+
`[i * sqrt(LIFETIME), (i+1) * sqrt(LIFETIME))`.
409+
410+
The prepared interval is:
411+
[left_bottom_tree_index * sqrt(LIFETIME), (left_bottom_tree_index + 2) * sqrt(LIFETIME))
412+
413+
"""
414+
415+
left_bottom_tree: HashSubTree | None = None
416+
"""
417+
The left bottom tree in the sliding window.
418+
419+
This covers epochs:
420+
[left_bottom_tree_index * sqrt(LIFETIME), (left_bottom_tree_index + 1) * sqrt(LIFETIME))
421+
"""
422+
423+
right_bottom_tree: HashSubTree | None = None
424+
"""
425+
The right bottom tree in the sliding window.
426+
427+
This covers epochs:
428+
[(left_bottom_tree_index + 1) * sqrt(LIFETIME), (left_bottom_tree_index + 2) * sqrt(LIFETIME))
429+
430+
Together with `left_bottom_tree`, this provides a prepared interval of
431+
exactly `2 * sqrt(LIFETIME)` consecutive epochs.
432+
"""

0 commit comments

Comments
 (0)