feat(admin): first-run bootstrap latch + CLI setup [operator §3]

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.
This commit is contained in:
jwinterm
2026-06-27 14:11:28 -04:00
parent 12e4a54c63
commit 67faf8f762
7 changed files with 387 additions and 0 deletions
@@ -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
);
+27
View File
@@ -36,6 +36,8 @@ USAGE:
smirk-admin <COMMAND> [FLAGS]
COMMANDS:
setup --pubkey <64hex> (first-run: seed the admin + latch)
reset-setup --i-understand
list-keys
add-key --pubkey <64hex> [--label <s>]
revoke-key --id <uuid>
@@ -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() {
+2
View File
@@ -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;
+209
View File
@@ -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<i64>) -> 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<SetupState, AppError> {
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<bool, AppError> {
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<Utc>| 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(())
}
}
+34
View File
@@ -37,6 +37,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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 <hex>`"
);
}
}
SetupState::Uninitialized => {
tracing::warn!("not yet bootstrapped — run `smirk-admin setup --pubkey <hex>`")
}
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,
+12
View File
@@ -280,6 +280,18 @@ pub struct NewAdminKey {
pub activation_deadline: Option<DateTime<Utc>>,
}
/// 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<DateTime<Utc>>,
pub locked_at: Option<DateTime<Utc>>,
pub updated_at: DateTime<Utc>,
pub integrity_mac: String,
}
/// A row in the tamper-evident privileged-action audit chain.
#[derive(Debug, Clone, FromRow)]
pub struct AdminAuditLog {
+87
View File
@@ -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;
}