crates/monetize-ui-server/src/auth.rs
//! Passkey login, sessions, and the gate in front of everything else — ported
//! from gunnar-ui-server (`auth/mod.rs`, `auth/middleware.rs`, `auth/routes.rs`)
//! with the invite machinery left out. Every mutation under `/api/` is money
//! or a repair, and it — like enrolling a passkey — wants a STEP-UP: an admin
//! passkey touched within [`STEP_UP_SECS`] ([`needs_step_up`], [`strong`]).
//!
//! The ceremony is hand-written against the WebAuthn spec: ES256 over P-256
//! only, attestation deliberately unverified. Sessions are stateless: a
//! `user|role|exp|auth_time|uv|epoch` payload MAC'd with a persisted key;
//! bumping the epoch revokes every cookie at once, inside [`Webauthn::verify_cookie`].
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use base64::Engine as _;
use http::{HeaderMap, Method, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::app::AppState;
use crate::role::Role;
use crate::state::StateStore;
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
pub const SESSION_COOKIE: &str = "monetize_ui_session";
pub const SESSION_TTL_SECS: i64 = 12 * 3600;
const CHALLENGE_TTL: Duration = Duration::from_secs(300);
/// `login/start` is unauthenticated; at the cap the oldest ceremony is dropped.
const MAX_PENDING: usize = 1024;
pub const AUDIT_TARGET: &str = "monetize_ui::audit";
pub fn now_secs() -> i64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or_default()
}
/// **STEP-UP** (5.4 §3b): how old a passkey touch may be and still move
/// money, press a repair button or enrol a key (5 min). A session lives
/// [`SESSION_TTL_SECS`] for reading; acting needs a fresh touch.
pub const STEP_UP_SECS: i64 = 300;
/// The `code` of a step-up refusal: the console answers it with a passkey
/// touch and the same request again, never with "your role does not allow".
pub const STEP_UP_CODE: &str = "stepup";
/// Pure: is a session with this role, `uv` flag and `auth_time` strong at
/// `now_secs` — an ADMIN passkey, user-verified, touched within
/// [`STEP_UP_SECS`]? An `auth_time` in the future is wrong, not fresh.
pub fn strong(role: Role, uv: bool, auth_time: i64, now_secs: i64) -> bool {
let age = now_secs - auth_time;
role == Role::Admin && uv && (0..=STEP_UP_SECS).contains(&age)
}
/// **What wants a fresh touch, decided in ONE place**: every mutation under
/// `/api/` (all of them money or a repair — see [`role_floor`]) and enrolling
/// a passkey, which would otherwise let a stolen session outlive its theft.
pub fn needs_step_up(method: &Method, path: &str) -> bool {
(!matches!(*method, Method::GET | Method::HEAD) && path.starts_with("/api/")) || path.starts_with("/auth/passkey/register/")
}
/// Has this caller stepped up? A passkey session is judged by [`strong`];
/// the break-glass bearer is a token (the machine door) and stays one.
pub fn stepped_up(caller: &Caller, now_secs: i64) -> bool {
match caller {
Caller::Operator(s) => strong(s.role, s.uv, s.auth_time, now_secs),
Caller::AdminToken => true,
}
}
/// The 403 a weak session gets, as JSON the console can tell apart.
pub fn step_up_refusal() -> Response {
let message = format!("this moves money or keys: touch your admin passkey again (user-verified, within {} min) and it goes through", STEP_UP_SECS / 60);
(StatusCode::FORBIDDEN, Json(json!({ "code": STEP_UP_CODE, "message": message }))).into_response()
}
fn rand32() -> Vec<u8> {
let mut b = [0u8; 32];
b[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
b[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
b.to_vec()
}
/// Where the relying party lives, as the browser sees it.
#[derive(Debug, Clone)]
pub struct RelyingParty {
/// A registrable domain, never an IP literal.
pub rp_id: String,
/// The exact browser origin, scheme and port included.
pub origin: String,
pub rp_name: String,
}
impl RelyingParty {
/// `MONETIZE_UI_WEBAUTHN_RP_ID` unset ⇒ `None` ⇒ the passkey surface is off.
pub fn from_env() -> Result<Option<RelyingParty>, String> {
let env = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
let Some(rp_id) = env("MONETIZE_UI_WEBAUTHN_RP_ID") else { return Ok(None) };
if rp_id.parse::<std::net::IpAddr>().is_ok() {
return Err(format!("MONETIZE_UI_WEBAUTHN_RP_ID is {rp_id:?}; WebAuthn needs a domain — use \"localhost\" in development"));
}
let explicit = env("MONETIZE_UI_WEBAUTHN_ORIGIN");
if rp_id == "localhost" && explicit.is_none() {
return Err("MONETIZE_UI_WEBAUTHN_RP_ID is \"localhost\" but MONETIZE_UI_WEBAUTHN_ORIGIN is unset; set it to http://localhost:8091".into());
}
let origin = explicit.unwrap_or_else(|| format!("https://{rp_id}")).trim_end_matches('/').to_owned();
Ok(Some(RelyingParty { origin, rp_id, rp_name: env("MONETIZE_UI_WEBAUTHN_RP_NAME").unwrap_or_else(|| "monetize".to_owned()) }))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Purpose { Register, Login }
/// A ceremony in flight; a registration carries the operator handle it
/// enrols for, minted by the console, never sent back by the browser.
struct Pending { challenge_b64: String, purpose: Purpose, at: Instant, operator: Option<String> }
/// A registered passkey.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoredCred {
/// base64url credential id.
pub id: String,
/// ES256 public key coordinates, 32 bytes each.
pub x: Vec<u8>,
pub y: Vec<u8>,
pub sign_count: u32,
/// The device name, for listing and revoking — never a login.
pub label: String,
pub created: i64,
/// The OPERATOR this key signs in as: an opaque handle the console mints
/// at enrolment (`op-…`), whose bytes are the credential's WebAuthn user
/// handle. No account name exists (owner 2026-10-11).
pub user: String,
pub role: Role,
pub last_used: Option<i64>,
pub disabled: bool,
}
/// A parsed, MAC-verified, unexpired session.
#[derive(Debug, Clone)]
pub struct Session {
pub user: String,
pub role: Role,
pub auth_time: i64,
pub uv: bool,
epoch: u64,
}
pub struct Webauthn {
rp: RelyingParty,
/// The SHARED relying party: what `nornir_auth_webauthn::pure` checks every
/// ceremony against, built and validated once in [`Webauthn::new`].
pure_rp: nornir_auth_webauthn::pure::PureRelyingParty,
session_key: [u8; 32],
pending: Mutex<HashMap<String, Pending>>,
state: StateStore,
}
impl Webauthn {
pub fn new(rp: RelyingParty, state: StateStore) -> Result<Webauthn, String> {
let key: [u8; 32] = state.get_or_init("session_key", rand32)?.try_into().map_err(|_| "the stored session key is not 32 bytes".to_owned())?;
// An IP-literal rp_id, or an origin that is not the rp_id or a subdomain
// of it, is a console nobody can ever enrol a key against. Refused at
// BOOT, by name, rather than at the first login.
let pure_rp = nornir_auth_webauthn::pure::PureRelyingParty::new(&rp.rp_id, &rp.origin, &rp.rp_name)
.map_err(|e| format!("the relying party this console binds passkeys to is unusable: {e}"))?;
Ok(Webauthn { rp, pure_rp, session_key: key, pending: Mutex::new(HashMap::new()), state })
}
pub fn origin(&self) -> &str {
&self.rp.origin
}
fn current_epoch(&self) -> u64 {
self.state.get("session_epoch").ok().flatten().and_then(|b| b.try_into().ok()).map(u64::from_le_bytes).unwrap_or(0)
}
/// Invalidate every outstanding session cookie.
pub fn bump_epoch(&self) -> Result<u64, String> {
let next = self.current_epoch().wrapping_add(1);
self.state.put("session_epoch", &next.to_le_bytes())?;
Ok(next)
}
fn new_challenge(&self, purpose: Purpose, operator: Option<String>) -> (String, String) {
let challenge_b64 = B64.encode(rand32());
let id = uuid::Uuid::new_v4().to_string();
let mut pending = self.pending.lock().unwrap_or_else(|e| e.into_inner());
pending.retain(|_, p| p.at.elapsed() < CHALLENGE_TTL);
while pending.len() >= MAX_PENDING {
let Some(oldest) = pending.iter().min_by_key(|(_, p)| p.at).map(|(k, _)| k.clone()) else { break };
pending.remove(&oldest);
}
pending.insert(id.clone(), Pending { challenge_b64: challenge_b64.clone(), purpose, at: Instant::now(), operator });
(id, challenge_b64)
}
/// Single-use, unexpired, and for this purpose.
fn take_challenge(&self, id: &str, want: Purpose) -> Option<Pending> {
let p = self.pending.lock().unwrap_or_else(|e| e.into_inner()).remove(id)?;
(p.at.elapsed() < CHALLENGE_TTL && p.purpose == want).then_some(p)
}
fn parse_session(&self, cookie: &str) -> Option<Session> {
let (payload_b64, mac_b64) = cookie.split_once('.')?;
let payload = B64.decode(payload_b64).ok()?;
let mac: [u8; 32] = B64.decode(mac_b64).ok()?.try_into().ok()?;
// blake3::Hash compares in constant time.
if blake3::Hash::from_bytes(mac) != blake3::keyed_hash(&self.session_key, &payload) {
return None;
}
let text = std::str::from_utf8(&payload).ok()?;
let mut f = text.splitn(6, '|');
let user = f.next()?.to_owned();
let role: Role = f.next()?.parse().ok()?;
let expires_at: i64 = f.next()?.parse().ok()?;
let auth_time: i64 = f.next()?.parse().ok()?;
let uv = f.next()? == "1";
let epoch: u64 = f.next()?.parse().ok()?;
(expires_at > now_secs()).then_some(Session { user, role, auth_time, uv, epoch })
}
/// MAC, expiry, and the revocation epoch — every authorisation path goes
/// through here, so revocation needs no work at any call site.
pub fn verify_cookie(&self, cookie: &str) -> Option<Session> {
let s = self.parse_session(cookie)?;
(s.epoch == self.current_epoch()).then_some(s)
}
/// Mint a session cookie VALUE for `user` as `role`. Public so an offline
/// enrolment or a test rig can hold a session without an authenticator.
pub fn mint_session(&self, user: &str, role: Role, uv: bool) -> String {
self.mint_session_at(user, role, uv, now_secs())
}
/// [`Webauthn::mint_session`] for a touch made at `auth_time`; the
/// session still expires [`SESSION_TTL_SECS`] from now.
pub fn mint_session_at(&self, user: &str, role: Role, uv: bool, auth_time: i64) -> String {
let now = now_secs();
let payload = format!("{user}|{}|{}|{auth_time}|{}|{}", role.as_token(), now + SESSION_TTL_SECS, uv as u8, self.current_epoch());
let mac = blake3::keyed_hash(&self.session_key, payload.as_bytes());
format!("{}.{}", B64.encode(payload.as_bytes()), B64.encode(mac.as_bytes()))
}
fn load_creds(&self) -> Vec<StoredCred> {
self.state.get("passkeys").ok().flatten().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
fn save_creds(&self, creds: &[StoredCred]) -> Result<(), String> {
self.state.put("passkeys", &serde_json::to_vec(creds).map_err(|e| e.to_string())?)
}
pub fn credentials(&self) -> Vec<StoredCred> {
self.load_creds()
}
/// Store a credential enrolled outside the browser ceremony (a key whose
/// public half arrived by other means). Refuses a duplicate id.
pub fn adopt(&self, cred: StoredCred) -> Result<(), String> {
let mut creds = self.load_creds();
if creds.iter().any(|c| c.id == cred.id) {
return Err("this key is already registered".into());
}
creds.push(cred);
self.save_creds(&creds)
}
/// **Break-glass recovery** (the operator lost the passkey device): every
/// passkey DISABLED (kept: the record says who held what), every session
/// revoked, so the break-glass token enrols the first passkey again.
/// Answers how many were disabled. The door that asks it is the operator's
/// alone (monetize-admin's mutual-TLS door); nothing on the web face does.
pub fn reset_passkeys(&self) -> Result<usize, String> {
let mut creds = self.load_creds();
let n = creds.iter().filter(|c| !c.disabled).count();
for c in creds.iter_mut() {
c.disabled = true;
}
self.save_creds(&creds)?;
self.bump_epoch()?;
Ok(n)
}
/// What to call an operator on screen: the label of its oldest enabled
/// key (its handle is opaque), `None` when it holds none.
pub fn operator_label(&self, operator: &str) -> Option<String> {
self.load_creds().into_iter().filter(|c| !c.disabled && c.user == operator).min_by_key(|c| c.created).map(|c| c.label)
}
pub fn enabled_admin_count(&self) -> usize {
self.load_creds().iter().filter(|c| !c.disabled && c.role.allows(Role::Admin)).count()
}
/// The break-glass token solves exactly one problem — the first passkey,
/// when no admin exists to approve one — so the moment an enabled admin
/// passkey exists it is spent history.
pub fn admin_token_usable(&self) -> bool {
self.enabled_admin_count() == 0
}
// ── The ceremonies ─────────────────────────────────────────────────
/// Options for `navigator.credentials.create` — **no account name**
/// (owner 2026-10-11: "creepy, take that away, we don't have any legacy").
///
/// `operator` is the caller's own handle when an operator's session adds a
/// key (a spare joins its operator, and that operator's keys are excluded
/// so one authenticator does not hold two of them); `None` — the
/// break-glass token's first admin — mints a fresh handle. `label` is the
/// device name the authenticator's picker shows.
///
/// `residentKey: required` + `userVerification: required`: the key keeps
/// the passkey and its user handle itself, so a login offers no credential
/// id and the handle says who signed in. `credProps` lets
/// [`Webauthn::register_finish`] refuse a key that kept nothing.
pub fn register_options(&self, operator: Option<&str>, label: &str) -> Result<Value, String> {
let operator = match operator.map(str::trim).filter(|o| !o.is_empty()) {
Some(o) => o.to_owned(),
None => format!("op-{}", B64.encode(&rand32()[..16])),
};
let label = label.trim();
let name = if label.is_empty() { format!("{} operator", self.rp.rp_name) } else { label.to_owned() };
let exclude: Vec<Value> = self.load_creds().iter().filter(|c| c.user == operator).map(|c| json!({ "type": "public-key", "id": c.id })).collect();
let (ceremony_id, challenge) = self.new_challenge(Purpose::Register, Some(operator.clone()));
Ok(json!({
"ceremony_id": ceremony_id,
"publicKey": {
"rp": { "id": self.rp.rp_id, "name": self.rp.rp_name },
"user": { "id": B64.encode(operator.as_bytes()), "name": name, "displayName": name },
"challenge": challenge,
"pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
"authenticatorSelection": { "residentKey": "required", "requireResidentKey": true, "userVerification": "required" },
"extensions": { "credProps": true },
"timeout": 120000,
"attestation": "none",
"excludeCredentials": exclude,
}
}))
}
/// Verify a registration and store the credential for the operator the
/// ceremony was started for. A browser that reports the credential as
/// NOT discoverable (`credProps.rk == false`) is refused: such a key could
/// never sign in here.
pub fn register_finish(&self, body: &Value) -> Result<StoredCred, String> {
let ceremony_id = str_field(body, "ceremony_id")?;
let pending = self.take_challenge(ceremony_id, Purpose::Register).ok_or("this registration ceremony is unknown or has expired")?;
let operator = pending.operator.ok_or("this registration ceremony names no operator")?;
if body.pointer("/clientExtensionResults/credProps/rk").and_then(Value::as_bool) == Some(false) {
return Err("this key did not keep a discoverable passkey (it may be full) — sign-in here needs one".into());
}
let response = body.get("response").ok_or("the body has no response")?;
let client_data = b64_field(response, "clientDataJSON")?;
let attestation = b64_field(response, "attestationObject")?;
let parsed = nornir_auth_webauthn::pure::verify_registration(&self.pure_rp, &attestation, &client_data, &pending.challenge_b64, true)
.map_err(|e| e.to_string())?;
let role = match body.get("role").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) {
Some(r) => r.parse()?,
None => Role::Admin,
};
let cred = StoredCred {
id: parsed.id,
x: parsed.x,
y: parsed.y,
sign_count: parsed.sign_count,
label: body.get("label").and_then(|v| v.as_str()).map(str::trim).filter(|s| !s.is_empty()).unwrap_or("passkey").to_owned(),
created: now_secs(),
user: operator,
role,
last_used: None,
disabled: false,
};
self.adopt(cred.clone())?;
Ok(cred)
}
/// Options for `navigator.credentials.get`: **one button, no name** — an
/// empty `allowCredentials` (the authenticator offers the discoverable
/// passkey it keeps; nobody's credential id goes to a stranger) and user
/// verification required.
pub fn login_options(&self) -> Result<Value, String> {
if self.load_creds().iter().all(|c| c.disabled) {
return Err("no passkey is registered yet".into());
}
let (ceremony_id, challenge) = self.new_challenge(Purpose::Login, None);
Ok(json!({
"ceremony_id": ceremony_id,
"publicKey": { "challenge": challenge, "rpId": self.rp.rp_id, "timeout": 120000, "userVerification": "required", "allowCredentials": [] }
}))
}
/// Verify an assertion and mint a session. The credential is found by its
/// id and must answer with ITS operator's user handle, user-verified.
/// Returns `(cookie value, operator, role, key label)`.
pub fn login_finish(&self, body: &Value) -> Result<(String, String, Role, String), String> {
let ceremony_id = str_field(body, "ceremony_id")?;
let challenge = self.take_challenge(ceremony_id, Purpose::Login).ok_or("this login ceremony is unknown or has expired")?.challenge_b64;
let id = str_field(body, "id")?;
let response = body.get("response").ok_or("the body has no response")?;
let client_data = b64_field(response, "clientDataJSON")?;
let auth_data = b64_field(response, "authenticatorData")?;
let signature = b64_field(response, "signature")?;
let mut creds = self.load_creds();
let at = creds.iter().position(|c| c.id == id).ok_or("this credential is not registered here")?;
if creds[at].disabled {
return Err("this credential has been revoked".into());
}
// The user handle a discoverable credential answers with IS the
// operator. None (a key enrolled before 2026-10-11 under a name, which
// kept nothing) or another's: refused — enrol the key again.
let handle = match response.get("userHandle").and_then(Value::as_str).filter(|h| !h.is_empty()) {
Some(h) => B64.decode(h).map_err(|_| "userHandle is not base64url".to_owned())?,
None => return Err("this key answered with no user handle — it holds no discoverable passkey for this console; enrol it again".into()),
};
if handle != creds[at].user.as_bytes() {
return Err("this key's user handle is not the operator it was enrolled for".into());
}
// ONE verifier: ceremony type, challenge, origin, rpIdHash,
// user-presence, the ES256 signature over authData || SHA256(clientData),
// and the cloned-credential counter rule with zero exempt on both sides.
let stored = nornir_auth_webauthn::pure::RegisteredCredential {
id: creds[at].id.clone(),
x: creds[at].x.clone(),
y: creds[at].y.clone(),
sign_count: creds[at].sign_count,
};
let verified = nornir_auth_webauthn::pure::verify_assertion(&self.pure_rp, &stored, &client_data, &auth_data, &signature, &challenge, true)
.map_err(|e| e.to_string())?;
creds[at].sign_count = verified.sign_count;
creds[at].last_used = Some(now_secs());
let (user, role, label) = (creds[at].user.clone(), creds[at].role, creds[at].label.clone());
let uv = verified.user_verified;
self.save_creds(&creds)?;
Ok((self.mint_session(&user, role, uv), user, role, label))
}
}
fn str_field<'a>(v: &'a Value, key: &str) -> Result<&'a str, String> {
v.get(key).and_then(|v| v.as_str()).ok_or_else(|| format!("the body has no {key}"))
}
fn b64_field(v: &Value, key: &str) -> Result<Vec<u8>, String> {
B64.decode(str_field(v, key)?).map_err(|_| format!("{key} is not base64url"))
}
/// `Secure` holds even in development: browsers treat `http://localhost` as trustworthy.
pub fn session_cookie_str(value: &str, max_age: i64) -> String {
format!("{SESSION_COOKIE}={value}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age={max_age}")
}
/// **Every `monetize_ui_session` value the request carries**, across EVERY `Cookie` header field
/// (an HTTP/2 browser may split its cookies into several fields, RFC 9113 §8.2.3) and every pair:
/// the first field alone missed a session cookie that rode in a later one, and an older cookie of
/// the same name (another path or domain) came first and shadowed the good one.
pub fn session_cookies(headers: &HeaderMap) -> Vec<String> {
headers
.get_all(http::header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.trim().split_once('='))
.filter(|(name, _)| *name == SESSION_COOKIE)
.map(|(_, v)| v.to_owned())
.collect()
}
/// The first session that VERIFIES among [`session_cookies`].
pub fn session_of(webauthn: &Webauthn, headers: &HeaderMap) -> Option<Session> {
session_cookies(headers).iter().find_map(|c| webauthn.verify_cookie(c))
}
pub fn session_cookie(headers: &HeaderMap) -> Option<String> {
session_cookies(headers).into_iter().next()
}
// ── Ceremony crypto ────────────────────────────────────
//
// GONE. check_client_data, check_auth_data, parse_registration and
// verify_signature stood here and stood identically in holger-ui and in
// gunnar-ui. They are now one implementation, `nornir_auth_webauthn::pure`,
// published from edda and linked by all three consoles.
// ── The gate ──────────────────────────────────────────────────────────
/// Paths reachable without a session, matched exactly (`/login` is the SPA route
/// the shell serves before a session exists) — a route added under
/// `/auth/` tomorrow is gated until someone lists it here on purpose.
pub fn is_public(path: &str) -> bool {
matches!(
path,
"/health" | "/login" | "/auth/status" | "/auth/logout" | "/auth/passkey/login/start" | "/auth/passkey/login/finish" | "/auth/passkey/register/start" | "/auth/passkey/register/finish"
)
}
/// trunk emits `crossorigin="anonymous"` on its scripts and styles, so the
/// browser fetches them without the cookie. One leading slash and a listed
/// extension, nothing deeper — no console route is a single segment.
pub fn is_public_asset(method: &Method, path: &str) -> bool {
const EXT: &[&str] = &[".wasm", ".js", ".css", ".woff2", ".woff", ".ttf", ".ico", ".png", ".svg", ".webp", ".map"];
if !matches!(*method, Method::GET | Method::HEAD) {
return false;
}
let Some(name) = path.strip_prefix('/') else { return false };
!name.contains('/') && EXT.iter().any(|e| name.ends_with(e))
}
/// Who is calling, if anyone: a passkey session, or the break-glass bearer (always admin).
#[derive(Debug, Clone)]
pub enum Caller { Operator(Session), AdminToken }
impl Caller {
pub fn role(&self) -> Role {
match self { Caller::Operator(s) => s.role, Caller::AdminToken => Role::Admin }
}
pub fn name(&self) -> &str {
match self { Caller::Operator(s) => &s.user, Caller::AdminToken => "admin-token" }
}
}
/// Resolve the caller from a request's headers.
pub fn resolve(state: &AppState, headers: &HeaderMap) -> Option<Caller> {
let offered = headers.get(http::header::AUTHORIZATION).and_then(|v| v.to_str().ok()).and_then(|v| v.strip_prefix("Bearer ")).filter(|t| !t.is_empty());
if let (Some(configured), Some(offered)) = (state.admin_token.as_deref(), offered) {
if blake3::hash(configured.as_bytes()) == blake3::hash(offered.as_bytes()) {
if state.webauthn.as_deref().is_none_or(Webauthn::admin_token_usable) {
return Some(Caller::AdminToken);
}
tracing::warn!(target: AUDIT_TARGET, event = "auth.token_retired", "the admin token was offered and refused: an admin passkey exists");
}
}
let webauthn = state.webauthn.as_ref()?;
session_of(webauthn, headers).map(Caller::Operator)
}
/// The console's DEV-NARRATE routes (`/api/narrate/{estate}`, `…/{vm}`): admin only.
pub const NARRATE_PREFIX: &str = "/api/narrate/";
/// The account-wide Iron page's read (MAP-SEG 1): every estate's machines and disks, the
/// orphans and the missing — the operator's, an admin's.
pub const IRON_PATH: &str = "/api/iron";
/// **The role a request needs, decided in ONE place.** Reads are a viewer's
/// (a quote reserves nothing and is one); every mutation under `/api/` is an
/// admin's — an order, a refund, the operator `Entitlement.Set`, a backup — and
/// all of them are money. `/auth/…` routes carry their own gates. Anything
/// else that mutates is closed by default.
pub fn role_floor(method: &Method, path: &str) -> Role {
// A narrated site's boot logs (lane N2) are the operator's: an admin's, reads included.
if path.starts_with(NARRATE_PREFIX) {
return Role::Admin;
}
// The account's iron, every estate's (MAP-SEG 1): the operator's, an admin's, reads included.
if path == IRON_PATH {
return Role::Admin;
}
if matches!(*method, Method::GET | Method::HEAD) || path.starts_with("/auth/") {
return Role::Viewer;
}
Role::Admin
}
/// May the console run with NO credential at all, bound to `bind`? Only on
/// loopback; an unparseable bind is not confined.
pub fn open_console_is_confined(bind: &str) -> bool {
if let Ok(addr) = bind.parse::<std::net::SocketAddr>() {
return addr.ip().is_loopback();
}
matches!(bind.rsplit_once(':'), Some((host, _)) if host.eq_ignore_ascii_case("localhost"))
}
/// **Is this request a BROWSER NAVIGATION** (an address typed, a link
/// followed, a reload) rather than a fetch? `text/html` in `Accept`, or the
/// browser's own Fetch Metadata saying so (`Sec-Fetch-Mode: navigate`,
/// `Sec-Fetch-Dest: document`) — a navigation whose `Accept` is `*/*` is
/// still one (madmin-root-no-login-redirect, the owner on
/// monetize.ignalina.dk 2026-10-01: such a navigation was handed `401 log
/// in to use the console` instead of the login page). A fetch says `cors`/
/// `same-origin` and `empty`, and gets the 401 it can act on.
pub fn is_navigation(headers: &http::HeaderMap) -> bool {
let has = |name: &str, want: &str| headers.get(name).and_then(|v| v.to_str().ok()).is_some_and(|v| v.trim().eq_ignore_ascii_case(want));
headers.get(http::header::ACCEPT).and_then(|v| v.to_str().ok()).is_some_and(|a| a.contains("text/html")) || has("sec-fetch-mode", "navigate") || has("sec-fetch-dest", "document")
}
/// Refuse anything that is not public and has no caller. A browser navigation
/// is redirected to `/login`; a fetch gets a 401 it can act on.
pub async fn require_auth(State(state): State<AppState>, request: Request, next: Next) -> Response {
let path = request.uri().path().to_owned();
if is_public(&path) || is_public_asset(request.method(), &path) || state.is_open() {
return next.run(request).await;
}
match resolve(&state, request.headers()) {
Some(caller) => {
let needed = role_floor(request.method(), &path);
if !caller.role().allows(needed) {
return (StatusCode::FORBIDDEN, format!("this needs the {needed} role and your session is {}", caller.role())).into_response();
}
if needs_step_up(request.method(), &path) && !stepped_up(&caller, now_secs()) {
tracing::info!(target: AUDIT_TARGET, event = "auth.step_up_wanted", user = caller.name(), path = %path, "a weak session was asked for a fresh touch");
return step_up_refusal();
}
let mut request = request;
request.extensions_mut().insert(caller);
next.run(request).await
}
None if state.passkey_enabled() && is_navigation(request.headers()) => Redirect::to("/login").into_response(),
None => (StatusCode::UNAUTHORIZED, "log in to use the console").into_response(),
}
}
// ── The /auth routes ──────────────────────────────────────────────────
type Answer = Result<Json<Value>, (StatusCode, String)>;
fn bad(m: impl Into<String>) -> (StatusCode, String) {
(StatusCode::BAD_REQUEST, m.into())
}
fn passkey(state: &AppState) -> Result<&Webauthn, (StatusCode, String)> {
state.webauthn.as_deref().ok_or((StatusCode::NOT_FOUND, "passkey login is not enabled".into()))
}
fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<Caller, (StatusCode, String)> {
match resolve(state, headers) {
Some(c) if c.role().allows(Role::Admin) => Ok(c),
Some(_) => Err((StatusCode::FORBIDDEN, "this needs an admin passkey".into())),
None => Err((StatusCode::UNAUTHORIZED, "log in first".into())),
}
}
/// `GET /auth/status` — what the console needs before it decides to render.
pub async fn status(State(state): State<AppState>, headers: HeaderMap) -> Json<Value> {
let caller = resolve(&state, &headers);
// Who, on screen: an operator by its key's label (the handle is opaque).
let who = match &caller {
Some(Caller::Operator(s)) => state.webauthn.as_deref().and_then(|w| w.operator_label(&s.user)).unwrap_or_else(|| s.user.clone()),
Some(c) => c.name().to_owned(),
None => String::new(),
};
Json(json!({
"authenticated": caller.is_some(),
"user": who,
"role": caller.as_ref().map(|c| c.role().as_token()),
"passkey_enabled": state.passkey_enabled(),
"open": state.is_open(),
// Tells the login page whether "register the first admin" is still possible with the token.
"bootstrap": state.admin_token.is_some() && state.webauthn.as_deref().is_none_or(Webauthn::admin_token_usable),
// The signed-in operator already holds an enabled key here: the login page then offers
// no enrol form until "Add another key" is pressed (owner 2026-10-19).
"has_passkey": matches!(&caller, Some(Caller::Operator(s)) if state.webauthn.as_deref().is_some_and(|w| w.operator_label(&s.user).is_some())),
}))
}
pub async fn logout() -> Response {
([(http::header::SET_COOKIE, session_cookie_str("", 0))], Json(json!({ "ok": true }))).into_response()
}
/// `GET /auth/passkey/login/start` — no account, ever: the discoverable
/// ceremony ([`Webauthn::login_options`]).
pub async fn login_start(State(state): State<AppState>) -> Answer {
Ok(Json(passkey(&state)?.login_options().map_err(bad)?))
}
pub async fn login_finish(State(state): State<AppState>, Json(body): Json<Value>) -> Response {
let w = match passkey(&state) { Ok(w) => w, Err(e) => return e.into_response() };
match w.login_finish(&body) {
Ok((cookie, operator, role, label)) => {
tracing::info!(target: AUDIT_TARGET, event = "auth.login", operator = %operator, key = %label, role = %role, "an operator logged in");
([(http::header::SET_COOKIE, session_cookie_str(&cookie, SESSION_TTL_SECS))], Json(json!({ "ok": true, "operator": operator, "label": label, "role": role.as_token() }))).into_response()
}
Err(e) => {
tracing::warn!(target: AUDIT_TARGET, event = "auth.login_failed", reason = %e, "a login was refused");
(StatusCode::UNAUTHORIZED, e).into_response()
}
}
}
/// [`require_admin`], stepped up: an admin passkey touched within
/// [`STEP_UP_SECS`], or the break-glass token.
fn require_strong_admin(state: &AppState, headers: &HeaderMap) -> Result<Caller, Box<Response>> {
let caller = require_admin(state, headers).map_err(|e| Box::new(e.into_response()))?;
if !stepped_up(&caller, now_secs()) {
return Err(Box::new(step_up_refusal()));
}
Ok(caller)
}
/// `GET /auth/passkey/register/start[?label=<device>]` — an admin (or the
/// break-glass token) enrols keys, with a fresh touch ([`needs_step_up`]). No
/// name: the token's enrolment is a new operator, a session's joins its own.
pub async fn register_start(State(state): State<AppState>, headers: HeaderMap, axum::extract::Query(q): axum::extract::Query<HashMap<String, String>>) -> Result<Json<Value>, Response> {
let caller = require_strong_admin(&state, &headers).map_err(|r| *r)?;
let w = passkey(&state).map_err(IntoResponse::into_response)?;
let operator = match &caller { Caller::Operator(s) => Some(s.user.as_str()), Caller::AdminToken => None };
Ok(Json(w.register_options(operator, q.get("label").map(String::as_str).unwrap_or("")).map_err(|e| bad(e).into_response())?))
}
pub async fn register_finish(State(state): State<AppState>, headers: HeaderMap, Json(body): Json<Value>) -> Result<Json<Value>, Response> {
let caller = require_strong_admin(&state, &headers).map_err(|r| *r)?;
let cred = passkey(&state).map_err(IntoResponse::into_response)?.register_finish(&body).map_err(|e| bad(e).into_response())?;
tracing::info!(target: AUDIT_TARGET, event = "auth.register", by = caller.name(), operator = %cred.user, key = %cred.label, role = %cred.role, "a passkey was enrolled");
Ok(Json(json!({ "ok": true, "id": cred.id })))
}
pub async fn credentials(State(state): State<AppState>, headers: HeaderMap) -> Answer {
require_admin(&state, &headers)?;
let creds: Vec<Value> = passkey(&state)?.credentials().iter().map(|c| json!({ "id": c.id, "label": c.label, "operator": c.user, "role": c.role, "created": c.created, "last_used": c.last_used, "disabled": c.disabled })).collect();
Ok(Json(json!({ "credentials": creds })))
}
pub async fn sessions_revoke_all(State(state): State<AppState>, headers: HeaderMap) -> Answer {
require_admin(&state, &headers)?;
let epoch = passkey(&state)?.bump_epoch().map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
Ok(Json(json!({ "ok": true, "epoch": epoch })))
}
#[cfg(test)]
mod tests {
use super::*;
/// **Break-glass recovery**: every passkey is disabled and every session
/// revoked, and the break-glass token (the one for the FIRST passkey) is
/// usable again — the operator lost the device, and the console's SSH key
/// (which every estate knows) must not be re-minted to recover.
#[test]
fn a_reset_disables_every_passkey_revokes_every_session_and_rearms_the_token() {
let (w, _d) = test_webauthn();
w.adopt(StoredCred { id: "k1".into(), x: vec![0; 32], y: vec![0; 32], sign_count: 0, label: "laptop".into(), created: 0, user: "operator".into(), role: Role::Admin, last_used: None, disabled: false }).unwrap();
let cookie = w.mint_session("operator", Role::Admin, true);
assert!(!w.admin_token_usable() && w.verify_cookie(&cookie).is_some());
assert_eq!(w.reset_passkeys().unwrap(), 1);
assert!(w.admin_token_usable(), "the break-glass token enrols the first passkey again");
assert!(w.verify_cookie(&cookie).is_none(), "every session is gone");
assert!(w.credentials().iter().all(|c| c.disabled), "kept, disabled — the record says who held it");
}
/// ★ **A session cookie in a later Cookie field, or behind a stale one of the same name, still
/// signs in** (owner 2026-10-17: the login "worked" in Chrome and the console stayed logged out;
/// Firefox was fine).
#[test]
fn a_session_in_a_later_cookie_field_or_behind_a_stale_one_is_found() {
let (w, _d) = test_webauthn();
let good = w.mint_session("op", Role::Admin, true);
let stale = "c3RhbGU.c3RhbGU";
let mut h = HeaderMap::new();
h.append(http::header::COOKIE, "theme=dark".parse().unwrap());
h.append(http::header::COOKIE, format!("{SESSION_COOKIE}={good}").parse().unwrap());
assert_eq!(session_of(&w, &h).map(|s| s.user), Some("op".to_string()), "the second Cookie field");
let mut h = HeaderMap::new();
h.append(http::header::COOKIE, format!("{SESSION_COOKIE}={stale}; {SESSION_COOKIE}={good}").parse().unwrap());
assert_eq!(session_of(&w, &h).map(|s| s.user), Some("op".to_string()), "behind a stale one");
assert_eq!(session_cookies(&h).len(), 2);
let mut h = HeaderMap::new();
h.append(http::header::COOKIE, format!("{SESSION_COOKIE}={stale}").parse().unwrap());
assert!(session_of(&w, &h).is_none());
}
pub(crate) fn test_webauthn() -> (Webauthn, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let state = StateStore::open(dir.path().join("state.redb")).unwrap();
let rp = RelyingParty { rp_id: "console.example".into(), origin: "https://console.example".into(), rp_name: "monetize".into() };
(Webauthn::new(rp, state).unwrap(), dir)
}
#[test]
fn a_minted_session_verifies_a_tampered_one_does_not_and_the_epoch_revokes() {
let (w, _d) = test_webauthn();
let cookie = w.mint_session("bob", Role::Viewer, true);
let s = w.verify_cookie(&cookie).unwrap();
assert_eq!((s.user.as_str(), s.role, s.uv), ("bob", Role::Viewer, true));
let (payload, mac) = cookie.split_once('.').unwrap();
let text = String::from_utf8(B64.decode(payload).unwrap()).unwrap();
let forged = format!("{}.{mac}", B64.encode(text.replace("|viewer|", "|admin|")));
assert!(w.verify_cookie(&forged).is_none(), "promoting the role must break the MAC");
w.bump_epoch().unwrap();
assert!(w.verify_cookie(&cookie).is_none(), "the epoch revokes every cookie");
}
#[test]
fn the_admin_token_retires_once_an_admin_passkey_exists() {
let (w, _d) = test_webauthn();
assert!(w.admin_token_usable());
w.adopt(StoredCred { id: "k1".into(), x: vec![0; 32], y: vec![0; 32], sign_count: 0, label: "yubikey".into(), created: 0, user: "bob".into(), role: Role::Admin, last_used: None, disabled: false }).unwrap();
assert!(!w.admin_token_usable());
assert!(w.adopt(StoredCred { id: "k1".into(), x: vec![], y: vec![], sign_count: 0, label: String::new(), created: 0, user: "x".into(), role: Role::Viewer, last_used: None, disabled: false }).is_err());
}
#[test]
fn the_gate_tables_are_exact() {
assert!(is_public("/auth/passkey/login/start") && !is_public("/auth/passkey/credentials") && !is_public("/api/products"));
assert!(is_public_asset(&Method::GET, "/monetize-ui-web-abc_bg.wasm") && !is_public_asset(&Method::GET, "/api/x/a.js") && !is_public_asset(&Method::POST, "/a.js"));
assert_eq!(role_floor(&Method::GET, "/api/quote"), Role::Viewer, "a quote reserves nothing");
assert_eq!(role_floor(&Method::POST, "/api/purchase"), Role::Admin, "an order spends money");
assert_eq!(role_floor(&Method::POST, "/api/entitlement/gunnar/t"), Role::Admin);
assert_eq!(role_floor(&Method::POST, "/anything"), Role::Admin, "closed by default");
assert_eq!(role_floor(&Method::GET, "/api/narrate/test3-holger-rs"), Role::Admin, "a site's boot logs are an admin's, reads included");
assert_eq!(role_floor(&Method::GET, "/api/narrate/test3-holger-rs/main"), Role::Admin);
// MAP-SEG 1: the account's iron, every estate's, is the operator's; an estate's own part is a viewer's.
assert_eq!(role_floor(&Method::GET, IRON_PATH), Role::Admin, "the account-wide iron is an admin's");
assert_eq!(role_floor(&Method::GET, "/api/products/gunnar/tenants"), Role::Viewer);
assert_eq!(role_floor(&Method::POST, "/api/estate-orders/test3-holger-rs/settle"), Role::Admin, "Abandon this install (F2) is an admin's");
assert!(open_console_is_confined("127.0.0.1:8091") && open_console_is_confined("localhost:8091"));
assert!(!open_console_is_confined("0.0.0.0:8091") && !open_console_is_confined("garbage"));
}
/// ★ **What wants a fresh touch, decided in ONE place**: every mutation
/// under `/api/` and enrolling a passkey; no read, no login, no logout.
#[test]
fn the_step_up_table_is_exact() {
for p in ["/api/purchase", "/api/purchase/abandon", "/api/entitlement/gunnar/t", "/api/backup", "/api/guardian/acme/press"] {
assert!(needs_step_up(&Method::POST, p), "POST {p}");
}
assert!(needs_step_up(&Method::GET, "/auth/passkey/register/start") && needs_step_up(&Method::POST, "/auth/passkey/register/finish"));
assert!(!needs_step_up(&Method::GET, "/api/purchase/gunnar/t/2026-09-04") && !needs_step_up(&Method::GET, "/api/quote"));
for p in ["/auth/passkey/login/start", "/auth/passkey/login/finish", "/auth/logout", "/auth/status"] {
assert!(!needs_step_up(&Method::POST, p) && !needs_step_up(&Method::GET, p), "{p}");
}
let now = 1_000_000;
let (w, _d) = test_webauthn();
let fresh = w.verify_cookie(&w.mint_session("op", Role::Admin, true)).unwrap();
assert!(stepped_up(&Caller::Operator(fresh), now_secs()));
let old = w.verify_cookie(&w.mint_session_at("op", Role::Admin, true, now_secs() - STEP_UP_SECS - 1)).unwrap();
assert!(!stepped_up(&Caller::Operator(old), now_secs()));
assert!(stepped_up(&Caller::AdminToken, now), "the break-glass bearer is a token and stays one");
assert!(strong(Role::Admin, true, now - STEP_UP_SECS, now) && !strong(Role::Admin, true, now - STEP_UP_SECS - 1, now));
}
fn offered(v: &Value) -> Vec<String> {
v["publicKey"]["allowCredentials"].as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_owned()).collect()
}
fn held(w: &Webauthn, id: &str, operator: &str) {
w.adopt(StoredCred { id: B64.encode(id), x: vec![0; 32], y: vec![0; 32], sign_count: 0, label: "yubikey".into(), created: 0, user: operator.into(), role: Role::Admin, last_used: None, disabled: false }).unwrap();
}
/// ★ **A login names nobody** (owner 2026-10-11: "creepy, take that
/// away, we don't have any legacy"): `login/start` takes no account, offers
/// no credential id, wants user verification, and does not grow with the
/// keys — the authenticator answers with the discoverable passkey it keeps.
#[test]
fn a_login_names_nobody_offers_no_ones_passkey_and_wants_user_verification() {
let (w, _d) = test_webauthn();
held(&w, "rickards-yubikey", "op-rickard");
let one = w.login_options().unwrap();
assert!(offered(&one).is_empty(), "a login offers credential ids: {one}");
assert_eq!(one["publicKey"]["userVerification"], "required", "{one}");
for i in 0..40 {
held(&w, &format!("key-of-operator-{i:02}"), &format!("op-{i}"));
}
let many = w.login_options().unwrap();
assert_eq!(one["publicKey"]["allowCredentials"], many["publicKey"]["allowCredentials"]);
}
// ── A soft authenticator: the browser half, signed for real ────────
mod soft {
use super::super::B64;
use base64::Engine as _;
use p256::ecdsa::{signature::Signer, Signature, SigningKey};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
pub const UP: u8 = 0x01;
pub const UV: u8 = 0x04;
const AT: u8 = 0x40;
pub struct Key { pub sk: SigningKey, pub id: Vec<u8> }
pub fn key(seed: u8) -> Key {
Key { sk: SigningKey::from_slice(&[seed; 32]).unwrap(), id: vec![seed; 20] }
}
fn client_data(kind: &str, challenge: &str) -> Vec<u8> {
serde_json::to_vec(&json!({ "type": kind, "challenge": challenge, "origin": "https://console.example", "crossOrigin": false })).unwrap()
}
fn auth_data(flags: u8, count: u32, attested: Option<&Key>) -> Vec<u8> {
let mut out = Sha256::digest(b"console.example").to_vec();
out.push(flags);
out.extend_from_slice(&count.to_be_bytes());
if let Some(k) = attested {
out.extend_from_slice(&[0u8; 16]);
out.extend_from_slice(&(k.id.len() as u16).to_be_bytes());
out.extend_from_slice(&k.id);
let point = k.sk.verifying_key().to_sec1_point(false);
let (x, y) = point.as_bytes()[1..].split_at(32);
let cose = ciborium::value::Value::Map(vec![
(1.into(), 2.into()),
(3.into(), (-7).into()),
((-1).into(), 1.into()),
((-2).into(), ciborium::value::Value::Bytes(x.to_vec())),
((-3).into(), ciborium::value::Value::Bytes(y.to_vec())),
]);
ciborium::into_writer(&cose, &mut out).unwrap();
}
out
}
/// `navigator.credentials.create(...).toJSON()` for `options`, plus
/// what the console's page sends beside it.
pub fn create(k: &Key, options: &Value, rk: Option<bool>, label: &str) -> Value {
let challenge = options["publicKey"]["challenge"].as_str().unwrap();
let att = ciborium::value::Value::Map(vec![
("fmt".into(), "none".into()),
("attStmt".into(), ciborium::value::Value::Map(vec![])),
("authData".into(), ciborium::value::Value::Bytes(auth_data(UP | UV | AT, 0, Some(k)))),
]);
let mut att_bytes = Vec::new();
ciborium::into_writer(&att, &mut att_bytes).unwrap();
let ext = match rk { Some(rk) => json!({ "credProps": { "rk": rk } }), None => json!({}) };
json!({
"ceremony_id": options["ceremony_id"],
"response": { "clientDataJSON": B64.encode(client_data("webauthn.create", challenge)), "attestationObject": B64.encode(att_bytes) },
"clientExtensionResults": ext,
"label": label,
"role": "admin",
})
}
/// `navigator.credentials.get(...).toJSON()`: `handle` is the user
/// handle a discoverable credential answers with.
pub fn get(k: &Key, options: &Value, flags: u8, handle: Option<&[u8]>) -> Value {
let challenge = options["publicKey"]["challenge"].as_str().unwrap();
let cd = client_data("webauthn.get", challenge);
let ad = auth_data(flags, 0, None);
let mut msg = ad.clone();
msg.extend_from_slice(&Sha256::digest(&cd));
let sig: Signature = k.sk.sign(&msg);
let mut response = json!({ "clientDataJSON": B64.encode(&cd), "authenticatorData": B64.encode(&ad), "signature": B64.encode(sig.to_der().as_bytes()) });
if let Some(h) = handle {
response["userHandle"] = json!(B64.encode(h));
}
json!({ "ceremony_id": options["ceremony_id"], "id": B64.encode(&k.id), "response": response })
}
}
/// ★ **Enrolment asks for no name** (owner 2026-10-11): the first admin
/// enrols with the break-glass token and an optional device label; the
/// key must keep the passkey itself (`residentKey: required`) and verify
/// its user; every enrolment by the token is a NEW operator, an opaque
/// handle the console mints — no account name exists to type or guess.
#[test]
fn enrolment_asks_for_no_name_and_wants_a_discoverable_user_verified_key() {
let (w, _d) = test_webauthn();
let a = w.register_options(None, "blue yubikey").unwrap();
let b = w.register_options(None, "").unwrap();
let sel = &a["publicKey"]["authenticatorSelection"];
assert_eq!((sel["residentKey"].as_str(), sel["requireResidentKey"].as_bool(), sel["userVerification"].as_str()), (Some("required"), Some(true), Some("required")), "{a}");
assert_eq!(a["publicKey"]["extensions"]["credProps"], true, "{a}");
assert_ne!(a["publicKey"]["user"]["id"], b["publicKey"]["user"]["id"], "each token enrolment is its own operator");
assert_eq!(a["publicKey"]["user"]["name"], "blue yubikey", "the picker shows the device label");
assert!(b["publicKey"]["user"]["name"].as_str().is_some_and(|n| !n.is_empty()), "{b}");
assert!(offered(&json!({ "publicKey": { "allowCredentials": a["publicKey"]["excludeCredentials"] } })).is_empty());
}
/// ★ **The round trip, usernameless**: enrol with no name, sign in with
/// one button (no allowCredentials), and the credential's USER HANDLE
/// says who it is — the session is that operator's. A second key enrolled
/// by that operator's session joins the same operator.
#[test]
fn a_discoverable_key_signs_in_by_its_user_handle_and_a_later_key_joins_its_operator() {
let (w, _d) = test_webauthn();
let k = soft::key(7);
let opts = w.register_options(None, "blue yubikey").unwrap();
let handle = B64.decode(opts["publicKey"]["user"]["id"].as_str().unwrap()).unwrap();
let cred = w.register_finish(&soft::create(&k, &opts, Some(true), "blue yubikey")).unwrap();
assert_eq!(cred.user.as_bytes(), handle.as_slice(), "the stored operator IS the user handle");
assert_eq!(cred.label, "blue yubikey");
let login = w.login_options().unwrap();
let (cookie, operator, role, label) = w.login_finish(&soft::get(&k, &login, soft::UP | soft::UV, Some(&handle))).unwrap();
assert_eq!((operator.as_bytes(), role, label.as_str()), (handle.as_slice(), Role::Admin, "blue yubikey"));
let s = w.verify_cookie(&cookie).unwrap();
assert!(s.uv && s.user == operator);
// A later key, by that operator's session: the same handle, and the
// operator's own key is excluded (no second credential on one key).
let more = w.register_options(Some(&operator), "spare").unwrap();
assert_eq!(B64.decode(more["publicKey"]["user"]["id"].as_str().unwrap()).unwrap(), handle);
assert_eq!(more["publicKey"]["excludeCredentials"][0]["id"], json!(cred.id));
let k2 = soft::key(9);
assert_eq!(w.register_finish(&soft::create(&k2, &more, Some(true), "spare")).unwrap().user, operator);
}
/// ★ **No legacy**: a login whose credential answers with no user handle
/// (a key that keeps no discoverable passkey — every one enrolled under a
/// name before 2026-10-11), with someone else's handle, or without user
/// verification is refused; an enrolment the browser reports as NOT
/// discoverable is refused before it is stored.
#[test]
fn a_login_without_the_right_user_handle_or_verification_is_refused() {
let (w, _d) = test_webauthn();
let k = soft::key(7);
let opts = w.register_options(None, "").unwrap();
let handle = B64.decode(opts["publicKey"]["user"]["id"].as_str().unwrap()).unwrap();
w.register_finish(&soft::create(&k, &opts, None, "")).unwrap();
for (flags, h, why) in [(soft::UP | soft::UV, None, "no user handle"), (soft::UP | soft::UV, Some(b"op-somebody-else".as_slice()), "a wrong handle"), (soft::UP, Some(handle.as_slice()), "no user verification")] {
let login = w.login_options().unwrap();
assert!(w.login_finish(&soft::get(&k, &login, flags, h)).is_err(), "{why} must be refused");
}
let login = w.login_options().unwrap();
assert!(w.login_finish(&soft::get(&k, &login, soft::UP | soft::UV, Some(&handle))).is_ok(), "the control signs in");
let not_resident = w.register_options(None, "").unwrap();
let err = w.register_finish(&soft::create(&soft::key(3), ¬_resident, Some(false), "")).unwrap_err();
assert!(err.contains("discoverable"), "{err}");
assert_eq!(w.credentials().len(), 1, "nothing stored");
}
#[test]
fn a_login_needs_a_registered_key_and_a_live_ceremony() {
let (w, _d) = test_webauthn();
assert_eq!(w.login_options().unwrap_err(), "no passkey is registered yet");
assert!(w.login_finish(&json!({ "ceremony_id": "nope", "id": "k" })).unwrap_err().contains("unknown or has expired"));
}
}
/// R5a: every passkey sign-in takes a challenge from this book. A panic that
/// once held it must not lock every operator out of the console until a
/// restart; the book holds whole challenges (one insert, one remove).
#[cfg(test)]
mod poisoned_pending_tests {
use super::*;
#[test]
fn a_poisoned_challenge_book_still_mints_and_takes() {
let (w, _d) = tests::test_webauthn();
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _held = w.pending.lock();
panic!("a panic while the challenges are held");
}));
assert!(w.pending.is_poisoned());
let (id, _) = w.new_challenge(Purpose::Login, None);
assert!(w.take_challenge(&id, Purpose::Login).is_some());
assert!(w.take_challenge(&id, Purpose::Login).is_none(), "single use");
}
}