Skip to content

feat(frost/roast): RFC-21 Phase 3.3 -- aggregation + bundle verification#3970

Merged
mswilkison merged 1 commit into
feat/frost-schnorr-migration-scaffoldfrom
feat/frost-roast-bundle-aggregation-2026-05-22
May 23, 2026
Merged

feat(frost/roast): RFC-21 Phase 3.3 -- aggregation + bundle verification#3970
mswilkison merged 1 commit into
feat/frost-schnorr-migration-scaffoldfrom
feat/frost-roast-bundle-aggregation-2026-05-22

Conversation

@mswilkison
Copy link
Copy Markdown
Contributor

Summary

Third Phase-3 implementation PR. Adds the methods that drive the
ROAST coordinator-aggregation flow defined in RFC-21's Resolved
Decisions
section:

Method Role
`RecordEvidence(handle, snap)` Accept a peer's signed `LocalEvidenceSnapshot`. Validates structure, verifies the operator signature, checks the snapshot's `AttemptContextHash` matches the handle's bound context, applies first-write-wins / equal-or-reject.
`AggregateBundle(handle)` Called by the elected coordinator. Sorts accumulated snapshots ascending by `SenderID`, builds the `TransitionMessage`, signs the canonical bundle bytes, transitions state to `Transitioned`.
`VerifyBundle(handle, msg)` Called by every receiver. Verifies coordinator signature, every snapshot's operator signature, and -- if the receiver has submitted its own snapshot -- presence of that snapshot in the bundle (censorship detection).

Stacked on #3969 (Phase 3.2).

What's new

`pkg/frost/roast/signature.go`

  • `Signer` / `SignatureVerifier` interfaces (Phase 4 wires them to `pkg/net`'s operator-key + member-keys surfaces).
  • `NoOpSigner` / `NoOpSignatureVerifier` for tests that don't exercise the crypto pipeline.
  • `CanonicalSnapshotBytes` -- JSON of snapshot fields excluding `OperatorSignature`.
  • `CanonicalBundleBytes` -- JSON of bundle fields excluding `CoordinatorSignature` but including 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.
  • Sentinels: `ErrSignatureInvalid`, `ErrSignatureMissing`, `ErrCensorshipDetected`.

`pkg/frost/roast/coordinator_state.go` (extended)

  • `Coordinator` interface gains `RecordEvidence`, `AggregateBundle`, `VerifyBundle`.
  • `NewInMemoryCoordinatorWithSigning(selfMember, signer, verifier)` -- production constructor (Phase 4 callers).
  • `NewInMemoryCoordinator()` preserved as a Phase-3.1-compatible convenience that uses no-op signing.
  • New sentinels: `ErrNotAggregator`, `ErrAttemptStateInvalid`, `ErrAttemptContextMismatch`, `ErrSnapshotConflict`.

`pkg/frost/roast/transition_message.go` (touched)

  • `validate()` methods promoted to public `Validate()` on both `LocalEvidenceSnapshot` and `TransitionMessage` so callers that construct messages in memory can validate without a marshal/unmarshal round-trip.

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)

  • `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.

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

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

PR Scope State
3.1 (#3968) Coordinator skeleton + seed bridge open
3.2 (#3969) TransitionMessage + LocalEvidenceSnapshot open
3.3 (this) Aggregation + bundle verification open
3.4 NextAttempt policy + thresholds next

Test plan

  • CI green.
  • Reviewer confirms the censorship-detection semantics are
    acceptable (specifically that we reject the bundle on missing
    self-snapshot rather than accept it and let silence-parking
    catch it).
  • Reviewer confirms the canonical-encoding contract (snapshot
    omits its own signature; bundle includes snapshot signatures but
    omits its own).

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).
Base automatically changed from feat/frost-roast-transition-message-2026-05-22 to feat/frost-schnorr-migration-scaffold May 23, 2026 01:07
@mswilkison mswilkison merged commit 786cead into feat/frost-schnorr-migration-scaffold May 23, 2026
15 checks passed
@mswilkison mswilkison deleted the feat/frost-roast-bundle-aggregation-2026-05-22 branch May 23, 2026 01:07
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant