mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 15:31:38 +02:00
"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.
208 lines
6.8 KiB
Rust
208 lines
6.8 KiB
Rust
//! 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);
|
|
}
|
|
}
|