From 80c3db8e0809b17e792ca297e7843a5d9b048655 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 15:22:16 -0400 Subject: [PATCH 1/2] unify(tbtc/signer): adopt RFC-21 Annex A coordinator-seed derivation + cross-language vectors The engine's attempt-context validation derived the coordinator-shuffle seed from the first 8 bytes of the raw message digest 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 (divergence flagged in #4026). At Phase-7 wiring every Go-derived attempt context would have failed strict-mode validation. Adopt the RFC-21 Annex A derivation as normative: - roast_attempt_shuffle_seed(key_group, session_id, message_digest) replaces roast_attempt_seed_from_message_digest_hex; the key-group handle's UTF-8 bytes feed the hash as an opaque string, exactly matching keep-core's attempt.DeriveAttemptSeed + foldAttemptSeed. - validate_attempt_context takes the session's key group and composes the shuffle source with the 0-based attempt number (wire encoding stays 1-based; the engine subtracts one), so both layers select the same coordinator for the same logical attempt. - testdata/coordinator_seed_vectors.json is a byte-identical copy of the canonical vector file generated from the Go implementation; coordinator_seed_derivation_matches_cross_language_vectors pins the seed, the coordinator, the wire mapping, and full strict-mode validate_attempt_context acceptance for all ten vectors (including negative folded seeds and the n=100 production set). - docs/roast-coordinator-seed-derivation.md mirrors the normative annex for signer-side readers. The coordinator-mismatch test now derives the provably-wrong coordinator instead of hardcoding one, so it stays valid under any seed derivation. Pairs with the Go-side PR on feat/frost-schnorr-migration-scaffold (RFC-21 Annex A + canonical vector file + Go conformance test). Co-Authored-By: Claude Fable 5 --- .../docs/roast-coordinator-seed-derivation.md | 55 +++ pkg/tbtc/signer/src/engine.rs | 228 ++++++++- .../testdata/coordinator_seed_vectors.json | 459 ++++++++++++++++++ 3 files changed, 732 insertions(+), 10 deletions(-) create mode 100644 pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md create mode 100644 pkg/tbtc/signer/testdata/coordinator_seed_vectors.json diff --git a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md new file mode 100644 index 0000000000..13cb3696fd --- /dev/null +++ b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md @@ -0,0 +1,55 @@ +# Coordinator-shuffle seed derivation (RFC-21 Annex A mirror) + +The normative definition of the ROAST coordinator-shuffle seed lives in +keep-core's RFC-21, *Annex A (normative): coordinator-shuffle seed +derivation* +(`docs/rfc/rfc-21-roast-coordinator-retry-and-transition-evidence.adoc` +on the `feat/frost-schnorr-migration-scaffold` branch). This file +mirrors the derivation for signer-side readers; if the two ever +disagree, the RFC annex wins. + +## Derivation + +```text +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] +``` + +- `KeyGroupBytes`: UTF-8 bytes of the canonical key-group handle. For + this engine that is the lowercase hex encoding of the serialized + group verifying key (the `key_group` string in `DkgResult`), treated + as an opaque string — never decoded to point bytes before hashing. +- `SessionID`: raw UTF-8 bytes. +- `MessageDigest`: exactly 32 bytes. +- `AttemptNumber`: the RFC-21 **0-based** attempt number. The FFI + `AttemptContext.attempt_number` carries the **1-based** wire encoding + (`wire = AttemptNumber + 1`, zero rejected); the engine subtracts one + before composing the shuffle source (`validate_attempt_context`). +- `GoMathRandShuffle`: the bit-exact port of Go's legacy `math/rand` + shuffle in `src/go_math_rand.rs`, pinned by keep-core PRs #4026 and + #4027. + +Implemented by `roast_attempt_shuffle_seed` in `src/engine.rs`. + +## Conformance vectors + +`testdata/coordinator_seed_vectors.json` is a byte-identical copy of +the canonical vector file generated from the Go implementation +(`pkg/frost/roast/testdata/coordinator_seed_vectors.json`, regenerated +there with `ROAST_SEED_VECTORS_REGEN=1 go test ./pkg/frost/roast -run +TestRegenerateCoordinatorSeedVectors`). The unit test +`coordinator_seed_derivation_matches_cross_language_vectors` pins the +seed, the selected coordinator, the 0-/1-based wire mapping, and full +`validate_attempt_context` acceptance for every vector. When the Go +side regenerates the file, re-copy it here verbatim. + +## History + +Before this unification the engine derived the seed from the first 8 +bytes of the raw message digest with the 1-based wire attempt number — +the legacy `signingAttemptSeed` convention of the pre-ROAST keep-core +signing loop. The divergence from the RFC-21 layer was flagged in +keep-core PR #4026 and resolved by adopting the Go derivation as +normative. diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index e03aad2f27..b42a5b5c96 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -5141,21 +5141,49 @@ fn roast_hash_hex_with_components( Ok(hash_hex(&payload)) } -fn roast_attempt_seed_from_message_digest_hex( +/// Derives the legacy `i64` coordinator-shuffle seed per RFC-21 Annex A +/// (normative; see `docs/roast-coordinator-seed-derivation.md`): +/// +/// ```text +/// AttemptSeed32 = SHA256(KeyGroupBytes || SessionID || MessageDigest) +/// ShuffleSeed_i64 = int64_from_be_bytes(AttemptSeed32[0..8]) +/// ``` +/// +/// `key_group` is the canonical FROST key-group handle (for this engine: +/// the lowercase hex encoding of the serialized group verifying key); its +/// UTF-8 bytes feed the hash as an opaque string, matching keep-core's +/// `attempt.DeriveAttemptSeed` + `foldAttemptSeed` composition exactly. +/// The shuffle-source composition adds the RFC-21 **0-based** attempt +/// number; callers holding the 1-based wire attempt number must subtract +/// one before composing. +/// +/// Cross-language agreement is pinned by +/// `testdata/coordinator_seed_vectors.json`, a byte-identical copy of the +/// canonical file generated from the Go implementation in +/// `pkg/frost/roast` on the RFC-21 branch. +fn roast_attempt_shuffle_seed( + key_group: &str, + session_id: &str, message_digest_hex: &str, ) -> Result { let message_digest_bytes = hex::decode(message_digest_hex).map_err(|_| { EngineError::Internal("message digest hex must decode for attempt seed".to_string()) })?; - if message_digest_bytes.len() < 8 { + if message_digest_bytes.len() != 32 { return Err(EngineError::Internal( - "message digest must be at least 8 bytes for attempt seed".to_string(), + "message digest must be exactly 32 bytes for attempt seed".to_string(), )); } + let mut hasher = Sha256::new(); + hasher.update(key_group.as_bytes()); + hasher.update(session_id.as_bytes()); + hasher.update(&message_digest_bytes); + let attempt_seed = hasher.finalize(); + let mut seed_bytes = [0_u8; 8]; - seed_bytes.copy_from_slice(&message_digest_bytes[..8]); + seed_bytes.copy_from_slice(&attempt_seed[..8]); Ok(i64::from_be_bytes(seed_bytes)) } @@ -5197,6 +5225,7 @@ fn roast_attempt_id_hex( fn validate_attempt_context( session_id: &str, + key_group: &str, message_digest_hex: &str, threshold: u16, attempt_context: Option<&AttemptContext>, @@ -5239,11 +5268,13 @@ fn validate_attempt_context( )); } - let attempt_seed = roast_attempt_seed_from_message_digest_hex(message_digest_hex)?; + let attempt_seed = roast_attempt_shuffle_seed(key_group, session_id, message_digest_hex)?; + // The wire attempt_number is 1-based (enforced above); the RFC-21 + // Annex A shuffle composition uses the 0-based attempt number. let expected_coordinator_identifier = select_coordinator_identifier( &canonical_included_participants, attempt_seed, - attempt_context.attempt_number, + attempt_context.attempt_number - 1, ) .ok_or_else(|| { EngineError::Validation( @@ -6276,6 +6307,7 @@ pub fn start_sign_round(mut request: StartSignRoundRequest) -> Result, + } + + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct CoordinatorSeedVector { + name: String, + key_group: String, + #[serde(rename = "sessionID")] + session_id: String, + message_digest_hex: String, + included_members: Vec, + attempt_number: u32, + wire_attempt_number: u32, + expected_shuffle_seed_int64: String, + expected_coordinator: u16, + } + + // Byte-identical copy of the canonical cross-language vector file + // generated from the Go implementation + // (pkg/frost/roast/testdata/coordinator_seed_vectors.json on the + // RFC-21 branch; regenerate there with ROAST_SEED_VECTORS_REGEN=1 + // and re-copy). Pins the RFC-21 Annex A derivation end to end so a + // semantic change on either side fails that side's own suite + // instead of fracturing coordinator agreement in a mixed + // deployment. + #[test] + fn coordinator_seed_derivation_matches_cross_language_vectors() { + let raw = include_str!("../testdata/coordinator_seed_vectors.json"); + let file: CoordinatorSeedVectorFile = + serde_json::from_str(raw).expect("coordinator seed vector file decodes"); + assert!( + !file.vectors.is_empty(), + "expected at least one coordinator seed vector" + ); + + let mut saw_negative_seed = false; + for vector in &file.vectors { + assert_eq!( + vector.wire_attempt_number, + vector.attempt_number + 1, + "wire attempt number must be the 1-based encoding in vector [{}]", + vector.name + ); + + let shuffle_seed = roast_attempt_shuffle_seed( + &vector.key_group, + &vector.session_id, + &vector.message_digest_hex, + ) + .expect("shuffle seed derives"); + let expected_shuffle_seed: i64 = vector + .expected_shuffle_seed_int64 + .parse() + .expect("expected shuffle seed parses as i64"); + assert_eq!( + shuffle_seed, expected_shuffle_seed, + "shuffle seed mismatch in vector [{}]", + vector.name + ); + if expected_shuffle_seed < 0 { + saw_negative_seed = true; + } + + // The shuffle-source composition uses the RFC-21 0-based + // attempt number, exactly as `validate_attempt_context` + // composes it from the 1-based wire encoding. + let coordinator = select_coordinator_identifier( + &vector.included_members, + shuffle_seed, + vector.wire_attempt_number - 1, + ) + .expect("coordinator selects"); + assert_eq!( + coordinator, vector.expected_coordinator, + "coordinator mismatch in vector [{}]", + vector.name + ); + + // End to end: an attempt context carrying the wire-encoded + // attempt number and the vector's coordinator passes the + // engine's strict validation under the vector's key group. + let fingerprint = roast_included_participants_fingerprint_hex(&vector.included_members) + .expect("fingerprint"); + let attempt_id = roast_attempt_id_hex( + &vector.session_id, + &vector.message_digest_hex, + vector.wire_attempt_number, + coordinator, + &fingerprint, + ) + .expect("attempt id"); + let attempt_context = AttemptContext { + attempt_number: vector.wire_attempt_number, + coordinator_identifier: coordinator, + included_participants: vector.included_members.clone(), + included_participants_fingerprint: fingerprint, + attempt_id, + }; + validate_attempt_context( + &vector.session_id, + &vector.key_group, + &vector.message_digest_hex, + 2, + Some(&attempt_context), + true, + ) + .unwrap_or_else(|err| { + panic!( + "vector [{}] context failed engine validation: {err:?}", + vector.name + ) + }); + } + + assert!( + saw_negative_seed, + "vector file must pin at least one negative shuffle seed" + ); + } + struct InteractiveDkgFixture { pre_normalization_even_y: bool, part3_requests: BTreeMap, @@ -10410,6 +10573,29 @@ mod tests { } } + // Resolves the key group the engine will use when validating attempt + // contexts for `session_id`: the session's dealer-DKG key group when + // the session exists, otherwise a deterministic per-session stand-in + // for tests that exercise `validate_attempt_context` directly without + // creating a session. Construction and validation must use the same + // resolver or the RFC-21 Annex A seed derivation diverges. + fn attempt_context_key_group_for_tests(session_id: &str) -> String { + if let Ok(state) = state() { + if let Ok(guard) = state.lock() { + if let Some(key_group) = guard + .sessions + .get(session_id) + .and_then(|session| session.dkg_result.as_ref()) + .map(|dkg| dkg.key_group.clone()) + { + return key_group; + } + } + } + + format!("test-key-group:{session_id}") + } + fn build_deterministic_attempt_context( session_id: &str, message_hex: &str, @@ -10421,12 +10607,17 @@ mod tests { .expect("canonical included participants"); let message_bytes = hex::decode(message_hex).expect("message hex"); let message_digest_hex = hash_hex(&message_bytes); - let attempt_seed = roast_attempt_seed_from_message_digest_hex(&message_digest_hex) - .expect("attempt seed from message digest"); + let key_group = attempt_context_key_group_for_tests(session_id); + let attempt_seed = roast_attempt_shuffle_seed(&key_group, session_id, &message_digest_hex) + .expect("attempt shuffle seed"); + assert!( + attempt_number >= 1, + "attempt_number is the 1-based wire encoding", + ); let coordinator_identifier = select_coordinator_identifier( &canonical_included_participants, attempt_seed, - attempt_number, + attempt_number - 1, ) .expect("deterministic coordinator"); @@ -10598,6 +10789,7 @@ mod tests { ); let validated_participants = validate_attempt_context( &session_id, + &attempt_context_key_group_for_tests(&session_id), &message_digest_hex, 2, Some(&permuted_attempt_context), @@ -10634,6 +10826,7 @@ mod tests { ); let err = validate_attempt_context( &session_id, + &attempt_context_key_group_for_tests(&session_id), &message_digest_hex, 2, Some(&tampered_attempt_context), @@ -12666,7 +12859,22 @@ mod tests { }) .expect("start sign round"); - let mismatched_attempt = build_attempt_context(session_id, message_hex, 1, 1, vec![1, 2]); + // Pick the member that is provably not the deterministic + // coordinator so the test stays valid under any seed derivation. + let deterministic_attempt = + build_deterministic_attempt_context(session_id, message_hex, 1, vec![1, 2]); + let mismatched_coordinator = if deterministic_attempt.coordinator_identifier == 1 { + 2 + } else { + 1 + }; + let mismatched_attempt = build_attempt_context( + session_id, + message_hex, + 1, + mismatched_coordinator, + vec![1, 2], + ); let err = finalize_sign_round( FinalizeSignRoundRequest { session_id: session_id.to_string(), diff --git a/pkg/tbtc/signer/testdata/coordinator_seed_vectors.json b/pkg/tbtc/signer/testdata/coordinator_seed_vectors.json new file mode 100644 index 0000000000..6e9a722083 --- /dev/null +++ b/pkg/tbtc/signer/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 710e59aa02037c4e8f217082b24b366ae12f5dc9 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 16:49:38 -0400 Subject: [PATCH 2/2] fix(tbtc/signer): seed the coordinator shuffle from the padded raw message, not the transcript digest Review finding on the seed unification: validate_attempt_context fed roast_attempt_shuffle_seed with the engine's internal transcript digest (hash_hex(message_bytes) = SHA256(message)), but the Go RFC-21 layer derives the seed from messageDigestFromBigInt(request.Message) -- the raw 32-byte signing digest itself. Valid Phase-7 Go attempt contexts would have selected a different coordinator and been rejected in strict mode; the conformance test missed it because its end-to-end leg called validate_attempt_context directly, bypassing start_sign_round's digest computation. - rfc21_message_digest mirrors messageDigestFromBigInt exactly: leading zero bytes insignificant (big.Int round-trip), big-endian left-pad to 32 bytes, more than 32 significant bytes rejected. - validate_attempt_context now takes the raw message bytes and seeds the shuffle from the padded message; the SHA256 transcript digest keeps feeding only the attempt_id check. StartSignRound passes its request message; FinalizeSignRound passes the cached sign_message_bytes stored by the same StartSignRound. - start_sign_round_accepts_go_derived_attempt_context_in_strict_mode reimplements the Go-side derivation inline (DeriveAttemptSeed + foldAttemptSeed + 0-based SelectCoordinator) and proves acceptance through the real strict-mode StartSignRound call path -- the test shape that would have caught the original finding. - Cross-language conformance test now treats the vector digest as the raw message (the exact production relationship) and binds attempt_id to the transcript digest, exercising the two-digest split. - Attempt-context proptests constrained to 32-byte messages, matching the RFC-21 bound the Go bridge enforces. Annex A on the Go branch is amended in lockstep to state the input unambiguously. Co-Authored-By: Claude Fable 5 --- .../docs/roast-coordinator-seed-derivation.md | 17 +- pkg/tbtc/signer/src/engine.rs | 227 +++++++++++++++--- 2 files changed, 215 insertions(+), 29 deletions(-) diff --git a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md index 13cb3696fd..b4eb24b90a 100644 --- a/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md +++ b/pkg/tbtc/signer/docs/roast-coordinator-seed-derivation.md @@ -22,7 +22,17 @@ Coordinator = GoMathRandShuffle(sort_ascending(IncludedSet), SourceSeed_i64) group verifying key (the `key_group` string in `DkgResult`), treated as an opaque string — never decoded to point bytes before hashing. - `SessionID`: raw UTF-8 bytes. -- `MessageDigest`: exactly 32 bytes. +- `MessageDigest`: the **raw signing message itself**, big-endian + left-padded with zeros to exactly 32 bytes (leading zero bytes are + insignificant; more than 32 significant bytes is rejected). This + mirrors keep-core's `messageDigestFromBigInt`: in BIP-340 production + the message the engine receives *is* the 32-byte sighash. It is + **not** the engine's internal transcript digest + (`SHA256(message_bytes)`), which continues to feed the + `round_id`/`attempt_id` derivations only. Implemented by + `rfc21_message_digest` in `src/engine.rs`; feeding the transcript + digest here instead was the cross-language coordinator divergence + caught in review of the unification PR. - `AttemptNumber`: the RFC-21 **0-based** attempt number. The FFI `AttemptContext.attempt_number` carries the **1-based** wire encoding (`wire = AttemptNumber + 1`, zero rejected); the engine subtracts one @@ -31,7 +41,10 @@ Coordinator = GoMathRandShuffle(sort_ascending(IncludedSet), SourceSeed_i64) shuffle in `src/go_math_rand.rs`, pinned by keep-core PRs #4026 and #4027. -Implemented by `roast_attempt_shuffle_seed` in `src/engine.rs`. +Implemented by `roast_attempt_shuffle_seed` in `src/engine.rs`; the +end-to-end acceptance of a Go-derived context through strict +`StartSignRound` is pinned by +`start_sign_round_accepts_go_derived_attempt_context_in_strict_mode`. ## Conformance vectors diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index b42a5b5c96..a80a7bbb2d 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -5141,6 +5141,42 @@ fn roast_hash_hex_with_components( Ok(hash_hex(&payload)) } +/// Computes the RFC-21 `MessageDigest` from the raw signing message +/// bytes, mirroring keep-core's `messageDigestFromBigInt` exactly: the +/// message **is** the digest (in BIP-340 production the signed message is +/// already a 32-byte sighash), leading zero bytes are insignificant +/// (Go round-trips through `big.Int`, which strips them), the value is +/// big-endian left-padded with zeros to exactly 32 bytes, and anything +/// longer than 32 significant bytes is rejected. +/// +/// This is deliberately NOT the engine's internal transcript digest +/// (`hash_hex(message_bytes)` = SHA256 of the message), which continues +/// to feed `round_id`/`attempt_id` derivations. Feeding the transcript +/// digest into the shuffle seed was the cross-language divergence this +/// helper exists to prevent: the Go RFC-21 layer seeds from the padded +/// message itself. +fn rfc21_message_digest(message_bytes: &[u8]) -> Result<[u8; 32], EngineError> { + let significant_bytes = { + let first_significant_index = message_bytes + .iter() + .position(|byte| *byte != 0) + .unwrap_or(message_bytes.len()); + &message_bytes[first_significant_index..] + }; + + if significant_bytes.len() > 32 { + return Err(EngineError::Validation(format!( + "message length [{}] exceeds the RFC-21 32-byte message digest; \ + attempt contexts only bind 32-byte signing digests", + significant_bytes.len() + ))); + } + + let mut digest = [0_u8; 32]; + digest[32 - significant_bytes.len()..].copy_from_slice(significant_bytes); + Ok(digest) +} + /// Derives the legacy `i64` coordinator-shuffle seed per RFC-21 Annex A /// (normative; see `docs/roast-coordinator-seed-derivation.md`): /// @@ -5153,6 +5189,8 @@ fn roast_hash_hex_with_components( /// the lowercase hex encoding of the serialized group verifying key); its /// UTF-8 bytes feed the hash as an opaque string, matching keep-core's /// `attempt.DeriveAttemptSeed` + `foldAttemptSeed` composition exactly. +/// `rfc21_message_digest` is the padded raw signing message (see +/// `rfc21_message_digest`), NOT the engine's SHA256 transcript digest. /// The shuffle-source composition adds the RFC-21 **0-based** attempt /// number; callers holding the 1-based wire attempt number must subtract /// one before composing. @@ -5164,22 +5202,12 @@ fn roast_hash_hex_with_components( fn roast_attempt_shuffle_seed( key_group: &str, session_id: &str, - message_digest_hex: &str, + rfc21_message_digest: &[u8; 32], ) -> Result { - let message_digest_bytes = hex::decode(message_digest_hex).map_err(|_| { - EngineError::Internal("message digest hex must decode for attempt seed".to_string()) - })?; - - if message_digest_bytes.len() != 32 { - return Err(EngineError::Internal( - "message digest must be exactly 32 bytes for attempt seed".to_string(), - )); - } - let mut hasher = Sha256::new(); hasher.update(key_group.as_bytes()); hasher.update(session_id.as_bytes()); - hasher.update(&message_digest_bytes); + hasher.update(rfc21_message_digest); let attempt_seed = hasher.finalize(); let mut seed_bytes = [0_u8; 8]; @@ -5226,6 +5254,7 @@ fn roast_attempt_id_hex( fn validate_attempt_context( session_id: &str, key_group: &str, + message_bytes: &[u8], message_digest_hex: &str, threshold: u16, attempt_context: Option<&AttemptContext>, @@ -5268,7 +5297,15 @@ fn validate_attempt_context( )); } - let attempt_seed = roast_attempt_shuffle_seed(key_group, session_id, message_digest_hex)?; + // The shuffle seed binds the RFC-21 MessageDigest -- the padded raw + // signing message, exactly as the Go layer's + // `messageDigestFromBigInt` produces it -- NOT the engine's SHA256 + // transcript digest (`message_digest_hex`), which feeds only the + // `attempt_id` derivation below. Mixing the two was the + // coordinator-selection divergence flagged on the seed-unification + // review. + let attempt_seed = + roast_attempt_shuffle_seed(key_group, session_id, &rfc21_message_digest(message_bytes)?)?; // The wire attempt_number is 1-based (enforced above); the RFC-21 // Annex A shuffle composition uses the 0-based attempt number. let expected_coordinator_identifier = select_coordinator_identifier( @@ -6308,6 +6345,7 @@ pub fn start_sign_round(mut request: StartSignRoundRequest) -> Result, @@ -10606,10 +10766,15 @@ mod tests { canonicalize_included_participants(&included_participants) .expect("canonical included participants"); let message_bytes = hex::decode(message_hex).expect("message hex"); - let message_digest_hex = hash_hex(&message_bytes); let key_group = attempt_context_key_group_for_tests(session_id); - let attempt_seed = roast_attempt_shuffle_seed(&key_group, session_id, &message_digest_hex) - .expect("attempt shuffle seed"); + // RFC-21 seed input: the padded raw message, not the SHA256 + // transcript digest -- mirroring the Go layer's derivation. + let attempt_seed = roast_attempt_shuffle_seed( + &key_group, + session_id, + &rfc21_message_digest(&message_bytes).expect("rfc21 message digest"), + ) + .expect("attempt shuffle seed"); assert!( attempt_number >= 1, "attempt_number is the 1-based wire encoding", @@ -10755,7 +10920,10 @@ mod tests { session_suffix in any::(), attempt_number in 1_u32..=16_u32, participants in participant_set_strategy(), - message_bytes in prop::collection::vec(any::(), 1..=128), + // RFC-21 attempt contexts only bind 32-byte signing digests + // (rfc21_message_digest rejects longer messages), so the + // strategy stays within that bound. + message_bytes in prop::collection::vec(any::(), 1..=32), ) { let session_id = format!("formal-attempt-session-{session_suffix}"); let message_hex = hex::encode(message_bytes); @@ -10784,12 +10952,13 @@ mod tests { &permuted_attempt_context.attempt_id ); - let message_digest_hex = hash_hex( - &hex::decode(&message_hex).expect("message hex should decode for validation"), - ); + let validation_message_bytes = + hex::decode(&message_hex).expect("message hex should decode for validation"); + let message_digest_hex = hash_hex(&validation_message_bytes); let validated_participants = validate_attempt_context( &session_id, &attempt_context_key_group_for_tests(&session_id), + &validation_message_bytes, &message_digest_hex, 2, Some(&permuted_attempt_context), @@ -10808,7 +10977,10 @@ mod tests { session_suffix in any::(), attempt_number in 1_u32..=16_u32, participants in participant_set_strategy(), - message_bytes in prop::collection::vec(any::(), 1..=128), + // RFC-21 attempt contexts only bind 32-byte signing digests + // (rfc21_message_digest rejects longer messages), so the + // strategy stays within that bound. + message_bytes in prop::collection::vec(any::(), 1..=32), ) { let session_id = format!("formal-attempt-tamper-session-{session_suffix}"); let message_hex = hex::encode(message_bytes); @@ -10821,12 +10993,13 @@ mod tests { ); tampered_attempt_context.attempt_id = "11".repeat(32); - let message_digest_hex = hash_hex( - &hex::decode(&message_hex).expect("message hex should decode for validation"), - ); + let validation_message_bytes = + hex::decode(&message_hex).expect("message hex should decode for validation"); + let message_digest_hex = hash_hex(&validation_message_bytes); let err = validate_attempt_context( &session_id, &attempt_context_key_group_for_tests(&session_id), + &validation_message_bytes, &message_digest_hex, 2, Some(&tampered_attempt_context),