diff --git a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md index ea4c2db9d1..2016f0b8e4 100644 --- a/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md +++ b/pkg/tbtc/signer/docs/phase-7-interactive-session-spec-freeze.md @@ -145,6 +145,17 @@ self-contained): mode is the only mode here: no legacy-shape fallback), checks policy gates and provenance, registers the session. Idempotent by full-request fingerprint; conflicting reopen fails closed. + **The member's key package is resolved from the session's own DKG + state (run_dkg), NOT carried in the request** — so the session + must already exist with completed DKG, and no signing secret + crosses the FFI/host boundary (section 4). This is a correction to + an earlier draft of this spec that had Open accept the key package + in the request; accepting it would have left key shares outside + the engine and defeated the sidecar's signing-secret boundary. A + request `threshold` is still carried but must equal the DKG + threshold. As a consequence, an interactive session always rides a + DKG-populated session and never creates registry entries of its + own. 2. `InteractiveRound1` — fresh nonces + commitments as in section 4. Per (session, attempt, member) at most one live handle; repeat calls return the same commitments (idempotent) until diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 5e1c6348d9..74bfc078ed 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -57,6 +57,22 @@ TbtcSignerResult frost_tbtc_finalize_sign_round(const uint8_t* request_ptr, size TbtcSignerResult frost_tbtc_build_taproot_tx(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_refresh_shares(const uint8_t* request_ptr, size_t request_len); +/* + * Phase 7.1 hardened interactive signing session. + * + * Unlike the stateless nonce contract above, secret nonces NEVER cross this + * boundary in either direction: the engine generates, holds, consumes, and + * zeroizes them internally, keyed by (session_id, attempt_id). The caller + * exchanges only public commitments, signing packages, and signature shares. + * frost_tbtc_interactive_round2 verifies the coordinator's signing package in + * full and consumes the attempt's nonces exactly once; a repeat call for a + * consumed attempt fails closed with the `consumed_nonce_replay` error code. + */ +TbtcSignerResult frost_tbtc_interactive_session_open(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_interactive_round1(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_interactive_round2(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_interactive_session_abort(const uint8_t* request_ptr, size_t request_len); + #ifdef __cplusplus } #endif diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index dc6b94c6a6..d02f2bcb89 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -146,6 +146,87 @@ pub struct SignShareResult { pub signature_share: NativeFrostSignatureShare, } +// Phase 7.1 hardened interactive signing session (frozen spec +// docs/phase-7-interactive-session-spec-freeze.md, section 5). Unlike +// the stateless primitives above, secret nonces NEVER appear in these +// requests or results: the engine generates, holds, consumes, and +// zeroizes them internally, keyed by (session_id, attempt_id). + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionOpenRequest { + pub session_id: String, + pub member_identifier: u16, + pub message_hex: String, + pub key_group: String, + /// Signing threshold; must equal the session's DKG threshold. The + /// key material itself is resolved from the engine's DKG state and + /// is never carried in this request - no signing secret crosses the + /// FFI (frozen spec section 4). + pub threshold: u16, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taproot_merkle_root_hex: Option, + /// Required: interactive sessions are strict-mode only; there is + /// no legacy-shape fallback on this path. + pub attempt_context: AttemptContext, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionOpenResult { + pub session_id: String, + pub attempt_id: String, + pub idempotent: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound1Request { + pub session_id: String, + pub attempt_id: String, + pub member_identifier: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound1Result { + /// The member's public signing commitments. Idempotent until the + /// attempt's nonces are consumed; the secret nonces they + /// correspond to never leave the engine. + pub commitments_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound2Request { + pub session_id: String, + pub attempt_id: String, + pub member_identifier: u16, + /// The coordinator's signing package (the chosen responsive + /// subset's commitment list). Verified in full - membership, + /// subset-of-included, exact threshold size, message binding, and + /// byte-identity of this member's own commitment entry - BEFORE + /// the nonces are consumed. + pub signing_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveRound2Result { + pub session_id: String, + pub attempt_id: String, + pub signature_share_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionAbortRequest { + pub session_id: String, + /// When set, abort only if the live attempt matches; when unset, + /// abort whatever attempt is live for the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attempt_id: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InteractiveSessionAbortResult { + pub session_id: String, + pub aborted: bool, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct AggregateRequest { pub signing_package_hex: String, @@ -521,6 +602,30 @@ pub struct SignerHardeningMetricsResult { pub finalize_sign_round_latency_samples: u64, pub refresh_shares_latency_p95_ms: u64, pub refresh_shares_latency_samples: u64, + #[serde(default)] + pub interactive_session_open_calls_total: u64, + #[serde(default)] + pub interactive_session_open_success_total: u64, + #[serde(default)] + pub interactive_round1_calls_total: u64, + #[serde(default)] + pub interactive_round1_success_total: u64, + #[serde(default)] + pub interactive_round2_calls_total: u64, + #[serde(default)] + pub interactive_round2_success_total: u64, + #[serde(default)] + pub interactive_session_abort_calls_total: u64, + #[serde(default)] + pub interactive_session_abort_success_total: u64, + #[serde(default)] + pub interactive_round1_latency_p95_ms: u64, + #[serde(default)] + pub interactive_round1_latency_samples: u64, + #[serde(default)] + pub interactive_round2_latency_p95_ms: u64, + #[serde(default)] + pub interactive_round2_latency_samples: u64, pub last_updated_unix: u64, } @@ -565,6 +670,10 @@ pub struct InitSignerConfigRequest { #[serde(default, skip_serializing_if = "Option::is_none")] pub max_sessions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_live_interactive_sessions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interactive_session_ttl_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub state_key_provider: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub state_key_command: Option, diff --git a/pkg/tbtc/signer/src/engine/config.rs b/pkg/tbtc/signer/src/engine/config.rs index fed755b442..79737296d9 100644 --- a/pkg/tbtc/signer/src/engine/config.rs +++ b/pkg/tbtc/signer/src/engine/config.rs @@ -51,6 +51,20 @@ pub(crate) const TBTC_SIGNER_MAX_SESSIONS_ENV: &str = "TBTC_SIGNER_MAX_SESSIONS" pub(crate) const TBTC_SIGNER_DEFAULT_MAX_SESSIONS: usize = 1024; +// Phase 7.1 interactive session bounds. Live interactive sessions hold +// secret nonces in memory, so they get a dedicated, smaller cap than +// the overall session registry, and a TTL after which an abandoned +// attempt's nonces are destroyed (expiry has abort semantics). +pub(crate) const TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV: &str = + "TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_MAX_LIVE_INTERACTIVE_SESSIONS: usize = 64; + +pub(crate) const TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV: &str = + "TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS"; + +pub(crate) const TBTC_SIGNER_DEFAULT_INTERACTIVE_SESSION_TTL_SECONDS: u64 = 3600; + pub(crate) const TBTC_SIGNER_STATE_LOCKFILE_SUFFIX: &str = ".lock"; pub(crate) const TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV: &str = "TBTC_SIGNER_ALLOW_BOOTSTRAP"; diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs index 4b74b209de..d20b6f42c0 100644 --- a/pkg/tbtc/signer/src/engine/init_config.rs +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -267,6 +267,16 @@ pub(crate) fn config_values_from_request( TBTC_SIGNER_MAX_SESSIONS_ENV, request.max_sessions, ); + insert_u64( + &mut values, + TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, + request.max_live_interactive_sessions, + ); + insert_u64( + &mut values, + TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV, + request.interactive_session_ttl_seconds, + ); insert_u64( &mut values, TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV, diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs new file mode 100644 index 0000000000..690870452e --- /dev/null +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -0,0 +1,864 @@ +// Phase 7.1: the hardened interactive signing session layer. +// +// Implements sections 4-5 of the frozen spec +// (docs/phase-7-interactive-session-spec-freeze.md). The defining +// property is engine-held nonce custody: round-1 nonces are generated +// from OS randomness, live only in in-memory session state bound to +// (session_id, attempt_id), are zeroized on consumption, abort, and +// expiry, and are NEVER serialized into a response or persisted state. +// The only durable artifacts are per-attempt consumption markers, +// written BEFORE a signature share leaves the engine +// (consumption-before-release), so a restart can never lead to a +// second share under the same nonces. +// +// Attempt contexts are strict-mode only: there is no legacy-shape +// fallback on this path. All entry points are idempotent or fail +// closed; none of them can be made to release more than one signature +// share per nonce pair. + +use super::*; + +pub fn interactive_session_open( + mut request: InteractiveSessionOpenRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_open_calls_total = telemetry + .interactive_session_open_calls_total + .saturating_add(1); + }); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + if request.member_identifier == 0 { + return Err(EngineError::Validation( + "member_identifier must be non-zero".to_string(), + )); + } + if request.threshold == 0 { + return Err(EngineError::Validation( + "threshold must be non-zero".to_string(), + )); + } + + let message_bytes = hex::decode(&request.message_hex) + .map_err(|_| EngineError::Validation("message_hex must be valid hex".to_string()))?; + if message_bytes.is_empty() { + return Err(EngineError::Validation( + "message_hex must not be empty".to_string(), + )); + } + let message_digest_hex = hash_hex(&message_bytes); + let taproot_merkle_root = + canonicalize_taproot_merkle_root_hex(&mut request.taproot_merkle_root_hex)?; + + // Canonicalize the attempt context before anything keys off it - + // lowercases the hex hash fields and sorts the included set, + // exactly as the coarse start_sign_round path does. The wire + // accepts attempt_id/fingerprint case-insensitively, so the marker + // registry and live-state comparisons MUST run on the canonical + // form or a re-cased retry of a consumed attempt would miss the + // marker and sign again. + request.attempt_context = canonical_attempt_context(&request.attempt_context); + + let request_fingerprint = interactive_open_request_fingerprint(&request)?; + let attempt_id = request.attempt_context.attempt_id.clone(); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + sweep_expired_interactive_state(&mut guard); + + let auto_quarantine_config = load_auto_quarantine_config()?; + + // The session must already exist with completed DKG. Key material + // lives in the engine's own DKG-populated state and is NEVER + // supplied through the request, so no signing secret crosses the + // FFI/host boundary (frozen spec section 4). Resolve the member's + // key package, run the policy gates, and validate the strict + // 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 { + session_id: request.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(), + )); + } + 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) + .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::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}], \ + which is not a DKG participant for this session" + ))); + } + } + (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 (already_consumed, matching_attempt_idempotent, live_attempt) = { + let session = guard + .sessions + .get(&request.session_id) + .expect("session existed under the held engine lock"); + let already_consumed = session + .consumed_interactive_attempt_markers + .contains(&attempt_id); + let live = session.interactive_signing.as_ref(); + 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) + }; + + if already_consumed { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + match matching_attempt_idempotent { + Some(true) => { + return Ok(InteractiveSessionOpenResult { + session_id: request.session_id, + attempt_id, + idempotent: true, + }); + } + Some(false) => { + return Err(EngineError::SessionConflict { + session_id: request.session_id.clone(), + }); + } + None => {} + } + + // A DIFFERENT live attempt is replaced ONLY by a strictly newer + // attempt: the retry loop advanced. A stale/delayed open for an + // older or equal attempt must not roll the session back and wipe + // the newer attempt's nonces. + let replacing = live_attempt.is_some(); + if let Some((live_attempt_id, live_attempt_number)) = live_attempt { + if live_attempt_id != attempt_id + && request.attempt_context.attempt_number <= live_attempt_number + { + return Err(EngineError::Validation(format!( + "attempt_number [{}] does not advance the live interactive attempt [{}]; \ + refusing to roll back to an older or equal attempt", + request.attempt_context.attempt_number, live_attempt_number + ))); + } + } + + // Capacity counts every live interactive session. When replacing, + // this session already holds one of those slots, so the cap does + // not apply; when not replacing, a new slot is being taken. + if !replacing { + let live_interactive_sessions = guard + .sessions + .values() + .filter(|session| session.interactive_signing.is_some()) + .count(); + if live_interactive_sessions >= max_live_interactive_sessions_limit() { + return Err(EngineError::Internal(format!( + "live interactive session count [{live_interactive_sessions}] reached max [{}]; \ + abort idle sessions or increase {}", + max_live_interactive_sessions_limit(), + TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV + ))); + } + } + + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + + if let Some(mut replaced) = session.interactive_signing.take() { + zeroize_interactive_round1(&mut replaced); + } + + session.interactive_signing = Some(InteractiveSigningState { + open_request_fingerprint: request_fingerprint, + attempt_context: request.attempt_context, + canonical_included_participants, + member_identifier: request.member_identifier, + threshold: request.threshold, + message_bytes: Zeroizing::new(message_bytes), + taproot_merkle_root, + key_package, + opened_at_unix: now_unix(), + round1: None, + }); + + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_open_success_total = telemetry + .interactive_session_open_success_total + .saturating_add(1); + }); + + Ok(InteractiveSessionOpenResult { + session_id: request.session_id, + attempt_id, + idempotent: false, + }) +} + +pub fn interactive_round1( + request: InteractiveRound1Request, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round1_calls_total = + telemetry.interactive_round1_calls_total.saturating_add(1); + }); + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::InteractiveRound1); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + // The live state and markers are keyed on the canonical (lowercase) + // attempt_id; the wire form may differ in casing. + let attempt_id = canonical_attempt_id(&request.attempt_id); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + sweep_expired_interactive_state(&mut guard); + + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + + if session + .consumed_interactive_attempt_markers + .contains(&attempt_id) + { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + let interactive = interactive_state_for_attempt_mut( + session, + &request.session_id, + &attempt_id, + request.member_identifier, + )?; + + if let Some(round1) = interactive.round1.as_ref() { + // Idempotent until consumed: the commitments are public and + // re-sending them is safe; the nonces never leave. + return Ok(InteractiveRound1Result { + commitments_hex: round1.commitments_hex.clone(), + }); + } + + let mut rng = zeroizing_rng_from_os(); + let (nonces, commitments) = + frost::round1::commit(interactive.key_package.signing_share(), &mut rng); + let commitment_bytes = commitments.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize signing commitments: {e}")) + })?; + let commitments_hex = hex::encode(commitment_bytes); + + interactive.round1 = Some(InteractiveRound1State { + nonces, + commitments_hex: commitments_hex.clone(), + }); + + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round1_success_total = + telemetry.interactive_round1_success_total.saturating_add(1); + }); + + Ok(InteractiveRound1Result { commitments_hex }) +} + +pub fn interactive_round2( + request: InteractiveRound2Request, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round2_calls_total = + telemetry.interactive_round2_calls_total.saturating_add(1); + }); + let _latency_guard = HardeningOperationLatencyGuard::new(HardeningOperation::InteractiveRound2); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + let mut signing_package_bytes = decode_hex_field( + "InteractiveRound2", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package_result = frost::SigningPackage::deserialize(&signing_package_bytes); + signing_package_bytes.zeroize(); + let signing_package = signing_package_result.map_err(|e| { + EngineError::Validation(format!("InteractiveRound2: invalid signing package: {e}")) + })?; + + // The live state and markers are keyed on the canonical (lowercase) + // attempt_id; the wire form may differ in casing. + let attempt_id = canonical_attempt_id(&request.attempt_id); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + sweep_expired_interactive_state(&mut guard); + + // Quarantine inputs must be read before the session is borrowed + // mutably from the same guard below. + let auto_quarantine_config = load_auto_quarantine_config()?; + let quarantined_operator_identifiers = guard.quarantined_operator_identifiers.clone(); + + let session = guard.sessions.get_mut(&request.session_id).ok_or_else(|| { + EngineError::SessionNotFound { + session_id: request.session_id.clone(), + } + })?; + + if session + .consumed_interactive_attempt_markers + .contains(&attempt_id) + { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + ensure_consumed_registry_insert_capacity( + &session.consumed_interactive_attempt_markers, + &attempt_id, + "consumed_interactive_attempt_markers", + &request.session_id, + )?; + + // Re-evaluate the signing gates at the share-release moment. The + // gates checked at Open are stale here: a kill switch recorded + // after Open (emergency rekey, finalization, quarantine, or a + // re-bound policy-checked tx) must stop the share leaving the + // engine. Read via immutable borrows of the live attempt before the + // mutable consume/sign borrow below. Skipped when no matching live + // attempt exists - there is no share to release in that case, and + // interactive_state_for_attempt_mut produces the canonical error. + if let Some(interactive) = session.interactive_signing.as_ref().filter(|interactive| { + interactive.attempt_context.attempt_id == attempt_id + && interactive.member_identifier == request.member_identifier + }) { + let bound_message_hex = hex::encode(interactive.message_bytes.as_slice()); + // Fast-path lifecycle/firewall and this node's own quarantine. + // The full chosen signing subset is quarantine-checked after the + // package is verified (below), once it is known to be a real + // subset of the included set. + enforce_interactive_signing_gates( + &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(), + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + } + + let interactive = interactive_state_for_attempt_mut( + session, + &request.session_id, + &attempt_id, + request.member_identifier, + )?; + + if interactive.round1.is_none() { + return Err(EngineError::SignRoundNotStarted { + session_id: request.session_id.clone(), + }); + } + + // ALL verification precedes consumption (frozen spec section 5, + // Round2): a package that fails any check leaves the nonce handle + // live, so an invalid package cannot burn the attempt. At most one + // share per handle still holds against two VALID packages because + // the consumption marker is written before the share is released. + verify_round2_signing_package(interactive, &signing_package)?; + + // The package is now confirmed to be a threshold-sized subset of the + // attempt's included set, so the chosen signing subset is known. + // Quarantine-check ALL of it before releasing a share: this node + // must not contribute to a signature whose subset includes a + // locally quarantined co-signer, matching the coarse path's + // all-signing-participants quarantine enforcement. + let signing_subset = round2_signing_subset(interactive, &signing_package)?; + enforce_not_quarantined_identifiers( + &request.session_id, + &signing_subset, + &quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + + // Consumption-before-release: the durable marker is persisted + // BEFORE the share is computed and returned. If persistence fails, + // the marker is rolled back and the nonces remain live - no share + // has left the engine. If share computation fails after the marker + // persisted, the attempt is dead (fail closed): the marker stays, + // the nonces are destroyed, and no share was released. + session + .consumed_interactive_attempt_markers + .insert(attempt_id.clone()); + if let Err(persist_error) = persist_engine_state_to_storage(&guard) { + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + session + .consumed_interactive_attempt_markers + .remove(&attempt_id); + return Err(persist_error); + } + + let session = guard + .sessions + .get_mut(&request.session_id) + .expect("session existed under the held engine lock"); + let interactive = session + .interactive_signing + .as_mut() + .expect("interactive state existed under the held engine lock"); + + let mut round1 = interactive + .round1 + .take() + .expect("round1 state existed under the held engine lock"); + + let signature_share_result = + if let Some(taproot_merkle_root) = interactive.taproot_merkle_root.as_ref() { + frost::round2::sign_with_tweak( + &signing_package, + &round1.nonces, + &interactive.key_package, + Some(taproot_merkle_root.as_slice()), + ) + } else { + frost::round2::sign(&signing_package, &round1.nonces, &interactive.key_package) + }; + round1.nonces.zeroize(); + drop(round1); + + // Round2 is terminal for this member's participation in the + // attempt: the marker is durable and the nonces are gone, so free + // the live session state now rather than letting it (and its + // resident key package + message) linger until the TTL sweep. This + // also returns the live-session capacity slot immediately. Done on + // both the success and share-computation-failure paths: the + // attempt is consumed either way, and the durable marker carries + // all further replay protection. + session.interactive_signing = None; + + let signature_share = signature_share_result + .map_err(|e| EngineError::Internal(format!("failed to create signature share: {e}")))?; + + let mut signature_share_bytes = signature_share.serialize(); + let signature_share_hex = hex::encode(&signature_share_bytes); + signature_share_bytes.zeroize(); + + record_hardening_telemetry(|telemetry| { + telemetry.interactive_round2_success_total = + telemetry.interactive_round2_success_total.saturating_add(1); + }); + + Ok(InteractiveRound2Result { + session_id: request.session_id, + attempt_id, + signature_share_hex, + }) +} + +pub fn interactive_session_abort( + request: InteractiveSessionAbortRequest, +) -> Result { + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_abort_calls_total = telemetry + .interactive_session_abort_calls_total + .saturating_add(1); + }); + enforce_provenance_gate()?; + validate_session_id(&request.session_id)?; + + // Canonicalize the optional attempt_id filter to match the + // canonical form the live state is keyed on. + let attempt_id_filter = request.attempt_id.as_deref().map(canonical_attempt_id); + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + // Abort takes the lock like every other entry point, so it sweeps + // expired interactive state too: the TTL guarantee (nonces gone + // within the TTL of inactivity) must hold even when the only + // post-expiry traffic is aborts for other sessions. + sweep_expired_interactive_state(&mut guard); + + let aborted = match guard.sessions.get_mut(&request.session_id) { + Some(session) => match session.interactive_signing.as_ref() { + Some(interactive) + if attempt_id_filter.is_none() + || attempt_id_filter.as_deref() + == Some(interactive.attempt_context.attempt_id.as_str()) => + { + let mut removed = session + .interactive_signing + .take() + .expect("interactive state existed under the held engine lock"); + zeroize_interactive_round1(&mut removed); + true + } + _ => false, + }, + None => false, + }; + + record_hardening_telemetry(|telemetry| { + telemetry.interactive_session_abort_success_total = telemetry + .interactive_session_abort_success_total + .saturating_add(1); + }); + + Ok(InteractiveSessionAbortResult { + session_id: request.session_id, + aborted, + }) +} + +// Looks up the live interactive state and pins the +// (attempt_id, member_identifier) binding every round call must carry. +fn interactive_state_for_attempt_mut<'session>( + session: &'session mut SessionState, + session_id: &str, + attempt_id: &str, + member_identifier: u16, +) -> Result<&'session mut InteractiveSigningState, EngineError> { + let interactive = + session + .interactive_signing + .as_mut() + .ok_or_else(|| EngineError::SessionNotFound { + session_id: format!("{session_id} (no live interactive attempt)"), + })?; + + if interactive.attempt_context.attempt_id != attempt_id { + return Err(EngineError::Validation(format!( + "attempt_id [{attempt_id}] does not match the live interactive attempt [{}]", + interactive.attempt_context.attempt_id + ))); + } + + if interactive.member_identifier != member_identifier { + return Err(EngineError::Validation( + "member_identifier does not match the open interactive session".to_string(), + )); + } + + Ok(interactive) +} + +// The frozen spec's Round2 checks (a)-(f). Returns Ok only when every +// check passes; the caller consumes the nonces strictly afterwards. +fn verify_round2_signing_package( + interactive: &InteractiveSigningState, + signing_package: &frost::SigningPackage, +) -> Result<(), EngineError> { + // (d) part 2 (deserialization already succeeded): the package must + // target exactly the session's message. A package for any other + // message - including the same message with different framing - + // must never reach the nonces. + if signing_package.message().as_slice() != interactive.message_bytes.as_slice() { + return Err(EngineError::Validation( + "signing package message does not match the open interactive session".to_string(), + )); + } + + let package_commitments = signing_package.signing_commitments(); + + // (c) exactly threshold-many participants, deliberately not + // at-least (frozen spec section 5). + if package_commitments.len() != usize::from(interactive.threshold) { + return Err(EngineError::Validation(format!( + "signing package carries [{}] commitments; expected exactly threshold [{}]", + package_commitments.len(), + interactive.threshold + ))); + } + + // (b) the chosen subset must be inside the attempt's included set. + let included_identifiers = interactive + .canonical_included_participants + .iter() + .map(|participant| participant_identifier_to_frost_identifier(*participant)) + .collect::, _>>()?; + for package_identifier in package_commitments.keys() { + if !included_identifiers.contains(package_identifier) { + return Err(EngineError::Validation( + "signing package contains a participant outside the attempt's included set" + .to_string(), + )); + } + } + + // (a) this member must be in the chosen subset. + let own_identifier = participant_identifier_to_frost_identifier(interactive.member_identifier)?; + let own_package_commitments = package_commitments.get(&own_identifier).ok_or_else(|| { + EngineError::Validation( + "signing package does not include this member's commitment".to_string(), + ) + })?; + + // (f) the member's own commitment entry must be byte-identical to + // its round-1 output. Without this, a malicious coordinator could + // substitute the commitment, make this member's correctly-computed + // share fail verification at aggregation, and manufacture false + // blame evidence against an honest member. + let own_package_commitment_bytes = own_package_commitments.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize package commitment: {e}")) + })?; + let round1 = interactive + .round1 + .as_ref() + .expect("caller verified round1 state exists"); + if hex::encode(own_package_commitment_bytes) != round1.commitments_hex { + return Err(EngineError::Validation( + "signing package commitment for this member does not match its round-1 output" + .to_string(), + )); + } + + Ok(()) +} + +// The signing gates the interactive path enforces at BOTH Open and +// the Round2 share-release moment, mirroring the coarse +// start_sign_round: emergency-rekey and finalized lifecycle, quarantine +// of the signing participants, and the signing-policy firewall binding +// of the message to a policy-checked build_taproot_tx. Centralized in +// one function so the two call sites cannot drift apart. +// +// quarantine_identifiers is the set to quarantine-check: at Open only +// this node's own member is known to sign; at Round2 it is the full +// chosen signing subset (the package's participants), so this node +// refuses to contribute a share to a package that includes any +// quarantined co-signer - the same all-participants check the coarse +// path applies. +#[allow(clippy::too_many_arguments)] +fn enforce_interactive_signing_gates( + session_id: &str, + quarantine_identifiers: &[u16], + message_hex: &str, + emergency_rekey_event: Option<&EmergencyRekeyEvent>, + session_finalized: bool, + tx_result: Option<&TransactionResult>, + quarantined_operator_identifiers: &HashSet, + auto_quarantine_config: Option<&AutoQuarantineConfig>, +) -> Result<(), EngineError> { + if let Some(emergency_rekey_event) = emergency_rekey_event { + return Err(EngineError::LifecyclePolicyRejected { + session_id: session_id.to_string(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "emergency rekey required for session [{}] since [{}]: {}", + session_id, emergency_rekey_event.triggered_at_unix, emergency_rekey_event.reason + ), + }); + } + if session_finalized { + return Err(EngineError::SessionFinalized { + session_id: session_id.to_string(), + }); + } + enforce_not_quarantined_identifiers( + session_id, + quarantine_identifiers, + quarantined_operator_identifiers, + auto_quarantine_config, + )?; + enforce_signing_message_binding_to_policy_checked_build_tx(session_id, message_hex, tx_result) +} + +// Canonical key form for an attempt_id at the round entry points, +// matching canonicalize_attempt_context_for_fingerprint (which +// lowercases attempt_id). The wire accepts attempt_id case- +// insensitively, so the marker registry and live-state lookups must +// operate on this form to be replay-safe. +fn canonical_attempt_id(attempt_id: &str) -> String { + attempt_id.to_ascii_lowercase() +} + +// The chosen signing subset as Go u16 identifiers: the included +// participants whose commitment appears in the signing package. The +// caller MUST have run verify_round2_signing_package first (which +// confirms the package is a threshold-sized subset of the included +// set), so every package participant maps back to an included member. +fn round2_signing_subset( + interactive: &InteractiveSigningState, + signing_package: &frost::SigningPackage, +) -> Result, EngineError> { + let package_identifiers = signing_package + .signing_commitments() + .keys() + .copied() + .collect::>(); + let mut subset = Vec::with_capacity(package_identifiers.len()); + for participant in &interactive.canonical_included_participants { + let frost_identifier = participant_identifier_to_frost_identifier(*participant)?; + if package_identifiers.contains(&frost_identifier) { + subset.push(*participant); + } + } + Ok(subset) +} + +pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningState) { + if let Some(mut round1) = interactive.round1.take() { + round1.nonces.zeroize(); + } +} + +// Lazy TTL enforcement: every interactive entry point sweeps before +// 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. +pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) { + let ttl_seconds = interactive_session_ttl_seconds(); + let now = now_unix(); + // Interactive sessions always ride a DKG-populated session (Open + // requires existing DKG state), so expiry only clears the live + // attempt's nonces; the session itself - DKG material, consumed + // markers - is retained for future signing. + for session in engine_state.sessions.values_mut() { + let expired = session + .interactive_signing + .as_ref() + .is_some_and(|interactive| { + now.saturating_sub(interactive.opened_at_unix) > ttl_seconds + }); + if expired { + if let Some(mut removed) = session.interactive_signing.take() { + zeroize_interactive_round1(&mut removed); + } + } + } +} + +pub(crate) fn max_live_interactive_sessions_limit() -> usize { + signer_env_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV) + .and_then(|value| value.trim().parse::().ok()) + .filter(|limit| *limit > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_MAX_LIVE_INTERACTIVE_SESSIONS) +} + +pub(crate) fn interactive_session_ttl_seconds() -> u64 { + signer_env_var(TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS_ENV) + .and_then(|value| value.trim().parse::().ok()) + .filter(|ttl| *ttl > 0) + .unwrap_or(TBTC_SIGNER_DEFAULT_INTERACTIVE_SESSION_TTL_SECONDS) +} + +fn interactive_open_request_fingerprint( + request: &InteractiveSessionOpenRequest, +) -> Result { + // The serialized request transiently contains key_package_hex; + // wipe the buffer once the fingerprint digest is taken. + let mut canonical = serde_json::to_vec(request).map_err(|e| { + EngineError::Internal(format!( + "failed to serialize InteractiveSessionOpen request for fingerprint: {e}" + )) + })?; + let fingerprint = hash_hex(&canonical); + canonical.zeroize(); + Ok(fingerprint) +} diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index d201d8b169..ff2854c950 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -11,6 +11,7 @@ //! - [`config`] — TBTC_SIGNER_* environment surface: constant names, defaults, and parsers. //! - [`dkg`] — run_dkg session flow and production gates for the transitional dealer path. //! - [`frost_ops`] — Stateless FROST primitives: dkg_part1..3, nonces, signing package, share, aggregate. +//! - [`interactive`] — Phase 7.1 hardened interactive signing session: engine-held nonce custody, Round1/Round2, consumption markers. //! - [`lifecycle`] — Operational lifecycle: canary rollout, refresh cadence/shares, emergency rekey, quarantine status. //! - [`nonce`] — Deterministic round-nonce binding (round-nonce-v3 transcript seed). //! - [`persistence`] — Encrypted state-file persistence: envelope codec, key providers, corruption recovery, persisted<->live conversions. @@ -37,7 +38,7 @@ use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; use chacha20poly1305::{XChaCha20Poly1305, XNonce}; #[cfg(unix)] use libc::{flock, EAGAIN, EWOULDBLOCK, LOCK_EX, LOCK_NB}; -use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; use std::fs; use std::io::{Read, Write}; #[cfg(unix)] @@ -68,15 +69,18 @@ use crate::api::{ DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package, DkgRound2Package, FinalizeSignRoundRequest, GenerateNoncesAndCommitmentsRequest, GenerateNoncesAndCommitmentsResult, InitSignerConfigRequest, InitSignerConfigResult, - NativeFrostCommitment, NativeFrostKeyPackage, NativeFrostPublicKeyPackage, - NativeFrostSignatureShare, NewSigningPackageRequest, NewSigningPackageResult, - 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, + InteractiveRound1Request, InteractiveRound1Result, InteractiveRound2Request, + InteractiveRound2Result, InteractiveSessionAbortRequest, InteractiveSessionAbortResult, + InteractiveSessionOpenRequest, InteractiveSessionOpenResult, NativeFrostCommitment, + NativeFrostKeyPackage, NativeFrostPublicKeyPackage, NativeFrostSignatureShare, + NewSigningPackageRequest, NewSigningPackageResult, 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; @@ -87,6 +91,7 @@ mod config; mod dkg; mod frost_ops; mod init_config; +mod interactive; mod lifecycle; mod nonce; mod persistence; @@ -108,6 +113,7 @@ pub(crate) use config::*; pub(crate) use dkg::*; pub(crate) use frost_ops::*; pub(crate) use init_config::*; +pub(crate) use interactive::*; pub(crate) use lifecycle::*; pub(crate) use nonce::*; pub(crate) use persistence::*; diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index 83d47be553..9a6200345e 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -39,6 +39,11 @@ pub(crate) struct PersistedSessionState { pub(crate) refresh_history: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) emergency_rekey_event: Option, + // Phase 7.1 interactive consumption markers - the ONLY durable + // artifact of interactive sessions (markers-only durability: live + // interactive state, including nonces, never persists). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) consumed_interactive_attempt_markers: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -1261,6 +1266,26 @@ impl TryFrom for SessionState { consumed_finalize_request_fingerprints.len(), "consumed_finalize_request_fingerprints", )?; + + let mut consumed_interactive_attempt_markers = HashSet::new(); + for attempt_marker in persisted.consumed_interactive_attempt_markers { + if attempt_marker.is_empty() { + return Err(EngineError::Internal( + "persisted consumed interactive attempt marker must be non-empty".to_string(), + )); + } + + if !consumed_interactive_attempt_markers.insert(attempt_marker.clone()) { + return Err(EngineError::Internal(format!( + "duplicate persisted consumed interactive attempt marker [{}]", + attempt_marker + ))); + } + } + ensure_consumed_registry_persisted_bound( + consumed_interactive_attempt_markers.len(), + "consumed_interactive_attempt_markers", + )?; if persisted.attempt_transition_records.len() > TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION { @@ -1315,6 +1340,11 @@ impl TryFrom for SessionState { refresh_result: persisted.refresh_result, refresh_history: persisted.refresh_history, emergency_rekey_event: persisted.emergency_rekey_event, + // Live interactive state never restores: nonces are gone by + // construction after a restart, so the attempt fails safe and + // only the consumption markers survive. + interactive_signing: None, + consumed_interactive_attempt_markers, }) } } @@ -1382,6 +1412,10 @@ impl TryFrom<&SessionState> for PersistedSessionState { session_state.consumed_finalize_request_fingerprints.len(), "consumed_finalize_request_fingerprints", )?; + ensure_consumed_registry_persisted_bound( + session_state.consumed_interactive_attempt_markers.len(), + "consumed_interactive_attempt_markers", + )?; if session_state.attempt_transition_records.len() > TBTC_SIGNER_MAX_ATTEMPT_TRANSITION_RECORDS_PER_SESSION { @@ -1415,6 +1449,12 @@ impl TryFrom<&SessionState> for PersistedSessionState { .cloned() .collect::>(); consumed_finalize_request_fingerprints.sort_unstable(); + let mut consumed_interactive_attempt_markers = session_state + .consumed_interactive_attempt_markers + .iter() + .cloned() + .collect::>(); + consumed_interactive_attempt_markers.sort_unstable(); Ok(PersistedSessionState { dkg_request_fingerprint: session_state.dkg_request_fingerprint.clone(), @@ -1438,6 +1478,7 @@ impl TryFrom<&SessionState> for PersistedSessionState { refresh_result: session_state.refresh_result.clone(), refresh_history: session_state.refresh_history.clone(), emergency_rekey_event: session_state.emergency_rekey_event.clone(), + consumed_interactive_attempt_markers, }) } } diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index 60c55c256b..43fe1dc3d0 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -52,6 +52,40 @@ impl Drop for ZeroizingChaCha20Rng { } } +// Phase 7.1 interactive session state. Lives ONLY in memory: the +// nonces must never persist (frozen spec, markers-only durability), +// and without them the rest of this struct is useless after a +// restart, so none of it is mirrored into PersistedSessionState. +// The durable artifact is SessionState.consumed_interactive_attempt_markers. +pub(crate) struct InteractiveSigningState { + pub(crate) open_request_fingerprint: String, + pub(crate) attempt_context: AttemptContext, + pub(crate) canonical_included_participants: Vec, + pub(crate) member_identifier: u16, + pub(crate) threshold: u16, + pub(crate) message_bytes: SecretBytes, + pub(crate) taproot_merkle_root: Option<[u8; 32]>, + pub(crate) key_package: frost::keys::KeyPackage, + pub(crate) opened_at_unix: u64, + pub(crate) round1: Option, +} + +// Secret round-1 nonces and the public commitments they correspond +// to. The nonces are zeroized at every exit path (consumption, abort, +// expiry, replacement) by the interactive module; the Drop impl is +// the backstop for paths that drop the struct without going through +// one of those. +pub(crate) struct InteractiveRound1State { + pub(crate) nonces: frost::round1::SigningNonces, + pub(crate) commitments_hex: String, +} + +impl Drop for InteractiveRound1State { + fn drop(&mut self) { + self.nonces.zeroize(); + } +} + #[derive(Default)] pub(crate) struct SessionState { pub(crate) dkg_request_fingerprint: Option, @@ -75,6 +109,8 @@ pub(crate) struct SessionState { pub(crate) refresh_result: Option, pub(crate) refresh_history: Vec, pub(crate) emergency_rekey_event: Option, + pub(crate) interactive_signing: Option, + pub(crate) consumed_interactive_attempt_markers: HashSet, } #[derive(Default)] diff --git a/pkg/tbtc/signer/src/engine/telemetry.rs b/pkg/tbtc/signer/src/engine/telemetry.rs index acfd74a873..054d25dd05 100644 --- a/pkg/tbtc/signer/src/engine/telemetry.rs +++ b/pkg/tbtc/signer/src/engine/telemetry.rs @@ -61,11 +61,21 @@ pub(crate) struct HardeningTelemetryState { pub(crate) differential_fuzz_critical_divergence_total: u64, pub(crate) canary_promotions_total: u64, pub(crate) canary_rollbacks_total: u64, + pub(crate) interactive_session_open_calls_total: u64, + pub(crate) interactive_session_open_success_total: u64, + pub(crate) interactive_round1_calls_total: u64, + pub(crate) interactive_round1_success_total: u64, + pub(crate) interactive_round2_calls_total: u64, + pub(crate) interactive_round2_success_total: u64, + pub(crate) interactive_session_abort_calls_total: u64, + pub(crate) interactive_session_abort_success_total: u64, pub(crate) run_dkg_latency: HardeningLatencyTracker, pub(crate) start_sign_round_latency: HardeningLatencyTracker, pub(crate) build_taproot_tx_latency: HardeningLatencyTracker, pub(crate) finalize_sign_round_latency: HardeningLatencyTracker, pub(crate) refresh_shares_latency: HardeningLatencyTracker, + pub(crate) interactive_round1_latency: HardeningLatencyTracker, + pub(crate) interactive_round2_latency: HardeningLatencyTracker, pub(crate) last_updated_unix: u64, } @@ -76,6 +86,11 @@ pub(crate) enum HardeningOperation { BuildTaprootTx, FinalizeSignRound, RefreshShares, + // Interactive Open/Abort are O(1) registry mutations and record + // call/success counters only; the two cryptographic rounds get + // latency tracking. + InteractiveRound1, + InteractiveRound2, } pub(crate) struct HardeningOperationLatencyGuard { @@ -134,6 +149,12 @@ pub(crate) fn record_hardening_operation_latency(operation: HardeningOperation, telemetry.finalize_sign_round_latency.record(duration_ms) } HardeningOperation::RefreshShares => telemetry.refresh_shares_latency.record(duration_ms), + HardeningOperation::InteractiveRound1 => { + telemetry.interactive_round1_latency.record(duration_ms) + } + HardeningOperation::InteractiveRound2 => { + telemetry.interactive_round2_latency.record(duration_ms) + } }); } @@ -180,6 +201,18 @@ pub fn hardening_metrics() -> SignerHardeningMetricsResult { finalize_sign_round_latency_samples: 0, refresh_shares_latency_p95_ms: 0, refresh_shares_latency_samples: 0, + interactive_session_open_calls_total: 0, + interactive_session_open_success_total: 0, + interactive_round1_calls_total: 0, + interactive_round1_success_total: 0, + interactive_round2_calls_total: 0, + interactive_round2_success_total: 0, + interactive_session_abort_calls_total: 0, + interactive_session_abort_success_total: 0, + interactive_round1_latency_p95_ms: 0, + interactive_round1_latency_samples: 0, + interactive_round2_latency_p95_ms: 0, + interactive_round2_latency_samples: 0, last_updated_unix: 0, }; @@ -229,6 +262,26 @@ pub fn hardening_metrics() -> SignerHardeningMetricsResult { telemetry.finalize_sign_round_latency.sample_count(); result.refresh_shares_latency_p95_ms = telemetry.refresh_shares_latency.p95_ms(); result.refresh_shares_latency_samples = telemetry.refresh_shares_latency.sample_count(); + result.interactive_session_open_calls_total = + telemetry.interactive_session_open_calls_total; + result.interactive_session_open_success_total = + telemetry.interactive_session_open_success_total; + result.interactive_round1_calls_total = telemetry.interactive_round1_calls_total; + result.interactive_round1_success_total = telemetry.interactive_round1_success_total; + result.interactive_round2_calls_total = telemetry.interactive_round2_calls_total; + result.interactive_round2_success_total = telemetry.interactive_round2_success_total; + result.interactive_session_abort_calls_total = + telemetry.interactive_session_abort_calls_total; + result.interactive_session_abort_success_total = + telemetry.interactive_session_abort_success_total; + result.interactive_round1_latency_p95_ms = + telemetry.interactive_round1_latency.p95_ms(); + result.interactive_round1_latency_samples = + telemetry.interactive_round1_latency.sample_count(); + result.interactive_round2_latency_p95_ms = + telemetry.interactive_round2_latency.p95_ms(); + result.interactive_round2_latency_samples = + telemetry.interactive_round2_latency.sample_count(); result.last_updated_unix = telemetry.last_updated_unix; } Err(error) => { diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index da0eb7d9dc..4f852670ec 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -701,6 +701,7 @@ fn persisted_session_state_fixture() -> PersistedSessionState { refresh_result: None, refresh_history: vec![], emergency_rekey_event: None, + consumed_interactive_attempt_markers: vec![], } } @@ -11142,3 +11143,1594 @@ fn init_signer_config_installs_production_config_with_valid_provenance() { assert!(signer_profile_is_production()); assert!(provenance_gate_enforced()); } + +// --- Phase 7.1: hardened interactive signing session --- +// +// These tests pin the frozen-spec contracts (sections 4-5 of +// docs/phase-7-interactive-session-spec-freeze.md): engine-held nonce +// custody, Round2 verification (a)-(f) including the own-commitment +// framing defense, verify-before-consume, consumption-before-release +// marker durability, and abort/expiry/capacity semantics. + +fn interactive_test_key_packages() -> BTreeMap { + let fixture = deterministic_interactive_dkg_fixture(0); + fixture + .part3_requests + .into_iter() + .map(|(id, request)| { + ( + id, + dkg_part3(request) + .expect("DKG part3 for fixture") + .key_package, + ) + }) + .collect() +} + +// Seed a session with DKG state (threshold 2, members 1..3) from the +// deterministic fixture, so the interactive path can resolve key +// material from engine state - the request never carries it. Idempotent +// per session_id. Returns the fixture's native key packages for tests +// that also drive the stateless primitive (e.g. the non-interactive +// member in an aggregation). +fn ensure_interactive_dkg_session( + session_id: &str, + key_group: &str, +) -> BTreeMap { + let native = interactive_test_key_packages(); + + let mut guard = state().expect("engine state").lock().expect("engine lock"); + let session = guard.sessions.entry(session_id.to_string()).or_default(); + if session.dkg_result.is_none() { + let mut frost_key_packages = BTreeMap::new(); + for (id, key_package) in &native { + let deserialized = frost::keys::KeyPackage::deserialize( + &hex::decode(&key_package.data_hex).expect("fixture key package hex decodes"), + ) + .expect("fixture key package deserializes"); + frost_key_packages.insert(*id, deserialized); + } + session.dkg_result = Some(DkgResult { + session_id: session_id.to_string(), + key_group: key_group.to_string(), + participant_count: native.len() as u16, + threshold: 2, + created_at_unix: now_unix(), + }); + session.dkg_key_packages = Some(frost_key_packages); + } + + native +} + +fn interactive_test_attempt_context( + session_id: &str, + key_group: &str, + message_bytes: &[u8], + included_participants: &[u16], + wire_attempt_number: u32, +) -> AttemptContext { + let shuffle_seed = roast_attempt_shuffle_seed( + key_group, + session_id, + &rfc21_message_digest(message_bytes).expect("rfc21 message digest"), + ) + .expect("shuffle seed"); + let coordinator = + select_coordinator_identifier(included_participants, shuffle_seed, wire_attempt_number - 1) + .expect("coordinator selects"); + let fingerprint = roast_included_participants_fingerprint_hex(included_participants) + .expect("included participants fingerprint"); + let attempt_id = roast_attempt_id_hex( + session_id, + &hash_hex(message_bytes), + wire_attempt_number, + coordinator, + &fingerprint, + ) + .expect("attempt id"); + + AttemptContext { + attempt_number: wire_attempt_number, + coordinator_identifier: coordinator, + included_participants: included_participants.to_vec(), + included_participants_fingerprint: fingerprint, + attempt_id, + } +} + +#[allow(clippy::too_many_arguments)] +fn open_interactive_for_test( + session_id: &str, + key_group: &str, + message_bytes: &[u8], + included_participants: &[u16], + wire_attempt_number: u32, + member_identifier: u16, + threshold: u16, +) -> Result { + // Key material is resolved from the session's DKG state, never the + // request, so seed that state first (idempotent). + ensure_interactive_dkg_session(session_id, key_group); + let attempt_context = interactive_test_attempt_context( + session_id, + key_group, + message_bytes, + included_participants, + wire_attempt_number, + ); + interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier, + message_hex: hex::encode(message_bytes), + key_group: key_group.to_string(), + threshold, + taproot_merkle_root_hex: None, + attempt_context, + }) +} + +fn interactive_package_for_test( + message_bytes: &[u8], + commitments: Vec, +) -> String { + new_signing_package(NewSigningPackageRequest { + message_hex: hex::encode(message_bytes), + commitments, + }) + .expect("signing package builds") + .signing_package_hex +} + +#[test] +fn interactive_session_full_round_trip_aggregates_bip340() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-e2e"; + let key_group = "interactive-e2e-key-group"; + let message = [0x42u8; 32]; + let included = [1u16, 2]; + + // Member 1 signs through the hardened session API; member 2 signs + // through the stateless primitive. The shares must interoperate: + // the session layer changes custody, not cryptography. + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("interactive session opens"); + assert!(!opened.idempotent); + + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("interactive round 1"); + + 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 stateless nonces"); + + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + + let round2 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect("interactive round 2 releases the share"); + assert_eq!(round2.attempt_id, opened.attempt_id); + + // A completed Round2 frees the live session state immediately: the + // resident key package + message must not linger to the TTL sweep, + // and the capacity slot must be returned. Only the durable marker + // remains. + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session.interactive_signing.is_none(), + "completed Round2 must free the live interactive session state" + ); + assert!( + session + .consumed_interactive_attempt_markers + .contains(&opened.attempt_id), + "the durable consumption marker must remain after Round2" + ); + } + + let member2_share = sign_share(SignShareRequest { + signing_package_hex: signing_package_hex.clone(), + nonces_hex: member2.nonces_hex, + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + }) + .expect("member 2 stateless share"); + + 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 aggregate = aggregate(AggregateRequest { + signing_package_hex, + signature_shares: vec![ + crate::api::NativeFrostSignatureShare { + identifier: key_packages[&1].identifier.clone(), + data_hex: round2.signature_share_hex, + }, + member2_share.signature_share, + ], + public_key_package: public_key_package.clone(), + }) + .expect("aggregate"); + + let signature_bytes = hex::decode(aggregate.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("interactive + stateless shares aggregate to a valid BIP-340 signature"); +} + +#[test] +fn interactive_round1_is_idempotent_until_consumed() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-round1-idempotent"; + let key_group = "interactive-test-key-group"; + let message = [0x21u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + + let first = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + let second = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("repeat round 1"); + assert_eq!( + first.commitments_hex, second.commitments_hex, + "round 1 must be idempotent until the nonces are consumed" + ); + + 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: first.commitments_hex.clone(), + }, + member2.commitment, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 consumes"); + + let replay = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect_err("round 1 after consumption must fail closed"); + assert!( + matches!(replay, EngineError::ConsumedNonceReplay { .. }), + "unexpected error: {replay:?}" + ); + assert_eq!(replay.code(), "consumed_nonce_replay"); +} + +#[test] +fn interactive_round2_rejects_substituted_own_commitment_then_accepts_corrected() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-framing-defense"; + let key_group = "interactive-test-key-group"; + let message = [0x33u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + + 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"); + + // A malicious coordinator substitutes member 1's commitment with a + // different (validly formed) commitment for the same key package. + // Without the own-commitment check the member would sign with its + // true nonces over a package misrepresenting its commitment - the + // share then fails verification at aggregation and becomes false + // blame evidence against an honest member. + let substituted = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .expect("substituted commitment"); + assert_ne!( + substituted.commitment.data_hex, round1.commitments_hex, + "fixture sanity: substituted commitment differs" + ); + + let framed_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: substituted.commitment.data_hex, + }, + member2.commitment.clone(), + ], + ); + let framed = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: framed_package_hex, + }) + .expect_err("substituted own commitment must be rejected"); + assert!( + matches!(framed, EngineError::Validation(ref message) + if message.contains("does not match its round-1 output")), + "unexpected error: {framed:?}" + ); + + // Verify-before-consume: the rejected package must NOT have burned + // the nonces; the honest package still succeeds. + let honest_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex.clone(), + }, + member2.commitment, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex: honest_package_hex, + }) + .expect("honest package succeeds after the framed one was rejected"); +} + +#[test] +fn interactive_round2_package_shape_rejections() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let key_group = "interactive-test-key-group"; + let message = [0x44u8; 32]; + + // Session A: included {1,2} - outside-set and message-mismatch. + let session_a = "interactive-shape-a"; + let opened_a = open_interactive_for_test(session_a, key_group, &message, &[1, 2], 1, 1, 2) + .expect("session A opens"); + let round1_a = interactive_round1(InteractiveRound1Request { + session_id: session_a.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + }) + .expect("session A round 1"); + + let member3 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&3].identifier.clone(), + key_package_hex: key_packages[&3].data_hex.clone(), + }) + .expect("member 3 nonces"); + + let outside_set_package = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_a.commitments_hex.clone(), + }, + member3.commitment.clone(), + ], + ); + let outside = interactive_round2(InteractiveRound2Request { + session_id: session_a.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: outside_set_package, + }) + .expect_err("participant outside the included set must be rejected"); + assert!( + matches!(outside, EngineError::Validation(ref m) if m.contains("included set")), + "unexpected error: {outside:?}" + ); + + 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 wrong_message = [0x55u8; 32]; + let wrong_message_package = interactive_package_for_test( + &wrong_message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_a.commitments_hex.clone(), + }, + member2.commitment.clone(), + ], + ); + let mismatch = interactive_round2(InteractiveRound2Request { + session_id: session_a.to_string(), + attempt_id: opened_a.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: wrong_message_package, + }) + .expect_err("package over a different message must be rejected"); + assert!( + matches!(mismatch, EngineError::Validation(ref m) if m.contains("message")), + "unexpected error: {mismatch:?}" + ); + + // Session B: included {1,2,3}, threshold 2 - size and self-missing. + let session_b = "interactive-shape-b"; + let opened_b = open_interactive_for_test(session_b, key_group, &message, &[1, 2, 3], 1, 1, 2) + .expect("session B opens"); + let round1_b = interactive_round1(InteractiveRound1Request { + session_id: session_b.to_string(), + attempt_id: opened_b.attempt_id.clone(), + member_identifier: 1, + }) + .expect("session B round 1"); + + let oversized_package = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1_b.commitments_hex.clone(), + }, + member2.commitment.clone(), + member3.commitment.clone(), + ], + ); + let oversized = interactive_round2(InteractiveRound2Request { + session_id: session_b.to_string(), + attempt_id: opened_b.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: oversized_package, + }) + .expect_err("more than exactly-threshold commitments must be rejected"); + assert!( + matches!(oversized, EngineError::Validation(ref m) if m.contains("exactly threshold")), + "unexpected error: {oversized:?}" + ); + + let self_missing_package = + interactive_package_for_test(&message, vec![member2.commitment, member3.commitment]); + let self_missing = interactive_round2(InteractiveRound2Request { + session_id: session_b.to_string(), + attempt_id: opened_b.attempt_id, + member_identifier: 1, + signing_package_hex: self_missing_package, + }) + .expect_err("a package excluding this member must be rejected"); + assert!( + matches!(self_missing, EngineError::Validation(ref m) + if m.contains("does not include this member")), + "unexpected error: {self_missing:?}" + ); +} + +#[test] +fn interactive_consumption_marker_survives_restart() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-restart-marker"; + let key_group = "interactive-test-key-group"; + let message = [0x61u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + 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, + ], + ); + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 consumes"); + + simulate_process_restart_for_tests(); + reload_state_from_storage_for_tests(); + + // The durable marker must reject the consumed attempt across a + // restart at every entry point, even though the live interactive + // state (and its nonces) did not survive by construction. + let reopen = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect_err("reopening a consumed attempt after restart must fail closed"); + assert!( + matches!(reopen, EngineError::ConsumedNonceReplay { .. }), + "unexpected error: {reopen:?}" + ); + + // A fresh attempt for the same session proceeds: the marker is + // attempt-scoped, not session-scoped. + let second_attempt = + open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2) + .expect("a new attempt opens after restart"); + let round2_without_round1 = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: second_attempt.attempt_id, + member_identifier: 1, + signing_package_hex: "00".repeat(8), + }) + .expect_err("round 2 without round 1 must fail"); + assert!( + matches!( + round2_without_round1, + EngineError::Validation(_) | EngineError::SignRoundNotStarted { .. } + ), + "unexpected error: {round2_without_round1:?}" + ); +} + +#[test] +fn interactive_round2_persist_fault_leaves_nonces_live() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let session_id = "interactive-persist-fault"; + let key_group = "interactive-test-key-group"; + let message = [0x71u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + 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.clone(), + }, + member2.commitment, + ], + ); + + // Consumption-before-release: if the durable marker cannot be + // persisted, NO share leaves the engine and the nonces stay live. + set_persist_fault_injection_for_tests(PersistFaultInjectionPoint::AfterTempSyncBeforeRename); + let faulted = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("injected persist fault must fail round 2"); + clear_persist_fault_injection_for_tests(); + assert!( + matches!(faulted, EngineError::Internal(ref m) if m.contains("injected persist fault")), + "unexpected error: {faulted:?}" + ); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + !session + .consumed_interactive_attempt_markers + .contains(&opened.attempt_id), + "a failed persist must roll the consumption marker back" + ); + } + + // The same attempt completes once persistence recovers - the + // nonces were never consumed by the failed call. + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 succeeds after the persist fault clears"); + + { + let guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get(session_id).expect("session exists"); + assert!( + session + .consumed_interactive_attempt_markers + .contains(&opened.attempt_id), + "successful round 2 must leave the durable marker" + ); + } +} + +#[test] +fn interactive_open_idempotency_conflict_and_replacement() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-open-lifecycle"; + let key_group = "interactive-test-key-group"; + let message = [0x81u8; 32]; + let included = [1u16, 2]; + + let first = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + assert!(!first.idempotent); + + let repeat = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("identical reopen is idempotent"); + assert!(repeat.idempotent); + assert_eq!(repeat.attempt_id, first.attempt_id); + + // Same attempt, different request: conflicting reopen fails closed. + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let conflicting = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.to_string(), + member_identifier: 1, + message_hex: hex::encode(message), + key_group: key_group.to_string(), + threshold: 2, + taproot_merkle_root_hex: Some( + "1111111111111111111111111111111111111111111111111111111111111111".to_string(), + ), + attempt_context, + }) + .expect_err("conflicting reopen of a live attempt must fail closed"); + assert!( + matches!(conflicting, EngineError::SessionConflict { .. }), + "unexpected error: {conflicting:?}" + ); + + // Round 1 for attempt 1, then open attempt 2: the retry loop has + // moved on, so the newer attempt implicitly aborts the older one + // and its nonces. + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: first.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1 for attempt 1"); + let second = open_interactive_for_test(session_id, key_group, &message, &included, 2, 1, 2) + .expect("a newer attempt replaces the live one"); + assert_ne!(second.attempt_id, first.attempt_id); + + let stale = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: first.attempt_id, + member_identifier: 1, + }) + .expect_err("the replaced attempt must no longer be live"); + assert!( + matches!(stale, EngineError::Validation(ref m) if m.contains("does not match")), + "unexpected error: {stale:?}" + ); +} + +#[test] +fn interactive_abort_destroys_nonces_and_is_idempotent() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-abort"; + let key_group = "interactive-test-key-group"; + let message = [0x91u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + + let aborted = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some(opened.attempt_id.clone()), + }) + .expect("abort"); + assert!(aborted.aborted); + + let again = interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.to_string(), + attempt_id: Some(opened.attempt_id.clone()), + }) + .expect("abort is idempotent"); + assert!(!again.aborted); + + let dead = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect_err("an aborted attempt must not serve round 1"); + assert!( + matches!(dead, EngineError::SessionNotFound { .. }), + "unexpected error: {dead:?}" + ); + + // Abort destroyed the nonces WITHOUT a consumption marker: the + // attempt was never consumed, so reopening it is allowed and gets + // FRESH nonces (the old ones are gone forever). + let reopened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an aborted (never consumed) attempt may reopen"); + assert_eq!(reopened.attempt_id, opened.attempt_id); +} + +#[test] +fn interactive_session_ttl_expiry_has_abort_semantics() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-ttl"; + let key_group = "interactive-test-key-group"; + let message = [0xa1u8; 32]; + let included = [1u16, 2]; + + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + + // Age the session past the TTL directly; the next entry point's + // lazy sweep must destroy the nonces with abort semantics. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get_mut(session_id).expect("session exists"); + let interactive = session + .interactive_signing + .as_mut() + .expect("live interactive state"); + interactive.opened_at_unix = interactive + .opened_at_unix + .saturating_sub(interactive_session_ttl_seconds() + 1); + } + + let expired = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect_err("an expired attempt must not serve round 1"); + assert!( + matches!(expired, EngineError::SessionNotFound { .. }), + "unexpected error: {expired:?}" + ); + + // Expiry, like abort, leaves no consumption marker: the attempt + // never released a share, so reopening is allowed. + open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an expired (never consumed) attempt may reopen"); +} + +#[test] +fn interactive_live_session_capacity_fails_closed() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0xb1u8; 32]; + let included = [1u16, 2]; + + std::env::set_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, "1"); + + let outcome = (|| -> Result<(), EngineError> { + open_interactive_for_test("interactive-cap-a", key_group, &message, &included, 1, 1, 2)?; + + let at_capacity = + open_interactive_for_test("interactive-cap-b", key_group, &message, &included, 1, 1, 2) + .expect_err("the live-session cap must fail closed"); + assert!( + matches!(at_capacity, EngineError::Internal(ref m) + if m.contains("live interactive session count")), + "unexpected error: {at_capacity:?}" + ); + + // An idempotent reopen of the live session does not trip the cap. + let reopen = open_interactive_for_test( + "interactive-cap-a", + key_group, + &message, + &included, + 1, + 1, + 2, + )?; + assert!(reopen.idempotent); + + // Aborting frees the slot. + interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "interactive-cap-a".to_string(), + attempt_id: None, + })?; + open_interactive_for_test("interactive-cap-b", key_group, &message, &included, 1, 1, 2)?; + Ok(()) + })(); + + std::env::remove_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV); + outcome.expect("capacity lifecycle"); +} + +#[test] +fn interactive_open_signing_policy_firewall_rejects_without_policy_checked_build_tx() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + // The critical security assertion: with the firewall enabled, a + // fresh interactive session with no prior policy-checked + // build_taproot_tx must NOT be able to open and sign an arbitrary + // message. It fails closed at the same gate the coarse path uses. + let outcome = open_interactive_for_test( + "interactive-firewall-no-build-tx", + "interactive-firewall-key-group", + &[0xc1u8; 32], + &[1u16, 2], + 1, + 1, + 2, + ); + + std::env::remove_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV); + clear_state_storage_policy_overrides(); + + let err = outcome.expect_err("interactive open must fail closed under the firewall"); + let EngineError::SigningPolicyRejected { reason_code, .. } = err else { + panic!("unexpected error variant: {err:?}"); + }; + assert_eq!(reason_code, "missing_policy_checked_build_tx"); +} + +#[test] +fn interactive_open_signing_policy_firewall_binds_message_to_build_tx() { + let _guard = lock_test_state(); + reset_for_tests(); + clear_state_storage_policy_overrides(); + + let session_id = "interactive-firewall-bound"; + std::env::set_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, "true"); + std::env::set_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV, "p2tr,p2wpkh"); + configure_required_signing_policy_limits_for_tests(); + + let dkg_result = run_dkg(RunDkgRequest { + session_id: session_id.to_string(), + participants: vec![ + crate::api::DkgParticipant { + identifier: 1, + public_key_hex: "02aa".to_string(), + }, + crate::api::DkgParticipant { + identifier: 2, + public_key_hex: "02bb".to_string(), + }, + ], + threshold: 2, + dkg_seed_hex: None, + }) + .expect("run dkg"); + + let tx_result = build_taproot_tx(build_policy_test_request(session_id)).expect("build tx"); + let bound_message_hex = policy_bound_message_hex_from_tx_result(&tx_result); + let bound_message = hex::decode(&bound_message_hex).expect("bound message decodes"); + + let outcome = (|| -> Result<(), EngineError> { + // A message NOT bound to the policy-checked tx is rejected even + // for an otherwise-valid attempt context. + let unbound = open_interactive_for_test( + session_id, + &dkg_result.key_group, + &[0xd2u8; 32], + &[1u16, 2], + 1, + 1, + 2, + ) + .expect_err("an unbound message must be rejected under the firewall"); + assert!( + matches!(unbound, EngineError::SigningPolicyRejected { ref reason_code, .. } + if reason_code == "signing_message_not_bound_to_policy_checked_build_tx"), + "unexpected error: {unbound:?}" + ); + + // The policy-bound message opens successfully: enforcement is + // real, not always-reject. + let opened = open_interactive_for_test( + session_id, + &dkg_result.key_group, + &bound_message, + &[1u16, 2], + 1, + 1, + 2, + )?; + assert!(!opened.idempotent); + Ok(()) + })(); + + std::env::remove_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV); + std::env::remove_var(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV); + clear_state_storage_policy_overrides(); + + outcome.expect("policy-bound interactive open lifecycle"); +} + +#[test] +fn interactive_consumed_marker_is_case_insensitive() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-attempt-id-casing"; + let key_group = "interactive-test-key-group"; + let message = [0xe3u8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + // Build the canonical (lowercase) attempt context, consume it, then + // retry the SAME logical attempt with the attempt_id upper-cased. + // validate_attempt_context accepts the hash fields case- + // insensitively, so a raw-keyed marker would miss and re-sign; + // the canonical keying must reject it as consumed. + let canonical = interactive_test_attempt_context(session_id, key_group, &message, &included, 1); + let opened = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.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: canonical.clone(), + }) + .expect("canonical open"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + 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, + ], + ); + // Round2 with an UPPER-cased attempt_id must still consume the + // canonical attempt (proves round entry points canonicalize). + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.to_ascii_uppercase(), + member_identifier: 1, + signing_package_hex, + }) + .expect("round 2 under an upper-cased attempt_id consumes the canonical attempt"); + + // Reopen the SAME attempt with an upper-cased attempt_id: the + // consumed marker must catch it. + let mut recased_context = canonical; + recased_context.attempt_id = recased_context.attempt_id.to_ascii_uppercase(); + let replay = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.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: recased_context, + }) + .expect_err("a re-cased consumed attempt must fail closed"); + assert!( + matches!(replay, EngineError::ConsumedNonceReplay { .. }), + "unexpected error: {replay:?}" + ); +} + +#[test] +fn interactive_abort_sweeps_expired_sessions() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0xf4u8; 32]; + let included = [1u16, 2]; + + // Open a live attempt on session A, then age it past the TTL. + let opened = open_interactive_for_test( + "interactive-abort-sweep-a", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect("session A opens"); + interactive_round1(InteractiveRound1Request { + session_id: "interactive-abort-sweep-a".to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard + .sessions + .get_mut("interactive-abort-sweep-a") + .expect("session A exists"); + let interactive = session + .interactive_signing + .as_mut() + .expect("live interactive state"); + interactive.opened_at_unix = interactive + .opened_at_unix + .saturating_sub(interactive_session_ttl_seconds() + 1); + } + + // An abort for a DIFFERENT session is the only post-expiry traffic; + // it must still sweep session A's expired nonces (the TTL guarantee + // holds regardless of which entry point takes the lock). + interactive_session_abort(InteractiveSessionAbortRequest { + session_id: "interactive-abort-sweep-other".to_string(), + attempt_id: None, + }) + .expect("abort for an unrelated session"); + + // The sweep clears session A's expired live attempt (and its + // nonces) even though the only post-expiry traffic was an abort for + // an unrelated session. The session itself is retained - it rides + // DKG state that persists for future signing. + let guard = state().expect("state").lock().expect("lock"); + let session = guard + .sessions + .get("interactive-abort-sweep-a") + .expect("session A (DKG state) is retained"); + assert!( + session.interactive_signing.is_none(), + "an abort elsewhere must still sweep an expired interactive attempt" + ); +} + +#[test] +fn interactive_open_rejected_on_session_lifecycle_states() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_group = "interactive-test-key-group"; + let message = [0x17u8; 32]; + let included = [1u16, 2]; + + // A session under an emergency rekey must refuse interactive opens, + // exactly as start_sign_round does. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.sessions.insert( + "interactive-lifecycle-rekey".to_string(), + SessionState { + emergency_rekey_event: Some(EmergencyRekeyEvent { + reason: "test rekey".to_string(), + triggered_at_unix: now_unix(), + }), + ..Default::default() + }, + ); + } + let rekey = open_interactive_for_test( + "interactive-lifecycle-rekey", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect_err("an emergency-rekey session must refuse interactive open"); + assert!( + matches!(rekey, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {rekey:?}" + ); + + // A terminally finalized session must refuse interactive opens. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.sessions.insert( + "interactive-lifecycle-finalized".to_string(), + SessionState { + finalize_request_fingerprint: Some("already-finalized".to_string()), + ..Default::default() + }, + ); + } + let finalized = open_interactive_for_test( + "interactive-lifecycle-finalized", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect_err("a finalized session must refuse interactive open"); + assert!( + matches!(finalized, EngineError::SessionFinalized { .. }), + "unexpected error: {finalized:?}" + ); +} + +#[test] +fn interactive_open_rejected_for_quarantined_member_honors_dao_allowlist() { + let _guard = lock_test_state(); + reset_for_tests(); + + 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"); + + // Member 1 is auto-quarantined. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.quarantined_operator_identifiers.insert(1); + } + + let key_group = "interactive-test-key-group"; + let message = [0x18u8; 32]; + let included = [1u16, 2]; + + let outcome = (|| -> Result<(), EngineError> { + let quarantined = open_interactive_for_test( + "interactive-quarantine", + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect_err("a quarantined member must not open an interactive session"); + assert!( + matches!(quarantined, EngineError::QuarantinePolicyRejected { ref reason_code, .. } + if reason_code == "operator_auto_quarantined"), + "unexpected error: {quarantined:?}" + ); + + // A DAO allowlist override restores the member's ability to sign. + std::env::set_var( + TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV, + "1", + ); + let allowlisted = open_interactive_for_test( + "interactive-quarantine-allowlisted", + key_group, + &message, + &included, + 1, + 1, + 2, + )?; + assert!(!allowlisted.idempotent); + Ok(()) + })(); + + std::env::remove_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV); + + outcome.expect("quarantine gate lifecycle"); +} + +#[test] +fn interactive_round2_rechecks_gates_at_share_release() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + let key_group = "interactive-test-key-group"; + let message = [0x19u8; 32]; + let included = [1u16, 2]; + + // Open + Round1 normally (gates pass at Open), build the package, + // THEN record an emergency rekey before Round2. The share must not + // leave the engine: Round2 re-evaluates the gates at release time. + let session_id = "interactive-toctou-rekey"; + let opened = open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("opens"); + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + }) + .expect("round 1"); + 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 AFTER Open/Round1. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get_mut(session_id).expect("session exists"); + session.emergency_rekey_event = Some(EmergencyRekeyEvent { + reason: "post-open rekey".to_string(), + triggered_at_unix: now_unix(), + }); + } + + let blocked = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex: signing_package_hex.clone(), + }) + .expect_err("a post-open emergency rekey must block the Round2 share"); + assert!( + matches!(blocked, EngineError::LifecyclePolicyRejected { ref reason_code, .. } + if reason_code == "emergency_rekey_required"), + "unexpected error: {blocked:?}" + ); + + // The block at release time must be fail-closed WITHOUT consuming + // the nonces: no marker was written (verify-before-consume applies + // to the gate recheck too), so clearing the kill switch lets the + // same attempt complete. This proves the recheck rejects before + // consumption rather than after. + { + let mut guard = state().expect("state").lock().expect("lock"); + let session = guard.sessions.get_mut(session_id).expect("session exists"); + assert!( + !session + .consumed_interactive_attempt_markers + .contains(&opened.attempt_id), + "a gate rejection must not consume the attempt" + ); + session.emergency_rekey_event = None; + } + + interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id, + member_identifier: 1, + signing_package_hex, + }) + .expect("the same attempt completes once the kill switch clears"); +} + +#[test] +fn interactive_open_rejects_threshold_below_key_package_min_signers() { + let _guard = lock_test_state(); + reset_for_tests(); + + // The fixture key packages are min_signers = 2. A request threshold + // of 3 must be rejected at Open: otherwise Round2 would accept a + // 3-commitment package, persist the marker, and only then have + // frost::round2::sign fail on the count - burning the nonce for a + // validation error. + let mismatch = open_interactive_for_test( + "interactive-threshold-mismatch", + "interactive-test-key-group", + &[0x1au8; 32], + &[1u16, 2, 3], + 1, + 1, + 3, + ) + .expect_err("a threshold below the key package min_signers must be rejected"); + assert!( + matches!(mismatch, EngineError::Validation(ref m) + if m.contains("does not match the DKG threshold")), + "unexpected error: {mismatch:?}" + ); + + // The matching threshold (2) opens. + open_interactive_for_test( + "interactive-threshold-match", + "interactive-test-key-group", + &[0x1au8; 32], + &[1u16, 2], + 1, + 1, + 2, + ) + .expect("the key-package-matching threshold opens"); +} + +#[test] +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.) + let attempt_context = interactive_test_attempt_context( + "interactive-no-dkg", + "interactive-test-key-group", + &[0x1bu8; 32], + &[1u16, 2], + 1, + ); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: "interactive-no-dkg".to_string(), + member_identifier: 1, + message_hex: hex::encode([0x1bu8; 32]), + key_group: "interactive-test-key-group".to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context, + }) + .expect_err("interactive open without a DKG session must fail closed"); + assert!( + matches!(err, EngineError::SessionNotFound { .. }), + "unexpected error: {err:?}" + ); + + // A member not in the session's DKG group is rejected even once DKG + // exists (the group has members 1..3, so member 4 is absent). + ensure_interactive_dkg_session("interactive-dkg-present", "interactive-test-key-group"); + let absent_member = interactive_test_attempt_context( + "interactive-dkg-present", + "interactive-test-key-group", + &[0x1bu8; 32], + &[1u16, 2], + 1, + ); + let absent = interactive_session_open(InteractiveSessionOpenRequest { + session_id: "interactive-dkg-present".to_string(), + member_identifier: 4, + message_hex: hex::encode([0x1bu8; 32]), + key_group: "interactive-test-key-group".to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context: absent_member, + }) + .expect_err("a non-DKG-participant member must be rejected"); + assert!( + matches!(absent, EngineError::Validation(ref m) + if m.contains("not a DKG participant") + || m.contains("included_participants")), + "unexpected error: {absent:?}" + ); +} + +#[test] +fn interactive_round2_rejects_quarantined_co_signer_in_package() { + let _guard = lock_test_state(); + reset_for_tests(); + + let session_id = "interactive-round2-quarantined-cosigner"; + let key_group = "interactive-test-key-group"; + let message = [0x1cu8; 32]; + let included = [1u16, 2]; + let key_packages = ensure_interactive_dkg_session(session_id, key_group); + + 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"); + + let outcome = (|| -> Result<(), EngineError> { + // This member (1) opens and runs round 1 while no one is + // quarantined; the co-signer (2) is quarantined afterward. + let opened = + open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2)?; + let round1 = interactive_round1(InteractiveRound1Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + })?; + let member2 = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: key_packages[&2].identifier.clone(), + key_package_hex: key_packages[&2].data_hex.clone(), + })?; + let signing_package_hex = interactive_package_for_test( + &message, + vec![ + NativeFrostCommitment { + identifier: key_packages[&1].identifier.clone(), + data_hex: round1.commitments_hex, + }, + member2.commitment, + ], + ); + + // Quarantine the co-signer (member 2) after round 1. + { + let mut guard = state().expect("state").lock().expect("lock"); + guard.quarantined_operator_identifiers.insert(2); + } + + // Round2 must refuse: this node will not contribute a share to a + // package whose subset includes a quarantined co-signer, even + // though this node (member 1) is not itself quarantined. + let blocked = interactive_round2(InteractiveRound2Request { + session_id: session_id.to_string(), + attempt_id: opened.attempt_id.clone(), + member_identifier: 1, + signing_package_hex, + }) + .expect_err("a quarantined co-signer in the package must block the share"); + assert!( + matches!(blocked, EngineError::QuarantinePolicyRejected { ref reason_code, .. } + if reason_code == "operator_auto_quarantined"), + "unexpected error: {blocked:?}" + ); + + // Fail-closed without consuming: clearing the quarantine lets the + // same attempt complete (the rejection preceded consumption). + { + let mut guard = state().expect("state").lock().expect("lock"); + assert!( + !guard + .sessions + .get(session_id) + .expect("session") + .consumed_interactive_attempt_markers + .contains(&opened.attempt_id), + "a quarantine rejection must not consume the attempt" + ); + guard.quarantined_operator_identifiers.remove(&2); + } + Ok(()) + })(); + + std::env::remove_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV); + std::env::remove_var(TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV); + outcome.expect("round2 co-signer quarantine lifecycle"); +} + +#[test] +fn interactive_open_rejects_phantom_included_participant() { + let _guard = lock_test_state(); + reset_for_tests(); + + // The session's DKG group is members 1..3. An attempt context whose + // included set names a phantom id (99) must be rejected even though + // the local member (1) is a real participant - otherwise a caller + // could bias the RFC-21 coordinator/attempt derivation with + // non-participants. + let session_id = "interactive-phantom-included"; + let key_group = "interactive-test-key-group"; + let message = [0x1du8; 32]; + ensure_interactive_dkg_session(session_id, key_group); + + let attempt_context = + interactive_test_attempt_context(session_id, key_group, &message, &[1u16, 99], 1); + let err = interactive_session_open(InteractiveSessionOpenRequest { + session_id: session_id.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 phantom included participant must be rejected"); + assert!( + matches!(err, EngineError::Validation(ref m) + if m.contains("not a DKG participant for this session")), + "unexpected error: {err:?}" + ); +} diff --git a/pkg/tbtc/signer/src/errors.rs b/pkg/tbtc/signer/src/errors.rs index dd2d6d9999..636d9ccf82 100644 --- a/pkg/tbtc/signer/src/errors.rs +++ b/pkg/tbtc/signer/src/errors.rs @@ -69,6 +69,19 @@ pub enum EngineError { session_id: String, round_id: String, }, + /// Returned when an interactive attempt whose nonce handle was already + /// consumed (a signature share was released, or release was durably + /// committed) is touched again - a second Round2 with the same handle, + /// or Round1/SessionOpen for a consumed attempt. The caller must mint a + /// new attempt; the engine will never release a second share under one + /// nonce pair (frozen Phase 7 spec, section 4). + #[error( + "interactive attempt [{attempt_id}] already consumed its nonces in session [{session_id}]" + )] + ConsumedNonceReplay { + session_id: String, + attempt_id: String, + }, #[error("internal error: {0}")] Internal(String), } @@ -90,6 +103,7 @@ impl EngineError { Self::SignRoundNotStarted { .. } => "sign_round_not_started", Self::ConsumedAttemptReplay { .. } => "consumed_attempt_replay", Self::ConsumedRoundReplay { .. } => "consumed_round_replay", + Self::ConsumedNonceReplay { .. } => "consumed_nonce_replay", Self::Internal(_) => "internal_error", } } @@ -113,6 +127,7 @@ impl EngineError { // attempt_id rather than retransmit. Self::ConsumedAttemptReplay { .. } => "recoverable", Self::ConsumedRoundReplay { .. } => "recoverable", + Self::ConsumedNonceReplay { .. } => "recoverable", Self::SessionFinalized { .. } => "terminal", Self::SessionNotFound { .. } => "terminal", Self::Internal(_) => "terminal", diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 910a353dde..81c9499996 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -10,10 +10,11 @@ use std::sync::OnceLock; use api::{ AggregateRequest, BuildTaprootTxRequest, DifferentialFuzzRequest, DkgPart1Request, DkgPart2Request, DkgPart3Request, FinalizeSignRoundRequest, - GenerateNoncesAndCommitmentsRequest, InitSignerConfigRequest, NewSigningPackageRequest, - PromoteCanaryRequest, QuarantineStatusRequest, RefreshCadenceStatusRequest, - RefreshSharesRequest, RollbackCanaryRequest, RunDkgRequest, SignShareRequest, - StartSignRoundRequest, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, + GenerateNoncesAndCommitmentsRequest, InitSignerConfigRequest, InteractiveRound1Request, + InteractiveRound2Request, InteractiveSessionAbortRequest, InteractiveSessionOpenRequest, + NewSigningPackageRequest, PromoteCanaryRequest, QuarantineStatusRequest, + RefreshCadenceStatusRequest, RefreshSharesRequest, RollbackCanaryRequest, RunDkgRequest, + SignShareRequest, StartSignRoundRequest, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; use ffi::{ @@ -300,6 +301,59 @@ pub extern "C" fn frost_tbtc_aggregate( }) } +// Phase 7.1 hardened interactive signing session (frozen spec +// docs/phase-7-interactive-session-spec-freeze.md). Additive ABI: the +// Go host adopts these in Phase 7.3; nothing breaks until it calls +// them. Secret nonces never cross this boundary in either direction. + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_session_open( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveSessionOpenRequest = parse_request(request_ptr, request_len)?; + let response = engine::interactive_session_open(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_round1( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveRound1Request = parse_request(request_ptr, request_len)?; + let response = engine::interactive_round1(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_round2( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveRound2Request = parse_request(request_ptr, request_len)?; + let response = engine::interactive_round2(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_interactive_session_abort( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InteractiveSessionAbortRequest = parse_request(request_ptr, request_len)?; + let response = engine::interactive_session_abort(request)?; + serialize_response(&response) + }) +} + #[no_mangle] pub extern "C" fn frost_tbtc_start_sign_round( request_ptr: *const u8, @@ -703,6 +757,71 @@ mod tests { assert_eq!(error.recovery_class, "recoverable"); } + #[test] + fn interactive_session_ffi_dispatch_smoke() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + let _profile_env = EnvVarGuard::set(super::TBTC_SIGNER_PROFILE_ENV, "development"); + let _provenance_env = EnvVarGuard::set("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false"); + + // Structurally valid requests whose semantics fail: proves + // symbol -> parse -> engine -> structured-error dispatch for + // every Phase 7.1 export without standing up a signing fixture + // (the engine tests own the cryptographic contracts). + let open = crate::api::InteractiveSessionOpenRequest { + session_id: "ffi-interactive-smoke".to_string(), + member_identifier: 1, + message_hex: "11".repeat(32), + key_group: "ffi-smoke-key-group".to_string(), + threshold: 2, + taproot_merkle_root_hex: None, + attempt_context: crate::api::AttemptContext { + attempt_number: 1, + coordinator_identifier: 1, + included_participants: vec![1, 2], + included_participants_fingerprint: "00".to_string(), + 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). + 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"); + + let round1 = crate::api::InteractiveRound1Request { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: "missing".to_string(), + member_identifier: 1, + }; + let (status, payload) = call_ffi(&round1, super::frost_tbtc_interactive_round1); + assert_ne!(status, 0); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("round1 error payload"); + assert_eq!(error.code, "session_not_found"); + + let round2 = crate::api::InteractiveRound2Request { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: "missing".to_string(), + member_identifier: 1, + signing_package_hex: "00".to_string(), + }; + let (status, payload) = call_ffi(&round2, super::frost_tbtc_interactive_round2); + assert_ne!(status, 0); + let error: ErrorResponse = serde_json::from_slice(&payload).expect("round2 error payload"); + assert_eq!(error.code, "validation_error"); + + let abort = crate::api::InteractiveSessionAbortRequest { + session_id: "ffi-interactive-smoke-missing".to_string(), + attempt_id: None, + }; + let (status, payload) = call_ffi(&abort, super::frost_tbtc_interactive_session_abort); + assert_eq!(status, 0); + let result: crate::api::InteractiveSessionAbortResult = + serde_json::from_slice(&payload).expect("abort result payload"); + assert!(!result.aborted); + } + fn native_frost_identifier(member_index: u8) -> String { let mut identifier = [0u8; 32]; identifier[0] = member_index;