mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
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:
@@ -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
|
||||
);
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user