diff --git a/migrations/20260625000002_admin.sql b/migrations/20260625000002_admin.sql new file mode 100644 index 0000000..e91b049 --- /dev/null +++ b/migrations/20260625000002_admin.sql @@ -0,0 +1,44 @@ +-- Admin authentication & authorization (operator §0.3 / §1.2). +-- +-- The server stores ONLY public keys — an admin proves control of a private key +-- (NIP-98 signed action) and is authorized by presence in this allowlist. Each +-- row carries an integrity MAC keyed by ADMIN_KEY_INTEGRITY_SECRET (separate from +-- DATABASE_URL): it covers id|pubkey|scope|created_at|activated_at|revoked_at, so +-- a DB-write attacker who flips revoked_at back to NULL (un-revoke) or revives a +-- key produces a MAC mismatch that the guard rejects. Revocation is a soft +-- revoke (the audit trail survives); the partial unique index keeps at most one +-- ACTIVE row per pubkey while allowing historical revoked rows. +CREATE TABLE admin_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pubkey TEXT NOT NULL, -- x-only secp256k1 hex, 64 lowercase + label TEXT, + scope TEXT NOT NULL DEFAULT 'admin', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_kind TEXT NOT NULL, -- 'admin' | 'cli' | 'bootstrap' (coarse; no social graph) + activated_at TIMESTAMPTZ, -- NULL = pending (first login not yet completed) + activation_deadline TIMESTAMPTZ, -- pending key auto-expires after this + revoked_at TIMESTAMPTZ, -- soft revoke + last_used_at TIMESTAMPTZ, + integrity_mac TEXT NOT NULL -- HMAC(integrity_secret, id|pubkey|scope|created_at|activated_at|revoked_at) +); + +-- At most one ACTIVE allowlist entry per pubkey; revoked rows are unconstrained. +CREATE UNIQUE INDEX admin_keys_active_pubkey ON admin_keys (pubkey) WHERE revoked_at IS NULL; + +-- Admin sessions are a SEPARATE table from user `sessions` (cryptographically +-- distinct tokens, distinct secret). Revoking the key cascades to its sessions. +CREATE TABLE admin_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + admin_key_id UUID NOT NULL REFERENCES admin_keys (id) ON DELETE CASCADE, + pubkey TEXT NOT NULL, + refresh_token_hash TEXT NOT NULL, -- peppered at rest (never the token) + access_jti TEXT NOT NULL, -- the live access token's id + device_info TEXT, + ip_address INET, -- real TCP peer, never XFF + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ +); + +CREATE INDEX idx_admin_sessions_key ON admin_sessions (admin_key_id); +CREATE INDEX idx_admin_sessions_jti ON admin_sessions (access_jti) WHERE revoked_at IS NULL; diff --git a/src/core/crypto/admin_mac.rs b/src/core/crypto/admin_mac.rs new file mode 100644 index 0000000..686c8d7 --- /dev/null +++ b/src/core/crypto/admin_mac.rs @@ -0,0 +1,104 @@ +//! Row-integrity MAC for the admin allowlist. +//! +//! Each `admin_keys` row carries an HMAC over its authorization-bearing fields, +//! keyed by `ADMIN_KEY_INTEGRITY_SECRET` (deliberately separate from +//! `DATABASE_URL`). Because the MAC covers `activated_at` AND `revoked_at`, a +//! DB-write attacker who flips `revoked_at` back to `NULL` (un-revoke) or revives +//! a key produces a mismatch the guard rejects — the MAC proves a pubkey is +//! *currently* authorized, not merely that it once was. Every write that changes +//! a covered field MUST recompute the MAC (the db layer does this centrally). + +use chrono::{DateTime, Utc}; +use subtle::ConstantTimeEq; +use uuid::Uuid; + +use super::pepper::peppered_hex; + +/// The covered fields. Timestamps are compared at microsecond precision (the +/// resolution Postgres stores), so the MAC round-trips exactly. +pub struct AdminKeyMacInput<'a> { + pub id: Uuid, + pub pubkey: &'a str, + pub scope: &'a str, + pub created_at: DateTime, + pub activated_at: Option>, + pub revoked_at: Option>, +} + +/// `hex(HMAC-SHA256(secret, "admin_key_mac" ‖ canonical))`. The canonical form is +/// a JSON array (unambiguous escaping) of the covered fields, timestamps as +/// microsecond epochs (`null` when absent — so present-but-zero ≠ absent). +pub fn compute_admin_key_mac(secret: &str, input: &AdminKeyMacInput) -> String { + let canonical = serde_json::json!([ + input.id.to_string(), + input.pubkey, + input.scope, + input.created_at.timestamp_micros(), + input.activated_at.map(|t| t.timestamp_micros()), + input.revoked_at.map(|t| t.timestamp_micros()), + ]) + .to_string(); + peppered_hex(secret, "admin_key_mac", &canonical) +} + +/// Constant-time check of a stored MAC against the recomputed one. +pub fn verify_admin_key_mac(secret: &str, input: &AdminKeyMacInput, mac_hex: &str) -> bool { + let expected = compute_admin_key_mac(secret, input); + expected.as_bytes().ct_eq(mac_hex.as_bytes()).into() +} + +#[cfg(test)] +mod tests { + use super::*; + + const PK: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn input() -> AdminKeyMacInput<'static> { + AdminKeyMacInput { + id: Uuid::from_u128(1), + pubkey: PK, + scope: "admin", + created_at: DateTime::from_timestamp(1_700_000_000, 0).unwrap(), + activated_at: None, + revoked_at: None, + } + } + + #[test] + fn deterministic_and_verifies() { + let i = input(); + let mac = compute_admin_key_mac("secret", &i); + assert!(verify_admin_key_mac("secret", &i, &mac)); + } + + #[test] + fn rejects_wrong_secret() { + let i = input(); + let mac = compute_admin_key_mac("secret-A", &i); + assert!(!verify_admin_key_mac("secret-B", &i, &mac)); + } + + #[test] + fn revocation_state_is_bound() { + // The MAC for a revoked key must not validate once revoked_at is cleared + // (the un-revoke attack), and vice versa. + let active = input(); + let mac_active = compute_admin_key_mac("secret", &active); + + let mut revoked = input(); + revoked.revoked_at = Some(DateTime::from_timestamp(1_700_000_500, 0).unwrap()); + assert!(!verify_admin_key_mac("secret", &revoked, &mac_active)); + + let mac_revoked = compute_admin_key_mac("secret", &revoked); + assert!(!verify_admin_key_mac("secret", &active, &mac_revoked)); + } + + #[test] + fn activation_state_is_bound() { + let pending = input(); + let mac_pending = compute_admin_key_mac("secret", &pending); + let mut activated = input(); + activated.activated_at = Some(DateTime::from_timestamp(1_700_000_100, 0).unwrap()); + assert!(!verify_admin_key_mac("secret", &activated, &mac_pending)); + } +} diff --git a/src/core/crypto/mod.rs b/src/core/crypto/mod.rs index e073e1c..7748e6b 100644 --- a/src/core/crypto/mod.rs +++ b/src/core/crypto/mod.rs @@ -1,5 +1,6 @@ //! Cryptographic primitives: NIP-98 auth and wallet-signature verification. +pub mod admin_mac; pub mod nip98; pub mod pepper; pub mod signatures; diff --git a/src/infra/db/admin_keys.rs b/src/infra/db/admin_keys.rs new file mode 100644 index 0000000..b94d1f6 --- /dev/null +++ b/src/infra/db/admin_keys.rs @@ -0,0 +1,212 @@ +//! Admin allowlist queries. +//! +//! Every read of an active key re-verifies its integrity MAC, and every write +//! that changes a covered field recomputes it, so MAC handling cannot drift or +//! be forgotten by a caller. A MAC mismatch on read is treated as "not +//! authorized" (fail-closed) and logged as tamper evidence. Timestamps bound by +//! the MAC are truncated to microseconds (Postgres' resolution) so the value +//! signed at write equals the value read back. + +use chrono::{SubsecRound, Utc}; +use tracing::instrument; +use uuid::Uuid; + +use crate::core::crypto::admin_mac::{ + compute_admin_key_mac, verify_admin_key_mac, AdminKeyMacInput, +}; +use crate::error::AppError; +use crate::models::db::{AdminKey, NewAdminKey}; + +use super::{unique_violation_as, Database}; + +/// Explicit `admin_keys` columns (FromRow maps by name). +const COLS: &str = "id, pubkey, label, scope, created_at, created_by_kind, \ + activated_at, activation_deadline, revoked_at, last_used_at, integrity_mac"; + +fn mac_input<'a>(k: &'a AdminKey) -> AdminKeyMacInput<'a> { + AdminKeyMacInput { + id: k.id, + pubkey: &k.pubkey, + scope: &k.scope, + created_at: k.created_at, + activated_at: k.activated_at, + revoked_at: k.revoked_at, + } +} + +impl Database { + /// Add an allowlist entry (pending — `activated_at` NULL until first login). + /// Fails 409 if an active key with this pubkey already exists. + #[instrument(skip(self, secret))] + pub async fn create_admin_key( + &self, + input: NewAdminKey, + secret: &str, + ) -> Result { + let id = Uuid::new_v4(); + let created_at = Utc::now().trunc_subsecs(6); + let mac = compute_admin_key_mac( + secret, + &AdminKeyMacInput { + id, + pubkey: &input.pubkey, + scope: &input.scope, + created_at, + activated_at: None, + revoked_at: None, + }, + ); + let sql = format!( + "INSERT INTO admin_keys \ + (id, pubkey, label, scope, created_at, created_by_kind, activation_deadline, integrity_mac) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}" + ); + let key = sqlx::query_as::<_, AdminKey>(&sql) + .bind(id) + .bind(&input.pubkey) + .bind(&input.label) + .bind(&input.scope) + .bind(created_at) + .bind(&input.created_by_kind) + .bind(input.activation_deadline) + .bind(&mac) + .fetch_one(self.pool()) + .await + .map_err(unique_violation_as( + "an active admin key with this pubkey already exists", + ))?; + Ok(key) + } + + /// The active (non-revoked) allowlist entry for `pubkey`, IF its integrity + /// MAC verifies. A mismatch returns `None` (fail-closed) and logs tampering. + #[instrument(skip(self, secret))] + pub async fn get_active_admin_key( + &self, + pubkey: &str, + secret: &str, + ) -> Result, AppError> { + let sql = format!("SELECT {COLS} FROM admin_keys WHERE pubkey = $1 AND revoked_at IS NULL"); + let row = sqlx::query_as::<_, AdminKey>(&sql) + .bind(pubkey) + .fetch_optional(self.pool()) + .await?; + match row { + Some(key) if verify_admin_key_mac(secret, &mac_input(&key), &key.integrity_mac) => { + Ok(Some(key)) + } + Some(key) => { + tracing::error!( + admin_key_id = %key.id, + "admin_keys integrity MAC mismatch — row tampered; rejecting" + ); + Ok(None) + } + None => Ok(None), + } + } + + /// All allowlist entries (active, pending, revoked), newest first. + #[instrument(skip(self))] + pub async fn list_admin_keys(&self) -> Result, AppError> { + let sql = format!("SELECT {COLS} FROM admin_keys ORDER BY created_at DESC"); + let rows = sqlx::query_as::<_, AdminKey>(&sql) + .fetch_all(self.pool()) + .await?; + Ok(rows) + } + + /// Count live (active + pending) keys — backs the `ADMIN_MAX_KEYS` cap. + #[instrument(skip(self))] + pub async fn count_live_admin_keys(&self) -> Result { + let n = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM admin_keys WHERE revoked_at IS NULL", + ) + .fetch_one(self.pool()) + .await?; + Ok(n) + } + + /// Mark a pending key activated (first successful login). Recomputes the MAC. + /// Returns the updated row, or `None` if not pending (already active/revoked). + #[instrument(skip(self, secret))] + pub async fn activate_admin_key( + &self, + id: Uuid, + secret: &str, + ) -> Result, AppError> { + let mut tx = self.pool().begin().await?; + let sel = format!( + "SELECT {COLS} FROM admin_keys \ + WHERE id = $1 AND revoked_at IS NULL AND activated_at IS NULL FOR UPDATE" + ); + let Some(key) = sqlx::query_as::<_, AdminKey>(&sel) + .bind(id) + .fetch_optional(&mut *tx) + .await? + else { + tx.rollback().await?; + return Ok(None); + }; + let activated_at = Utc::now().trunc_subsecs(6); + let mac = compute_admin_key_mac( + secret, + &AdminKeyMacInput { + activated_at: Some(activated_at), + ..mac_input(&key) + }, + ); + let upd = format!( + "UPDATE admin_keys SET activated_at = $2, integrity_mac = $3 WHERE id = $1 RETURNING {COLS}" + ); + let updated = sqlx::query_as::<_, AdminKey>(&upd) + .bind(id) + .bind(activated_at) + .bind(&mac) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(updated)) + } + + /// Soft-revoke a key (recomputing the MAC over the new `revoked_at`). Returns + /// the updated row, or `None` if it was already revoked / does not exist. + #[instrument(skip(self, secret))] + pub async fn revoke_admin_key( + &self, + id: Uuid, + secret: &str, + ) -> Result, AppError> { + let mut tx = self.pool().begin().await?; + let sel = format!( + "SELECT {COLS} FROM admin_keys WHERE id = $1 AND revoked_at IS NULL FOR UPDATE" + ); + let Some(key) = sqlx::query_as::<_, AdminKey>(&sel) + .bind(id) + .fetch_optional(&mut *tx) + .await? + else { + tx.rollback().await?; + return Ok(None); + }; + let revoked_at = Utc::now().trunc_subsecs(6); + let mac = compute_admin_key_mac( + secret, + &AdminKeyMacInput { + revoked_at: Some(revoked_at), + ..mac_input(&key) + }, + ); + let upd = format!( + "UPDATE admin_keys SET revoked_at = $2, integrity_mac = $3 WHERE id = $1 RETURNING {COLS}" + ); + let updated = sqlx::query_as::<_, AdminKey>(&upd) + .bind(id) + .bind(revoked_at) + .bind(&mac) + .fetch_one(&mut *tx) + .await?; + tx.commit().await?; + Ok(Some(updated)) + } +} diff --git a/src/infra/db/mod.rs b/src/infra/db/mod.rs index 186fb5b..338accd 100644 --- a/src/infra/db/mod.rs +++ b/src/infra/db/mod.rs @@ -6,6 +6,7 @@ //! `seed_fingerprint`) are peppered inside these methods so the plaintext never //! reaches a column and the peppering cannot drift between call sites. +mod admin_keys; mod audit; mod challenges; mod grin_slatepacks; diff --git a/src/models/db.rs b/src/models/db.rs index b1fc8e9..d8265fd 100644 --- a/src/models/db.rs +++ b/src/models/db.rs @@ -180,6 +180,41 @@ pub struct AuditLog { pub created_at: DateTime, } +/// An admin allowlist entry. The server holds only the pubkey; `integrity_mac` +/// binds the authorization-bearing fields so a DB-write attacker cannot silently +/// un-revoke or revive a key. Never `Serialize` to a public surface. +#[derive(Debug, Clone, FromRow)] +pub struct AdminKey { + pub id: Uuid, + pub pubkey: String, + pub label: Option, + pub scope: String, + pub created_at: DateTime, + pub created_by_kind: String, + /// `NULL` while pending (first login not yet completed). + pub activated_at: Option>, + pub activation_deadline: Option>, + pub revoked_at: Option>, + pub last_used_at: Option>, + pub integrity_mac: String, +} + +/// An admin session backing a short-lived admin access token. Separate table and +/// secret from user `sessions` so the two token kinds can never be interchanged. +#[derive(Debug, Clone, FromRow)] +pub struct AdminSession { + pub id: Uuid, + pub admin_key_id: Uuid, + pub pubkey: String, + pub refresh_token_hash: String, + pub access_jti: String, + pub device_info: Option, + pub ip_address: Option, + pub created_at: DateTime, + pub expires_at: DateTime, + pub revoked_at: Option>, +} + // ── Input structs (no auto-generated fields) ──────────────────────────────── /// Input for creating a new user. Peppered values are supplied by the db layer. @@ -234,6 +269,17 @@ pub struct NewGrinSlatepack { pub expires_at: DateTime, } +/// Input for adding an admin allowlist entry. The `id`, timestamps, and +/// `integrity_mac` are computed by the db layer. +#[derive(Debug, Clone)] +pub struct NewAdminKey { + pub pubkey: String, + pub label: Option, + pub scope: String, + pub created_by_kind: String, + pub activation_deadline: Option>, +} + #[derive(Debug, Clone)] pub struct NewAuditLog { pub user_id: Option, diff --git a/tests/admin_keys.rs b/tests/admin_keys.rs new file mode 100644 index 0000000..9ecc00b --- /dev/null +++ b/tests/admin_keys.rs @@ -0,0 +1,134 @@ +//! L1 integration: the admin allowlist + integrity MAC against real Postgres. +//! The load-bearing test is `tampered_revoke_flip_is_rejected`: a DB-write +//! attacker who clears `revoked_at` via raw SQL must not resurrect the key. + +mod common; + +use smirk_backend_core::models::db::NewAdminKey; +use uuid::Uuid; + +const SECRET: &str = "admin-key-integrity-secret-0123456789ab"; + +fn new_key(pubkey: &str) -> NewAdminKey { + NewAdminKey { + pubkey: pubkey.to_string(), + label: Some("test".into()), + scope: "admin".into(), + created_by_kind: "cli".into(), + activation_deadline: None, + } +} + +#[tokio::test] +async fn create_then_active_lookup_verifies_mac() { + let app = require_app!(); + let db = &app.state.db; + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + + let created = db.create_admin_key(new_key(&pk), SECRET).await.unwrap(); + assert!(created.activated_at.is_none(), "starts pending"); + + let active = db + .get_active_admin_key(&pk, SECRET) + .await + .unwrap() + .expect("active key with valid MAC"); + assert_eq!(active.id, created.id); + + // A different integrity secret must NOT validate the row. + assert!( + db.get_active_admin_key(&pk, "WRONG-SECRET") + .await + .unwrap() + .is_none(), + "wrong secret rejected" + ); +} + +#[tokio::test] +async fn duplicate_active_pubkey_conflicts() { + let app = require_app!(); + let db = &app.state.db; + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + + db.create_admin_key(new_key(&pk), SECRET).await.unwrap(); + let err = db.create_admin_key(new_key(&pk), SECRET).await.unwrap_err(); + assert!( + matches!(err, smirk_backend_core::error::AppError::Conflict(_)), + "second active key for the same pubkey is a conflict" + ); +} + +#[tokio::test] +async fn revoke_removes_from_active_set() { + let app = require_app!(); + let db = &app.state.db; + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + + let key = db.create_admin_key(new_key(&pk), SECRET).await.unwrap(); + let revoked = db.revoke_admin_key(key.id, SECRET).await.unwrap().unwrap(); + assert!(revoked.revoked_at.is_some()); + assert!(db + .get_active_admin_key(&pk, SECRET) + .await + .unwrap() + .is_none()); + // Re-revoking is a no-op (already revoked). + assert!(db.revoke_admin_key(key.id, SECRET).await.unwrap().is_none()); + // After revoke the pubkey is free for a fresh active key (partial index). + assert!(db.create_admin_key(new_key(&pk), SECRET).await.is_ok()); +} + +#[tokio::test] +async fn activate_flips_pending_once() { + let app = require_app!(); + let db = &app.state.db; + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + + let key = db.create_admin_key(new_key(&pk), SECRET).await.unwrap(); + let activated = db + .activate_admin_key(key.id, SECRET) + .await + .unwrap() + .unwrap(); + assert!(activated.activated_at.is_some()); + // Still MAC-valid after the activation rewrite. + assert!(db + .get_active_admin_key(&pk, SECRET) + .await + .unwrap() + .is_some()); + // Activating again is a no-op (no longer pending). + assert!(db + .activate_admin_key(key.id, SECRET) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn tampered_revoke_flip_is_rejected() { + let app = require_app!(); + let db = &app.state.db; + let pk = format!("{:0>64}", Uuid::new_v4().simple()); + + let key = db.create_admin_key(new_key(&pk), SECRET).await.unwrap(); + db.revoke_admin_key(key.id, SECRET).await.unwrap(); + + // Attacker clears revoked_at via raw SQL WITHOUT recomputing the MAC. + sqlx::query("UPDATE admin_keys SET revoked_at = NULL WHERE id = $1") + .bind(key.id) + .execute(db.pool()) + .await + .unwrap(); + + // The row is now "active" by column, but its MAC was computed for the + // revoked state, so the guard's lookup must reject it. + assert!( + db.get_active_admin_key(&pk, SECRET) + .await + .unwrap() + .is_none(), + "un-revoke tamper rejected by integrity MAC" + ); +}