From 13b04ec5603426d879de1eb0a550fb65ef0b7699 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 1 Jul 2026 16:33:34 -0500 Subject: [PATCH] hardening(signer): fail closed on stateless nonce primitives in production The stateless FROST FFI primitives `generate_nonces_and_commitments` and `sign_share` in `frost_ops.rs` hand a one-time signing nonce pair out to the host and later accept it back as opaque `nonces_hex`. That leaves nonce custody -- and the single-use invariant -- entirely with the caller (the `SignShareRequest` contract even states the caller "is cryptographically responsible for single use"). A host bug or compromise that replays one `nonces_hex` across two distinct signing packages produces two Schnorr shares under the same nonce, which algebraically solves for the long-term secret share. The deterministic StartSignRound/FinalizeSignRound path is already fenced off in production by `enforce_transitional_signing_disabled_in_production`, but these stateless primitives gated only on the provenance attestation and had no production-profile guard -- a fail-closed asymmetry. A production signer could therefore be driven through the host-custody nonce path and inherit that catastrophic blast radius. Add `enforce_stateless_nonce_primitives_disabled_in_production`, mirroring the deterministic guard's style (same `signer_profile_is_production()` predicate, same `EngineError::LifecyclePolicyRejected` variant and message shape), and invoke it in both primitives immediately after the provenance gate -- before any secret key package or nonce is deserialized. Under the production profile both refuse with reason_code `stateless_nonce_primitives_disabled_in_production`; production signing must use the interactive FROST path (`interactive.rs`), where the engine keeps nonce custody and enforces durable single-use consumption markers. Non-production/test profiles retain current behavior. No current production caller reaches these paths (defense-in-depth before activation). Adds a focused test proving both primitives fail closed under the production profile with valid provenance -- feeding the same secret inputs that succeed under development -- and still work under the development profile. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/frost_ops.rs | 36 +++++++++++ pkg/tbtc/signer/src/engine/tests.rs | 80 +++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/pkg/tbtc/signer/src/engine/frost_ops.rs b/pkg/tbtc/signer/src/engine/frost_ops.rs index 018ffdb274..cae05637c6 100644 --- a/pkg/tbtc/signer/src/engine/frost_ops.rs +++ b/pkg/tbtc/signer/src/engine/frost_ops.rs @@ -172,10 +172,45 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result Result<(), EngineError> { + if signer_profile_is_production() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: operation.to_string(), + reason_code: "stateless_nonce_primitives_disabled_in_production".to_string(), + detail: format!( + "stateless host-custody nonce primitive [{operation}] is disabled when {TBTC_SIGNER_PROFILE_ENV}={TBTC_SIGNER_PROFILE_PRODUCTION}; production signing must use the interactive FROST path, which keeps nonce custody inside the engine and enforces single-use nonces" + ), + }); + } + + Ok(()) +} + pub fn generate_nonces_and_commitments( request: GenerateNoncesAndCommitmentsRequest, ) -> Result { enforce_provenance_gate()?; + enforce_stateless_nonce_primitives_disabled_in_production("GenerateNoncesAndCommitments")?; let key_package = decode_key_package( "GenerateNoncesAndCommitments", @@ -235,6 +270,7 @@ pub fn new_signing_package( pub fn sign_share(request: SignShareRequest) -> Result { enforce_provenance_gate()?; + enforce_stateless_nonce_primitives_disabled_in_production("SignShare")?; let signing_package_bytes = decode_hex_field( "SignShare", diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index b00f624fe9..f0da8e24d7 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -4685,6 +4685,86 @@ fn finalize_sign_round_rejects_transitional_signing_in_production_profile() { clear_state_storage_policy_overrides(); } +#[test] +fn stateless_nonce_primitives_reject_under_production_profile() { + let _guard = lock_test_state(); + reset_for_tests(); + + // Build real, secret-bearing inputs under the development profile: valid + // key packages, a nonce pair per signer, and a signing package. Both + // stateless primitives must succeed here, proving the production gate does + // not regress the transitional/host-orchestrated path or the test path. + let fixture = deterministic_interactive_dkg_fixture(7); + let mut part3_results = BTreeMap::new(); + for (id, request) in fixture.part3_requests { + part3_results.insert(id, dkg_part3(request).expect("DKG part3")); + } + + let signing_participants = [1u16, 2]; + let mut commitments = Vec::new(); + let mut nonces_by_participant = BTreeMap::new(); + for id in signing_participants { + let result = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }) + .expect("development profile generates nonces"); + commitments.push(result.commitment); + nonces_by_participant.insert(id, result.nonces_hex); + } + let signing_package = new_signing_package(NewSigningPackageRequest { + message_hex: hex::encode([0x11u8; 32]), + commitments, + }) + .expect("development profile builds signing package"); + sign_share(SignShareRequest { + signing_package_hex: signing_package.signing_package_hex.clone(), + nonces_hex: nonces_by_participant[&1].clone().into(), + key_package_identifier: part3_results[&1].key_package.identifier.clone(), + key_package_hex: part3_results[&1].key_package.data_hex.clone().into(), + }) + .expect("development profile signs share"); + + // Flip to the production profile with valid provenance configured so the + // primitives reach the new gate rather than tripping the provenance gate + // first. Feed the SAME secret key package, host-custody nonces, and + // signing package that just succeeded: production must now fail closed + // BEFORE any secret is deserialized (the gate is the second statement of + // each primitive, ahead of decode_key_package / nonce deserialization). + configure_valid_provenance_attestation_for_tests(); + let _signer_profile = SignerProfileGuard::production(); + + let nonces_err = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: part3_results[&1].key_package.identifier.clone(), + key_package_hex: part3_results[&1].key_package.data_hex.clone(), + }) + .expect_err("production profile must refuse to mint host-custody nonces"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = nonces_err else { + panic!("unexpected error variant for generate_nonces_and_commitments"); + }; + assert_eq!( + reason_code, + "stateless_nonce_primitives_disabled_in_production" + ); + + let share_err = sign_share(SignShareRequest { + signing_package_hex: signing_package.signing_package_hex.clone(), + nonces_hex: nonces_by_participant[&1].clone().into(), + key_package_identifier: part3_results[&1].key_package.identifier.clone(), + key_package_hex: part3_results[&1].key_package.data_hex.clone().into(), + }) + .expect_err("production profile must refuse to sign a host-supplied nonce"); + let EngineError::LifecyclePolicyRejected { reason_code, .. } = share_err else { + panic!("unexpected error variant for sign_share"); + }; + assert_eq!( + reason_code, + "stateless_nonce_primitives_disabled_in_production" + ); + + reset_for_tests(); +} + #[test] fn start_sign_round_accepts_valid_attempt_context_in_roast_strict_mode() { let _guard = lock_test_state();