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]
|
smirk-admin <COMMAND> [FLAGS]
|
||||||
|
|
||||||
COMMANDS:
|
COMMANDS:
|
||||||
|
setup --pubkey <64hex> (first-run: seed the admin + latch)
|
||||||
|
reset-setup --i-understand
|
||||||
list-keys
|
list-keys
|
||||||
add-key --pubkey <64hex> [--label <s>]
|
add-key --pubkey <64hex> [--label <s>]
|
||||||
revoke-key --id <uuid>
|
revoke-key --id <uuid>
|
||||||
@@ -84,6 +86,8 @@ async fn run(args: &[String]) -> Result<(), String> {
|
|||||||
let db = Database::new(pool, String::new(), String::new());
|
let db = Database::new(pool, String::new(), String::new());
|
||||||
|
|
||||||
match cmd.as_str() {
|
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,
|
"list-keys" => list_keys(&db).await,
|
||||||
"add-key" => add_key(&db, &secret, rest).await,
|
"add-key" => add_key(&db, &secret, rest).await,
|
||||||
"revoke-key" => revoke_key(&db, &secret, rest).await,
|
"revoke-key" => revoke_key(&db, &secret, rest).await,
|
||||||
@@ -155,6 +159,29 @@ fn preflight_env_perms() -> Result<(), String> {
|
|||||||
|
|
||||||
// ── commands ─────────────────────────────────────────────────────────────────
|
// ── 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> {
|
async fn list_keys(db: &Database) -> Result<(), String> {
|
||||||
let keys = db.list_admin_keys().await.map_err(|e| e.to_string())?;
|
let keys = db.list_admin_keys().await.map_err(|e| e.to_string())?;
|
||||||
if keys.is_empty() {
|
if keys.is_empty() {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ mod challenges;
|
|||||||
mod grin_slatepacks;
|
mod grin_slatepacks;
|
||||||
mod login_events;
|
mod login_events;
|
||||||
mod restore_attempts;
|
mod restore_attempts;
|
||||||
|
mod server_config;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
mod user_keys;
|
mod user_keys;
|
||||||
mod users;
|
mod users;
|
||||||
@@ -21,6 +22,7 @@ mod users;
|
|||||||
pub use admin_keys::{AddKeyOutcome, RevokeKeyOutcome};
|
pub use admin_keys::{AddKeyOutcome, RevokeKeyOutcome};
|
||||||
pub use challenges::ConsumedChallenge;
|
pub use challenges::ConsumedChallenge;
|
||||||
pub use login_events::LoginStats;
|
pub use login_events::LoginStats;
|
||||||
|
pub use server_config::SetupState;
|
||||||
|
|
||||||
use sqlx::PgPool;
|
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 sessions = SessionManager::new(&config.auth.jwt_secret, config.auth.jwt_expiry_hours);
|
||||||
let chains = ChainClients::from_config(&config)?;
|
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 addr = format!("{}:{}", config.server_host, config.server_port);
|
||||||
let prices_cache = Arc::new(tokio::sync::RwLock::new(PriceSnapshot::empty(
|
let prices_cache = Arc::new(tokio::sync::RwLock::new(PriceSnapshot::empty(
|
||||||
&config.features.prices_currency,
|
&config.features.prices_currency,
|
||||||
|
|||||||
@@ -280,6 +280,18 @@ pub struct NewAdminKey {
|
|||||||
pub activation_deadline: Option<DateTime<Utc>>,
|
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.
|
/// A row in the tamper-evident privileged-action audit chain.
|
||||||
#[derive(Debug, Clone, FromRow)]
|
#[derive(Debug, Clone, FromRow)]
|
||||||
pub struct AdminAuditLog {
|
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