// Shared WebAuthn + blob plumbing for the home server's pages (home.js and the // enrolment popup enroll.js). Kept framework-free and storage-free: each page // owns its own session state; these helpers just run ceremonies and move bytes. // All crypto lives in crypto.js — this file never touches key material beyond // forwarding a ceremony's PRF output back to the caller. async function api(path, opts = {}) { const res = await fetch(path, opts); if (!res.ok) throw new Error((await res.text().catch(() => "")).trim() || `HTTP ${res.status}`); return res; } export async function apiJSON(path, opts) { return (await api(path, opts)).json(); } // The server speaks the WebAuthn JSON wire format (base64url strings); the // browser API wants ArrayBuffers. Decode just the fields we use. export function decodePublicKeyOptions(publicKey) { const out = { ...publicKey, challenge: b64urlToBytes(publicKey.challenge) }; if (publicKey.user) out.user = { ...publicKey.user, id: b64urlToBytes(publicKey.user.id) }; for (const k of ["excludeCredentials", "allowCredentials"]) { if (publicKey[k]) out[k] = publicKey[k].map((c) => ({ ...c, id: b64urlToBytes(c.id) })); } return out; } // Serialize a credential for the server, by hand: it guarantees the PRF // results (secret key material!) never ride along — only `enabled` survives. export function credentialToJSON(cred) { const r = cred.response; const ext = cred.getClientExtensionResults(); const out = { id: cred.id, rawId: bytesToB64url(new Uint8Array(cred.rawId)), type: cred.type, clientExtensionResults: ext.prf ? { prf: { enabled: !!ext.prf.enabled } } : {}, response: { clientDataJSON: bytesToB64url(new Uint8Array(r.clientDataJSON)) }, }; if (r.attestationObject) out.response.attestationObject = bytesToB64url(new Uint8Array(r.attestationObject)); if (r.authenticatorData) out.response.authenticatorData = bytesToB64url(new Uint8Array(r.authenticatorData)); if (r.signature) out.response.signature = bytesToB64url(new Uint8Array(r.signature)); if (r.userHandle) out.response.userHandle = bytesToB64url(new Uint8Array(r.userHandle)); return out; } export function prfOutputOf(cred) { const first = cred.getClientExtensionResults().prf?.results?.first; return first ? new Uint8Array(first) : null; } // beginRegister/beginLogin fetch the ceremony options WITHOUT touching the // authenticator. Callers pre-fetch these on page load so the button click can // reach navigator.credentials.* synchronously — Safari/WebKit drops the // transient user activation across an intervening network await and then // rejects the ceremony with NotAllowedError (the "prompt flashes and dies" // bug), so the WebAuthn call must fire inside the gesture, not after a fetch. export function beginRegister() { return apiJSON("/api/register/begin", { method: "POST" }); } export function beginLogin() { return apiJSON("/api/login/begin", { method: "POST" }); } // runRegister creates a NEW passkey (a new anonymous account) with PRF, then // returns { token, credId, prf, proof }. Pass pre-fetched beginRegister() options so // create() fires within the click (see above); omit to fetch inline (fallback). // Some authenticators evaluate PRF at create time; the rest only during an // assertion, which this handles so the caller always gets a usable PRF output. export async function runRegister(prefetched) { const { session, options } = prefetched || await apiJSON("/api/register/begin", { method: "POST" }); const publicKey = decodePublicKeyOptions(options.publicKey); publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } }; let cred; try { cred = await navigator.credentials.create({ publicKey }); } catch (e) { e.phase = "create"; throw e; } const ext = cred.getClientExtensionResults(); if (!ext.prf?.enabled && !ext.prf?.results) { throw new Error("no-prf"); // caller shows the "manager can't protect a backup" copy } const fin = await apiJSON("/api/register/finish?session=" + encodeURIComponent(session), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentialToJSON(cred)), }); let prf = prfOutputOf(cred); if (!prf) { // Enabled but not evaluated at create time — one assertion gets the output. // The challenge is local: PRF eval happens on the authenticator, no server check. let assertion; try { assertion = await navigator.credentials.get({ publicKey: { challenge: crypto.getRandomValues(new Uint8Array(32)), allowCredentials: [{ type: "public-key", id: cred.rawId }], userVerification: "required", extensions: { prf: { eval: { first: prfEvalInput() } } }, }, }); } catch (e) { e.phase = "prf-assertion"; throw e; } prf = prfOutputOf(assertion); } if (!prf) throw new Error("no PRF output from this passkey"); // proof: register/finish answers exactly like login/finish (finishCeremony), // so a fresh ceremony proof rides along for immediate gated ops. return { token: fin.token, credId: fin.credential_id, prf, proof: fin.proof }; } // runLogin runs a full assertion (discoverable credential) with PRF eval and // returns { token, credId, prf, proof }. Pass pre-fetched beginLogin() options // so get() fires within the click gesture (Safari activation — see above). // proof is a fresh ceremony proof for destructive ops; prf decrypts the blob. // The server never sees prf. export async function runLogin(prefetched) { const { session, options } = prefetched || await apiJSON("/api/login/begin", { method: "POST" }); const publicKey = decodePublicKeyOptions(options.publicKey); publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } }; const cred = await navigator.credentials.get({ publicKey }); const fin = await apiJSON("/api/login/finish?session=" + encodeURIComponent(session), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentialToJSON(cred)), }); return { token: fin.token, credId: fin.credential_id, prf: prfOutputOf(cred), proof: fin.proof }; } // fetchBlob returns { bytes, version } or null (404 — no backup yet). export async function fetchBlob(token) { const res = await fetch("/api/blob", { headers: { Authorization: "Bearer " + token } }); if (res.status === 404) return null; if (!res.ok) throw new Error(`HTTP ${res.status}`); return { bytes: new Uint8Array(await res.arrayBuffer()), version: (res.headers.get("ETag") || '"0"').replace(/"/g, ""), }; } // putBlob returns the new version, or null on a 409 version race (caller // re-fetches + retries). export async function putBlob(token, bytes, ifMatchVersion) { const res = await fetch("/api/blob", { method: "PUT", headers: { Authorization: "Bearer " + token, "If-Match": `"${ifMatchVersion}"`, "Content-Type": "application/octet-stream", }, body: bytes, }); if (res.status === 409) return null; if (!res.ok) throw new Error(`HTTP ${res.status}`); return String((await res.json()).version); } // crypto.js re-exports below so pages import one module. (Keeps import lists // short and the DEK/envelope helpers alongside the ceremony helpers.) import { b64urlToBytes, bytesToB64url, prfEvalInput, } from "/static/crypto.js";