From 769f9d779157c71c5772112d46e5b65c4a4fd0fb Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 19:30:56 -0400 Subject: [PATCH 1/9] feat(tbtc/signer): Phase 7.1 hardened interactive signing session Implements sections 4-5 of the frozen Phase 7 spec: the interactive two-round signing path with 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), zeroize on consumption, abort, expiry, and replacement, and never appear in a request, response, or persisted state. The only durable artifact is the per-attempt consumption marker, persisted BEFORE the signature share leaves the engine; a persist failure rolls the marker back with the nonces left live, and a marker without a released share just kills the attempt - fail closed. A restart can therefore never produce a second share under one nonce pair, and the cloned-state nonce-reuse class is structurally gone. Entry points (strict-mode attempt contexts only, no legacy fallback): InteractiveSessionOpen (key package supplied once per session, validated against the member; idempotent by request fingerprint; conflicting reopen fails closed; a newer attempt implicitly aborts the prior live one), InteractiveRound1 (idempotent until consumed), InteractiveRound2 (verification precedes consumption: message binding, subset-of-included, exactly-threshold size, own membership, and the own-commitment byte-identity check (f) that defeats coordinator framing of honest members), InteractiveSessionAbort (idempotent; destroys nonces without a marker, so a never-consumed attempt may reopen with fresh nonces). Live sessions are capacity-capped (fail-closed at TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS, default 64) and TTL-swept lazily with abort semantics (TBTC_SIGNER_INTERACTIVE_SESSION_TTL_SECONDS, default 3600); both knobs ride the init-config surface. New structured error code consumed_nonce_replay; call/success counters for all four operations and latency tracking for the two cryptographic rounds. The four FFI exports (frost_tbtc_interactive_*) ship additively per the established pattern - the Go host adopts them in Phase 7.3. Tests: 10 engine tests pin the e2e round trip (one member through the session API, one through the stateless primitive, aggregating to a verified BIP-340 signature), the framing-attack rejection and verify-before-consume recovery, package-shape rejections, replay and restart-marker durability, persist-fault marker rollback, open lifecycle, abort, TTL expiry, and capacity; one lib test pins FFI dispatch for all four exports. Full suite 255 passed / 1 ignored, clippy -D warnings clean across all targets including the bench-restart-hook shape, Phase 5 chaos suite green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/api.rs | 110 +++ pkg/tbtc/signer/src/engine/config.rs | 14 + pkg/tbtc/signer/src/engine/init_config.rs | 10 + pkg/tbtc/signer/src/engine/interactive.rs | 598 ++++++++++++ pkg/tbtc/signer/src/engine/mod.rs | 26 +- pkg/tbtc/signer/src/engine/persistence.rs | 41 + pkg/tbtc/signer/src/engine/state.rs | 36 + pkg/tbtc/signer/src/engine/telemetry.rs | 53 ++ pkg/tbtc/signer/src/engine/tests.rs | 1023 +++++++++++++++++++++ pkg/tbtc/signer/src/errors.rs | 15 + pkg/tbtc/signer/src/lib.rs | 127 ++- 11 files changed, 2039 insertions(+), 14 deletions(-) create mode 100644 pkg/tbtc/signer/src/engine/interactive.rs diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index dc6b94c6a6..27149af729 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -146,6 +146,88 @@ 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, + 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, + /// The member's key package, supplied once per session and held by + /// the engine for the session's lifetime (in memory only: + /// interactive session state follows markers-only durability). + pub key_package_identifier: String, + pub key_package_hex: String, +} + +#[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 +603,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 +671,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..576bc90aa2 --- /dev/null +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -0,0 +1,598 @@ +// 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)?; + + // Strict-mode-only attempt context: required, fully validated, + // coordinator recomputed per RFC-21 Annex A. + let canonical_included_participants = validate_attempt_context( + &request.session_id, + &request.key_group, + &message_bytes, + &message_digest_hex, + request.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(), + )); + } + + let key_package = decode_key_package( + "InteractiveSessionOpen", + &request.key_package_identifier, + &request.key_package_hex, + )?; + let expected_identifier = + participant_identifier_to_frost_identifier(request.member_identifier)?; + if *key_package.identifier() != expected_identifier { + return Err(EngineError::Validation( + "key_package_identifier must match member_identifier".to_string(), + )); + } + + 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); + + ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + + // The live-session capacity check counts nonce-bearing sessions + // OTHER than this one, so an idempotent reopen or an + // implicit-abort replacement never trips the cap. + let live_interactive_sessions = guard + .sessions + .iter() + .filter(|(session_id, session)| { + session.interactive_signing.is_some() && session_id.as_str() != request.session_id + }) + .count(); + + let session = guard + .sessions + .entry(request.session_id.clone()) + .or_default(); + + if session + .consumed_interactive_attempt_markers + .contains(&attempt_id) + { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id, + }); + } + + if let Some(existing) = session.interactive_signing.as_ref() { + if existing.attempt_context.attempt_id == attempt_id { + if existing.open_request_fingerprint == request_fingerprint { + return Ok(InteractiveSessionOpenResult { + session_id: request.session_id, + attempt_id, + idempotent: true, + }); + } + return Err(EngineError::SessionConflict { + session_id: request.session_id.clone(), + }); + } + // A different attempt for the same session implicitly aborts + // the previous live attempt: the retry loop has moved on, and + // a stuck prior attempt must not strand its nonces. Zeroize + // happens in the round-1 state's drop path below. + } + + if session.interactive_signing.is_none() + && 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 + ))); + } + + 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)?; + + 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(&request.attempt_id) + { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id: request.attempt_id, + }); + } + + let interactive = interactive_state_for_attempt_mut( + session, + &request.session_id, + &request.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}")) + })?; + + 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(&request.attempt_id) + { + return Err(EngineError::ConsumedNonceReplay { + session_id: request.session_id.clone(), + attempt_id: request.attempt_id, + }); + } + + ensure_consumed_registry_insert_capacity( + &session.consumed_interactive_attempt_markers, + &request.attempt_id, + "consumed_interactive_attempt_markers", + &request.session_id, + )?; + + let interactive = interactive_state_for_attempt_mut( + session, + &request.session_id, + &request.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)?; + + // 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(request.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(&request.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); + + 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: request.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)?; + + let mut guard = state()? + .lock() + .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; + + let aborted = match guard.sessions.get_mut(&request.session_id) { + Some(session) => match session.interactive_signing.as_ref() { + Some(interactive) + if request.attempt_id.is_none() + || request.attempt_id.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(()) +} + +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(); + 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..cfa01f8e3b 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,1025 @@ 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() +} + +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( + key_packages: &BTreeMap, + session_id: &str, + key_group: &str, + message_bytes: &[u8], + included_participants: &[u16], + wire_attempt_number: u32, + member_identifier: u16, + threshold: u16, +) -> Result { + 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, + key_package_identifier: key_packages[&member_identifier].identifier.clone(), + key_package_hex: key_packages[&member_identifier].data_hex.clone(), + }) +} + +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( + &key_packages, + 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); + + 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( + &key_packages, + 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( + &key_packages, + 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( + &key_packages, + 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( + &key_packages, + 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( + &key_packages, + 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( + &key_packages, + 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( + &key_packages, + 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( + &key_packages, + 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 key_packages = interactive_test_key_packages(); + 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( + &key_packages, + session_id, + key_group, + &message, + &included, + 1, + 1, + 2, + ) + .expect("opens"); + assert!(!first.idempotent); + + let repeat = open_interactive_for_test( + &key_packages, + 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, + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .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( + &key_packages, + 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 key_packages = interactive_test_key_packages(); + 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( + &key_packages, + 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( + &key_packages, + 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 key_packages = interactive_test_key_packages(); + 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( + &key_packages, + 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( + &key_packages, + 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_packages = interactive_test_key_packages(); + 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( + &key_packages, + "interactive-cap-a", + key_group, + &message, + &included, + 1, + 1, + 2, + )?; + + let at_capacity = open_interactive_for_test( + &key_packages, + "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( + &key_packages, + "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( + &key_packages, + "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"); +} 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..15d7b817b5 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: 0, // invalid: wire attempt numbers are 1-based + coordinator_identifier: 1, + included_participants: vec![1, 2], + included_participants_fingerprint: "00".to_string(), + attempt_id: "ffi-smoke-attempt".to_string(), + }, + key_package_identifier: "00".to_string(), + key_package_hex: "00".to_string(), + }; + 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, "validation_error"); + + 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; From 90cb7c301945eaa0e7c65e971be0de69c6b4216d Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 19:40:05 -0400 Subject: [PATCH 2/9] fix(tbtc/signer): free interactive session state on completion and reject Two resource issues found reviewing the 7.1 session layer, both of my own making: 1. Round2 took the nonces but left interactive_signing = Some with the key package and message still resident, so a completed session held its live-session capacity slot AND kept key material in memory until the TTL sweep. A node doing many sequential signings could exhaust the cap of 64 with completed-but-unswept sessions and fail-close new opens. Round2 now clears interactive_signing once the attempt is terminal (success or post-marker share failure); the durable marker carries all further replay protection. 2. interactive_session_open inserted an empty SessionState via entry().or_default() BEFORE the consumed-marker / conflict / capacity reject checks, so a flood of rejected opens for distinct session_ids accumulated empty sessions against the global 1024 session cap, which could then starve DKG. Open now decides from a read-only view and only inserts once committed to creating the attempt. Tests extended: the e2e test asserts the live state is freed (marker retained) after Round2; the capacity test asserts a rejected open leaves no empty session. Full suite 255 passed / 1 ignored, clippy -D warnings clean, chaos suite green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/interactive.rs | 109 ++++++++++++++-------- pkg/tbtc/signer/src/engine/tests.rs | 31 ++++++ 2 files changed, 99 insertions(+), 41 deletions(-) diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 576bc90aa2..300792003b 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -98,62 +98,79 @@ pub fn interactive_session_open( ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; - // The live-session capacity check counts nonce-bearing sessions - // OTHER than this one, so an idempotent reopen or an - // implicit-abort replacement never trips the cap. - let live_interactive_sessions = guard - .sessions - .iter() - .filter(|(session_id, session)| { - session.interactive_signing.is_some() && session_id.as_str() != request.session_id - }) - .count(); - - let session = guard - .sessions - .entry(request.session_id.clone()) - .or_default(); + // Decide everything from a read-only view BEFORE inserting anything, + // so the reject paths (consumed marker, conflict, capacity) never + // leave an empty SessionState behind. Returns: whether the attempt + // is already consumed, the disposition of any live attempt under + // this exact attempt_id (Some(true)=idempotent, Some(false)= + // conflicting fingerprint, None=no matching live attempt), and + // whether a live interactive attempt is being replaced. + let (already_consumed, matching_attempt_idempotent, replacing) = { + let existing = guard.sessions.get(&request.session_id); + let already_consumed = existing.is_some_and(|session| { + session + .consumed_interactive_attempt_markers + .contains(&attempt_id) + }); + let matching_attempt_idempotent = existing + .and_then(|session| session.interactive_signing.as_ref()) + .filter(|interactive| interactive.attempt_context.attempt_id == attempt_id) + .map(|interactive| interactive.open_request_fingerprint == request_fingerprint); + let replacing = existing.is_some_and(|session| session.interactive_signing.is_some()); + (already_consumed, matching_attempt_idempotent, replacing) + }; - if session - .consumed_interactive_attempt_markers - .contains(&attempt_id) - { + if already_consumed { return Err(EngineError::ConsumedNonceReplay { session_id: request.session_id.clone(), attempt_id, }); } - if let Some(existing) = session.interactive_signing.as_ref() { - if existing.attempt_context.attempt_id == attempt_id { - if existing.open_request_fingerprint == request_fingerprint { - return Ok(InteractiveSessionOpenResult { - session_id: request.session_id, - attempt_id, - idempotent: true, - }); - } + 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(), }); } - // A different attempt for the same session implicitly aborts - // the previous live attempt: the retry loop has moved on, and - // a stuck prior attempt must not strand its nonces. Zeroize - // happens in the round-1 state's drop path below. + // None: no live attempt under this attempt_id. If a DIFFERENT + // attempt is live it is implicitly aborted below - the retry + // loop has moved on and a stuck prior attempt must not strand + // its nonces. + None => {} } - if session.interactive_signing.is_none() - && 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 - ))); + // 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 + .entry(request.session_id.clone()) + .or_default(); + if let Some(mut replaced) = session.interactive_signing.take() { zeroize_interactive_round1(&mut replaced); } @@ -370,6 +387,16 @@ pub fn interactive_round2( 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}")))?; diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index cfa01f8e3b..0d366e52e3 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -11307,6 +11307,25 @@ fn interactive_session_full_round_trip_aggregates_bip340() { .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, @@ -12131,6 +12150,18 @@ fn interactive_live_session_capacity_fails_closed() { "unexpected error: {at_capacity:?}" ); + // A capacity rejection for a brand-new session_id must NOT + // leave an empty SessionState behind (it would otherwise + // accumulate against the global session cap and could starve + // DKG). + { + let guard = state().expect("state").lock().expect("lock"); + assert!( + !guard.sessions.contains_key("interactive-cap-b"), + "a rejected interactive open must not insert an empty session" + ); + } + // An idempotent reopen of the live session does not trip the cap. let reopen = open_interactive_for_test( &key_packages, From 36055a34c95d24a212bd6af2f523b6059bb59feb Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 19:47:08 -0400 Subject: [PATCH 3/9] fix(tbtc/signer): declare the Phase 7.1 interactive FFI in the C header The four frost_tbtc_interactive_* symbols were exported from Rust but absent from the hand-maintained public header, so a cgo consumer could not compile against the 7.1 ABI without hand-declaring them - blocking the Phase 7.3 Go adoption path (review finding). Adds the declarations with a header comment drawing the custody contrast against the stateless nonce block above: here secret nonces never cross the boundary in either direction. Verified: header symbols now exactly match the Rust exports, header parses as valid C. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/include/frost_tbtc.h | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 From fb6f33d4a6ab1cd820883c970e5acb6c1297e5c8 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 20:07:05 -0400 Subject: [PATCH 4/9] fix(tbtc/signer): close firewall bypass, attempt-id casing, abort sweep Three findings on the 7.1 interactive path, all mine: P1 (security) - InteractiveSessionOpen bypassed the signing-policy firewall. The coarse start_sign_round binds the signed message to a prior policy-checked build_taproot_tx (enforce_signing_message_binding_to_policy_checked_build_tx); the interactive open never called it, so with the firewall enabled a caller holding a key package could open a fresh session and sign an arbitrary message - a direct violation of frozen spec section 5 ("Open checks policy gates"). Open now enforces the same binding before creating any state; a session with no policy-checked tx fails closed. P2 (replay) - validate_attempt_context accepts the attempt_id hash field case-insensitively (eq_ignore_ascii_case), but the marker registry and live-state lookups keyed on the raw string. A consumed attempt retried with different hex casing missed the marker and could be signed again. Open now canonicalizes the whole attempt context via canonical_attempt_context (matching the coarse path), and the round entry points canonicalize the incoming attempt_id, so all keying is on the lowercase canonical form. P3 (TTL) - InteractiveSessionAbort was the only entry point that did not sweep expired interactive state, so an abort for an unrelated session left other sessions' expired nonces in memory past the TTL. Abort now sweeps like every other locked entry point. Tests: firewall reject-without-build-tx (the security assertion) + bound-message accept/mismatch-reject; consumed-marker case insensitivity across Round2 and reopen; abort sweeps an unrelated session's expired state. Full suite 259 passed / 1 ignored, clippy -D warnings clean, chaos suite green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/interactive.rs | 75 ++++++- pkg/tbtc/signer/src/engine/tests.rs | 258 ++++++++++++++++++++++ 2 files changed, 321 insertions(+), 12 deletions(-) diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 300792003b..83b99bc4a4 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -51,6 +51,15 @@ pub fn interactive_session_open( 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); + // Strict-mode-only attempt context: required, fully validated, // coordinator recomputed per RFC-21 Annex A. let canonical_included_participants = validate_attempt_context( @@ -98,6 +107,22 @@ pub fn interactive_session_open( ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + // Signing-policy firewall (frozen spec section 5: Open "checks + // policy gates"). When the firewall is enabled, the message must be + // bound to a prior policy-checked build_taproot_tx for this + // session, exactly as the coarse start_sign_round path enforces it + // - otherwise a caller holding a key package could open an + // interactive session on a fresh session_id and sign an arbitrary + // message. A session with no policy-checked tx fails closed here. + enforce_signing_message_binding_to_policy_checked_build_tx( + &request.session_id, + &request.message_hex, + guard + .sessions + .get(&request.session_id) + .and_then(|session| session.tx_result.as_ref()), + )?; + // Decide everything from a read-only view BEFORE inserting anything, // so the reject paths (consumed marker, conflict, capacity) never // leave an empty SessionState behind. Returns: whether the attempt @@ -212,6 +237,10 @@ pub fn interactive_round1( 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()))?; @@ -225,18 +254,18 @@ pub fn interactive_round1( if session .consumed_interactive_attempt_markers - .contains(&request.attempt_id) + .contains(&attempt_id) { return Err(EngineError::ConsumedNonceReplay { session_id: request.session_id.clone(), - attempt_id: request.attempt_id, + attempt_id, }); } let interactive = interactive_state_for_attempt_mut( session, &request.session_id, - &request.attempt_id, + &attempt_id, request.member_identifier, )?; @@ -291,6 +320,10 @@ pub fn interactive_round2( 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()))?; @@ -304,17 +337,17 @@ pub fn interactive_round2( if session .consumed_interactive_attempt_markers - .contains(&request.attempt_id) + .contains(&attempt_id) { return Err(EngineError::ConsumedNonceReplay { session_id: request.session_id.clone(), - attempt_id: request.attempt_id, + attempt_id, }); } ensure_consumed_registry_insert_capacity( &session.consumed_interactive_attempt_markers, - &request.attempt_id, + &attempt_id, "consumed_interactive_attempt_markers", &request.session_id, )?; @@ -322,7 +355,7 @@ pub fn interactive_round2( let interactive = interactive_state_for_attempt_mut( session, &request.session_id, - &request.attempt_id, + &attempt_id, request.member_identifier, )?; @@ -347,7 +380,7 @@ pub fn interactive_round2( // the nonces are destroyed, and no share was released. session .consumed_interactive_attempt_markers - .insert(request.attempt_id.clone()); + .insert(attempt_id.clone()); if let Err(persist_error) = persist_engine_state_to_storage(&guard) { let session = guard .sessions @@ -355,7 +388,7 @@ pub fn interactive_round2( .expect("session existed under the held engine lock"); session .consumed_interactive_attempt_markers - .remove(&request.attempt_id); + .remove(&attempt_id); return Err(persist_error); } @@ -411,7 +444,7 @@ pub fn interactive_round2( Ok(InteractiveRound2Result { session_id: request.session_id, - attempt_id: request.attempt_id, + attempt_id, signature_share_hex, }) } @@ -427,15 +460,24 @@ pub fn interactive_session_abort( 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 request.attempt_id.is_none() - || request.attempt_id.as_deref() + if attempt_id_filter.is_none() + || attempt_id_filter.as_deref() == Some(interactive.attempt_context.attempt_id.as_str()) => { let mut removed = session @@ -567,6 +609,15 @@ fn verify_round2_signing_package( Ok(()) } +// 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() +} + pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningState) { if let Some(mut round1) = interactive.round1.take() { round1.nonces.zeroize(); diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 0d366e52e3..85bbc19d8b 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -12196,3 +12196,261 @@ fn interactive_live_session_capacity_fails_closed() { 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 key_packages = interactive_test_key_packages(); + let outcome = open_interactive_for_test( + &key_packages, + "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 key_packages = interactive_test_key_packages(); + + 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( + &key_packages, + 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( + &key_packages, + 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 key_packages = interactive_test_key_packages(); + let session_id = "interactive-attempt-id-casing"; + let key_group = "interactive-test-key-group"; + let message = [0xe3u8; 32]; + let included = [1u16, 2]; + + // 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(), + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.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, + key_package_identifier: key_packages[&1].identifier.clone(), + key_package_hex: key_packages[&1].data_hex.clone(), + }) + .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_packages = interactive_test_key_packages(); + 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( + &key_packages, + "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"); + + let guard = state().expect("state").lock().expect("lock"); + let session = guard + .sessions + .get("interactive-abort-sweep-a") + .expect("session A still present"); + assert!( + session.interactive_signing.is_none(), + "an abort must sweep expired interactive state in other sessions" + ); +} From 03fb6d6cdbd1e9a2444fa1eff5a74959cefbf6c3 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 20:28:29 -0400 Subject: [PATCH 5/9] fix(tbtc/signer): apply session lifecycle and quarantine gates on interactive open The interactive path accepted an open after only the signing-policy firewall check, so on a session start_sign_round would refuse it could still emit a share - bypassing the established lifecycle/quarantine gates (review finding). InteractiveSessionOpen now enforces, before installing any interactive state, the same gates the coarse path does: - emergency_rekey_event on an existing session -> LifecyclePolicyRejected (emergency_rekey_required), - a terminally finalized session -> SessionFinalized, - an auto-quarantined member_identifier (absent a DAO allowlist override) -> QuarantinePolicyRejected, reusing enforce_not_quarantined_identifiers so the allowlist override is honored identically. The quarantine check targets this node's own member_identifier - the member it is about to produce a share for - rather than the whole included set, since under t-of-included a quarantined included member simply will not be among the responsive subset. Tests: an emergency-rekey session and a finalized session both refuse interactive open; a quarantined member is rejected and a DAO allowlist override restores signing. Full suite 261 passed / 1 ignored, clippy -D warnings clean, chaos suite green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/interactive.rs | 38 ++++++ pkg/tbtc/signer/src/engine/tests.rs | 137 ++++++++++++++++++++++ 2 files changed, 175 insertions(+) diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 83b99bc4a4..551f5d4a84 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -107,6 +107,44 @@ pub fn interactive_session_open( ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; + // Session lifecycle gates (frozen spec section 5: Open "checks + // policy gates"). The interactive path must refuse in exactly the + // states the coarse start_sign_round refuses: a session under an + // emergency rekey, or one already terminally finalized. Without + // these, InteractiveRound1/Round2 could emit a share where the + // established path would not. + if let Some(existing_session) = guard.sessions.get(&request.session_id) { + if let Some(emergency_rekey_event) = existing_session.emergency_rekey_event.as_ref() { + return Err(EngineError::LifecyclePolicyRejected { + session_id: request.session_id.clone(), + reason_code: "emergency_rekey_required".to_string(), + detail: format!( + "emergency rekey required for session [{}] since [{}]: {}", + request.session_id, + emergency_rekey_event.triggered_at_unix, + emergency_rekey_event.reason + ), + }); + } + if existing_session.finalize_request_fingerprint.is_some() { + return Err(EngineError::SessionFinalized { + session_id: request.session_id.clone(), + }); + } + } + + // Quarantine gate: this node is about to produce a share for + // member_identifier, so an auto-quarantined member (absent a DAO + // allowlist override) must not be able to sign through the + // interactive path either. + let auto_quarantine_config = load_auto_quarantine_config()?; + enforce_not_quarantined_identifiers( + &request.session_id, + &[request.member_identifier], + &guard.quarantined_operator_identifiers, + auto_quarantine_config.as_ref(), + )?; + // Signing-policy firewall (frozen spec section 5: Open "checks // policy gates"). When the firewall is enabled, the message must be // bound to a prior policy-checked build_taproot_tx for this diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 85bbc19d8b..62df27d9bd 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -12454,3 +12454,140 @@ fn interactive_abort_sweeps_expired_sessions() { "an abort must sweep expired interactive state in other sessions" ); } + +#[test] +fn interactive_open_rejected_on_session_lifecycle_states() { + let _guard = lock_test_state(); + reset_for_tests(); + + let key_packages = interactive_test_key_packages(); + 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( + &key_packages, + "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( + &key_packages, + "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_packages = interactive_test_key_packages(); + 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( + &key_packages, + "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( + &key_packages, + "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"); +} From 0d739a56d40f9901949659fd5b0ed75208ba5bc1 Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 20:42:37 -0400 Subject: [PATCH 6/9] fix(tbtc/signer): re-evaluate signing gates at the Round2 share release The coarse start_sign_round checks the lifecycle/quarantine/firewall gates and produces the signature share in one atomic call. The interactive path splits Open from Round2, so a kill switch recorded in that window - emergency rekey, finalization, quarantine, or a re-bound policy-checked tx - was not seen when the share actually left the engine at Round2 (review finding: gates checked only at Open are stale at release time). The gate logic is extracted into enforce_interactive_signing_gates and called at BOTH Open and Round2, so the two sites cannot drift and the share is gated at the moment it leaves the engine. The Round2 recheck runs before consumption (verify-before-consume): a gate rejection leaves the nonces live and the attempt recoverable, so a transient kill switch does not burn the attempt. The message rechecked is the one bound at Open (held in interactive state), and the signing package's message is independently verified equal to it. Test: open + round1 + build package, record an emergency rekey, then Round2 is rejected (emergency_rekey_required) WITHOUT consuming the attempt; clearing the rekey lets the same attempt complete. Full suite 262 passed / 1 ignored, clippy -D warnings clean, chaos suite green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/interactive.rs | 131 ++++++++++++++-------- pkg/tbtc/signer/src/engine/tests.rs | 96 ++++++++++++++++ 2 files changed, 179 insertions(+), 48 deletions(-) diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 551f5d4a84..29f1ca58e5 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -107,60 +107,24 @@ pub fn interactive_session_open( ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; - // Session lifecycle gates (frozen spec section 5: Open "checks - // policy gates"). The interactive path must refuse in exactly the - // states the coarse start_sign_round refuses: a session under an - // emergency rekey, or one already terminally finalized. Without - // these, InteractiveRound1/Round2 could emit a share where the - // established path would not. - if let Some(existing_session) = guard.sessions.get(&request.session_id) { - if let Some(emergency_rekey_event) = existing_session.emergency_rekey_event.as_ref() { - return Err(EngineError::LifecyclePolicyRejected { - session_id: request.session_id.clone(), - reason_code: "emergency_rekey_required".to_string(), - detail: format!( - "emergency rekey required for session [{}] since [{}]: {}", - request.session_id, - emergency_rekey_event.triggered_at_unix, - emergency_rekey_event.reason - ), - }); - } - if existing_session.finalize_request_fingerprint.is_some() { - return Err(EngineError::SessionFinalized { - session_id: request.session_id.clone(), - }); - } - } - - // Quarantine gate: this node is about to produce a share for - // member_identifier, so an auto-quarantined member (absent a DAO - // allowlist override) must not be able to sign through the - // interactive path either. + // 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. let auto_quarantine_config = load_auto_quarantine_config()?; - enforce_not_quarantined_identifiers( + let existing_session = guard.sessions.get(&request.session_id); + enforce_interactive_signing_gates( &request.session_id, - &[request.member_identifier], + request.member_identifier, + &request.message_hex, + existing_session.and_then(|session| session.emergency_rekey_event.as_ref()), + existing_session.is_some_and(|session| session.finalize_request_fingerprint.is_some()), + existing_session.and_then(|session| session.tx_result.as_ref()), &guard.quarantined_operator_identifiers, auto_quarantine_config.as_ref(), )?; - // Signing-policy firewall (frozen spec section 5: Open "checks - // policy gates"). When the firewall is enabled, the message must be - // bound to a prior policy-checked build_taproot_tx for this - // session, exactly as the coarse start_sign_round path enforces it - // - otherwise a caller holding a key package could open an - // interactive session on a fresh session_id and sign an arbitrary - // message. A session with no policy-checked tx fails closed here. - enforce_signing_message_binding_to_policy_checked_build_tx( - &request.session_id, - &request.message_hex, - guard - .sessions - .get(&request.session_id) - .and_then(|session| session.tx_result.as_ref()), - )?; - // Decide everything from a read-only view BEFORE inserting anything, // so the reject paths (consumed marker, conflict, capacity) never // leave an empty SessionState behind. Returns: whether the attempt @@ -367,6 +331,11 @@ pub fn interactive_round2( .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(), @@ -390,6 +359,31 @@ pub fn interactive_round2( &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()); + 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, @@ -647,6 +641,47 @@ fn verify_round2_signing_package( 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 this node's own member, 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. +#[allow(clippy::too_many_arguments)] +fn enforce_interactive_signing_gates( + session_id: &str, + member_identifier: 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, + &[member_identifier], + 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- diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 62df27d9bd..f6f6bf9d9e 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -12591,3 +12591,99 @@ fn interactive_open_rejected_for_quarantined_member_honors_dao_allowlist() { 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( + &key_packages, + 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"); +} From edf7952ad9c0aef7d2e770bae9470163eb3fc8ad Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 21:01:27 -0400 Subject: [PATCH 7/9] fix(tbtc/signer): bound interactive session registry and validate threshold Two findings on the 7.1 path: P2 (liveness/DoS) - an interactive open that later expired or was aborted before Round2 left an otherwise-empty SessionState in the registry. Since open inserts new session IDs and ensure_session_insert_capacity counts every map entry, a caller could churn unique sessions until TBTC_SIGNER_MAX_SESSIONS filled, after which DKG / build_tx / new interactive sessions were rejected until restart. The TTL sweep and abort now drop a session that holds nothing durable once its live attempt is cleared, via a new SessionState::is_disposable that checks EVERY field so a session still carrying consumed markers or DKG material is never removed. P2 (verify-before-consume) - Open accepted a threshold below the key package's min_signers; Round2 would then accept a too-small signing package, persist the consumed marker, and only then have frost::round2::sign fail on the commitment count - burning the nonce for a validation error. Open now rejects threshold != key package min_signers before storing the session. Tests: open-then-abort churn under a 2-session cap stays bounded (no accumulation); the abort-sweep test now asserts the empty session is dropped, not just cleared; threshold-below-min_signers is rejected and the matching threshold opens. Full suite 264 passed / 1 ignored, clippy -D warnings clean, chaos suite green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/interactive.rs | 46 ++++++++-- pkg/tbtc/signer/src/engine/state.rs | 38 ++++++++ pkg/tbtc/signer/src/engine/tests.rs | 100 ++++++++++++++++++++-- 3 files changed, 172 insertions(+), 12 deletions(-) diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 29f1ca58e5..2347764b58 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -96,6 +96,19 @@ pub fn interactive_session_open( "key_package_identifier must match member_identifier".to_string(), )); } + // The signing threshold is fixed by the key material. Reject a + // mismatch at Open: otherwise Round2 would accept a signing package + // of the requested (wrong) size, persist the consumed marker, and + // only then have frost::round2::sign fail on the commitment count - + // burning the nonce handle for a validation error, against the + // verify-before-consume contract. + if *key_package.min_signers() != request.threshold { + return Err(EngineError::Validation(format!( + "threshold [{}] does not match the key package min_signers [{}]", + request.threshold, + *key_package.min_signers() + ))); + } let request_fingerprint = interactive_open_request_fingerprint(&request)?; let attempt_id = request.attempt_context.attempt_id.clone(); @@ -524,6 +537,18 @@ pub fn interactive_session_abort( None => false, }; + // Drop the session if aborting left it with nothing durable, so an + // open-then-abort churn cannot accumulate empty entries against + // TBTC_SIGNER_MAX_SESSIONS. + if aborted + && guard + .sessions + .get(&request.session_id) + .is_some_and(SessionState::is_disposable) + { + guard.sessions.remove(&request.session_id); + } + record_hardening_telemetry(|telemetry| { telemetry.interactive_session_abort_success_total = telemetry .interactive_session_abort_success_total @@ -704,19 +729,28 @@ pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningSta pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) { let ttl_seconds = interactive_session_ttl_seconds(); let now = now_unix(); - for session in engine_state.sessions.values_mut() { + engine_state.sessions.retain(|_session_id, session| { 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); - } + if !expired { + // Untouched sessions are kept as-is; only sessions whose + // live attempt we just expired are candidates for removal. + return true; } - } + if let Some(mut removed) = session.interactive_signing.take() { + zeroize_interactive_round1(&mut removed); + } + // Having cleared the expired attempt, drop the session if it now + // holds nothing durable, so churned interactive opens cannot + // accumulate empty entries against TBTC_SIGNER_MAX_SESSIONS. A + // session that still carries consumed markers or DKG material is + // kept. + !session.is_disposable() + }); } pub(crate) fn max_live_interactive_sessions_limit() -> usize { diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index 43fe1dc3d0..f44bdba57f 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -113,6 +113,44 @@ pub(crate) struct SessionState { pub(crate) consumed_interactive_attempt_markers: HashSet, } +impl SessionState { + // True when the session holds no durable or live state worth a + // registry slot: removing it loses nothing. Used to drop a session + // that only ever held a now-cleared interactive attempt, so churned + // interactive opens (open -> expire/abort before Round2) cannot + // accumulate empty entries and exhaust TBTC_SIGNER_MAX_SESSIONS. + // + // EVERY field must be checked here: a field omitted from this + // conjunction risks dropping a session that still carries replay + // protection (consumed markers) or DKG material. When adding a + // field to SessionState, add it here too. + pub(crate) fn is_disposable(&self) -> bool { + self.dkg_request_fingerprint.is_none() + && self.dkg_key_packages.is_none() + && self.dkg_public_key_package.is_none() + && self.dkg_result.is_none() + && self.sign_request_fingerprint.is_none() + && self.sign_message_bytes.is_none() + && self.round_state.is_none() + && self.active_attempt_context.is_none() + && self.attempt_transition_records.is_empty() + && self.consumed_attempt_ids.is_empty() + && self.consumed_sign_round_ids.is_empty() + && self.finalize_request_fingerprint.is_none() + && self.signature_result.is_none() + && self.consumed_finalize_round_ids.is_empty() + && self.consumed_finalize_request_fingerprints.is_empty() + && self.build_tx_request_fingerprint.is_none() + && self.tx_result.is_none() + && self.refresh_request_fingerprint.is_none() + && self.refresh_result.is_none() + && self.refresh_history.is_empty() + && self.emergency_rekey_event.is_none() + && self.interactive_signing.is_none() + && self.consumed_interactive_attempt_markers.is_empty() + } +} + #[derive(Default)] pub(crate) struct EngineState { pub(crate) sessions: HashMap, diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index f6f6bf9d9e..db6da03d22 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -12444,14 +12444,13 @@ fn interactive_abort_sweeps_expired_sessions() { }) .expect("abort for an unrelated session"); + // Session A held only its (now-expired) interactive attempt, so the + // sweep must remove the whole entry, not just clear the live state - + // otherwise empty sessions accumulate against TBTC_SIGNER_MAX_SESSIONS. let guard = state().expect("state").lock().expect("lock"); - let session = guard - .sessions - .get("interactive-abort-sweep-a") - .expect("session A still present"); assert!( - session.interactive_signing.is_none(), - "an abort must sweep expired interactive state in other sessions" + !guard.sessions.contains_key("interactive-abort-sweep-a"), + "an abort must sweep AND drop an otherwise-empty expired session" ); } @@ -12687,3 +12686,92 @@ fn interactive_round2_rechecks_gates_at_share_release() { }) .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 key_packages = interactive_test_key_packages(); + let mismatch = open_interactive_for_test( + &key_packages, + "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 key package min_signers")), + "unexpected error: {mismatch:?}" + ); + + // The matching threshold (2) opens. + open_interactive_for_test( + &key_packages, + "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_abort_churn_does_not_exhaust_session_registry() { + let _guard = lock_test_state(); + reset_for_tests(); + + // A tiny global session cap: if open-then-abort left empty session + // entries behind, this churn would fill the registry and then reject + // a fresh open. The disposal on abort must keep the registry clear. + std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); + + let key_packages = interactive_test_key_packages(); + let key_group = "interactive-test-key-group"; + let message = [0x1bu8; 32]; + let included = [1u16, 2]; + + let outcome = (|| -> Result<(), EngineError> { + for cycle in 0..16 { + let session_id = format!("interactive-churn-{cycle}"); + open_interactive_for_test( + &key_packages, + &session_id, + key_group, + &message, + &included, + 1, + 1, + 2, + )?; + interactive_session_abort(InteractiveSessionAbortRequest { + session_id: session_id.clone(), + attempt_id: None, + })?; + } + // The registry is clear, so the global cap still has room. + let guard = state().expect("state").lock().expect("lock"); + assert!( + guard.sessions.is_empty(), + "open-then-abort churn must not accumulate session entries: {} present", + guard.sessions.len() + ); + Ok(()) + })(); + + std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); + outcome.expect("session churn stays bounded"); +} From 289df954352fc1f93ccae54bce668cdbd4f9514f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 22:32:23 -0400 Subject: [PATCH 8/9] fix(tbtc/signer): resolve interactive key material from DKG state, not the request Two findings, the first central to the whole effort: P2 (secret boundary) - InteractiveSessionOpenRequest carried key_package_hex, forcing the private FROST key package through the host/FFI request buffer on every open. That directly contradicts the frozen spec section 4 ("key shares already env/command-only ... no secret signing material transits the Go/Rust interface") and would leave the sidecar unable to provide the signing-secret boundary that is the entire point of Phase 7. Open now resolves the member's key package from the session's own DKG state (run_dkg-populated), exactly like the coarse start_sign_round: the session must already exist with completed DKG, the request carries no key material, and the threshold is validated against the DKG threshold. The key_package fields are removed from the request; the spec section 5 is corrected accordingly. Because interactive sessions now always ride a DKG-populated session and never create registry entries, the empty-session-churn fixed last round is impossible by construction, so the is_disposable disposal logic (and its churn test) is reverted as dead code; sweep/abort just clear the live attempt and retain the DKG session. P2 (rollback) - a delayed InteractiveSessionOpen for an older attempt could replace a newer live attempt and wipe its nonces. Open now replaces a different live attempt ONLY when the incoming attempt_number strictly advances the live one; an older-or-equal attempt is rejected. Tests: aggregation, framing, replay, restart, persist-fault, TTL, capacity, lifecycle, quarantine, firewall, and the new TOCTOU recheck all rebuilt on DKG-seeded sessions (key material from engine state); added open-requires-DKG-session and non-participant rejection; threshold mismatch now reports the DKG threshold. Full suite 264 passed / 1 ignored, clippy -D warnings clean, chaos green. Co-Authored-By: Claude Fable 5 --- ...phase-7-interactive-session-spec-freeze.md | 11 + pkg/tbtc/signer/src/api.rs | 9 +- pkg/tbtc/signer/src/engine/interactive.rs | 254 +++++----- pkg/tbtc/signer/src/engine/state.rs | 38 -- pkg/tbtc/signer/src/engine/tests.rs | 451 ++++++------------ pkg/tbtc/signer/src/lib.rs | 8 +- 6 files changed, 298 insertions(+), 473 deletions(-) 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/src/api.rs b/pkg/tbtc/signer/src/api.rs index 27149af729..d02f2bcb89 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -158,17 +158,16 @@ pub struct InteractiveSessionOpenRequest { 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, - /// The member's key package, supplied once per session and held by - /// the engine for the session's lifetime (in memory only: - /// interactive session state follows markers-only durability). - pub key_package_identifier: String, - pub key_package_hex: String, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 2347764b58..825c47fffe 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -60,56 +60,6 @@ pub fn interactive_session_open( // marker and sign again. request.attempt_context = canonical_attempt_context(&request.attempt_context); - // Strict-mode-only attempt context: required, fully validated, - // coordinator recomputed per RFC-21 Annex A. - let canonical_included_participants = validate_attempt_context( - &request.session_id, - &request.key_group, - &message_bytes, - &message_digest_hex, - request.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(), - )); - } - - let key_package = decode_key_package( - "InteractiveSessionOpen", - &request.key_package_identifier, - &request.key_package_hex, - )?; - let expected_identifier = - participant_identifier_to_frost_identifier(request.member_identifier)?; - if *key_package.identifier() != expected_identifier { - return Err(EngineError::Validation( - "key_package_identifier must match member_identifier".to_string(), - )); - } - // The signing threshold is fixed by the key material. Reject a - // mismatch at Open: otherwise Round2 would accept a signing package - // of the requested (wrong) size, persist the consumed marker, and - // only then have frost::round2::sign fail on the commitment count - - // burning the nonce handle for a validation error, against the - // verify-before-consume contract. - if *key_package.min_signers() != request.threshold { - return Err(EngineError::Validation(format!( - "threshold [{}] does not match the key package min_signers [{}]", - request.threshold, - *key_package.min_signers() - ))); - } - let request_fingerprint = interactive_open_request_fingerprint(&request)?; let attempt_id = request.attempt_context.attempt_id.clone(); @@ -118,46 +68,116 @@ pub fn interactive_session_open( .map_err(|_| EngineError::Internal("engine lock poisoned".to_string()))?; sweep_expired_interactive_state(&mut guard); - ensure_session_insert_capacity(&guard.sessions, &request.session_id)?; - - // 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. let auto_quarantine_config = load_auto_quarantine_config()?; - let existing_session = guard.sessions.get(&request.session_id); - enforce_interactive_signing_gates( - &request.session_id, - request.member_identifier, - &request.message_hex, - existing_session.and_then(|session| session.emergency_rekey_event.as_ref()), - existing_session.is_some_and(|session| session.finalize_request_fingerprint.is_some()), - existing_session.and_then(|session| session.tx_result.as_ref()), - &guard.quarantined_operator_identifiers, - auto_quarantine_config.as_ref(), - )?; - // Decide everything from a read-only view BEFORE inserting anything, - // so the reject paths (consumed marker, conflict, capacity) never - // leave an empty SessionState behind. Returns: whether the attempt - // is already consumed, the disposition of any live attempt under - // this exact attempt_id (Some(true)=idempotent, Some(false)= - // conflicting fingerprint, None=no matching live attempt), and - // whether a live interactive attempt is being replaced. - let (already_consumed, matching_attempt_idempotent, replacing) = { - let existing = guard.sessions.get(&request.session_id); - let already_consumed = existing.is_some_and(|session| { - session - .consumed_interactive_attempt_markers - .contains(&attempt_id) - }); - let matching_attempt_idempotent = existing - .and_then(|session| session.interactive_signing.as_ref()) + // 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 key_package = session + .dkg_key_packages + .as_ref() + .ok_or_else(|| EngineError::Internal("missing DKG key package cache".to_string()))? + .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. + 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(), + )); + } + (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 replacing = existing.is_some_and(|session| session.interactive_signing.is_some()); - (already_consumed, matching_attempt_idempotent, replacing) + 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 { @@ -180,13 +200,26 @@ pub fn interactive_session_open( session_id: request.session_id.clone(), }); } - // None: no live attempt under this attempt_id. If a DIFFERENT - // attempt is live it is implicitly aborted below - the retry - // loop has moved on and a stuck prior attempt must not strand - // its nonces. 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. @@ -208,8 +241,8 @@ pub fn interactive_session_open( let session = guard .sessions - .entry(request.session_id.clone()) - .or_default(); + .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); @@ -537,18 +570,6 @@ pub fn interactive_session_abort( None => false, }; - // Drop the session if aborting left it with nothing durable, so an - // open-then-abort churn cannot accumulate empty entries against - // TBTC_SIGNER_MAX_SESSIONS. - if aborted - && guard - .sessions - .get(&request.session_id) - .is_some_and(SessionState::is_disposable) - { - guard.sessions.remove(&request.session_id); - } - record_hardening_telemetry(|telemetry| { telemetry.interactive_session_abort_success_total = telemetry .interactive_session_abort_success_total @@ -729,28 +750,23 @@ pub(crate) fn zeroize_interactive_round1(interactive: &mut InteractiveSigningSta pub(crate) fn sweep_expired_interactive_state(engine_state: &mut EngineState) { let ttl_seconds = interactive_session_ttl_seconds(); let now = now_unix(); - engine_state.sessions.retain(|_session_id, session| { + // 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 { - // Untouched sessions are kept as-is; only sessions whose - // live attempt we just expired are candidates for removal. - return true; - } - if let Some(mut removed) = session.interactive_signing.take() { - zeroize_interactive_round1(&mut removed); + if expired { + if let Some(mut removed) = session.interactive_signing.take() { + zeroize_interactive_round1(&mut removed); + } } - // Having cleared the expired attempt, drop the session if it now - // holds nothing durable, so churned interactive opens cannot - // accumulate empty entries against TBTC_SIGNER_MAX_SESSIONS. A - // session that still carries consumed markers or DKG material is - // kept. - !session.is_disposable() - }); + } } pub(crate) fn max_live_interactive_sessions_limit() -> usize { diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index f44bdba57f..43fe1dc3d0 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -113,44 +113,6 @@ pub(crate) struct SessionState { pub(crate) consumed_interactive_attempt_markers: HashSet, } -impl SessionState { - // True when the session holds no durable or live state worth a - // registry slot: removing it loses nothing. Used to drop a session - // that only ever held a now-cleared interactive attempt, so churned - // interactive opens (open -> expire/abort before Round2) cannot - // accumulate empty entries and exhaust TBTC_SIGNER_MAX_SESSIONS. - // - // EVERY field must be checked here: a field omitted from this - // conjunction risks dropping a session that still carries replay - // protection (consumed markers) or DKG material. When adding a - // field to SessionState, add it here too. - pub(crate) fn is_disposable(&self) -> bool { - self.dkg_request_fingerprint.is_none() - && self.dkg_key_packages.is_none() - && self.dkg_public_key_package.is_none() - && self.dkg_result.is_none() - && self.sign_request_fingerprint.is_none() - && self.sign_message_bytes.is_none() - && self.round_state.is_none() - && self.active_attempt_context.is_none() - && self.attempt_transition_records.is_empty() - && self.consumed_attempt_ids.is_empty() - && self.consumed_sign_round_ids.is_empty() - && self.finalize_request_fingerprint.is_none() - && self.signature_result.is_none() - && self.consumed_finalize_round_ids.is_empty() - && self.consumed_finalize_request_fingerprints.is_empty() - && self.build_tx_request_fingerprint.is_none() - && self.tx_result.is_none() - && self.refresh_request_fingerprint.is_none() - && self.refresh_result.is_none() - && self.refresh_history.is_empty() - && self.emergency_rekey_event.is_none() - && self.interactive_signing.is_none() - && self.consumed_interactive_attempt_markers.is_empty() - } -} - #[derive(Default)] pub(crate) struct EngineState { pub(crate) sessions: HashMap, diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index db6da03d22..2243cf0469 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -11168,6 +11168,42 @@ fn interactive_test_key_packages() -> BTreeMap 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, @@ -11206,7 +11242,6 @@ fn interactive_test_attempt_context( #[allow(clippy::too_many_arguments)] fn open_interactive_for_test( - key_packages: &BTreeMap, session_id: &str, key_group: &str, message_bytes: &[u8], @@ -11215,6 +11250,9 @@ fn open_interactive_for_test( 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, @@ -11230,8 +11268,6 @@ fn open_interactive_for_test( threshold, taproot_merkle_root_hex: None, attempt_context, - key_package_identifier: key_packages[&member_identifier].identifier.clone(), - key_package_hex: key_packages[&member_identifier].data_hex.clone(), }) } @@ -11261,17 +11297,8 @@ fn interactive_session_full_round_trip_aggregates_bip340() { // 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("interactive session opens"); + 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 { @@ -11376,17 +11403,8 @@ fn interactive_round1_is_idempotent_until_consumed() { let message = [0x21u8; 32]; let included = [1u16, 2]; - let opened = open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -11452,17 +11470,8 @@ fn interactive_round2_rejects_substituted_own_commitment_then_accepts_corrected( let message = [0x33u8; 32]; let included = [1u16, 2]; - let opened = open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -11547,17 +11556,8 @@ fn interactive_round2_package_shape_rejections() { // Session A: included {1,2} - outside-set and message-mismatch. let session_a = "interactive-shape-a"; - let opened_a = open_interactive_for_test( - &key_packages, - session_a, - key_group, - &message, - &[1, 2], - 1, - 1, - 2, - ) - .expect("session A opens"); + 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(), @@ -11623,17 +11623,8 @@ fn interactive_round2_package_shape_rejections() { // 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( - &key_packages, - session_b, - key_group, - &message, - &[1, 2, 3], - 1, - 1, - 2, - ) - .expect("session B opens"); + 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(), @@ -11691,17 +11682,8 @@ fn interactive_consumption_marker_survives_restart() { let message = [0x61u8; 32]; let included = [1u16, 2]; - let opened = open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -11737,17 +11719,8 @@ fn interactive_consumption_marker_survives_restart() { // 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect_err("reopening a consumed attempt after restart must fail closed"); + 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:?}" @@ -11755,17 +11728,9 @@ fn interactive_consumption_marker_survives_restart() { // A fresh attempt for the same session proceeds: the marker is // attempt-scoped, not session-scoped. - let second_attempt = open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 2, - 1, - 2, - ) - .expect("a new attempt opens after restart"); + 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, @@ -11793,17 +11758,8 @@ fn interactive_round2_persist_fault_leaves_nonces_live() { let message = [0x71u8; 32]; let included = [1u16, 2]; - let opened = open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -11880,36 +11836,17 @@ fn interactive_open_idempotency_conflict_and_replacement() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("identical reopen is 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); @@ -11926,8 +11863,6 @@ fn interactive_open_idempotency_conflict_and_replacement() { "1111111111111111111111111111111111111111111111111111111111111111".to_string(), ), attempt_context, - key_package_identifier: key_packages[&1].identifier.clone(), - key_package_hex: key_packages[&1].data_hex.clone(), }) .expect_err("conflicting reopen of a live attempt must fail closed"); assert!( @@ -11944,17 +11879,8 @@ fn interactive_open_idempotency_conflict_and_replacement() { member_identifier: 1, }) .expect("round 1 for attempt 1"); - let second = open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 2, - 1, - 2, - ) - .expect("a newer attempt replaces the live one"); + 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 { @@ -11974,23 +11900,13 @@ fn interactive_abort_destroys_nonces_and_is_idempotent() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -12026,17 +11942,8 @@ fn interactive_abort_destroys_nonces_and_is_idempotent() { // 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("an aborted (never consumed) attempt may reopen"); + 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); } @@ -12045,23 +11952,13 @@ fn interactive_session_ttl_expiry_has_abort_semantics() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -12096,17 +11993,8 @@ fn interactive_session_ttl_expiry_has_abort_semantics() { // Expiry, like abort, leaves no consumption marker: the attempt // never released a share, so reopening is allowed. - open_interactive_for_test( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("an expired (never consumed) attempt may reopen"); + open_interactive_for_test(session_id, key_group, &message, &included, 1, 1, 2) + .expect("an expired (never consumed) attempt may reopen"); } #[test] @@ -12114,7 +12002,6 @@ fn interactive_live_session_capacity_fails_closed() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); let key_group = "interactive-test-key-group"; let message = [0xb1u8; 32]; let included = [1u16, 2]; @@ -12122,49 +12009,19 @@ fn interactive_live_session_capacity_fails_closed() { std::env::set_var(TBTC_SIGNER_MAX_LIVE_INTERACTIVE_SESSIONS_ENV, "1"); let outcome = (|| -> Result<(), EngineError> { - open_interactive_for_test( - &key_packages, - "interactive-cap-a", - key_group, - &message, - &included, - 1, - 1, - 2, - )?; + open_interactive_for_test("interactive-cap-a", key_group, &message, &included, 1, 1, 2)?; - let at_capacity = open_interactive_for_test( - &key_packages, - "interactive-cap-b", - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect_err("the live-session cap must fail closed"); + 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:?}" ); - // A capacity rejection for a brand-new session_id must NOT - // leave an empty SessionState behind (it would otherwise - // accumulate against the global session cap and could starve - // DKG). - { - let guard = state().expect("state").lock().expect("lock"); - assert!( - !guard.sessions.contains_key("interactive-cap-b"), - "a rejected interactive open must not insert an empty session" - ); - } - // An idempotent reopen of the live session does not trip the cap. let reopen = open_interactive_for_test( - &key_packages, "interactive-cap-a", key_group, &message, @@ -12180,16 +12037,7 @@ fn interactive_live_session_capacity_fails_closed() { session_id: "interactive-cap-a".to_string(), attempt_id: None, })?; - open_interactive_for_test( - &key_packages, - "interactive-cap-b", - key_group, - &message, - &included, - 1, - 1, - 2, - )?; + open_interactive_for_test("interactive-cap-b", key_group, &message, &included, 1, 1, 2)?; Ok(()) })(); @@ -12211,9 +12059,7 @@ fn interactive_open_signing_policy_firewall_rejects_without_policy_checked_build // 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 key_packages = interactive_test_key_packages(); let outcome = open_interactive_for_test( - &key_packages, "interactive-firewall-no-build-tx", "interactive-firewall-key-group", &[0xc1u8; 32], @@ -12265,13 +12111,11 @@ fn interactive_open_signing_policy_firewall_binds_message_to_build_tx() { 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 key_packages = interactive_test_key_packages(); 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( - &key_packages, session_id, &dkg_result.key_group, &[0xd2u8; 32], @@ -12290,7 +12134,6 @@ fn interactive_open_signing_policy_firewall_binds_message_to_build_tx() { // The policy-bound message opens successfully: enforcement is // real, not always-reject. let opened = open_interactive_for_test( - &key_packages, session_id, &dkg_result.key_group, &bound_message, @@ -12315,11 +12158,11 @@ fn interactive_consumed_marker_is_case_insensitive() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); 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. @@ -12335,8 +12178,6 @@ fn interactive_consumed_marker_is_case_insensitive() { threshold: 2, taproot_merkle_root_hex: None, attempt_context: canonical.clone(), - key_package_identifier: key_packages[&1].identifier.clone(), - key_package_hex: key_packages[&1].data_hex.clone(), }) .expect("canonical open"); let round1 = interactive_round1(InteractiveRound1Request { @@ -12382,8 +12223,6 @@ fn interactive_consumed_marker_is_case_insensitive() { threshold: 2, taproot_merkle_root_hex: None, attempt_context: recased_context, - key_package_identifier: key_packages[&1].identifier.clone(), - key_package_hex: key_packages[&1].data_hex.clone(), }) .expect_err("a re-cased consumed attempt must fail closed"); assert!( @@ -12397,14 +12236,12 @@ fn interactive_abort_sweeps_expired_sessions() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); 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( - &key_packages, "interactive-abort-sweep-a", key_group, &message, @@ -12444,13 +12281,18 @@ fn interactive_abort_sweeps_expired_sessions() { }) .expect("abort for an unrelated session"); - // Session A held only its (now-expired) interactive attempt, so the - // sweep must remove the whole entry, not just clear the live state - - // otherwise empty sessions accumulate against TBTC_SIGNER_MAX_SESSIONS. + // 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!( - !guard.sessions.contains_key("interactive-abort-sweep-a"), - "an abort must sweep AND drop an otherwise-empty expired session" + session.interactive_signing.is_none(), + "an abort elsewhere must still sweep an expired interactive attempt" ); } @@ -12459,7 +12301,6 @@ fn interactive_open_rejected_on_session_lifecycle_states() { let _guard = lock_test_state(); reset_for_tests(); - let key_packages = interactive_test_key_packages(); let key_group = "interactive-test-key-group"; let message = [0x17u8; 32]; let included = [1u16, 2]; @@ -12480,7 +12321,6 @@ fn interactive_open_rejected_on_session_lifecycle_states() { ); } let rekey = open_interactive_for_test( - &key_packages, "interactive-lifecycle-rekey", key_group, &message, @@ -12508,7 +12348,6 @@ fn interactive_open_rejected_on_session_lifecycle_states() { ); } let finalized = open_interactive_for_test( - &key_packages, "interactive-lifecycle-finalized", key_group, &message, @@ -12540,14 +12379,12 @@ fn interactive_open_rejected_for_quarantined_member_honors_dao_allowlist() { guard.quarantined_operator_identifiers.insert(1); } - let key_packages = interactive_test_key_packages(); 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( - &key_packages, "interactive-quarantine", key_group, &message, @@ -12569,7 +12406,6 @@ fn interactive_open_rejected_for_quarantined_member_honors_dao_allowlist() { "1", ); let allowlisted = open_interactive_for_test( - &key_packages, "interactive-quarantine-allowlisted", key_group, &message, @@ -12605,17 +12441,8 @@ fn interactive_round2_rechecks_gates_at_share_release() { // 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( - &key_packages, - session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - ) - .expect("opens"); + 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(), @@ -12697,9 +12524,7 @@ fn interactive_open_rejects_threshold_below_key_package_min_signers() { // 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 key_packages = interactive_test_key_packages(); let mismatch = open_interactive_for_test( - &key_packages, "interactive-threshold-mismatch", "interactive-test-key-group", &[0x1au8; 32], @@ -12711,13 +12536,12 @@ fn interactive_open_rejects_threshold_below_key_package_min_signers() { .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 key package min_signers")), + if m.contains("does not match the DKG threshold")), "unexpected error: {mismatch:?}" ); // The matching threshold (2) opens. open_interactive_for_test( - &key_packages, "interactive-threshold-match", "interactive-test-key-group", &[0x1au8; 32], @@ -12730,48 +12554,61 @@ fn interactive_open_rejects_threshold_below_key_package_min_signers() { } #[test] -fn interactive_open_abort_churn_does_not_exhaust_session_registry() { +fn interactive_open_requires_an_existing_dkg_session() { let _guard = lock_test_state(); reset_for_tests(); - // A tiny global session cap: if open-then-abort left empty session - // entries behind, this churn would fill the registry and then reject - // a fresh open. The disposal on abort must keep the registry clear. - std::env::set_var(TBTC_SIGNER_MAX_SESSIONS_ENV, "2"); - - let key_packages = interactive_test_key_packages(); - let key_group = "interactive-test-key-group"; - let message = [0x1bu8; 32]; - let included = [1u16, 2]; - - let outcome = (|| -> Result<(), EngineError> { - for cycle in 0..16 { - let session_id = format!("interactive-churn-{cycle}"); - open_interactive_for_test( - &key_packages, - &session_id, - key_group, - &message, - &included, - 1, - 1, - 2, - )?; - interactive_session_abort(InteractiveSessionAbortRequest { - session_id: session_id.clone(), - attempt_id: None, - })?; - } - // The registry is clear, so the global cap still has room. - let guard = state().expect("state").lock().expect("lock"); - assert!( - guard.sessions.is_empty(), - "open-then-abort churn must not accumulate session entries: {} present", - guard.sessions.len() - ); - Ok(()) - })(); + // 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:?}" + ); - std::env::remove_var(TBTC_SIGNER_MAX_SESSIONS_ENV); - outcome.expect("session churn stays bounded"); + // 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:?}" + ); } diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 15d7b817b5..81c9499996 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -776,19 +776,19 @@ mod tests { threshold: 2, taproot_merkle_root_hex: None, attempt_context: crate::api::AttemptContext { - attempt_number: 0, // invalid: wire attempt numbers are 1-based + attempt_number: 1, coordinator_identifier: 1, included_participants: vec![1, 2], included_participants_fingerprint: "00".to_string(), attempt_id: "ffi-smoke-attempt".to_string(), }, - key_package_identifier: "00".to_string(), - key_package_hex: "00".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, "validation_error"); + assert_eq!(error.code, "session_not_found"); let round1 = crate::api::InteractiveRound1Request { session_id: "ffi-interactive-smoke-missing".to_string(), From 4940a9c32a9c8fe87916ebf1565905c7752d0d9b Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 22:53:58 -0400 Subject: [PATCH 9/9] fix(tbtc/signer): quarantine the full Round2 subset; reject phantom included IDs Two findings: P1 (quarantine bypass) - the Round2 gate recheck only quarantine-checked this node's own member_identifier. The chosen signing subset (the package's participants) is known at Round2, so this node could release a share into a package that includes a quarantined co-signer, bypassing the all-signing-participants quarantine the coarse path enforces. Round2 now computes the package's u16 subset (after verify confirms it is a threshold-sized subset of the included set) and quarantine-checks ALL of it before consuming the nonce. The Open gate keeps checking only this member (the responsive subset is not chosen until Round2); the gate helper now takes the identifier set to check so the two sites stay aligned. P2 (phantom participants) - Open validated the attempt context's internal consistency but never checked that the included participants are real DKG members. A caller could pad the included set with phantom ids to bias the RFC-21 coordinator/attempt derivation, with Round2 then releasing a share under an attempt context that is not a genuine DKG subset. Open now rejects any included participant absent from the session's dkg_key_packages. Tests: a co-signer quarantined after round 1 blocks the Round2 share without consuming the attempt (clearing it lets the attempt complete); a phantom included id is rejected at Open even with a valid local member. Full suite 266 passed / 1 ignored, clippy -D warnings clean, chaos green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/src/engine/interactive.rs | 85 +++++++++++++-- pkg/tbtc/signer/src/engine/tests.rs | 122 ++++++++++++++++++++++ 2 files changed, 197 insertions(+), 10 deletions(-) diff --git a/pkg/tbtc/signer/src/engine/interactive.rs b/pkg/tbtc/signer/src/engine/interactive.rs index 825c47fffe..690870452e 100644 --- a/pkg/tbtc/signer/src/engine/interactive.rs +++ b/pkg/tbtc/signer/src/engine/interactive.rs @@ -101,10 +101,11 @@ pub fn interactive_session_open( request.threshold, dkg.threshold ))); } - let key_package = session + let dkg_key_packages = session .dkg_key_packages .as_ref() - .ok_or_else(|| EngineError::Internal("missing DKG key package cache".to_string()))? + .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( @@ -118,10 +119,12 @@ pub fn interactive_session_open( // 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. + // 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.member_identifier], &request.message_hex, session.emergency_rekey_event.as_ref(), session.finalize_request_fingerprint.is_some(), @@ -153,6 +156,19 @@ pub fn interactive_session_open( .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) }; @@ -418,9 +434,13 @@ pub fn interactive_round2( && 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, + &[request.member_identifier], &bound_message_hex, session.emergency_rekey_event.as_ref(), session.finalize_request_fingerprint.is_some(), @@ -450,6 +470,20 @@ pub fn interactive_round2( // 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 @@ -690,13 +724,20 @@ fn verify_round2_signing_package( // 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 this node's own member, 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. +// 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, - member_identifier: u16, + quarantine_identifiers: &[u16], message_hex: &str, emergency_rekey_event: Option<&EmergencyRekeyEvent>, session_finalized: bool, @@ -721,7 +762,7 @@ fn enforce_interactive_signing_gates( } enforce_not_quarantined_identifiers( session_id, - &[member_identifier], + quarantine_identifiers, quarantined_operator_identifiers, auto_quarantine_config, )?; @@ -737,6 +778,30 @@ 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(); diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 2243cf0469..4f852670ec 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -12612,3 +12612,125 @@ fn interactive_open_requires_an_existing_dkg_session() { "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:?}" + ); +}