diff --git a/packages/testing/src/consensus_testing/__init__.py b/packages/testing/src/consensus_testing/__init__.py index 5df38efa2..8606760be 100644 --- a/packages/testing/src/consensus_testing/__init__.py +++ b/packages/testing/src/consensus_testing/__init__.py @@ -7,21 +7,18 @@ from consensus_testing.test_fixtures import ( ApiEndpointTest, BaseConsensusFixture, - DropComponentMessageBinding, + DropMessageBinding, ForkChoiceTest, GossipsubHandlerTest, - IncrementComponentSlot, IncrementEmittedSlot, JustifiabilityTest, NetworkingCodecTest, PoseidonPermutationTest, - RebindComponentToAlternateHeadRoot, RebindToAlternateHeadRoot, SlotClockTest, SSZTest, StateTransitionTest, - SwapComponentMessageBindings, - SwapComponentParticipantPublicKey, + SwapMessageBindings, SwapParticipantPublicKey, SyncTest, VerifyMultiMessageProofsTest, @@ -72,15 +69,12 @@ "StateTransitionTest", "ForkChoiceTest", "VerifySingleMessageProofsTest", + "VerifyMultiMessageProofsTest", "RebindToAlternateHeadRoot", "IncrementEmittedSlot", "SwapParticipantPublicKey", - "VerifyMultiMessageProofsTest", - "RebindComponentToAlternateHeadRoot", - "IncrementComponentSlot", - "SwapComponentParticipantPublicKey", - "SwapComponentMessageBindings", - "DropComponentMessageBinding", + "SwapMessageBindings", + "DropMessageBinding", "VerifySignaturesTest", "SSZTest", "NetworkingCodecTest", diff --git a/packages/testing/src/consensus_testing/keys.py b/packages/testing/src/consensus_testing/keys.py index 253571690..f0e5fcae1 100755 --- a/packages/testing/src/consensus_testing/keys.py +++ b/packages/testing/src/consensus_testing/keys.py @@ -514,6 +514,7 @@ def sign_and_aggregate( self, validator_indices: list[ValidatorIndex], attestation_data: AttestationData, + precomputed_signatures: Mapping[ValidatorIndex, Signature] | None = None, ) -> SingleMessageAggregate: """ Sign attestation data with each validator and aggregate the result. @@ -529,15 +530,19 @@ def sign_and_aggregate( Args: validator_indices: Validators to sign with. attestation_data: The attestation data to sign. + precomputed_signatures: Optional pre-computed signatures keyed by + validator index. Missing entries are signed on the fly. Returns: Cryptographically valid single-message aggregate proof covering validator_indices. """ + signatures = precomputed_signatures or {} raw_xmss = [ ( validator_index, self.get_public_keys(validator_index)[0], - self.sign_attestation_data(validator_index, attestation_data), + signatures.get(validator_index) + or self.sign_attestation_data(validator_index, attestation_data), ) for validator_index in validator_indices ] @@ -577,39 +582,17 @@ def build_attestation_proofs( """ lookup = signature_lookup or {} - proofs: list[SingleMessageAggregate] = [] - for aggregate in aggregated_attestations: - # Decode which validators participated from the bitfield. - validator_indices = aggregate.aggregation_bits.to_validator_indices() - - # Try the lookup first for pre-computed signatures. - # Fall back to signing on the fly for any missing entries. - signatures_for_data = lookup.get(aggregate.data, {}) - - # Collect the attestation public keys for each participant. - public_keys = [ - self.get_public_keys(validator_index)[0] for validator_index in validator_indices - ] - - # Gather individual signatures, computing any that are missing. - signatures = [ - signatures_for_data.get(validator_index) - or self.sign_attestation_data(validator_index, aggregate.data) - for validator_index in validator_indices - ] - - # Produce a single aggregated proof that the leanVM can verify - # in one pass over all participants. - proofs.append( - SingleMessageAggregate.aggregate( - children=[], - raw_xmss=list(zip(validator_indices, public_keys, signatures, strict=True)), - message=hash_tree_root(aggregate.data), - slot=aggregate.data.slot, - ) + # Produce one aggregated proof per attestation that the leanVM can + # verify in one pass over all participants. + # Participants are decoded from each attestation's bitfield. + return [ + self.sign_and_aggregate( + list(aggregate.aggregation_bits.to_validator_indices()), + aggregate.data, + precomputed_signatures=lookup.get(aggregate.data, {}), ) - - return proofs + for aggregate in aggregated_attestations + ] def _generate_single_keypair( diff --git a/packages/testing/src/consensus_testing/test_fixtures/__init__.py b/packages/testing/src/consensus_testing/test_fixtures/__init__.py index 42fb8e21d..f89a697cb 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/__init__.py +++ b/packages/testing/src/consensus_testing/test_fixtures/__init__.py @@ -11,36 +11,28 @@ from consensus_testing.test_fixtures.ssz import SSZTest from consensus_testing.test_fixtures.state_transition import StateTransitionTest from consensus_testing.test_fixtures.sync import SyncTest -from consensus_testing.test_fixtures.verify_multi_message_proofs import ( - DropComponentMessageBinding, - IncrementComponentSlot, - RebindComponentToAlternateHeadRoot, - SwapComponentMessageBindings, - SwapComponentParticipantPublicKey, - VerifyMultiMessageProofsTest, -) -from consensus_testing.test_fixtures.verify_signatures import VerifySignaturesTest -from consensus_testing.test_fixtures.verify_single_message_proofs import ( +from consensus_testing.test_fixtures.verify_proofs import ( + DropMessageBinding, IncrementEmittedSlot, RebindToAlternateHeadRoot, + SwapMessageBindings, SwapParticipantPublicKey, + VerifyMultiMessageProofsTest, VerifySingleMessageProofsTest, ) +from consensus_testing.test_fixtures.verify_signatures import VerifySignaturesTest __all__ = [ "BaseConsensusFixture", "StateTransitionTest", "ForkChoiceTest", "VerifySingleMessageProofsTest", + "VerifyMultiMessageProofsTest", "RebindToAlternateHeadRoot", "IncrementEmittedSlot", "SwapParticipantPublicKey", - "VerifyMultiMessageProofsTest", - "RebindComponentToAlternateHeadRoot", - "IncrementComponentSlot", - "SwapComponentParticipantPublicKey", - "SwapComponentMessageBindings", - "DropComponentMessageBinding", + "SwapMessageBindings", + "DropMessageBinding", "VerifySignaturesTest", "SSZTest", "NetworkingCodecTest", diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_multi_message_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_multi_message_proofs.py deleted file mode 100644 index fb7acf2ef..000000000 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_multi_message_proofs.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Fixture format for multi-message aggregate proof verification vectors.""" - -from __future__ import annotations - -from typing import ClassVar - -from pydantic import BaseModel, Field - -from consensus_testing.keys import XmssKeyManager -from consensus_testing.test_fixtures.base import BaseConsensusFixture -from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.containers import PublicKey -from lean_spec.spec.forks import ( - AggregationBits, - Checkpoint, - Slot, - ValidatorIndex, -) -from lean_spec.spec.forks.lstar.containers import ( - AttestationData, - MultiMessageAggregate, - SingleMessageAggregate, -) -from lean_spec.spec.ssz import ByteList512KiB, Bytes32 - -ALTERNATE_HEAD_ROOT: Bytes32 = Bytes32(b"\xee" * 32) -"""Sentinel head root used by the rebind tamper to bind one component off-target.""" - - -class RebindComponentToAlternateHeadRoot(BaseModel): - """ - Rebind one component's proof to an alternate head root. - - The honest attestation data is still emitted for every component. - Only the targeted component's proof bytes carry the alternate binding. - """ - - component_index: int - """Index of the component whose proof is rebound.""" - - -class IncrementComponentSlot(BaseModel): - """Bump one component's emitted slot while its proof stays bound to the original slot.""" - - component_index: int - """Index of the component whose emitted slot is bumped.""" - - -class SwapComponentParticipantPublicKey(BaseModel): - """ - Replace one participant's public key with another validator's attestation key. - - The honest proof is still emitted. - Only the targeted component's public key layout carries the swap. - """ - - component_index: int - """Index of the component whose participant list is edited.""" - - participant_index: int - """Position in that component's participant list whose key is replaced.""" - - with_validator_index: ValidatorIndex - """Validator whose attestation key replaces the original.""" - - -class SwapComponentMessageBindings(BaseModel): - """ - Swap the emitted message-slot bindings of two components. - - The merged proof and the per-component key layout stay honest. - Each component's proof is then checked against the other component's binding. - A conforming verifier rejects this transposition. - """ - - first_component_index: int - """Index of one component whose emitted message-slot binding is swapped.""" - - second_component_index: int - """Index of the other component whose emitted message-slot binding is swapped.""" - - -class DropComponentMessageBinding(BaseModel): - """ - Drop one component's emitted message-slot binding while keeping its keys. - - The emitted binding list ends up shorter than the per-component key list. - A conforming verifier rejects the length mismatch. - """ - - component_index: int - """Index of the component whose emitted message-slot binding is removed.""" - - -Tamper = ( - RebindComponentToAlternateHeadRoot - | IncrementComponentSlot - | SwapComponentParticipantPublicKey - | SwapComponentMessageBindings - | DropComponentMessageBinding -) -"""Union of post-generation mutations that each produce a rejection vector.""" - - -class VerifyMultiMessageProofsTest(BaseConsensusFixture): - """Verify a multi-message aggregate proof against precomputed bytes.""" - - format_name: ClassVar[str] = "verify_multi_message_proofs_test" - - description: ClassVar[str] = ( - "Tests multi-message aggregate proof verification against precomputed proof bytes." - ) - - validator_indices_per_message: list[list[ValidatorIndex]] = Field(exclude=True) - """Per-component validator lists contributing raw signatures.""" - - attestation_data_per_message: list[AttestationData] - """Signed object for each component.""" - - tamper: Tamper | None = Field(default=None, exclude=True) - """Optional post-generation mutation that produces a rejection vector.""" - - # Fields below are populated during generation. - # - # Together they form the client-visible portion of the JSON vector. - - public_keys_per_message: list[list[PublicKey]] | None = None - """Attestation public keys per component, parallel to the participation bits.""" - - aggregation_bits_per_message: list[AggregationBits] | None = None - """Per-component participation bitfields naming each component's contributors.""" - - messages: list[Bytes32] | None = None - """Hash tree root per component, bound into the proof.""" - - slots: list[Slot] | None = None - """Slot per component, bound into the proof.""" - - proof: ByteList512KiB | None = None - """Aggregated multi-message proof bytes for clients to verify.""" - - def make_fixture(self) -> VerifyMultiMessageProofsTest: - """ - Generate the merged proof, optionally tamper one binding, self-verify, return self. - - Raises: - AssertionError: If the verifier outcome disagrees with the configured expectation. - ValueError: If the tamper is misconfigured or the input has no components. - """ - key_manager = XmssKeyManager.shared() - component_count = len(self.attestation_data_per_message) - if component_count == 0: - raise ValueError("at least one component is required for a multi-message vector") - if len(self.validator_indices_per_message) != component_count: - raise ValueError( - f"validator_indices_per_message length {len(self.validator_indices_per_message)} " - f"does not match attestation_data_per_message length {component_count}" - ) - - # Phase 1: derive the honest bundle for each component. - messages: list[Bytes32] = [] - slots: list[Slot] = [] - public_keys_per_message: list[list[PublicKey]] = [] - aggregation_bits_per_message: list[AggregationBits] = [] - components: list[SingleMessageAggregate] = [] - - for validator_indices, attestation_data in zip( - self.validator_indices_per_message, - self.attestation_data_per_message, - strict=True, - ): - messages.append(hash_tree_root(attestation_data)) - slots.append(attestation_data.slot) - public_keys = [key_manager.get_public_keys(i)[0] for i in validator_indices] - public_keys_per_message.append(public_keys) - aggregation_bits_per_message.append(AggregationBits.from_indices(validator_indices)) - components.append( - self._single_message_aggregate( - key_manager, attestation_data, validator_indices, public_keys - ) - ) - - # Phase 2: honest merge. - merged = MultiMessageAggregate.aggregate( - components, - public_keys_per_aggregate=public_keys_per_message, - ) - - # Phase 3: optionally mutate exactly one binding of the bundle. - match self.tamper: - case RebindComponentToAlternateHeadRoot(component_index=component_index): - self._check_component_index(component_index, component_count) - # Regenerate the targeted component against an alternate head root and re-merge. - # The emitted attestation data, message, slot, keys, and bits stay honest. - # Only the merged proof bytes carry the alternate binding for this component. - honest = self.attestation_data_per_message[component_index] - alt_data = AttestationData( - slot=honest.slot, - head=Checkpoint(root=ALTERNATE_HEAD_ROOT, slot=honest.slot), - target=honest.target, - source=honest.source, - ) - components[component_index] = self._single_message_aggregate( - key_manager, - alt_data, - self.validator_indices_per_message[component_index], - public_keys_per_message[component_index], - ) - merged = MultiMessageAggregate.aggregate( - components, - public_keys_per_aggregate=public_keys_per_message, - ) - - case IncrementComponentSlot(component_index=component_index): - self._check_component_index(component_index, component_count) - bumped = slots[component_index] + Slot(1) - # A bumped slot landing on another component's slot would make the rejection - # ambiguous, since the verifier could then fail on the wrong binding. - if any( - other_index != component_index and other_slot == bumped - for other_index, other_slot in enumerate(slots) - ): - raise ValueError( - f"incremented slot {bumped} collides with another component's slot; " - f"pick component slots that stay distinct after the bump" - ) - slots[component_index] = bumped - - case SwapComponentParticipantPublicKey( - component_index=component_index, - participant_index=position, - with_validator_index=replacement_index, - ): - self._check_component_index(component_index, component_count) - public_keys = public_keys_per_message[component_index] - if not 0 <= position < len(public_keys): - raise ValueError( - f"participant_index {position} out of range " - f"for component {component_index} with {len(public_keys)} keys" - ) - replacement = key_manager.get_public_keys(replacement_index)[0] - # A replacement matching the original key would leave the bundle honest. - # The verifier would then accept and the rejection would be a false positive. - if replacement == public_keys[position]: - raise ValueError( - f"participant key replacement at component {component_index} " - f"position {position} matches the original; " - f"pick a with_validator_index distinct from the participant there" - ) - # The honest merge already bound the proof to the honest keys. - # Editing the emitted key here without re-merging is what breaks verification. - public_keys[position] = replacement - - case SwapComponentMessageBindings( - first_component_index=first_index, - second_component_index=second_index, - ): - self._check_component_index(first_index, component_count) - self._check_component_index(second_index, component_count) - if first_index == second_index: - raise ValueError("swap message bindings requires two distinct components") - # Swap each component's emitted message and slot so its proof faces the other's - # binding, while the merged proof and key layout stay honest. - messages[first_index], messages[second_index] = ( - messages[second_index], - messages[first_index], - ) - slots[first_index], slots[second_index] = ( - slots[second_index], - slots[first_index], - ) - - case DropComponentMessageBinding(component_index=component_index): - self._check_component_index(component_index, component_count) - # Remove one component's emitted message and slot but keep its keys. - # The binding list is now shorter than the per-component key list. - del messages[component_index] - del slots[component_index] - - # Phase 4: self-verify and assert the outcome against the configured expectation. - exception_raised: Exception | None = None - # Catch any exception so a verifier raising the wrong type still produces - # a comparable "expected X got Y" message instead of crashing the filler. - try: - merged.verify( - public_keys_per_message=public_keys_per_message, - messages=list(zip(messages, slots, strict=True)), - ) - except Exception as exception: - exception_raised = exception - self.assert_expected_outcome(exception_raised) - - # Phase 5: publish the client-visible outputs and return self. - self.messages = messages - self.slots = slots - self.public_keys_per_message = public_keys_per_message - self.aggregation_bits_per_message = aggregation_bits_per_message - self.proof = merged.proof - return self - - @staticmethod - def _check_component_index(component_index: int, component_count: int) -> None: - """Reject a tamper that targets a component outside the bundle.""" - if not 0 <= component_index < component_count: - raise ValueError( - f"component_index {component_index} out of range for {component_count} components" - ) - - def _single_message_aggregate( - self, - key_manager: XmssKeyManager, - attestation_data: AttestationData, - validator_indices: list[ValidatorIndex], - public_keys: list[PublicKey], - ) -> SingleMessageAggregate: - """Aggregate raw signatures from each validator into a single-message component.""" - signatures = [ - key_manager.sign_attestation_data(i, attestation_data) for i in validator_indices - ] - return SingleMessageAggregate.aggregate( - children=[], - raw_xmss=list(zip(validator_indices, public_keys, signatures, strict=True)), - message=hash_tree_root(attestation_data), - slot=attestation_data.slot, - ) 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..79bd56ba4 --- /dev/null +++ b/packages/testing/src/consensus_testing/test_fixtures/verify_proofs.py @@ -0,0 +1,504 @@ +"""Fixture formats for aggregate proof verification vectors.""" + +from __future__ import annotations + +from typing import ClassVar + +from pydantic import BaseModel, Field + +from consensus_testing.keys import XmssKeyManager +from consensus_testing.test_fixtures.base import BaseConsensusFixture +from lean_spec.spec.crypto.merkleization import hash_tree_root +from lean_spec.spec.crypto.xmss.containers import PublicKey +from lean_spec.spec.forks import ( + AggregationBits, + Checkpoint, + Slot, + ValidatorIndex, +) +from lean_spec.spec.forks.lstar.containers import ( + AttestationData, + MultiMessageAggregate, + SingleMessageAggregate, +) +from lean_spec.spec.ssz import ByteList512KiB, Bytes32 + +ALTERNATE_HEAD_ROOT: Bytes32 = Bytes32(b"\xee" * 32) +"""Sentinel head root used by the rebind tamper to bind one component off-target.""" + + +class RebindToAlternateHeadRoot(BaseModel): + """ + Rebind one component's proof to an alternate head root. + + The honest attestation data is still emitted. + Only the targeted component's proof bytes carry the alternate binding. + """ + + component_index: int = 0 + """Index of the component whose proof is rebound (0 for single-message vectors).""" + + +class IncrementEmittedSlot(BaseModel): + """Bump one component's emitted slot while its proof stays bound to the original slot.""" + + component_index: int = 0 + """Index of the component whose emitted slot is bumped (0 for single-message vectors).""" + + +class SwapParticipantPublicKey(BaseModel): + """ + Replace one participant's public key with another validator's attestation key. + + The honest proof is still emitted. + Only the targeted component's public key layout carries the swap. + """ + + component_index: int = 0 + """Index of the component whose participant list is edited (0 for single-message vectors).""" + + participant_index: int + """Position in that component's participant list whose key is replaced.""" + + with_validator_index: ValidatorIndex + """Validator whose attestation key replaces the original.""" + + +class SwapMessageBindings(BaseModel): + """ + Swap the emitted message-slot bindings of two components. + + The merged proof and the per-component key layout stay honest. + Each component's proof is then checked against the other component's binding. + A conforming verifier rejects this transposition. + """ + + first_component_index: int + """Index of one component whose emitted message-slot binding is swapped.""" + + second_component_index: int + """Index of the other component whose emitted message-slot binding is swapped.""" + + +class DropMessageBinding(BaseModel): + """ + Drop one component's emitted message-slot binding while keeping its keys. + + The emitted binding list ends up shorter than the per-component key list. + A conforming verifier rejects the length mismatch. + """ + + component_index: int + """Index of the component whose emitted message-slot binding is removed.""" + + +SingleMessageTamper = RebindToAlternateHeadRoot | IncrementEmittedSlot | SwapParticipantPublicKey +"""Mutations applicable to a single-message vector, the degenerate one-component case.""" + +MultiMessageTamper = ( + RebindToAlternateHeadRoot + | IncrementEmittedSlot + | SwapParticipantPublicKey + | SwapMessageBindings + | DropMessageBinding +) +"""Union of post-generation mutations that each produce a rejection vector.""" + + +def _check_component_index(component_index: int, component_count: int) -> None: + """Reject a tamper that targets a component outside the bundle.""" + if not 0 <= component_index < component_count: + raise ValueError( + f"component_index {component_index} out of range for {component_count} components" + ) + + +def _alternate_head_data(honest: AttestationData) -> AttestationData: + """Return the honest attestation data rebound to the alternate sentinel head root.""" + return AttestationData( + slot=honest.slot, + head=Checkpoint(root=ALTERNATE_HEAD_ROOT, slot=honest.slot), + target=honest.target, + source=honest.source, + ) + + +def _swap_participant_public_key( + key_manager: XmssKeyManager, + public_keys: list[PublicKey], + participant_index: int, + replacement_validator_index: ValidatorIndex, + component_index: int, +) -> None: + """ + Replace one participant's emitted public key in place. + + Raises: + ValueError: If the position is out of range, or the replacement matches + the original key and would leave the bundle honest. + """ + if not 0 <= participant_index < len(public_keys): + raise ValueError( + f"participant_index {participant_index} out of range " + f"for component {component_index} with {len(public_keys)} keys" + ) + replacement = key_manager.get_public_keys(replacement_validator_index)[0] + # A replacement matching the original key would leave the bundle honest. + # The verifier would then accept and the rejection would be a false positive. + if replacement == public_keys[participant_index]: + raise ValueError( + f"participant key replacement at component {component_index} " + f"position {participant_index} matches the original; " + f"pick a with_validator_index distinct from the participant there" + ) + public_keys[participant_index] = replacement + + +class VerifySingleMessageProofsTest(BaseConsensusFixture): + """Verify a single-message aggregate proof against precomputed bytes.""" + + format_name: ClassVar[str] = "verify_single_message_proofs_test" + + description: ClassVar[str] = ( + "Tests single-message aggregate proof verification against precomputed proof bytes." + ) + + validator_indices: list[ValidatorIndex] = Field(exclude=True) + """Validators contributing raw signatures to the aggregate.""" + + attestation_data: AttestationData + """The signed object.""" + + tamper: SingleMessageTamper | None = Field(default=None, exclude=True) + """Optional post-generation mutation that produces a rejection vector.""" + + child_groups: list[list[ValidatorIndex]] = Field(default_factory=list, exclude=True) + """Optional partition of the participating validators into pre-aggregated child subsets.""" + + # Fields below are populated during generation. + # + # Together they form the client-visible portion of the JSON vector. + + public_keys: list[PublicKey] | None = None + """Attestation public keys for the participating validators. + + Ordered consistently with the participation bitfield. + """ + + aggregation_bits: AggregationBits | None = None + """Participation bitfield naming the contributing validators.""" + + message: Bytes32 | None = None + """Hash tree root of the signed object, 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) -> VerifySingleMessageProofsTest: + """ + Generate the proof, optionally tamper, self-verify, and return the populated copy. + + Raises: + AssertionError: If the verifier outcome disagrees with the configured expectation. + ValueError: If the tamper is misconfigured. + """ + key_manager = XmssKeyManager.shared() + + # A single-message vector carries exactly one component. + # The shared tampers must therefore target component 0. + if self.tamper is not None: + _check_component_index(self.tamper.component_index, component_count=1) + + # Phase 1: derive the honest bundle. + message = hash_tree_root(self.attestation_data) + slot = self.attestation_data.slot + public_keys = [key_manager.get_public_keys(i)[0] for i in self.validator_indices] + aggregation_bits = AggregationBits.from_indices(self.validator_indices) + proof = self._aggregate_proof(key_manager, self.attestation_data) + + # Phase 2: optionally mutate exactly one binding of that bundle. + match self.tamper: + case RebindToAlternateHeadRoot(): + # Regenerate the proof against an alternate head root. + # - The honest attestation data, message, slot, keys, and bits stay emitted. + # - Only the proof bytes carry the alternate binding. + proof = self._aggregate_proof( + key_manager, _alternate_head_data(self.attestation_data) + ) + + case IncrementEmittedSlot(): + slot = slot + Slot(1) + + case SwapParticipantPublicKey( + participant_index=participant_index, + with_validator_index=replacement_index, + ): + _swap_participant_public_key( + key_manager, public_keys, participant_index, replacement_index, 0 + ) + + # Phase 3: self-verify and assert the outcome against the configured expectation. + candidate = SingleMessageAggregate(participants=aggregation_bits, proof=proof) + exception_raised: Exception | None = None + # Catch any exception so a verifier raising the wrong type still produces + # a comparable "expected X got Y" message instead of crashing the filler. + try: + candidate.verify(public_keys, message, slot) + except Exception as exception: + exception_raised = exception + self.assert_expected_outcome(exception_raised) + + # Phase 4: publish the client-visible outputs and return self. + self.message = message + self.slot = slot + self.public_keys = public_keys + self.aggregation_bits = aggregation_bits + self.proof = proof + return self + + def _aggregate_proof( + self, + key_manager: XmssKeyManager, + attestation_data: AttestationData, + ) -> ByteList512KiB: + """Aggregate signatures into proof bytes, recursively when child subsets are provided.""" + if self.child_groups: + return self._aggregate_recursive(key_manager, attestation_data) + return key_manager.sign_and_aggregate(self.validator_indices, attestation_data).proof + + def _aggregate_recursive( + self, + key_manager: XmssKeyManager, + attestation_data: AttestationData, + ) -> ByteList512KiB: + """ + Build a two-level proof so the verifier is exercised on the recursive path. + + Children are leaf-only sub-proofs, so the folded tree is exactly two levels deep. + + Raises: + ValueError: If a child group names an unknown validator or reuses one. + AggregationError: If the prover rejects the inputs. + """ + # Phase 1: reject a malformed partition before signing anything. + # + # Every grouped index must name a participant the bundle already carries. + # No validator may appear in more than one child group. + grouped_indices = [i for group in self.child_groups for i in group] + grouped_index_set = set(grouped_indices) + unknown_indices = grouped_index_set - set(self.validator_indices) + if unknown_indices: + raise ValueError( + "child_groups reference indices not in validator_indices: " + f"{sorted(map(int, unknown_indices))}" + ) + if len(grouped_indices) != len(grouped_index_set): + raise ValueError("child_groups assign a validator to more than one child group") + + # Phase 2: pre-aggregate each child group into its own single-message proof. + children = [ + ( + key_manager.sign_and_aggregate(group, attestation_data), + [key_manager.get_public_keys(i)[0] for i in group], + ) + for group in self.child_groups + ] + + # Phase 3: collect leaf signatures from validators not in any child group. + raw_indices = [i for i in self.validator_indices if i not in grouped_index_set] + raw_xmss = [ + ( + i, + key_manager.get_public_keys(i)[0], + key_manager.sign_attestation_data(i, attestation_data), + ) + for i in raw_indices + ] + + # Phase 4: fold the child proofs and leaf signatures into one outer aggregate. + aggregate = SingleMessageAggregate.aggregate( + children=children, + raw_xmss=raw_xmss, + message=hash_tree_root(attestation_data), + slot=attestation_data.slot, + ) + return aggregate.proof + + +class VerifyMultiMessageProofsTest(BaseConsensusFixture): + """Verify a multi-message aggregate proof against precomputed bytes.""" + + format_name: ClassVar[str] = "verify_multi_message_proofs_test" + + description: ClassVar[str] = ( + "Tests multi-message aggregate proof verification against precomputed proof bytes." + ) + + validator_indices_per_message: list[list[ValidatorIndex]] = Field(exclude=True) + """Per-component validator lists contributing raw signatures.""" + + attestation_data_per_message: list[AttestationData] + """Signed object for each component.""" + + tamper: MultiMessageTamper | None = Field(default=None, exclude=True) + """Optional post-generation mutation that produces a rejection vector.""" + + # Fields below are populated during generation. + # + # Together they form the client-visible portion of the JSON vector. + + public_keys_per_message: list[list[PublicKey]] | None = None + """Attestation public keys per component, parallel to the participation bits.""" + + aggregation_bits_per_message: list[AggregationBits] | None = None + """Per-component participation bitfields naming each component's contributors.""" + + messages: list[Bytes32] | None = None + """Hash tree root per component, bound into the proof.""" + + slots: list[Slot] | None = None + """Slot per component, bound into the proof.""" + + proof: ByteList512KiB | None = None + """Aggregated multi-message proof bytes for clients to verify.""" + + def make_fixture(self) -> VerifyMultiMessageProofsTest: + """ + Generate the merged proof, optionally tamper one binding, self-verify, return self. + + Raises: + AssertionError: If the verifier outcome disagrees with the configured expectation. + ValueError: If the tamper is misconfigured or the input has no components. + """ + key_manager = XmssKeyManager.shared() + component_count = len(self.attestation_data_per_message) + if component_count == 0: + raise ValueError("at least one component is required for a multi-message vector") + if len(self.validator_indices_per_message) != component_count: + raise ValueError( + f"validator_indices_per_message length {len(self.validator_indices_per_message)} " + f"does not match attestation_data_per_message length {component_count}" + ) + + # Phase 1: derive the honest bundle for each component. + messages: list[Bytes32] = [] + slots: list[Slot] = [] + public_keys_per_message: list[list[PublicKey]] = [] + aggregation_bits_per_message: list[AggregationBits] = [] + components: list[SingleMessageAggregate] = [] + + for validator_indices, attestation_data in zip( + self.validator_indices_per_message, + self.attestation_data_per_message, + strict=True, + ): + messages.append(hash_tree_root(attestation_data)) + slots.append(attestation_data.slot) + public_keys_per_message.append( + [key_manager.get_public_keys(i)[0] for i in validator_indices] + ) + aggregation_bits_per_message.append(AggregationBits.from_indices(validator_indices)) + components.append(key_manager.sign_and_aggregate(validator_indices, attestation_data)) + + # Phase 2: honest merge. + merged = MultiMessageAggregate.aggregate( + components, + public_keys_per_aggregate=public_keys_per_message, + ) + + # Phase 3: optionally mutate exactly one binding of the bundle. + match self.tamper: + case RebindToAlternateHeadRoot(component_index=component_index): + _check_component_index(component_index, component_count) + # Regenerate the targeted component against an alternate head root and re-merge. + # The emitted attestation data, message, slot, keys, and bits stay honest. + # Only the merged proof bytes carry the alternate binding for this component. + components[component_index] = key_manager.sign_and_aggregate( + self.validator_indices_per_message[component_index], + _alternate_head_data(self.attestation_data_per_message[component_index]), + ) + merged = MultiMessageAggregate.aggregate( + components, + public_keys_per_aggregate=public_keys_per_message, + ) + + case IncrementEmittedSlot(component_index=component_index): + _check_component_index(component_index, component_count) + bumped = slots[component_index] + Slot(1) + # A bumped slot landing on another component's slot would make the rejection + # ambiguous, since the verifier could then fail on the wrong binding. + if any( + other_index != component_index and other_slot == bumped + for other_index, other_slot in enumerate(slots) + ): + raise ValueError( + f"incremented slot {bumped} collides with another component's slot; " + f"pick component slots that stay distinct after the bump" + ) + slots[component_index] = bumped + + case SwapParticipantPublicKey( + component_index=component_index, + participant_index=participant_index, + with_validator_index=replacement_index, + ): + _check_component_index(component_index, component_count) + # The honest merge already bound the proof to the honest keys. + # Editing the emitted key here without re-merging is what breaks verification. + _swap_participant_public_key( + key_manager, + public_keys_per_message[component_index], + participant_index, + replacement_index, + component_index, + ) + + case SwapMessageBindings( + first_component_index=first_index, + second_component_index=second_index, + ): + _check_component_index(first_index, component_count) + _check_component_index(second_index, component_count) + if first_index == second_index: + raise ValueError("swap message bindings requires two distinct components") + # Swap each component's emitted message and slot so its proof faces the other's + # binding, while the merged proof and key layout stay honest. + messages[first_index], messages[second_index] = ( + messages[second_index], + messages[first_index], + ) + slots[first_index], slots[second_index] = ( + slots[second_index], + slots[first_index], + ) + + case DropMessageBinding(component_index=component_index): + _check_component_index(component_index, component_count) + # Remove one component's emitted message and slot but keep its keys. + # The binding list is now shorter than the per-component key list. + del messages[component_index] + del slots[component_index] + + # Phase 4: self-verify and assert the outcome against the configured expectation. + exception_raised: Exception | None = None + # Catch any exception so a verifier raising the wrong type still produces + # a comparable "expected X got Y" message instead of crashing the filler. + try: + merged.verify( + public_keys_per_message=public_keys_per_message, + messages=list(zip(messages, slots, strict=True)), + ) + except Exception as exception: + exception_raised = exception + self.assert_expected_outcome(exception_raised) + + # Phase 5: publish the client-visible outputs and return self. + self.messages = messages + self.slots = slots + self.public_keys_per_message = public_keys_per_message + self.aggregation_bits_per_message = aggregation_bits_per_message + self.proof = merged.proof + return self diff --git a/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py b/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py deleted file mode 100644 index 9ef470536..000000000 --- a/packages/testing/src/consensus_testing/test_fixtures/verify_single_message_proofs.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Fixture format for single-message aggregate proof verification vectors.""" - -from __future__ import annotations - -from typing import ClassVar - -from pydantic import BaseModel, Field - -from consensus_testing.keys import XmssKeyManager -from consensus_testing.test_fixtures.base import BaseConsensusFixture -from lean_spec.spec.crypto.merkleization import hash_tree_root -from lean_spec.spec.crypto.xmss.containers import PublicKey -from lean_spec.spec.forks import ( - AggregationBits, - Checkpoint, - Slot, - ValidatorIndex, -) -from lean_spec.spec.forks.lstar.containers import ( - AttestationData, - SingleMessageAggregate, -) -from lean_spec.spec.ssz import ByteList512KiB, Bytes32 - -ALTERNATE_HEAD_ROOT: Bytes32 = Bytes32(b"\xee" * 32) -"""Sentinel head root used by the rebind tamper to bind the proof off-target.""" - - -class RebindToAlternateHeadRoot(BaseModel): - """ - Rebind the proof to an alternate head root inside the attestation data. - - The honest attestation data is still emitted. - Only the proof bytes carry a binding to the alternate root. - """ - - -class IncrementEmittedSlot(BaseModel): - """Bump the emitted slot field while the proof stays bound to the original slot.""" - - -class SwapParticipantPublicKey(BaseModel): - """Replace one participant's public key with another validator's attestation key.""" - - index: int - """Position in the participant list whose key is replaced.""" - - with_validator_index: ValidatorIndex - """Validator whose attestation key replaces the original.""" - - -Tamper = RebindToAlternateHeadRoot | IncrementEmittedSlot | SwapParticipantPublicKey -"""Discriminated union of post-generation mutations that produce a rejection vector.""" - - -class VerifySingleMessageProofsTest(BaseConsensusFixture): - """Verify a single-message aggregate proof against precomputed bytes.""" - - format_name: ClassVar[str] = "verify_single_message_proofs_test" - - description: ClassVar[str] = ( - "Tests single-message aggregate proof verification against precomputed proof bytes." - ) - - validator_indices: list[ValidatorIndex] = Field(exclude=True) - """Validators contributing raw signatures to the aggregate.""" - - attestation_data: AttestationData - """The signed object.""" - - tamper: Tamper | None = Field(default=None, exclude=True) - """Optional post-generation mutation that produces a rejection vector.""" - - child_groups: list[list[ValidatorIndex]] = Field(default_factory=list, exclude=True) - """Optional partition of the participating validators into pre-aggregated child subsets.""" - - # Fields below are populated during generation. - # - # Together they form the client-visible portion of the JSON vector. - - public_keys: list[PublicKey] | None = None - """Attestation public keys for the participating validators. - - Ordered consistently with the participation bitfield. - """ - - aggregation_bits: AggregationBits | None = None - """Participation bitfield naming the contributing validators.""" - - message: Bytes32 | None = None - """Hash tree root of the signed object, 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) -> VerifySingleMessageProofsTest: - """ - Generate the proof, optionally tamper, self-verify, and return the populated copy. - - Raises: - AssertionError: If the verifier outcome disagrees with the configured expectation. - ValueError: If the tamper is misconfigured. - """ - key_manager = XmssKeyManager.shared() - - # Phase 1: derive the honest bundle. - message = hash_tree_root(self.attestation_data) - slot = self.attestation_data.slot - public_keys = [key_manager.get_public_keys(i)[0] for i in self.validator_indices] - aggregation_bits = AggregationBits.from_indices(self.validator_indices) - proof = self._aggregate_proof( - key_manager, self.attestation_data, self.validator_indices, public_keys - ) - - # Phase 2: optionally mutate exactly one binding of that bundle. - match self.tamper: - case RebindToAlternateHeadRoot(): - # Regenerate the proof against an alternate head root. - # - The honest attestation data, message, slot, keys, and bits stay emitted. - # - Only the proof bytes carry the alternate binding. - honest = self.attestation_data - alt_data = AttestationData( - slot=honest.slot, - head=Checkpoint(root=ALTERNATE_HEAD_ROOT, slot=honest.slot), - target=honest.target, - source=honest.source, - ) - proof = self._aggregate_proof( - key_manager, alt_data, self.validator_indices, public_keys - ) - - case IncrementEmittedSlot(): - slot = slot + Slot(1) - - case SwapParticipantPublicKey(index=index, with_validator_index=replacement_index): - 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(replacement_index)[0] - # A replacement matching the original key would leave the bundle honest. - # The verifier would then accept and the rejection would be a false positive. - if replacement == public_keys[index]: - raise ValueError( - f"swap_public_key replacement at index {index} matches the original; " - f"pick a with_validator_index distinct from the participant there" - ) - public_keys[index] = replacement - - # Phase 3: self-verify and assert the outcome against the configured expectation. - candidate = SingleMessageAggregate(participants=aggregation_bits, proof=proof) - exception_raised: Exception | None = None - # Catch any exception so a verifier raising the wrong type still produces - # a comparable "expected X got Y" message instead of crashing the filler. - try: - candidate.verify(public_keys, message, slot) - except Exception as exception: - exception_raised = exception - self.assert_expected_outcome(exception_raised) - - # Phase 4: publish the client-visible outputs and return self. - self.message = message - self.slot = slot - self.public_keys = public_keys - self.aggregation_bits = aggregation_bits - self.proof = proof - return self - - def _aggregate_proof( - self, - key_manager: XmssKeyManager, - attestation_data: AttestationData, - validator_indices: list[ValidatorIndex], - public_keys: list[PublicKey], - ) -> ByteList512KiB: - """Aggregate signatures into proof bytes, recursively when child subsets are provided.""" - if self.child_groups: - return self._aggregate_recursive( - key_manager, attestation_data, validator_indices, public_keys - ) - return self._aggregate_flat(key_manager, attestation_data, validator_indices, public_keys) - - def _aggregate_flat( - self, - key_manager: XmssKeyManager, - attestation_data: AttestationData, - validator_indices: list[ValidatorIndex], - public_keys: list[PublicKey], - ) -> ByteList512KiB: - """Aggregate every validator's leaf signature into one single-message proof.""" - signatures = [ - key_manager.sign_attestation_data(i, attestation_data) for i in validator_indices - ] - aggregate = SingleMessageAggregate.aggregate( - children=[], - raw_xmss=list(zip(validator_indices, public_keys, signatures, strict=True)), - message=hash_tree_root(attestation_data), - slot=attestation_data.slot, - ) - return aggregate.proof - - def _aggregate_recursive( - self, - key_manager: XmssKeyManager, - attestation_data: AttestationData, - validator_indices: list[ValidatorIndex], - public_keys: list[PublicKey], - ) -> ByteList512KiB: - """ - Build a two-level proof so the verifier is exercised on the recursive path. - - Children are leaf-only sub-proofs, so the folded tree is exactly two levels deep. - - Raises: - ValueError: If a child group names an unknown validator or reuses one. - AggregationError: If the prover rejects the inputs. - """ - message = hash_tree_root(attestation_data) - slot = attestation_data.slot - index_to_public_key = dict(zip(validator_indices, public_keys, strict=True)) - - # Phase 1: reject a malformed partition before signing anything. - # - # Every grouped index must name a participant the bundle already carries. - # No validator may appear in more than one child group. - grouped_indices = [i for group in self.child_groups for i in group] - grouped_index_set = set(grouped_indices) - unknown_indices = grouped_index_set - set(validator_indices) - if unknown_indices: - raise ValueError( - "child_groups reference indices not in validator_indices: " - f"{sorted(map(int, unknown_indices))}" - ) - if len(grouped_indices) != len(grouped_index_set): - raise ValueError("child_groups assign a validator to more than one child group") - - # Phase 2: pre-aggregate each child group into its own single-message proof. - children: list[tuple[SingleMessageAggregate, list[PublicKey]]] = [] - for group in self.child_groups: - group_public_keys = [index_to_public_key[i] for i in group] - group_signatures = [ - key_manager.sign_attestation_data(i, attestation_data) for i in group - ] - child = SingleMessageAggregate.aggregate( - children=[], - raw_xmss=list(zip(group, group_public_keys, group_signatures, strict=True)), - message=message, - slot=slot, - ) - children.append((child, group_public_keys)) - - # Phase 3: collect leaf signatures from validators not in any child group. - raw_indices = [i for i in validator_indices if i not in grouped_index_set] - raw_public_keys = [index_to_public_key[i] for i in raw_indices] - raw_signatures = [ - key_manager.sign_attestation_data(i, attestation_data) for i in raw_indices - ] - - # Phase 4: fold the child proofs and leaf signatures into one outer aggregate. - aggregate = SingleMessageAggregate.aggregate( - children=children, - raw_xmss=list(zip(raw_indices, raw_public_keys, raw_signatures, strict=True)), - message=message, - slot=slot, - ) - return aggregate.proof diff --git a/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py b/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py index 2a467e736..04419f089 100644 --- a/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py +++ b/tests/consensus/lstar/verify_proofs/test_multi_message_invalid.py @@ -2,11 +2,11 @@ import pytest from consensus_testing import ( - DropComponentMessageBinding, - IncrementComponentSlot, - RebindComponentToAlternateHeadRoot, - SwapComponentMessageBindings, - SwapComponentParticipantPublicKey, + DropMessageBinding, + IncrementEmittedSlot, + RebindToAlternateHeadRoot, + SwapMessageBindings, + SwapParticipantPublicKey, VerifyMultiMessageProofsTestFiller, ) @@ -41,7 +41,7 @@ def test_multi_message_wrong_message_in_one_component( ), ], expect_exception=AggregationError, - tamper=RebindComponentToAlternateHeadRoot(component_index=1), + tamper=RebindToAlternateHeadRoot(component_index=1), ) @@ -69,7 +69,7 @@ def test_multi_message_wrong_slot_in_one_component( ), ], expect_exception=AggregationError, - tamper=IncrementComponentSlot(component_index=1), + tamper=IncrementEmittedSlot(component_index=1), ) @@ -97,7 +97,7 @@ def test_multi_message_wrong_public_key_in_one_component( ), ], expect_exception=AggregationError, - tamper=SwapComponentParticipantPublicKey( + tamper=SwapParticipantPublicKey( component_index=1, participant_index=0, with_validator_index=ValidatorIndex(2), @@ -129,7 +129,7 @@ def test_multi_message_components_with_swapped_bindings( ), ], expect_exception=AggregationError, - tamper=SwapComponentMessageBindings( + tamper=SwapMessageBindings( first_component_index=0, second_component_index=1, ), @@ -160,5 +160,5 @@ def test_multi_message_missing_one_component_binding( ), ], expect_exception=AggregationError, - tamper=DropComponentMessageBinding(component_index=1), + tamper=DropMessageBinding(component_index=1), ) diff --git a/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py b/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py index 6c11a6379..8b3f5ff57 100644 --- a/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py +++ b/tests/consensus/lstar/verify_proofs/test_single_message_invalid.py @@ -62,5 +62,7 @@ def test_single_message_wrong_public_keys( source=Checkpoint(root=Bytes32(b"\x33" * 32), slot=Slot(0)), ), expect_exception=AggregationError, - tamper=SwapParticipantPublicKey(index=0, with_validator_index=ValidatorIndex(1)), + tamper=SwapParticipantPublicKey( + participant_index=0, with_validator_index=ValidatorIndex(1) + ), )