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
7 changes: 5 additions & 2 deletions src/lean_spec/subspecs/xmss/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,9 +405,12 @@ def verify(self, pk: PublicKey, epoch: Uint64, message: bytes, sig: Signature) -
# Retrieve the scheme's configuration parameters.
config = self.config

# A signature for an epoch beyond the scheme's lifetime is invalid.
# Validate epoch bounds.
#
# Return False instead of raising to avoid panic on invalid signatures.
# The epoch is attacker-controlled input.
if epoch > self.config.LIFETIME:
raise ValueError("The signature is for a future epoch.")
return False

# Re-encode the message using the randomness `rho` from the signature.
#
Expand Down
13 changes: 7 additions & 6 deletions src/lean_spec/subspecs/xmss/subtree.py
Original file line number Diff line number Diff line change
Expand Up @@ -587,16 +587,17 @@ def verify_path(

Returns:
`True` if the path is valid and reconstructs the root, `False` otherwise.

Raises:
ValueError: If the tree depth exceeds 32 or position doesn't match path length.
Returns `False` for invalid inputs (depth > 32 or position out of bounds).
"""
# Compute the depth
# Validate depth and position bounds.
#
# These checks guard against malformed attacker-controlled input.
# Return False instead of raising to avoid panic on invalid signatures.
depth = len(opening.siblings)
if depth > 32:
raise ValueError("Depth exceeds maximum of 32.")
return False
if int(position) >= (1 << depth):
raise ValueError("Position exceeds tree capacity.")
return False

# Start: hash leaf parts to get leaf node.
current = hasher.apply(
Expand Down
44 changes: 44 additions & 0 deletions tests/lean_spec/subspecs/xmss/test_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,47 @@ def test_deterministic_signing() -> None:
assert sig1.rho == sig2.rho
assert sig1.hashes == sig2.hashes
assert sig1.path.siblings == sig2.path.siblings


class TestVerifySecurityBounds:
"""
Security tests for verify method input validation.

Verification functions must return False (not raise) on attacker-controlled invalid input.
This prevents denial-of-service via malformed signatures.
"""

def test_rejects_epoch_beyond_lifetime(self) -> None:
"""verify returns False when epoch exceeds scheme LIFETIME."""
scheme = TEST_SIGNATURE_SCHEME

# Generate valid keys.
pk, sk = scheme.key_gen(Uint64(0), Uint64(scheme.config.LIFETIME))

# Sign a valid message at a valid epoch.
valid_epoch = Uint64(4)
message = b"\x42" * scheme.config.MESSAGE_LENGTH
signature = scheme.sign(sk, valid_epoch, message)

# Verify with an epoch beyond LIFETIME.
invalid_epoch = Uint64(int(scheme.config.LIFETIME) + 1)

# Must return False, not raise.
result = scheme.verify(pk, invalid_epoch, message, signature)
assert result is False

def test_rejects_very_large_epoch(self) -> None:
"""verify returns False for absurdly large epoch values."""
scheme = TEST_SIGNATURE_SCHEME
pk, sk = scheme.key_gen(Uint64(0), Uint64(scheme.config.LIFETIME))

valid_epoch = Uint64(4)
message = b"\x42" * scheme.config.MESSAGE_LENGTH
signature = scheme.sign(sk, valid_epoch, message)

# Try to verify with a huge epoch.
huge_epoch = Uint64(2**32)

# Must return False, not raise.
result = scheme.verify(pk, huge_epoch, message, signature)
assert result is False
88 changes: 87 additions & 1 deletion tests/lean_spec/subspecs/xmss/test_merkle_tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
TreeTweak,
TweakHasher,
)
from lean_spec.subspecs.xmss.types import HashDigestVector
from lean_spec.subspecs.xmss.types import HashDigestList, HashDigestVector, HashTreeOpening
from lean_spec.types import Uint64
from lean_spec.types.exceptions import SSZValueError


def _run_commit_open_verify_roundtrip(
Expand Down Expand Up @@ -108,3 +109,88 @@ def test_commit_open_verify_roundtrip(
_run_commit_open_verify_roundtrip(
PROD_TWEAK_HASHER, PROD_RAND, num_leaves, depth, start_index, leaf_parts_len
)


class TestVerifyPathSecurityBounds:
"""
Security tests for verify_path input validation.

Verification functions must return False (not raise) on attacker-controlled invalid input.
This prevents denial-of-service via malformed signatures.
"""

def test_ssz_validation_rejects_excessive_depth(self) -> None:
"""
SSZ type system rejects openings with depth > 32.

HashDigestList has a LIMIT of 32, so the type system prevents
creating malformed openings at the SSZ level. The check in
verify_path is defense-in-depth for deserialized data.
"""
rand = PROD_RAND

# Attempting to create a list with 33 siblings raises at the type level.
excessive_siblings = [rand.domain() for _ in range(33)]
with pytest.raises(SSZValueError):
HashDigestList(data=excessive_siblings)

def test_rejects_position_exceeding_tree_capacity(self) -> None:
"""verify_path returns False when position >= 2^depth."""
rand = PROD_RAND
hasher = PROD_TWEAK_HASHER
parameter = rand.parameter()

root = rand.domain()
leaf_parts = [rand.domain()]

# Create an opening with depth=4 (supports positions 0-15).
siblings = [rand.domain() for _ in range(4)]
opening = HashTreeOpening(siblings=HashDigestList(data=siblings))

# Position 16 is out of bounds for depth 4 (capacity = 2^4 = 16).
result = verify_path(
hasher=hasher,
parameter=parameter,
root=root,
position=Uint64(16),
leaf_parts=leaf_parts,
opening=opening,
)
assert result is False

# Position 100 is also out of bounds.
result = verify_path(
hasher=hasher,
parameter=parameter,
root=root,
position=Uint64(100),
leaf_parts=leaf_parts,
opening=opening,
)
assert result is False

def test_valid_position_at_boundary(self) -> None:
"""verify_path accepts position at maximum valid value (2^depth - 1)."""
rand = PROD_RAND
hasher = PROD_TWEAK_HASHER
parameter = rand.parameter()

root = rand.domain()
leaf_parts = [rand.domain()]

# Create an opening with depth=4.
siblings = [rand.domain() for _ in range(4)]
opening = HashTreeOpening(siblings=HashDigestList(data=siblings))

# Position 15 is the maximum valid position for depth 4.
# This should not return False due to bounds check (may still fail root check).
result = verify_path(
hasher=hasher,
parameter=parameter,
root=root,
position=Uint64(15),
leaf_parts=leaf_parts,
opening=opening,
)
# Result may be False due to wrong root, but importantly it didn't raise.
assert isinstance(result, bool)
Loading