feat(frost/roast): RFC-21 Phase 3.3 -- aggregation + bundle verification#3970
Merged
mswilkison merged 1 commit intoMay 23, 2026
Conversation
Extends the Coordinator interface with the three methods that drive the ROAST coordinator-aggregation flow defined in RFC-21's Resolved Decisions section: * RecordEvidence(handle, snapshot) -- accept a peer's signed LocalEvidenceSnapshot. Validates structure, verifies the operator signature via the configured SignatureVerifier, checks the snapshot's AttemptContextHash matches the handle's bound context, and applies first-write-wins / equal-or-reject semantics. The self-submission is tracked separately so VerifyBundle can later detect coordinator censorship. * AggregateBundle(handle) -- called by the elected coordinator's node. Sorts the accumulated snapshots ascending by SenderID, builds the TransitionMessage, signs the canonical bundle bytes with the local Signer, and transitions the attempt state through Aggregating to Transitioned. Returns ErrNotAggregator when the caller's selfMember is not the elected coordinator. * VerifyBundle(handle, msg) -- called by every receiver. Verifies the bundle's coordinator signature against the attempt's elected coordinator, verifies each contained snapshot's operator signature, and -- when the receiver has already submitted its own snapshot -- verifies that snapshot is present and byte-identical in the bundle. Returns ErrCensorshipDetected when an honest receiver's evidence has been dropped or mutated by the coordinator. Supporting surface (pkg/frost/roast/signature.go): * Signer interface (Sign payload -> sig) -- Phase 4 will wire to pkg/net's operator-key signing. * SignatureVerifier interface (Verify payload, sig, member) -- Phase 4 will wire to pkg/net's member-keys table. * NoOpSigner / NoOpSignatureVerifier for tests that don't exercise the crypto pipeline; preserve the Phase-3.1 NewInMemoryCoordinator convenience constructor. * CanonicalSnapshotBytes / CanonicalBundleBytes -- deterministic JSON encodings the signatures cover. The snapshot encoding omits OperatorSignature; the bundle encoding includes every snapshot's OperatorSignature so the coordinator's signature attests to the exact assembled set. * verifySnapshotSignature / verifyBundleSignature / verifyOwnObservationsPresent -- the receiver-side checks, each testable in isolation. Sentinel errors: ErrNotAggregator, ErrAttemptStateInvalid, ErrAttemptContextMismatch, ErrSnapshotConflict, ErrSignatureInvalid, ErrSignatureMissing, ErrCensorshipDetected. Constructor changes: * NewInMemoryCoordinator() preserves the Phase-3.1 signature; it internally calls NewInMemoryCoordinatorWithSigning(0, NoOpSigner, NoOpSignatureVerifier). The selfMember=0 sentinel disables the censorship-detection check (the caller has no submitted snapshot to verify presence of). * NewInMemoryCoordinatorWithSigning(selfMember, signer, verifier) is the production constructor for Phase 4. The Phase 1B-style validate() methods on LocalEvidenceSnapshot and TransitionMessage are promoted to public Validate() so callers that construct messages in memory can validate without a marshal/unmarshal round-trip. Tests (24 new cases across signature_test.go and bundle_aggregation_test.go): Signature pipeline: * NoOpSigner returns empty; NoOpVerifier accepts everything; NoOp pair is concurrency-safe under 32x32 goroutines. * CanonicalSnapshotBytes excludes OperatorSignature. * CanonicalBundleBytes excludes CoordinatorSignature but includes every snapshot's OperatorSignature. * fakeSigner / fakeVerifier deterministic round-trip with SHA256(memberID || payload). * Tampered-payload rejection. * Coordinator-mismatch rejection. * Censorship-detection helper for missing snapshot and mutated signature; skip semantics for selfMember == 0 and no selfSubmission. Aggregation and verification: * RecordEvidence: nil rejection, unknown handle, context hash mismatch, bad signature, valid-and-idempotent re-submission, conflict rejection, self-submission tracking. * AggregateBundle: non-aggregator rejection, signed bundle build (size, ordering, signature, terminal state), deterministic bundle JSON across different record orderings. * VerifyBundle: valid acceptance, censorship detection, coordinator- signature forgery, snapshot-signature forgery, attempt-context mismatch, nil message, unknown attempt, concurrent record-and-aggregate safety. All pass under: go test ./pkg/frost/roast/..., go test -race ./pkg/frost/roast/..., go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/..., staticcheck -checks '-SA1019' ./pkg/frost/roast/..., go vet ./pkg/frost/roast/..., gofmt -l ./pkg/frost/roast/. Stacked on Phase 3.2 (#3969).
3 tasks
Base automatically changed from
feat/frost-roast-transition-message-2026-05-22
to
feat/frost-schnorr-migration-scaffold
May 23, 2026 01:07
786cead
into
feat/frost-schnorr-migration-scaffold
15 checks passed
mswilkison
added a commit
that referenced
this pull request
May 23, 2026
#3971) ## Summary **Closes Phase 3 of RFC-21.** Adds the deterministic \`(AttemptContext, TransitionMessage) -> AttemptContext\` policy that makes ROAST-aware retry possible. Two honest signers fed the same previous context and the same verified bundle compute byte-identical next contexts -- the foundational invariant the coordinator-aggregation model exists to enforce. Stacked on #3970 (Phase 3.3). ## What lands ### Policy (RFC-21 Layer B) | Step | Logic | |---|---| | 1. Permanent exclusion (transport) | overflow count summed across bundle ≥ \`OverflowExclusionThreshold\` (constant = 4). | | 2. Permanent exclusion (validation) | reject events; no-op hook (reject category lands in a later phase). | | 3. Silence parking (strictly transient) | senders in prev IncludedSet not present in bundle, not now permanently excluded → moved to TransientlyParked for **one attempt only**. | | 4. Reinstatement | prev TransientlyParked members rejoin IncludedSet automatically. | | 5. Infeasibility | if next IncludedSet < threshold → \`ErrAttemptInfeasible\`. | ### AttemptContext: \`TransientlyParked\` field Parking metadata must flow between attempts because two honest coordinators computing the next attempt must agree on who is parked. Including \`TransientlyParked\` in the canonical hash binds the contract. The Phase 1A pinned-fixture test's inline reference encoder is updated in lockstep, so any future drift between the production encoder and the reference is still caught at code review. ### Why "strictly transient" This was the formal mitigation Gemini's Phase-3 design review asked for. A peer falsely labelled silent (network blip, coordinator censorship caught at \`VerifyBundle\`) is reinstated by the very next attempt without intervention. Permanent exclusion only follows from overflow or validation reject, neither of which can fire on a slow-but-honest peer. ### \`NextAttempt(handle, bundle, threshold, dkgGroupPublicKey)\` | Parameter | Why | |---|---| | \`handle\` | identifies the previous attempt (its IncludedSet + ExcludedSet + TransientlyParked) | | \`bundle\` | verified TransitionMessage (caller must call \`VerifyBundle\` first) | | \`threshold\` | FROST signing threshold \`t\` for the key group; constant across a session | | \`dkgGroupPublicKey\` | per RFC-21 Decision 2: extract from FFI signer material at attempt construction | Threshold = 0 disables the infeasibility check (test seam; never production). ## Test coverage 15 new cases in \`next_attempt_test.go\` covering: - No-evidence baseline (IncludedSet unchanged, AttemptNumber++) - Overflow at exact threshold triggers permanent exclusion - Overflow below threshold does not exclude - Silent member → \`TransientlyParked\`, not \`ExcludedSet\` - Previously parked → reinstated to \`IncludedSet\` - **Full park/reinstate cycle across N → N+1 → N+2** -- the defining test for "strictly transient" parking - Original signer set size preserved across transitions - Determinism: same inputs → same next-context hash - Infeasibility error when below threshold - Threshold = 0 disables check - Overflow summed across observers (not maxed) - Nil bundle rejected - Unknown handle returns \`ErrUnknownAttempt\` - \`OverflowExclusionThreshold\` constant matches RFC-21 spec ## Phase 3 status | PR | Scope | State | |---|---|---| | 3.1 (#3968) | Coordinator skeleton + seed bridge | open, Gemini-approved | | 3.2 (#3969) | TransitionMessage + LocalEvidenceSnapshot | open, Gemini-approved | | 3.3 (#3970) | Aggregation + bundle verification | open, Gemini-approved | | **3.4 (this)** | **NextAttempt policy + thresholds** | **open** | Phase 3 is now feature-complete. **Phase 4** wires receivers to a real coordinator instance behind the \`frost_roast_retry\` build tag. ## Verification | Command | Result | |---|---| | \`go build ./...\` | clean | | \`go test ./pkg/frost/roast/...\` | pass | | \`go test -race ./pkg/frost/roast/...\` | pass | | \`go test -tags 'frost_native frost_tbtc_signer' ./pkg/frost/...\` | pass (5 packages) | | \`staticcheck -checks '-SA1019' ./pkg/frost/roast/...\` | silent | | \`go vet ./pkg/frost/roast/...\` | clean | | \`gofmt -l ./pkg/frost/roast/\` | silent | ## Test plan - [ ] CI green. - [ ] Reviewer confirms the canonical-hash extension is acceptable (Phase 1B field is optional during Phases 1-5, so live peers running older code can still interop without consuming the hash). - [ ] Reviewer confirms the parking discipline as documented matches what RFC-21's Resolved Decisions section specifies.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Third Phase-3 implementation PR. Adds the methods that drive the
ROAST coordinator-aggregation flow defined in RFC-21's Resolved
Decisions section:
Stacked on #3969 (Phase 3.2).
What's new
`pkg/frost/roast/signature.go`
`pkg/frost/roast/coordinator_state.go` (extended)
`pkg/frost/roast/transition_message.go` (touched)
What's tested
`signature_test.go` (13 cases)
Signature interfaces, canonical encodings, signature verification round-trips (via a deterministic SHA-256 fake signer/verifier), tampered-payload rejection, coordinator-mismatch rejection, censorship-detection helper (missing snapshot, mutated signature, skip semantics).
`bundle_aggregation_test.go` (11 cases)
Verification
Why the censorship-detection check is what it is
A receiver that has submitted its own snapshot but is missing from
the bundle has two possible explanations: (1) the elected coordinator
maliciously dropped the snapshot, or (2) the bundle was assembled
before the receiver's submission arrived. In either case, feeding
the bundle into `NextAttempt` would penalise the receiver (via
silence-parking), so the bundle must be rejected pending re-broadcast
on the next attempt. `ErrCensorshipDetected` is the unambiguous
signal.
When the receiver has not yet submitted (selfMember == 0 or
selfSubmission == nil), the check is skipped: there is no submitted
snapshot whose presence to verify.
Phase 3 status
Test plan
acceptable (specifically that we reject the bundle on missing
self-snapshot rather than accept it and let silence-parking
catch it).
omits its own signature; bundle includes snapshot signatures but
omits its own).