From 67faf8f762a99a4f720cc5f7c4e641bd1ee6e5b7 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 27 Jun 2026 14:11:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20first-run=20bootstrap=20latch=20?= =?UTF-8?q?+=20CLI=20setup=20[operator=20=C2=A73]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No trust-on-first-use: bootstrap mode is an explicit, MAC-protected latch (server_config singleton), NOT inferred from "no admin keys". The latch MAC (ADMIN_KEY_INTEGRITY_SECRET over setup_state|bootstrap_completed_at) means a DB-write attacker who restores a pre-bootstrap backup or flips the state to re-open TOFU produces a mismatch the boot path detects and fails closed on. - migration 20260626000002: server_config (id=1 singleton; setup_state + bootstrap_completed_at + integrity_mac). - db::server_config: read_setup_state -> {Fresh,Uninitialized,Locked,Tampered} (MAC-verified); has_any_users; init_server_config (adoption: a live upgrade latches `locked` so it never exposes a setup window; a truly empty DB begins uninitialized); bootstrap_admin (insert the FIRST active admin AND latch locked + hash-chained audit, ONE tx; refuses if an active admin already exists); reset_setup (shell recovery; recomputes MAC, preserves admin keys). - main boot (admin enabled): adopt/await-bootstrap, and FAIL CLOSED on a tampered latch. - smirk-admin: `setup --pubkey` (the supported headless bootstrap) + `reset-setup --i-understand`. - 1 L1 test: Fresh -> locked(valid) -> wrong-secret Tampered -> raw-flip Tampered -> reset Uninitialized -> bootstrap refused while an active admin exists. Deferred (documented): the HTTP /setup/* wizard. Per the spec the CLI / `docker exec` is the SUPPORTED bootstrap (pure-PaaS without shell is explicitly unsupported), so the loopback TTY wizard is a later convenience, not load-bearing. --- migrations/20260626000002_server_config.sql | 16 ++ src/bin/smirk-admin.rs | 27 +++ src/infra/db/mod.rs | 2 + src/infra/db/server_config.rs | 209 ++++++++++++++++++++ src/main.rs | 34 ++++ src/models/db.rs | 12 ++ tests/server_config.rs | 87 ++++++++ 7 files changed, 387 insertions(+) create mode 100644 migrations/20260626000002_server_config.sql create mode 100644 src/infra/db/server_config.rs create mode 100644 tests/server_config.rs diff --git a/migrations/20260626000002_server_config.sql b/migrations/20260626000002_server_config.sql new file mode 100644 index 0000000..539912f --- /dev/null +++ b/migrations/20260626000002_server_config.sql @@ -0,0 +1,16 @@ +-- First-run bootstrap latch (operator §3.2). +-- +-- Bootstrap mode is NOT derived from "no active admin keys" — it is an explicit, +-- MAC-protected latch, so a DB-write attacker who restores a pre-bootstrap backup +-- (or flips the state) to re-open trust-on-first-use produces a MAC mismatch the +-- server detects and fails closed on. The MAC is keyed by the same +-- ADMIN_KEY_INTEGRITY_SECRET as admin_keys, over setup_state|bootstrap_completed_at. +-- Singleton row (id = 1). +CREATE TABLE server_config ( + id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), + setup_state TEXT NOT NULL DEFAULT 'uninitialized', -- uninitialized | locked + bootstrap_completed_at TIMESTAMPTZ, + locked_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + integrity_mac TEXT NOT NULL +); diff --git a/src/bin/smirk-admin.rs b/src/bin/smirk-admin.rs index 3c4177a..1ec439b 100644 --- a/src/bin/smirk-admin.rs +++ b/src/bin/smirk-admin.rs @@ -36,6 +36,8 @@ USAGE: smirk-admin [FLAGS] COMMANDS: + setup --pubkey <64hex> (first-run: seed the admin + latch) + reset-setup --i-understand list-keys add-key --pubkey <64hex> [--label ] revoke-key --id @@ -84,6 +86,8 @@ async fn run(args: &[String]) -> Result<(), String> { let db = Database::new(pool, String::new(), String::new()); match cmd.as_str() { + "setup" => setup_cmd(&db, &secret, rest).await, + "reset-setup" => reset_setup_cmd(&db, &secret, rest).await, "list-keys" => list_keys(&db).await, "add-key" => add_key(&db, &secret, rest).await, "revoke-key" => revoke_key(&db, &secret, rest).await, @@ -155,6 +159,29 @@ fn preflight_env_perms() -> Result<(), String> { // ── commands ───────────────────────────────────────────────────────────────── +async fn setup_cmd(db: &Database, secret: &str, args: &[String]) -> Result<(), String> { + let pubkey = validate_pubkey(&require_flag(args, "--pubkey")?)?; + db.bootstrap_admin(&pubkey, secret) + .await + .map_err(|e| e.to_string())?; + println!("bootstrapped: active admin {pubkey}"); + println!("setup latched (locked); the network admin plane is now usable"); + Ok(()) +} + +async fn reset_setup_cmd(db: &Database, secret: &str, args: &[String]) -> Result<(), String> { + if !has_flag(args, "--i-understand") { + return Err( + "reset-setup re-opens first-run setup; existing admin keys are preserved. \ + Re-run with --i-understand to confirm" + .into(), + ); + } + db.reset_setup(secret).await.map_err(|e| e.to_string())?; + println!("setup reset to uninitialized (existing admin keys preserved)"); + Ok(()) +} + async fn list_keys(db: &Database) -> Result<(), String> { let keys = db.list_admin_keys().await.map_err(|e| e.to_string())?; if keys.is_empty() { diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 1cef8bc..b429485 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -14,6 +14,7 @@ mod challenges; mod grin_slatepacks; mod login_events; mod restore_attempts; +mod server_config; mod sessions; mod user_keys; mod users; @@ -21,6 +22,7 @@ mod users; pub use admin_keys::{AddKeyOutcome, RevokeKeyOutcome}; pub use challenges::ConsumedChallenge; pub use login_events::LoginStats; +pub use server_config::SetupState; use sqlx::PgPool; diff --git a/src/infra/db/server_config.rs b/src/infra/db/server_config.rs new file mode 100644 index 0000000..5ac03d3 --- /dev/null +++ b/src/infra/db/server_config.rs @@ -0,0 +1,209 @@ +//! First-run bootstrap latch (operator §3.2). +//! +//! The latch is an explicit, MAC-protected singleton — bootstrap mode is NOT +//! inferred from "no admin keys". The MAC (keyed by `ADMIN_KEY_INTEGRITY_SECRET`, +//! over `setup_state|bootstrap_completed_at`) means a DB-write attacker who +//! restores a pre-bootstrap backup or flips the state to re-open trust-on-first- +//! use produces a mismatch the boot path detects (`SetupState::Tampered`) and +//! fails closed on. The supported bootstrap is the CLI (`smirk-admin setup`), +//! which inserts the first admin AND latches in one transaction. + +use chrono::{SubsecRound, Utc}; +use tracing::instrument; + +use crate::core::crypto::pepper::peppered_hex; +use crate::error::AppError; +use crate::models::db::{NewAdminAudit, ServerConfig}; + +use super::Database; + +/// Resolved bootstrap state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupState { + /// No latch row yet (a brand-new database). + Fresh, + /// Latch present + valid, not yet bootstrapped. + Uninitialized, + /// Latch present + valid, bootstrap complete. + Locked, + /// Latch present but its MAC does not verify — tamper / restore-to-pre-boot. + Tampered, +} + +/// `HMAC(secret, "server_config" ‖ setup_state ‖ bootstrap_completed_at)`. +fn latch_mac(secret: &str, setup_state: &str, completed_at_micros: Option) -> String { + let completed = completed_at_micros + .map(|m| m.to_string()) + .unwrap_or_else(|| "null".to_string()); + peppered_hex( + secret, + "server_config", + &format!("{setup_state}\u{1f}{completed}"), + ) +} + +fn verify_latch(secret: &str, c: &ServerConfig) -> bool { + use subtle::ConstantTimeEq; + let expected = latch_mac( + secret, + &c.setup_state, + c.bootstrap_completed_at.map(|t| t.timestamp_micros()), + ); + expected.as_bytes().ct_eq(c.integrity_mac.as_bytes()).into() +} + +impl Database { + /// Read + verify the bootstrap latch. + #[instrument(skip(self, secret))] + pub async fn read_setup_state(&self, secret: &str) -> Result { + let row = sqlx::query_as::<_, ServerConfig>( + "SELECT id, setup_state, bootstrap_completed_at, locked_at, updated_at, integrity_mac \ + FROM server_config WHERE id = 1", + ) + .fetch_optional(self.pool()) + .await?; + Ok(match row { + None => SetupState::Fresh, + Some(c) if !verify_latch(secret, &c) => SetupState::Tampered, + Some(c) if c.setup_state == "locked" => SetupState::Locked, + Some(_) => SetupState::Uninitialized, + }) + } + + /// Whether the DB already holds user data (drives migration adoption: a live + /// deployment must latch `locked` so it never exposes a setup window). + #[instrument(skip(self))] + pub async fn has_any_users(&self) -> Result { + let exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS (SELECT 1 FROM users)") + .fetch_one(self.pool()) + .await?; + Ok(exists) + } + + /// Create the latch row if absent. `locked` adopts an existing deployment + /// (state=locked, completed/locked now); otherwise a fresh install begins + /// `uninitialized`. No-op if the row already exists. + #[instrument(skip(self, secret))] + pub async fn init_server_config(&self, secret: &str, locked: bool) -> Result<(), AppError> { + let now = Utc::now().trunc_subsecs(6); + let (state, completed, locked_at) = if locked { + ("locked", Some(now), Some(now)) + } else { + ("uninitialized", None, None) + }; + let mac = latch_mac( + secret, + state, + completed.map(|t: chrono::DateTime| t.timestamp_micros()), + ); + sqlx::query( + "INSERT INTO server_config (id, setup_state, bootstrap_completed_at, locked_at, integrity_mac) \ + VALUES (1, $1, $2, $3, $4) ON CONFLICT (id) DO NOTHING", + ) + .bind(state) + .bind(completed) + .bind(locked_at) + .bind(&mac) + .execute(self.pool()) + .await?; + Ok(()) + } + + /// Headless bootstrap (CLI): insert the FIRST admin (active) and latch + /// `locked`, in ONE transaction + a hash-chained audit row. Refuses if an + /// active admin already exists (use `add-key` for more admins). + #[instrument(skip(self, secret))] + pub async fn bootstrap_admin(&self, pubkey: &str, secret: &str) -> Result<(), AppError> { + use crate::core::crypto::admin_mac::{compute_admin_key_mac, AdminKeyMacInput}; + let mut tx = self.pool().begin().await?; + + let has_admin = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM admin_keys WHERE revoked_at IS NULL AND activated_at IS NOT NULL)", + ) + .fetch_one(&mut *tx) + .await?; + if has_admin { + tx.rollback().await?; + return Err(AppError::Conflict( + "an active admin already exists; use add-key".into(), + )); + } + + // Insert an already-ACTIVE admin (bootstrap, not pending-first-login). + let id = uuid::Uuid::new_v4(); + let now = Utc::now().trunc_subsecs(6); + let mac = compute_admin_key_mac( + secret, + &AdminKeyMacInput { + id, + pubkey, + scope: "admin", + created_at: now, + activated_at: Some(now), + activation_deadline: None, + revoked_at: None, + }, + ); + sqlx::query( + "INSERT INTO admin_keys \ + (id, pubkey, label, scope, created_at, created_by_kind, activated_at, integrity_mac) \ + VALUES ($1, $2, 'bootstrap', 'admin', $3, 'bootstrap', $3, $4)", + ) + .bind(id) + .bind(pubkey) + .bind(now) + .bind(&mac) + .execute(&mut *tx) + .await + .map_err(super::unique_violation_as( + "an active admin key with this pubkey already exists", + ))?; + + // Latch locked (upsert so adoption rows also become bootstrapped). + let latch = latch_mac(secret, "locked", Some(now.timestamp_micros())); + sqlx::query( + "INSERT INTO server_config (id, setup_state, bootstrap_completed_at, locked_at, integrity_mac) \ + VALUES (1, 'locked', $1, $1, $2) \ + ON CONFLICT (id) DO UPDATE SET setup_state = 'locked', \ + bootstrap_completed_at = $1, locked_at = $1, updated_at = NOW(), integrity_mac = $2", + ) + .bind(now) + .bind(&latch) + .execute(&mut *tx) + .await?; + + self.append_admin_audit( + &mut tx, + &NewAdminAudit { + action: "bootstrap".into(), + actor_kind: "bootstrap".into(), + actor_pubkey_prefix: Some(pubkey.chars().take(16).collect()), + target: None, + details: None, + ip_address: None, + }, + secret, + ) + .await?; + tx.commit().await?; + Ok(()) + } + + /// Reset the latch to `uninitialized` (shell `reset-setup` only). Recomputes + /// the MAC so it verifies again; existing admin keys are left untouched, so + /// recovery cannot worsen a lockout. + #[instrument(skip(self, secret))] + pub async fn reset_setup(&self, secret: &str) -> Result<(), AppError> { + let mac = latch_mac(secret, "uninitialized", None); + sqlx::query( + "INSERT INTO server_config (id, setup_state, bootstrap_completed_at, locked_at, integrity_mac) \ + VALUES (1, 'uninitialized', NULL, NULL, $1) \ + ON CONFLICT (id) DO UPDATE SET setup_state = 'uninitialized', \ + bootstrap_completed_at = NULL, locked_at = NULL, updated_at = NOW(), integrity_mac = $1", + ) + .bind(&mac) + .execute(self.pool()) + .await?; + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 4511d9a..79f45e4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -37,6 +37,40 @@ async fn main() -> Result<(), Box> { let sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours); let chains = ChainClients::from_config(&config)?; + // First-run bootstrap latch (operator §3.2). Only meaningful with the admin + // surface enabled. Fail closed on a tampered latch (restore-to-pre-bootstrap). + if config.admin.enabled { + use smirk_backend_core::infra::db::SetupState; + let secret = &config.admin.key_integrity_secret; + match db.read_setup_state(secret).await? { + SetupState::Fresh => { + // Adopt an existing deployment as already-bootstrapped (so a live + // upgrade never exposes a setup window); a truly empty DB begins + // uninitialized, awaiting `smirk-admin setup`. + let adopt = db.has_any_users().await?; + db.init_server_config(secret, adopt).await?; + if adopt { + tracing::info!("existing deployment adopted: bootstrap latched (locked)"); + } else { + tracing::warn!( + "fresh install: no admin yet — run `smirk-admin setup --pubkey `" + ); + } + } + SetupState::Uninitialized => { + tracing::warn!("not yet bootstrapped — run `smirk-admin setup --pubkey `") + } + SetupState::Locked => tracing::info!("bootstrap latch: locked"), + SetupState::Tampered => { + return Err( + "server_config bootstrap latch MAC invalid (tamper or restore-to-pre-bootstrap); \ + run `smirk-admin reset-setup --i-understand` if this is intentional" + .into(), + ); + } + } + } + let addr = format!("{}:{}", config.server_host, config.server_port); let prices_cache = Arc::new(tokio::sync::RwLock::new(PriceSnapshot::empty( &config.features.prices_currency, diff --git a/src/models/db.rs b/src/models/db.rs index 0ba59d6..0dea4fa 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -280,6 +280,18 @@ pub struct NewAdminKey { pub activation_deadline: Option>, } +/// The singleton bootstrap-latch row (operator §3.2). MAC-protected against +/// restore-to-pre-bootstrap tampering. +#[derive(Debug, Clone, FromRow)] +pub struct ServerConfig { + pub id: i32, + pub setup_state: String, + pub bootstrap_completed_at: Option>, + pub locked_at: Option>, + pub updated_at: DateTime, + pub integrity_mac: String, +} + /// A row in the tamper-evident privileged-action audit chain. #[derive(Debug, Clone, FromRow)] pub struct AdminAuditLog { diff --git a/tests/server_config.rs b/tests/server_config.rs new file mode 100644 index 0000000..5351c71 --- /dev/null +++ b/tests/server_config.rs @@ -0,0 +1,87 @@ +//! L1 integration: the first-run bootstrap latch (operator §3.2). +//! +//! The latch is a singleton (id=1) only this suite reads (main's boot logic does +//! not run under the harness), so this one sequential test owns it and drives the +//! state machine deterministically. The bootstrap SUCCESS path needs an empty +//! admin allowlist, which the shared test DB cannot guarantee; instead this +//! asserts bootstrap's refusal guard (with a guaranteed active admin) — the +//! success path's pieces (admin insert MAC + latch MAC) are covered elsewhere. + +mod common; + +use smirk_backend_core::infra::db::SetupState; +use smirk_backend_core::models::db::NewAdminKey; +use uuid::Uuid; + +const SECRET: &str = "server-config-integrity-secret-0123456789ab"; + +#[tokio::test] +async fn latch_state_machine_and_tamper_detection() { + let app = require_app!(); + let db = &app.state.db; + + // Own the singleton: no row => Fresh. + sqlx::query("DELETE FROM server_config WHERE id = 1") + .execute(db.pool()) + .await + .unwrap(); + assert_eq!( + db.read_setup_state(SECRET).await.unwrap(), + SetupState::Fresh + ); + + // Adoption path: init creates a valid locked latch. + db.init_server_config(SECRET, true).await.unwrap(); + assert_eq!( + db.read_setup_state(SECRET).await.unwrap(), + SetupState::Locked + ); + + // Wrong secret => MAC fails => Tampered (fail-closed). + assert_eq!( + db.read_setup_state("WRONG-SECRET").await.unwrap(), + SetupState::Tampered + ); + + // Raw flip of state without recomputing the MAC => Tampered (the + // restore-to-pre-bootstrap attack). + sqlx::query( + "UPDATE server_config SET setup_state = 'uninitialized', bootstrap_completed_at = NULL WHERE id = 1", + ) + .execute(db.pool()) + .await + .unwrap(); + assert_eq!( + db.read_setup_state(SECRET).await.unwrap(), + SetupState::Tampered + ); + + // reset-setup recomputes the MAC => verifies again as uninitialized. + db.reset_setup(SECRET).await.unwrap(); + assert_eq!( + db.read_setup_state(SECRET).await.unwrap(), + SetupState::Uninitialized + ); + + // With an active admin present, bootstrap must refuse (no second bootstrap). + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + let k = db + .create_admin_key( + NewAdminKey { + pubkey: pk.clone(), + label: None, + scope: "admin".into(), + created_by_kind: "cli".into(), + activation_deadline: None, + }, + SECRET, + ) + .await + .unwrap(); + db.activate_admin_key(k.id, SECRET).await.unwrap(); + assert!( + db.bootstrap_admin(&"11".repeat(32), SECRET).await.is_err(), + "bootstrap refused while an active admin exists" + ); + let _ = db.revoke_admin_key(k.id, SECRET).await; +}