Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions pkg/tbtc/signer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/tbtc/signer/include/frost_tbtc.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
103 changes: 103 additions & 0 deletions pkg/tbtc/signer/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_bootstrap: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_roast_strict: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allow_bench_restart_hook: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roast_coordinator_timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_cadence_seconds: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_corruption_policy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_corrupt_backup_limit: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_sessions: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_key_provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_key_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_key_command_timeout_secs: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enforce_provenance_gate: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance_attestation_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance_attestation_payload: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance_attestation_signature_hex: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance_trust_root: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_approved_version: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enforce_admission_policy: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admission_min_participants: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admission_min_threshold: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admission_required_identifiers: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admission_allowlist_identifiers: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enforce_signing_policy_firewall: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_allowed_script_classes: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_max_output_count: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_max_output_value_sats: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_max_total_output_value_sats: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_allowed_utc_start_hour: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_allowed_utc_end_hour: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_rate_limit_per_minute: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enable_auto_quarantine: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_quarantine_fault_threshold: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_quarantine_timeout_penalty: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_quarantine_invalid_share_penalty: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auto_quarantine_dao_allowlist_identifiers: Option<Vec<u16>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canary_max_start_sign_round_p95_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canary_max_finalize_sign_round_p95_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canary_max_policy_reject_rate_bps: Option<u64>,
}

#[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,
}
35 changes: 17 additions & 18 deletions pkg/tbtc/signer/src/engine/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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::<u64>().ok())
.filter(|timeout_ms| {
*timeout_ms >= TBTC_SIGNER_MIN_ROAST_COORDINATOR_TIMEOUT_MS
Expand All @@ -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::<u64>().ok())
.filter(|value| {
*value >= TBTC_SIGNER_MIN_REFRESH_CADENCE_SECONDS
Expand All @@ -205,7 +204,7 @@ pub(crate) fn refresh_cadence_seconds() -> u64 {
pub(crate) fn parse_identifier_set_from_env(
env_name: &str,
) -> Result<Option<HashSet<u16>>, EngineError> {
let Ok(raw_value) = std::env::var(env_name) else {
let Some(raw_value) = signer_env_var(env_name) else {
return Ok(None);
};

Expand Down Expand Up @@ -246,7 +245,7 @@ pub(crate) fn parse_usize_from_env_with_default(
env_name: &str,
default_value: usize,
) -> Result<usize, EngineError> {
let Ok(raw_value) = std::env::var(env_name) else {
let Some(raw_value) = signer_env_var(env_name) else {
return Ok(default_value);
};

Expand All @@ -263,7 +262,7 @@ pub(crate) fn parse_u64_from_env_with_default(
env_name: &str,
default_value: u64,
) -> Result<u64, EngineError> {
let Ok(raw_value) = std::env::var(env_name) else {
let Some(raw_value) = signer_env_var(env_name) else {
return Ok(default_value);
};

Expand All @@ -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<usize, 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)))?;
raw_value.trim().parse::<usize>().map_err(|_| {
EngineError::Internal(format!(
"failed to parse usize env [{}] value [{}]",
Expand All @@ -288,8 +287,8 @@ pub(crate) fn parse_usize_from_env_required(env_name: &str) -> Result<usize, Eng
}

pub(crate) fn parse_u64_from_env_required(env_name: &str) -> Result<u64, 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)))?;
raw_value.trim().parse::<u64>().map_err(|_| {
EngineError::Internal(format!(
"failed to parse u64 env [{}] value [{}]",
Expand All @@ -299,7 +298,7 @@ pub(crate) fn parse_u64_from_env_required(env_name: &str) -> Result<u64, EngineE
}

pub(crate) fn parse_u8_from_env_optional(env_name: &str) -> Result<Option<u8>, EngineError> {
let Ok(raw_value) = std::env::var(env_name) else {
let Some(raw_value) = signer_env_var(env_name) else {
return Ok(None);
};

Expand All @@ -321,8 +320,8 @@ pub(crate) fn parse_u8_from_env_optional(env_name: &str) -> Result<Option<u8>, E
pub(crate) fn parse_script_class_set_required(
env_name: &str,
) -> Result<HashSet<String>, 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!(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading