diff --git a/pkg/tbtc/signer/Cargo.lock b/pkg/tbtc/signer/Cargo.lock index 6e25f344a8..34d2aff98d 100644 --- a/pkg/tbtc/signer/Cargo.lock +++ b/pkg/tbtc/signer/Cargo.lock @@ -365,9 +365,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 7aa3c5b1ca..d8dd58b54f 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -38,6 +38,7 @@ TbtcSignerResult frost_tbtc_run_dkg(const uint8_t* request_ptr, size_t request_l TbtcSignerResult frost_tbtc_dkg_part1(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_dkg_part3(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_persist_distributed_dkg_key_package(const uint8_t* request_ptr, size_t request_len); /* * Stateless interactive signing nonce contract: diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index a20c3d9526..d1f1cda98c 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -88,6 +88,20 @@ pub struct DkgPart3Result { pub public_key_package: NativeFrostPublicKeyPackage, } +/// Persists the result of a DISTRIBUTED FROST DKG for one seat: this node's own +/// key package (from Part3) plus the group public key package, into the engine +/// session state that the interactive signing path loads. Unlike the dealer +/// `run_dkg`, a distributed node holds only its OWN secret key package. +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct PersistDistributedDkgKeyPackageRequest { + pub session_id: String, + pub participant_identifier: u16, + pub threshold: u16, + pub participant_count: u16, + pub key_package: NativeFrostKeyPackage, + pub public_key_package: NativeFrostPublicKeyPackage, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct NativeFrostCommitment { pub identifier: String, diff --git a/pkg/tbtc/signer/src/engine/dkg.rs b/pkg/tbtc/signer/src/engine/dkg.rs index f3af9338f6..8154876567 100644 --- a/pkg/tbtc/signer/src/engine/dkg.rs +++ b/pkg/tbtc/signer/src/engine/dkg.rs @@ -187,6 +187,240 @@ pub fn run_dkg(request: RunDkgRequest) -> Result { Ok(result) } +/// Persists a DISTRIBUTED FROST DKG result for one seat so the interactive +/// signing path can load its key. The dealer `run_dkg` above persists ALL key +/// packages (it generates them all); a distributed DKG instead runs Part1/2/3 +/// across nodes and each node's Part3 returns only ITS OWN secret key package. +/// This op stores that key package (keyed by this node's participant identifier) +/// together with the group public key package, then persists - the exact session +/// shape interactive signing consumes (own key package by member_identifier; the +/// public key package for the participant set and aggregation). A MULTI-SEAT +/// operator calls it once per local seat and the key packages accumulate under +/// one session (same key group). There is NO production gate: this is the real +/// distributed path, not the transitional dealer one. +pub fn persist_distributed_dkg_key_package( + mut request: PersistDistributedDkgKeyPackageRequest, +) -> Result { + const OP: &str = "persist_distributed_dkg_key_package"; + // data_hex is the serialized SECRET signing share. Move it into a zeroizing holder + // BEFORE any fallible check (validation, admission, quarantine can all return first), + // so serde's owned String is wiped on EVERY return path rather than dropped un-wiped. + let data_hex = Zeroizing::new(std::mem::take(&mut request.key_package.data_hex)); + validate_session_id(&request.session_id)?; + // Gate BEFORE decoding or persisting any key material: this op writes signing + // material to durable state that interactive signing trusts after restart, so + // an unattested runtime must not be able to install it - the same gate run_dkg + // and every interactive op enforce. + enforce_provenance_gate()?; + + if request.participant_identifier == 0 { + return Err(EngineError::Validation(format!( + "{OP}: participant identifier must be non-zero" + ))); + } + if request.threshold < 2 || request.participant_count < request.threshold { + return Err(EngineError::Validation(format!( + "{OP}: threshold [{}] must be between 2 and participant_count [{}]", + request.threshold, request.participant_count + ))); + } + + let public_key_package = native_public_key_package_to_frost(OP, &request.public_key_package)?; + + // The group public key package is the authoritative participant set. EVERY + // verifying share must have a canonical (u16-derived) identifier: a + // non-canonical one cannot be a real group member, and silently dropping it + // would let it slip past the admission allowlist/required checks below while + // still inflating the participant count. + let mut admission_participant_identifiers = HashSet::new(); + for identifier in public_key_package.verifying_shares().keys() { + match frost_identifier_to_u16(*identifier) { + Some(participant_identifier) => { + admission_participant_identifiers.insert(participant_identifier); + } + None => { + return Err(EngineError::Validation(format!( + "{OP}: public key package contains a non-canonical participant identifier" + ))) + } + } + } + + // The caller's participant_count must match the authoritative public-package + // set, or downstream consumers of the stored DkgResult get the wrong group + // size for this key material. + if request.participant_count as usize != admission_participant_identifiers.len() { + return Err(EngineError::Validation(format!( + "{OP}: participant_count [{}] does not match the public key package [{}]", + request.participant_count, + admission_participant_identifiers.len() + ))); + } + + // Enforce the SAME DKG admission policy the dealer run_dkg enforces, over the + // participant set derived from the public key package. Otherwise a caller could + // persist a package that omits a required participant or includes a + // non-allowlisted one, and interactive signing would later trust it. + enforce_admission_policy_for( + &request.session_id, + admission_participant_identifiers.len(), + &admission_participant_identifiers, + request.threshold, + )?; + + // Enforce operator quarantine over the same derived participant set, exactly + // as the dealer run_dkg does: a distributed DKG whose group includes a + // quarantined operator must not be persisted and then trusted by later + // interactive signing sessions. + let auto_quarantine_config = load_auto_quarantine_config()?; + let quarantined_operator_identifiers = { + let guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + guard.quarantined_operator_identifiers.clone() + }; + let participant_identifiers: Vec = + admission_participant_identifiers.iter().copied().collect(); + enforce_not_quarantined_identifiers( + &request.session_id, + &participant_identifiers, + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + let key_package = decode_key_package(OP, &request.key_package.identifier, &data_hex)?; + + // The key package must belong to this participant AND be consistent with the + // group public key package: matching identifier, embedded threshold, group + // verifying key, and this participant's verifying share. An inconsistent + // package (e.g. min_signers 3 vs a stored threshold of 2, or a share from a + // different DKG) would let interactive signing open an attempt it can never + // complete and burn it at share release. + let frost_identifier = + participant_identifier_to_frost_identifier(request.participant_identifier)?; + if *key_package.identifier() != frost_identifier { + return Err(EngineError::Validation(format!( + "{OP}: key package identifier does not match participant_identifier" + ))); + } + if *key_package.min_signers() != request.threshold { + return Err(EngineError::Validation(format!( + "{OP}: key package min_signers [{}] does not match threshold [{}]", + *key_package.min_signers(), + request.threshold + ))); + } + if key_package.verifying_key() != public_key_package.verifying_key() { + return Err(EngineError::Validation(format!( + "{OP}: key package group verifying key does not match the public key package" + ))); + } + match public_key_package.verifying_shares().get(&frost_identifier) { + None => { + return Err(EngineError::Validation(format!( + "{OP}: participant_identifier is not a member of the public key package" + ))) + } + Some(verifying_share) if verifying_share != key_package.verifying_share() => { + return Err(EngineError::Validation(format!( + "{OP}: key package verifying share does not match the public key package" + ))) + } + Some(_) => {} + } + + // The checks above only trust the PUBLIC verifying share embedded in the key + // package; Round2 signs with the embedded SECRET signing share, and + // deserialization does not prove the signing scalar derives to that public + // share. Verify signing_share -> verifying_share, so a corrupt or malformed + // key package cannot be stored and then burn signing attempts producing shares + // that never verify. + // signing_share() is Copy (frost-core SigningShare is Copy + DefaultIsZeroes, NOT + // ZeroizeOnDrop), so bind the extracted copy and zeroize it right after the check - + // otherwise the secret scalar lingers as un-wiped stack residue. (The copy frost's + // own by-value VerifyingShare::from makes internally is beyond our reach.) + let mut signing_share = *key_package.signing_share(); + let derives_to_verifying_share = + frost::keys::VerifyingShare::from(signing_share) == *key_package.verifying_share(); + signing_share.zeroize(); + if !derives_to_verifying_share { + return Err(EngineError::Validation(format!( + "{OP}: key package signing share does not derive to its verifying share" + ))); + } + + let key_group = public_key_package + .verifying_key() + .serialize() + .map(hex::encode) + .map_err(|e| { + EngineError::Internal(format!("{OP}: failed to serialize verifying key: {e}")) + })?; + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + + let session = guard + .sessions + .entry(request.session_id.clone()) + .or_insert_with(SessionState::default); + + // A session may already hold a DKG result: this seat re-persisting (idempotent) + // or, for a MULTI-SEAT operator, a sibling seat of the SAME distributed DKG. + // Same key group -> accumulate this seat's key package into the session; a + // different key group for the same session is a conflict. + if let Some(existing) = &session.dkg_result { + if existing.key_group != key_group { + return Err(EngineError::SessionConflict { + session_id: request.session_id, + }); + } + // Same group key is NOT enough: a sibling seat of the SAME distributed DKG + // must carry the SAME threshold, participant count, and public key package. + // Otherwise a second seat could be validated against a different submitted + // public package while the session keeps the first, so later signing would + // use public material inconsistent with this seat's key. + if existing.threshold != request.threshold + || existing.participant_count != request.participant_count + { + return Err(EngineError::Validation(format!( + "{OP}: threshold/participant_count does not match the stored DKG for this session" + ))); + } + if session.dkg_public_key_package.as_ref() != Some(&public_key_package) { + return Err(EngineError::Validation(format!( + "{OP}: public key package does not match the stored DKG for this session" + ))); + } + } else { + session.dkg_result = Some(DkgResult { + session_id: request.session_id.clone(), + key_group, + participant_count: request.participant_count, + threshold: request.threshold, + created_at_unix: now_unix(), + }); + session.dkg_public_key_package = Some(public_key_package); + } + + session + .dkg_key_packages + .get_or_insert_with(BTreeMap::new) + .insert(request.participant_identifier, key_package); + + // Clone the result before the `&guard` persist call so the mutable `session` + // borrow ends here (mirrors run_dkg's ordering). + let result = session + .dkg_result + .clone() + .expect("dkg_result was just set for this session"); + persist_engine_state_to_storage(&guard)?; + + Ok(result) +} + pub(crate) fn enforce_bootstrap_dealer_dkg_disabled_in_production( session_id: &str, ) -> Result<(), EngineError> { diff --git a/pkg/tbtc/signer/src/engine/frost_ops.rs b/pkg/tbtc/signer/src/engine/frost_ops.rs index 018ffdb274..b3a4ba041b 100644 --- a/pkg/tbtc/signer/src/engine/frost_ops.rs +++ b/pkg/tbtc/signer/src/engine/frost_ops.rs @@ -173,14 +173,17 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result Result { + // key_package_hex is the serialized SECRET signing share. Hold it zeroizing so + // serde's owned String is wiped on EVERY return path, not left to drop un-wiped. + let key_package_hex = Zeroizing::new(std::mem::take(&mut request.key_package_hex)); enforce_provenance_gate()?; let key_package = decode_key_package( "GenerateNoncesAndCommitments", &request.key_package_identifier, - &request.key_package_hex, + &key_package_hex, )?; let mut rng = zeroizing_rng_from_os(); let (mut nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng); @@ -233,7 +236,13 @@ pub fn new_signing_package( }) } -pub fn sign_share(request: SignShareRequest) -> Result { +pub fn sign_share(mut request: SignShareRequest) -> Result { + // The two SECRET request fields - the one-time signing nonces and this seat's + // signing key package - are held zeroizing so serde's owned Strings are wiped on + // EVERY return path (every decode/deserialize below can fail first), not left to + // drop un-wiped. signing_package_hex is public, so it stays in request. + let nonces_hex = Zeroizing::new(std::mem::take(&mut request.nonces_hex)); + let key_package_hex = Zeroizing::new(std::mem::take(&mut request.key_package_hex)); enforce_provenance_gate()?; let signing_package_bytes = decode_hex_field( @@ -244,17 +253,18 @@ pub fn sign_share(request: SignShareRequest) -> Result key_package, Err(err) => { nonces.zeroize(); diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 3ab889ac04..48fb465e62 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -145,130 +145,164 @@ pub fn interactive_session_open( // attempt context against the DKG threshold/key group - mirroring // the coarse start_sign_round - all under one immutable borrow, // then do the mutable install. - let (key_package, canonical_included_participants) = { - let session = guard.sessions.get(&request.session_id).ok_or_else(|| { - EngineError::SessionNotFound { + // The DKG key material is a WALLET-level asset keyed by key_group, not by the + // per-signing session_id: interactive signing runs under a fresh RoastSessionID + // per message, while the wallet key lives under the session its DKG completed in. + // Resolve that wallet session by key_group so ANY signing session can reach the + // material (and the wallet-level policy gates below); the per-signing state + // (consumed markers, live attempt, nonces) still lives under request.session_id, + // and the attempt context is still validated against request.session_id so + // coordinator/attempt derivation is unchanged. DkgNotReady now means "no wallet + // key for this key_group" rather than "this exact session lacks DKG". + let wallet_session_id = + resolve_wallet_session_id(&guard, &request.session_id, &request.key_group).ok_or_else( + || EngineError::DkgNotReady { session_id: request.session_id.clone(), + }, + )?; + let (key_package, canonical_included_participants) = + { + let session = guard.sessions.get(&wallet_session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: wallet_session_id.clone(), + } + })?; + let dkg = session + .dkg_result + .as_ref() + .ok_or_else(|| EngineError::DkgNotReady { + session_id: request.session_id.clone(), + })?; + if request.key_group != dkg.key_group { + return Err(EngineError::Validation( + "key_group does not match DKG output for this session".to_string(), + )); } - })?; - let dkg = session - .dkg_result - .as_ref() - .ok_or_else(|| EngineError::DkgNotReady { - session_id: request.session_id.clone(), + if request.threshold != dkg.threshold { + return Err(EngineError::Validation(format!( + "threshold [{}] does not match the DKG threshold [{}] for this session", + request.threshold, dkg.threshold + ))); + } + let dkg_key_packages = session.dkg_key_packages.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG key package cache".to_string()) })?; - if request.key_group != dkg.key_group { - return Err(EngineError::Validation( - "key_group does not match DKG output for this session".to_string(), - )); - } - if request.threshold != dkg.threshold { - return Err(EngineError::Validation(format!( - "threshold [{}] does not match the DKG threshold [{}] for this session", - request.threshold, dkg.threshold - ))); - } - let dkg_key_packages = session - .dkg_key_packages - .as_ref() - .ok_or_else(|| EngineError::Internal("missing DKG key package cache".to_string()))?; - let key_package = dkg_key_packages - .get(&request.member_identifier) + // The public key package carries a verifying share for EVERY DKG + // participant, so it is the authoritative participant set. A distributed + // DKG node holds only its OWN secret key package (dkg_key_packages has a + // single entry), so the included-participants membership check below must + // use the public package, not dkg_key_packages. + let dkg_public_key_package = + session.dkg_public_key_package.as_ref().ok_or_else(|| { + EngineError::Internal("missing DKG public key package".to_string()) + })?; + let key_package = dkg_key_packages + .get(&request.member_identifier) + .ok_or_else(|| { + EngineError::Validation( + "member_identifier is not a DKG participant for this session".to_string(), + ) + })? + .clone(); + + // Lifecycle + quarantine + signing-policy-firewall gates (frozen + // spec section 5: Open "checks policy gates"). The SAME helper + // runs again at Round2 (the share-release moment) so a policy + // change recorded after Open - emergency rekey, finalization, + // quarantine, or a re-bound policy-checked tx - cannot let a + // share escape. At Open only this node's own member is known to + // sign; Round2 re-checks quarantine over the actual chosen + // subset. + enforce_interactive_signing_gates( + &request.session_id, + &[request.member_identifier], + &request.message_hex, + session.emergency_rekey_event.as_ref(), + session.finalize_request_fingerprint.is_some(), + session.tx_result.as_ref(), + &guard.quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + // Strict-mode-only attempt context: required, fully validated + // against the DKG threshold/key group, coordinator recomputed + // per RFC-21 Annex A. + let canonical_included_participants = validate_attempt_context( + &request.session_id, + &dkg.key_group, + &message_bytes, + &message_digest_hex, + dkg.threshold, + Some(&request.attempt_context), + true, + )? .ok_or_else(|| { - EngineError::Validation( - "member_identifier is not a DKG participant for this session".to_string(), + EngineError::Internal( + "strict attempt context validation returned no participants".to_string(), ) - })? - .clone(); - - // Lifecycle + quarantine + signing-policy-firewall gates (frozen - // spec section 5: Open "checks policy gates"). The SAME helper - // runs again at Round2 (the share-release moment) so a policy - // change recorded after Open - emergency rekey, finalization, - // quarantine, or a re-bound policy-checked tx - cannot let a - // share escape. At Open only this node's own member is known to - // sign; Round2 re-checks quarantine over the actual chosen - // subset. - enforce_interactive_signing_gates( - &request.session_id, - &[request.member_identifier], - &request.message_hex, - session.emergency_rekey_event.as_ref(), - session.finalize_request_fingerprint.is_some(), - session.tx_result.as_ref(), - &guard.quarantined_operator_identifiers, - auto_quarantine_config.as_ref(), - )?; - - // Strict-mode-only attempt context: required, fully validated - // against the DKG threshold/key group, coordinator recomputed - // per RFC-21 Annex A. - let canonical_included_participants = validate_attempt_context( - &request.session_id, - &dkg.key_group, - &message_bytes, - &message_digest_hex, - dkg.threshold, - Some(&request.attempt_context), - true, - )? - .ok_or_else(|| { - EngineError::Internal( - "strict attempt context validation returned no participants".to_string(), - ) - })?; - if !canonical_included_participants.contains(&request.member_identifier) { - return Err(EngineError::Validation( - "member_identifier must be included in attempt_context.included_participants" - .to_string(), - )); - } - // Every included participant must be a real DKG member of this - // session. Otherwise a caller could pad the included set with - // phantom identifiers to bias the RFC-21 coordinator/attempt - // derivation, and Round2 could release a share under an attempt - // context that is not a genuine DKG subset. - for participant in &canonical_included_participants { - if !dkg_key_packages.contains_key(participant) { - return Err(EngineError::Validation(format!( - "attempt_context.included_participants contains [{participant}], \ + })?; + if !canonical_included_participants.contains(&request.member_identifier) { + return Err(EngineError::Validation( + "member_identifier must be included in attempt_context.included_participants" + .to_string(), + )); + } + // Every included participant must be a real DKG member of this + // session. Otherwise a caller could pad the included set with + // phantom identifiers to bias the RFC-21 coordinator/attempt + // derivation, and Round2 could release a share under an attempt + // context that is not a genuine DKG subset. Checked against the public + // key package (the full participant set) so it holds for a distributed + // DKG node, which caches only its own secret key package. + for participant in &canonical_included_participants { + let participant_frost_identifier = + participant_identifier_to_frost_identifier(*participant)?; + if !dkg_public_key_package + .verifying_shares() + .contains_key(&participant_frost_identifier) + { + return Err(EngineError::Validation(format!( + "attempt_context.included_participants contains [{participant}], \ which is not a DKG participant for this session" - ))); + ))); + } } - } - (key_package, canonical_included_participants) - }; + (key_package, canonical_included_participants) + }; // Disposition over the (now-confirmed) existing session: consumed // marker, idempotent/conflicting reopen of this exact attempt, and // the live attempt (id + number) for the replacement decision. let member_identifier = request.member_identifier; - let (already_consumed, matching_attempt_idempotent, live_attempt) = { - let session = guard - .sessions - .get(&request.session_id) - .expect("session existed under the held engine lock"); - // Per-member consumed check: this member's composite marker, or a legacy - // bare attempt_id marker (fail-closed for the whole attempt). - let already_consumed = interactive_attempt_consumed( - &session.consumed_interactive_attempt_markers, - &attempt_id, - member_identifier, - ); - // Disposition is scoped to THIS member's live entry; sibling seats are - // independent and on their own attempt timelines. - let live = session.interactive_signing.get(&member_identifier); - let matching_attempt_idempotent = live - .filter(|interactive| interactive.attempt_context.attempt_id == attempt_id) - .map(|interactive| interactive.open_request_fingerprint == request_fingerprint); - let live_attempt = live.map(|interactive| { - ( - interactive.attempt_context.attempt_id.clone(), - interactive.attempt_context.attempt_number, - ) - }); - (already_consumed, matching_attempt_idempotent, live_attempt) - }; + let (already_consumed, matching_attempt_idempotent, live_attempt) = + match guard.sessions.get(&request.session_id) { + Some(session) => { + // Per-member consumed check: this member's composite marker, or a legacy + // bare attempt_id marker (fail-closed for the whole attempt). + let already_consumed = interactive_attempt_consumed( + &session.consumed_interactive_attempt_markers, + &attempt_id, + member_identifier, + ); + // Disposition is scoped to THIS member's live entry; sibling seats are + // independent and on their own attempt timelines. + let live = session.interactive_signing.get(&member_identifier); + let matching_attempt_idempotent = live + .filter(|interactive| interactive.attempt_context.attempt_id == attempt_id) + .map(|interactive| interactive.open_request_fingerprint == request_fingerprint); + let live_attempt = live.map(|interactive| { + ( + interactive.attempt_context.attempt_id.clone(), + interactive.attempt_context.attempt_number, + ) + }); + (already_consumed, matching_attempt_idempotent, live_attempt) + } + // A fresh per-signing session (a distinct RoastSessionID that is not the + // wallet's DKG session) does not exist yet: no consumed markers, no live + // attempt. It is created at the install below. + None => (false, None, None), + }; if already_consumed { return Err(EngineError::ConsumedNonceReplay { @@ -343,10 +377,47 @@ pub fn interactive_session_open( } } + // A per-signing session is keyed by RoastSessionID (message/root/start-block), NOT + // by key_group, so two DIFFERENT wallets signing the same digest at the same block + // on a node that holds members of both could collide on one session id. A session + // belongs to exactly ONE wallet key for its lifetime: reject an Open whose key_group + // differs from the session's ESTABLISHED one - its DKG key group when it is a + // co-located DKG session, else the key group bound by a prior Open. Rejecting + // regardless of live members keeps bound_key_group and dkg_result mutually + // consistent so Round2/Aggregate/verify_share always resolve the right wallet. This + // closes both (a) the rebind window that outlived a member's Round2 (the live-entry + // set is empty in the consumed-but-unaggregated gap) and (b) binding through another + // wallet's idle DKG session (where dkg_result would otherwise win over bound_key_group + // and sign B's share against A's material, bypassing B's rekey/finalization gates). + if let Some(existing) = guard.sessions.get(&request.session_id) { + let established = existing + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.as_str()) + .or(existing.bound_key_group.as_deref()); + if let Some(established) = established { + if established != request.key_group { + return Err(EngineError::SessionConflict { + session_id: request.session_id.clone(), + }); + } + } + } + + // Create the per-signing session on first Open if it is distinct from the wallet + // DKG session (the production case). Its DKG material is NOT copied here - it stays + // the single wallet copy, resolved by key_group; only per-signing state lives here. + // Bound by the SAME total-session cap as every other session-creating path (a fresh + // RoastSessionID per message would otherwise let the registry grow unbounded and + // then be rejected on reload); a reopen of an existing session is exempt. + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; let session = guard .sessions - .get_mut(&request.session_id) - .expect("session existed under the held engine lock"); + .entry(request.session_id.clone()) + .or_insert_with(SessionState::default); + // Bind this signing session to the wallet key it signs for, so Round2 and Aggregate + // resolve the same wallet material by key_group. + session.bound_key_group = Some(request.key_group.clone()); // Replace only THIS member's prior entry (zeroizing its old nonces); sibling // seats' entries are untouched. @@ -492,6 +563,36 @@ pub fn interactive_round2( let auto_quarantine_config = load_auto_quarantine_config()?; let quarantined_operator_identifiers = guard.quarantined_operator_identifiers.clone(); + // Wallet-level policy gates (emergency rekey / finalization / tx-binding) live on + // the WALLET (DKG) session, NOT this per-signing session. Resolve them by the + // key_group this session serves (its own DKG when co-located, else the key_group + // bound at Open) so the Round2 kill-switch re-check - the share-release moment - + // fires for cross-session interactive signing too. Read here (before the mutable + // per-signing borrow) into owned locals; if read from the empty per-signing + // session a rekey/finalization recorded AFTER Open would silently fail OPEN. + let (wallet_emergency_rekey, wallet_finalized, wallet_tx_result) = { + let bound_key_group = guard.sessions.get(&request.session_id).and_then(|session| { + session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.clone()) + .or_else(|| session.bound_key_group.clone()) + }); + match bound_key_group + .and_then(|key_group| { + resolve_wallet_session_id(&guard, &request.session_id, &key_group) + }) + .and_then(|wallet_session_id| guard.sessions.get(&wallet_session_id)) + { + Some(wallet) => ( + wallet.emergency_rekey_event.clone(), + wallet.finalize_request_fingerprint.is_some(), + wallet.tx_result.clone(), + ), + None => (None, false, None), + } + }; + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { EngineError::SessionNotFound { session_id: request.session_id.clone(), @@ -579,9 +680,9 @@ pub fn interactive_round2( &request.session_id, &[request.member_identifier], &bound_message_hex, - session.emergency_rekey_event.as_ref(), - session.finalize_request_fingerprint.is_some(), - session.tx_result.as_ref(), + wallet_emergency_rekey.as_ref(), + wallet_finalized, + wallet_tx_result.as_ref(), &quarantined_operator_identifiers, auto_quarantine_config.as_ref(), )?; @@ -764,30 +865,54 @@ pub fn interactive_aggregate( // and so a caller cannot substitute verifying material. The session // must exist with completed DKG. let public_key_package = { - let session = guard.sessions.get(&request.session_id).ok_or_else(|| { - EngineError::SessionNotFound { - session_id: request.session_id.clone(), + // The completion marker is per-signing-session state; read it - and the wallet + // key_group this session serves - from request.session_id. + let key_group = { + let session = guard.sessions.get(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + // Reject a completed attempt: re-aggregation is not a recovery path (a + // lost signature is recovered with a fresh attempt), and the marker is + // durable so a completed attempt stays rejected across a restart. + if interactive_attempt_aggregated( + &session.aggregated_interactive_attempt_markers, + &attempt_id, + &aggregated_message_digest, + taproot_merkle_root.as_ref(), + ) { + return Err(EngineError::InteractiveAttemptAlreadyAggregated { + session_id: request.session_id.clone(), + attempt_id, + }); } - })?; - // Reject a completed attempt: re-aggregation is not a recovery path (a - // lost signature is recovered with a fresh attempt), and the marker is - // durable so a completed attempt stays rejected across a restart. - if interactive_attempt_aggregated( - &session.aggregated_interactive_attempt_markers, - &attempt_id, - &aggregated_message_digest, - taproot_merkle_root.as_ref(), - ) { - return Err(EngineError::InteractiveAttemptAlreadyAggregated { - session_id: request.session_id.clone(), - attempt_id, - }); - } - if session.dkg_result.is_none() { - return Err(EngineError::DkgNotReady { + // The wallet key this signing session serves: its own DKG (co-located) or + // the key_group bound at Open (distinct per-signing RoastSessionID). + session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.clone()) + .or_else(|| session.bound_key_group.clone()) + .ok_or_else(|| EngineError::DkgNotReady { + session_id: request.session_id.clone(), + })? + }; + // The group's public key package (the verifying shares used to check each + // contribution) is a WALLET-level asset resolved by key_group, so a per-signing + // session can verify shares. Read from the engine's own DKG state, not the + // request, so a caller cannot substitute verifying material. + let wallet_session_id = resolve_wallet_session_id(&guard, &request.session_id, &key_group) + .ok_or_else(|| EngineError::DkgNotReady { session_id: request.session_id.clone(), - }); - } + })?; + let session = + guard + .sessions + .get(&wallet_session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: wallet_session_id.clone(), + })?; session .dkg_public_key_package .as_ref() @@ -1222,6 +1347,47 @@ pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningSta // acting, so an abandoned session's nonces are destroyed the first // time anything touches the engine after expiry. Expiry has abort // semantics - the durable consumption markers are untouched. +/// Resolve the session that holds the DKG key material for `key_group`. +/// +/// Interactive signing runs under a fresh RoastSessionID per message, but a wallet's +/// DKG key material is a WALLET-level asset that lives under the session its DKG +/// completed in. This returns that wallet session so any per-signing session can reach +/// the material by key_group: +/// - prefer `session_id` itself if it already holds this wallet's DKG output (the +/// co-located case: DKG and signing share one session, as in the coarse path and +/// the single-session tests); +/// - otherwise find the session whose completed DKG produced `key_group`. +/// +/// Returns None when no completed DKG for `key_group` exists (i.e. no wallet key), which +/// callers map to DkgNotReady. +pub(crate) fn resolve_wallet_session_id( + engine_state: &EngineState, + session_id: &str, + key_group: &str, +) -> Option { + // Prefer the request's own session (the co-located DKG+signing case). + if let Some(session) = engine_state.sessions.get(session_id) { + if session + .dkg_result + .as_ref() + .is_some_and(|dkg| dkg.key_group == key_group) + { + return Some(session_id.to_string()); + } + } + // Otherwise find the wallet session whose completed DKG produced this key_group. + engine_state + .sessions + .iter() + .find(|(_, session)| { + session + .dkg_result + .as_ref() + .is_some_and(|dkg| dkg.key_group == key_group) + }) + .map(|(id, _)| id.clone()) +} + pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) { let ttl_seconds = interactive_session_ttl_seconds(); let now = now_unix(); diff --git a/pkg/tbtc/signer/src/engine/lifecycle.rs b/pkg/tbtc/signer/src/engine/lifecycle.rs index 85083fba6d..bc6f574458 100644 --- a/pkg/tbtc/signer/src/engine/lifecycle.rs +++ b/pkg/tbtc/signer/src/engine/lifecycle.rs @@ -132,15 +132,37 @@ pub fn trigger_emergency_rekey( let mut guard = state()? .lock() .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; - let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { - EngineError::SessionNotFound { - session_id: request.session_id.clone(), - } - })?; + + // Emergency rekey is a WALLET-level kill switch, and interactive Round2 reads it + // from the wallet (DKG) session resolved by key_group. Defense in depth: if a + // caller passes a per-signing session id (a distinct RoastSessionID bound to a + // wallet key but holding no DKG of its own), record the event on the WALLET session + // it serves, so the writer lands the kill switch exactly where every reader looks - + // the writer and reader can never diverge. A session that already holds the DKG + // resolves to itself, so co-located callers are unchanged. + let target_session_id = guard + .sessions + .get(&request.session_id) + .and_then(|session| { + if session.dkg_result.is_some() { + None + } else { + session.bound_key_group.clone() + } + }) + .and_then(|key_group| resolve_wallet_session_id(&guard, &request.session_id, &key_group)) + .unwrap_or_else(|| request.session_id.clone()); + + let session = + guard + .sessions + .get_mut(&target_session_id) + .ok_or_else(|| EngineError::SessionNotFound { + session_id: request.session_id.clone(), + })?; if session.emergency_rekey_event.is_some() { return Err(EngineError::Validation(format!( - "emergency rekey already triggered for session [{}]; event is immutable", - request.session_id + "emergency rekey already triggered for session [{target_session_id}]; event is immutable" ))); } let triggered_at_unix = now_unix(); @@ -151,11 +173,11 @@ pub fn trigger_emergency_rekey( persist_engine_state_to_storage(&guard)?; Ok(TriggerEmergencyRekeyResult { - session_id: request.session_id.clone(), + session_id: target_session_id.clone(), emergency_rekey_required: true, reason: reason.to_string(), triggered_at_unix, - recommended_new_session_id: format!("{}-rekey-{}", request.session_id, triggered_at_unix), + recommended_new_session_id: format!("{target_session_id}-rekey-{triggered_at_unix}"), }) } diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index 2a2452bbe3..c30b657618 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -75,14 +75,14 @@ use crate::api::{ InteractiveSessionAbortRequest, InteractiveSessionAbortResult, InteractiveSessionOpenRequest, InteractiveSessionOpenResult, NativeFrostCommitment, NativeFrostKeyPackage, NativeFrostPublicKeyPackage, NativeFrostSignatureShare, NewSigningPackageRequest, - NewSigningPackageResult, ParticipantFrostIdentifier, PromoteCanaryRequest, PromoteCanaryResult, - QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, - RefreshCadenceStatusResult, RefreshSharesRequest, RefreshSharesResult, - RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, RoundContribution, - RoundState, RunDkgRequest, ShareMaterial, SignShareRequest, SignShareResult, SignatureResult, - SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, TranscriptAuditRecord, - TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest, - TriggerEmergencyRekeyResult, VerifyBlameProofRequest, + NewSigningPackageResult, ParticipantFrostIdentifier, PersistDistributedDkgKeyPackageRequest, + PromoteCanaryRequest, PromoteCanaryResult, QuarantineStatusRequest, QuarantineStatusResult, + RefreshCadenceStatusRequest, RefreshCadenceStatusResult, RefreshSharesRequest, + RefreshSharesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, + RoundContribution, RoundState, RunDkgRequest, ShareMaterial, SignShareRequest, SignShareResult, + SignatureResult, SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, + TranscriptAuditRecord, TranscriptAuditRequest, TranscriptAuditResult, + TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, VerifyBlameProofRequest, }; use crate::errors::EngineError; use crate::go_math_rand::select_coordinator_identifier; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index 667521df74..b98377d4b5 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -63,6 +63,15 @@ pub(crate) struct PersistedSessionState { // deserializes to an empty set. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(crate) aggregated_interactive_attempt_markers: Vec, + // The wallet key_group a per-signing (cross-session) attempt is bound to. Durable + // ALONGSIDE the interactive markers: for a distributed-DKG wallet the signing + // session has no dkg_result, so this is the ONLY link back to the wallet DKG. It + // must survive a restart between Round2 (shares consumed, markers written) and + // InteractiveAggregate, or Aggregate/verify_share would resolve neither dkg_result + // nor bound_key_group and return DkgNotReady, stranding the collected shares. Public + // (a key group id), not secret. serde(default) keeps pre-existing state loadable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) bound_key_group: Option, } // Hand-written Debug: `sign_message_hex` is `SecretString` @@ -126,6 +135,7 @@ impl std::fmt::Debug for PersistedSessionState { "aggregated_interactive_attempt_markers", &self.aggregated_interactive_attempt_markers, ) + .field("bound_key_group", &self.bound_key_group) .finish() } } @@ -1448,6 +1458,11 @@ impl TryFrom for SessionState { // construction after a restart, so the attempt fails safe and // only the consumption markers survive. Empty map (no live members). interactive_signing: BTreeMap::new(), + // Restore the wallet binding: for a cross-session signing session it is the + // only durable link to the wallet DKG, needed so an InteractiveAggregate that + // runs after a restart (past a member's Round2) can still resolve the wallet + // by key_group. Public data; survives with the consumed/aggregate markers. + bound_key_group: persisted.bound_key_group, consumed_interactive_attempt_markers, aggregated_interactive_attempt_markers, }) @@ -1591,6 +1606,7 @@ impl TryFrom<&SessionState> for PersistedSessionState { emergency_rekey_event: session_state.emergency_rekey_event.clone(), consumed_interactive_attempt_markers, aggregated_interactive_attempt_markers, + bound_key_group: session_state.bound_key_group.clone(), }) } } diff --git a/pkg/tbtc/signer/src/engine/policy.rs b/pkg/tbtc/signer/src/engine/policy.rs index c13d2ec081..fb24f0b8ef 100644 --- a/pkg/tbtc/signer/src/engine/policy.rs +++ b/pkg/tbtc/signer/src/engine/policy.rs @@ -175,53 +175,69 @@ pub(crate) fn reject_admission_policy( } pub(crate) fn enforce_admission_policy(request: &RunDkgRequest) -> Result<(), EngineError> { + let participant_identifiers: HashSet = request + .participants + .iter() + .map(|participant| participant.identifier) + .collect(); + enforce_admission_policy_for( + &request.session_id, + request.participants.len(), + &participant_identifiers, + request.threshold, + ) +} + +/// Admission checks over the raw participant primitives, shared by the dealer +/// `run_dkg` and the distributed-DKG persist path (which derives the participant +/// identifiers from the group public key package rather than a dealer request). +pub(crate) fn enforce_admission_policy_for( + session_id: &str, + participant_count: usize, + participant_identifiers: &HashSet, + threshold: u16, +) -> Result<(), EngineError> { let policy = match load_admission_policy_config() { Ok(Some(policy)) => policy, Ok(None) => return Ok(()), Err(error) => { return reject_admission_policy( - &request.session_id, + session_id, "invalid_policy_configuration", error.to_string(), ) } }; - if request.participants.len() < policy.min_participants { + if participant_count < policy.min_participants { return reject_admission_policy( - &request.session_id, + session_id, "participant_count_below_policy_minimum", format!( "participant count [{}] below policy minimum [{}]", - request.participants.len(), - policy.min_participants + participant_count, policy.min_participants ), ); } - if request.threshold < policy.min_threshold { + if threshold < policy.min_threshold { return reject_admission_policy( - &request.session_id, + session_id, "threshold_below_policy_minimum", format!( "threshold [{}] below policy minimum [{}]", - request.threshold, policy.min_threshold + threshold, policy.min_threshold ), ); } - let participant_identifiers: HashSet = request - .participants - .iter() - .map(|participant| participant.identifier) - .collect(); if let Some(required_identifier) = policy .required_identifiers .iter() .find(|identifier| !participant_identifiers.contains(identifier)) { return reject_admission_policy( - &request.session_id, + session_id, "required_identifier_missing", format!( "required identifier [{}] missing from request", @@ -236,7 +252,7 @@ pub(crate) fn enforce_admission_policy(request: &RunDkgRequest) -> Result<(), En .find(|identifier| !allowlist_identifiers.contains(identifier)) { return reject_admission_policy( - &request.session_id, + session_id, "participant_identifier_not_allowlisted", format!( "participant identifier [{}] not present in configured allowlist", @@ -246,7 +262,7 @@ pub(crate) fn enforce_admission_policy(request: &RunDkgRequest) -> Result<(), En } } - log_policy_decision("admission_policy", &request.session_id, "allow", "ok"); + log_policy_decision("admission_policy", session_id, "allow", "ok"); Ok(()) } diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index f145920e4a..cb1bafb591 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -114,6 +114,14 @@ pub(crate) struct SessionState { // Keyed by member_identifier; each entry is independent (own attempt, nonces, // replace/round2/expiry). Was Option (one member per session). pub(crate) interactive_signing: BTreeMap, + // The key_group this per-signing session signs for, set at InteractiveSessionOpen. + // Interactive signing runs under a fresh RoastSessionID per message, so a wallet's + // DKG material lives under a DIFFERENT (wallet/DKG) session; this binds the signing + // session to its wallet key so Round2/Aggregate resolve the same material by + // key_group. Transient (re-set on every Open); deliberately NOT persisted, because + // the in-memory interactive attempt it serves does not survive a restart either - + // a restart forces a fresh Open, which re-sets it. + pub(crate) bound_key_group: Option, pub(crate) consumed_interactive_attempt_markers: HashSet, // Phase 7.2b InteractiveAggregate completion markers: an attempt whose // aggregate signature has been produced is recorded here so a repeat diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index d76be5121a..83b7896c8b 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -703,6 +703,7 @@ fn persisted_session_state_fixture() -> PersistedSessionState { emergency_rekey_event: None, consumed_interactive_attempt_markers: vec![], aggregated_interactive_attempt_markers: vec![], + bound_key_group: None, } } @@ -908,6 +909,402 @@ fn canary_rollout_status_rejects_when_provenance_gate_requires_attestation() { clear_state_storage_policy_overrides(); } +// Real per-seat distributed-DKG material in the native (Go-facing) form the +// persist op takes: each member's OWN key package plus the shared public key +// package. Dealer-generated here for brevity, but shaped like a distributed +// Part3 output (one key package per member + one public key package). +fn sample_distributed_dkg_native_material( + seed: u8, +) -> ( + crate::api::NativeFrostPublicKeyPackage, + BTreeMap, +) { + let identifiers = [1_u16, 2, 3] + .iter() + .map(|m| participant_identifier_to_frost_identifier(*m).expect("frost identifier")) + .collect::>(); + let rng = ZeroizingChaCha20Rng::from_seed([seed; 32]); + let (shares, public_key_package) = frost::keys::generate_with_dealer( + 3, + 2, + frost::keys::IdentifierList::Custom(&identifiers), + rng, + ) + .expect("generate_with_dealer"); + + // Normalize to even-Y exactly as dkg_part3 does, so this material matches a + // real distributed DKG's output (the x-only verifying key is even-Y per + // BIP-340); raw generate_with_dealer output is odd-Y for some seeds. + let is_even_y = public_key_package.has_even_y(); + let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + let native_public = + native_public_key_package_from_frost(&public_key_package).expect("native public package"); + + let mut native_key_packages = BTreeMap::new(); + for member in [1_u16, 2, 3] { + let frost_id = + participant_identifier_to_frost_identifier(member).expect("frost identifier"); + let share = shares.get(&frost_id).expect("share for member").clone(); + let key_package = frost::keys::KeyPackage::try_from(share) + .expect("key package") + .into_even_y(Some(is_even_y)); + native_key_packages.insert( + member, + crate::api::NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(key_package.serialize().expect("serialize key package")), + }, + ); + } + + (native_public, native_key_packages) +} + +// A multi-seat operator persists several local seats of the SAME distributed DKG +// into one session; the key packages must accumulate (not overwrite), so every +// local seat can later open an interactive signing session. +#[test] +fn persist_distributed_dkg_key_package_accumulates_seats_under_one_session() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let session_id = "session-distributed-persist-accumulate".to_string(); + + let result1 = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist seat 1"); + assert_eq!(result1.threshold, 2); + assert_eq!(result1.participant_count, 3); + assert!(!result1.key_group.is_empty()); + + let result2 = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 2, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&2).expect("seat 2").clone(), + public_key_package: native_public.clone(), + }) + .expect("persist seat 2"); + assert_eq!( + result2.key_group, result1.key_group, + "sibling seats of one DKG must share the key group" + ); + + let guard = state().expect("state").lock().expect("engine lock"); + let session = guard.sessions.get(&session_id).expect("session exists"); + let key_packages = session + .dkg_key_packages + .as_ref() + .expect("key packages present"); + assert!( + key_packages.contains_key(&1) && key_packages.contains_key(&2), + "both accumulated seats must be stored (got {:?})", + key_packages.keys().collect::>() + ); + assert!(session.dkg_public_key_package.is_some()); +} + +// The op rejects a key package whose own identifier does not match the claimed +// participant, and refuses to install a DIFFERENT DKG's key group over a session +// that already holds one. +#[test] +fn persist_distributed_dkg_key_package_rejects_mismatched_and_conflicting_inputs() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + + let mismatch = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-mismatch".to_string(), + participant_identifier: 2, // the key package below is seat 1's + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect_err("identifier mismatch must be rejected"); + assert!( + matches!(mismatch, EngineError::Validation(_)), + "got {mismatch:?}" + ); + + // A threshold that disagrees with the key package's embedded min_signers (the + // material is 2-of-3) must be rejected at persist, not burned at share release. + let threshold_mismatch = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-threshold-mismatch".to_string(), + participant_identifier: 1, + threshold: 3, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect_err("a threshold disagreeing with the key package must be rejected"); + assert!( + matches!(threshold_mismatch, EngineError::Validation(_)), + "got {threshold_mismatch:?}" + ); + + let session_id = "session-persist-conflict".to_string(); + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("first DKG persists"); + + let (other_public, other_key_packages) = sample_distributed_dkg_native_material(9); + let conflict = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: other_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: other_public, + }) + .expect_err("a different key group for the same session must conflict"); + assert!( + matches!(conflict, EngineError::SessionConflict { .. }), + "got {conflict:?}" + ); +} + +// The op writes durable signing material, so it enforces the DKG admission policy +// over the participant set DERIVED from the public key package: a group that +// includes a non-allowlisted participant is rejected before anything is stored. +#[test] +fn persist_distributed_dkg_key_package_enforces_admission_policy() { + let _guard = lock_test_state(); + reset_for_tests(); + + clear_state_storage_policy_overrides(); + std::env::set_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV, "true"); + std::env::set_var(TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV, "1,2"); + + // The group's members are 1, 2, 3 (from the public key package); member 3 is + // not on the allowlist, so persistence must be refused. + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-admission".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("a group with a non-allowlisted participant must be rejected"); + + let EngineError::AdmissionPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "participant_identifier_not_allowlisted"); + + // Restore the global policy env so later tests see the default configuration + // (reset_for_tests does not clear the admission overrides). + clear_state_storage_policy_overrides(); +} + +// A distributed DKG whose group includes an auto-quarantined operator must be +// refused before persistence, exactly as the dealer run_dkg refuses it. +#[test] +fn persist_distributed_dkg_key_package_rejects_quarantined_participant() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, "2"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, "1"); + std::env::set_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, "2"); + + // Operator 3, a member of the group below (members 1,2,3), is auto-quarantined. + { + let mut guard = state().expect("state").lock().expect("engine lock"); + guard.quarantined_operator_identifiers.insert(3); + } + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-quarantine".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("a group including a quarantined operator must be rejected"); + assert!( + matches!(err, EngineError::QuarantinePolicyRejected { ref reason_code, .. } + if reason_code == "operator_auto_quarantined"), + "unexpected error: {err:?}" + ); + + clear_state_storage_policy_overrides(); +} + +// The op writes signing material to durable state, so it must enforce the same +// provenance gate run_dkg and the interactive path do: an unattested runtime +// cannot install distributed-DKG signing material. +#[test] +fn persist_distributed_dkg_key_package_rejects_when_provenance_gate_requires_attestation() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, "true"); + std::env::set_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, "sigstore-main"); + std::env::set_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, "0.1.0"); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-provenance".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public, + }) + .expect_err("expected provenance gate rejection"); + + let EngineError::ProvenanceGateRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "missing_attestation_status"); + + clear_state_storage_policy_overrides(); +} + +// A key package whose SECRET signing share does not derive to its (public) +// verifying share must be rejected: it would open interactive attempts and burn +// them, producing shares that never verify. Crafted with seat 1's identity and +// verifying share (so it matches the public package) but seat 2's signing share. +#[test] +fn persist_distributed_dkg_key_package_rejects_signing_share_not_deriving_to_public() { + let _guard = lock_test_state(); + reset_for_tests(); + + let identifiers = [1_u16, 2, 3] + .iter() + .map(|m| participant_identifier_to_frost_identifier(*m).expect("frost id")) + .collect::>(); + let rng = ZeroizingChaCha20Rng::from_seed([7_u8; 32]); + let (shares, public_key_package) = frost::keys::generate_with_dealer( + 3, + 2, + frost::keys::IdentifierList::Custom(&identifiers), + rng, + ) + .expect("generate_with_dealer"); + let is_even_y = public_key_package.has_even_y(); + let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + let native_public = + native_public_key_package_from_frost(&public_key_package).expect("native public"); + + let key_package_of = |member: u16| { + let id = participant_identifier_to_frost_identifier(member).expect("frost id"); + frost::keys::KeyPackage::try_from(shares.get(&id).expect("share").clone()) + .expect("key package") + .into_even_y(Some(is_even_y)) + }; + let key_package_1 = key_package_of(1); + let key_package_2 = key_package_of(2); + + // Seat 1's identity + verifying share, but seat 2's signing share. + let corrupt = frost::keys::KeyPackage::new( + *key_package_1.identifier(), + *key_package_2.signing_share(), + *key_package_1.verifying_share(), + *key_package_1.verifying_key(), + *key_package_1.min_signers(), + ); + let corrupt_data = corrupt.serialize().expect("serialize corrupt key package"); + + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: "session-persist-share-mismatch".to_string(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: crate::api::NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(*key_package_1.identifier()), + data_hex: hex::encode(corrupt_data), + }, + public_key_package: native_public, + }) + .expect_err("a signing share not deriving to its verifying share must be rejected"); + assert!(matches!(err, EngineError::Validation(_)), "got {err:?}"); +} + +// A second seat of the SAME session (same group key) must carry the SAME public +// key package. A package with the same group verifying key but a different +// verifying-shares map must be rejected on accumulate, or later signing would use +// public material inconsistent with the newly stored key. +#[test] +fn persist_distributed_dkg_key_package_rejects_mismatched_public_package_on_accumulate() { + let _guard = lock_test_state(); + reset_for_tests(); + + let (native_public, native_key_packages) = sample_distributed_dkg_native_material(7); + let session_id = "session-persist-accumulate-mismatch".to_string(); + + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: native_public.clone(), + }) + .expect("first seat persists"); + + // Same group verifying key (so the same key group), but a different shares map: + // give seat 3 seat 2's verifying share. Seat 1's own share is unchanged, so its + // key package still validates - only the accumulate public-package check fails. + let mut tampered = native_public.clone(); + let id2 = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(2).expect("frost id"), + ); + let id3 = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(3).expect("frost id"), + ); + let share2 = tampered + .verifying_shares + .get(&id2) + .expect("share 2") + .clone(); + tampered.verifying_shares.insert(id3, share2); + + let err = + persist_distributed_dkg_key_package(crate::api::PersistDistributedDkgKeyPackageRequest { + session_id: session_id.clone(), + participant_identifier: 1, + threshold: 2, + participant_count: 3, + key_package: native_key_packages.get(&1).expect("seat 1").clone(), + public_key_package: tampered, + }) + .expect_err("a mismatched public package for the same session must be rejected"); + assert!(matches!(err, EngineError::Validation(_)), "got {err:?}"); +} + #[test] fn run_dkg_accepts_valid_signed_provenance_attestation() { let _guard = lock_test_state(); @@ -7102,6 +7499,208 @@ fn persisted_engine_state_rejects_session_registry_over_limit() { clear_state_storage_policy_overrides(); } +#[test] +fn persisted_session_state_round_trip_preserves_bound_key_group() { + // A cross-session signing session has no dkg_result, so bound_key_group is the only + // durable link back to the wallet DKG. It MUST survive a persist/reload: otherwise an + // InteractiveAggregate/verify_share that runs after a restart (past a member's Round2, + // where the live state is already gone) would resolve neither dkg_result nor + // bound_key_group and return DkgNotReady, stranding the collected shares. + let session = SessionState { + bound_key_group: Some("wallet-key-group".to_string()), + ..Default::default() + }; + let persisted = PersistedSessionState::try_from(&session).expect("serialize"); + assert_eq!( + persisted.bound_key_group.as_deref(), + Some("wallet-key-group") + ); + let restored = SessionState::try_from(persisted).expect("deserialize"); + assert_eq!( + restored.bound_key_group.as_deref(), + Some("wallet-key-group"), + "bound_key_group must survive persist/reload for cross-session signing" + ); +} + +#[test] +fn interactive_open_cross_session_respects_the_session_cap() { + // A fresh RoastSessionID per message must not let Open grow the session registry + // past TBTC_SIGNER_MAX_SESSIONS: otherwise the cross-session path could build an + // over-limit registry that the reload path (see the test above) then rejects, + // stranding the node's persisted state. Open enforces the SAME total-session cap as + // every other session-creating path; a reopen of an existing session stays exempt. + let _guard = lock_test_state(); + reset_for_tests(); + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "1"); + + let wallet_session = "wallet-dkg-session-cap"; + let key_group = "cross-session-cap-key-group"; + let message = [0x23u8; 32]; + let included = [1u16, 2]; + + // The wallet DKG session fills the cap (1 of 1). + ensure_interactive_dkg_session(wallet_session, key_group); + + // A distinct signing session would be a SECOND session -> rejected by the cap, + // BEFORE any per-signing state is installed. + let attempt_context = + interactive_test_attempt_context("roast-over-cap", key_group, &message, &included, 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: "roast-over-cap".to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context, + }) + .expect_err("a new cross-session Open at the session cap must be rejected"); + assert!( + matches!(err, EngineError::Internal(ref m) if m.contains("reached max")), + "unexpected error: {err:?}" + ); + // The over-cap session must NOT have been created. + { + let guard = state().expect("state").lock().expect("lock"); + assert!( + !guard.sessions.contains_key("roast-over-cap"), + "a capped-out Open must not create the session" + ); + } + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); +} + +#[test] +fn interactive_open_refuses_to_rebind_a_live_session_to_a_different_key_group() { + // A per-signing session is keyed by RoastSessionID (message/root/start-block), NOT + // key_group, so two wallets can collide on one session id. While a member is + // mid-signing under one wallet key, an Open for a DIFFERENT key group on the same + // session id must be REJECTED - not silently rebind bound_key_group and make the + // live member's Round2/Aggregate resolve the wrong wallet material. + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_a = "wallet-dkg-a-rebind"; + let wallet_b = "wallet-dkg-b-rebind"; + let key_group_a = "key-group-a-rebind"; + let key_group_b = "key-group-b-rebind"; + let shared_session = "roast-collision-session"; + let message = [0x24u8; 32]; + let included = [1u16, 2]; + + ensure_interactive_dkg_session(wallet_a, key_group_a); + ensure_interactive_dkg_session(wallet_b, key_group_b); + + // Member 1 opens under wallet A on the shared session and runs Round1 (a LIVE entry). + let ctx_a = + interactive_test_attempt_context(shared_session, key_group_a, &message, &included, 1); + let opened_a = interactive_session_open(InteractiveSessionOpenRequest { + session_id: shared_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group_a.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context: ctx_a, + }) + .expect("wallet A opens on the shared session"); + interactive_round1(InteractiveRound1Request { + session_id: shared_session.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + }) + .expect("wallet A round 1 leaves a live entry"); + + // Wallet B tries to open the SAME session id while A is live -> rejected. + let ctx_b = + interactive_test_attempt_context(shared_session, key_group_b, &message, &included, 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: shared_session.to_string(), + member_identifier: 2, + message_hex: hex::encode(message), + key_group: key_group_b.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context: ctx_b, + }) + .expect_err("a different key group must not rebind a live session"); + assert!( + matches!(err, EngineError::SessionConflict { .. }), + "unexpected error: {err:?}" + ); + + // Wallet A's binding and live entry are intact - not corrupted by B's attempt. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(shared_session).expect("shared session"); + assert_eq!(session.bound_key_group.as_deref(), Some(key_group_a)); + assert!( + session.interactive_signing.contains_key(&1), + "wallet A's live member entry must survive B's rejected open" + ); + } +} + +#[test] +fn interactive_open_refuses_to_bind_through_another_wallets_dkg_session() { + // If request.session_id names wallet A's (idle) DKG session but the request's + // key_group is wallet B, Open must NOT install B into A's session: with dkg_result + // A present, later Round2/Aggregate/verify_share would resolve A's material while + // signing B's share (wrong wallet, bypassing B's rekey/finalization gates). A + // session belongs to ONE key group for its lifetime, so the mismatch is rejected - + // even with no live members (dkg_result establishes the binding). + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_a = "wallet-a-dkg-bind"; + let wallet_b = "wallet-b-dkg-bind"; + let key_group_a = "key-group-a-dkg-bind"; + let key_group_b = "key-group-b-dkg-bind"; + let message = [0x25u8; 32]; + let included = [1u16, 2]; + + // wallet_a is an IDLE DKG session (dkg_result A, no live interactive entries); + // wallet_b provides key_group B's material so its wallet resolution succeeds. + ensure_interactive_dkg_session(wallet_a, key_group_a); + ensure_interactive_dkg_session(wallet_b, key_group_b); + + // Open wallet A's DKG session id but for key_group B -> rejected. + let ctx = interactive_test_attempt_context(wallet_a, key_group_b, &message, &included, 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: wallet_a.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group_b.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context: ctx, + }) + .expect_err("binding through another wallet's DKG session must be rejected"); + assert!( + matches!(err, EngineError::SessionConflict { .. }), + "unexpected error: {err:?}" + ); + + // Wallet A's DKG session is untouched: no B binding installed. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(wallet_a).expect("wallet A session"); + assert_eq!( + session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.as_str()), + Some(key_group_a) + ); + assert!( + session.bound_key_group.is_none(), + "no cross-wallet binding may be installed on wallet A's session" + ); + } +} + #[test] fn max_sessions_limit_env_parser_is_strict_positive() { let _guard = lock_test_state(); @@ -11573,6 +12172,148 @@ fn interactive_session_full_round_trip_aggregates_bip340() { .expect("interactive + stateless shares aggregate to a valid BIP-340 signature"); } +#[test] +fn interactive_signs_across_sessions_by_key_group() { + // PRODUCTION SHAPE: a wallet's DKG material is persisted under its DKG session, + // but interactive ROAST signing runs under a DIFFERENT, per-message session (the + // RoastSessionID). The engine must resolve the wallet key by key_group so signing + // under a distinct session still works - otherwise distributed-DKG wallets, which + // are signable ONLY via the interactive path, could never sign. The single-session + // tests miss this because they persist and sign under one id. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let wallet_session = "wallet-dkg-session"; + let signing_session = "roast-signing-session"; + let key_group = "cross-session-key-group"; + let message = [0x5au8; 32]; + let included = [1u16, 2]; + + // The DKG material lives ONLY under the wallet (DKG) session. + ensure_interactive_dkg_session(wallet_session, key_group); + + // All signing runs under a DISTINCT session id, with the attempt context derived + // from THAT signing session (coordinator/attempt id bind to the RoastSessionID, + // unchanged by this fix). + let open_under_signing_session = |member: u16| { + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: member, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context, + }) + .unwrap_or_else(|e| panic!("member {member} opens under the signing session: {e:?}")) + }; + let opened1 = open_under_signing_session(1); + let opened2 = open_under_signing_session(2); + assert_eq!(opened1.attempt_id, opened2.attempt_id); + + // The wallet material must NOT be copied into the signing session (no secret + // duplication): the signing session holds only per-signing state, bound to the + // wallet key by key_group. + { + let guard = state().expect("state").lock().expect("lock"); + let signing = guard + .sessions + .get(signing_session) + .expect("signing session created on open"); + assert!( + signing.dkg_key_packages.is_none() && signing.dkg_result.is_none(), + "signing session must not hold a copy of the wallet DKG material" + ); + assert_eq!( + signing.bound_key_group.as_deref(), + Some(key_group), + "signing session is bound to the wallet key it signs for" + ); + } + + let round1_m1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened1.attempt_id.clone(), + member_identifier: 1, + }) + .expect("member 1 round 1 under the signing session"); + let round1_m2 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened2.attempt_id.clone(), + member_identifier: 2, + }) + .expect("member 2 round 1 under the signing session"); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_m1.commitments_hex.clone(), + }, + NativeFrostCommitment { + identifier: key_packages[&2].identifier.clone(), + data_hex: round1_m2.commitments_hex.clone(), + }, + ], + ); + + let round2_m1 = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened1.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 1 round 2 under the signing session"); + let round2_m2 = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened2.attempt_id.clone(), + member_identifier: 2, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("member 2 round 2 under the signing session"); + + // interactive_aggregate resolves the group public key by key_group from the wallet + // session and produces a valid BIP-340 signature over the distinct signing session. + let aggregated = interactive_aggregate(InteractiveAggregateRequest { + session_id: signing_session.to_string(), + attempt_id: opened1.attempt_id.clone(), + signing_package_hex: signing_package_hex.clone(), + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2_m1.signature_share_hex, + }, + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&2].identifier.clone(), + data_hex: round2_m2.signature_share_hex, + }, + ], + taproot_merkle_root_hex: None, + }) + .expect("interactive aggregate resolves wallet material by key_group across sessions"); + + let public_key_package = dkg_part3( + deterministic_interactive_dkg_fixture(0) + .part3_requests + .remove(&1) + .expect("fixture participant 1"), + ) + .expect("public key package") + .public_key_package; + + let signature_bytes = hex::decode(aggregated.signature_hex).expect("signature hex"); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key_bytes = hex::decode(public_key_package.verifying_key).expect("key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only key"); + Secp256k1::verification_only() + .verify_schnorr(&signature, &SecpMessage::from_digest(message), &public_key) + .expect("cross-session interactive signing produces a valid BIP-340 signature"); +} + #[test] fn interactive_multi_seat_two_members_one_process_aggregate_bip340() { // Multi-seat: ONE process drives TWO local members through the interactive @@ -13394,6 +14135,186 @@ fn interactive_round2_rechecks_gates_at_share_release() { .expect("the same attempt completes once the kill switch clears"); } +#[test] +fn interactive_round2_rechecks_gates_at_share_release_across_sessions() { + // The cross-session counterpart of the above: the emergency-rekey kill switch is + // recorded on the WALLET (DKG) session, but signing runs under a DISTINCT + // per-message session. Round2 must STILL block the share - the wallet-level gate + // has to be resolved by key_group, not read from the (empty) per-signing session. + // This is the exact fail-open the state-split risked for distributed-DKG wallets, + // whose only signing path is interactive. + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let wallet_session = "wallet-dkg-session-rekey"; + let signing_session = "roast-signing-session-rekey"; + let key_group = "cross-session-rekey-key-group"; + let message = [0x21u8; 32]; + let included = [1u16, 2]; + + // DKG material lives ONLY under the wallet session. + ensure_interactive_dkg_session(wallet_session, key_group); + + // Open + Round1 under the distinct signing session (gates clear at Open). + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context, + }) + .expect("opens under the signing session"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1 under the signing session"); + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 nonces"); + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Kill switch recorded on the WALLET session AFTER Open/Round1 - NOT on the + // signing session the operator is driving. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard + .sessions + .get_mut(wallet_session) + .expect("wallet session exists"); + session.emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: "post-open rekey on the wallet session".to_string(), + triggered_at_unix: now_unix(), + }); + } + + // Round2 under the signing session MUST block - the wallet-level rekey gate is + // resolved by key_group from the wallet session, not the empty signing session. + let blocked = interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("a wallet-session emergency rekey must block a cross-session Round2 share"); + assert!( + matches!(blocked, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {blocked:?}" + ); + + // The rejection must be fail-closed WITHOUT consuming the nonce: clearing the + // kill switch on the wallet session lets the same attempt complete. + { + let mut guard = state().expect("state").lock().expect("lock"); + assert!( + !guard + .sessions + .get(signing_session) + .expect("signing session exists") + .consumed_interactive_attempt_markers + .contains(&interactive_consumed_marker(&opened.attempt_id, 1)), + "a cross-session gate rejection must not consume the attempt" + ); + guard + .sessions + .get_mut(wallet_session) + .expect("wallet session exists") + .emergency_rekey_event = None; + } + + interactive_round2(InteractiveRound2Request { + session_id: signing_session.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("the cross-session attempt completes once the wallet kill switch clears"); +} + +#[test] +fn trigger_emergency_rekey_on_signing_session_records_on_wallet_session() { + // Defense in depth (writer side): emergency rekey is a WALLET-level kill switch, + // and interactive Round2 resolves it from the wallet session by key_group. If a + // caller triggers it on a per-signing session (a distinct RoastSessionID bound to a + // wallet key), the event must land on the WALLET session - where a reader looks - + // not on the ephemeral signing session. This makes the writer and reader keying + // impossible to diverge. + let _guard = lock_test_state(); + reset_for_tests(); + + let wallet_session = "wallet-dkg-session-rekey-writer"; + let signing_session = "roast-signing-session-rekey-writer"; + let key_group = "cross-session-rekey-writer-key-group"; + let message = [0x22u8; 32]; + let included = [1u16, 2]; + + ensure_interactive_dkg_session(wallet_session, key_group); + + // Open under the distinct signing session so it is bound to the wallet key. + let attempt_context = + interactive_test_attempt_context(signing_session, key_group, &message, &included, 1); + interactive_session_open(InteractiveSessionOpenRequest { + session_id: signing_session.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context, + }) + .expect("opens under the signing session"); + + // Trigger the kill switch on the SIGNING session id. + let result = trigger_emergency_rekey(TriggerEmergencyRekeyRequest { + session_id: signing_session.to_string(), + reason: "compromise detected".to_string(), + }) + .expect("emergency rekey triggers"); + + // It must have been recorded on the resolved WALLET session, where Round2 reads it. + assert_eq!( + result.session_id, wallet_session, + "the rekey must be recorded on the resolved wallet session" + ); + let guard = state().expect("state").lock().expect("lock"); + assert!( + guard + .sessions + .get(wallet_session) + .expect("wallet session") + .emergency_rekey_event + .is_some(), + "the wallet session must hold the kill switch" + ); + assert!( + guard + .sessions + .get(signing_session) + .expect("signing session") + .emergency_rekey_event + .is_none(), + "the ephemeral signing session must NOT hold the kill switch" + ); +} + #[test] fn interactive_open_rejects_threshold_below_key_package_min_signers() { let _guard = lock_test_state(); @@ -13438,11 +14359,11 @@ fn interactive_open_requires_an_existing_dkg_session() { let _guard = lock_test_state(); reset_for_tests(); - // Key material is resolved from engine DKG state, never the request, - // so an interactive open against a session with no DKG fails closed - // - the interactive path cannot create a session or sign with - // caller-supplied material. (This is also why interactive opens - // cannot churn empty registry entries.) + // Key material is resolved from engine DKG state by key_group, never the + // request, so an interactive open when NO wallet key exists for that + // key_group fails closed with DkgNotReady - the interactive path never + // signs with caller-supplied material. (It MAY create a per-signing session + // bound to an EXISTING wallet key, but only then.) let attempt_context = interactive_test_attempt_context( "interactive-no-dkg", "interactive-test-key-group", @@ -13459,9 +14380,9 @@ fn interactive_open_requires_an_existing_dkg_session() { taproot_merkle_root_hex: None, attempt_context, }) - .expect_err("interactive open without a DKG session must fail closed"); + .expect_err("interactive open with no wallet key for the key_group must fail closed"); assert!( - matches!(err, EngineError::SessionNotFound { .. }), + matches!(err, EngineError::DkgNotReady { .. }), "unexpected error: {err:?}" ); diff --git a/pkg/tbtc/signer/src/engine/verify_share.rs b/pkg/tbtc/signer/src/engine/verify_share.rs index fe23d1d037..3d7ea1a8f5 100644 --- a/pkg/tbtc/signer/src/engine/verify_share.rs +++ b/pkg/tbtc/signer/src/engine/verify_share.rs @@ -90,14 +90,32 @@ pub fn verify_signature_share( // inactivity) must hold even when the only post-expiry traffic is // verify-share blame rechecks. Mirrors InteractiveAggregate. sweep_expired_interactive_state(&mut guard); - let session = match guard.sessions.get(&request.session_id) { - Some(session) => session, + // The public key package is a WALLET-level asset resolved by key_group, so a + // per-signing session (a distinct RoastSessionID) can be blame-checked. The + // key_group is this signing session's own DKG (co-located) or the one bound at + // Open; a missing session/binding/DKG is not the member's fault -> indeterminate. + let key_group = match guard.sessions.get(&request.session_id) { + Some(session) => session + .dkg_result + .as_ref() + .map(|dkg| dkg.key_group.clone()) + .or_else(|| session.bound_key_group.clone()), + None => None, + }; + let key_group = match key_group { + Some(key_group) => key_group, None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), }; - if session.dkg_result.is_none() { - return Ok(verdict(ShareVerificationVerdict::Indeterminate)); - } - match session.dkg_public_key_package.as_ref() { + let wallet_session_id = + match resolve_wallet_session_id(&guard, &request.session_id, &key_group) { + Some(id) => id, + None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), + }; + match guard + .sessions + .get(&wallet_session_id) + .and_then(|session| session.dkg_public_key_package.as_ref()) + { Some(package) => package.clone(), None => return Ok(verdict(ShareVerificationVerdict::Indeterminate)), } diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index cc6ec054bf..72d6215bdf 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -13,10 +13,10 @@ use api::{ FinalizeSignRoundRequest, FrostTbtcAbiVersionResult, GenerateNoncesAndCommitmentsRequest, InitSignerConfigRequest, InteractiveAggregateRequest, InteractiveRound1Request, InteractiveRound2Request, InteractiveSessionAbortRequest, InteractiveSessionOpenRequest, - NewSigningPackageRequest, PromoteCanaryRequest, QuarantineStatusRequest, - RefreshCadenceStatusRequest, RefreshSharesRequest, RollbackCanaryRequest, RunDkgRequest, - SignShareRequest, StartSignRoundRequest, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, - VerifyBlameProofRequest, + NewSigningPackageRequest, PersistDistributedDkgKeyPackageRequest, PromoteCanaryRequest, + QuarantineStatusRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, + RollbackCanaryRequest, RunDkgRequest, SignShareRequest, StartSignRoundRequest, + TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; use ffi::{ ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, @@ -38,7 +38,10 @@ const TBTC_SIGNER_VERSION: &str = "tbtc-signer/0.1.0-bootstrap"; /// a new field or enum value can appear in an existing response that an old bridge does /// not tolerate, that is a MAJOR bump. const TBTC_SIGNER_ABI_MAJOR: u32 = 1; -const TBTC_SIGNER_ABI_MINOR: u32 = 0; +// Minor 1 adds the additive, backward-compatible symbol +// frost_tbtc_persist_distributed_dkg_key_package; a bridge that needs it must +// require abi_minor >= 1 so it fail-closes against an older lib lacking the symbol. +const TBTC_SIGNER_ABI_MINOR: u32 = 1; use engine::TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV; #[cfg(test)] use engine::TBTC_SIGNER_PROFILE_ENV; @@ -282,6 +285,19 @@ pub extern "C" fn frost_tbtc_dkg_part3( }) } +#[no_mangle] +pub extern "C" fn frost_tbtc_persist_distributed_dkg_key_package( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: PersistDistributedDkgKeyPackageRequest = + parse_request(request_ptr, request_len)?; + let response = engine::persist_distributed_dkg_key_package(request)?; + serialize_response(&response) + }) +} + #[no_mangle] pub extern "C" fn frost_tbtc_generate_nonces_and_commitments( request_ptr: *const u8, @@ -851,12 +867,13 @@ mod tests { attempt_id: "ffi-smoke-attempt".to_string(), }, }; - // No DKG session exists, so Open fails closed with session_not_found - // (key material is resolved from engine DKG state, never the request). + // No wallet key exists for this key_group, so Open fails closed with + // dkg_not_ready (key material is resolved from engine DKG state by + // key_group, never the request). let (status, payload) = call_ffi(&open, super::frost_tbtc_interactive_session_open); assert_ne!(status, 0); let error: ErrorResponse = serde_json::from_slice(&payload).expect("open error payload"); - assert_eq!(error.code, "session_not_found"); + assert_eq!(error.code, "dkg_not_ready"); let round1 = crate::api::InteractiveRound1Request { session_id: "ffi-interactive-smoke-missing".to_string(), @@ -1163,9 +1180,10 @@ mod tests { serde_json::from_slice(&payload).expect("abi version payload decode"); // The enforced FFI contract starts at 1.0; bump deliberately per the // TBTC_SIGNER_ABI_MAJOR / TBTC_SIGNER_ABI_MINOR rules. This test pins the - // current value so an accidental bump is caught. + // current value so an accidental bump is caught. Minor is 1 since adding + // frost_tbtc_persist_distributed_dkg_key_package (additive symbol). assert_eq!(abi.abi_major, 1); - assert_eq!(abi.abi_minor, 0); + assert_eq!(abi.abi_minor, 1); } #[test]