From fdb957acad489a6756f2e30c75f55b9fad655700 Mon Sep 17 00:00:00 2001 From: Thomas Coratger Date: Thu, 22 Jan 2026 16:58:58 +0100 Subject: [PATCH] xmss: returning false instead of raising for verification --- src/lean_spec/subspecs/xmss/interface.py | 7 +- src/lean_spec/subspecs/xmss/subtree.py | 13 +-- .../lean_spec/subspecs/xmss/test_interface.py | 44 ++++++++++ .../subspecs/xmss/test_merkle_tree.py | 88 ++++++++++++++++++- 4 files changed, 143 insertions(+), 9 deletions(-) diff --git a/src/lean_spec/subspecs/xmss/interface.py b/src/lean_spec/subspecs/xmss/interface.py index 3955443b..5a45c744 100644 --- a/src/lean_spec/subspecs/xmss/interface.py +++ b/src/lean_spec/subspecs/xmss/interface.py @@ -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. # diff --git a/src/lean_spec/subspecs/xmss/subtree.py b/src/lean_spec/subspecs/xmss/subtree.py index b44a3aa4..c1f1f6a2 100644 --- a/src/lean_spec/subspecs/xmss/subtree.py +++ b/src/lean_spec/subspecs/xmss/subtree.py @@ -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( diff --git a/tests/lean_spec/subspecs/xmss/test_interface.py b/tests/lean_spec/subspecs/xmss/test_interface.py index cf801e4b..9c573690 100644 --- a/tests/lean_spec/subspecs/xmss/test_interface.py +++ b/tests/lean_spec/subspecs/xmss/test_interface.py @@ -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 diff --git a/tests/lean_spec/subspecs/xmss/test_merkle_tree.py b/tests/lean_spec/subspecs/xmss/test_merkle_tree.py index 343599a6..ae6094bc 100644 --- a/tests/lean_spec/subspecs/xmss/test_merkle_tree.py +++ b/tests/lean_spec/subspecs/xmss/test_merkle_tree.py @@ -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( @@ -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)