Skip to content

Commit 27a28c1

Browse files
tcoratgerclaude
andauthored
feat(xmss): validate XmssConfig fields at construction (#1123)
Add Field(gt=0) positivity constraints to every numeric XmssConfig field, matching the style used by PoseidonParams. Extend the existing model validator to reject an odd LOG_LIFETIME, since the key splits into a top tree and bottom trees that each cover LOG_LIFETIME / 2 levels. Previously a non-positive field or an odd lifetime exponent only failed deep in tree-building with a cryptic error. Now misconfiguration is caught at construction with a clear message. Defaults are unchanged and all three shipped configs still construct. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b15da08 commit 27a28c1

2 files changed

Lines changed: 51 additions & 15 deletions

File tree

src/lean_spec/spec/crypto/xmss/constants.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import math
44
from typing import Final, Self
55

6-
from pydantic import model_validator
6+
from pydantic import Field, model_validator
77

88
from lean_spec.base import StrictBaseModel
99
from lean_spec.config import LEAN_ENV
@@ -15,55 +15,59 @@
1515
class XmssConfig(StrictBaseModel):
1616
"""A model holding the configuration constants for an XMSS preset."""
1717

18-
LOG_LIFETIME: int
18+
LOG_LIFETIME: int = Field(gt=0)
1919
"""Base-2 logarithm of the scheme's maximum lifetime, the Merkle tree height."""
2020

21-
DIMENSION: int
21+
DIMENSION: int = Field(gt=0)
2222
"""Number of hash chains per signature, v.
2323
Security-derived: it sets how many codeword chunks a signature commits to."""
2424

25-
BASE: int
25+
BASE: int = Field(gt=0)
2626
"""Alphabet size for the digits of the encoded message, the Winternitz parameter."""
2727

28-
Z: int
28+
Z: int = Field(gt=0)
2929
"""Number of base-BASE digits extracted from each field element."""
3030

31-
Q: int
31+
Q: int = Field(gt=0)
3232
"""Quotient fixing the digit decomposition, constrained by Q * BASE^Z == P - 1."""
3333

34-
TARGET_SUM: int
34+
TARGET_SUM: int = Field(gt=0)
3535
"""Required sum of all codeword chunks for a signature to be valid.
3636
Security-derived: it tunes the forgery resistance of the encoding."""
3737

38-
MAX_TRIES: int
38+
MAX_TRIES: int = Field(gt=0)
3939
"""Maximum resampling attempts when searching for a codeword that meets the target sum.
4040
Performance knob: a higher cap trades signing time for fewer hard failures."""
4141

42-
PARAMETER_LENGTH: int
42+
PARAMETER_LENGTH: int = Field(gt=0)
4343
"""Length of the public parameter P, in field elements."""
4444

45-
TWEAK_LENGTH_FIELD_ELEMENTS: int
45+
TWEAK_LENGTH_FIELD_ELEMENTS: int = Field(gt=0)
4646
"""Length of a domain-separating tweak, in field elements."""
4747

48-
MESSAGE_LENGTH_FIELD_ELEMENTS: int
48+
MESSAGE_LENGTH_FIELD_ELEMENTS: int = Field(gt=0)
4949
"""Length of a message after being encoded into field elements."""
5050

51-
RAND_LENGTH_FIELD_ELEMENTS: int
51+
RAND_LENGTH_FIELD_ELEMENTS: int = Field(gt=0)
5252
"""Length of the randomness rho used during message encoding, in field elements."""
5353

54-
HASH_LENGTH_FIELD_ELEMENTS: int
54+
HASH_LENGTH_FIELD_ELEMENTS: int = Field(gt=0)
5555
"""Output length of the main tweakable hash function, in field elements.
5656
Security-derived: it sets the collision resistance of every digest."""
5757

58-
CAPACITY: int
58+
CAPACITY: int = Field(gt=0)
5959
"""Capacity of the Poseidon sponge, in field elements.
6060
Security-derived: the capacity sets the sponge's security level."""
6161

6262
@model_validator(mode="after")
6363
def _validate_decomposition(self) -> Self:
64-
"""Verify that Q * BASE^Z == P - 1."""
64+
"""Verify that Q * BASE^Z == P - 1 and that LOG_LIFETIME is even."""
6565
if self.Q * self.BASE**self.Z != P - 1:
6666
raise ValueError(f"Q * BASE^Z must equal P-1={P - 1}")
67+
# The key splits into a top tree and bottom trees.
68+
# Each covers LOG_LIFETIME / 2 levels, so the lifetime exponent must be even.
69+
if self.LOG_LIFETIME % 2 != 0:
70+
raise ValueError(f"LOG_LIFETIME must be even, got {self.LOG_LIFETIME}")
6771
return self
6872

6973
@property

tests/spec/crypto/xmss/test_constants.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,38 @@ def test_decomposition_validator_accepts_valid_product() -> None:
5252
assert config.Q * config.BASE**config.Z == P - 1
5353

5454

55+
def test_non_positive_field_is_rejected() -> None:
56+
"""A field constrained to be positive rejects a zero value at construction."""
57+
kwargs = _valid_config_kwargs()
58+
kwargs["DIMENSION"] = 0
59+
with pytest.raises(ValueError) as exception_info:
60+
XmssConfig(**kwargs)
61+
assert str(exception_info.value) == (
62+
"1 validation error for XmssConfig\n"
63+
"DIMENSION\n"
64+
" Input should be greater than 0 "
65+
"[type=greater_than, input_value=0, input_type=int]\n"
66+
" For further information visit "
67+
"https://errors.pydantic.dev/2.12/v/greater_than"
68+
)
69+
70+
71+
def test_odd_log_lifetime_is_rejected() -> None:
72+
"""An odd lifetime exponent cannot split into equal top and bottom trees."""
73+
kwargs = _valid_config_kwargs()
74+
kwargs["LOG_LIFETIME"] = 31
75+
with pytest.raises(ValueError) as exception_info:
76+
XmssConfig(**kwargs)
77+
assert str(exception_info.value) == (
78+
"1 validation error for XmssConfig\n"
79+
" Value error, LOG_LIFETIME must be even, got 31 "
80+
"[type=value_error, input_value={'LOG_LIFETIME': 31, 'DIM...ENTS': 8, 'CAPACITY': 9}, "
81+
"input_type=dict]\n"
82+
" For further information visit "
83+
"https://errors.pydantic.dev/2.12/v/value_error"
84+
)
85+
86+
5587
def test_target_config_is_test_config_under_test_env() -> None:
5688
"""The active configuration under the test environment is the test preset."""
5789
assert TARGET_CONFIG is TEST_CONFIG

0 commit comments

Comments
 (0)