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

nordisk/gunnar-ui

crates/gunnar-ui-web/src/machine_enrol.rs

//! **Machines asking to enrol** (2026-10-05) — what the Identities page shows
//! and sends, on the host side of the wasm boundary where it is tested.
//!
//! An ops kit asks gunnar for a credential (`estate console enrol --request`)
//! and prints a code. The administrator sees the request here — "Pending:
//! installer kit on dwarf requests admin — code ABC-123" — compares the code
//! with the kit's screen, and presses Approve: the browser asks THEIR key for
//! a fresh, user-verified touch, and the console relays it to gunnar, which
//! verifies it and makes the kit's MACHINE principal. Nothing is decided on
//! this page: who may approve, whether the touch is fresh, what the machine
//! becomes — every refusal is gunnar's, shown verbatim.

use serde::Deserialize;

/// One request, as `GET /auth/machine/requests` sends it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Request {
    pub id: String,
    pub machine: String,
    pub label: String,
    pub role: String,
    pub code: String,
    pub key: String,
    pub expires: i64,
}

/// The page's answer: `{ "requests": [...] }`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Requests {
    pub requests: Vec<Request>,
}

/// **The row's sentence**: `Pending: <label> requests <role> — code <code>`.
pub fn pending_line(r: &Request) -> String {
    let label = if r.label.trim().is_empty() { r.machine.as_str() } else { r.label.trim() };
    format!("Pending: {label} requests {} — code {}", r.role, r.code)
}

/// The roles an approver may give a machine, lowest first.
pub const GIVABLE_ROLES: [&str; 3] = ["viewer", "operator", "admin"];

/// The detail under it: the machine it becomes and its key's fingerprint.
pub fn detail_line(r: &Request) -> String {
    format!("becomes the machine {} · key {}", r.machine, r.key)
}

const URL_SAFE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

/// base64url without padding — how WebAuthn's binary fields travel as JSON.
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 = (u32::from(chunk[0]) << 16)
            | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
            | u32::from(*chunk.get(2).unwrap_or(&0));
        let take = chunk.len() + 1;
        for i in 0..take {
            out.push(URL_SAFE[((n >> (18 - 6 * i)) & 63) as usize] as char);
        }
    }
    out
}

/// The inverse of [`b64url`] (padding and the standard alphabet's `+/` are
/// taken too); `None` for anything else.
pub fn from_b64url(text: &str) -> Option<Vec<u8>> {
    let mut out = Vec::with_capacity(text.len() * 3 / 4);
    let (mut acc, mut bits) = (0u32, 0u32);
    for c in text.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)
}

/// What the authenticator answered, raw.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Touch {
    pub raw_id: Vec<u8>,
    pub client_data_json: Vec<u8>,
    pub authenticator_data: Vec<u8>,
    pub signature: Vec<u8>,
}

/// **The approval's body**: the code shown beside the request, the role the
/// approver GIVES (owner order 2026-10-18: a request asks for the lowest by
/// default, and the admin grants more here) and the approver's touch, as the
/// login page sends an assertion. Pure.
pub fn approval_body(ceremony_id: &str, code: &str, role: &str, touch: &Touch) -> serde_json::Value {
    serde_json::json!({
        "code": code,
        "role": role,
        "ceremony_id": ceremony_id,
        "id": b64url(&touch.raw_id),
        "response": {
            "clientDataJSON": b64url(&touch.client_data_json),
            "authenticatorData": b64url(&touch.authenticator_data),
            "signature": b64url(&touch.signature),
        },
    })
}

