diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index e36d16344b..ac7e665098 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -106,6 +106,52 @@ 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) 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 + `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. 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. + +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..4b74b209de --- /dev/null +++ b/pkg/tbtc/signer/src/engine/init_config.rs @@ -0,0 +1,486 @@ +// 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, +} + +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(candidate) = validation_candidate() { + return candidate.values.get(name).cloned(); + } + 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 candidate = Arc::new(InstalledSignerConfig { + values, + fingerprint: config_fingerprint.clone(), + }); + + // 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, key-provider and provenance-gate requirements; knobs the runtime warn-and-defaults on + // keep that behavior. + { + let _candidate_guard = ValidationCandidateGuard::install(Arc::clone(&candidate)); + validate_candidate_config()?; + } + + // 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, + idempotent: false, + config_fingerprint, + configured_key_count, + }) +} + +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()?; + // 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()?; + // 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(()) +} + +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..83d47be553 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 @@ -495,10 +494,21 @@ pub(crate) fn execute_state_key_command(command_spec: &str) -> Result Result { - let provider = std::env::var(TBTC_SIGNER_STATE_KEY_PROVIDER_ENV) +/// 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()); + .unwrap_or_else(|| TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT.to_string()); match provider.as_str() { TBTC_SIGNER_STATE_KEY_PROVIDER_ENV_DEFAULT => { @@ -512,8 +522,40 @@ 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 + // config FFI carries operational knobs only (see signer_env_var). std::env::var(TBTC_SIGNER_STATE_ENCRYPTION_KEY_HEX_ENV).map_err(|_| { EngineError::Internal(format!( "missing required state encryption key env [{}]", @@ -531,20 +573,7 @@ pub(crate) fn state_encryption_key_material() -> 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 - )) - })?; - 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() { @@ -578,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/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..60c55c256b 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); @@ -264,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 ))); } @@ -322,8 +322,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 +334,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..da0eb7d9dc 100644 --- a/pkg/tbtc/signer/src/engine/tests.rs +++ b/pkg/tbtc/signer/src/engine/tests.rs @@ -10556,3 +10556,589 @@ 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 { + 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, 2); + + 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 { + profile: Some("development".to_string()), + 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 { + profile: Some("development".to_string()), + 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 { + profile: Some("development".to_string()), + 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 { + profile: Some("development".to_string()), + 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 { + 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, 4); + + 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(); + + 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 { + 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(), + ), + 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"); + + 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 { + profile: Some("development".to_string()), + 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" + ); +} + +#[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")); +} + +#[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(); + + 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. + 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.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()); +} 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(); + } }