src/step_up.rs
//! **A stale touch, answered in place** (A28 M9; Gunnar's 16000a5, owner 2026-10-19).
//!
//! holger refuses a sensitive act (an invite, a repository token, revoking a key) when the
//! session's user-verified touch is older than ten minutes. The refusal is machine-readable:
//! a 403 whose JSON body carries `"code": "stepup"` on the passkey door, and the bracketed
//! code `[holger-token-7]` on the token door. It is not a role refusal: the person may do
//! this, and their touch is only too old.
//!
//! The console answers it right where the person is. It asks the key for a fresh,
//! user-verified touch (`/-/auth/passkey/step-up/start` and `…/finish`, the person's OWN
//! key, PIN or fingerprint), keeps the session holger mints again, and sends the refused call
//! ONCE more. No page, no "log in again". The check stays holger's and stays as strict: the
//! retry goes through only if holger accepts the fresh touch.
//!
//! The decisions are pure and host-tested here; the browser half ([`touch`]) is the
//! WebAuthn call and two door calls.
#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
use std::future::Future;
/// The step-up options: the person's own keys, user verification required.
pub const START_PATH: &str = "/-/auth/passkey/step-up/start";
/// The fresh touch; holger answers with a session minted again.
pub const FINISH_PATH: &str = "/-/auth/passkey/step-up/finish";
/// The passkey door's word for "a fresh touch is needed" (holger `passkey::STEP_UP_CODE`).
pub const STEP_UP_CODE: &str = "stepup";
/// The token door's step-up code (holger errcode `TOKEN_MINT_STEP_UP`).
pub const TOKEN_STEP_UP_CODE: &str = "holger-token-7";
/// What the person reads when the touch did not happen (cancelled, timed out, no key).
pub const NEEDS_TOUCH: &str =
"This needs a fresh touch of your key, confirmed with its PIN or fingerprint (from the last 10 minutes).";
/// Pure: **is this passkey-door answer holger's step-up refusal?** By its code, never by
/// its words.
pub fn is_step_up(status: u16, body: &str) -> bool {
status == 403
&& serde_json::from_str::<serde_json::Value>(body)
.ok()
.is_some_and(|v| v.get("code").and_then(|c| c.as_str()) == Some(STEP_UP_CODE))
}
/// Pure: is this token-door refusal holger's step-up (`[holger-token-7]`)?
pub fn is_token_step_up(r: &crate::security::RefusalView) -> bool {
r.code == TOKEN_STEP_UP_CODE
}
/// Pure: what a failed touch reads: [`NEEDS_TOUCH`], and the reason when there is one.
pub fn touch_failed_words(why: &str) -> String {
let why = why.trim();
if why.is_empty() {
NEEDS_TOUCH.to_owned()
} else {
format!("{NEEDS_TOUCH} {why}")
}
}
/// **The one decision**: send `call`; if holger refuses it for a stale touch (`is_step_up`),
/// ask for a fresh touch (`touch`) and send the same call ONCE more. A touch that fails
/// sends nothing again and answers `touch_failed(why)`. Any other refusal is answered as
/// it came, with no touch.
pub async fn answer_in_place<T, E, C, CF, Tc, TF>(
mut call: C,
is_step_up: impl Fn(&E) -> bool,
touch: Tc,
touch_failed: impl FnOnce(String) -> E,
) -> Result<T, E>
where
C: FnMut() -> CF,
CF: Future<Output = Result<T, E>>,
Tc: FnOnce() -> TF,
TF: Future<Output = Result<(), String>>,
{
match call().await {
Err(e) if is_step_up(&e) => match touch().await {
Ok(()) => call().await,
Err(why) => Err(touch_failed(why)),
},
answer => answer,
}
}
// ── the WebAuthn wire (pure) ───────────────────────────────────────────────
/// What holger's step-up options ask the key for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ask {
pub ceremony_id: String,
pub challenge: Vec<u8>,
pub rp_id: String,
pub timeout: u32,
/// The person's own credential ids.
pub allow: Vec<Vec<u8>>,
}
/// Pure: holger's `step-up/start` answer as the key's question. User verification is
/// always required, whatever the answer says: a step-up is never a plain touch.
pub fn ask_of(start: &serde_json::Value) -> Result<Ask, String> {
let pk = &start["publicKey"];
let ceremony_id = start["ceremony_id"].as_str().filter(|c| !c.is_empty()).ok_or("holger sent no step-up ceremony")?;
let challenge = pk["challenge"].as_str().and_then(b64url_decode).filter(|c| !c.is_empty()).ok_or("holger sent no challenge")?;
let allow = pk["allowCredentials"]
.as_array()
.map(|a| a.iter().filter_map(|c| c["id"].as_str().and_then(b64url_decode)).collect())
.unwrap_or_default();
Ok(Ask {
ceremony_id: ceremony_id.to_owned(),
challenge,
rp_id: pk["rpId"].as_str().unwrap_or_default().to_owned(),
timeout: pk["timeout"].as_u64().map_or(300_000, |t| t.min(600_000) as u32),
allow,
})
}
/// What the key answered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Assertion {
pub id: String,
pub client_data: Vec<u8>,
pub authenticator_data: Vec<u8>,
pub signature: Vec<u8>,
}
/// Pure: the `step-up/finish` body, the shape of the login page's own finish.
pub fn finish_body(ceremony_id: &str, a: &Assertion) -> serde_json::Value {
serde_json::json!({
"ceremony_id": ceremony_id,
"id": a.id,
"response": {
"clientDataJSON": b64url(&a.client_data),
"authenticatorData": b64url(&a.authenticator_data),
"signature": b64url(&a.signature),
},
})
}
/// Pure: did `step-up/finish` mint the session again? (Its token is the console server's
/// cookie by the time the answer reaches the page, A34-38: the page reads `signed_in`.)
pub fn fresh_session(status: u16, body: &str) -> Result<(), String> {
if status != 200 {
return Err(crate::session::refusal_words(status, body));
}
serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v["signed_in"].as_bool())
.filter(|s| *s)
.map(|_| ())
.ok_or_else(|| "holger answered the touch without a session".to_owned())
}
const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
/// Pure: base64url without padding.
pub fn b64url(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, b)| n | (u32::from(*b) << (16 - 8 * i)));
for i in 0..=chunk.len() {
out.push(B64URL[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
}
out
}
/// Pure: base64url, padded or not (`+` and `/` read too). `None` for anything else.
pub fn b64url_decode(s: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let (mut acc, mut bits) = (0u32, 0u32);
for c in s.trim_end_matches('=').bytes() {
let v = match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'-' | b'+' => 62,
b'_' | b'/' => 63,
_ => return None,
};
acc = (acc << 6) | u32::from(v);
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
// ── the browser half ───────────────────────────────────────────────────────
/// **The touch**: holger's options, the key (`navigator.credentials.get`, user verification
/// required), holger's finish; the session it mints again replaces this tab's. `Err`: the
/// reason, in words.
#[cfg(target_arch = "wasm32")]
pub async fn touch() -> Result<(), String> {
let (status, text) = crate::session::door(START_PATH, Some(&serde_json::json!({}))).await?;
if status != 200 {
return Err(crate::session::refusal_words(status, &text));
}
let start: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("holger's step-up options are unreadable: {e}"))?;
let ask = ask_of(&start)?;
let a = key_answer(&ask).await?;
let (status, text) = crate::session::door(FINISH_PATH, Some(&finish_body(&ask.ceremony_id, &a))).await?;
fresh_session(status, &text)
}
#[cfg(target_arch = "wasm32")]
async fn key_answer(ask: &Ask) -> Result<Assertion, String> {
use js_sys::{Array, Object, Reflect, Uint8Array};
use wasm_bindgen::{JsCast, JsValue};
let set = |o: &Object, k: &str, v: &JsValue| {
let _ = Reflect::set(o, &JsValue::from_str(k), v);
};
let pk = Object::new();
set(&pk, "challenge", &Uint8Array::from(ask.challenge.as_slice()).into());
if !ask.rp_id.is_empty() {
set(&pk, "rpId", &JsValue::from_str(&ask.rp_id));
}
set(&pk, "timeout", &JsValue::from(ask.timeout));
set(&pk, "userVerification", &JsValue::from_str("required"));
let allow = Array::new();
for id in &ask.allow {
let c = Object::new();
set(&c, "type", &JsValue::from_str("public-key"));
set(&c, "id", &Uint8Array::from(id.as_slice()).into());
allow.push(&c);
}
set(&pk, "allowCredentials", &allow);
let opts = Object::new();
set(&opts, "publicKey", &pk);
let window = web_sys::window().ok_or("no window")?;
let promise = window
.navigator()
.credentials()
.get_with_options(opts.unchecked_ref())
.map_err(|e| js_words(&e))?;
let cred = wasm_bindgen_futures::JsFuture::from(promise).await.map_err(|e| js_words(&e))?;
if cred.is_null() || cred.is_undefined() {
return Err("the key gave no answer".into());
}
let get = |o: &JsValue, k: &str| Reflect::get(o, &JsValue::from_str(k)).unwrap_or(JsValue::UNDEFINED);
let bytes = |v: JsValue| Uint8Array::new(&v).to_vec();
let response = get(&cred, "response");
Ok(Assertion {
id: get(&cred, "id").as_string().unwrap_or_default(),
client_data: bytes(get(&response, "clientDataJSON")),
authenticator_data: bytes(get(&response, "authenticatorData")),
signature: bytes(get(&response, "signature")),
})
}
/// A browser refusal in words: a cancelled or timed-out touch is `NotAllowedError`.
#[cfg(target_arch = "wasm32")]
fn js_words(e: &wasm_bindgen::JsValue) -> String {
let name = js_sys::Reflect::get(e, &"name".into()).ok().and_then(|n| n.as_string()).unwrap_or_default();
if name == "NotAllowedError" {
return "The touch was cancelled or timed out.".into();
}
js_sys::Reflect::get(e, &"message".into()).ok().and_then(|m| m.as_string()).unwrap_or_else(|| format!("{e:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn block_on<F: Future>(f: F) -> F::Output {
let mut f = std::pin::pin!(f);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
loop {
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
return v;
}
}
}
#[derive(Debug, PartialEq)]
enum E {
Stale,
Other(&'static str),
Touch(String),
}
/// Run `answer_in_place` over scripted answers: `(result, calls, touches)`.
fn run(answers: Vec<Result<u32, E>>, touch: Result<(), String>) -> (Result<u32, E>, usize, usize) {
let answers = std::cell::RefCell::new(answers.into_iter());
let (calls, touches) = (Cell::new(0), Cell::new(0));
let r = block_on(answer_in_place(
|| {
calls.set(calls.get() + 1);
let next = answers.borrow_mut().next().expect("no more answers scripted");
async move { next }
},
|e| *e == E::Stale,
|| {
touches.set(touches.get() + 1);
async move { touch }
},
E::Touch,
));
(r, calls.get(), touches.get())
}
/// ★ A28 M9: a stale touch triggers the touch in place, and the refused call is sent
/// once more — no page, no "log in again".
#[test]
fn a_stale_touch_is_answered_in_place_and_the_call_sent_once_more() {
assert_eq!(run(vec![Err(E::Stale), Ok(7)], Ok(())), (Ok(7), 2, 1));
}
/// The check stays holger's: a cancelled touch sends nothing again, and a call still
/// refused after a touch is not touched again (no loop).
#[test]
fn a_failed_touch_sends_nothing_again_and_there_is_no_loop() {
assert_eq!(run(vec![Err(E::Stale)], Err("cancelled".into())), (Err(E::Touch("cancelled".into())), 1, 1));
assert_eq!(run(vec![Err(E::Stale), Err(E::Stale)], Ok(())), (Err(E::Stale), 2, 1));
}
/// Any other answer is taken as it came, with no touch.
#[test]
fn another_refusal_or_a_success_asks_for_no_touch() {
assert_eq!(run(vec![Err(E::Other("not an admin"))], Ok(())), (Err(E::Other("not an admin")), 1, 0));
assert_eq!(run(vec![Ok(1)], Ok(())), (Ok(1), 1, 0));
}
/// holger's step-up refusal is read by its CODE; the old words alone are not one.
#[test]
fn the_step_up_is_read_by_its_code_never_by_its_words() {
let words = "log in again with your key first: this needs a user-verified session from the last 10 minutes";
assert!(is_step_up(403, &serde_json::json!({ "error": words, "code": "stepup" }).to_string()));
assert!(is_step_up(403, r#"{"code":"stepup","error":"anything"}"#));
assert!(!is_step_up(403, words), "a sentence prefix is no code");
assert!(!is_step_up(403, &serde_json::json!({ "error": words }).to_string()));
assert!(!is_step_up(401, r#"{"code":"stepup"}"#), "only a 403");
assert!(!is_step_up(403, r#"{"code":"other"}"#));
let token = |code: &str| crate::security::RefusalView { code: code.into(), text: "x".into() };
assert!(is_token_step_up(&token("holger-token-7")));
assert!(!is_token_step_up(&token("holger-token-6")));
assert!(!is_token_step_up(&token("PermissionDenied")));
assert_eq!(touch_failed_words(""), NEEDS_TOUCH);
assert!(touch_failed_words("The touch was cancelled or timed out.").ends_with("cancelled or timed out."));
}
#[test]
fn base64url_round_trips() {
for n in 0..40u8 {
let bytes: Vec<u8> = (0..n).map(|i| i.wrapping_mul(37).wrapping_add(n)).collect();
assert_eq!(b64url_decode(&b64url(&bytes)).as_deref(), Some(bytes.as_slice()), "{n}");
}
assert_eq!(b64url(b"\xfb\xff"), "-_8");
assert_eq!(b64url(b"hello"), "aGVsbG8");
assert_eq!(b64url_decode("aGVsbG8=").as_deref(), Some(&b"hello"[..]));
assert_eq!(b64url_decode("a b"), None);
}
/// holger's options become the key's question, UV always required; the finish carries
/// the key's answer in the login page's own shape; the fresh session is the server's cookie.
#[test]
fn the_wire_of_the_touch() {
let start = serde_json::json!({
"ceremony_id": "c1",
"publicKey": { "challenge": "aGVsbG8", "rpId": "holger.example", "timeout": 300000,
"userVerification": "required", "allowCredentials": [{ "type": "public-key", "id": "AQI" }] }
});
let ask = ask_of(&start).unwrap();
assert_eq!((ask.ceremony_id.as_str(), ask.challenge.as_slice(), ask.rp_id.as_str(), ask.timeout), ("c1", &b"hello"[..], "holger.example", 300_000));
assert_eq!(ask.allow, vec![vec![1u8, 2]]);
assert!(ask_of(&serde_json::json!({ "publicKey": {} })).is_err(), "no ceremony, no touch");
let a = Assertion { id: "AQI".into(), client_data: b"cd".to_vec(), authenticator_data: b"ad".to_vec(), signature: b"sg".to_vec() };
let body = finish_body("c1", &a);
assert_eq!(body["ceremony_id"], "c1");
assert_eq!(body["id"], "AQI");
assert_eq!(body["response"]["clientDataJSON"], b64url(b"cd"));
assert_eq!(body["response"]["signature"], b64url(b"sg"));
assert_eq!(fresh_session(200, r#"{"signed_in":true,"role":"Admin"}"#), Ok(()));
assert_eq!(fresh_session(403, r#"{"error":"that key belongs to another account: touch your own key"}"#), Err("that key belongs to another account: touch your own key".into()));
assert!(fresh_session(200, "{}").is_err());
}
}