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:
jwinterm
2026-06-26 06:36:55 -04:00
parent 49df1d1ae5
commit 4489c1c540
10 changed files with 754 additions and 8 deletions
+9 -1
View File
@@ -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![],