Skip to content

Commit 09f61d3

Browse files
authored
spec(frost/roast): normative coordinator-seed derivation (RFC-21 Annex 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)
2 parents 5fa67b1 + 9914b78 commit 09f61d3

3 files changed

Lines changed: 899 additions & 0 deletions

File tree

docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,95 @@ gossiped attestations *are* the persistent record.
771771
signer remains a single-process engine; coordinator state lives on
772772
the keep-core side.
773773

774+
== Annex A (normative): coordinator-shuffle seed derivation
775+
776+
This annex is the single normative definition of the coordinator-
777+
shuffle seed. Two independent implementations exist -- the Go RFC-21
778+
layer (`pkg/frost/roast`) and the Rust tbtc-signer attempt-context
779+
validation (`pkg/tbtc/signer/src/engine.rs`) -- and both MUST derive
780+
byte-identical values from this text. Conformance is pinned by the
781+
shared vector file regenerated from the Go implementation
782+
(`pkg/frost/roast/testdata/coordinator_seed_vectors.json`, mirrored
783+
byte-identically to `pkg/tbtc/signer/testdata/`); any divergence
784+
fails the drifting side's own unit suite.
785+
786+
=== Inputs
787+
788+
* `KeyGroupBytes`: the UTF-8 bytes of the canonical FROST key-group
789+
handle. For `FrostTBTCSignerV1` signer material this handle is the
790+
lowercase hex encoding of the serialized group verifying key as
791+
produced by the tbtc-signer engine (the `KeyGroup` string), and
792+
`ExtractDkgGroupPublicKeyFromMaterial` returns exactly these bytes.
793+
The handle is treated as an opaque byte string; implementations
794+
MUST NOT decode it to point bytes before hashing.
795+
* `SessionID`: the session identifier's raw UTF-8 bytes.
796+
* `MessageDigest`: the **raw signing message itself**, big-endian
797+
left-padded with zeros to exactly 32 bytes; leading zero bytes are
798+
insignificant, and more than 32 significant bytes MUST be rejected.
799+
This is precisely keep-core's `messageDigestFromBigInt` output: in
800+
BIP-340 production the signed message already is the 32-byte
801+
sighash, so padding is a no-op. Implementations MUST NOT substitute
802+
any transcript hash of the message here -- in particular not the
803+
tbtc-signer engine's internal `SHA256(message_bytes)` digest, which
804+
feeds only the engine's `round_id`/`attempt_id` derivations. Seeding
805+
from the transcript digest instead of the padded message was a
806+
cross-language coordinator divergence caught in review; the
807+
engine-side contract is pinned by
808+
`start_sign_round_accepts_go_derived_attempt_context_in_strict_mode`.
809+
* `AttemptNumber`: the RFC-21 attempt number, **0-based** (attempt
810+
zero is the first attempt). The tbtc-signer FFI `AttemptContext`
811+
carries `wire_attempt_number = AttemptNumber + 1` (1-based, zero
812+
rejected); implementations consuming the wire form MUST subtract
813+
one before the composition below.
814+
* `IncludedSet`: the attempt's included member indices.
815+
816+
=== Derivation
817+
818+
----
819+
AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest)
820+
ShuffleSeed_i64 = int64_from_be_bytes(AttemptSeed32[0..8])
821+
SourceSeed_i64 = ShuffleSeed_i64 + int64(AttemptNumber) // two's-complement wrap
822+
Coordinator = GoMathRandShuffle(sort_ascending(IncludedSet), SourceSeed_i64)[0]
823+
----
824+
825+
`GoMathRandShuffle` is the exact shuffle of Go's legacy `math/rand`:
826+
`rand.New(rand.NewSource(seed)).Shuffle` -- ported bit-exactly to
827+
Rust in `pkg/tbtc/signer/src/go_math_rand.rs` and pinned by the
828+
cross-language vectors of keep-core PRs #4026/#4027. The addition is
829+
two's-complement wrapping on both sides (Go's defined signed
830+
overflow; Rust `wrapping_add`).
831+
832+
=== Non-goals and accepted properties
833+
834+
* The concatenation `KeyGroupBytes || SessionID || MessageDigest` is
835+
not length-framed, so input-boundary collisions are theoretically
836+
expressible. This is accepted: the seed feeds a *non-cryptographic*
837+
shuffle whose only job is deterministic agreement on rotation
838+
order among honest members; every input is independently and
839+
unambiguously bound (and framed) in the `AttemptContext` hash that
840+
protocol messages verify. The seed is not a security boundary, and
841+
no exclusion or signing decision may ever be derived from it.
842+
* Only the first 8 bytes of `AttemptSeed32` influence the shuffle;
843+
the remaining 24 bytes are bound via the `AttemptContext` hash
844+
only.
845+
* Grindability: an adversary who can choose `SessionID` or
846+
`MessageDigest` can bias coordinator rotation order. Session and
847+
message identifiers are protocol-fixed inputs (deposit/redemption
848+
driven), not free adversary choices; rotation-order bias degrades
849+
at worst into the serial-attempt latency bound, never into a
850+
safety property.
851+
852+
=== History
853+
854+
Before this annex, the two implementations diverged: the Go layer
855+
derived `fold(SHA256(KeyGroup || SessionID || MessageDigest))` with
856+
0-based attempts while the Rust engine used the first 8 bytes of the
857+
raw `MessageDigest` with 1-based wire attempts (the legacy
858+
`signingAttemptSeed` convention of the pre-ROAST signing loop, which
859+
`pkg/tbtc/signing_loop.go` retains until Phase-7 migrates it to this
860+
annex). The divergence was flagged in keep-core PR #4026 and resolved
861+
by adopting the Go derivation as normative.
862+
774863
== References
775864

776865
* Ruffing, Ronge, Aranha, Schneider. ``ROAST: Robust Asynchronous

0 commit comments

Comments
 (0)