From a7dd330d253f2551c90fdea3da4193261f5bc5a2 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 15:08:59 -0400 Subject: [PATCH 1/3] spec(frost/roast): normative coordinator-seed derivation annex + cross-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 --- ...dinator-retry-and-transition-evidence.adoc | 77 +++ .../roast/coordinator_seed_vectors_test.go | 344 +++++++++++++ .../testdata/coordinator_seed_vectors.json | 459 ++++++++++++++++++ 3 files changed, 880 insertions(+) create mode 100644 pkg/frost/roast/coordinator_seed_vectors_test.go create mode 100644 pkg/frost/roast/testdata/coordinator_seed_vectors.json diff --git a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc index f1ddcd5a73..83c1ac9aa1 100644 --- a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc +++ b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc @@ -732,6 +732,83 @@ gossiped attestations *are* the persistent record. signer remains a single-process engine; coordinator state lives on the keep-core side. +== Annex A (normative): coordinator-shuffle seed derivation + +This annex is the single normative definition of the coordinator- +shuffle seed. Two independent implementations exist -- the Go RFC-21 +layer (`pkg/frost/roast`) and the Rust tbtc-signer attempt-context +validation (`pkg/tbtc/signer/src/engine.rs`) -- and both MUST derive +byte-identical values from this text. Conformance is pinned by the +shared vector file regenerated from the Go implementation +(`pkg/frost/roast/testdata/coordinator_seed_vectors.json`, mirrored +byte-identically to `pkg/tbtc/signer/testdata/`); any divergence +fails the drifting side's own unit suite. + +=== Inputs + +* `KeyGroupBytes`: the UTF-8 bytes of the canonical FROST key-group + handle. For `FrostTBTCSignerV1` signer material this handle is the + lowercase hex encoding of the serialized group verifying key as + produced by the tbtc-signer engine (the `KeyGroup` string), and + `ExtractDkgGroupPublicKeyFromMaterial` returns exactly these bytes. + The handle is treated as an opaque byte string; implementations + MUST NOT decode it to point bytes before hashing. +* `SessionID`: the session identifier's raw UTF-8 bytes. +* `MessageDigest`: the 32-byte signing message digest. +* `AttemptNumber`: the RFC-21 attempt number, **0-based** (attempt + zero is the first attempt). The tbtc-signer FFI `AttemptContext` + carries `wire_attempt_number = AttemptNumber + 1` (1-based, zero + rejected); implementations consuming the wire form MUST subtract + one before the composition below. +* `IncludedSet`: the attempt's included member indices. + +=== Derivation + +---- +AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +ShuffleSeed_i64 = int64_from_be_bytes(AttemptSeed32[0..8]) +SourceSeed_i64 = ShuffleSeed_i64 + int64(AttemptNumber) // two's-complement wrap +Coordinator = GoMathRandShuffle(sort_ascending(IncludedSet), SourceSeed_i64)[0] +---- + +`GoMathRandShuffle` is the exact shuffle of Go's legacy `math/rand`: +`rand.New(rand.NewSource(seed)).Shuffle` -- ported bit-exactly to +Rust in `pkg/tbtc/signer/src/go_math_rand.rs` and pinned by the +cross-language vectors of keep-core PRs #4026/#4027. The addition is +two's-complement wrapping on both sides (Go's defined signed +overflow; Rust `wrapping_add`). + +=== Non-goals and accepted properties + +* The concatenation `KeyGroupBytes || SessionID || MessageDigest` is + not length-framed, so input-boundary collisions are theoretically + expressible. This is accepted: the seed feeds a *non-cryptographic* + shuffle whose only job is deterministic agreement on rotation + order among honest members; every input is independently and + unambiguously bound (and framed) in the `AttemptContext` hash that + protocol messages verify. The seed is not a security boundary, and + no exclusion or signing decision may ever be derived from it. +* Only the first 8 bytes of `AttemptSeed32` influence the shuffle; + the remaining 24 bytes are bound via the `AttemptContext` hash + only. +* Grindability: an adversary who can choose `SessionID` or + `MessageDigest` can bias coordinator rotation order. Session and + message identifiers are protocol-fixed inputs (deposit/redemption + driven), not free adversary choices; rotation-order bias degrades + at worst into the serial-attempt latency bound, never into a + safety property. + +=== History + +Before this annex, the two implementations diverged: the Go layer +derived `fold(SHA256(KeyGroup || SessionID || MessageDigest))` with +0-based attempts while the Rust engine used the first 8 bytes of the +raw `MessageDigest` with 1-based wire attempts (the legacy +`signingAttemptSeed` convention of the pre-ROAST signing loop, which +`pkg/tbtc/signing_loop.go` retains until Phase-7 migrates it to this +annex). The divergence was flagged in keep-core PR #4026 and resolved +by adopting the Go derivation as normative. + == References * Ruffing, Ronge, Aranha, Schneider. ``ROAST: Robust Asynchronous diff --git a/pkg/frost/roast/coordinator_seed_vectors_test.go b/pkg/frost/roast/coordinator_seed_vectors_test.go new file mode 100644 index 0000000000..f27e5c7284 --- /dev/null +++ b/pkg/frost/roast/coordinator_seed_vectors_test.go @@ -0,0 +1,344 @@ +package roast + +import ( + "encoding/hex" + "encoding/json" + "fmt" + "os" + "strconv" + "testing" + + "github.com/keep-network/keep-core/pkg/frost/roast/attempt" + "github.com/keep-network/keep-core/pkg/protocol/group" +) + +// coordinatorSeedVectorsPath is the cross-language conformance vector +// file for the normative coordinator-shuffle seed derivation (RFC-21 +// Annex A). The Rust signer carries a byte-identical copy at +// pkg/tbtc/signer/testdata/coordinator_seed_vectors.json and pins the +// same expectations; this file is the canonical source, regenerated +// from the Go implementation via TestRegenerateCoordinatorSeedVectors. +const coordinatorSeedVectorsPath = "testdata/coordinator_seed_vectors.json" + +type coordinatorSeedVectorFile struct { + Description string `json:"description"` + Vectors []coordinatorSeedVector `json:"vectors"` +} + +type coordinatorSeedVector struct { + Name string `json:"name"` + // KeyGroup is the canonical FROST key-group handle. Its UTF-8 + // bytes are the DkgGroupPublicKey input to DeriveAttemptSeed + // (for FrostTBTCSignerV1 material this is the lowercase hex + // encoding of the serialized group verifying key). + KeyGroup string `json:"keyGroup"` + SessionID string `json:"sessionID"` + MessageDigestHex string `json:"messageDigestHex"` + // IncludedMembers is the canonical ascending included set. + IncludedMembers []uint16 `json:"includedMembers"` + // AttemptNumber is the RFC-21 0-based attempt number used in the + // shuffle-source composition. + AttemptNumber uint32 `json:"attemptNumber"` + // WireAttemptNumber is the 1-based attempt number carried by the + // tbtc-signer FFI AttemptContext for the same logical attempt: + // always AttemptNumber + 1. + WireAttemptNumber uint32 `json:"wireAttemptNumber"` + // ExpectedShuffleSeedInt64 is the folded legacy int64 shuffle + // seed, encoded as a decimal string so JSON number precision + // cannot corrupt it. + ExpectedShuffleSeedInt64 string `json:"expectedShuffleSeedInt64"` + ExpectedCoordinator uint16 `json:"expectedCoordinator"` +} + +func loadCoordinatorSeedVectors(t *testing.T) coordinatorSeedVectorFile { + t.Helper() + raw, err := os.ReadFile(coordinatorSeedVectorsPath) + if err != nil { + t.Fatalf( + "cannot read %s (regenerate with ROAST_SEED_VECTORS_REGEN=1): %v", + coordinatorSeedVectorsPath, + err, + ) + } + var file coordinatorSeedVectorFile + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatalf("cannot parse %s: %v", coordinatorSeedVectorsPath, err) + } + if len(file.Vectors) == 0 { + t.Fatalf("%s contains no vectors", coordinatorSeedVectorsPath) + } + return file +} + +func deriveCoordinatorSeedVectorOutputs( + t *testing.T, + vector coordinatorSeedVector, +) (int64, group.MemberIndex) { + t.Helper() + decoded, err := hex.DecodeString(vector.MessageDigestHex) + if err != nil || len(decoded) != attempt.MessageDigestLength { + t.Fatalf( + "vector %q: messageDigestHex must decode to exactly %d bytes", + vector.Name, + attempt.MessageDigestLength, + ) + } + var digest [attempt.MessageDigestLength]byte + copy(digest[:], decoded) + + seed := attempt.DeriveAttemptSeed( + []byte(vector.KeyGroup), + vector.SessionID, + digest, + ) + folded := foldAttemptSeed(seed) + + members := make([]group.MemberIndex, 0, len(vector.IncludedMembers)) + for _, m := range vector.IncludedMembers { + members = append(members, group.MemberIndex(m)) + } + coordinator, err := SelectCoordinator( + members, + folded, + uint(vector.AttemptNumber), + ) + if err != nil { + t.Fatalf("vector %q: SelectCoordinator: %v", vector.Name, err) + } + return folded, coordinator +} + +// TestCoordinatorSeedDerivation_ConformanceVectors pins the normative +// RFC-21 Annex A derivation end to end: +// +// AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +// ShuffleSeed_i64 = int64_be(AttemptSeed32[0:8]) +// SourceSeed = ShuffleSeed_i64 + int64(AttemptNumber) // 0-based +// Coordinator = GoMathRandShuffle(sorted(IncludedSet), SourceSeed)[0] +// +// Any semantic change to DeriveAttemptSeed, foldAttemptSeed, or +// SelectCoordinator fails this suite, and the byte-identical vector +// copy in the Rust signer fails its mirror test -- either side +// drifting breaks its own CI rather than fracturing coordinator +// agreement in a mixed deployment. +func TestCoordinatorSeedDerivation_ConformanceVectors(t *testing.T) { + file := loadCoordinatorSeedVectors(t) + + sawNegativeSeed := false + for _, vector := range file.Vectors { + vector := vector + t.Run(vector.Name, func(t *testing.T) { + if vector.WireAttemptNumber != vector.AttemptNumber+1 { + t.Fatalf( + "wireAttemptNumber [%d] must equal attemptNumber+1 [%d]", + vector.WireAttemptNumber, + vector.AttemptNumber+1, + ) + } + + folded, coordinator := deriveCoordinatorSeedVectorOutputs(t, vector) + + expectedSeed, err := strconv.ParseInt( + vector.ExpectedShuffleSeedInt64, 10, 64, + ) + if err != nil { + t.Fatalf( + "expectedShuffleSeedInt64 %q is not a valid int64: %v", + vector.ExpectedShuffleSeedInt64, err, + ) + } + if folded != expectedSeed { + t.Fatalf( + "shuffle seed mismatch: derived %d, vector pins %d", + folded, expectedSeed, + ) + } + if coordinator != group.MemberIndex(vector.ExpectedCoordinator) { + t.Fatalf( + "coordinator mismatch: derived %d, vector pins %d", + coordinator, vector.ExpectedCoordinator, + ) + } + }) + if vector.ExpectedShuffleSeedInt64 != "" && vector.ExpectedShuffleSeedInt64[0] == '-' { + sawNegativeSeed = true + } + } + + // The legacy seed is a reinterpreted uint64, so roughly half of + // all derivations are negative. Keep at least one negative pin in + // the file so a sign-handling regression (e.g. an unsigned port) + // cannot pass. + if !sawNegativeSeed { + t.Fatal("vector file must pin at least one negative shuffle seed") + } +} + +// TestRegenerateCoordinatorSeedVectors rewrites the conformance +// vector file from the deterministic input matrix below using the +// current Go implementation. Guarded behind an env flag so it never +// rewrites during normal CI: +// +// ROAST_SEED_VECTORS_REGEN=1 go test ./pkg/frost/roast -run TestRegenerateCoordinatorSeedVectors +// +// After regenerating, copy the file byte-identically to +// pkg/tbtc/signer/testdata/coordinator_seed_vectors.json on the +// signer branch. +func TestRegenerateCoordinatorSeedVectors(t *testing.T) { + if os.Getenv("ROAST_SEED_VECTORS_REGEN") != "1" { + t.Skip("set ROAST_SEED_VECTORS_REGEN=1 to regenerate the vector file") + } + + productionKeyGroup := "024d79b696a25e478a1c747fcaad380a" + + "ddbd8b2ef7c333126ab2e2c3b2533b7df2" + opaqueKeyGroup := "roast-vector-opaque-key-group-handle" + + digestA := make([]byte, attempt.MessageDigestLength) + for i := range digestA { + digestA[i] = byte(i) + } + digestB := make([]byte, attempt.MessageDigestLength) + for i := range digestB { + digestB[i] = byte(0xf0 - i) + } + + wideSet := make([]uint16, 0, 100) + for m := uint16(1); m <= 100; m++ { + wideSet = append(wideSet, m) + } + + type vectorInput struct { + name string + keyGroup string + sessionID string + digest []byte + members []uint16 + attempt uint32 + } + inputs := []vectorInput{ + { + name: "five-members-attempt-0", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "five-members-attempt-1", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 1, + }, + { + name: "five-members-attempt-5", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 5, + }, + { + name: "sparse-members-attempt-0", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{2, 7, 9, 11}, + attempt: 0, + }, + { + name: "different-session-changes-seed", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-2", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "different-digest-changes-seed", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestB, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "opaque-key-group-handle", + keyGroup: opaqueKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: []uint16{1, 2, 3, 4, 5}, + attempt: 0, + }, + { + name: "production-group-size-attempt-0", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestA, + members: wideSet, + attempt: 0, + }, + { + name: "production-group-size-attempt-3", + keyGroup: productionKeyGroup, + sessionID: "session-roast-seed-vector-1", + digest: digestB, + members: wideSet, + attempt: 3, + }, + { + name: "opaque-key-group-wide-set-attempt-7", + keyGroup: opaqueKeyGroup, + sessionID: "session-roast-seed-vector-2", + digest: digestB, + members: wideSet, + attempt: 7, + }, + } + + file := coordinatorSeedVectorFile{ + Description: "Cross-language conformance vectors for the RFC-21 Annex A " + + "coordinator-shuffle seed derivation: ShuffleSeed_i64 = " + + "int64_be(SHA256(KeyGroupBytes || SessionID || MessageDigest)[0:8]); " + + "coordinator = GoMathRandShuffle(sorted(IncludedMembers), " + + "ShuffleSeed_i64 + int64(AttemptNumber))[0] with the 0-based RFC-21 " + + "AttemptNumber. wireAttemptNumber is the 1-based tbtc-signer FFI " + + "encoding of the same attempt. Canonical copy: " + + "pkg/frost/roast/testdata/coordinator_seed_vectors.json (Go); " + + "mirrored byte-identically to " + + "pkg/tbtc/signer/testdata/coordinator_seed_vectors.json (Rust).", + } + + for _, input := range inputs { + var digest [attempt.MessageDigestLength]byte + copy(digest[:], input.digest) + vector := coordinatorSeedVector{ + Name: input.name, + KeyGroup: input.keyGroup, + SessionID: input.sessionID, + MessageDigestHex: hex.EncodeToString(input.digest), + IncludedMembers: input.members, + AttemptNumber: input.attempt, + WireAttemptNumber: input.attempt + 1, + } + folded, coordinator := deriveCoordinatorSeedVectorOutputs(t, vector) + vector.ExpectedShuffleSeedInt64 = fmt.Sprintf("%d", folded) + vector.ExpectedCoordinator = uint16(coordinator) + file.Vectors = append(file.Vectors, vector) + } + + encoded, err := json.MarshalIndent(file, "", " ") + if err != nil { + t.Fatalf("encode vector file: %v", err) + } + encoded = append(encoded, '\n') + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("create testdata dir: %v", err) + } + if err := os.WriteFile(coordinatorSeedVectorsPath, encoded, 0o644); err != nil { + t.Fatalf("write vector file: %v", err) + } + t.Logf("regenerated %s with %d vectors", coordinatorSeedVectorsPath, len(file.Vectors)) +} diff --git a/pkg/frost/roast/testdata/coordinator_seed_vectors.json b/pkg/frost/roast/testdata/coordinator_seed_vectors.json new file mode 100644 index 0000000000..6e9a722083 --- /dev/null +++ b/pkg/frost/roast/testdata/coordinator_seed_vectors.json @@ -0,0 +1,459 @@ +{ + "description": "Cross-language conformance vectors for the RFC-21 Annex A coordinator-shuffle seed derivation: ShuffleSeed_i64 = int64_be(SHA256(KeyGroupBytes || SessionID || MessageDigest)[0:8]); coordinator = GoMathRandShuffle(sorted(IncludedMembers), ShuffleSeed_i64 + int64(AttemptNumber))[0] with the 0-based RFC-21 AttemptNumber. wireAttemptNumber is the 1-based tbtc-signer FFI encoding of the same attempt. Canonical copy: pkg/frost/roast/testdata/coordinator_seed_vectors.json (Go); mirrored byte-identically to pkg/tbtc/signer/testdata/coordinator_seed_vectors.json (Rust).", + "vectors": [ + { + "name": "five-members-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 3 + }, + { + "name": "five-members-attempt-1", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 1, + "wireAttemptNumber": 2, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 4 + }, + { + "name": "five-members-attempt-5", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 5, + "wireAttemptNumber": 6, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 5 + }, + { + "name": "sparse-members-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 2, + 7, + 9, + 11 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 11 + }, + { + "name": "different-session-changes-seed", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-2", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-4761519992160790326", + "expectedCoordinator": 4 + }, + { + "name": "different-digest-changes-seed", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "4077301444353698382", + "expectedCoordinator": 1 + }, + { + "name": "opaque-key-group-handle", + "keyGroup": "roast-vector-opaque-key-group-handle", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "8068678687664591308", + "expectedCoordinator": 5 + }, + { + "name": "production-group-size-attempt-0", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 0, + "wireAttemptNumber": 1, + "expectedShuffleSeedInt64": "-2028522963755751589", + "expectedCoordinator": 55 + }, + { + "name": "production-group-size-attempt-3", + "keyGroup": "024d79b696a25e478a1c747fcaad380addbd8b2ef7c333126ab2e2c3b2533b7df2", + "sessionID": "session-roast-seed-vector-1", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 3, + "wireAttemptNumber": 4, + "expectedShuffleSeedInt64": "4077301444353698382", + "expectedCoordinator": 15 + }, + { + "name": "opaque-key-group-wide-set-attempt-7", + "keyGroup": "roast-vector-opaque-key-group-handle", + "sessionID": "session-roast-seed-vector-2", + "messageDigestHex": "f0efeeedecebeae9e8e7e6e5e4e3e2e1e0dfdedddcdbdad9d8d7d6d5d4d3d2d1", + "includedMembers": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 34, + 35, + 36, + 37, + 38, + 39, + 40, + 41, + 42, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 52, + 53, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 73, + 74, + 75, + 76, + 77, + 78, + 79, + 80, + 81, + 82, + 83, + 84, + 85, + 86, + 87, + 88, + 89, + 90, + 91, + 92, + 93, + 94, + 95, + 96, + 97, + 98, + 99, + 100 + ], + "attemptNumber": 7, + "wireAttemptNumber": 8, + "expectedShuffleSeedInt64": "-6872856820098921194", + "expectedCoordinator": 18 + } + ] +} From d44fa864298ac932570f2ac34a88b415e8136815 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 16:49:36 -0400 Subject: [PATCH 2/3] =?UTF-8?q?docs(rfc-21):=20Annex=20A=20=E2=80=94=20pin?= =?UTF-8?q?=20MessageDigest=20as=20the=20padded=20raw=20message,=20not=20a?= =?UTF-8?q?=20transcript=20hash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...-coordinator-retry-and-transition-evidence.adoc | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc index 83c1ac9aa1..03f18d7a0b 100644 --- a/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc +++ b/docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc @@ -754,7 +754,19 @@ fails the drifting side's own unit suite. The handle is treated as an opaque byte string; implementations MUST NOT decode it to point bytes before hashing. * `SessionID`: the session identifier's raw UTF-8 bytes. -* `MessageDigest`: the 32-byte signing message digest. +* `MessageDigest`: the **raw signing message itself**, big-endian + left-padded with zeros to exactly 32 bytes; leading zero bytes are + insignificant, and more than 32 significant bytes MUST be rejected. + This is precisely keep-core's `messageDigestFromBigInt` output: in + BIP-340 production the signed message already is the 32-byte + sighash, so padding is a no-op. Implementations MUST NOT substitute + any transcript hash of the message here -- in particular not the + tbtc-signer engine's internal `SHA256(message_bytes)` digest, which + feeds only the engine's `round_id`/`attempt_id` derivations. Seeding + from the transcript digest instead of the padded message was a + cross-language coordinator divergence caught in review; the + engine-side contract is pinned by + `start_sign_round_accepts_go_derived_attempt_context_in_strict_mode`. * `AttemptNumber`: the RFC-21 attempt number, **0-based** (attempt zero is the first attempt). The tbtc-signer FFI `AttemptContext` carries `wire_attempt_number = AttemptNumber + 1` (1-based, zero From 9914b78fadf17358ff9f0df6b1bbf521edfd620f Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 17:49:48 -0400 Subject: [PATCH 3/3] docs(frost/roast): mark seed-vector regeneration as a protocol-change 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 --- pkg/frost/roast/coordinator_seed_vectors_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pkg/frost/roast/coordinator_seed_vectors_test.go b/pkg/frost/roast/coordinator_seed_vectors_test.go index f27e5c7284..8269cfd5b8 100644 --- a/pkg/frost/roast/coordinator_seed_vectors_test.go +++ b/pkg/frost/roast/coordinator_seed_vectors_test.go @@ -184,6 +184,13 @@ func TestCoordinatorSeedDerivation_ConformanceVectors(t *testing.T) { // After regenerating, copy the file byte-identically to // pkg/tbtc/signer/testdata/coordinator_seed_vectors.json on the // signer branch. +// +// Regenerating is a protocol-change event, not a refresh: the file +// pins the normative RFC-21 Annex A behaviour, so a run that produces +// different bytes means the derivation changed and requires an +// Annex A update in the same change plus a mixed-fleet rollout note. +// Both language suites passing after a dual regen is NOT evidence of +// compatibility with already-deployed engines. func TestRegenerateCoordinatorSeedVectors(t *testing.T) { if os.Getenv("ROAST_SEED_VECTORS_REGEN") != "1" { t.Skip("set ROAST_SEED_VECTORS_REGEN=1 to regenerate the vector file")