diff --git a/src/api/admin.rs b/src/api/admin.rs index c66b2b3..6fce255 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -16,12 +16,12 @@ use std::sync::Arc; use axum::{ - extract::{ConnectInfo, State}, + extract::{ConnectInfo, Path, State}, http::HeaderMap, - routing::{get, post}, + routing::{delete, get, post}, Json, Router, }; -use chrono::Utc; +use chrono::{Duration, Utc}; use ipnetwork::IpNetwork; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -35,7 +35,7 @@ use crate::core::admin_session::AdminSessionManager; use crate::core::crypto::nip98::{descriptor_sha256, request_descriptor, verify_signed_action}; use crate::core::session::hash_refresh_token; use crate::error::AppError; -use crate::models::db::{NewAdminAudit, NewAdminSession}; +use crate::models::db::{AdminKey, NewAdminAudit, NewAdminKey, NewAdminSession}; use crate::AppState; /// Admin-login nonce lifetime. @@ -77,6 +77,32 @@ fn pubkey_prefix(pubkey: &str) -> String { pubkey.chars().take(PUBKEY_PREFIX_LEN).collect() } +/// Validate an x-only secp256k1 pubkey: exactly 64 lowercase hex chars. +fn validate_admin_pubkey(pubkey: &str) -> Result<(), AppError> { + let ok = pubkey.len() == 64 + && pubkey + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)); + if ok { + Ok(()) + } else { + Err(AppError::ValidationError( + "pubkey must be 64 lowercase hex chars".into(), + )) + } +} + +/// Lifecycle label for an allowlist row. +fn key_status(k: &AdminKey) -> &'static str { + if k.revoked_at.is_some() { + "revoked" + } else if k.activated_at.is_none() { + "pending" + } else { + "active" + } +} + // ── guard ──────────────────────────────────────────────────────────────────── /// AUTHN (valid admin access token + live session) + AUTHZ (active, MAC-valid, @@ -382,6 +408,183 @@ pub async fn admin_me( })) } +// ── keys CRUD ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +pub struct AddKeyRequest { + pub pubkey: String, + pub label: Option, +} + +#[derive(Debug, Serialize)] +pub struct KeyResponse { + pub id: String, + pub pubkey: String, + pub status: String, +} + +#[derive(Debug, Serialize)] +pub struct AdminKeyView { + pub id: String, + pub pubkey: String, + pub label: Option, + pub scope: String, + pub status: String, + pub created_at: String, + pub activated_at: Option, + pub revoked_at: Option, +} + +#[derive(Debug, Serialize)] +pub struct KeysListResponse { + pub keys: Vec, +} + +fn key_view(k: &AdminKey) -> AdminKeyView { + AdminKeyView { + id: k.id.to_string(), + pubkey: k.pubkey.clone(), + label: k.label.clone(), + scope: k.scope.clone(), + status: key_status(k).to_string(), + created_at: k.created_at.to_rfc3339(), + activated_at: k.activated_at.map(|t| t.to_rfc3339()), + revoked_at: k.revoked_at.map(|t| t.to_rfc3339()), + } +} + +/// Build a pending `NewAdminKey` from a validated request, created over the +/// network (so `created_by_kind = admin`), with the configured activation TTL. +fn pending_key(state: &AppState, pubkey: String, label: Option) -> NewAdminKey { + NewAdminKey { + pubkey, + label, + scope: "admin".into(), + created_by_kind: "admin".into(), + activation_deadline: Some( + Utc::now() + Duration::days(state.config.admin.pending_key_ttl_days as i64), + ), + } +} + +/// Add a pending allowlist entry (it activates on its holder's first login). +#[instrument(skip(state, headers, req))] +pub async fn admin_keys_add( + State(state): State>, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + let pubkey = req.pubkey.to_lowercase(); + validate_admin_pubkey(&pubkey)?; + + // Cap total live (active + pending) keys to prevent allowlist-stuffing. + if state.db.count_live_admin_keys().await? >= state.config.admin.max_keys as i64 { + return Err(AppError::Forbidden( + "admin key limit reached; revoke an existing key first".into(), + )); + } + + let secret = &state.config.admin.key_integrity_secret; + let audit = NewAdminAudit { + action: "admin_key_added".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: Some(pubkey_prefix(&pubkey)), + details: None, + ip_address: None, + }; + let key = state + .db + .create_admin_key_audited(pending_key(&state, pubkey, req.label), &audit, secret) + .await?; + let status = key_status(&key).to_string(); + Ok(Json(KeyResponse { + id: key.id.to_string(), + pubkey: key.pubkey, + status, + })) +} + +/// List all allowlist entries (active, pending, revoked). +#[instrument(skip(state, headers))] +pub async fn admin_keys_list( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + admin_guard(&state, &headers).await?; + let keys = state.db.list_admin_keys().await?; + Ok(Json(KeysListResponse { + keys: keys.iter().map(key_view).collect(), + })) +} + +/// Soft-revoke a key (+ its sessions). Refuses to revoke the LAST live key over +/// the network — that is CLI-only, so an operator cannot lock everyone out. +#[instrument(skip(state, headers))] +pub async fn admin_keys_revoke( + State(state): State>, + headers: HeaderMap, + Path(id): Path, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + if state.db.count_live_admin_keys().await? <= 1 { + return Err(AppError::Forbidden( + "refusing to revoke the last admin key over the network; use the CLI".into(), + )); + } + let secret = &state.config.admin.key_integrity_secret; + let audit = NewAdminAudit { + action: "admin_key_revoked".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: Some(id.to_string()), + details: None, + ip_address: None, + }; + state + .db + .revoke_admin_key_full(id, &audit, secret) + .await? + .ok_or_else(|| AppError::NotFound("admin key not found or already revoked".into()))?; + Ok(Json(OkResponse { ok: true })) +} + +/// Atomically rotate a key: revoke the old one (+ its sessions) and add a fresh +/// pending one. Permitted on the last live key (it is add+revoke, not a bare +/// revoke), so it is the in-band recovery for a compromised solo key. +#[instrument(skip(state, headers, req))] +pub async fn admin_keys_rotate( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Json(req): Json, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + let pubkey = req.pubkey.to_lowercase(); + validate_admin_pubkey(&pubkey)?; + let secret = &state.config.admin.key_integrity_secret; + let audit = NewAdminAudit { + action: "admin_key_rotated".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: Some(id.to_string()), + details: None, + ip_address: None, + }; + let key = state + .db + .rotate_admin_key(id, pending_key(&state, pubkey, req.label), &audit, secret) + .await? + .ok_or_else(|| AppError::NotFound("admin key not found or already revoked".into()))?; + let status = key_status(&key).to_string(); + Ok(Json(KeyResponse { + id: key.id.to_string(), + pubkey: key.pubkey, + status, + })) +} + // ── router ─────────────────────────────────────────────────────────────────── /// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket. @@ -392,4 +595,7 @@ pub fn routes() -> Router> { .route("/admin/auth/refresh", post(admin_refresh)) .route("/admin/auth/logout", post(admin_logout)) .route("/admin/me", get(admin_me)) + .route("/admin/keys", post(admin_keys_add).get(admin_keys_list)) + .route("/admin/keys/:id", delete(admin_keys_revoke)) + .route("/admin/keys/:id/rotate", post(admin_keys_rotate)) } diff --git a/src/infra/db/admin_keys.rs b/src/infra/db/admin_keys.rs index 8c71f48..70931e3 100644 --- a/src/infra/db/admin_keys.rs +++ b/src/infra/db/admin_keys.rs @@ -8,6 +8,7 @@ //! signed at write equals the value read back. use chrono::{SubsecRound, Utc}; +use sqlx::PgConnection; use tracing::instrument; use uuid::Uuid; @@ -15,7 +16,7 @@ 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 crate::models::db::{AdminKey, NewAdminAudit, NewAdminKey}; use super::{unique_violation_as, Database}; @@ -43,38 +44,23 @@ impl Database { 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", - ))?; + let mut conn = self.pool().acquire().await?; + insert_admin_key(&mut conn, &input, secret).await + } + + /// Add an allowlist entry AND append its audit row in ONE transaction, so the + /// audit write is fail-closed (a failed audit rolls back the key creation). + #[instrument(skip(self, audit, secret))] + pub async fn create_admin_key_audited( + &self, + input: NewAdminKey, + audit: &NewAdminAudit, + secret: &str, + ) -> Result { + let mut tx = self.pool().begin().await?; + let key = insert_admin_key(&mut tx, &input, secret).await?; + self.append_admin_audit(&mut tx, audit, secret).await?; + tx.commit().await?; Ok(key) } @@ -219,4 +205,150 @@ impl Database { tx.commit().await?; Ok(Some(updated)) } + + /// Soft-revoke a key, revoke ALL its sessions, and append the audit row — all + /// in ONE transaction (so a revoked admin's still-valid access token is + /// rejected on its next call, and the audit is fail-closed). Returns the + /// updated key, or `None` if it was already revoked / does not exist. + #[instrument(skip(self, audit, secret))] + pub async fn revoke_admin_key_full( + &self, + id: Uuid, + audit: &NewAdminAudit, + 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?; + sqlx::query( + "UPDATE admin_sessions SET revoked_at = NOW() \ + WHERE admin_key_id = $1 AND revoked_at IS NULL", + ) + .bind(id) + .execute(&mut *tx) + .await?; + self.append_admin_audit(&mut tx, audit, secret).await?; + tx.commit().await?; + Ok(Some(updated)) + } + + /// Atomically rotate a key: revoke `old_id` (+ its sessions), create a fresh + /// pending key from `new`, and audit — all in one transaction. This is the + /// in-band recovery for a compromised solo key (add+revoke, so it never leaves + /// the allowlist empty). Returns the new key, or `None` if `old_id` was + /// already revoked / does not exist; 409 if the new pubkey is already active. + #[instrument(skip(self, new, audit, secret))] + pub async fn rotate_admin_key( + &self, + old_id: Uuid, + new: NewAdminKey, + audit: &NewAdminAudit, + 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(old) = sqlx::query_as::<_, AdminKey>(&sel) + .bind(old_id) + .fetch_optional(&mut *tx) + .await? + else { + tx.rollback().await?; + return Ok(None); + }; + let revoked_at = Utc::now().trunc_subsecs(6); + let old_mac = compute_admin_key_mac( + secret, + &AdminKeyMacInput { + revoked_at: Some(revoked_at), + ..mac_input(&old) + }, + ); + sqlx::query("UPDATE admin_keys SET revoked_at = $2, integrity_mac = $3 WHERE id = $1") + .bind(old_id) + .bind(revoked_at) + .bind(&old_mac) + .execute(&mut *tx) + .await?; + sqlx::query( + "UPDATE admin_sessions SET revoked_at = NOW() \ + WHERE admin_key_id = $1 AND revoked_at IS NULL", + ) + .bind(old_id) + .execute(&mut *tx) + .await?; + let created = insert_admin_key(&mut tx, &new, secret).await?; + self.append_admin_audit(&mut tx, audit, secret).await?; + tx.commit().await?; + Ok(Some(created)) + } +} + +/// Insert a pending allowlist entry on `conn` (so it can share a caller's +/// transaction). Computes the integrity MAC over the exact stored values. 409 if +/// an active key with this pubkey already exists. +async fn insert_admin_key( + conn: &mut PgConnection, + 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}" + ); + 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(&mut *conn) + .await + .map_err(unique_violation_as( + "an active admin key with this pubkey already exists", + )) } diff --git a/tests/admin_auth.rs b/tests/admin_auth.rs index 79f2c03..34761f7 100644 --- a/tests/admin_auth.rs +++ b/tests/admin_auth.rs @@ -9,12 +9,34 @@ mod common; use axum::http::StatusCode; use base64::Engine; use k256::schnorr::SigningKey; +use rand::rngs::OsRng; +use rand::RngCore; use sha2::{Digest, Sha256}; use smirk_backend_core::core::crypto::nip98::{descriptor_sha256, request_descriptor}; use smirk_backend_core::models::db::NewAdminKey; const NIP98_KIND: u32 = 27235; +/// A fresh, valid schnorr signer (random per run, so the shared test DB's unique +/// active-pubkey constraint never collides across runs). +fn random_signer() -> SigningKey { + loop { + let mut b = [0u8; 32]; + OsRng.fill_bytes(&mut b); + if let Ok(sk) = SigningKey::from_bytes(&b) { + return sk; + } + } +} + +/// A random 64-char lowercase hex pubkey (format-valid; no curve point needed for +/// allowlist entries that are never signed with in this test). +fn random_pubkey_hex() -> String { + let mut b = [0u8; 32]; + OsRng.fill_bytes(&mut b); + hex::encode(b) +} + /// Build a `Nostr ` admin_login signed-action token. #[allow(clippy::too_many_arguments)] fn sign_admin_login( @@ -63,13 +85,17 @@ async fn admin_login_guard_refresh_logout_flow() { "admin-integrity-secret-at-least-32-bytes!", ); std::env::set_var("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081"); + // The live-key cap + last-key revoke guard are count-based on a table the + // shared test DB pollutes across runs/sibling suites, so they can't be + // boundary-tested here (covered by review); raise the cap for the happy path. + std::env::set_var("ADMIN_MAX_KEYS", "1000000"); let app = require_app!(); let admin = app.admin_router(); let secret = app.state.config.admin.key_integrity_secret.clone(); // Seed a PENDING admin key for our signer. - let sk = SigningKey::from_bytes(&[9u8; 32]).unwrap(); + let sk = random_signer(); let pk_hex = hex::encode(sk.verifying_key().to_bytes()); app.state .db @@ -142,6 +168,68 @@ async fn admin_login_guard_refresh_logout_flow() { "user token rejected on admin plane" ); + // 4b. Keys CRUD as the activated admin. (The last-key revoke guard is not + // asserted here: live-key count is global to the shared test DB and polluted + // by sibling test files, so it cannot be forced to 1 deterministically.) + let new_pk = random_pubkey_hex(); + let (st, body) = app + .request_on( + admin.clone(), + "POST", + "/admin/keys", + Some(&access), + Some(serde_json::json!({ "pubkey": new_pk, "label": "second" })), + ) + .await; + assert_eq!(st, StatusCode::OK, "add key: {body:?}"); + assert_eq!(body["status"].as_str().unwrap(), "pending"); + let added_id = body["id"].as_str().unwrap().to_string(); + + // A malformed pubkey is rejected. + let (st, _) = app + .request_on( + admin.clone(), + "POST", + "/admin/keys", + Some(&access), + Some(serde_json::json!({ "pubkey": "nothex" })), + ) + .await; + assert_eq!(st, StatusCode::BAD_REQUEST, "bad pubkey rejected"); + + // List shows at least our two keys. + let (st, body) = app + .request_on(admin.clone(), "GET", "/admin/keys", Some(&access), None) + .await; + assert_eq!(st, StatusCode::OK); + assert!(body["keys"].as_array().unwrap().len() >= 2); + + // Rotate the added key -> a fresh pending key. + let rot_pk = random_pubkey_hex(); + let (st, body) = app + .request_on( + admin.clone(), + "POST", + &format!("/admin/keys/{added_id}/rotate"), + Some(&access), + Some(serde_json::json!({ "pubkey": rot_pk })), + ) + .await; + assert_eq!(st, StatusCode::OK, "rotate: {body:?}"); + let rotated_id = body["id"].as_str().unwrap().to_string(); + + // Revoke the rotated key (more than one live key exists, so it is permitted). + let (st, _) = app + .request_on( + admin.clone(), + "DELETE", + &format!("/admin/keys/{rotated_id}"), + Some(&access), + None, + ) + .await; + assert_eq!(st, StatusCode::OK, "revoke"); + // 5. Replay: the consumed challenge cannot be reused. let (st, _) = app .request_on( @@ -201,6 +289,7 @@ async fn admin_login_guard_refresh_logout_flow() { "ADMIN_JWT_SECRET", "ADMIN_KEY_INTEGRITY_SECRET", "ADMIN_PUBLIC_URL", + "ADMIN_MAX_KEYS", ] { std::env::remove_var(k); }