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:
+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![],
|
||||
|
||||
Reference in New Issue
Block a user