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