Skip to content

spec(frost/roast): normative coordinator-seed derivation (RFC-21 Annex A) + cross-language conformance vectors#4030

Merged
mswilkison merged 3 commits into
feat/frost-schnorr-migration-scaffoldfrom
unify/roast-coordinator-seed-go-2026-06-11
Jun 11, 2026
Merged

spec(frost/roast): normative coordinator-seed derivation (RFC-21 Annex A) + cross-language conformance vectors#4030
mswilkison merged 3 commits into
feat/frost-schnorr-migration-scaffoldfrom
unify/roast-coordinator-seed-go-2026-06-11

Conversation

@mswilkison

Copy link
Copy Markdown
Contributor

Stacked on #3866 (base: feat/frost-schnorr-migration-scaffold). Implements item 3 of the review feedback (duplicated, divergent protocol constants) — Go half; the Rust half is the paired PR stacked on #4005.

Problem

The coordinator-shuffle seed derivation exists twice, in two languages, on two branches, with no single source of truth — and the two copies disagree (flagged in #4026):

seed attempt numbering
Go RFC-21 layer fold(SHA256(KeyGroup ‖ SessionID ‖ MessageDigest)) 0-based
Rust engine validation int64_be(MessageDigest[0..8]) (legacy signingAttemptSeed convention) 1-based wire

At Phase-7 wiring, every Go-derived attempt context would fail the Rust engine's strict-mode validation — a network-fracturing liveness failure that property tests on either side cannot catch.

What this PR does (Go half)

  1. RFC-21 Annex A (normative) — single normative definition of the derivation: inputs (including the exact KeyGroupBytes definition for FrostTBTCSignerV1 material — the UTF-8 bytes of the hex key-group handle, treated opaquely), the 0-based composition with the two's-complement-wrapping addition, the wire = AttemptNumber + 1 FFI mapping, and the accepted non-goals (unframed concatenation, first-8-byte fold, grindability bounds) with rationale. The Go derivation is adopted as normative: it binds key group + session + digest rather than the digest alone, and the live pkg/tbtc signing loop's legacy convention is explicitly documented as the thing Phase 7 migrates from.

  2. Generated conformance vectorspkg/frost/roast/testdata/coordinator_seed_vectors.json: ten end-to-end vectors (folded seed int64 + selected coordinator) covering attempts 0/1/3/5/7, sparse and production-size (n=100) member sets, opaque key-group handles, and negative folded seeds. Regenerated from the deterministic input matrix via ROAST_SEED_VECTORS_REGEN=1 go test -run TestRegenerateCoordinatorSeedVectors — generation-from-spec rather than hand-pinning, per the review.

  3. Conformance testTestCoordinatorSeedDerivation_ConformanceVectors pins DeriveAttemptSeed → foldAttemptSeed → SelectCoordinator end to end against the file, asserts the wire-mapping invariant on every vector, and requires at least one negative-seed pin so an unsigned-integer port cannot pass.

The paired Rust PR switches the engine to this derivation (subtracting 1 from the wire attempt number before composition) and consumes a byte-identical copy of the vector file, so either side drifting fails its own CI rather than fracturing coordinator agreement in a mixed deployment.

No behavior change on the Go side — it was already normative-conformant; this PR makes that the specified behavior and pins it.

Tests

go test ./pkg/frost/... passes; vectors verified present with 7 negative-seed pins out of 10.

🤖 Generated with Claude Code

…s-language conformance vectors

The coordinator-shuffle seed existed in two divergent implementations:
the Go RFC-21 layer derives fold(SHA256(KeyGroup || SessionID ||
MessageDigest)) with 0-based attempt numbers, while the Rust signer's
attempt-context validation used the first 8 bytes of the raw message
digest with 1-based wire attempts (the legacy signingAttemptSeed
convention). Flagged in #4026; at Phase-7 wiring every Go-derived
attempt context would fail Rust-side validation.

This PR makes the Go derivation normative and durable:

- RFC-21 Annex A (normative) specifies the derivation, the
  KeyGroupBytes definition for FrostTBTCSignerV1 material, the
  0-based/1-based wire mapping (wire = AttemptNumber + 1), wrapping
  semantics, and the accepted non-goals (unframed concatenation,
  first-8-bytes fold, grindability bounds) with rationale.
- pkg/frost/roast/testdata/coordinator_seed_vectors.json pins ten
  end-to-end vectors (seed int64 + selected coordinator), covering
  attempt 0/1/5/3/7, sparse and production-size (n=100) member sets,
  opaque key-group handles, and negative folded seeds. The file is
  regenerated from the Go implementation via
  ROAST_SEED_VECTORS_REGEN=1 (TestRegenerateCoordinatorSeedVectors),
  so the vectors provably come from the spec'd input matrix.
- TestCoordinatorSeedDerivation_ConformanceVectors consumes the file
  and pins DeriveAttemptSeed -> foldAttemptSeed -> SelectCoordinator
  end to end, including the wire-mapping invariant and at least one
  negative-seed pin so an unsigned port cannot pass.

The Rust signer adopts the same derivation and a byte-identical
vector copy in the paired PR stacked on #4005.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 61e65c6f-6adc-45b9-96b0-27e8bcfa8909

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch unify/roast-coordinator-seed-go-2026-06-11

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

…not a transcript hash

Review on the paired Rust PR caught the engine seeding the shuffle
from its internal SHA256(message) transcript digest while the Go layer
seeds from messageDigestFromBigInt(request.Message) -- the padded
32-byte message itself. Make the annex unambiguous: MessageDigest is
the raw signing message big-endian left-padded to 32 bytes (>32
significant bytes rejected), never a transcript hash of it; the
engine's internal digest feeds only round_id/attempt_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mswilkison

Copy link
Copy Markdown
Contributor Author

Annex A amended (latest commit): MessageDigest is now specified unambiguously as the raw signing message big-endian left-padded to 32 bytes (keep-core's messageDigestFromBigInt output; >32 significant bytes rejected) — never a transcript hash of the message. This closes the input-layer ambiguity behind the divergence Codex caught on the paired Rust PR #4031, where the engine seeded from its internal SHA256(message) transcript digest. The engine-side contract is pinned by start_sign_round_accepts_go_derived_attempt_context_in_strict_mode on #4031.

… event

A regen run that produces different bytes means the normative Annex A
derivation changed; it requires an annex update in the same change and
a mixed-fleet rollout note. Both language suites passing after a dual
regen is not evidence of compatibility with deployed engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mswilkison added a commit that referenced this pull request Jun 11, 2026
…+ cross-language conformance vectors (#4031)

Stacked on #4005 (base: `extraction/frost-signer-mirror-2026-05-26`).
Implements item 3 of the review feedback (duplicated, divergent protocol
constants) — Rust half; pairs with the Go-side PR #4030 stacked on
#3866.

## Problem

Flagged in #4026: the engine validated attempt contexts using
`int64_be(MessageDigest[0..8])` with the 1-based wire attempt number
(the legacy `signingAttemptSeed` convention), while the Go RFC-21 layer
derives `fold(SHA256(KeyGroup ‖ SessionID ‖ MessageDigest))` with
0-based attempt numbers. At Phase-7 wiring, every Go-derived attempt
context would fail the engine's strict-mode `validate_attempt_context` —
a deterministic, network-wide liveness failure invisible to either
side's property tests.

## What changed

- **`roast_attempt_shuffle_seed(key_group, session_id,
message_digest_hex)`** implements the normative RFC-21 Annex A
derivation (see #4030). The key-group handle — this engine's hex-encoded
serialized group verifying key — feeds the hash as an opaque UTF-8
string, exactly matching keep-core's `attempt.DeriveAttemptSeed` +
`foldAttemptSeed` composition, including the strict 32-byte digest
requirement.
- **`validate_attempt_context` now takes the session's key group**
(threaded from `dkg.key_group` at StartSignRound and the session's
`DkgResult` at FinalizeSignRound) and composes the shuffle source with
the **0-based** RFC-21 attempt number. The FFI wire encoding stays
1-based (`attempt_number >= 1` still enforced; `wire = AttemptNumber +
1`); the engine subtracts one before composition, per the annex.
- **`testdata/coordinator_seed_vectors.json`** — byte-identical copy of
the canonical file generated from the Go implementation.
`coordinator_seed_derivation_matches_cross_language_vectors` pins, for
all ten vectors: the folded seed (including negative values, so an
unsigned port cannot pass), the selected coordinator (including the
n=100 production-shape set), the 0-/1-based wire mapping, and end-to-end
strict-mode `validate_attempt_context` acceptance of a context built
from the wire encoding. Either language drifting now fails its own unit
suite.
- **`docs/roast-coordinator-seed-derivation.md`** mirrors the normative
annex for signer-side readers, with the regen/copy procedure.
- The coordinator-mismatch test derives the provably-wrong coordinator
instead of hardcoding member 1 (which, under the new seed, happened to
become the correct selection — exactly the class of silent assumption
these vectors exist to catch).

## Notes

- Mixed-version note: engines on the old derivation reject contexts
produced under the new one (and vice versa) — strict-mode attempt
contexts are not yet produced by the Go layer in any deployment, so this
is pre-wiring cleanup with no live-fleet impact.
- The attempt-context vector suite (`roast-attempt-context-v1.json`) is
unaffected: it pins fingerprint/attempt-id domains with the coordinator
as an *input*.
- Port back to the tBTC monorepo signer alongside the next extraction
sync.

## Tests

Full suite: 245 passed, 0 failed; clippy and rustfmt clean. New
conformance test exercises all ten cross-language vectors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@mswilkison mswilkison merged commit 09f61d3 into feat/frost-schnorr-migration-scaffold Jun 11, 2026
16 checks passed
@mswilkison mswilkison deleted the unify/roast-coordinator-seed-go-2026-06-11 branch June 11, 2026 23:11
mswilkison added a commit that referenced this pull request Jun 11, 2026
…e coordinator shuffle (#4034)

Stacked on #3866 (base: `feat/frost-schnorr-migration-scaffold`).
Implements the review item "widen the Go↔Rust math/rand parity from a
handful of pinned vectors to corpus-based differential fuzzing" — Go
half; the Rust consumer is the paired PR stacked on #4005.

## What

A generated 600-case differential corpus over `SelectCoordinator`
(`testdata/coordinator_shuffle_corpus.json`, 176 KB), replayed by
`TestCoordinatorShuffle_DifferentialCorpus` here and by the identical
byte-for-byte copy in the Rust signer's `go_math_rand` tests:

- **216 boundary cases**: seeds {0, ±1, `i64::MIN/MAX`, `MIN+3`/`MAX−3`,
the #4026 pin seed and its negation} × attempts {0, 1, 7, `u32::MAX`} —
exercising the two's-complement wrapping `seed + attempt` composition —
× six member sets including unsorted and reversed inputs (pinning the
internal sort both implementations perform).
- **384 generated cases**: fixed-seed generator sweeping set sizes
1..255 (the full `group.MemberIndex` range), full-range `int64` seeds,
and small/large/extreme attempt numbers.

Regeneration is deterministic and gated
(`ROAST_SHUFFLE_CORPUS_REGEN=1`), so the corpus provably comes from the
documented case matrix rather than hand-pinning.

This complements #4030's Annex-A seed-derivation vectors: those pin the
*derivation* end-to-end on 10 vectors; this corpus stress-pins the
*shuffle port itself* — the actual cross-language landmine — at volume,
including the integer-boundary regions where a port diverges first.

Not full continuous fuzzing (no coverage-guided harness); it's the
pragmatic corpus-differential version that rides the existing unit-test
CI on both sides at negligible cost. A coverage-guided Go-oracle harness
can layer on later if desired.

## Tests

`go test ./pkg/frost/roast/...` passes (corpus replay + regeneration
roundtrip verified).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
mswilkison added a commit that referenced this pull request Jun 11, 2026
…chain timeouts (#4032)

Stacked on #4030 (base: `unify/roast-coordinator-seed-go-2026-06-11`),
which is itself stacked on #3866 — so the annex numbering (A: seed
derivation, B: latency budget) lands in order. Implements item 4 of the
review feedback (serial attempts vs real ROAST concurrency: "it should
be a computed bound in the docs").

## What

Adds **RFC-21 Annex B (informative): serial-attempt latency budget vs
on-chain timeouts** — the computed bound with current code and chain
values, and an honest statement of which constraint actually binds:

- **Parameters**: 41 blocks (≈8.2 min) per attempt
(`signingAttemptMaximumBlocks`), `signingAttemptsLimit = 5`, engine
ROAST coordinator timeout 30 s (sub-dominant), n=100/t=51/f=49,
`redemptionTimeout` 5 days, moving-funds timeouts 7 days.
- **Serial-delay arithmetic**: as deployed, ≤ ~41 min before the loop
gives up (~175× inside the redemption timeout); the review's f·τ worst
case at a hypothetical f+1 = 50-attempt limit is ~6.8 h (~17× inside).
Serial latency comfortably fits the deadlines whenever attempts can
succeed at all.
- **The honest caveat**: the binding liveness constraint is
**all-honest-subset sampling**, not serial latency. Because the
transitional finalize requires every included member to contribute,
per-attempt success is `∏(49−i)/(100−i)` — 0.49 / 0.24 / 0.11 / 0.025
for f = 1/2/3/5 — so for f ≥ 3 the 5-attempt loop fails with
better-than-even odds long before any timeout is approached. The
existing `signingAttemptsLimit` rationale in `pkg/tbtc/node.go`
explicitly assumes f ≤ 2; beyond that the backstops are
operator-inactivity claims, redemption-timeout slashing, and wallet
retirement — all outside the signing loop. (This profile is inherited
from the tECDSA-era loop, not introduced by FROST.)
- **Codified recommendation**: before the ECDSA-retirement phases, adopt
t-of-included finalize (first t responsive members — groundwork already
in the signer's `true-late-t-of-n-finalize-considerations.md`) at
minimum for redemption signings; treat bounded `n−t+1` concurrency as
the follow-on; until then alert when observed attempt-failure rates
imply f ≥ 3 behaviour.

Docs-only; no code change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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