From c3dff24a10e95651c87d41661cf7abde9a88d5cb Mon Sep 17 00:00:00 2001 From: unnawut Date: Tue, 26 May 2026 17:57:43 +0700 Subject: [PATCH 1/4] test(consensus): add VerifyProofsTest fixture and Type-1 valid vectors Introduces a new consensus fixture format that emits self-contained multi-signature verification vectors so cross-client implementations can run their own Type-1 verifier and compare outcomes. Three positive vectors land alongside the fixture: a single-validator baseline, a four-validator all-participating case, and a four-validator non-contiguous bitfield case ([1, 0, 1, 1]). The fixture also surfaces the spec-layer binding between attestation data and proof: clients recompute hash_tree_root(attestation_data) and must match the emitted message field before running the verifier. --- .../testing/src/consensus_testing/__init__.py | 4 + .../test_fixtures/__init__.py | 2 + .../test_fixtures/verify_proofs.py | 290 ++++++++++++++++++ .../consensus/lstar/verify_proofs/__init__.py | 0 .../lstar/verify_proofs/test_type_1_valid.py | 71 +++++ 5 files changed, 367 insertions(+) create mode 100644 packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py create mode 100644 tests/consensus/lstar/verify_proofs/__init__.py create mode 100644 tests/consensus/lstar/verify_proofs/test_type_1_valid.py diff --git a/packages/testing/src/consensus_testing/__init__.py b/packages/testing/src/consensus_testing/__init__.py index 0108c98ab..95e8bae8a 100644 --- a/packages/testing/src/consensus_testing/__init__.py +++ b/packages/testing/src/consensus_testing/__init__.py @@ -16,6 +16,7 @@ SSZTest, StateTransitionTest, SyncTest, + VerifyProofsTest, VerifySignaturesTest, ) from .test_types import ( @@ -37,6 +38,7 @@ StateTransitionTestFiller = Type[StateTransitionTest] ForkChoiceTestFiller = Type[ForkChoiceTest] +VerifyProofsTestFiller = Type[VerifyProofsTest] VerifySignaturesTestFiller = Type[VerifySignaturesTest] SSZTestFiller = Type[SSZTest] NetworkingCodecTestFiller = Type[NetworkingCodecTest] @@ -61,6 +63,7 @@ "BaseConsensusFixture", "StateTransitionTest", "ForkChoiceTest", + "VerifyProofsTest", "VerifySignaturesTest", "SSZTest", "NetworkingCodecTest", @@ -84,6 +87,7 @@ # Type aliases for test function signatures "StateTransitionTestFiller", "ForkChoiceTestFiller", + "VerifyProofsTestFiller", "VerifySignaturesTestFiller", "SSZTestFiller", "NetworkingCodecTestFiller", diff --git a/packages/testing/src/consensus_testing/test_fixtures/__init__.py b/packages/testing/src/consensus_testing/test_fixtures/__init__.py index 452617fcb..1053546a5 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/__init__.py +++ b/packages/testing/src/consensus_testing/test_fixtures/__init__.py @@ -11,12 +11,14 @@ from .ssz import SSZTest from .state_transition import StateTransitionTest from .sync import SyncTest +from .verify_proofs import VerifyProofsTest from .verify_signatures import VerifySignaturesTest __all__ = [ "BaseConsensusFixture", "StateTransitionTest", "ForkChoiceTest", + "VerifyProofsTest", "VerifySignaturesTest", "SSZTest", "NetworkingCodecTest", diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py new file mode 100644 index 000000000..9b17f246a --- /dev/null +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py @@ -0,0 +1,290 @@ +"""Multi-signature proof verification fixture format.""" + +from __future__ import annotations + +from typing import Any, ClassVar, Literal + +from pydantic import Field + +from lean_spec.forks.lstar.containers import AttestationData +from lean_spec.subspecs.ssz.hash import hash_tree_root +from lean_spec.subspecs.xmss.aggregation import ( + AggregationError, + TypeOneMultiSignature, +) +from lean_spec.subspecs.xmss.containers import PublicKey +from lean_spec.types import ( + AggregationBits, + Boolean, + ByteList512KiB, + Bytes32, + Checkpoint, + Slot, + ValidatorIndex, + ValidatorIndices, +) + +from ..keys import XmssKeyManager +from .base import BaseConsensusFixture + + +class VerifyProofsTest(BaseConsensusFixture): + """Fixture for primitive multi-signature proof verification. + + Generates a Type-1 proof for the configured validators signing the + attestation data and emits the public inputs alongside the proof + bytes. + + To consume a vector, clients parse the SSZ containers, confirm + that the recomputed attestation root matches the message field, + run their Type-1 verifier against the emitted public keys, + message, slot, and proof, and check that the outcome matches + expect_valid. + """ + + format_name: ClassVar[str] = "verify_proofs_test" + description: ClassVar[str] = ( + "Tests multi-signature proof verification against precomputed proof bytes." + ) + + proof_type: Literal["type_1"] = "type_1" + """Proof shape under test. + + Type-1 covers many validators signing one message. + """ + + validator_ids: list[ValidatorIndex] = Field(exclude=True) + """Validators contributing raw signatures to the aggregate. + + Used only during generation. + The resolved public keys, bitfield, and proof bytes are emitted + instead so clients consume a self-contained vector. + """ + + attestation_data: AttestationData + """The signed object. + + Clients re-derive its hash tree root and must match the message + field below. + """ + + expect_valid: bool = True + """Whether clients must accept the emitted proof. + + A vector with expect_valid set to false carries an intentionally + bad proof or input combination that every conformant client must + reject. + """ + + tamper: dict[str, Any] | None = Field(default=None, exclude=True) + """Optional post-generation mutation that produces a rejection vector. + + Each operation rewrites part of how the spec binds inputs to the + proof. + + Supported operations: + + - ``{"operation": "rebind_with_alt_head_root"}`` + Generate the proof bound to a different head root inside the + attestation data while emitting the honest attestation data, + message, slot, pubkeys, and bits. + The proof binding then disagrees with the emitted message. + + - ``{"operation": "shift_emitted_slot"}`` + Increment the emitted slot field by one while leaving the proof + bound to the original slot. + A client verifying against the emitted slot must reject. + + - ``{"operation": "swap_public_key", "index": int,`` + ``"with_validator_id": int}`` + Replace the emitted public key at the given index with another + validator's attestation public key. + The proof was generated honestly, so the emitted set no longer + matches the keys the proof was bound to. + + - ``{"operation": "shrink_aggregation_bits", "length": int}`` + Truncate the emitted aggregation bits to the given length while + leaving the public key count untouched. + The verifier's pubkey-count check rejects on the mismatch. + """ + + # Output fields below are populated by make_fixture and complete + # the client-visible portion of the JSON vector. + + public_keys: list[PublicKey] = [] + """Attestation public keys for the participating validators. + + Ordered by validator_ids order, matching the aggregation_bits mask. + """ + + aggregation_bits: AggregationBits | None = None + """Participation bitfield derived from validator_ids.""" + + message: Bytes32 | None = None + """Hash tree root of attestation_data, bound into the proof.""" + + slot: Slot | None = None + """Slot bound into the proof.""" + + proof: ByteList512KiB | None = None + """Aggregated proof bytes for clients to verify.""" + + def make_fixture(self) -> VerifyProofsTest: + """Generate a Type-1 proof, optionally tamper, and self-verify. + + Returns: + A copy with the computed output fields populated. + + Raises: + AssertionError: If self-verification of the emitted bundle + disagrees with expect_valid. + ValueError: If the tamper operation is unknown or + misconfigured. + """ + key_manager = XmssKeyManager.shared() + + emitted = self._generate(key_manager, self.attestation_data, self.validator_ids) + if self.tamper is not None: + emitted = self._apply_tamper(key_manager, emitted) + + verified = self._verify_emitted(emitted) + if verified != self.expect_valid: + raise AssertionError( + f"Self-verification mismatch: verified={verified}, " + f"expect_valid={self.expect_valid}, " + f"tamper={self.tamper!r}" + ) + + return self.model_copy( + update={ + "attestation_data": emitted["attestation_data"], + "public_keys": emitted["public_keys"], + "aggregation_bits": emitted["aggregation_bits"], + "message": emitted["message"], + "slot": emitted["slot"], + "proof": emitted["proof_bytes"], + } + ) + + def _generate( + self, + key_manager: XmssKeyManager, + attestation_data: AttestationData, + validator_ids: list[ValidatorIndex], + ) -> dict[str, Any]: + """Generate an honest Type-1 proof bundle for the given inputs.""" + message = hash_tree_root(attestation_data) + slot = attestation_data.slot + public_keys = [key_manager.get_public_keys(vid)[0] for vid in validator_ids] + bits = ValidatorIndices(data=validator_ids).to_aggregation_bits() + signatures = [ + key_manager.sign_attestation_data(vid, attestation_data) for vid in validator_ids + ] + proof_obj = TypeOneMultiSignature.aggregate( + children=[], + raw_xmss=list(zip(validator_ids, public_keys, signatures, strict=True)), + message=message, + slot=slot, + ) + return { + "attestation_data": attestation_data, + "message": message, + "slot": slot, + "public_keys": public_keys, + "aggregation_bits": bits, + "proof_bytes": proof_obj.proof, + } + + def _apply_tamper( + self, + key_manager: XmssKeyManager, + emitted: dict[str, Any], + ) -> dict[str, Any]: + """Apply the configured tamper to the honest bundle.""" + assert self.tamper is not None + operation = self.tamper.get("operation") + + if operation == "rebind_with_alt_head_root": + return self._tamper_rebind_with_alt_head_root(key_manager, emitted) + if operation == "shift_emitted_slot": + return self._tamper_shift_emitted_slot(emitted) + if operation == "swap_public_key": + return self._tamper_swap_public_key(key_manager, emitted) + if operation == "shrink_aggregation_bits": + return self._tamper_shrink_aggregation_bits(emitted) + + raise ValueError(f"Unknown tamper operation: {operation!r}") + + def _tamper_rebind_with_alt_head_root( + self, + key_manager: XmssKeyManager, + emitted: dict[str, Any], + ) -> dict[str, Any]: + """Regenerate the proof against an alternate head root. + + The emitted attestation data, message, slot, pubkeys, and bits + stay honest. + Only the proof bytes carry a binding to the alternate root, so + the verifier rejects on the message mismatch. + """ + honest_data: AttestationData = emitted["attestation_data"] + alt_head_root = Bytes32(b"\xee" * 32) + alt_data = honest_data.model_copy( + update={"head": Checkpoint(root=alt_head_root, slot=honest_data.slot)} + ) + alt_bundle = self._generate(key_manager, alt_data, self.validator_ids) + return {**emitted, "proof_bytes": alt_bundle["proof_bytes"]} + + def _tamper_shift_emitted_slot(self, emitted: dict[str, Any]) -> dict[str, Any]: + """Increment the emitted slot field while leaving the proof bound to the original.""" + return {**emitted, "slot": Slot(int(emitted["slot"]) + 1)} + + def _tamper_swap_public_key( + self, + key_manager: XmssKeyManager, + emitted: dict[str, Any], + ) -> dict[str, Any]: + """Replace the emitted pubkey at index i with another validator's.""" + index = int(self.tamper["index"]) # type: ignore[index] + with_validator_id = ValidatorIndex(int(self.tamper["with_validator_id"])) # type: ignore[index] + + public_keys: list[PublicKey] = list(emitted["public_keys"]) + if not 0 <= index < len(public_keys): + raise ValueError( + f"swap_public_key index {index} out of range for {len(public_keys)} keys" + ) + + replacement = key_manager.get_public_keys(with_validator_id)[0] + public_keys[index] = replacement + return {**emitted, "public_keys": public_keys} + + def _tamper_shrink_aggregation_bits(self, emitted: dict[str, Any]) -> dict[str, Any]: + """Truncate emitted aggregation_bits to a shorter length.""" + length = int(self.tamper["length"]) # type: ignore[index] + bits: AggregationBits = emitted["aggregation_bits"] + if length < 0 or length >= len(bits.data): + raise ValueError( + f"shrink_aggregation_bits length {length} must be in [0, {len(bits.data)})" + ) + truncated = AggregationBits(data=[Boolean(bool(b)) for b in bits.data[:length]]) + return {**emitted, "aggregation_bits": truncated} + + def _verify_emitted(self, emitted: dict[str, Any]) -> bool: + """Run the verifier against the emitted bundle. + + Catches every failure mode a client would surface so the self- + check stays aligned with what a conformant client must do. + """ + try: + candidate = TypeOneMultiSignature( + participants=emitted["aggregation_bits"], + proof=emitted["proof_bytes"], + ) + candidate.verify( + emitted["public_keys"], + emitted["message"], + emitted["slot"], + ) + return True + except (AggregationError, AssertionError, ValueError): + return False diff --git a/tests/consensus/lstar/verify_proofs/__init__.py b/tests/consensus/lstar/verify_proofs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/consensus/lstar/verify_proofs/test_type_1_valid.py b/tests/consensus/lstar/verify_proofs/test_type_1_valid.py new file mode 100644 index 000000000..b3573ed95 --- /dev/null +++ b/tests/consensus/lstar/verify_proofs/test_type_1_valid.py @@ -0,0 +1,71 @@ +"""Type-1 multi-signature proof verification vectors — valid cases.""" + +import pytest +from consensus_testing import VerifyProofsTestFiller + +from lean_spec.forks.lstar.containers import AttestationData +from lean_spec.types import Bytes32, Checkpoint, Slot, ValidatorIndex + +pytestmark = pytest.mark.valid_until("Lstar") + + +HEAD_ROOT = Bytes32(b"\x11" * 32) +TARGET_ROOT = Bytes32(b"\x22" * 32) +SOURCE_ROOT = Bytes32(b"\x33" * 32) + + +def _make_attestation_data(slot: Slot) -> AttestationData: + """Build an attestation data with deterministic roots for the given slot.""" + return AttestationData( + slot=slot, + head=Checkpoint(root=HEAD_ROOT, slot=slot), + target=Checkpoint(root=TARGET_ROOT, slot=slot), + source=Checkpoint(root=SOURCE_ROOT, slot=Slot(0)), + ) + + +def test_type_1_single_validator( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Single-validator Type-1 proof must verify. + + Smallest positive case for the multi-signature primitive. + Catches clients that skip the degenerate one-participant branch + or mis-bind the message and slot into the proof. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(0)], + attestation_data=_make_attestation_data(Slot(1)), + expect_valid=True, + ) + + +def test_type_1_four_validators( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Four-validator Type-1 proof, all participating, must verify. + + Matches existing unit-test scale. + First vector with a contiguous all-participating bitfield. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(i) for i in range(4)], + attestation_data=_make_attestation_data(Slot(2)), + expect_valid=True, + ) + + +def test_type_1_four_validators_partial( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Four-validator committee with a non-contiguous participating set. + + Aggregation bits resolve to [1, 0, 1, 1]. + Catches clients that mis-index participants when the bitfield has + a False slot interleaved with True ones. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(0), ValidatorIndex(2), ValidatorIndex(3)], + attestation_data=_make_attestation_data(Slot(3)), + expect_valid=True, + ) From bee8dc50c6481ba4f76d738d582426ec393053af Mon Sep 17 00:00:00 2001 From: unnawut Date: Tue, 26 May 2026 17:59:42 +0700 Subject: [PATCH 2/4] test(consensus): add Type-1 verify_proofs rejection vectors Adds four negative vectors exercising spec-layer bindings between inputs and the multi-signature proof. Each vector uses a tamper operation on the fixture to produce a structurally valid bundle that must be rejected by a conformant verifier: - wrong_message: proof bound to an alternate head root inside the attestation data - wrong_slot: emitted slot field shifted while the proof binding stays on the original slot - wrong_public_keys: one emitted pubkey replaced with another validator's - aggregation_bits_length_mismatch: emitted bits truncated while the pubkey count stays unchanged Vectors covering malformed or truncated proof bytes are intentionally out of scope: leanSpec consumes the multi-signature primitive as a black box and primitive integrity belongs to its own conformance suite. Pubkey ordering is also not a binding to test: the aggregator sorts participants internally, so the verifier is order-insensitive. --- .../verify_proofs/test_type_1_invalid.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/consensus/lstar/verify_proofs/test_type_1_invalid.py diff --git a/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py b/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py new file mode 100644 index 000000000..ff51faf24 --- /dev/null +++ b/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py @@ -0,0 +1,99 @@ +"""Type-1 multi-signature proof verification vectors — rejection cases.""" + +import pytest +from consensus_testing import VerifyProofsTestFiller + +from lean_spec.forks.lstar.containers import AttestationData +from lean_spec.types import Bytes32, Checkpoint, Slot, ValidatorIndex + +pytestmark = pytest.mark.valid_until("Lstar") + + +HEAD_ROOT = Bytes32(b"\x11" * 32) +TARGET_ROOT = Bytes32(b"\x22" * 32) +SOURCE_ROOT = Bytes32(b"\x33" * 32) + + +def _make_attestation_data(slot: Slot) -> AttestationData: + """Build an attestation data with deterministic roots for the given slot.""" + return AttestationData( + slot=slot, + head=Checkpoint(root=HEAD_ROOT, slot=slot), + target=Checkpoint(root=TARGET_ROOT, slot=slot), + source=Checkpoint(root=SOURCE_ROOT, slot=Slot(0)), + ) + + +def test_type_1_wrong_message( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Proof bound to a different message must not verify. + + The emitted attestation data is honest, but the proof bytes were + produced by signing an alternate attestation data with a different + head root. + A client recomputes the emitted attestation root, matches the + emitted message field, then verifies the proof against that + message and must reject. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(0)], + attestation_data=_make_attestation_data(Slot(6)), + expect_valid=False, + tamper={"operation": "rebind_with_alt_head_root"}, + ) + + +def test_type_1_wrong_slot( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Proof bound to one slot, emitted under a different slot, must reject. + + The proof was honestly generated at slot 4, but the emitted slot + field is bumped to 5. + A client must verify the proof against the emitted slot field and + reject on the slot binding mismatch. + A client that derives the verifier slot from any other source + would incorrectly accept the vector. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(0)], + attestation_data=_make_attestation_data(Slot(4)), + expect_valid=False, + tamper={"operation": "shift_emitted_slot"}, + ) + + +def test_type_1_wrong_public_keys( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Swapped public key at one participant slot must cause rejection. + + The proof was generated using validator 0's attestation key, but + the emitted public key set carries validator 1's key in its place. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(0)], + attestation_data=_make_attestation_data(Slot(7)), + expect_valid=False, + tamper={"operation": "swap_public_key", "index": 0, "with_validator_id": 1}, + ) + + +def test_type_1_aggregation_bits_length_mismatch( + verify_proofs_test: VerifyProofsTestFiller, +) -> None: + """Pubkey count disagreeing with True-bit count must cause rejection. + + The honest bundle has four participants, four public keys, and a + bitfield of length four with all bits set. + Truncating the emitted bits to length three leaves four pubkeys + against three True bits. + The verifier's pubkey-count check rejects on the mismatch. + """ + verify_proofs_test( + validator_ids=[ValidatorIndex(i) for i in range(4)], + attestation_data=_make_attestation_data(Slot(8)), + expect_valid=False, + tamper={"operation": "shrink_aggregation_bits", "length": 3}, + ) From 2dad82d73dfbc93b2d19a44f4b8268a599a666dc Mon Sep 17 00:00:00 2001 From: unnawut Date: Thu, 28 May 2026 14:44:28 +0700 Subject: [PATCH 3/4] test(consensus): align VerifyProofsTest with sibling fixture conventions Brings the new fixture in line with the patterns the other consensus test fixtures follow: - Drop ``from __future__ import annotations`` (PR #759 removed it from Pydantic-defining files); quote the one self-reference instead. - Replace the bespoke ``expect_valid: bool`` field with the inherited ``expect_exception`` field already used by SSZTest, NetworkingCodec, and VerifySignaturesTest. Rejection vectors now pin ``AggregationError`` and the framework serializes the class name to JSON. - Switch the tamper dispatch in ``_apply_tamper`` from ``if/elif`` to ``match/case`` to follow the pattern in slot_clock and networking_codec. - Expand the module-level docstring from one line to a short paragraph describing what the fixture covers. - Normalize the ``public_keys`` default from ``[]`` to ``| None = None`` to match every other output field on the model. --- .../test_fixtures/verify_proofs.py | 93 ++++++++++--------- .../verify_proofs/test_type_1_invalid.py | 9 +- .../lstar/verify_proofs/test_type_1_valid.py | 3 - 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py index 9b17f246a..2d03e25fc 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py @@ -1,6 +1,10 @@ -"""Multi-signature proof verification fixture format.""" +"""Multi-signature proof verification fixture format. -from __future__ import annotations +Generates JSON test vectors for the Type-1 multi-signature primitive. +Each vector emits the public inputs and proof bytes a conformant +client must verify, plus the attestation data the message hash is +derived from so SSZ hashing agreement is exercised on the same path. +""" from typing import Any, ClassVar, Literal @@ -39,7 +43,8 @@ class VerifyProofsTest(BaseConsensusFixture): that the recomputed attestation root matches the message field, run their Type-1 verifier against the emitted public keys, message, slot, and proof, and check that the outcome matches - expect_valid. + expect_exception (None means verify must succeed; a class name + means verify must reject). """ format_name: ClassVar[str] = "verify_proofs_test" @@ -68,14 +73,6 @@ class VerifyProofsTest(BaseConsensusFixture): field below. """ - expect_valid: bool = True - """Whether clients must accept the emitted proof. - - A vector with expect_valid set to false carries an intentionally - bad proof or input combination that every conformant client must - reject. - """ - tamper: dict[str, Any] | None = Field(default=None, exclude=True) """Optional post-generation mutation that produces a rejection vector. @@ -111,7 +108,7 @@ class VerifyProofsTest(BaseConsensusFixture): # Output fields below are populated by make_fixture and complete # the client-visible portion of the JSON vector. - public_keys: list[PublicKey] = [] + public_keys: list[PublicKey] | None = None """Attestation public keys for the participating validators. Ordered by validator_ids order, matching the aggregation_bits mask. @@ -129,15 +126,15 @@ class VerifyProofsTest(BaseConsensusFixture): proof: ByteList512KiB | None = None """Aggregated proof bytes for clients to verify.""" - def make_fixture(self) -> VerifyProofsTest: + def make_fixture(self) -> "VerifyProofsTest": """Generate a Type-1 proof, optionally tamper, and self-verify. Returns: A copy with the computed output fields populated. Raises: - AssertionError: If self-verification of the emitted bundle - disagrees with expect_valid. + AssertionError: If the verifier outcome on the emitted + bundle disagrees with expect_exception. ValueError: If the tamper operation is unknown or misconfigured. """ @@ -147,13 +144,7 @@ def make_fixture(self) -> VerifyProofsTest: if self.tamper is not None: emitted = self._apply_tamper(key_manager, emitted) - verified = self._verify_emitted(emitted) - if verified != self.expect_valid: - raise AssertionError( - f"Self-verification mismatch: verified={verified}, " - f"expect_valid={self.expect_valid}, " - f"tamper={self.tamper!r}" - ) + self._assert_verify_matches_expectation(emitted) return self.model_copy( update={ @@ -204,16 +195,17 @@ def _apply_tamper( assert self.tamper is not None operation = self.tamper.get("operation") - if operation == "rebind_with_alt_head_root": - return self._tamper_rebind_with_alt_head_root(key_manager, emitted) - if operation == "shift_emitted_slot": - return self._tamper_shift_emitted_slot(emitted) - if operation == "swap_public_key": - return self._tamper_swap_public_key(key_manager, emitted) - if operation == "shrink_aggregation_bits": - return self._tamper_shrink_aggregation_bits(emitted) - - raise ValueError(f"Unknown tamper operation: {operation!r}") + match operation: + case "rebind_with_alt_head_root": + return self._tamper_rebind_with_alt_head_root(key_manager, emitted) + case "shift_emitted_slot": + return self._tamper_shift_emitted_slot(emitted) + case "swap_public_key": + return self._tamper_swap_public_key(key_manager, emitted) + case "shrink_aggregation_bits": + return self._tamper_shrink_aggregation_bits(emitted) + case _: + raise ValueError(f"Unknown tamper operation: {operation!r}") def _tamper_rebind_with_alt_head_root( self, @@ -269,22 +261,37 @@ def _tamper_shrink_aggregation_bits(self, emitted: dict[str, Any]) -> dict[str, truncated = AggregationBits(data=[Boolean(bool(b)) for b in bits.data[:length]]) return {**emitted, "aggregation_bits": truncated} - def _verify_emitted(self, emitted: dict[str, Any]) -> bool: - """Run the verifier against the emitted bundle. + def _assert_verify_matches_expectation(self, emitted: dict[str, Any]) -> None: + """Verify the emitted bundle and check the outcome against expect_exception. - Catches every failure mode a client would surface so the self- - check stays aligned with what a conformant client must do. + Honest vectors expect verification to succeed (expect_exception is None). + Tampered vectors expect a specific exception type to be raised. """ + candidate = TypeOneMultiSignature( + participants=emitted["aggregation_bits"], + proof=emitted["proof_bytes"], + ) + exception_raised: Exception | None = None try: - candidate = TypeOneMultiSignature( - participants=emitted["aggregation_bits"], - proof=emitted["proof_bytes"], - ) candidate.verify( emitted["public_keys"], emitted["message"], emitted["slot"], ) - return True - except (AggregationError, AssertionError, ValueError): - return False + except AggregationError as exc: + exception_raised = exc + + if self.expect_exception is None: + if exception_raised is not None: + raise AssertionError(f"Verifier rejected an honest bundle: {exception_raised}") + return + + if exception_raised is None: + raise AssertionError( + f"Expected {self.expect_exception.__name__} but verification succeeded" + ) + if not isinstance(exception_raised, self.expect_exception): + raise AssertionError( + f"Expected {self.expect_exception.__name__} but got " + f"{type(exception_raised).__name__}: {exception_raised}" + ) diff --git a/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py b/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py index ff51faf24..64ff2b91d 100644 --- a/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py +++ b/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py @@ -4,6 +4,7 @@ from consensus_testing import VerifyProofsTestFiller from lean_spec.forks.lstar.containers import AttestationData +from lean_spec.subspecs.xmss.aggregation import AggregationError from lean_spec.types import Bytes32, Checkpoint, Slot, ValidatorIndex pytestmark = pytest.mark.valid_until("Lstar") @@ -39,7 +40,7 @@ def test_type_1_wrong_message( verify_proofs_test( validator_ids=[ValidatorIndex(0)], attestation_data=_make_attestation_data(Slot(6)), - expect_valid=False, + expect_exception=AggregationError, tamper={"operation": "rebind_with_alt_head_root"}, ) @@ -59,7 +60,7 @@ def test_type_1_wrong_slot( verify_proofs_test( validator_ids=[ValidatorIndex(0)], attestation_data=_make_attestation_data(Slot(4)), - expect_valid=False, + expect_exception=AggregationError, tamper={"operation": "shift_emitted_slot"}, ) @@ -75,7 +76,7 @@ def test_type_1_wrong_public_keys( verify_proofs_test( validator_ids=[ValidatorIndex(0)], attestation_data=_make_attestation_data(Slot(7)), - expect_valid=False, + expect_exception=AggregationError, tamper={"operation": "swap_public_key", "index": 0, "with_validator_id": 1}, ) @@ -94,6 +95,6 @@ def test_type_1_aggregation_bits_length_mismatch( verify_proofs_test( validator_ids=[ValidatorIndex(i) for i in range(4)], attestation_data=_make_attestation_data(Slot(8)), - expect_valid=False, + expect_exception=AggregationError, tamper={"operation": "shrink_aggregation_bits", "length": 3}, ) diff --git a/tests/consensus/lstar/verify_proofs/test_type_1_valid.py b/tests/consensus/lstar/verify_proofs/test_type_1_valid.py index b3573ed95..3cc27febb 100644 --- a/tests/consensus/lstar/verify_proofs/test_type_1_valid.py +++ b/tests/consensus/lstar/verify_proofs/test_type_1_valid.py @@ -36,7 +36,6 @@ def test_type_1_single_validator( verify_proofs_test( validator_ids=[ValidatorIndex(0)], attestation_data=_make_attestation_data(Slot(1)), - expect_valid=True, ) @@ -51,7 +50,6 @@ def test_type_1_four_validators( verify_proofs_test( validator_ids=[ValidatorIndex(i) for i in range(4)], attestation_data=_make_attestation_data(Slot(2)), - expect_valid=True, ) @@ -67,5 +65,4 @@ def test_type_1_four_validators_partial( verify_proofs_test( validator_ids=[ValidatorIndex(0), ValidatorIndex(2), ValidatorIndex(3)], attestation_data=_make_attestation_data(Slot(3)), - expect_valid=True, ) From f61c0ed18e2b504810a5052a65c3455dcc69180c Mon Sep 17 00:00:00 2001 From: unnawut Date: Thu, 28 May 2026 16:22:34 +0700 Subject: [PATCH 4/4] test(consensus): drop aggregation_bits_length_mismatch rejection vector The check that fires here is the early-reject in the spec wrapper's verify method (len(public_keys) != participants.count(True)), not a consensus-critical binding. In real consensus the inconsistency cannot arise because clients resolve public keys from the bitfield plus the validator registry as one operation. A client that did pass a wrong pubkey count would also be rejected by the underlying recursive verifier on its internal pubkey-set commitment, so the wrapper check is at best an early exit with a nicer error message. The remaining three rejection vectors still exercise the meaningful spec-layer bindings: message hash, slot, and pubkey set. --- .../test_fixtures/verify_proofs.py | 19 ------------------- .../verify_proofs/test_type_1_invalid.py | 19 ------------------- 2 files changed, 38 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py index 2d03e25fc..9f6e31fd1 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py @@ -19,7 +19,6 @@ from lean_spec.subspecs.xmss.containers import PublicKey from lean_spec.types import ( AggregationBits, - Boolean, ByteList512KiB, Bytes32, Checkpoint, @@ -98,11 +97,6 @@ class VerifyProofsTest(BaseConsensusFixture): validator's attestation public key. The proof was generated honestly, so the emitted set no longer matches the keys the proof was bound to. - - - ``{"operation": "shrink_aggregation_bits", "length": int}`` - Truncate the emitted aggregation bits to the given length while - leaving the public key count untouched. - The verifier's pubkey-count check rejects on the mismatch. """ # Output fields below are populated by make_fixture and complete @@ -202,8 +196,6 @@ def _apply_tamper( return self._tamper_shift_emitted_slot(emitted) case "swap_public_key": return self._tamper_swap_public_key(key_manager, emitted) - case "shrink_aggregation_bits": - return self._tamper_shrink_aggregation_bits(emitted) case _: raise ValueError(f"Unknown tamper operation: {operation!r}") @@ -250,17 +242,6 @@ def _tamper_swap_public_key( public_keys[index] = replacement return {**emitted, "public_keys": public_keys} - def _tamper_shrink_aggregation_bits(self, emitted: dict[str, Any]) -> dict[str, Any]: - """Truncate emitted aggregation_bits to a shorter length.""" - length = int(self.tamper["length"]) # type: ignore[index] - bits: AggregationBits = emitted["aggregation_bits"] - if length < 0 or length >= len(bits.data): - raise ValueError( - f"shrink_aggregation_bits length {length} must be in [0, {len(bits.data)})" - ) - truncated = AggregationBits(data=[Boolean(bool(b)) for b in bits.data[:length]]) - return {**emitted, "aggregation_bits": truncated} - def _assert_verify_matches_expectation(self, emitted: dict[str, Any]) -> None: """Verify the emitted bundle and check the outcome against expect_exception. diff --git a/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py b/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py index 64ff2b91d..14361d305 100644 --- a/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py +++ b/tests/consensus/lstar/verify_proofs/test_type_1_invalid.py @@ -79,22 +79,3 @@ def test_type_1_wrong_public_keys( expect_exception=AggregationError, tamper={"operation": "swap_public_key", "index": 0, "with_validator_id": 1}, ) - - -def test_type_1_aggregation_bits_length_mismatch( - verify_proofs_test: VerifyProofsTestFiller, -) -> None: - """Pubkey count disagreeing with True-bit count must cause rejection. - - The honest bundle has four participants, four public keys, and a - bitfield of length four with all bits set. - Truncating the emitted bits to length three leaves four pubkeys - against three True bits. - The verifier's pubkey-count check rejects on the mismatch. - """ - verify_proofs_test( - validator_ids=[ValidatorIndex(i) for i in range(4)], - attestation_data=_make_attestation_data(Slot(8)), - expect_exception=AggregationError, - tamper={"operation": "shrink_aggregation_bits", "length": 3}, - )