From 299d79e65f2db859cfcd3c559f502c85f2072779 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 13:51:37 -0400 Subject: [PATCH 1/3] hardening(tbtc/signer): bind full transcript into round nonces, gate transitional signing out of production The transitional StartSignRound/FinalizeSignRound flow derives round-1 nonces deterministically. Its nonce-reuse safety previously rested on two indirect properties: (1) every transcript-affecting input staying bound into the seed via the round_id derivation schema, and (2) consumed-round registry integrity on durable state, which rollback/restore/replication can silently violate. Remove the failure class instead of guarding it: - Introduce RoundNonceBinding with a documented invariant: every value entering the FROST binding factor, challenge, Lagrange set, or key material selection feeds the nonce seed directly. The seed now also binds the group verifying key, the Taproot tweak root, and the canonical signing-participant set (domain bumped to round-nonce-v2). Nonce safety no longer depends on round_id schema evolution or on registry integrity: any transcript variation yields a fresh nonce, so state rollback can only repeat identical transcripts (yielding the identical signature), never the same nonce under a new challenge. - Gate the deterministic-nonce entry points out of the production profile. Dealer DKG was already production-blocked, but persisted state created under a development profile could be carried into a production-profile process and signed with. StartSignRound and FinalizeSignRound now reject with transitional_deterministic_signing_disabled_in_production; production signing is the interactive FROST path with OS-random nonces only. A per-boot RAM-only salt was considered and rejected: the transitional flow has every member independently derive all participants' commitments with no round-1 exchange, so cross-machine determinism is load-bearing; a per-machine salt would break every multi-member bootstrap session. Mirror note: port back to the tBTC monorepo signer alongside the next extraction sync. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine.rs | 439 ++++++++++++++++++++++++++++------ 1 file changed, 368 insertions(+), 71 deletions(-) diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index e03aad2f27..e9266edbea 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -4875,26 +4875,67 @@ pub fn aggregate(request: AggregateRequest) -> Result { + session_id: &'a str, + round_id: &'a str, + /// Serialized group verifying key; binds the nonce to the concrete group + /// key material (it enters the FROST binding-factor and challenge + /// preimages), not just to the session label that references it. + group_verifying_key_bytes: &'a [u8], + message_bytes: &'a [u8], + /// Taproot tweak applied at round 2; tweaking changes the challenge. + taproot_merkle_root: Option<&'a [u8; 32]>, + /// Canonical (sorted, deduplicated) signing set; determines the + /// commitment list and the Lagrange coefficients. + signing_participants: &'a [u16], + participant_identifier: u16, +} + fn build_deterministic_round_nonce_and_commitment( key_package: &frost::keys::KeyPackage, - session_id: &str, - round_id: &str, - message_bytes: &[u8], - participant_identifier: u16, + binding: &RoundNonceBinding<'_>, ) -> ( frost::round1::SigningNonces, frost::round1::SigningCommitments, ) { - // Defense-in-depth: bind nonces directly to message bytes in addition to - // `round_id` so future round ID schema changes cannot weaken nonce safety. + let mut signing_participants_bytes = Vec::with_capacity(binding.signing_participants.len() * 2); + for signing_participant in binding.signing_participants { + signing_participants_bytes.extend_from_slice(&signing_participant.to_be_bytes()); + } + let (taproot_tweak_tag, taproot_tweak_bytes): (&[u8], &[u8]) = match binding.taproot_merkle_root + { + Some(taproot_merkle_root) => (b"taproot-tweak", taproot_merkle_root.as_slice()), + None => (b"no-taproot-tweak", &[]), + }; + let mut signing_share_bytes = key_package.signing_share().serialize(); + // Domain bumped to v2 when the binding set was widened beyond + // (session, round, message, participant); see `RoundNonceBinding`. let mut nonce_seed = deterministic_seed(&[ - b"round-nonce", + b"round-nonce-v2", &signing_share_bytes, - session_id.as_bytes(), - round_id.as_bytes(), - message_bytes, - &participant_identifier.to_le_bytes(), + binding.group_verifying_key_bytes, + binding.session_id.as_bytes(), + binding.round_id.as_bytes(), + binding.message_bytes, + taproot_tweak_tag, + taproot_tweak_bytes, + &signing_participants_bytes, + &binding.participant_identifier.to_le_bytes(), ]); signing_share_bytes.zeroize(); let mut nonce_rng = ZeroizingChaCha20Rng::from_seed(nonce_seed); @@ -6034,6 +6075,32 @@ fn enforce_bootstrap_dealer_dkg_disabled_in_production( Ok(()) } +/// The transitional StartSignRound/FinalizeSignRound flow derives round-1 +/// nonces deterministically (see `RoundNonceBinding`) and only operates on +/// dealer-DKG sessions where one engine holds every participant's key +/// package. Blocking dealer DKG in production (above) is not enough on its +/// own: persisted state created under a development profile could be carried +/// into a production-profile process and signed with there. Gate the signing +/// entry points themselves so a production signer can never execute the +/// deterministic-nonce path, regardless of how its on-disk state was created. +/// Production signing must use the interactive FROST path, which draws +/// nonces from OS randomness. +fn enforce_transitional_signing_disabled_in_production( + session_id: &str, +) -> Result<(), EngineError> { + if signer_profile_is_production() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: session_id.to_string(), + reason_code: "transitional_deterministic_signing_disabled_in_production".to_string(), + detail: format!( + "transitional deterministic-nonce signing (StartSignRound/FinalizeSignRound) is disabled when {TBTC_SIGNER_PROFILE_ENV}={TBTC_SIGNER_PROFILE_PRODUCTION}; production signing must use the interactive FROST path with OS-random nonces" + ), + }); + } + + Ok(()) +} + fn development_dealer_dkg_seed(dkg_seed_hex: Option<&str>) -> Result<[u8; 32], EngineError> { let Some(seed_hex) = dkg_seed_hex else { let mut seed = [0_u8; 32]; @@ -6066,6 +6133,7 @@ pub fn start_sign_round(mut request: StartSignRoundRequest) -> Result Result Result, + dkg_public_key_package: &frost::keys::PublicKeyPackage, signing_participants: &[u16], request: &StartSignRoundRequest, round_id: &str, message_bytes: &[u8], taproot_merkle_root: Option<&[u8; 32]>, ) -> Result { + let group_verifying_key_bytes = + dkg_public_key_package + .verifying_key() + .serialize() + .map_err(|e| { + EngineError::Internal(format!("failed to serialize group verifying key: {e}")) + })?; let mut commitments = BTreeMap::new(); let mut own_nonces = None; @@ -6488,10 +6574,15 @@ fn build_real_signature_share_contribution( let frost_identifier = participant_identifier_to_frost_identifier(*participant_identifier)?; let (mut nonces, participant_commitments) = build_deterministic_round_nonce_and_commitment( key_package, - &request.session_id, - round_id, - message_bytes, - *participant_identifier, + &RoundNonceBinding { + session_id: &request.session_id, + round_id, + group_verifying_key_bytes: &group_verifying_key_bytes, + message_bytes, + taproot_merkle_root, + signing_participants, + participant_identifier: *participant_identifier, + }, ); commitments.insert(frost_identifier, participant_commitments); @@ -6555,6 +6646,7 @@ pub fn finalize_sign_round( let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::FinalizeSignRound); enforce_provenance_gate()?; validate_session_id(&request.session_id)?; + enforce_transitional_signing_disabled_in_production(&request.session_id)?; let strict_roast_mode_enabled = roast_strict_mode_enabled(); let finalize_taproot_merkle_root = canonicalize_taproot_merkle_root_hex(&mut request.taproot_merkle_root_hex)?; @@ -6761,6 +6853,12 @@ pub fn finalize_sign_round( } } + let group_verifying_key_bytes = dkg_public_key_package + .verifying_key() + .serialize() + .map_err(|e| { + EngineError::Internal(format!("failed to serialize group verifying key: {e}")) + })?; let mut commitments = BTreeMap::new(); for signing_participant in &signing_participants { let key_package = dkg_key_packages.get(signing_participant).ok_or_else(|| { @@ -6774,10 +6872,15 @@ pub fn finalize_sign_round( let (mut participant_nonces, participant_commitments) = build_deterministic_round_nonce_and_commitment( key_package, - &round_state.session_id, - &round_state.round_id, - sign_message_bytes, - *signing_participant, + &RoundNonceBinding { + session_id: &round_state.session_id, + round_id: &round_state.round_id, + group_verifying_key_bytes: &group_verifying_key_bytes, + message_bytes: sign_message_bytes, + taproot_merkle_root: finalize_taproot_merkle_root.as_ref(), + signing_participants: &signing_participants, + participant_identifier: *signing_participant, + }, ); participant_nonces.zeroize(); commitments.insert(frost_identifier, participant_commitments); @@ -10882,7 +10985,28 @@ mod tests { #[test] fn production_profile_forces_roast_strict_mode_without_env_flag() { let _guard = lock_test_state(); - let state_path = configure_test_state_path("production_forces_roast_strict"); + reset_for_tests(); + + { + let _signer_profile = SignerProfileGuard::production(); + let _roast_strict_mode = RoastStrictModeGuard::set(Some("false")); + assert!( + roast_strict_mode_enabled(), + "production profile must force ROAST strict mode regardless of env flag", + ); + } + + let _roast_strict_mode = RoastStrictModeGuard::set(Some("false")); + assert!( + !roast_strict_mode_enabled(), + "development profile must honor the disabled strict-mode env flag", + ); + } + + #[test] + fn start_sign_round_rejects_transitional_signing_in_production_profile() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_rejects_transitional_signing"); reset_for_tests(); std::env::set_var( TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, @@ -10894,7 +11018,7 @@ mod tests { ); let dkg_result = run_dkg(RunDkgRequest { - session_id: "session-production-forces-roast-strict".to_string(), + session_id: "session-production-rejects-transitional".to_string(), participants: vec![ crate::api::DkgParticipant { identifier: 1, @@ -10912,12 +11036,17 @@ mod tests { // RAII guards restore the prior env on Drop so a panic or early return // does not leak production-profile state into subsequent tests. + // + // This is the state-smuggling scenario: the dealer session above was + // created under the development profile, and the process now runs as + // production. The deterministic-nonce signing entry point itself must + // reject, even with the strict-mode env flag explicitly disabled. configure_valid_provenance_attestation_for_tests(); let _signer_profile = SignerProfileGuard::production(); let _roast_strict_mode = RoastStrictModeGuard::set(Some("false")); let err = start_sign_round(StartSignRoundRequest { - session_id: "session-production-forces-roast-strict".to_string(), + session_id: "session-production-rejects-transitional".to_string(), member_identifier: 1, message_hex: "deadbeef".to_string(), key_group: dkg_result.key_group, @@ -10926,14 +11055,87 @@ mod tests { attempt_context: None, attempt_transition_evidence: None, }) - .expect_err("production profile should require ROAST attempt context"); + .expect_err("production profile should reject transitional signing"); - let EngineError::Validation(message) = err else { + let EngineError::LifecyclePolicyRejected { reason_code, .. } = err else { panic!("unexpected error variant"); }; - assert!( - message.contains("attempt_context is required"), - "unexpected validation message: {message}" + assert_eq!( + reason_code, + "transitional_deterministic_signing_disabled_in_production" + ); + + reset_for_tests(); + cleanup_test_state_artifacts(&state_path); + clear_state_storage_policy_overrides(); + } + + #[test] + fn finalize_sign_round_rejects_transitional_signing_in_production_profile() { + let _guard = lock_test_state(); + let state_path = configure_test_state_path("production_rejects_transitional_finalize"); + reset_for_tests(); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND, + ); + std::env::set_var( + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + format!("printf '{}\\n'", TEST_STATE_ENCRYPTION_KEY_HEX), + ); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: "session-production-rejects-transitional-finalize".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("seed non-production dkg"); + + let round_state = start_sign_round(StartSignRoundRequest { + session_id: "session-production-rejects-transitional-finalize".to_string(), + member_identifier: 1, + message_hex: "deadbeef".to_string(), + key_group: dkg_result.key_group, + taproot_merkle_root_hex: None, + signing_participants: Some(vec![1, 2]), + attempt_context: None, + attempt_transition_evidence: None, + }) + .expect("start sign round under development profile"); + + // A round started under the development profile must not be + // finalizable by a production-profile process either; the gate fires + // before any round state is consumed. + configure_valid_provenance_attestation_for_tests(); + let _signer_profile = SignerProfileGuard::production(); + + let err = finalize_sign_round( + FinalizeSignRoundRequest { + session_id: "session-production-rejects-transitional-finalize".to_string(), + taproot_merkle_root_hex: None, + attempt_context: None, + round_contributions: vec![round_state.own_contribution.clone()], + }, + false, + ) + .expect_err("production profile should reject transitional finalize"); + + let EngineError::LifecyclePolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant"); + }; + assert_eq!( + reason_code, + "transitional_deterministic_signing_disabled_in_production" ); reset_for_tests(); @@ -12886,6 +13088,7 @@ mod tests { }; let member_two_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_two_request, &round_state.round_id, @@ -12900,6 +13103,7 @@ mod tests { }; let member_three_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_three_request, &round_state.round_id, @@ -13038,6 +13242,7 @@ mod tests { }; let member_two_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_two_request, &round_state.round_id, @@ -13052,6 +13257,7 @@ mod tests { }; let member_three_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_three_request, &round_state.round_id, @@ -13213,6 +13419,7 @@ mod tests { }; let member_two_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_two_request, &round_state.round_id, @@ -13436,6 +13643,7 @@ mod tests { contributions.push( build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, signing_participants.as_slice(), &member_request, &first_round_state.round_id, @@ -13471,12 +13679,12 @@ mod tests { } #[test] - fn deterministic_round_nonce_and_commitment_is_message_bound() { + fn deterministic_round_nonce_and_commitment_binds_full_transcript() { let _guard = lock_test_state(); reset_for_tests(); let run_dkg_request = RunDkgRequest { - session_id: "session-nonce-message-bound".to_string(), + session_id: "session-nonce-transcript-bound".to_string(), participants: vec![ crate::api::DkgParticipant { identifier: 1, @@ -13486,6 +13694,10 @@ mod tests { identifier: 2, public_key_hex: "02bb".to_string(), }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, ], threshold: 2, dkg_seed_hex: None, @@ -13493,52 +13705,123 @@ mod tests { run_dkg(run_dkg_request).expect("run dkg"); - let key_package = { + let other_session_request = RunDkgRequest { + session_id: "session-nonce-transcript-bound-other".to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + crate::api::DkgParticipant { + identifier: 3, + public_key_hex: "02cc".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }; + run_dkg(other_session_request).expect("run other dkg"); + + let fetch_session_material = |session_id: &str| { let guard = state().expect("engine state").lock().expect("engine lock"); - let session = guard - .sessions - .get("session-nonce-message-bound") - .expect("session state"); + let session = guard.sessions.get(session_id).expect("session state"); - session - .dkg_key_packages - .as_ref() - .expect("dkg key packages") - .get(&1) - .expect("key package") - .clone() + ( + session + .dkg_key_packages + .as_ref() + .expect("dkg key packages") + .get(&1) + .expect("key package") + .clone(), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + ) }; + let (key_package, public_key_package) = + fetch_session_material("session-nonce-transcript-bound"); + let (_, other_public_key_package) = + fetch_session_material("session-nonce-transcript-bound-other"); + + let group_verifying_key_bytes = public_key_package + .verifying_key() + .serialize() + .expect("group verifying key bytes"); + let other_group_verifying_key_bytes = other_public_key_package + .verifying_key() + .serialize() + .expect("other group verifying key bytes"); - let session_id = "session-nonce-message-bound"; - let round_id = "fixed-round-id"; - let participant_identifier = 1u16; let message_one = hex::decode("deadbeef").expect("message one decode"); let message_two = hex::decode("cafebabe").expect("message two decode"); - - let (_, commitments_one) = build_deterministic_round_nonce_and_commitment( - &key_package, - session_id, - round_id, - &message_one, - participant_identifier, - ); - let (_, commitments_one_retry) = build_deterministic_round_nonce_and_commitment( - &key_package, - session_id, - round_id, - &message_one, - participant_identifier, - ); - let (_, commitments_two) = build_deterministic_round_nonce_and_commitment( - &key_package, - session_id, - round_id, - &message_two, - participant_identifier, + let taproot_merkle_root = [0x42_u8; 32]; + let baseline_participants: Vec = vec![1, 2]; + let wider_participants: Vec = vec![1, 2, 3]; + + let baseline_binding = RoundNonceBinding { + session_id: "session-nonce-transcript-bound", + round_id: "fixed-round-id", + group_verifying_key_bytes: &group_verifying_key_bytes, + message_bytes: &message_one, + taproot_merkle_root: None, + signing_participants: &baseline_participants, + participant_identifier: 1, + }; + + let (_, baseline_commitments) = + build_deterministic_round_nonce_and_commitment(&key_package, &baseline_binding); + let (_, retry_commitments) = + build_deterministic_round_nonce_and_commitment(&key_package, &baseline_binding); + assert_eq!( + baseline_commitments, retry_commitments, + "identical binding inputs must re-derive identical commitments", ); - assert_eq!(commitments_one, commitments_one_retry); - assert_ne!(commitments_one, commitments_two); + // Each transcript-affecting input must independently change the nonce. + let variant_bindings = [ + RoundNonceBinding { + message_bytes: &message_two, + ..baseline_binding + }, + RoundNonceBinding { + taproot_merkle_root: Some(&taproot_merkle_root), + ..baseline_binding + }, + RoundNonceBinding { + signing_participants: &wider_participants, + ..baseline_binding + }, + RoundNonceBinding { + group_verifying_key_bytes: &other_group_verifying_key_bytes, + ..baseline_binding + }, + RoundNonceBinding { + session_id: "session-nonce-transcript-bound-other", + ..baseline_binding + }, + RoundNonceBinding { + round_id: "other-round-id", + ..baseline_binding + }, + RoundNonceBinding { + participant_identifier: 2, + ..baseline_binding + }, + ]; + for (variant_index, variant_binding) in variant_bindings.iter().enumerate() { + let (_, variant_commitments) = + build_deterministic_round_nonce_and_commitment(&key_package, variant_binding); + assert_ne!( + baseline_commitments, variant_commitments, + "binding variant [{variant_index}] must change the derived commitment", + ); + } } #[test] @@ -13587,14 +13870,20 @@ mod tests { .clone() .expect("round signing participants"); - let dkg_key_packages = { + let (dkg_key_packages, dkg_public_key_package) = { let guard = state().expect("engine state").lock().expect("engine lock"); let session = guard .sessions .get(&start_request.session_id) .expect("session state"); - session.dkg_key_packages.clone().expect("dkg key packages") + ( + session.dkg_key_packages.clone().expect("dkg key packages"), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + ) }; let member_two_request = StartSignRoundRequest { @@ -13604,6 +13893,7 @@ mod tests { }; let member_two_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_two_request, &round_state.round_id, @@ -13687,14 +13977,20 @@ mod tests { .clone() .expect("round signing participants"); - let dkg_key_packages = { + let (dkg_key_packages, dkg_public_key_package) = { let guard = state().expect("engine state").lock().expect("engine lock"); let session = guard .sessions .get(&start_request.session_id) .expect("session state"); - session.dkg_key_packages.clone().expect("dkg key packages") + ( + session.dkg_key_packages.clone().expect("dkg key packages"), + session + .dkg_public_key_package + .clone() + .expect("dkg public key package"), + ) }; let member_two_request = StartSignRoundRequest { @@ -13704,6 +14000,7 @@ mod tests { }; let member_two_contribution = build_real_signature_share_contribution( &dkg_key_packages, + &dkg_public_key_package, &signing_participants, &member_two_request, &round_state.round_id, From 0b1f4dbb62d5276cd6fae0f5c866b4778733a031 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 17:49:41 -0400 Subject: [PATCH 2/3] docs(tbtc/signer): pin v2 nonce-seed encoding invariants in a comment The widened round-nonce-v2 binding mixes encodings: the participants set serializes big-endian while participant_identifier keeps the v1 little-endian encoding. Harmless (fixed-width parts, length-framed by deterministic_seed) but part of the derived value -- note that any encoding change requires a new seed domain, never an in-place edit. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index e9266edbea..ba644a9804 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -4925,6 +4925,14 @@ fn build_deterministic_round_nonce_and_commitment( let mut signing_share_bytes = key_package.signing_share().serialize(); // Domain bumped to v2 when the binding set was widened beyond // (session, round, message, participant); see `RoundNonceBinding`. + // + // Encoding note: the participants set serializes big-endian while + // `participant_identifier` keeps the v1 little-endian encoding. The + // mix is harmless -- both are fixed-width and `deterministic_seed` + // length-frames every part -- but it is part of the derived value: + // changing either encoding changes derived commitments fleet-wide + // and requires a new domain (`round-nonce-v3`), never an in-place + // edit. let mut nonce_seed = deterministic_seed(&[ b"round-nonce-v2", &signing_share_bytes, From 8afe502a24709f40592491d7c70c41193f05e45b Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 18:37:09 -0400 Subject: [PATCH 3/3] fix(tbtc/signer): bind full PublicKeyPackage into round-nonce seed (v3) Closes a completeness gap in the v2 RoundNonceBinding found in third-pass review (P1). The seed bound only the *group* verifying key, not the individual verifying shares. In the transitional flow every member re-derives ALL participants' round-1 commitments from the held key packages, so each other participant's verifying share enters the commitment list -> this member's binding factor and challenge. Two key packages can share a group verifying key while differing in a non-target share (any threshold t>=3 admits two polynomials with identical f(0) and target share but a different non-target share). Consequence under the old binding: a rolled-back/restored/cloned state (exactly #4028's threat model) could present an identical nonce seed under a *different* challenge -> the same member signs two different challenges with one deterministic nonce -> share extraction. The in-process run_dkg SessionConflict guard does not cover this, by design: nonce safety must not depend on registry integrity, since durable state can be rolled back or replicated. The production hard-gate still blocks this transitional flow in production, so the exposure is confined to the dealer-DKG dev/staging path; the interactive production path draws from OS randomness and is unaffected. Fix: bind the full serialized PublicKeyPackage (group key AND every verifying share); domain round-nonce-v2 -> round-nonce-v3. Regression: deterministic_round_nonce_and_commitment_binds_full_transcript now includes a variant with the baseline group key but a non-target verifying share swapped; it produces an identical seed (and asserts an identical group key) under the old binding, a different commitment under the new one. Full signer suite 246 pass; clippy/rustfmt clean. Mirror note: v3 domain + the widened binding port back to the tBTC monorepo signer with the next extraction sync, alongside the rest of #4028. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine.rs | 109 ++++++++++++++++++++++++---------- 1 file changed, 77 insertions(+), 32 deletions(-) diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index ba644a9804..1b5423ab78 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -4888,14 +4888,29 @@ pub fn aggregate(request: AggregateRequest) -> Result { session_id: &'a str, round_id: &'a str, - /// Serialized group verifying key; binds the nonce to the concrete group - /// key material (it enters the FROST binding-factor and challenge - /// preimages), not just to the session label that references it. - group_verifying_key_bytes: &'a [u8], + /// Serialized full `PublicKeyPackage` — the group verifying key AND + /// every participant's verifying share. Binds the nonce to the concrete + /// key material that determines the whole commitment list (every other + /// participant's commitment feeds this member's binding factor and + /// challenge), not just to the group key or the session label. + public_key_package_bytes: &'a [u8], message_bytes: &'a [u8], /// Taproot tweak applied at round 2; tweaking changes the challenge. taproot_merkle_root: Option<&'a [u8; 32]>, @@ -4923,20 +4938,23 @@ fn build_deterministic_round_nonce_and_commitment( }; let mut signing_share_bytes = key_package.signing_share().serialize(); - // Domain bumped to v2 when the binding set was widened beyond - // (session, round, message, participant); see `RoundNonceBinding`. + // Domain v3: v2 widened the set beyond (session, round, message, + // participant); v3 widens the key-material binding from the group + // verifying key alone to the full PublicKeyPackage (every verifying + // share), closing the case where two key packages share a group key + // but differ in a non-target share. See `RoundNonceBinding`. // // Encoding note: the participants set serializes big-endian while // `participant_identifier` keeps the v1 little-endian encoding. The // mix is harmless -- both are fixed-width and `deterministic_seed` // length-frames every part -- but it is part of the derived value: // changing either encoding changes derived commitments fleet-wide - // and requires a new domain (`round-nonce-v3`), never an in-place + // and requires a new domain (`round-nonce-v4`), never an in-place // edit. let mut nonce_seed = deterministic_seed(&[ - b"round-nonce-v2", + b"round-nonce-v3", &signing_share_bytes, - binding.group_verifying_key_bytes, + binding.public_key_package_bytes, binding.session_id.as_bytes(), binding.round_id.as_bytes(), binding.message_bytes, @@ -6560,13 +6578,9 @@ fn build_real_signature_share_contribution( message_bytes: &[u8], taproot_merkle_root: Option<&[u8; 32]>, ) -> Result { - let group_verifying_key_bytes = - dkg_public_key_package - .verifying_key() - .serialize() - .map_err(|e| { - EngineError::Internal(format!("failed to serialize group verifying key: {e}")) - })?; + let public_key_package_bytes = dkg_public_key_package.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize public key package: {e}")) + })?; let mut commitments = BTreeMap::new(); let mut own_nonces = None; @@ -6585,7 +6599,7 @@ fn build_real_signature_share_contribution( &RoundNonceBinding { session_id: &request.session_id, round_id, - group_verifying_key_bytes: &group_verifying_key_bytes, + public_key_package_bytes: &public_key_package_bytes, message_bytes, taproot_merkle_root, signing_participants, @@ -6861,12 +6875,9 @@ pub fn finalize_sign_round( } } - let group_verifying_key_bytes = dkg_public_key_package - .verifying_key() - .serialize() - .map_err(|e| { - EngineError::Internal(format!("failed to serialize group verifying key: {e}")) - })?; + let public_key_package_bytes = dkg_public_key_package.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize public key package: {e}")) + })?; let mut commitments = BTreeMap::new(); for signing_participant in &signing_participants { let key_package = dkg_key_packages.get(signing_participant).ok_or_else(|| { @@ -6883,7 +6894,7 @@ pub fn finalize_sign_round( &RoundNonceBinding { session_id: &round_state.session_id, round_id: &round_state.round_id, - group_verifying_key_bytes: &group_verifying_key_bytes, + public_key_package_bytes: &public_key_package_bytes, message_bytes: sign_message_bytes, taproot_merkle_root: finalize_taproot_merkle_root.as_ref(), signing_participants: &signing_participants, @@ -13757,14 +13768,43 @@ mod tests { let (_, other_public_key_package) = fetch_session_material("session-nonce-transcript-bound-other"); - let group_verifying_key_bytes = public_key_package - .verifying_key() + let public_key_package_bytes = public_key_package .serialize() - .expect("group verifying key bytes"); - let other_group_verifying_key_bytes = other_public_key_package - .verifying_key() + .expect("public key package bytes"); + let other_public_key_package_bytes = other_public_key_package .serialize() - .expect("other group verifying key bytes"); + .expect("other public key package bytes"); + + // F1 regression: a package sharing the baseline's GROUP verifying + // key but differing in a non-target participant's verifying share + // (members 2 and 3 swapped). The target is member 1, so the old + // group-key-only binding produced an identical seed here even + // though every member re-derives member 2's commitment from this + // share -- the silent nonce-reuse-under-a-different-challenge case. + let identifier_two = participant_identifier_to_frost_identifier(2).expect("identifier 2"); + let identifier_three = participant_identifier_to_frost_identifier(3).expect("identifier 3"); + let mut perturbed_verifying_shares = public_key_package.verifying_shares().clone(); + let share_two = *perturbed_verifying_shares + .get(&identifier_two) + .expect("verifying share 2"); + let share_three = *perturbed_verifying_shares + .get(&identifier_three) + .expect("verifying share 3"); + perturbed_verifying_shares.insert(identifier_two, share_three); + perturbed_verifying_shares.insert(identifier_three, share_two); + let perturbed_share_package = frost::keys::PublicKeyPackage::new( + perturbed_verifying_shares, + *public_key_package.verifying_key(), + None, + ); + assert_eq!( + perturbed_share_package.verifying_key(), + public_key_package.verifying_key(), + "perturbed package must keep the baseline group verifying key", + ); + let perturbed_share_package_bytes = perturbed_share_package + .serialize() + .expect("perturbed share package bytes"); let message_one = hex::decode("deadbeef").expect("message one decode"); let message_two = hex::decode("cafebabe").expect("message two decode"); @@ -13775,7 +13815,7 @@ mod tests { let baseline_binding = RoundNonceBinding { session_id: "session-nonce-transcript-bound", round_id: "fixed-round-id", - group_verifying_key_bytes: &group_verifying_key_bytes, + public_key_package_bytes: &public_key_package_bytes, message_bytes: &message_one, taproot_merkle_root: None, signing_participants: &baseline_participants, @@ -13806,7 +13846,12 @@ mod tests { ..baseline_binding }, RoundNonceBinding { - group_verifying_key_bytes: &other_group_verifying_key_bytes, + public_key_package_bytes: &other_public_key_package_bytes, + ..baseline_binding + }, + // Same group key, one non-target verifying share changed. + RoundNonceBinding { + public_key_package_bytes: &perturbed_share_package_bytes, ..baseline_binding }, RoundNonceBinding {