/// **`navigator.credentials.get`** with the options the console sent
/// (`GET /auth/machine/step-up`): the challenge and every allowed id decoded
/// from base64url, the rest as sent. The browser asks the person's key.
#[cfg(target_arch = "wasm32")]
pub async fn touch(options: &serde_json::Value) -> Result<Touch, String> {
    use js_sys::{Array, Function, Object, Promise, Reflect, Uint8Array};
    use wasm_bindgen::{JsCast, JsValue};

    // Never the browser's own message: its error's NAME, said in plain
    // words (`crate::refusal::passkey_error_words`).
    fn err(e: JsValue) -> String {
        let name = Reflect::get(&e, &JsValue::from_str("name")).ok().and_then(|m| m.as_string()).unwrap_or_default();
        crate::refusal::passkey_error_words(&name).to_owned()
    }
    fn set(o: &Object, k: &str, v: &JsValue) -> Result<(), String> {
        Reflect::set(o, &JsValue::from_str(k), v).map(|_| ()).map_err(err)
    }
    fn bytes(of: &JsValue, k: &str) -> Result<Vec<u8>, String> {
        let buf = Reflect::get(of, &JsValue::from_str(k)).map_err(err)?;
        Ok(Uint8Array::new(&buf).to_vec())
    }

    let pk = options.get("publicKey").ok_or("the console sent no publicKey options")?;
    let challenge = pk
        .get("challenge")
        .and_then(|c| c.as_str())
        .and_then(from_b64url)
        .ok_or("the console sent no challenge")?;
    let public_key = Object::new();
    set(&public_key, "challenge", &Uint8Array::from(&challenge[..]).into())?;
    if let Some(rp) = pk.get("rpId").and_then(|v| v.as_str()) {
        set(&public_key, "rpId", &JsValue::from_str(rp))?;
    }
    if let Some(t) = pk.get("timeout").and_then(|v| v.as_f64()) {
        set(&public_key, "timeout", &JsValue::from_f64(t))?;
    }
    if let Some(uv) = pk.get("userVerification").and_then(|v| v.as_str()) {
        set(&public_key, "userVerification", &JsValue::from_str(uv))?;
    }
    let allow = Array::new();
    for c in pk.get("allowCredentials").and_then(|a| a.as_array()).into_iter().flatten() {
        let Some(id) = c.get("id").and_then(|i| i.as_str()).and_then(from_b64url) else { continue };
        let d = Object::new();
        set(&d, "type", &JsValue::from_str("public-key"))?;
        set(&d, "id", &Uint8Array::from(&id[..]).into())?;
        allow.push(&d);
    }
    set(&public_key, "allowCredentials", &allow.into())?;
    let request = Object::new();
    set(&request, "publicKey", &public_key.into())?;

    let navigator = web_sys::window().ok_or("no window")?.navigator();
    let credentials = Reflect::get(navigator.as_ref(), &JsValue::from_str("credentials")).map_err(err)?;
    let get: Function = Reflect::get(&credentials, &JsValue::from_str("get"))
        .map_err(err)?
        .dyn_into()
        .map_err(|_| "this browser does not support passkeys".to_owned())?;
    let promise: Promise = get.call1(&credentials, &request).map_err(err)?.dyn_into().map_err(err)?;
    let answered = wasm_bindgen_futures::JsFuture::from(promise).await.map_err(err)?;
    let response = Reflect::get(&answered, &JsValue::from_str("response")).map_err(err)?;
    Ok(Touch {
        raw_id: bytes(&answered, "rawId")?,
        client_data_json: bytes(&response, "clientDataJSON")?,
        authenticator_data: bytes(&response, "authenticatorData")?,
        signature: bytes(&response, "signature")?,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_pending_row_reads_label_role_and_code() {
        let r = Request {
            id: "abc".into(),
            machine: "kit-dwarf".into(),
            label: "installer kit on dwarf".into(),
            role: "admin".into(),
            code: "ABC-123".into(),
            key: "SHA256:xyz".into(),
            expires: 0,
        };
        assert_eq!(pending_line(&r), "Pending: installer kit on dwarf requests admin — code ABC-123");
        assert_eq!(detail_line(&r), "becomes the machine kit-dwarf · key SHA256:xyz");
        let unnamed = Request { label: " ".into(), ..r };
        assert!(pending_line(&unnamed).starts_with("Pending: kit-dwarf requests admin"));
    }

    #[test]
    fn base64url_round_trips_every_length() {
        for n in 0..40u8 {
            let bytes: Vec<u8> = (0..n).map(|i| i.wrapping_mul(37).wrapping_add(250)).collect();
            let text = b64url(&bytes);
            assert!(!text.contains('=') && !text.contains('+') && !text.contains('/'), "{text}");
            assert_eq!(from_b64url(&text).as_deref(), Some(&bytes[..]), "{n}");
        }
        assert_eq!(b64url(b"\xfb\xff"), "-_8");
        assert_eq!(from_b64url("+/8=").as_deref(), Some(&b"\xfb\xff"[..]));
        assert_eq!(from_b64url("a b"), None);
    }

    #[test]
    fn the_approval_carries_the_code_and_the_touch_as_a_login_does() {
        let t = Touch { raw_id: vec![1, 2], client_data_json: b"{}".to_vec(), authenticator_data: vec![9], signature: vec![7, 7] };
        let body = approval_body("c-1", "ABC-123", "operator", &t);
        assert_eq!(body["code"], "ABC-123");
        assert_eq!(body["role"], "operator", "the role the admin gives");
        assert_eq!(body["ceremony_id"], "c-1");
        assert_eq!(body["id"], "AQI");
        assert_eq!(body["response"]["clientDataJSON"], "e30");
        assert_eq!(body["response"]["signature"], "Bwc");
    }
}