Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions pkg/tbtc/signer/src/engine/frost_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,45 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result<DkgPart3Result, EngineError
Ok(result)
}

/// The stateless `generate_nonces_and_commitments` / `sign_share` FFI
/// primitives 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. Unlike the deterministic StartSignRound/FinalizeSignRound path --
/// which is already fenced off in production by
/// `enforce_transitional_signing_disabled_in_production` -- these primitives
/// had no production gate, so a production signer could be driven through
/// them and inherit that blast radius. Fail closed under the production
/// profile: production signing must use the interactive FROST path
/// (`interactive.rs`), where the engine retains nonce custody and enforces
/// durable single-use consumption markers. Non-production profiles keep the
/// primitives available for the transitional/host-orchestrated flow and for
/// tests.
pub(crate) fn enforce_stateless_nonce_primitives_disabled_in_production(
operation: &str,
) -> 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<GenerateNoncesAndCommitmentsResult, EngineError> {
enforce_provenance_gate()?;
enforce_stateless_nonce_primitives_disabled_in_production("GenerateNoncesAndCommitments")?;

let key_package = decode_key_package(
"GenerateNoncesAndCommitments",
Expand Down Expand Up @@ -235,6 +270,7 @@ pub fn new_signing_package(

pub fn sign_share(request: SignShareRequest) -> Result<SignShareResult, EngineError> {
enforce_provenance_gate()?;
enforce_stateless_nonce_primitives_disabled_in_production("SignShare")?;

let signing_package_bytes = decode_hex_field(
"SignShare",
Expand Down
80 changes: 80 additions & 0 deletions pkg/tbtc/signer/src/engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading