Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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());
    }
}