From 4489c1c54017af24b879a81e03c01747f768ce8f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 26 Jun 2026 06:36:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20admin=20auth=20flow=20+=20guard?= =?UTF-8?q?=20+=20loopback=20plane=20[operator=20=C2=A71.1-1.4,=20=C2=A72.?= =?UTF-8?q?1]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Sign in with Smirk" for operators. Auth is a NIP-98 signed action over a single-use nonce (AUTHN) + the MAC'd allowlist (AUTHZ), composed in admin_guard so a protected route physically cannot run without both. All rejections collapse to one opaque 401 (no enumeration oracle). - src/api/admin.rs: admin_guard (verify access token -> live session by jti -> live MAC-verified, activated allowlist entry) + AdminContext; handlers: - challenge: mint a 120s admin_login nonce + return the {url, instance_id} the wallet must bind. - verify: verify_signed_action (purpose/nonce/descriptor/instance) THEN consume the nonce (forged sig can't burn a victim's nonce; replay loses the race) -> authorize (pending key allowed for first login, which activates it) -> mint + persist session AND the login audit row in ONE tx (fail-closed) -> bump last_used. - refresh: re-run FULL authorization before minting (closes the "8h refresh is the real blast radius" gap) + constant-time refresh-hash bind + rotate jti so the old access token dies. - logout: revoke the session; audit. me: guarded whoami. - admin_router (lib): served on a SEPARATE loopback socket in main (confidentiality by socket, not middleware); no CORS layer (cross-origin browser calls blocked); warns if bound non-loopback (hard bind-guard lands with admin posture). Routes are intentionally absent from the public OpenAPI. - config: ADMIN_PUBLIC_URL (the signed-action u-tag base; validated when enabled). AppState.admin_sessions (Some only when enabled). AdminSessionManager.mint_access (refresh) + db helpers (create_admin_session_audited, touch_admin_key_last_used). - test harness: admin_router() + request_on(router); 1 L1 test exercising the full flow end-to-end incl. user-token rejection, replay rejection, jti rotation, and post-logout revocation. NOTE (for the upcoming S1 review): admin-login timing-oracle hardening (fixed-cost allowlist probe + jittered 401 floor, spec §1.3) is deferred — the loopback/onion exposure + single-use nonce + signature gate are the v1 boundary. Per-pubkey backoff (§0.5) also pending. --- src/api/admin.rs | 395 +++++++++++++++++++++++++++++++++ src/api/mod.rs | 1 + src/config.rs | 10 +- src/core/admin_session.rs | 19 ++ src/infra/db/admin_keys.rs | 10 + src/infra/db/admin_sessions.rs | 33 ++- src/lib.rs | 19 ++ src/main.rs | 37 ++- tests/admin_auth.rs | 207 +++++++++++++++++ tests/common/mod.rs | 31 ++- 10 files changed, 754 insertions(+), 8 deletions(-) create mode 100644 src/api/admin.rs create mode 100644 tests/admin_auth.rs diff --git a/src/api/admin.rs b/src/api/admin.rs new file mode 100644 index 0000000..c66b2b3 --- /dev/null +++ b/src/api/admin.rs @@ -0,0 +1,395 @@ +//! Admin authentication ("Sign in with Smirk") — loopback plane. +//! +//! Auth is a NIP-98 *signed action*: the admin proves control of a private key +//! over a server-issued single-use nonce (AUTHN), and is authorized by the MAC'd +//! allowlist (AUTHZ). The two are composed in [`admin_guard`] so a protected +//! handler physically cannot run without both. Every failure — bad signature, +//! good signature for a non-admin, missing session — collapses to the same +//! opaque `401`, so the surface is not an enumeration oracle. Tokens are minted +//! by [`crate::core::admin_session`] (distinct secret/audience); sessions and the +//! login audit row are written in one transaction (fail-closed). +//! +//! These routes are served only on the loopback admin plane (see +//! [`crate::admin_router`]); they are intentionally absent from the public +//! OpenAPI surface. + +use std::sync::Arc; + +use axum::{ + extract::{ConnectInfo, State}, + http::HeaderMap, + routing::{get, post}, + Json, Router, +}; +use chrono::Utc; +use ipnetwork::IpNetwork; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::net::SocketAddr; +use subtle::ConstantTimeEq; +use tracing::{info, instrument, warn}; +use uuid::Uuid; + +use crate::api::middleware::{bearer_token, client_ip}; +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::AppState; + +/// Admin-login nonce lifetime. +const ADMIN_CHALLENGE_TTL_SECS: i64 = 120; +/// Freshness window for the signed action (tight — state-change grade). +const ADMIN_ACTION_MAX_AGE_SECS: i64 = 30; +/// How many hex chars of an actor pubkey land in the audit trail (no full key). +const PUBKEY_PREFIX_LEN: usize = 16; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// One opaque failure for every admin-auth rejection (no enumeration oracle). +fn admin_auth_fail() -> AppError { + AppError::AuthError("Invalid admin credentials".into()) +} + +fn admin_manager(state: &AppState) -> Result<&AdminSessionManager, AppError> { + state + .admin_sessions + .as_ref() + .ok_or_else(|| AppError::NotFound("admin surface is not enabled".into())) +} + +/// The absolute URL the signed action's `u` tag must bind (config, never Host). +fn admin_verify_url(state: &AppState) -> String { + format!( + "{}/admin/auth/verify", + state.config.admin.public_url.trim_end_matches('/') + ) +} + +/// A stable per-instance id bound into the signed action, so a challenge signed +/// for this instance cannot be relayed to another. +fn admin_instance_id(state: &AppState) -> String { + hex::encode(Sha256::digest(state.config.admin.public_url.as_bytes()))[..16].to_string() +} + +fn pubkey_prefix(pubkey: &str) -> String { + pubkey.chars().take(PUBKEY_PREFIX_LEN).collect() +} + +// ── guard ──────────────────────────────────────────────────────────────────── + +/// AUTHN (valid admin access token + live session) + AUTHZ (active, MAC-valid, +/// activated allowlist entry), composed. Injected into protected handlers. +#[derive(Debug, Clone)] +pub struct AdminContext { + pub pubkey: String, + pub admin_key_id: Uuid, + pub session_id: Uuid, +} + +/// Resolve and authorize the admin behind a request, or a single opaque 401. +pub async fn admin_guard( + state: &Arc, + headers: &HeaderMap, +) -> Result { + let mgr = admin_manager(state).map_err(|_| admin_auth_fail())?; + let token = bearer_token(headers).map_err(|_| admin_auth_fail())?; + let info = mgr.verify_access(token).map_err(|_| admin_auth_fail())?; + + // Live session for this access token's jti (rejects revoked/expired). + let session = state + .db + .find_active_admin_session_by_jti(&info.jti) + .await? + .ok_or_else(admin_auth_fail)?; + + // Live, uncached allowlist re-check (MAC re-verified inside): must be active + // AND activated (a pending key is not yet authorized for protected routes). + let secret = &state.config.admin.key_integrity_secret; + let key = state + .db + .get_active_admin_key(&info.pubkey, secret) + .await? + .filter(|k| k.activated_at.is_some()) + .ok_or_else(admin_auth_fail)?; + + Ok(AdminContext { + pubkey: info.pubkey, + admin_key_id: key.id, + session_id: session.id, + }) +} + +// ── DTOs ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +pub struct AdminChallengeResponse { + /// The single-use nonce to bind in the signed action's `challenge` tag. + pub challenge: String, + /// The URL the signed action's `u` tag must equal. + pub url: String, + /// The instance id to bind in the `instance_id` tag. + pub instance_id: String, + pub expires_in: i64, +} + +#[derive(Debug, Deserialize)] +pub struct AdminVerifyRequest { + /// `Nostr ` signed-action token. + pub admin_token: String, + /// The nonce from the challenge (must equal the event's `challenge` tag). + pub challenge: String, +} + +#[derive(Debug, Serialize)] +pub struct AdminTokenResponse { + pub access_token: String, + pub refresh_token: String, + pub expires_in: i64, +} + +#[derive(Debug, Deserialize)] +pub struct AdminRefreshRequest { + pub refresh_token: String, +} + +#[derive(Debug, Serialize)] +pub struct AdminMeResponse { + pub pubkey: String, + pub admin_key_id: String, +} + +#[derive(Debug, Serialize)] +pub struct OkResponse { + pub ok: bool, +} + +// ── handlers ───────────────────────────────────────────────────────────────── + +/// Issue a single-use admin-login nonce + the binding descriptor the wallet signs. +#[instrument(skip(state))] +pub async fn admin_challenge( + State(state): State>, +) -> Result, AppError> { + admin_manager(&state)?; + let challenge = state + .db + .issue_challenge("admin_login", None, ADMIN_CHALLENGE_TTL_SECS) + .await?; + Ok(Json(AdminChallengeResponse { + challenge, + url: admin_verify_url(&state), + instance_id: admin_instance_id(&state), + expires_in: ADMIN_CHALLENGE_TTL_SECS, + })) +} + +/// Verify the signed action, consume the nonce, authorize, and mint a session. +#[instrument(skip(state, headers, peer, req))] +pub async fn admin_verify( + State(state): State>, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Json(req): Json, +) -> Result, AppError> { + let mgr = admin_manager(&state)?; + let ip = client_ip(&state, &headers, peer); + let secret = &state.config.admin.key_integrity_secret; + + // 1. Prove the signature + bindings (purpose/nonce/descriptor/instance). The + // descriptor binds the verify URL with an EMPTY body (the proof rides in the + // JSON body and cannot also be inside the thing it signs). + let url = admin_verify_url(&state); + let descriptor = request_descriptor("POST", "/admin/auth/verify", "", b""); + let payload = descriptor_sha256(&descriptor); + let instance_id = admin_instance_id(&state); + let pubkey = verify_signed_action( + &req.admin_token, + &url, + "POST", + "admin_login", + &req.challenge, + &payload, + None, + Some(&instance_id), + Utc::now().timestamp(), + ADMIN_ACTION_MAX_AGE_SECS, + ) + .map_err(|_| admin_auth_fail())?; + + // 2. Single-use: consume AFTER the signature checks, so a forged signature + // cannot burn a victim's nonce. A replay/expired nonce loses the race. + if state + .db + .consume_challenge(&req.challenge, "admin_login") + .await? + .is_none() + { + return Err(admin_auth_fail()); + } + + // 3. AUTHZ: a non-revoked, MAC-valid allowlist entry (pending allowed for the + // first login, which activates it). + let key = state + .db + .get_active_admin_key(&pubkey, secret) + .await? + .ok_or_else(admin_auth_fail)?; + if key.activated_at.is_none() { + if let Some(deadline) = key.activation_deadline { + if Utc::now() > deadline { + warn!(admin_key_id = %key.id, "pending admin key past activation deadline"); + return Err(admin_auth_fail()); + } + } + state.db.activate_admin_key(key.id, secret).await?; + } + + // 4. Mint + persist session + audit (audit shares the session's transaction). + let session_id = Uuid::new_v4(); + let pair = mgr.create_token_pair(&pubkey, session_id)?; + let refresh_hash = hash_refresh_token( + &pair.refresh_token, + &state.config.secrets.refresh_token_pepper, + ); + let audit = NewAdminAudit { + action: "admin_login".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&pubkey)), + target: None, + details: None, + ip_address: Some(IpNetwork::from(ip)), + }; + state + .db + .create_admin_session_audited( + NewAdminSession { + id: session_id, + admin_key_id: key.id, + pubkey: pubkey.clone(), + refresh_token_hash: refresh_hash, + access_jti: pair.access_jti.clone(), + device_info: None, + ip_address: Some(IpNetwork::from(ip)), + expires_at: Utc::now() + mgr.refresh_ttl(), + }, + &audit, + secret, + ) + .await?; + state.db.touch_admin_key_last_used(key.id).await?; + + info!(admin_key_id = %key.id, "admin authenticated"); + Ok(Json(AdminTokenResponse { + access_token: pair.access_token, + refresh_token: pair.refresh_token, + expires_in: pair.expires_in, + })) +} + +/// Re-run the FULL authorization, then mint a fresh access token (rotating the +/// session jti). A revoked admin's in-flight refresh token cannot mint anew. +#[instrument(skip(state, req))] +pub async fn admin_refresh( + State(state): State>, + Json(req): Json, +) -> Result, AppError> { + let mgr = admin_manager(&state)?; + let (pubkey, session_id) = mgr + .verify_refresh(&req.refresh_token) + .map_err(|_| admin_auth_fail())?; + + // Re-authorize against the live allowlist (closes the "8h refresh is the real + // blast radius" gap). + let secret = &state.config.admin.key_integrity_secret; + let key = state + .db + .get_active_admin_key(&pubkey, secret) + .await? + .filter(|k| k.activated_at.is_some()) + .ok_or_else(admin_auth_fail)?; + + // The session must still be live, and the presented refresh token must match + // the one bound to it (constant-time). + let session = state + .db + .find_active_admin_session_by_id(session_id) + .await? + .ok_or_else(admin_auth_fail)?; + let presented = hash_refresh_token( + &req.refresh_token, + &state.config.secrets.refresh_token_pepper, + ); + let hash_ok: bool = presented + .as_bytes() + .ct_eq(session.refresh_token_hash.as_bytes()) + .into(); + if !hash_ok || session.admin_key_id != key.id { + return Err(admin_auth_fail()); + } + + let (access_token, jti, expires_in) = mgr.mint_access_token(&pubkey)?; + state + .db + .rotate_admin_session_jti(session_id, &jti) + .await? + .ok_or_else(admin_auth_fail)?; + + Ok(Json(AdminTokenResponse { + access_token, + refresh_token: req.refresh_token, + expires_in, + })) +} + +/// Revoke the current admin session. +#[instrument(skip(state, headers))] +pub async fn admin_logout( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + state.db.revoke_admin_session(ctx.session_id).await?; + let secret = &state.config.admin.key_integrity_secret; + let _ = state + .db + .record_admin_audit( + &NewAdminAudit { + action: "admin_logout".into(), + actor_kind: "admin".into(), + actor_pubkey_prefix: Some(pubkey_prefix(&ctx.pubkey)), + target: None, + details: None, + ip_address: None, + }, + secret, + ) + .await; + Ok(Json(OkResponse { ok: true })) +} + +/// Whoami for the authenticated admin (also exercises the guard). +#[instrument(skip(state, headers))] +pub async fn admin_me( + State(state): State>, + headers: HeaderMap, +) -> Result, AppError> { + let ctx = admin_guard(&state, &headers).await?; + Ok(Json(AdminMeResponse { + pubkey: ctx.pubkey, + admin_key_id: ctx.admin_key_id.to_string(), + })) +} + +// ── router ─────────────────────────────────────────────────────────────────── + +/// Admin-plane routes. Mounted by [`crate::admin_router`] on the loopback socket. +pub fn routes() -> Router> { + Router::new() + .route("/admin/auth/challenge", post(admin_challenge)) + .route("/admin/auth/verify", post(admin_verify)) + .route("/admin/auth/refresh", post(admin_refresh)) + .route("/admin/auth/logout", post(admin_logout)) + .route("/admin/me", get(admin_me)) +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 6a74688..a4ba8de 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,6 @@ //! HTTP API layer: handlers and the OpenAPI assembly. +pub mod admin; pub mod auth; pub mod capabilities; pub mod health; diff --git a/src/config.rs b/src/config.rs index c098bff..2211c94 100644 --- a/src/config.rs +++ b/src/config.rs @@ -261,6 +261,10 @@ pub struct PowConfig { pub struct AdminConfig { pub enabled: bool, pub bind: String, + /// Absolute base URL the operator's wallet reaches the admin plane at (the + /// value the signed-action `u` tag is verified against — never the Host + /// header). Loopback by default; a Tor onion / SSH-tunnel URL in production. + pub public_url: String, pub jwt_secret: String, /// MAC secret protecting admin/setup trust anchors against DB tampering. pub key_integrity_secret: String, @@ -410,6 +414,7 @@ impl Config { admin: AdminConfig { enabled: env_bool("ADMIN_ENABLED", false), bind: env_or("ADMIN_BIND", "127.0.0.1:8081"), + public_url: env_or("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081"), jwt_secret: env_or("ADMIN_JWT_SECRET", ""), key_integrity_secret: env_or("ADMIN_KEY_INTEGRITY_SECRET", ""), pubkeys: env_list("ADMIN_PUBKEYS"), @@ -496,7 +501,7 @@ impl Config { require_secret("ALTCHA_HMAC_KEY", &self.pow.hmac_key, 32)?; } - // Admin surface: dedicated secrets when enabled. + // Admin surface: dedicated secrets + a real public URL when enabled. if self.admin.enabled { require_secret("ADMIN_JWT_SECRET", &self.admin.jwt_secret, 32)?; require_secret( @@ -504,6 +509,8 @@ impl Config { &self.admin.key_integrity_secret, 32, )?; + url::Url::parse(&self.admin.public_url) + .map_err(|_| cfg_err("ADMIN_PUBLIC_URL must be an absolute URL"))?; } // Fleet mode cannot rely on in-process challenge state. @@ -664,6 +671,7 @@ mod tests { admin: AdminConfig { enabled: false, bind: "127.0.0.1:8081".into(), + public_url: "http://127.0.0.1:8081".into(), jwt_secret: String::new(), key_integrity_secret: String::new(), pubkeys: vec![], diff --git a/src/core/admin_session.rs b/src/core/admin_session.rs index d9a4a60..696bdbe 100644 --- a/src/core/admin_session.rs +++ b/src/core/admin_session.rs @@ -139,6 +139,25 @@ impl AdminSessionManager { }) } + /// Mint a fresh access token (new `jti`) for an existing session, e.g. on + /// refresh. Returns `(token, jti, expires_in_secs)`; the caller rotates the + /// session's `access_jti` so the previous access token stops matching. + pub fn mint_access_token(&self, admin_pubkey: &str) -> Result<(String, String, i64), AppError> { + let now = Utc::now(); + let jti = Uuid::new_v4().to_string(); + let claims = AdminAccessClaims { + sub: admin_pubkey.to_string(), + aud: ADMIN_ACCESS_AUD.to_string(), + scope: ADMIN_SCOPE.to_string(), + jti: jti.clone(), + iat: now.timestamp(), + exp: (now + Duration::minutes(ACCESS_TTL_MINUTES)).timestamp(), + }; + let token = encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key) + .map_err(|e| AppError::Internal(format!("admin access token: {e}")))?; + Ok((token, jti, ACCESS_TTL_MINUTES * 60)) + } + /// Verify an admin refresh token, returning `(admin_pubkey, session_id)`. pub fn verify_refresh(&self, token: &str) -> Result<(String, Uuid), AppError> { let data = diff --git a/src/infra/db/admin_keys.rs b/src/infra/db/admin_keys.rs index b94d1f6..8c71f48 100644 --- a/src/infra/db/admin_keys.rs +++ b/src/infra/db/admin_keys.rs @@ -127,6 +127,16 @@ impl Database { Ok(n) } + /// Bump `last_used_at` (not covered by the integrity MAC, so no recompute). + #[instrument(skip(self))] + pub async fn touch_admin_key_last_used(&self, id: Uuid) -> Result<(), AppError> { + sqlx::query("UPDATE admin_keys SET last_used_at = NOW() WHERE id = $1") + .bind(id) + .execute(self.pool()) + .await?; + Ok(()) + } + /// 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))] diff --git a/src/infra/db/admin_sessions.rs b/src/infra/db/admin_sessions.rs index 8acb3d8..b7a4729 100644 --- a/src/infra/db/admin_sessions.rs +++ b/src/infra/db/admin_sessions.rs @@ -9,7 +9,7 @@ use tracing::instrument; use uuid::Uuid; use crate::error::AppError; -use crate::models::db::{AdminSession, NewAdminSession}; +use crate::models::db::{AdminSession, NewAdminAudit, NewAdminSession}; use super::Database; @@ -42,6 +42,37 @@ impl Database { Ok(s) } + /// Create a session AND append its login audit row in ONE transaction, so + /// the audit write is fail-closed: if it fails, the session is not created. + #[instrument(skip(self, session, audit, secret), fields(admin_key_id = %session.admin_key_id))] + pub async fn create_admin_session_audited( + &self, + session: NewAdminSession, + audit: &NewAdminAudit, + secret: &str, + ) -> Result { + let mut tx = self.pool().begin().await?; + let sql = format!( + "INSERT INTO admin_sessions \ + (id, admin_key_id, pubkey, refresh_token_hash, access_jti, device_info, ip_address, expires_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING {COLS}" + ); + let row = sqlx::query_as::<_, AdminSession>(&sql) + .bind(session.id) + .bind(session.admin_key_id) + .bind(&session.pubkey) + .bind(&session.refresh_token_hash) + .bind(&session.access_jti) + .bind(&session.device_info) + .bind(session.ip_address) + .bind(session.expires_at) + .fetch_one(&mut *tx) + .await?; + self.append_admin_audit(&mut tx, audit, secret).await?; + tx.commit().await?; + Ok(row) + } + /// The live session whose access token carries `jti` (not revoked/expired). #[instrument(skip(self, jti))] pub async fn find_active_admin_session_by_jti( diff --git a/src/lib.rs b/src/lib.rs index 83bd486..045c5c9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; use crate::config::Config; +use crate::core::admin_session::AdminSessionManager; use crate::core::session::{SessionManager, WebChallenge}; use crate::infra::chains::ChainClients; use crate::infra::db::Database; @@ -64,6 +65,9 @@ pub struct AppState { /// Latest fetched fiat prices, refreshed by a background task when the price /// feed is enabled. Stays empty (and `/prices` 404s) otherwise. pub prices: Arc>, + /// Admin token minter/verifier. `Some` only when the admin surface is + /// enabled; the admin plane and guard refuse all requests otherwise. + pub admin_sessions: Option, } /// Assemble the full application router with every route mounted and state @@ -120,6 +124,21 @@ pub fn build_router(state: Arc) -> Router { .layer(TraceLayer::new_for_http()) } +/// Assemble the admin-plane router. Served on a SEPARATE loopback socket (see +/// `main`), never merged into the public router: confidentiality is by socket, +/// not middleware ordering. Deliberately carries NO CORS layer (a browser's +/// cross-origin call to the loopback port gets no `Access-Control-Allow-Origin` +/// and is blocked); per-route `admin_guard` is the load-bearing control. +/// Subsystem 2 adds Host-allowlist + anti-clickjacking hardening on top. +pub fn admin_router(state: Arc) -> Router { + Router::new() + .merge(api::admin::routes()) + .with_state(state) + .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)) + .layer(TimeoutLayer::new(REQUEST_TIMEOUT)) + .layer(TraceLayer::new_for_http()) +} + /// Build the CORS layer. With no configured origins, allow any origin — safe /// because authentication is a Bearer token (no cookies), so a cross-origin /// request carries no ambient credentials. Credentials are never allowed. diff --git a/src/main.rs b/src/main.rs index 709d084..4511d9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,8 +9,9 @@ use std::sync::Arc; use sqlx::postgres::PgPoolOptions; use smirk_backend_core::{ - build_router, config::Config, core::session::SessionManager, infra::chains::ChainClients, - infra::db::Database, infra::prices, infra::prices::PriceSnapshot, AppState, + admin_router, build_router, config::Config, core::admin_session::AdminSessionManager, + core::session::SessionManager, infra::chains::ChainClients, infra::db::Database, infra::prices, + infra::prices::PriceSnapshot, AppState, }; #[tokio::main] @@ -40,6 +41,10 @@ async fn main() -> Result<(), Box> { let prices_cache = Arc::new(tokio::sync::RwLock::new(PriceSnapshot::empty( &config.features.prices_currency, ))); + let admin_sessions = config + .admin + .enabled + .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { config, db, @@ -47,6 +52,7 @@ async fn main() -> Result<(), Box> { chains, web_challenges: Arc::default(), prices: prices_cache, + admin_sessions, }); // Periodic GC of expired website-auth challenges. Bounds the single-node @@ -125,6 +131,33 @@ async fn main() -> Result<(), Box> { } } + // Admin plane: a SEPARATE loopback listener (confidentiality is by socket, + // not middleware ordering). The fail-closed non-loopback bind guard and the + // browser/Host hardening land with the admin-posture subsystem; for now bind + // to the configured (loopback-default) address and warn if it isn't local. + if state.config.admin.enabled { + let admin_addr = state.config.admin.bind.clone(); + if !admin_addr.starts_with("127.") && !admin_addr.starts_with("[::1]") { + tracing::warn!( + bind = %admin_addr, + "admin plane is not bound to loopback — ensure this is intentional" + ); + } + let admin_app = admin_router(state.clone()); + let admin_listener = tokio::net::TcpListener::bind(&admin_addr).await?; + tracing::info!("admin plane listening on http://{admin_addr}"); + tokio::spawn(async move { + if let Err(e) = axum::serve( + admin_listener, + admin_app.into_make_service_with_connect_info::(), + ) + .await + { + tracing::error!(error = %e, "admin plane server exited"); + } + }); + } + let app = build_router(state); let listener = tokio::net::TcpListener::bind(&addr).await?; diff --git a/tests/admin_auth.rs b/tests/admin_auth.rs new file mode 100644 index 0000000..79f2c03 --- /dev/null +++ b/tests/admin_auth.rs @@ -0,0 +1,207 @@ +//! L1 integration: the admin auth flow on the loopback plane — challenge → sign +//! → verify → guarded /admin/me → refresh (jti rotation) → logout → replay. +//! +//! One sequential test: it enables the admin surface via process env, so running +//! it alongside others in the same binary would race the config. + +mod common; + +use axum::http::StatusCode; +use base64::Engine; +use k256::schnorr::SigningKey; +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; + +/// Build a `Nostr ` admin_login signed-action token. +#[allow(clippy::too_many_arguments)] +fn sign_admin_login( + sk: &SigningKey, + url: &str, + challenge: &str, + payload: &str, + instance_id: &str, + created_at: i64, +) -> String { + let pk_hex = hex::encode(sk.verifying_key().to_bytes()); + let tags = vec![ + vec!["u".to_string(), url.to_string()], + vec!["method".to_string(), "POST".to_string()], + vec!["purpose".to_string(), "admin_login".to_string()], + vec!["challenge".to_string(), challenge.to_string()], + vec!["payload".to_string(), payload.to_string()], + vec!["instance_id".to_string(), instance_id.to_string()], + ]; + let serial = serde_json::to_string(&serde_json::json!([ + 0, pk_hex, created_at, NIP98_KIND, tags, "" + ])) + .unwrap(); + let id = hex::encode(Sha256::digest(serial.as_bytes())); + let sig = sk.sign_raw(&hex::decode(&id).unwrap(), &[0u8; 32]).unwrap(); + let ev = serde_json::json!({ + "id": id, "pubkey": pk_hex, "created_at": created_at, + "kind": NIP98_KIND, "tags": tags, "content": "", "sig": hex::encode(sig.to_bytes()) + }); + format!( + "Nostr {}", + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&ev).unwrap()) + ) +} + +#[tokio::test] +async fn admin_login_guard_refresh_logout_flow() { + // Enable the admin surface (must precede try_app's config load). + std::env::set_var("ADMIN_ENABLED", "true"); + std::env::set_var( + "ADMIN_JWT_SECRET", + "admin-jwt-secret-at-least-32-bytes-long!!", + ); + std::env::set_var( + "ADMIN_KEY_INTEGRITY_SECRET", + "admin-integrity-secret-at-least-32-bytes!", + ); + std::env::set_var("ADMIN_PUBLIC_URL", "http://127.0.0.1:8081"); + + 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 pk_hex = hex::encode(sk.verifying_key().to_bytes()); + app.state + .db + .create_admin_key( + NewAdminKey { + pubkey: pk_hex.clone(), + label: Some("test".into()), + scope: "admin".into(), + created_by_kind: "cli".into(), + activation_deadline: None, + }, + &secret, + ) + .await + .unwrap(); + + // 1. Challenge. + let (st, body) = app + .request_on(admin.clone(), "POST", "/admin/auth/challenge", None, None) + .await; + assert_eq!(st, StatusCode::OK); + let challenge = body["challenge"].as_str().unwrap().to_string(); + let url = body["url"].as_str().unwrap().to_string(); + let instance_id = body["instance_id"].as_str().unwrap().to_string(); + + // 2. Sign the admin_login action over the verify descriptor. + let payload = descriptor_sha256(&request_descriptor("POST", "/admin/auth/verify", "", b"")); + let token = sign_admin_login( + &sk, + &url, + &challenge, + &payload, + &instance_id, + chrono::Utc::now().timestamp(), + ); + + // 3. Verify -> tokens. + let (st, body) = app + .request_on( + admin.clone(), + "POST", + "/admin/auth/verify", + None, + Some(serde_json::json!({ "admin_token": token, "challenge": challenge })), + ) + .await; + assert_eq!(st, StatusCode::OK, "verify: {body:?}"); + let access = body["access_token"].as_str().unwrap().to_string(); + let refresh = body["refresh_token"].as_str().unwrap().to_string(); + + // 4. Guard: /admin/me works with the access token, fails without / with a user token. + let (st, body) = app + .request_on(admin.clone(), "GET", "/admin/me", Some(&access), None) + .await; + assert_eq!(st, StatusCode::OK); + assert_eq!(body["pubkey"].as_str().unwrap(), pk_hex); + + let (st, _) = app + .request_on(admin.clone(), "GET", "/admin/me", None, None) + .await; + assert_eq!(st, StatusCode::UNAUTHORIZED, "no token rejected"); + + let (_, user_access, _) = app.mint_session().await; + let (st, _) = app + .request_on(admin.clone(), "GET", "/admin/me", Some(&user_access), None) + .await; + assert_eq!( + st, + StatusCode::UNAUTHORIZED, + "user token rejected on admin plane" + ); + + // 5. Replay: the consumed challenge cannot be reused. + let (st, _) = app + .request_on( + admin.clone(), + "POST", + "/admin/auth/verify", + None, + Some(serde_json::json!({ "admin_token": token, "challenge": challenge })), + ) + .await; + assert_eq!(st, StatusCode::UNAUTHORIZED, "replayed challenge rejected"); + + // 6. Refresh rotates the access jti: old access token stops working, new one works. + let (st, body) = app + .request_on( + admin.clone(), + "POST", + "/admin/auth/refresh", + None, + Some(serde_json::json!({ "refresh_token": refresh })), + ) + .await; + assert_eq!(st, StatusCode::OK); + let access2 = body["access_token"].as_str().unwrap().to_string(); + + let (st, _) = app + .request_on(admin.clone(), "GET", "/admin/me", Some(&access), None) + .await; + assert_eq!( + st, + StatusCode::UNAUTHORIZED, + "old access token invalid after refresh" + ); + let (st, _) = app + .request_on(admin.clone(), "GET", "/admin/me", Some(&access2), None) + .await; + assert_eq!(st, StatusCode::OK, "new access token valid"); + + // 7. Logout revokes the session: the access token no longer authenticates. + let (st, _) = app + .request_on( + admin.clone(), + "POST", + "/admin/auth/logout", + Some(&access2), + None, + ) + .await; + assert_eq!(st, StatusCode::OK); + let (st, _) = app + .request_on(admin.clone(), "GET", "/admin/me", Some(&access2), None) + .await; + assert_eq!(st, StatusCode::UNAUTHORIZED, "session revoked after logout"); + + for k in [ + "ADMIN_ENABLED", + "ADMIN_JWT_SECRET", + "ADMIN_KEY_INTEGRITY_SECRET", + "ADMIN_PUBLIC_URL", + ] { + std::env::remove_var(k); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d044147..fbed58d 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -21,8 +21,8 @@ use tower::ServiceExt; use uuid::Uuid; use smirk_backend_core::{ - build_router, config::Config, core::session::SessionManager, infra::chains::ChainClients, - infra::db::Database, AppState, + build_router, config::Config, core::admin_session::AdminSessionManager, + core::session::SessionManager, infra::chains::ChainClients, infra::db::Database, AppState, }; static INIT_ENV: Once = Once::new(); @@ -73,6 +73,10 @@ pub async fn try_app() -> Option { let prices = Arc::new(tokio::sync::RwLock::new( smirk_backend_core::infra::prices::PriceSnapshot::empty(&config.features.prices_currency), )); + let admin_sessions = config + .admin + .enabled + .then(|| AdminSessionManager::new(&config.admin.jwt_secret)); let state = Arc::new(AppState { config, db, @@ -80,6 +84,7 @@ pub async fn try_app() -> Option { chains, web_challenges: Arc::default(), prices, + admin_sessions, }); Some(TestApp { @@ -108,13 +113,31 @@ pub struct TestApp { } impl TestApp { - /// Issue a JSON request and return `(status, parsed-body-or-Null)`. + /// The loopback admin-plane router (served separately from the public one). + pub fn admin_router(&self) -> Router { + smirk_backend_core::admin_router(self.state.clone()) + } + + /// Issue a JSON request against the public router. pub async fn request( &self, method: &str, uri: &str, token: Option<&str>, body: Option, + ) -> (StatusCode, Value) { + self.request_on(self.router.clone(), method, uri, token, body) + .await + } + + /// Issue a JSON request against an arbitrary router (e.g. the admin plane). + pub async fn request_on( + &self, + router: Router, + method: &str, + uri: &str, + token: Option<&str>, + body: Option, ) -> (StatusCode, Value) { // The per-IP rate limiter keys on ConnectInfo; in production `main` // supplies it via into_make_service_with_connect_info. oneshot requests @@ -138,7 +161,7 @@ impl TestApp { .unwrap(), None => builder.body(Body::empty()).unwrap(), }; - let resp = self.router.clone().oneshot(req).await.unwrap(); + let resp = router.oneshot(req).await.unwrap(); let status = resp.status(); let bytes = to_bytes(resp.into_body(), 1 << 20).await.unwrap(); let json = if bytes.is_empty() {