mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
feat(admin): admin auth flow + guard + loopback plane [operator §1.1-1.4, §2.1]
"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.
This commit is contained in:
@@ -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<AppState>,
|
||||
headers: &HeaderMap,
|
||||
) -> Result<AdminContext, AppError> {
|
||||
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 <base64(event)>` 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<Arc<AppState>>,
|
||||
) -> Result<Json<AdminChallengeResponse>, 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<Arc<AppState>>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<AdminVerifyRequest>,
|
||||
) -> Result<Json<AdminTokenResponse>, 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<Arc<AppState>>,
|
||||
Json(req): Json<AdminRefreshRequest>,
|
||||
) -> Result<Json<AdminTokenResponse>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<OkResponse>, 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<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<AdminMeResponse>, 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<Arc<AppState>> {
|
||||
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))
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! HTTP API layer: handlers and the OpenAPI assembly.
|
||||
|
||||
pub mod admin;
|
||||
pub mod auth;
|
||||
pub mod capabilities;
|
||||
pub mod health;
|
||||
|
||||
+9
-1
@@ -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![],
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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<AdminSession, AppError> {
|
||||
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(
|
||||
|
||||
+19
@@ -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<RwLock<PriceSnapshot>>,
|
||||
/// 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<AdminSessionManager>,
|
||||
}
|
||||
|
||||
/// Assemble the full application router with every route mounted and state
|
||||
@@ -120,6 +124,21 @@ pub fn build_router(state: Arc<AppState>) -> 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<AppState>) -> 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.
|
||||
|
||||
+35
-2
@@ -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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::error!(error = %e, "admin plane server exited");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let app = build_router(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
@@ -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 <base64(event)>` 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);
|
||||
}
|
||||
}
|
||||
+27
-4
@@ -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<TestApp> {
|
||||
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<TestApp> {
|
||||
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<Value>,
|
||||
) -> (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<Value>,
|
||||
) -> (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() {
|
||||
|
||||
Reference in New Issue
Block a user