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();