From 71083e7b943e02e8cd568ab2ec4525ca8414a500 Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 21:47:29 -0400 Subject: [PATCH 1/4] feat(tbtc/signer): install TBTC_SIGNER_* knobs via init-time FFI config Post-merge follow-up #3 from the June 2026 review stack: shrink the ops/audit surface by letting the host install the signer's operational configuration once at startup (frost_tbtc_init_signer_config) instead of exporting ~40 TBTC_SIGNER_* environment variables. Design (parity by construction): - every operational env read now goes through one chokepoint, engine::signer_env_var; with no config installed it falls through to the process environment, so existing env-driven behavior (and the entire pre-existing test suite) is unchanged - an installed config wins wholesale: the environment is no longer consulted for covered knobs, and an unset field means the built-in default - no per-knob source mixing - typed InitSignerConfigRequest (field = lowercased env suffix) converts to the same canonical strings the existing parsers consume, so every clamp/warn/reject path runs unchanged on identical inputs - deny_unknown_fields: a typo'd knob fails the init instead of silently running on defaults; enforcement-gated policy combinations (admission, firewall, auto-quarantine) are validated at install with rollback - re-init is idempotent for an identical request, rejected on conflict - secrets never ride the config FFI: TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX stays on the dedicated env/command key-provider channel (deliberate std::env::var exception, commented) - deletes lib.rs's duplicated profile/truthy parsing in favor of the engine's single implementation Verified: fmt --check; clippy --all-targets -D warnings; full suite 235 passed + 1 ignored / 24 / 1 (11 new tests incl. FFI round-trip); --features bench-restart-hook builds; chaos suite and formal_verification_ filter pass. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/README.md | 29 ++ pkg/tbtc/signer/include/frost_tbtc.h | 1 + pkg/tbtc/signer/src/api.rs | 103 ++++++ pkg/tbtc/signer/src/engine/config.rs | 35 +- pkg/tbtc/signer/src/engine/init_config.rs | 415 ++++++++++++++++++++++ pkg/tbtc/signer/src/engine/lifecycle.rs | 9 +- pkg/tbtc/signer/src/engine/mod.rs | 22 +- pkg/tbtc/signer/src/engine/persistence.rs | 23 +- pkg/tbtc/signer/src/engine/policy.rs | 8 +- pkg/tbtc/signer/src/engine/provenance.rs | 10 +- pkg/tbtc/signer/src/engine/state.rs | 12 +- pkg/tbtc/signer/src/engine/tests.rs | 275 ++++++++++++++ pkg/tbtc/signer/src/engine/testsupport.rs | 1 + pkg/tbtc/signer/src/lib.rs | 91 +++-- 14 files changed, 936 insertions(+), 98 deletions(-) create mode 100644 pkg/tbtc/signer/src/engine/init_config.rs diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index e36d16344b..0c2d93401b 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -106,6 +106,35 @@ Sample input schemas are provided in: - `pkg/tbtc/signer/scripts/admission-override.sample.json` - `pkg/tbtc/signer/scripts/admission-override-registry.sample.json` +## Init-Time Configuration (`frost_tbtc_init_signer_config`) + +Hosts should install the signer's operational configuration once at startup +via `frost_tbtc_init_signer_config` instead of exporting `TBTC_SIGNER_*` +environment variables. The request is a JSON object whose field names are the +lowercased `TBTC_SIGNER_*` suffixes (`{"profile": "production", +"roast_coordinator_timeout_ms": 30000, ...}`). + +Semantics: + +- Once installed, the process environment is **not consulted** for any + covered knob: an unset field means the built-in default, not the + environment value. There is no per-knob mixing of the two sources. +- Unknown field names fail the init (typos cannot silently fall back to + defaults in production). +- Re-initialization with an identical request is idempotent; a conflicting + request is rejected. +- The init validates enforcement-gated policy combinations (admission, + signing-policy firewall, auto-quarantine) and rolls the install back if + they are incomplete, so a misconfigured signer fails at startup rather + than at first signing. +- **Secrets never ride the config FFI**: `TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX` + is read exclusively from the dedicated key-provider channel below, even + when a config is installed. + +Without an installed config the signer falls back to reading the +`TBTC_SIGNER_*` environment (development/test behavior); in non-development +profiles this fallback logs a one-time warning. + ## Encrypted State Key Providers Signer state persistence is encrypted at rest. Key-provider behavior is controlled diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index dd798fe7eb..5e1c6348d9 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -19,6 +19,7 @@ typedef struct { } TbtcSignerResult; TbtcSignerResult frost_tbtc_version(void); +TbtcSignerResult frost_tbtc_init_signer_config(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_roast_liveness_policy(void); TbtcSignerResult frost_tbtc_hardening_metrics(void); TbtcSignerResult frost_tbtc_roast_transcript_audit(const uint8_t* request_ptr, size_t request_len); diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index 14492609ce..dc6b94c6a6 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -530,3 +530,106 @@ pub struct ErrorResponse { pub message: String, pub recovery_class: String, } + +/// Init-time signer configuration installed once by the host over FFI. +/// +/// Every field mirrors one `TBTC_SIGNER_*` environment variable (field name = +/// lowercased variable suffix). Once a config is installed the process +/// environment is no longer consulted for any covered knob: unset fields mean +/// the built-in default, not the environment value. The state-encryption key +/// (`TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`) is deliberately absent — secrets +/// stay on the dedicated env/command key-provider channel and never ride the +/// config FFI. Unknown fields are rejected so a typo'd knob fails the init +/// instead of silently running on a default. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InitSignerConfigRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_bootstrap: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_roast_strict: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_bench_restart_hook: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roast_coordinator_timeout_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refresh_cadence_seconds: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_corruption_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_corrupt_backup_limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_sessions: 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_key_command_timeout_secs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce_provenance_gate: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_attestation_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_attestation_payload: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_attestation_signature_hex: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance_trust_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_approved_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce_admission_policy: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_min_participants: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_min_threshold: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_required_identifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admission_allowlist_identifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enforce_signing_policy_firewall: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_allowed_script_classes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_max_output_count: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_max_output_value_sats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_max_total_output_value_sats: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_allowed_utc_start_hour: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_allowed_utc_end_hour: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub policy_rate_limit_per_minute: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enable_auto_quarantine: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_fault_threshold: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_timeout_penalty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_invalid_share_penalty: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_quarantine_dao_allowlist_identifiers: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_start_sign_round_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_finalize_sign_round_p95_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canary_max_policy_reject_rate_bps: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct InitSignerConfigResult { + pub installed: bool, + pub idempotent: bool, + pub config_fingerprint: String, + pub configured_key_count: u32, +} diff --git a/pkg/tbtc/signer/src/engine/config.rs b/pkg/tbtc/signer/src/engine/config.rs index 72d3a5cf9f..fed755b442 100644 --- a/pkg/tbtc/signer/src/engine/config.rs +++ b/pkg/tbtc/signer/src/engine/config.rs @@ -53,9 +53,10 @@ pub(crate) const TBTC_SIGNER_DEFAULT_MAX_SESSIONS: usize = 1024; pub(crate) const TBTC_SIGNER_STATE_LOCKFILE_SUFFIX: &str = ".lock"; +pub(crate) const TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV: &str = "TBTC_SIGNER_ALLOW_BOOTSTRAP"; + pub(crate) const TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV: &str = "TBTC_SIGNER_ENABLE_ROAST_STRICT"; -#[cfg(any(test, feature = "bench-restart-hook"))] pub(crate) const TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV: &str = "TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK"; @@ -181,8 +182,7 @@ pub(crate) const TBTC_SIGNER_DEFAULT_CANARY_MAX_POLICY_REJECT_RATE_BPS: u64 = 1_ pub(crate) const TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS: u64 = 10_000; pub(crate) fn roast_coordinator_timeout_ms() -> u64 { - std::env::var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|timeout_ms| { *timeout_ms >= TBTC_SIGNER_MIN_ROAST_COORDINATOR_TIMEOUT_MS @@ -192,8 +192,7 @@ pub(crate) fn roast_coordinator_timeout_ms() -> u64 { } pub(crate) fn refresh_cadence_seconds() -> u64 { - std::env::var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|value| { *value >= TBTC_SIGNER_MIN_REFRESH_CADENCE_SECONDS @@ -205,7 +204,7 @@ pub(crate) fn refresh_cadence_seconds() -> u64 { pub(crate) fn parse_identifier_set_from_env( env_name: &str, ) -> Result>, EngineError> { - let Ok(raw_value) = std::env::var(env_name) else { + let Some(raw_value) = signer_env_var(env_name) else { return Ok(None); }; @@ -246,7 +245,7 @@ pub(crate) fn parse_usize_from_env_with_default( env_name: &str, default_value: usize, ) -> Result { - let Ok(raw_value) = std::env::var(env_name) else { + let Some(raw_value) = signer_env_var(env_name) else { return Ok(default_value); }; @@ -263,7 +262,7 @@ pub(crate) fn parse_u64_from_env_with_default( env_name: &str, default_value: u64, ) -> Result { - let Ok(raw_value) = std::env::var(env_name) else { + let Some(raw_value) = signer_env_var(env_name) else { return Ok(default_value); }; @@ -277,8 +276,8 @@ pub(crate) fn parse_u64_from_env_with_default( } pub(crate) fn parse_usize_from_env_required(env_name: &str) -> Result { - let raw_value = std::env::var(env_name) - .map_err(|_| EngineError::Internal(format!("missing required env [{}]", env_name)))?; + let raw_value = signer_env_var(env_name) + .ok_or_else(|| EngineError::Internal(format!("missing required env [{}]", env_name)))?; raw_value.trim().parse::().map_err(|_| { EngineError::Internal(format!( "failed to parse usize env [{}] value [{}]", @@ -288,8 +287,8 @@ pub(crate) fn parse_usize_from_env_required(env_name: &str) -> Result Result { - let raw_value = std::env::var(env_name) - .map_err(|_| EngineError::Internal(format!("missing required env [{}]", env_name)))?; + let raw_value = signer_env_var(env_name) + .ok_or_else(|| EngineError::Internal(format!("missing required env [{}]", env_name)))?; raw_value.trim().parse::().map_err(|_| { EngineError::Internal(format!( "failed to parse u64 env [{}] value [{}]", @@ -299,7 +298,7 @@ pub(crate) fn parse_u64_from_env_required(env_name: &str) -> Result Result, EngineError> { - let Ok(raw_value) = std::env::var(env_name) else { + let Some(raw_value) = signer_env_var(env_name) else { return Ok(None); }; @@ -321,8 +320,8 @@ pub(crate) fn parse_u8_from_env_optional(env_name: &str) -> Result, E pub(crate) fn parse_script_class_set_required( env_name: &str, ) -> Result, EngineError> { - let raw_value = std::env::var(env_name) - .map_err(|_| EngineError::Internal(format!("missing required env [{}]", env_name)))?; + let raw_value = signer_env_var(env_name) + .ok_or_else(|| EngineError::Internal(format!("missing required env [{}]", env_name)))?; let raw_value = raw_value.trim(); if raw_value.is_empty() { return Err(EngineError::Internal(format!( @@ -362,20 +361,20 @@ pub(crate) fn roast_strict_mode_enabled() -> bool { return true; } - std::env::var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV) + signer_env_var(TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV) .map(|raw_value| truthy_env_flag(&raw_value)) .unwrap_or(false) } #[cfg(any(test, feature = "bench-restart-hook"))] pub(crate) fn bench_restart_hook_enabled() -> bool { - std::env::var(TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV) + signer_env_var(TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV) .map(|raw_value| truthy_env_flag(&raw_value)) .unwrap_or(false) } pub(crate) fn signer_profile_is_production() -> bool { - let raw = std::env::var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); + let raw = signer_env_var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); let normalized = raw.trim().to_ascii_lowercase(); match normalized.as_str() { TBTC_SIGNER_PROFILE_PRODUCTION | "" => true, diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs new file mode 100644 index 0000000000..80229153dd --- /dev/null +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -0,0 +1,415 @@ +// Init-time signer configuration: a typed, FFI-installed snapshot that +// replaces the process environment as the source of TBTC_SIGNER_* knobs. + +use super::*; +use std::sync::{Arc, RwLock}; + +static INSTALLED_SIGNER_CONFIG: OnceLock>>> = + OnceLock::new(); +static ENV_FALLBACK_WARNING_EMITTED: OnceLock<()> = OnceLock::new(); + +pub(crate) struct InstalledSignerConfig { + pub(crate) values: HashMap, + pub(crate) fingerprint: String, +} + +fn installed_signer_config_slot() -> &'static RwLock>> { + INSTALLED_SIGNER_CONFIG.get_or_init(|| RwLock::new(None)) +} + +fn installed_signer_config() -> Option> { + installed_signer_config_slot() + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() +} + +/// Single chokepoint for every `TBTC_SIGNER_*` operational read. +/// +/// With an installed init-time config the process environment is NOT +/// consulted: the snapshot is the sole source of truth and an absent key +/// means the built-in default. Without an installed config this falls +/// through to the process environment (test/development behavior, and the +/// transitional path for hosts that have not adopted the init FFI yet). +/// +/// The state-encryption key (`TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX`) is +/// deliberately NOT routed through here: secrets stay on the dedicated +/// env/command key-provider channel and never ride the config FFI. +pub(crate) fn signer_env_var(name: &str) -> Option { + if let Some(config) = installed_signer_config() { + return config.values.get(name).cloned(); + } + warn_production_env_fallback_once(name); + std::env::var(name).ok() +} + +fn warn_production_env_fallback_once(name: &str) { + // The production check reads the environment directly: routing it through + // signer_env_var would recurse, and on this path no config is installed + // so the environment is the authoritative source anyway. + if name == TBTC_SIGNER_PROFILE_ENV { + return; + } + ENV_FALLBACK_WARNING_EMITTED.get_or_init(|| { + let raw = std::env::var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); + let normalized = raw.trim().to_ascii_lowercase(); + if normalized.as_str() != TBTC_SIGNER_PROFILE_DEVELOPMENT { + eprintln!( + "warning: TBTC_SIGNER_* knobs are being read from the process \ + environment; production hosts should install an init-time \ + config via frost_tbtc_init_signer_config" + ); + } + }); +} + +pub fn init_signer_config( + request: InitSignerConfigRequest, +) -> Result { + let config_fingerprint = fingerprint(&request)?; + let values = config_values_from_request(&request)?; + let configured_key_count = values.len() as u32; + + let slot = installed_signer_config_slot(); + { + let mut guard = slot + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = guard.as_ref() { + if existing.fingerprint == config_fingerprint { + return Ok(InitSignerConfigResult { + installed: true, + idempotent: true, + config_fingerprint, + configured_key_count: existing.values.len() as u32, + }); + } + return Err(EngineError::Validation(format!( + "signer config already installed with fingerprint [{}]; \ + conflicting re-initialization rejected", + existing.fingerprint + ))); + } + *guard = Some(Arc::new(InstalledSignerConfig { + values, + fingerprint: config_fingerprint.clone(), + })); + } + + // Fail-closed validation: run the same loaders the runtime gates use, + // under the just-installed snapshot, and roll the install back if any of + // them reject. Knobs the runtime warn-and-defaults on keep that behavior. + if let Err(error) = validate_installed_config() { + let mut guard = slot + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = None; + return Err(error); + } + + Ok(InitSignerConfigResult { + installed: true, + idempotent: false, + config_fingerprint, + configured_key_count, + }) +} + +fn validate_installed_config() -> Result<(), EngineError> { + load_admission_policy_config()?; + load_signing_policy_firewall_config()?; + load_auto_quarantine_config()?; + Ok(()) +} + +pub(crate) fn config_values_from_request( + request: &InitSignerConfigRequest, +) -> Result, EngineError> { + let mut values = HashMap::new(); + + if let Some(profile) = &request.profile { + let normalized = profile.trim().to_ascii_lowercase(); + if normalized != TBTC_SIGNER_PROFILE_PRODUCTION + && normalized != TBTC_SIGNER_PROFILE_DEVELOPMENT + { + return Err(EngineError::Validation(format!( + "profile must be '{}' or '{}'; got [{}]", + TBTC_SIGNER_PROFILE_PRODUCTION, TBTC_SIGNER_PROFILE_DEVELOPMENT, profile + ))); + } + values.insert(TBTC_SIGNER_PROFILE_ENV.to_string(), normalized); + } + + insert_bool( + &mut values, + TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV, + request.allow_bootstrap, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENABLE_ROAST_STRICT_ENV, + request.enable_roast_strict, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ALLOW_BENCH_RESTART_HOOK_ENV, + request.allow_bench_restart_hook, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV, + request.enforce_provenance_gate, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV, + request.enforce_admission_policy, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV, + request.enforce_signing_policy_firewall, + ); + insert_bool( + &mut values, + TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV, + request.enable_auto_quarantine, + ); + + insert_u64( + &mut values, + TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, + request.roast_coordinator_timeout_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, + request.refresh_cadence_seconds, + ); + insert_u64( + &mut values, + TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV, + request.state_corrupt_backup_limit, + ); + insert_u64( + &mut values, + TBTC_SIGNER_MAX_SESSIONS_ENV, + request.max_sessions, + ); + insert_u64( + &mut values, + TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV, + request.state_key_command_timeout_secs, + ); + insert_u64( + &mut values, + TBTC_SIGNER_ADMISSION_MIN_PARTICIPANTS_ENV, + request.admission_min_participants, + ); + insert_u64( + &mut values, + TBTC_SIGNER_ADMISSION_MIN_THRESHOLD_ENV, + request.admission_min_threshold, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_MAX_OUTPUT_COUNT_ENV, + request.policy_max_output_count, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_MAX_OUTPUT_VALUE_SATS_ENV, + request.policy_max_output_value_sats, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_MAX_TOTAL_OUTPUT_VALUE_SATS_ENV, + request.policy_max_total_output_value_sats, + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_RATE_LIMIT_PER_MINUTE_ENV, + request.policy_rate_limit_per_minute, + ); + insert_u64( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_FAULT_THRESHOLD_ENV, + request.auto_quarantine_fault_threshold, + ); + insert_u64( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_TIMEOUT_PENALTY_ENV, + request.auto_quarantine_timeout_penalty, + ); + insert_u64( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_INVALID_SHARE_PENALTY_ENV, + request.auto_quarantine_invalid_share_penalty, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV, + request.canary_max_start_sign_round_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV, + request.canary_max_finalize_sign_round_p95_ms, + ); + insert_u64( + &mut values, + TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV, + request.canary_max_policy_reject_rate_bps, + ); + + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_ALLOWED_UTC_START_HOUR_ENV, + request.policy_allowed_utc_start_hour.map(u64::from), + ); + insert_u64( + &mut values, + TBTC_SIGNER_POLICY_ALLOWED_UTC_END_HOUR_ENV, + request.policy_allowed_utc_end_hour.map(u64::from), + ); + + insert_string(&mut values, TBTC_SIGNER_STATE_PATH_ENV, &request.state_path)?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV, + &request.state_corruption_policy, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, + &request.state_key_provider, + )?; + insert_string( + &mut values, + TBTC_SIGNER_STATE_KEY_COMMAND_ENV, + &request.state_key_command, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV, + &request.provenance_attestation_status, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV, + &request.provenance_attestation_payload, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV, + &request.provenance_attestation_signature_hex, + )?; + insert_string( + &mut values, + TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV, + &request.provenance_trust_root, + )?; + insert_string( + &mut values, + TBTC_SIGNER_MIN_APPROVED_VERSION_ENV, + &request.min_approved_version, + )?; + + insert_identifier_list( + &mut values, + TBTC_SIGNER_ADMISSION_REQUIRED_IDENTIFIERS_ENV, + &request.admission_required_identifiers, + )?; + insert_identifier_list( + &mut values, + TBTC_SIGNER_ADMISSION_ALLOWLIST_IDENTIFIERS_ENV, + &request.admission_allowlist_identifiers, + )?; + insert_identifier_list( + &mut values, + TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV, + &request.auto_quarantine_dao_allowlist_identifiers, + )?; + + if let Some(script_classes) = &request.policy_allowed_script_classes { + if script_classes.is_empty() || script_classes.iter().any(|class| class.trim().is_empty()) { + return Err(EngineError::Validation(format!( + "config field for [{}] must contain at least one non-empty script class when set", + TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV + ))); + } + values.insert( + TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV.to_string(), + script_classes.join(","), + ); + } + + Ok(values) +} + +fn insert_bool(values: &mut HashMap, key: &str, value: Option) { + if let Some(value) = value { + values.insert( + key.to_string(), + if value { "true" } else { "false" }.to_string(), + ); + } +} + +fn insert_u64(values: &mut HashMap, key: &str, value: Option) { + if let Some(value) = value { + values.insert(key.to_string(), value.to_string()); + } +} + +fn insert_string( + values: &mut HashMap, + key: &str, + value: &Option, +) -> Result<(), EngineError> { + if let Some(value) = value { + if value.trim().is_empty() { + return Err(EngineError::Validation(format!( + "config field for [{}] must not be empty when set", + key + ))); + } + values.insert(key.to_string(), value.clone()); + } + Ok(()) +} + +fn insert_identifier_list( + values: &mut HashMap, + key: &str, + identifiers: &Option>, +) -> Result<(), EngineError> { + if let Some(identifiers) = identifiers { + if identifiers.is_empty() { + return Err(EngineError::Validation(format!( + "config field for [{}] must contain at least one identifier when set", + key + ))); + } + let mut sorted = identifiers.clone(); + sorted.sort_unstable(); + sorted.dedup(); + values.insert( + key.to_string(), + sorted + .iter() + .map(|identifier| identifier.to_string()) + .collect::>() + .join(","), + ); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn clear_installed_signer_config_for_tests() { + let mut guard = installed_signer_config_slot() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *guard = None; +} diff --git a/pkg/tbtc/signer/src/engine/lifecycle.rs b/pkg/tbtc/signer/src/engine/lifecycle.rs index b514df43fb..85083fba6d 100644 --- a/pkg/tbtc/signer/src/engine/lifecycle.rs +++ b/pkg/tbtc/signer/src/engine/lifecycle.rs @@ -3,24 +3,21 @@ use super::*; pub(crate) fn canary_max_start_sign_round_p95_ms() -> u64 { - std::env::var(TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_CANARY_MAX_START_SIGN_ROUND_P95_MS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_START_SIGN_ROUND_P95_MS) } pub(crate) fn canary_max_finalize_sign_round_p95_ms() -> u64 { - std::env::var(TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value > 0) .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_FINALIZE_SIGN_ROUND_P95_MS) } pub(crate) fn canary_max_policy_reject_rate_bps() -> u64 { - std::env::var(TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_CANARY_MAX_POLICY_REJECT_RATE_BPS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|value| *value <= TBTC_SIGNER_MAX_POLICY_REJECT_RATE_BPS) .unwrap_or(TBTC_SIGNER_DEFAULT_CANARY_MAX_POLICY_REJECT_RATE_BPS) diff --git a/pkg/tbtc/signer/src/engine/mod.rs b/pkg/tbtc/signer/src/engine/mod.rs index 8d4c3a327e..d201d8b169 100644 --- a/pkg/tbtc/signer/src/engine/mod.rs +++ b/pkg/tbtc/signer/src/engine/mod.rs @@ -67,16 +67,16 @@ use crate::api::{ DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package, DkgRound2Package, FinalizeSignRoundRequest, GenerateNoncesAndCommitmentsRequest, - GenerateNoncesAndCommitmentsResult, 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, + 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, }; use crate::errors::EngineError; use crate::go_math_rand::select_coordinator_identifier; @@ -86,6 +86,7 @@ mod codec; mod config; mod dkg; mod frost_ops; +mod init_config; mod lifecycle; mod nonce; mod persistence; @@ -106,6 +107,7 @@ pub(crate) use codec::*; pub(crate) use config::*; pub(crate) use dkg::*; pub(crate) use frost_ops::*; +pub(crate) use init_config::*; 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 c1568563e2..a02cedc8ff 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -250,8 +250,7 @@ set {}={} to quarantine the file and continue with clean state", } pub(crate) fn state_key_command_timeout_secs() -> u64 { - std::env::var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_STATE_KEY_COMMAND_TIMEOUT_SECS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|value| { *value >= TBTC_SIGNER_MIN_STATE_KEY_COMMAND_TIMEOUT_SECS @@ -496,9 +495,9 @@ pub(crate) fn execute_state_key_command(command_spec: &str) -> Result Result { - let provider = std::env::var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV) + let provider = signer_env_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV) .map(|value| value.trim().to_ascii_lowercase()) - .unwrap_or_else(|_| TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string()); + .unwrap_or_else(|| TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string()); match provider.as_str() { TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT => { @@ -514,6 +513,9 @@ pub(crate) fn state_encryption_key_material() -> Result Result { - let command_spec = std::env::var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV).map_err(|_| { - EngineError::Internal(format!( - "missing required state key command env [{}]", - TBTC_SIGNER_STATE_KEY_COMMAND_ENV - )) - })?; + let command_spec = + signer_env_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV).ok_or_else(|| { + EngineError::Internal(format!( + "missing required state key command env [{}]", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + )) + })?; if command_spec.trim().is_empty() { return Err(EngineError::Internal(format!( "state key command env [{}] must be non-empty", diff --git a/pkg/tbtc/signer/src/engine/policy.rs b/pkg/tbtc/signer/src/engine/policy.rs index 157bf962e5..c13d2ec081 100644 --- a/pkg/tbtc/signer/src/engine/policy.rs +++ b/pkg/tbtc/signer/src/engine/policy.rs @@ -55,19 +55,19 @@ pub(crate) fn provenance_gate_enforced() -> bool { return true; } - std::env::var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV) + signer_env_var(TBTC_SIGNER_ENFORCE_PROVENANCE_GATE_ENV) .map(|raw_value| truthy_env_flag(&raw_value)) .unwrap_or(false) } pub(crate) fn admission_policy_enforced() -> bool { - std::env::var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV) + signer_env_var(TBTC_SIGNER_ENFORCE_ADMISSION_POLICY_ENV) .map(|raw_value| truthy_env_flag(&raw_value)) .unwrap_or(false) } pub(crate) fn signing_policy_firewall_enforced() -> bool { - std::env::var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV) + signer_env_var(TBTC_SIGNER_ENFORCE_SIGNING_POLICY_FIREWALL_ENV) .map(|raw_value| truthy_env_flag(&raw_value)) .unwrap_or(false) } @@ -297,7 +297,7 @@ pub(crate) fn load_signing_policy_firewall_config( } pub(crate) fn auto_quarantine_enabled() -> bool { - std::env::var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV) + signer_env_var(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV) .map(|raw_value| truthy_env_flag(&raw_value)) .unwrap_or(false) } diff --git a/pkg/tbtc/signer/src/engine/provenance.rs b/pkg/tbtc/signer/src/engine/provenance.rs index 16531b1201..631ba40c85 100644 --- a/pkg/tbtc/signer/src/engine/provenance.rs +++ b/pkg/tbtc/signer/src/engine/provenance.rs @@ -163,7 +163,7 @@ pub(crate) fn enforce_provenance_gate() -> Result<(), EngineError> { return Ok(()); } - let attestation_status = std::env::var(TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV) + let attestation_status = signer_env_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV) .unwrap_or_default() .trim() .to_ascii_lowercase(); @@ -186,7 +186,7 @@ pub(crate) fn enforce_provenance_gate() -> Result<(), EngineError> { ); } - let trust_root = std::env::var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV) + let trust_root = signer_env_var(TBTC_SIGNER_PROVENANCE_TRUST_ROOT_ENV) .unwrap_or_default() .trim() .to_string(); @@ -202,7 +202,7 @@ pub(crate) fn enforce_provenance_gate() -> Result<(), EngineError> { let trust_root_pubkey = parse_provenance_trust_root_pubkey(&trust_root)?; let raw_attestation_payload = - std::env::var(TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV).unwrap_or_default(); + signer_env_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_PAYLOAD_ENV).unwrap_or_default(); let attestation_payload = raw_attestation_payload.trim().to_string(); if attestation_payload.len() != raw_attestation_payload.len() { eprintln!( @@ -224,7 +224,7 @@ pub(crate) fn enforce_provenance_gate() -> Result<(), EngineError> { } let attestation_signature_hex = - std::env::var(TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV) + signer_env_var(TBTC_SIGNER_PROVENANCE_ATTESTATION_SIGNATURE_HEX_ENV) .unwrap_or_default() .trim() .to_string(); @@ -308,7 +308,7 @@ pub(crate) fn enforce_provenance_gate() -> Result<(), EngineError> { ); } - let min_approved_version = std::env::var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV) + let min_approved_version = signer_env_var(TBTC_SIGNER_MIN_APPROVED_VERSION_ENV) .unwrap_or_default() .trim() .to_string(); diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index 669175b398..389f129fe6 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -245,8 +245,7 @@ pub(crate) fn state() -> Result<&'static Mutex, EngineError> { } pub(crate) fn state_file_path() -> Result { - let configured_path = std::env::var(TBTC_SIGNER_STATE_PATH_ENV) - .ok() + let configured_path = signer_env_var(TBTC_SIGNER_STATE_PATH_ENV) .map(|path| path.trim().to_string()) .filter(|path| !path.is_empty()) .map(PathBuf::from); @@ -322,8 +321,7 @@ pub(crate) fn ensure_state_file_lock() -> Result<(), EngineError> { } pub(crate) fn state_corruption_policy() -> CorruptStatePolicy { - let policy = std::env::var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV) - .ok() + let policy = signer_env_var(TBTC_SIGNER_STATE_CORRUPTION_POLICY_ENV) .map(|value| value.trim().to_ascii_lowercase()) .unwrap_or_default(); @@ -335,15 +333,13 @@ pub(crate) fn state_corruption_policy() -> CorruptStatePolicy { } pub(crate) fn state_corrupt_backup_limit() -> usize { - std::env::var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV) - .ok() + signer_env_var(TBTC_SIGNER_STATE_CORRUPT_BACKUP_LIMIT_ENV) .and_then(|value| value.trim().parse::().ok()) .unwrap_or(TBTC_SIGNER_DEFAULT_CORRUPT_BACKUP_LIMIT) } pub(crate) fn max_sessions_limit() -> usize { - std::env::var(TBTC_SIGNER_MAX_SESSIONS_ENV) - .ok() + signer_env_var(TBTC_SIGNER_MAX_SESSIONS_ENV) .and_then(|value| value.trim().parse::().ok()) .filter(|limit| *limit > 0) .unwrap_or(TBTC_SIGNER_DEFAULT_MAX_SESSIONS) diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 02b312cde7..e30152d02f 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -10556,3 +10556,278 @@ fn command_key_provider_survives_restart_with_stable_key() { cleanup_test_state_artifacts(&state_path); clear_state_storage_policy_overrides(); } + +// --- init-time signer config (frost_tbtc_init_signer_config) --- + +/// Clears the installed config on drop so a panicking test cannot leak an +/// installed snapshot into unrelated tests that expect env-fallback mode. +struct InstalledConfigClearGuard; + +impl Drop for InstalledConfigClearGuard { + fn drop(&mut self) { + clear_installed_signer_config_for_tests(); + } +} + +#[test] +fn init_signer_config_overrides_environment_for_covered_knobs() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + + let result = init_signer_config(InitSignerConfigRequest { + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + assert!(result.installed); + assert!(!result.idempotent); + assert_eq!(result.configured_key_count, 1); + + assert_eq!(roast_coordinator_timeout_ms(), 60_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_ignores_environment_wholesale_for_unset_fields() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // A valid env override that would normally win... + std::env::set_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV, "120"); + assert_eq!(refresh_cadence_seconds(), 120); + + // ...is ignored once a config is installed, even though the installed + // config does not set that field: absent field = built-in default. + init_signer_config(InitSignerConfigRequest { + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + assert_eq!( + refresh_cadence_seconds(), + TBTC_SIGNER_DEFAULT_REFRESH_CADENCE_SECONDS + ); + std::env::remove_var(TBTC_SIGNER_REFRESH_CADENCE_SECONDS_ENV); +} + +#[test] +fn init_signer_config_is_idempotent_for_identical_request_and_rejects_conflicts() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let request = InitSignerConfigRequest { + max_sessions: Some(64), + ..InitSignerConfigRequest::default() + }; + let first = init_signer_config(request.clone()).expect("first install"); + assert!(!first.idempotent); + + let second = init_signer_config(request).expect("identical re-init"); + assert!(second.idempotent); + assert_eq!(second.config_fingerprint, first.config_fingerprint); + + let conflict = init_signer_config(InitSignerConfigRequest { + max_sessions: Some(128), + ..InitSignerConfigRequest::default() + }) + .expect_err("conflicting re-init must be rejected"); + let message = conflict.to_string(); + assert!( + message.contains("conflicting re-initialization rejected"), + "unexpected error: {message}" + ); +} + +#[test] +fn init_signer_config_rejects_invalid_profile_without_installing() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("staging".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("invalid profile must be rejected"); + assert!(error.to_string().contains("profile"), "{error}"); + + // Nothing installed: environment reads still apply. + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_rolls_back_install_when_policy_validation_fails() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Firewall enforcement on, but the required allowed-script-classes knob + // is absent from the same config (and, wholesale semantics, the + // environment cannot supply it) -> the loader rejects and the install + // must roll back. (Admission knobs would NOT trip this: that loader + // falls back to defaults for absent values.) + let error = init_signer_config(InitSignerConfigRequest { + enforce_signing_policy_firewall: Some(true), + ..InitSignerConfigRequest::default() + }) + .expect_err("incomplete firewall policy must fail the init"); + assert!( + error.to_string().contains("missing required env"), + "unexpected error: {error}" + ); + + // Rolled back: env fallback is live again. + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_validates_complete_admission_policy_at_install() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let result = init_signer_config(InitSignerConfigRequest { + enforce_admission_policy: Some(true), + admission_min_participants: Some(3), + admission_min_threshold: Some(2), + ..InitSignerConfigRequest::default() + }) + .expect("complete admission policy installs"); + assert_eq!(result.configured_key_count, 3); + + let config = load_admission_policy_config() + .expect("load admission policy") + .expect("admission policy enforced"); + assert_eq!(config.min_participants, 3); + assert_eq!(config.min_threshold, 2); +} + +#[test] +fn init_signer_config_keeps_state_encryption_key_on_environment_channel() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // reset_for_tests points the env key at TEST_STATE_ENCRYPTION_KEY_HEX. + // Installing a config that selects the env provider but (by design) + // cannot carry the key itself must still resolve the key from the real + // environment. + init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_key_provider: Some("env".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + let material = state_encryption_key_material().expect("key material resolves from env"); + assert_eq!( + material.key_provider, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT + ); +} + +#[test] +fn init_signer_config_production_profile_forces_roast_strict_mode() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // lock_test_state pins the env profile to development; the installed + // config must override it wholesale. + init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + assert!(signer_profile_is_production()); + assert!(roast_strict_mode_enabled()); +} + +#[test] +fn reset_for_tests_clears_installed_signer_config() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + init_signer_config(InitSignerConfigRequest { + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + assert_eq!(roast_coordinator_timeout_ms(), 60_000); + + reset_for_tests(); + + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_request_rejects_unknown_fields() { + let parsed: Result = + serde_json::from_str(r#"{"polciy_max_output_count": 1}"#); + assert!(parsed.is_err(), "typo'd field names must fail the parse"); +} + +#[test] +fn init_signer_config_canonicalizes_list_and_bool_encodings() { + let values = config_values_from_request(&InitSignerConfigRequest { + enable_auto_quarantine: Some(false), + auto_quarantine_dao_allowlist_identifiers: Some(vec![3, 1, 2, 2]), + policy_allowed_script_classes: Some(vec!["P2TR".to_string(), "p2wpkh".to_string()]), + ..InitSignerConfigRequest::default() + }) + .expect("convert request"); + + assert_eq!( + values + .get(TBTC_SIGNER_ENABLE_AUTO_QUARANTINE_ENV) + .map(String::as_str), + Some("false") + ); + assert_eq!( + values + .get(TBTC_SIGNER_AUTO_QUARANTINE_DAO_ALLOWLIST_IDENTIFIERS_ENV) + .map(String::as_str), + Some("1,2,3") + ); + // Raw values are inserted; the existing loader normalizes case exactly as + // it does for environment values. + assert_eq!( + values + .get(TBTC_SIGNER_POLICY_ALLOWED_SCRIPT_CLASSES_ENV) + .map(String::as_str), + Some("P2TR,p2wpkh") + ); + + let empty_list = config_values_from_request(&InitSignerConfigRequest { + admission_required_identifiers: Some(Vec::new()), + ..InitSignerConfigRequest::default() + }); + assert!( + empty_list.is_err(), + "empty identifier list must be rejected" + ); +} diff --git a/pkg/tbtc/signer/src/engine/testsupport.rs b/pkg/tbtc/signer/src/engine/testsupport.rs index fc5c13c8e3..4baae90efb 100644 --- a/pkg/tbtc/signer/src/engine/testsupport.rs +++ b/pkg/tbtc/signer/src/engine/testsupport.rs @@ -23,6 +23,7 @@ pub fn lock_test_state() -> std::sync::MutexGuard<'static, ()> { #[cfg(test)] pub fn reset_for_tests() { + clear_installed_signer_config_for_tests(); clear_persist_fault_injection_for_tests(); std::env::set_var( TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 17f620e7d2..910a353dde 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, NewSigningPackageRequest, PromoteCanaryRequest, - QuarantineStatusRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, - RollbackCanaryRequest, RunDkgRequest, SignShareRequest, StartSignRoundRequest, - TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + GenerateNoncesAndCommitmentsRequest, InitSignerConfigRequest, NewSigningPackageRequest, + PromoteCanaryRequest, QuarantineStatusRequest, RefreshCadenceStatusRequest, + RefreshSharesRequest, RollbackCanaryRequest, RunDkgRequest, SignShareRequest, + StartSignRoundRequest, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, + VerifyBlameProofRequest, }; use ffi::{ ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, @@ -23,46 +24,22 @@ use ffi::{ pub use ffi::TbtcBuffer; const TBTC_SIGNER_VERSION: &str = "tbtc-signer/0.1.0-bootstrap"; -const TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV: &str = "TBTC_SIGNER_ALLOW_BOOTSTRAP"; -const TBTC_SIGNER_PROFILE_ENV: &str = "TBTC_SIGNER_PROFILE"; -const TBTC_SIGNER_PROFILE_PRODUCTION: &str = "production"; -const TBTC_SIGNER_PROFILE_DEVELOPMENT: &str = "development"; +use engine::TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV; +#[cfg(test)] +use engine::TBTC_SIGNER_PROFILE_ENV; #[cfg(test)] static TEST_BOOTSTRAP_MODE_OVERRIDE: OnceLock>> = OnceLock::new(); -fn bootstrap_mode_flag_enabled(raw_value: &str) -> bool { - matches!( - raw_value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) -} - fn bootstrap_mode_enabled_from_env() -> bool { - if signer_profile_is_production() { + if engine::signer_profile_is_production() { return false; } - std::env::var(TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV) - .map(|raw_value| bootstrap_mode_flag_enabled(&raw_value)) + engine::signer_env_var(TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV) + .map(|raw_value| engine::truthy_env_flag(&raw_value)) .unwrap_or(false) } -fn signer_profile_is_production() -> bool { - let raw = std::env::var(TBTC_SIGNER_PROFILE_ENV).unwrap_or_default(); - let normalized = raw.trim().to_ascii_lowercase(); - match normalized.as_str() { - TBTC_SIGNER_PROFILE_PRODUCTION | "" => true, - TBTC_SIGNER_PROFILE_DEVELOPMENT => false, - other => panic!( - "{} must be '{}' or '{}'; got {:?}", - TBTC_SIGNER_PROFILE_ENV, - TBTC_SIGNER_PROFILE_PRODUCTION, - TBTC_SIGNER_PROFILE_DEVELOPMENT, - other - ), - } -} - #[cfg(test)] fn test_bootstrap_mode_override() -> &'static std::sync::Mutex> { TEST_BOOTSTRAP_MODE_OVERRIDE.get_or_init(|| std::sync::Mutex::new(None)) @@ -90,6 +67,18 @@ pub extern "C" fn frost_tbtc_version() -> TbtcSignerResult { success_from_string(TBTC_SIGNER_VERSION.to_string()) } +#[no_mangle] +pub extern "C" fn frost_tbtc_init_signer_config( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: InitSignerConfigRequest = parse_request(request_ptr, request_len)?; + let response = engine::init_signer_config(request)?; + serialize_response(&response) + }) +} + #[no_mangle] pub extern "C" fn frost_tbtc_roast_liveness_policy() -> TbtcSignerResult { ffi_entry(|| serialize_response(&engine::roast_liveness_policy())) @@ -1876,7 +1865,7 @@ mod tests { for (value, expected) in test_cases { assert_eq!( - super::bootstrap_mode_flag_enabled(value), + super::engine::truthy_env_flag(value), expected, "unexpected bootstrap-mode flag classification for [{value:?}]", ); @@ -1900,12 +1889,12 @@ mod tests { let _allow_bootstrap_env = EnvVarGuard::set(super::TBTC_SIGNER_ALLOW_BOOTSTRAP_ENV, "true"); let _profile_env = EnvVarGuard::unset(super::TBTC_SIGNER_PROFILE_ENV); - assert!(super::signer_profile_is_production()); + assert!(super::engine::signer_profile_is_production()); assert!(!super::bootstrap_mode_enabled_from_env()); std::env::set_var(super::TBTC_SIGNER_PROFILE_ENV, " "); - assert!(super::signer_profile_is_production()); + assert!(super::engine::signer_profile_is_production()); assert!(!super::bootstrap_mode_enabled_from_env()); } @@ -1922,4 +1911,32 @@ mod tests { assert!(!super::bootstrap_mode_enabled()); } + #[test] + fn init_signer_config_ffi_round_trip_installs_and_reports_fingerprint() { + let _guard = crate::engine::lock_test_state(); + crate::engine::reset_for_tests(); + + let request = crate::api::InitSignerConfigRequest { + profile: Some("development".to_string()), + roast_coordinator_timeout_ms: Some(45_000), + ..crate::api::InitSignerConfigRequest::default() + }; + let (status, response_bytes) = call_ffi(&request, crate::frost_tbtc_init_signer_config); + assert_eq!( + status, + 0, + "init must succeed: {:?}", + String::from_utf8_lossy(&response_bytes) + ); + + let response: crate::api::InitSignerConfigResult = + serde_json::from_slice(&response_bytes).expect("response parses"); + assert!(response.installed); + assert!(!response.idempotent); + assert_eq!(response.configured_key_count, 2); + assert!(!response.config_fingerprint.is_empty()); + + // Clear the installed snapshot so env-driven tests are unaffected. + crate::engine::reset_for_tests(); + } } From 9226be12b88c0a176b69277fd35fdee03242dd6c Mon Sep 17 00:00:00 2001 From: maclane Date: Thu, 11 Jun 2026 22:45:43 -0400 Subject: [PATCH 2/4] fix(tbtc/signer): validate init config privately before publishing it Addresses the converged Codex/Gemini review finding plus two findings from my own review of #4037: - candidate configs are now validated through a thread-local resolver override visible only to the validating thread, and published to the global slot only after validation succeeds. Previously the candidate was installed first and rolled back on failure, so a concurrent identical init could report idempotent success while the twin rolled the slot back to None, and concurrent readers could briefly act on a config that never legally installed. Both impossible now: failed init has no observable side effects, and idempotent success is only ever reported against a validated, installed config. - init now validates state_file_path(): a production config (explicit, or by profile-omission default) without state_path fails at init instead of installing and then failing at first state access with an env-var-oriented message; the state-path error now also names the state_path config field. - new end-to-end test: installed config's state_path is honored by run_dkg persistence after a process (re)start, and the existing in-process state-path-switch refusal is pinned as the contract for installing a config after state has been touched. - README: do not inline key material into state_key_command (the command string rides the config FFI); documented the no-side-effects init guarantee and the production state_path requirement. Suite: 237 passed + 1 ignored / 24 / 1; clippy -D warnings, fmt, chaos suite, bench-restart-hook feature build all green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/README.md | 12 +- pkg/tbtc/signer/src/engine/init_config.rs | 127 ++++++++++++++++------ pkg/tbtc/signer/src/engine/state.rs | 3 +- pkg/tbtc/signer/src/engine/tests.rs | 116 +++++++++++++++++++- 4 files changed, 221 insertions(+), 37 deletions(-) diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index 0c2d93401b..93bdc277ad 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -129,7 +129,17 @@ Semantics: than at first signing. - **Secrets never ride the config FFI**: `TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX` is read exclusively from the dedicated key-provider channel below, even - when a config is installed. + when a config is installed. Do not inline key material into the + `state_key_command` string either — have the command fetch the secret — + because the command string itself is part of the config request. +- A failed init has no observable side effects: the candidate config is + validated privately before it is published, so concurrent callers can + never read a config that is later rejected. +- Production configs (explicitly `"profile": "production"`, or by omission — + production is the default) must set `state_path`; the init rejects them + otherwise. Install the config before the first state-touching call: once + the state-file lock is bound, the engine refuses to switch state paths + in-process. Without an installed config the signer falls back to reading the `TBTC_SIGNER_*` environment (development/test behavior); in non-development diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs index 80229153dd..dbb4e8bc4e 100644 --- a/pkg/tbtc/signer/src/engine/init_config.rs +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -1,13 +1,50 @@ // Init-time signer configuration: a typed, FFI-installed snapshot that // replaces the process environment as the source of TBTC_SIGNER_* knobs. +// +// Install guarantee: a candidate config is validated through the same +// loaders the runtime gates use while visible only to the validating +// thread (thread-local override below). It is published to the global +// slot only after validation succeeds, so an unvalidated or rejected +// config can never be observed by any other caller, and init results are +// truthful under concurrent initialization (idempotent success is only +// ever reported against a validated, installed config). use super::*; +use std::cell::RefCell; use std::sync::{Arc, RwLock}; static INSTALLED_SIGNER_CONFIG: OnceLock>>> = OnceLock::new(); static ENV_FALLBACK_WARNING_EMITTED: OnceLock<()> = OnceLock::new(); +thread_local! { + // Candidate config visible ONLY to the thread running init-time + // validation. Keeping the candidate off the global slot until it + // validates means no other caller can ever observe an unvalidated + // config, and a failed init has no observable side effects. + static VALIDATION_CANDIDATE: RefCell>> = + const { RefCell::new(None) }; +} + +struct ValidationCandidateGuard; + +impl ValidationCandidateGuard { + fn install(candidate: Arc) -> Self { + VALIDATION_CANDIDATE.with(|slot| *slot.borrow_mut() = Some(candidate)); + ValidationCandidateGuard + } +} + +impl Drop for ValidationCandidateGuard { + fn drop(&mut self) { + VALIDATION_CANDIDATE.with(|slot| *slot.borrow_mut() = None); + } +} + +fn validation_candidate() -> Option> { + VALIDATION_CANDIDATE.with(|slot| slot.borrow().clone()) +} + pub(crate) struct InstalledSignerConfig { pub(crate) values: HashMap, pub(crate) fingerprint: String, @@ -36,6 +73,9 @@ fn installed_signer_config() -> Option> { /// deliberately NOT routed through here: secrets stay on the dedicated /// env/command key-provider channel and never ride the config FFI. pub(crate) fn signer_env_var(name: &str) -> Option { + if let Some(candidate) = validation_candidate() { + return candidate.values.get(name).cloned(); + } if let Some(config) = installed_signer_config() { return config.values.get(name).cloned(); } @@ -69,43 +109,41 @@ pub fn init_signer_config( let config_fingerprint = fingerprint(&request)?; let values = config_values_from_request(&request)?; let configured_key_count = values.len() as u32; + let candidate = Arc::new(InstalledSignerConfig { + values, + fingerprint: config_fingerprint.clone(), + }); - let slot = installed_signer_config_slot(); + // Fast path against an already-installed (and therefore already + // validated) config; the authoritative re-check happens under the write + // lock below. + if let Some(existing) = installed_signer_config() { + return reinit_result(&existing, &config_fingerprint); + } + + // Fail-closed validation BEFORE anything is published: the candidate is + // visible only to this thread's loaders via the thread-local override, + // so no other caller can ever observe an unvalidated config and a failed + // init leaves prior state (installed config or environment fallback) + // untouched. Validation runs the same loaders the runtime gates use plus + // the state-path requirement; knobs the runtime warn-and-defaults on + // keep that behavior. { - let mut guard = slot - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(existing) = guard.as_ref() { - if existing.fingerprint == config_fingerprint { - return Ok(InitSignerConfigResult { - installed: true, - idempotent: true, - config_fingerprint, - configured_key_count: existing.values.len() as u32, - }); - } - return Err(EngineError::Validation(format!( - "signer config already installed with fingerprint [{}]; \ - conflicting re-initialization rejected", - existing.fingerprint - ))); - } - *guard = Some(Arc::new(InstalledSignerConfig { - values, - fingerprint: config_fingerprint.clone(), - })); + let _candidate_guard = ValidationCandidateGuard::install(Arc::clone(&candidate)); + validate_candidate_config()?; } - // Fail-closed validation: run the same loaders the runtime gates use, - // under the just-installed snapshot, and roll the install back if any of - // them reject. Knobs the runtime warn-and-defaults on keep that behavior. - if let Err(error) = validate_installed_config() { - let mut guard = slot - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - *guard = None; - return Err(error); + // Publish, re-checking under the write lock: two threads may have + // validated identical (or conflicting) candidates concurrently. + let mut guard = installed_signer_config_slot() + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(existing) = guard.as_ref() { + let existing = Arc::clone(existing); + drop(guard); + return reinit_result(&existing, &config_fingerprint); } + *guard = Some(candidate); Ok(InitSignerConfigResult { installed: true, @@ -115,10 +153,33 @@ pub fn init_signer_config( }) } -fn validate_installed_config() -> Result<(), EngineError> { +fn reinit_result( + existing: &InstalledSignerConfig, + config_fingerprint: &str, +) -> Result { + if existing.fingerprint == config_fingerprint { + return Ok(InitSignerConfigResult { + installed: true, + idempotent: true, + config_fingerprint: config_fingerprint.to_string(), + configured_key_count: existing.values.len() as u32, + }); + } + Err(EngineError::Validation(format!( + "signer config already installed with fingerprint [{}]; \ + conflicting re-initialization rejected", + existing.fingerprint + ))) +} + +fn validate_candidate_config() -> Result<(), EngineError> { load_admission_policy_config()?; load_signing_policy_firewall_config()?; load_auto_quarantine_config()?; + // Production (explicit or by profile-omission default) requires an + // explicit state path; surfacing this at init beats failing the first + // state access after a host migrates to the config FFI. + state_file_path()?; Ok(()) } diff --git a/pkg/tbtc/signer/src/engine/state.rs b/pkg/tbtc/signer/src/engine/state.rs index 389f129fe6..60c55c256b 100644 --- a/pkg/tbtc/signer/src/engine/state.rs +++ b/pkg/tbtc/signer/src/engine/state.rs @@ -263,7 +263,8 @@ pub(crate) fn state_file_path() -> Result { if signer_profile_is_production() { return Err(EngineError::Internal(format!( - "{} must be set when {}={}; refusing to use the implicit temp-dir signer state path", + "{} (or the state_path field of the init-time signer config) must be \ + set when {}={}; refusing to use the implicit temp-dir signer state path", TBTC_SIGNER_STATE_PATH_ENV, TBTC_SIGNER_PROFILE_ENV, TBTC_SIGNER_PROFILE_PRODUCTION ))); } diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index e30152d02f..1a06adb55b 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -10580,13 +10580,14 @@ fn init_signer_config_overrides_environment_for_covered_knobs() { assert_eq!(roast_coordinator_timeout_ms(), 120_000); let result = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), roast_coordinator_timeout_ms: Some(60_000), ..InitSignerConfigRequest::default() }) .expect("install config"); assert!(result.installed); assert!(!result.idempotent); - assert_eq!(result.configured_key_count, 1); + assert_eq!(result.configured_key_count, 2); assert_eq!(roast_coordinator_timeout_ms(), 60_000); std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); @@ -10606,6 +10607,7 @@ fn init_signer_config_ignores_environment_wholesale_for_unset_fields() { // ...is ignored once a config is installed, even though the installed // config does not set that field: absent field = built-in default. init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), roast_coordinator_timeout_ms: Some(60_000), ..InitSignerConfigRequest::default() }) @@ -10626,6 +10628,7 @@ fn init_signer_config_is_idempotent_for_identical_request_and_rejects_conflicts( clear_state_storage_policy_overrides(); let request = InitSignerConfigRequest { + profile: Some("development".to_string()), max_sessions: Some(64), ..InitSignerConfigRequest::default() }; @@ -10637,6 +10640,7 @@ fn init_signer_config_is_idempotent_for_identical_request_and_rejects_conflicts( assert_eq!(second.config_fingerprint, first.config_fingerprint); let conflict = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), max_sessions: Some(128), ..InitSignerConfigRequest::default() }) @@ -10681,6 +10685,7 @@ fn init_signer_config_rolls_back_install_when_policy_validation_fails() { // must roll back. (Admission knobs would NOT trip this: that loader // falls back to defaults for absent values.) let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), enforce_signing_policy_firewall: Some(true), ..InitSignerConfigRequest::default() }) @@ -10704,13 +10709,14 @@ fn init_signer_config_validates_complete_admission_policy_at_install() { clear_state_storage_policy_overrides(); let result = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), enforce_admission_policy: Some(true), admission_min_participants: Some(3), admission_min_threshold: Some(2), ..InitSignerConfigRequest::default() }) .expect("complete admission policy installs"); - assert_eq!(result.configured_key_count, 3); + assert_eq!(result.configured_key_count, 4); let config = load_admission_policy_config() .expect("load admission policy") @@ -10755,6 +10761,12 @@ fn init_signer_config_production_profile_forces_roast_strict_mode() { // config must override it wholesale. init_signer_config(InitSignerConfigRequest { profile: Some("production".to_string()), + state_path: Some( + std::env::temp_dir() + .join("frost_init_config_prod_profile_state.json") + .to_string_lossy() + .into_owned(), + ), ..InitSignerConfigRequest::default() }) .expect("install config"); @@ -10771,6 +10783,7 @@ fn reset_for_tests_clears_installed_signer_config() { clear_state_storage_policy_overrides(); init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), roast_coordinator_timeout_ms: Some(60_000), ..InitSignerConfigRequest::default() }) @@ -10831,3 +10844,102 @@ fn init_signer_config_canonicalizes_list_and_bool_encodings() { "empty identifier list must be rejected" ); } + +#[test] +fn init_signer_config_rejects_production_config_without_state_path() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Explicit production AND production-by-omission (the wholesale default + // when the profile field is unset) must both fail the init when no + // state_path is configured, instead of installing and then failing at + // the first state access. + for request in [ + InitSignerConfigRequest { + profile: Some("production".to_string()), + ..InitSignerConfigRequest::default() + }, + InitSignerConfigRequest { + roast_coordinator_timeout_ms: Some(60_000), + ..InitSignerConfigRequest::default() + }, + ] { + let error = init_signer_config(request) + .expect_err("production config without state_path must fail the init"); + assert!( + error + .to_string() + .contains("refusing to use the implicit temp-dir signer state path"), + "unexpected error: {error}" + ); + } + + // Nothing installed: environment reads still apply. + std::env::set_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV, "120000"); + assert_eq!(roast_coordinator_timeout_ms(), 120_000); + std::env::remove_var(TBTC_SIGNER_ROAST_COORDINATOR_TIMEOUT_MS_ENV); +} + +#[test] +fn init_signer_config_state_path_is_honored_end_to_end() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let state_path = std::env::temp_dir().join(format!( + "frost_init_config_e2e_state_{}.json", + std::process::id() + )); + let _ = fs::remove_file(&state_path); + + init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_path: Some(state_path.to_string_lossy().into_owned()), + ..InitSignerConfigRequest::default() + }) + .expect("install config"); + + let dkg_request = RunDkgRequest { + session_id: "session-init-config-e2e".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, + }; + + // The state-file lock was already bound to the default path by the + // pre-install persist in reset_for_tests, and the engine refuses to + // switch state paths in-process: installing a config after state has + // been touched fails loudly instead of splitting state across paths. + let error = run_dkg(dkg_request.clone()) + .expect_err("state-path switch after first state access must be refused"); + assert!( + error.to_string().contains("refusing to switch"), + "unexpected error: {error}" + ); + + // A fresh process that installs the config before touching state binds + // the lock at the configured path and persists there. + simulate_process_restart_for_tests(); + run_dkg(dkg_request).expect("run dkg under installed config after restart"); + + assert!( + state_path.exists(), + "engine state must persist at the config-provided path" + ); + + reset_for_tests(); + let _ = fs::remove_file(&state_path); + let _ = fs::remove_file(state_path.with_extension("json.lock")); +} From 5fdb09642b355754dc224b6ce50acfc6b97b104f Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 08:52:41 -0400 Subject: [PATCH 3/4] fix(tbtc/signer): validate key-provider settings at config init Addresses Codex's re-review P2: the init validated state_path but not the adjacent key-provider knobs, so a production config that omitted state_key_provider (wholesale-defaulting to the env provider, which production forbids) - or that selected the command provider without a command - installed successfully and then failed at the first state access. - extract the structural head of state_encryption_key_material into resolve_state_key_provider_plan (provider selection, the production env-provider prohibition, command-spec presence; error strings unchanged) and have both the runtime key path and init validation consume it - one source of truth, no drift - init validation rejects: production defaulting to the env provider, command provider without state_key_command, unknown provider values; all WITHOUT reading the secret or executing the key command (pinned by a test whose key command points at a nonexistent binary) - README documents that production configs must carry the command key-provider pair Suite: 241 passed + 1 ignored / 24 / 1 (4 new tests); clippy -D warnings, fmt, chaos suite, bench-restart-hook build all green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/README.md | 5 +- pkg/tbtc/signer/src/engine/init_config.rs | 6 +- pkg/tbtc/signer/src/engine/persistence.rs | 64 +++++++++++------ pkg/tbtc/signer/src/engine/tests.rs | 88 +++++++++++++++++++++++ 4 files changed, 139 insertions(+), 24 deletions(-) diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index 93bdc277ad..b4b2cf5b17 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -137,7 +137,10 @@ Semantics: never read a config that is later rejected. - Production configs (explicitly `"profile": "production"`, or by omission — production is the default) must set `state_path`; the init rejects them - otherwise. Install the config before the first state-touching call: once + otherwise. The init also rejects structurally unusable key-provider + settings (production forbids the `env` provider, so production configs + must set `state_key_provider: "command"` plus `state_key_command`) — + validated without reading the secret or executing the key command. Install the config before the first state-touching call: once the state-file lock is bound, the engine refuses to switch state paths in-process. diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs index dbb4e8bc4e..e25c2cf3dd 100644 --- a/pkg/tbtc/signer/src/engine/init_config.rs +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -126,7 +126,7 @@ pub fn init_signer_config( // so no other caller can ever observe an unvalidated config and a failed // init leaves prior state (installed config or environment fallback) // untouched. Validation runs the same loaders the runtime gates use plus - // the state-path requirement; knobs the runtime warn-and-defaults on + // the state-path and key-provider requirements; knobs the runtime warn-and-defaults on // keep that behavior. { let _candidate_guard = ValidationCandidateGuard::install(Arc::clone(&candidate)); @@ -180,6 +180,10 @@ fn validate_candidate_config() -> Result<(), EngineError> { // explicit state path; surfacing this at init beats failing the first // state access after a host migrates to the config FFI. state_file_path()?; + // The key-provider settings must be structurally usable too (production + // forbids the env provider; the command provider requires a command). + // Resolved WITHOUT reading the secret or executing the key command. + resolve_state_key_provider_plan()?; Ok(()) } diff --git a/pkg/tbtc/signer/src/engine/persistence.rs b/pkg/tbtc/signer/src/engine/persistence.rs index a02cedc8ff..83d47be553 100644 --- a/pkg/tbtc/signer/src/engine/persistence.rs +++ b/pkg/tbtc/signer/src/engine/persistence.rs @@ -494,7 +494,18 @@ pub(crate) fn execute_state_key_command(command_spec: &str) -> Result Result { +/// Where the state-encryption key will come from, validated structurally: +/// provider selection, the production prohibition on the env provider, and +/// command-spec presence. Shared by `state_encryption_key_material` (which +/// goes on to read the secret or execute the command) and init-time config +/// validation (which deliberately must NOT touch the secret channel or run +/// commands). +pub(crate) enum StateKeyProviderPlan { + Env, + Command { command_spec: String }, +} + +pub(crate) fn resolve_state_key_provider_plan() -> Result { let provider = signer_env_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV) .map(|value| value.trim().to_ascii_lowercase()) .unwrap_or_else(|| TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string()); @@ -511,7 +522,36 @@ pub(crate) fn state_encryption_key_material() -> Result { + let command_spec = + signer_env_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV).ok_or_else(|| { + EngineError::Internal(format!( + "missing required state key command env [{}]", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + )) + })?; + if command_spec.trim().is_empty() { + return Err(EngineError::Internal(format!( + "state key command env [{}] must be non-empty", + TBTC_SIGNER_STATE_KEY_COMMAND_ENV + ))); + } + Ok(StateKeyProviderPlan::Command { command_spec }) + } + _ => Err(EngineError::Internal(format!( + "unsupported state key provider [{}]; expected [{}] or [{}]", + provider, + TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, + TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND + ))), + } +} +pub(crate) fn state_encryption_key_material() -> Result { + match resolve_state_key_provider_plan()? { + StateKeyProviderPlan::Env => { let raw_key_hex = // Deliberately read from the real environment even when an init-time // config is installed: the state-encryption key is a secret and the @@ -533,21 +573,7 @@ pub(crate) fn state_encryption_key_material() -> Result { - let command_spec = - signer_env_var(TBTC_SIGNER_STATE_KEY_COMMAND_ENV).ok_or_else(|| { - EngineError::Internal(format!( - "missing required state key command env [{}]", - TBTC_SIGNER_STATE_KEY_COMMAND_ENV - )) - })?; - if command_spec.trim().is_empty() { - return Err(EngineError::Internal(format!( - "state key command env [{}] must be non-empty", - TBTC_SIGNER_STATE_KEY_COMMAND_ENV - ))); - } - + StateKeyProviderPlan::Command { command_spec } => { let mut output = execute_state_key_command(&command_spec)?; if !output.status.success() { @@ -581,12 +607,6 @@ pub(crate) fn state_encryption_key_material() -> Result Err(EngineError::Internal(format!( - "unsupported state key provider [{}]; expected [{}] or [{}]", - provider, - TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT, - TBTC_SIGNER_STATE_KEY_PROVIDER_COMMAND - ))), } } diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index 1a06adb55b..e5ddef32b3 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -10767,6 +10767,8 @@ fn init_signer_config_production_profile_forces_roast_strict_mode() { .to_string_lossy() .into_owned(), ), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), ..InitSignerConfigRequest::default() }) .expect("install config"); @@ -10943,3 +10945,89 @@ fn init_signer_config_state_path_is_honored_end_to_end() { let _ = fs::remove_file(&state_path); let _ = fs::remove_file(state_path.with_extension("json.lock")); } + +#[test] +fn init_signer_config_rejects_production_config_defaulting_to_env_key_provider() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Wholesale semantics: leaving state_key_provider unset in the config + // defaults to the env provider even if the environment exported + // "command" - and production forbids the env provider. This must fail + // the init, not the first state access. + std::env::set_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV, "command"); + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("production config defaulting to the env key provider must fail the init"); + assert!( + error.to_string().contains("is not allowed in profile"), + "unexpected error: {error}" + ); + std::env::remove_var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV); +} + +#[test] +fn init_signer_config_rejects_command_key_provider_without_command() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_key_provider: Some("command".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("command key provider without a command must fail the init"); + assert!( + error + .to_string() + .contains("missing required state key command env"), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_rejects_unknown_state_key_provider() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + state_key_provider: Some("kms".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("unknown key provider must fail the init"); + assert!( + error.to_string().contains("unsupported state key provider"), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_validates_command_key_provider_without_executing_it() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // The command path deliberately points at a binary that cannot succeed: + // if init executed the key command, this install would fail. Structural + // validation must accept it without running it. + let result = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("structurally valid production config installs without running the key command"); + assert!(result.installed); +} From 7beceecb2ac664318c07666c808de93cb00af78e Mon Sep 17 00:00:00 2001 From: maclane Date: Fri, 12 Jun 2026 09:15:34 -0400 Subject: [PATCH 4/4] fix(tbtc/signer): validate the provenance gate at config init Addresses Codex's third-round P2 (the family member my own review had deferred): production forces the provenance gate, so a production config without a complete, verifiable attestation set installed successfully and then failed every protected operation. - validate_candidate_config now runs enforce_provenance_gate(): self- gating (no-op when unenforced, so dev configs are unaffected), reads only candidate values plus local crypto - no secrets, no command execution, no network. Full verification at init (status, payload signature against trust root, runtime-version minimum, TTL); runtime calls still re-check, so an init-time pass does not exempt TTL aging. - production-config tests now carry a complete signed attestation (reusing the existing build_signed_provenance_attestation fixture); new tests pin: production-without-attestation rejected at init, enforced-gate-with-unparseable-trust-root rejected, and a complete production config (state path + command key provider + valid attestation + min version) installs. - README documents the production attestation requirement and the TTL caveat. Suite: 244 passed + 1 ignored / 24 / 1 (3 new tests); clippy -D warnings, fmt, chaos suite all green. Co-Authored-By: Claude Fable 5 --- pkg/tbtc/signer/README.md | 10 +- pkg/tbtc/signer/src/engine/init_config.rs | 8 +- pkg/tbtc/signer/src/engine/tests.rs | 111 ++++++++++++++++++++++ 3 files changed, 125 insertions(+), 4 deletions(-) diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index b4b2cf5b17..ac7e665098 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -124,9 +124,13 @@ Semantics: - Re-initialization with an identical request is idempotent; a conflicting request is rejected. - The init validates enforcement-gated policy combinations (admission, - signing-policy firewall, auto-quarantine) and rolls the install back if - they are incomplete, so a misconfigured signer fails at startup rather - than at first signing. + signing-policy firewall, auto-quarantine) plus the provenance gate, so a + misconfigured signer fails at startup rather than at first signing. Since + production forces the provenance gate, production configs must carry a + complete attestation set (`provenance_attestation_status`/`_payload`/ + `_signature_hex`, `provenance_trust_root`, `min_approved_version`); the + init-time pass does not exempt runtime re-checks — attestation TTL aging + still applies per call. - **Secrets never ride the config FFI**: `TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX` is read exclusively from the dedicated key-provider channel below, even when a config is installed. Do not inline key material into the diff --git a/pkg/tbtc/signer/src/engine/init_config.rs b/pkg/tbtc/signer/src/engine/init_config.rs index e25c2cf3dd..4b74b209de 100644 --- a/pkg/tbtc/signer/src/engine/init_config.rs +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -126,7 +126,7 @@ pub fn init_signer_config( // so no other caller can ever observe an unvalidated config and a failed // init leaves prior state (installed config or environment fallback) // untouched. Validation runs the same loaders the runtime gates use plus - // the state-path and key-provider requirements; knobs the runtime warn-and-defaults on + // the state-path, key-provider and provenance-gate requirements; knobs the runtime warn-and-defaults on // keep that behavior. { let _candidate_guard = ValidationCandidateGuard::install(Arc::clone(&candidate)); @@ -184,6 +184,12 @@ fn validate_candidate_config() -> Result<(), EngineError> { // forbids the env provider; the command provider requires a command). // Resolved WITHOUT reading the secret or executing the key command. resolve_state_key_provider_plan()?; + // Production forces the provenance gate, so a production config without + // a complete, verifiable attestation set is unusable for every protected + // operation - reject it at init. The gate self-gates (no-op when not + // enforced), reads only candidate values plus local crypto, and runtime + // calls still re-check it: an init-time pass does not exempt TTL aging. + enforce_provenance_gate()?; Ok(()) } diff --git a/pkg/tbtc/signer/src/engine/tests.rs b/pkg/tbtc/signer/src/engine/tests.rs index e5ddef32b3..da0eb7d9dc 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -10757,6 +10757,12 @@ fn init_signer_config_production_profile_forces_roast_strict_mode() { let _clear = InstalledConfigClearGuard; clear_state_storage_policy_overrides(); + let (test_trust_root, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + // lock_test_state pins the env profile to development; the installed // config must override it wholesale. init_signer_config(InitSignerConfigRequest { @@ -10769,6 +10775,11 @@ fn init_signer_config_production_profile_forces_roast_strict_mode() { ), state_key_provider: Some("command".to_string()), state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some(test_trust_root.clone()), + provenance_attestation_payload: Some(test_payload.clone()), + provenance_attestation_signature_hex: Some(test_signature.clone()), + min_approved_version: Some(TBTC_SIGNER_RUNTIME_VERSION.to_string()), ..InitSignerConfigRequest::default() }) .expect("install config"); @@ -11018,6 +11029,12 @@ fn init_signer_config_validates_command_key_provider_without_executing_it() { let _clear = InstalledConfigClearGuard; clear_state_storage_policy_overrides(); + let (test_trust_root, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + // The command path deliberately points at a binary that cannot succeed: // if init executed the key command, this install would fail. Structural // validation must accept it without running it. @@ -11026,8 +11043,102 @@ fn init_signer_config_validates_command_key_provider_without_executing_it() { state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), state_key_provider: Some("command".to_string()), state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some(test_trust_root.clone()), + provenance_attestation_payload: Some(test_payload.clone()), + provenance_attestation_signature_hex: Some(test_signature.clone()), + min_approved_version: Some(TBTC_SIGNER_RUNTIME_VERSION.to_string()), ..InitSignerConfigRequest::default() }) .expect("structurally valid production config installs without running the key command"); assert!(result.installed); } + +#[test] +fn init_signer_config_rejects_production_config_without_provenance_attestation() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + // Production forces the provenance gate; a production config that is + // otherwise complete but carries no attestation set is unusable for + // every protected operation and must fail the init, not the first call. + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + ..InitSignerConfigRequest::default() + }) + .expect_err("production config without provenance attestation must fail the init"); + assert!( + error + .to_string() + .contains(TBTC_SIGNER_PROVENANCE_ATTESTATION_STATUS_ENV), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_rejects_enforced_gate_with_unparseable_trust_root() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let (_, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + let error = init_signer_config(InitSignerConfigRequest { + profile: Some("development".to_string()), + enforce_provenance_gate: Some(true), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some("not-a-pubkey".to_string()), + provenance_attestation_payload: Some(test_payload), + provenance_attestation_signature_hex: Some(test_signature), + ..InitSignerConfigRequest::default() + }) + .expect_err("enforced gate with unparseable trust root must fail the init"); + assert!( + error + .to_string() + .to_ascii_lowercase() + .contains("trust_root"), + "unexpected error: {error}" + ); +} + +#[test] +fn init_signer_config_installs_production_config_with_valid_provenance() { + let _guard = lock_test_state(); + reset_for_tests(); + let _clear = InstalledConfigClearGuard; + clear_state_storage_policy_overrides(); + + let (test_trust_root, test_payload, test_signature) = build_signed_provenance_attestation( + TBTC_SIGNER_REQUIRED_ATTESTATION_STATUS_APPROVED, + TBTC_SIGNER_RUNTIME_VERSION, + Some(now_unix() + 3600), + ); + + let result = init_signer_config(InitSignerConfigRequest { + profile: Some("production".to_string()), + state_path: Some("/var/lib/tbtc/signer-state.json".to_string()), + state_key_provider: Some("command".to_string()), + state_key_command: Some("/nonexistent/key-helper-never-run-at-init".to_string()), + provenance_attestation_status: Some("approved".to_string()), + provenance_trust_root: Some(test_trust_root), + provenance_attestation_payload: Some(test_payload), + provenance_attestation_signature_hex: Some(test_signature), + min_approved_version: Some(TBTC_SIGNER_RUNTIME_VERSION.to_string()), + ..InitSignerConfigRequest::default() + }) + .expect("complete production config installs"); + assert!(result.installed); + assert!(signer_profile_is_production()); + assert!(provenance_gate_enforced()); +}