29 — H5: migrating qrysm attestation aggregation from a signature-list to one ML-ADSA aggregate¶
Audit finding H5 (docs/26): qrysm carries a list of per-attester ML-DSA-87 signatures and the
invariant len(signatures) == len(attesting_indices) is load-bearing across SSZ, indexed-attestation
conversion, signature verification, slashing, fork-choice, and the aggregator gossip topic. Replacing
that list with a single ML-ADSA aggregate is the real integration milestone behind the project goal —
[[project-goal]] BLS-like aggregation for QRL 2.0. This document specifies the migration in two phases,
with Phase 1 implemented and tested now (mladsaconsensus/), and Phase 2 scoped as the
proto/SSZ-codegen effort.
0. Why a list exists at all¶
ML-DSA-87 signatures do not concatenate-combine: there is no public operation that takes
{σ_1,…,σ_n} over the same message and yields one short signature an unmodified verifier accepts. So
upstream qrysm — like every naïve PQ port of a BLS pipeline — simply keeps every signature:
proto/qrysm/v1alpha1/attestation.pb.go:32
Signatures [][]byte `ssz-max:"128" ssz-size:"?,4627"` // up to 128 × 4627 B ≈ 592 KB
Construction F's decentralized §7.7 combine (docs/17; mladsa/decentralized.go) does produce one
byte-exact ML-DSA-87 signature from the committee's public per-content contributions, verifiable
against a reconstructed aggregate key pk*. That is what makes the list collapsible to a single
element. The combine is the proven path — CombineFromPublic is byte-identical to AggregateF
(TestDecentralized_EqualsAggregateF), and is non-interactive ([[ml-adsa-noninteractive]]) and has no
intermediary aggregator (every value is public, so each validator self-combines).
1. The load-bearing invariant (what must change)¶
len(signatures) == len(attesting_indices), enforced at:
| Site | Line | Role |
|---|---|---|
proto/.../attestation/attestation_utils.go ConvertToIndexed |
68–69 | rejects unless len(att.Signatures) == len(attestingIndices); pairs each sig with an index |
proto/.../attestation/attestation_utils.go (indexed) verify |
138–139, 150–153 | len(indexedAtt.Signatures) == len(pubKeys); loops, decoding Signatures[index] per attester |
beacon-chain/core/blocks/signature.go createAttestationSignatureBatch |
187–226 | builds a parallel batch: sigs[i]=ia.Signatures, one pubkey per index, message = ComputeSigningRoot(ia.Data, domain); verifies each (sig_j, pk_j, msg) |
proto/.../generated.ssz.go Attestation.HashTreeRootWith |
158–177 | merkleizes the signature list (each element must be 4627 B; ≤128) into the attestation root |
So the signature list is woven into: indexed-attestation conversion, the per-index verification batch, and the SSZ hash-tree-root (hence block roots, fork-choice, and the slasher, which keys on the indexed attestation). Any swap must preserve a verifier that, given the public attesting set, accepts.
2. The replacement primitive¶
mladsa/consensus.go (new) provides the consensus-flavoured §7.7 combine over an explicit message
(the attestation signing root) with ctx = domain — no decision-mode bindMsg wrapper, because for an
attestation the "who" is the public AggregationBits and the "what" is the signing root:
AggPkStar(rho, ts) -> pk* = (rho, Power2Round(Σ tᵢ).t1) // verifier side
ConsensusContentKeys(rho, msg, members) -> [tᵢ = ContentKeyDerive(seedᵢ,A,msg).t] // epoch-tree data
ConsensusAggregate(rho, ctx, msg, members, σ) -> (pk*, σ*, [tᵢ], ok) // aggregator side
ConsensusAggregate runs the proven combine: per member ContentParts → (skᵢ, yᵢ, wᵢ) (per-message
key + deterministic nonce refresh, F-C4), pk* = AggPkStar, W*=Σwᵢ, c̃* = H(μ ‖ w1Encode(HighBits W*)),
zᵢ = ContentResponse(skᵢ,yᵢ,ĉ), σ* = CombineFromPublic(...). Tests TestConsensusAggregate_RoundTrip
(N∈{1,4,16,64}, unmodified Verify(pk*,msg,σ*,ctx) accepts; verifier independently rebuilds the same
pk* from tᵢ) and TestConsensusAggregate_Refresh (different message ⇒ different pk*/σ*;
cross-message verification fails — no leak).
One-time discipline. The signing root is unique per (slot, committee, data), so the deterministic
nonce DeriveNonce(seed, root) is one-time per attestation; production must guard re-aggregation of the
same root with the DurableOneTimeGuard (audit M2, onetime_durable.go) — the same restart-safe,
fsync'd, mark-before-sign used-set already shipped for AggregateF.
3. Phase 1 — single aggregate in the existing field (IMPLEMENTED)¶
mladsaconsensus/attestation.go wires the primitive onto the real qrysmpb.Attestation with no
proto/SSZ regeneration: the Signatures [][]byte field is kept but holds exactly one element —
the aggregate.
AggregateAttestation(rho, domain, signingRoot, data, committee, bits)
-> (*qrysmpb.Attestation{AggregationBits: bits, Data: data, Signatures: [][]byte{σ*}}, [tᵢ for set bits], err)
VerifyAggregatedAttestation(att, attesterKeys, rho, domain, signingRoot) bool
// requires len(att.Signatures)==1; pk* = AggPkStar(rho, attesterKeys); mladsa.Verify(pk*, signingRoot, σ*, domain)
The Phase-1 invariant len(att.Signatures)==1 replaces len(sigs)==len(bits). attesterKeys are
the attesting members' public per-content keys (set-bit order) — in production sourced from the epoch
key-tree (§6.2, VerifyContentInTree), so the verifier needs no secret.
What the tests prove (mladsaconsensus/attestation_test.go, all green):
- TestAggregateAttestation_SingleSigCompression: 16-member committee, 12 attesters → one 4627 B
aggregate (vs. 12 × 4627 = 55 524 B); the populated *Attestation produces a non-zero SSZ
HashTreeRoot (still a valid hashable consensus object); bits preserved; verification accepts.
Measured: 12.0× signature-payload reduction — no needed information lost (the public signer set
is exactly the AggregationBits, and pk* is reconstructible from the epoch-tree tᵢ).
- TestVerify_RejectsTampering: wrong signing root, wrong domain, truncated/altered attester-key set,
flipped signature byte, and a 2-signature (non-Phase-1) attestation are all rejected.
- TestSybil_WrongKeySetCannotForge: substituting a fabricated member's per-content key into the
reconstructed set fails to verify — pk* binds the exact attesting set (fakes can't change the
result).
- TestEmptyBits_Rejected: no attesters ⇒ ErrNoAttesters (audit M7 discipline).
Phase 1 is buildable in isolation today: go build ./proto/qrysm/v1alpha1/, go build ./mladsa/,
go build ./mladsaconsensus/ all succeed; the proto's committed generated.ssz.go hashes the
single-element list unchanged.
3.1 Compression accounting (no lost information)¶
| Committee attesters n | Upstream list | ML-ADSA Phase 1 | Reduction |
|---|---|---|---|
| 16 | 74 032 B | 4 627 B | 16.0× |
| 64 | 296 128 B | 4 627 B | 64.0× |
| 128 (max) | 592 256 B | 4 627 B | 128.0× |
The aggregate is constant-size (4627 B) regardless of committee size. The only per-attester data
that remains is the AggregationBits (≤16 B for 128 members) — which is already present upstream and
is exactly the public information the verifier needs to select the tᵢ and rebuild pk*. Nothing
needed is dropped: signer set = bits, key = Σ epoch-tree tᵢ, validity = one FIPS-204 Verify.
4. Phase 2 — proto/SSZ shrink to a single bytes signature (IMPLEMENTED)¶
Phase 2 makes the type honest: Attestation/IndexedAttestation now hold ONE fixed-size
bytes signature = 3 [ssz_size 4627] by construction, not a repeated bytes list. The entire qrysm
module (every non-test package) builds clean with the change.
4.1 Toolchain reproduction (no Bazel)¶
The repo's codegen is Bazel-driven (protoc-gen-go-cast + fastssz sszgen), and Bazel is unavailable
here. Both generators were reproduced standalone:
pb.go: the repo's committed files areprotoc-gen-go v1.36.5, but the go.mod-pinnedprotoc-gen-go-cast(v0.0.0-20230228) bundles an oldinternal_gengo(v1.26.0) whose output a modernprotoc(libprotoc 34.1) makesformat.Sourcereject on the larger files. Fix: rebuilt the cast plugin againstgoogle.golang.org/protobuf v1.36.5(it importsinternal_gengoas a regular package), which regenerates the two smallattestation.pb.gofaithfully (v1.36.5 style + thessz-size:"4627"tag, single field +GetSignature). For the two largebeacon_block.pb.go(which still tripped the plugin'sformat.Source), the change is surgically spliced: start from the committed cast-correct file, swap theIndexedAttestationfield + getter, and replace the embeddedrawDescliteral with one from a cleanprotoc --go_outregen of the edited proto (the rawDesc is a self-contained serializedFileDescriptorProto; field/dep indices are unchanged because only a scalarbytesfield's cardinality changed). Verified:proto.Marshal/Unmarshalround-trips at runtime (the spliced descriptor matches the struct — seeTestPhase2_SSZAndProtoRoundTrip).generated.ssz.go: regenerated with go-installedsszgen(from the pinned fastssz dep) over a temp dir of just the package's.pb.gofiles (sszgen chokes on the package'scontext-importing.pb.gw.gogateway files, which Bazel sandboxes out). Result: field (2) is now a fixed 4627-byte vector (buf[132:4759]), not a merkleized list with mixin — the attestation now has exactly ONE variable field (the bitlist). This changes the attestation hash-tree-root layout → a hard fork.
4.2 What changed (done)¶
- Proto (4 files:
attestation.proto+beacon_block.protoin bothqrysm/v1alpha1andqrl/v1):repeated bytes signatures = 3 [ssz_max 128, ssz_size "?,4627"]→bytes signature = 3 [ssz_size "4627"]. - IndexedAttestation mirrors the change;
ConvertToIndexed(attestation_utils.go) drops thelen(sigs)==len(indices)check, sorts the attesting indices, and carries the single aggregate. - Verification (
attestation_utils.go:VerifyIndexedAttestationSigs,signature.go:createAttestationSignatureBatch): the per-index parallel loop is replaced by ONE aggregate verify —pk*is reconstructed from the attesting validators' epoch-key-tree per-content keys via the new seamattestation.AggregateAttestationPubkey, message =ComputeSigningRoot(data, domain), ctx ="ZOND"(the hardcoded go-qrllib wallet domain constant — unchanged by the Zond→QRL 2.0 rename), oneml_dsa_87verify (the aggregate σ is a byte-exact ML-DSA-87 signature under pk). TheSignatureBatchnow carries one entry per attestation (σ vs pk), not one per attester. The resolver is nil by default so verification never silently accepts without the epoch key tree (the H4-full binding); deployments/tests wire it. - Aggregation (
aggregation/attestations/{attestations,maxcover}.go): the BLS-styleAggregatePair/max-cover signature-list splice is removed — finished ML-DSA-87 aggregates cannot be byte-merged. The bit/coverage selection is kept; the mergedSignatureis the common aggregate (dedup of identical σ), and merging distinct* aggregates returns the newErrMLADSADistinctAggregates(the union aggregate must come from the single-shot §7.7 combine over per-validator contributions, i.e.mladsaconsensus/mladsa.CombineFromPublic). - Conversions / API migrated:
cloners.go,proto/migration/v1alpha1_to_v1.go, thebeacon-chain/rpc/qrl/sharedJSON structs (signatures []string→signature string), validator client beacon-api helpers, slasher simulator, test utils — the whole module compiles.
4.3 Remaining (Phase 2 follow-on, design-level)¶
- H4-full: implement
attestation.AggregateAttestationPubkeyagainst the real beacon-state epoch key tree (the static validator registration pubkey carries onlyt1; the full per-contenttᵢlive in the epoch key tree). Until then the hot path refuses (honest, not silently-accepting). - Slashing: surround/double-vote detection keys on
(indices, data)— shape-unaffected — but the slasher's signature check uses the aggregate-verify path (now wired throughVerifyIndexedAttestationSigs). - H6: batch ML-ADSA verify + gossip rate-limiting (heavier than BLS). H3 size-cap is now structural (fixed 4627 B field).
- Test suite: the production code builds clean; the
_test.gosuite across the tree still uses the old.Signaturesfield and needs the same mechanical migration (and the aggregation tests need rewriting to the single-aggregate semantics —AggregatePairnow dedups/errors instead of splicing).
4.4 Backward compatibility + aggregate equivalence (mladsalegacy/)¶
ML-ADSA loses BLS's commutativity/free mergeability, and the Phase-2 wire change drops the per-attester
list. Two consequences are handled by the new mladsalegacy package so the transition is non-destructive:
- Legacy concatenated aggregates stay valid, in parallel.
mladsalegacy.VerifyLegacyConcatenatedreproduces the EXACT former upstream verification (extracted from the pristineupstream/qrysmattestation_utils.go:len(signatures)==len(pubKeys),messageHash=ComputeSigningRoot(data,domain), each per-attester ML-DSA-87sig.Verify(pubkey, root)), operating on a self-containedLegacyConcatenatedAttestation(the proto type itself no longer carries the list).UnmarshalLegacyAttestationSSZdecodes the OLD wire layout (fixed part 136, two offsets — bitlist + signature list), andDetectFormatdistinguishes legacy bytes (first offset == 136) from new single-aggregate bytes (first offset == 4759), so a node runs BOTH validators and routes historical/in-flight aggregates correctly. So a larger aggsig that came from concatenation (former work) is still validated. - Aggregate equivalence ("say the same thing"). Now that byte-canonicality no longer holds across
aggregation paths,
mladsalegacyprovides a statement-equivalence:SameStatement/EquivalentNew/EquivalentAcrossdecide whether two aggregates certify the same signer set (AggregationBits) over the same AttestationData (by SSZ hash-tree-root) — across legacy-concat and ML-ADSA forms.ByteIdenticalcaptures the STRONG case: becauseConsensusAggregateis deterministic (PRF nonces + sorted participants), identical (signer set, message) inputs yield a byte-identical σ* (dedup). Tests:TestLegacyConcatenated_VerifyAndDecode(verbatim verify + old-format decode/round-trip + tamper/len/domain rejection),TestDetectFormat_NewVsLegacy,TestEquivalence(cross-format equivalence, signer-set/data discrimination, byte-identity). See [[h5-legacy-and-equivalence]].
4.5 Order-independence, equivalence, partiality, and equivalence-class hardness¶
Four properties requested for the transition (all implemented + tested/proven):
- Order / collection-path independence (byte-identical). The §7.7 combine is built entirely on
commutative coefficient sums (
pk* = AggPkStar(Σ tᵢ),W* = Σ wᵢ,z* = Σ zᵢ) and Construction-F uses deterministic PRF nonces, so aggregating the SAME final component set in ANY input order (or any incremental collection order) yields a byte-identicalpk*andσ*. Two groups aggregating the same components independently with differently-ordered lists therefore always agree and cross-validate. TestTestAggregate_OrderInvariant(12 members × 11 permutations: reverse, rotations, seeded shuffles → identical bytes + mutually equivalent + valid). NOTE: this is input-order independence within ONE combine; it is distinct from BLS-style merging of finished aggregates, which ML-ADSA cannot do (the challengec̃*depends on the fullW*). - Aggregate equivalence (no byte-equality required).
mladsaconsensus.SignaturesEquivalentdecides whether two aggregates are EQUIVALENT = both valid signatures certifying the SAME statement (samepk*, which deterministically encodes the signer set, over the same message/ctx). ML-DSA-87 is not a unique-signature scheme, so this is the right general notion; it is the safety net for aggregates assembled by different parties/paths. Plusmladsalegacy.SameStatement/EquivalentAcrossfor the cross-format (legacy-concat ↔ ML-ADSA) statement equivalence. TestTestSignaturesEquivalent_Discrimination(same set ⇒ equivalent; different/partial set ⇒ differentpk*⇒ not equivalent; tampered ⇒ not equivalent). - Partial recognizable as partial.
AggregationBitsis the explicit, cryptographically-BOUND record of exactly who signed (pk*is reconstructed from precisely the epoch-tree keys the set bits select, andσ*verifies only under thatpk*— an aggregator cannot inflate the bits to pass a partial off as complete without those members' real contributions). Somladsaconsensus.IsPartial/IsComplete/Coverage/MeetsQuorumdecide partiality from the bits relative to committee size / quorum, and it is tamper-evident. TestTestPartialRecognizable. - Equivalence-class guessing is as hard as normal ML-DSA (machine-checked). For a fixed
(pk*, m)the equivalence class is{σ : verify pk* m σ}; it may hold >1 element.formal/ml_adsa_euf.ecnow provesequiv_class_guess_bound: producing ANY class member (un-queriedm, PoP-valid cohort) is — by definition of the class — exactly the MSUFCMA win, so it inherits the SAME tight boundadv_mlwe + Pr[STMSIS]. The multiplicity of valid signatures gives the adversary no advantage, and the bits of security equal ML-DSA-87 Cat 5 (pk*is a bona fide ML-DSA key — sum of MLWE samples is MLWE — andverifyis the unmodified FIPS-204 verifier). Lemmasin_equiv_classE,equiv_class_guess_eq_forge,equiv_class_guess_bound; whole formal suite green (19 classical EC + 5 quantum + 5 Coq = 29 artifacts).
4.6 Live local-net proof: aggregate-of-aggregates is order-independent (cmd/mladsa-hieragg)¶
A real, multi-process local net demonstrating — beyond a shadow of a doubt, no mocks/string-print fakes — that ML-ADSA "aggregate of aggregates" is order- and grouping-independent:
- Roles (separate OS processes, real crypto):
user(a real random ML-DSA key; publishes a real §7.7 commitmentw_ithen responsez_iover the real shared challenge),aggregator(performs a genuine HIERARCHICAL combine — sums each sub-group's responses into a partial sum viamladsa.CoeffSumL, then sums the partials in its assigned order, thenmladsa.CombineFromPublic), andjudge(trusts nothing: independently re-derivespk*+signed-message from the public commitments, byte-compares both aggregators'σ*/pk*, and RE-VERIFIES bothσ*with go-qrllib's nativeml_dsa_87.Verify; exits non-zero on any mismatch). - Result. Two aggregators with DIFFERENT partitions and DIFFERENT orders (Alice
[[1,2,3],[4,5],[6,7,8]]/0,1,2; Bob[[6,7],[8,1,2],[3,4,5]]/2,1,0) produce the BYTE-IDENTICALσ*andpk*, both go-qrllib-native-verified — judge exit 0. - Negative controls (prove the judge has teeth). (a) a byzantine aggregator that DROPS a signer →
the combine fails its FIPS-204 bounds (defense in depth) → rejected; (b) one that publishes a
TAMPERED
σ*→ the judge's independent native re-verification fails + byte-mismatch → rejected. Both exit non-zero. - Why this is the only sound "aggregate of aggregates": finished aggregates cannot be merged (the
Fiat-Shamir challenge
c̃*depends on the fullΣ wᵢ); hierarchical aggregation is done on the CONTRIBUTIONS via associative/commutative mod-q coefficient summation, hence byte-identical regardless of grouping/order. Run:zsh cmd/mladsa-hieragg/run-hieragg.sh(positive + both negatives, asserted).
4.6b H4-full: AggregateAttestationPubkey wired to the real epoch key tree (DONE)¶
The verification seam attestation.AggregateAttestationPubkey is now backed by an authenticated
epoch-key-tree resolver (mladsaconsensus/epochtree.go), closing the H4-full gap (a static validator
registration pubkey only carries t1; the full per-content tᵢ live in the signed epoch tree).
- Predictable content label. The per-content KEY refresh is keyed by
AttestationContentLabel(data) = (slot, committee_index)— predictable at epoch start, so the epoch tree can pre-commit it — while the Fiat-Shamir message μ binds the full signing root.mladsa.ConsensusAggregateLabeledseparates the key label from the signed message;AggregateAttestationnow uses it (one-time per (validator, slot, committee), so the deterministic nonce is never reused — §4.7). EpochKeyTreeStore+Resolve. Maps validator index → its signed epoch commitment (root +SigReg+ per-label key + Merkle path). For each attester the resolver enforces: (1) the epoch root is authentic —mladsa.VerifyEpochRoot(signed by the registration key); (2) the committed registration key matches the beacon-state pubkey (defense in depth); (3) the key is a proven member of that root for the content label —mladsa.VerifyContentInTree(F-C9); plus a coeff-range gate. Only thenpk* = AggPkStar(rho, Σ tᵢ).store.Install()activates it across the consensus verify path.- Test
TestEpochKeyTreeResolver_EndToEnd: real epoch trees built + committed; the aggregate verifies throughVerifyIndexedAttestationSigswith pk from the authenticated tree; four negative controls rejected* — uncommitted/injected key (membership fails), forged epoch root (authenticity fails), regpk mismatch vs beacon state, and an attester with no commitment.
This means an injected or equivocating tᵢ cannot shift pk* (the original H4 risk): only
epoch-tree-committed, registration-authenticated keys enter the aggregate key.
Epoch-boundary population from beacon state (mladsaconsensus/epochpopulate.go, DONE). The store is
populated at each epoch boundary directly from the REAL state.ReadOnlyBeaconState:
BuildEpochKeyTreeStore iterates the active validator set (ReadFromEveryValidator +
helpers.IsActiveValidatorUsingTrie), reads each validator's on-chain registration pubkey
(val.PublicKey()), pulls that validator's published epoch commitment (root + σ_reg + per-content
keys + Merkle paths) from an EpochKeyProvider (the p2p/gossip cache), and authenticates the root
against the on-chain pubkey via mladsa.VerifyEpochRoot before recording it. InstallEpochKeyTreeStore
builds + installs it and returns the EXCLUDED validators (those with a missing/unauthentic commitment —
they simply can't contribute to any pk*). Key design point: no new beacon-state field is required —
the on-chain registration pubkey is the trust anchor, and σ_reg binds the (off-chain, p2p-published)
epoch-key root to that on-chain identity (§7.4). Test TestPopulateFromBeaconState_EndToEnd constructs a
real BeaconStateZond whose validators' pubkeys are the members' registration keys, populates from it,
verifies a real aggregate through the consensus path using the state-resolved pk*, and confirms an
uncommitted validator is excluded (and that including it fails verification).
Production glue + live proof (mladsaconsensus/epochcache.go, cmd/mladsa-epochnet, DONE). The
remaining pieces are now concrete and proven end to end:
- Publish: PublishEpochKeyBundle builds a validator's epoch tree and returns the gossip
EpochKeyBundle (root + σ_reg + per-content keys + Merkle paths).
- Validating gossip cache: GossipEpochKeyCache implements EpochKeyProvider; Ingest REJECTS
(never caches) a bundle whose root isn't signed by the claimed reg key or any of whose content keys
isn't a committed member of the root — the gossip-validation gate.
- Epoch hook: the node calls InstallEpochKeyTreeStore(ctx, state, rho, epoch, cache, labels) at the
boundary (binding each cached commitment to the on-chain reg pubkey again).
- Integration test TestGlue_PublishIngestRefreshVerify: publish → validating ingest (with forged-root
and tampered-key ingest rejections) → real beacon state → refresh → aggregate → verify.
- Live multi-process cmd/mladsa-epochnet (run-epochnet.sh): N validator processes publish bundles
+ contributions; a node process ingests/validates into the cache, builds a real BeaconStateZond from
the published reg keys, installs the resolver, combines the decentralized aggregate, and verifies via
attestation.VerifyIndexedAttestationSigs (pk from the epoch tree) AND go-qrllib native — node exit
code is the verdict. Proven live:* all-honest → verified (exit 0); with a forged validator → its
bundle is rejected at ingest and it is excluded, and the honest set STILL produces a valid verified
aggregate (resilience). The end-to-end trust chain runs unbroken: on-chain reg pubkey → VerifyEpochRoot
→ VerifyContentInTree → AggPkStar → FIPS-204/go-qrllib verify.
4.7 Determinism, nonces, and equivalence-class hardness in BOTH ROM and QROM¶
The byte-identity of §4.6 comes from deterministic per-content nonces (DeriveNonce(seed, C, σ), a PRF),
not randomness. This is the modern defensive default (RFC 6979 deterministic ECDSA, EdDSA, FIPS-204
ML-DSA itself derive the nonce from the key+message) — it eliminates the single biggest real-world
signature failure mode (biased/backdoored RNG leaking the key). The one inherent risk of determinism —
the same nonce under two different challenges yields z−z' = (c−c')·s1 ⇒ key recovery — is closed by
three layers, the core machine-checked:
- Per-content one-time release (N1/N2 + audit M2): each validator emits exactly one
z_iper content label; the restart-safe fsync'dDurableOneTimeGuardrefuses a second release, so a malicious aggregator presenting a different participant subset (hence a differentc̃*) cannot extract a secondz_ifrom the samey_i. - Commitment-bound unbiasable challenge (ROS defense — proven):
formal/ml_adsa_F_concurrent.ecproves the aggregate challenge is independent of the adversary's nonce (unbiasable_challenge,challenge_adversary_independent), so the Drijvers/ROS linear system is unsolvable and concurrent EUF-CMA collapses to the single-session bound — no ROS/AGM/OMDL assumption. - Equivocation is slashable (and self-punishing: it is the nonce reuse that leaks the key).
Operational rule this imposes: one content label = one canonical participant set = one challenge =
one response per validator. This is exactly why §4.6's two aggregators used the SAME signer set (same
c̃*, each validator releases z_i once) — order/grouping differed, the challenge did not. Producing
two different-subset aggregates of the same content from the same validators is correctly impossible
(the one-time guard refuses the second response).
Equivalence-class guessing = ML-DSA hardness, ROM and QROM. Because ML-DSA-87 is not a
unique-signature scheme, the set class(pk*, m) = {σ : verify pk* m σ} may hold >1 element; producing
ANY member without the secret is, by definition, the EUF-CMA win, so it inherits the tight bound with no
advantage from multiplicity. Machine-checked in both models, per the project's ROM+QROM goal:
ml_adsa_euf.ec (equiv_class_guess_bound ≤ adv_mlwe + Pr[STMSIS]) and ml_adsa_qrom.ec
(qrom_equiv_class_uncond, quantum adversary, ≤ adv_mlwe + Pr[QROM_STMSIS(BqCMA)]). Bits = ML-DSA-87
Cat 5 (pk* is a bona fide ML-DSA key — sum of MLWE samples is MLWE — and verify is the unmodified
FIPS-204 verifier). Residuals: deterministic signatures are more exposed to fault/side-channel
attacks (shared with EdDSA; mitigated by constant-time + fault countermeasures, not a protocol vuln),
and the whole argument rests on the one-time guard being enforced at every validator. See
[[deterministic-nonce-security]].
4.8 Future option (documented, NOT implemented): subnet randomness via drand¶
If a future variant ever needs true randomness (e.g. a randomized-nonce mode, or a beacon for committee selection), one approach: a drand-style threshold randomness beacon run as a validator subnet, with the per-round randomness encrypted between subnet members (so third parties cannot learn the values) and the recent series encrypted at rest. Trade-off to weigh: losing the stored series could itself create risks (e.g. inability to reproduce/verify past rounds, or — if randomness fed nonces — the same nonce-reuse danger determinism avoids). Deliberately left as an idea for now; the current design needs no external randomness (deterministic nonces, §4.7).
5. Status & cross-references¶
- Implemented (Phase 1 + Phase 2):
mladsa/consensus.go,mladsaconsensus/(aggregate + verify on the realAttestation), the proto/SSZ shrink (single fixed 4627 Bsignature), and the consumer rewrites (verification, aggregation, conversions). Whole non-test module builds clean. Tests green:TestPhase2_SSZAndProtoRoundTrip(SSZ marshal/unmarshal + HashTreeRoot stability +proto.Marshalround-trip + IndexedAttestation SSZ),TestVerifyIndexedAttestationSigs_MLADSA(the rewritten consensus verify accepts a real §7.7 aggregate via the pk resolver against the real go-qrllib crypto, and rejects nil-resolver / wrong-pk / tampered-sig), plus the Phase-1 compression/tamper/sybil/empty tests. - Remaining: H4-full beacon-state wiring / test-suite migration (the epoch-tree pk* resolver
itself is DONE — see §4.6b), H6 (batch-verify/rate-limit), slasher
signature-path validation under load, and the
_test.gosuite migration. - Related: docs/17 (§7.7 combine), docs/23 (QRL 2.0 deployment), docs/26 (audit; H4/H5 rows), docs/27
(3-surface code spec),
mladsa/decentralized.go(CombineFromPublic),cmd/mladsa-devnet(live decentralized consensus devnet).
Bottom line: the wire is now honestly single by construction — one constant-size 4627 B ML-DSA-87
aggregate replaces the per-attester list across proto, SSZ (new hash-tree-root layout), verification, and
aggregation; the whole module builds; the aggregate verifies through the real qrysm crypto; and
fakes/sybils/tampering are rejected. The compression is 12–128× with no lost needed information (signer
set = AggregationBits, key = Σ epoch-tree tᵢ, validity = one FIPS-204 verify). What remains is the
epoch-tree pk* wiring (H4-full), batch-verify (H6), and migrating the test suite.