Skip to content

test(frost/roast): pin cross-language coordinator selection vectors#4026

Merged
mswilkison merged 1 commit into
feat/frost-schnorr-migration-scaffoldfrom
test/roast-cross-language-coordinator-vectors
Jun 11, 2026
Merged

test(frost/roast): pin cross-language coordinator selection vectors#4026
mswilkison merged 1 commit into
feat/frost-schnorr-migration-scaffoldfrom
test/roast-cross-language-coordinator-vectors

Conversation

@mswilkison

@mswilkison mswilkison commented Jun 10, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3866 (base: feat/frost-schnorr-migration-scaffold).

What

Adds TestSelectCoordinator_CrossLanguagePinnedVectors to pkg/frost/roast/coordinator_test.go, pinning concrete SelectCoordinator outputs for fixed (members, seed, attempt) tuples.

Why

The Rust signer (PR #4005) ports Go's math/rand shuffle semantics in pkg/tbtc/signer/src/go_math_rand.rs and pins exact expected coordinators in select_coordinator_matches_known_keep_core_vectors (seed 6879463052285329321). The Go suite, however, only asserted properties (determinism, input-order independence, seed/attempt sensitivity) — never concrete outputs.

That asymmetry means a Go-side semantic change (e.g. migrating to math/rand/v2, changing the shuffle, or altering the attemptSeed + attemptNumber composition) would pass the entire Go suite while silently breaking coordinator agreement with the Rust engine — a network-fracturing liveness failure that would only surface in mixed-version soak testing.

This PR pins the exact same vectors as the Rust test (verified locally that current Go code produces them), plus pins the previously value-free (seed=333, attempt=4) case to its concrete result. Either side drifting now fails its own unit suite.

Review note (no code change)

While verifying parity I noticed the two layers derive the legacy int64 shuffle seed differently today:

  • Go RFC-21 layer: foldAttemptSeed(SHA256(DkgGroupPublicKey || SessionID || MessageDigest)) (first 8 bytes, BE), 0-based AttemptNumber.
  • Rust engine strict-mode validation (roast_attempt_seed_from_message_digest_hex): first 8 bytes of the raw message digest, with a 1-based attempt_number.

Not a live bug — keep-core does not yet send attempt_context over the FFI, and Rust strict mode is opt-in — but when a later phase wires RFC-21 attempt contexts into the Rust engine's validate_attempt_context, the two expected-coordinator computations will disagree unless one side is aligned first. Flagging so it lands on the integration checklist rather than in a testnet incident.

The Rust engine ports Go's math/rand shuffle semantics
(pkg/tbtc/signer/src/go_math_rand.rs on the FROST signer mirror
branch) and pins concrete SelectCoordinator outcomes in
select_coordinator_matches_known_keep_core_vectors. The Go side only
asserted determinism properties, so a Go-side change to the selection
semantics (math/rand/v2, a different shuffle, a different seed
composition) would pass the Go suite while silently fracturing
coordinator agreement with the Rust engine.

Pin the same concrete vectors on the Go side so either side drifting
fails its own test suite.

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

coderabbitai Bot commented Jun 10, 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: 9f0c5e19-5df2-440f-9b24-27e755d43cd9

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 test/roast-cross-language-coordinator-vectors

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.

@mswilkison mswilkison merged commit b7317f2 into feat/frost-schnorr-migration-scaffold Jun 11, 2026
16 checks passed
@mswilkison mswilkison deleted the test/roast-cross-language-coordinator-vectors branch June 11, 2026 17:15
mswilkison added a commit that referenced this pull request Jun 11, 2026
…nce test (#4027)

Stacked on #4005 (base: `extraction/frost-signer-mirror-2026-05-26`).
Companion to #4026 on the Go branch.

## What

`select_coordinator_is_input_order_independent` in
`pkg/tbtc/signer/src/go_math_rand.rs` asserted only that two input
orderings of the member set agree — not what they agree on. This pins
the concrete result (`Some(4)` for `(members=[1..6], seed=333,
attempt=4)`), matching the value now pinned on the Go side in
`TestSelectCoordinator_CrossLanguagePinnedVectors` (#4026).

## Why

Coordinator selection must agree byte-for-byte between Go's
`SelectCoordinator` and this Rust port of Go's `math/rand` shuffle, or
honest nodes elect different coordinators and the ROAST liveness path
fractures. With the vector sets symmetric across both test suites,
either implementation drifting fails its own unit tests instead of
surfacing in mixed-version soak testing.

Verified locally: `cargo test --lib go_math_rand` passes, `cargo fmt
--check` clean.
mswilkison added a commit that referenced this pull request Jun 11, 2026
…ffle corpus (#4035)

Stacked on #4005 (base: `extraction/frost-signer-mirror-2026-05-26`).
Rust half of the corpus-based differential parity item from the review;
pairs with the Go-side PR #4034.

Adds `testdata/coordinator_shuffle_corpus.json` — a byte-identical copy
of the canonical 600-case corpus generated from keep-core's Go
`SelectCoordinator` — and
`select_coordinator_matches_cross_language_differential_corpus`, which
replays every case through the `go_math_rand` port: 216 integer-boundary
cases (seeds 0/±1/`i64::MIN`/`i64::MAX`/the #4026 pin seed; wrapping
`seed + attempt` composition up to `u32::MAX`; unsorted and reversed
member inputs pinning the internal sort) plus 384 generated sweeps over
set sizes 1..255 with full-range seeds.

All 600 cases replay identically today — direct evidence the `math/rand`
port is bit-exact across the boundary regions where ports diverge first.
Any future drift in source seeding, Fisher-Yates order, `int31n` bounds,
sign handling, wrapping, or sorting fails this suite on the drifting
side.

Full signer suite passes (245 tests); clippy/rustfmt clean. Mirror note:
port back to the tBTC monorepo signer with the next extraction sync.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 added a commit that referenced this pull request Jun 11, 2026
…x A) + cross-language conformance vectors (#4030)

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 vectors** —
`pkg/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 test** —
`TestCoordinatorSeedDerivation_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](https://claude.com/claude-code)
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)
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