| 1 | // Shared WebAuthn + blob plumbing for the home server's pages (home.js and the |
| 2 | // enrolment popup enroll.js). Kept framework-free and storage-free: each page |
| 3 | // owns its own session state; these helpers just run ceremonies and move bytes. |
| 4 | // All crypto lives in crypto.js — this file never touches key material beyond |
| 5 | // forwarding a ceremony's PRF output back to the caller. |
| 6 | |
| 7 | async function api(path, opts = {}) { |
| 8 | const res = await fetch(path, opts); |
| 9 | if (!res.ok) throw new Error((await res.text().catch(() => "")).trim() || `HTTP ${res.status}`); |
| 10 | return res; |
| 11 | } |
| 12 | |
| 13 | export async function apiJSON(path, opts) { |
| 14 | return (await api(path, opts)).json(); |
| 15 | } |
| 16 | |
| 17 | // The server speaks the WebAuthn JSON wire format (base64url strings); the |
| 18 | // browser API wants ArrayBuffers. Decode just the fields we use. |
| 19 | export function decodePublicKeyOptions(publicKey) { |
| 20 | const out = { ...publicKey, challenge: b64urlToBytes(publicKey.challenge) }; |
| 21 | if (publicKey.user) out.user = { ...publicKey.user, id: b64urlToBytes(publicKey.user.id) }; |
| 22 | for (const k of ["excludeCredentials", "allowCredentials"]) { |
| 23 | if (publicKey[k]) out[k] = publicKey[k].map((c) => ({ ...c, id: b64urlToBytes(c.id) })); |
| 24 | } |
| 25 | return out; |
| 26 | } |
| 27 | |
| 28 | // Serialize a credential for the server, by hand: it guarantees the PRF |
| 29 | // results (secret key material!) never ride along — only `enabled` survives. |
| 30 | export function credentialToJSON(cred) { |
| 31 | const r = cred.response; |
| 32 | const ext = cred.getClientExtensionResults(); |
| 33 | const out = { |
| 34 | id: cred.id, |
| 35 | rawId: bytesToB64url(new Uint8Array(cred.rawId)), |
| 36 | type: cred.type, |
| 37 | clientExtensionResults: ext.prf ? { prf: { enabled: !!ext.prf.enabled } } : {}, |
| 38 | response: { clientDataJSON: bytesToB64url(new Uint8Array(r.clientDataJSON)) }, |
| 39 | }; |
| 40 | if (r.attestationObject) out.response.attestationObject = bytesToB64url(new Uint8Array(r.attestationObject)); |
| 41 | if (r.authenticatorData) out.response.authenticatorData = bytesToB64url(new Uint8Array(r.authenticatorData)); |
| 42 | if (r.signature) out.response.signature = bytesToB64url(new Uint8Array(r.signature)); |
| 43 | if (r.userHandle) out.response.userHandle = bytesToB64url(new Uint8Array(r.userHandle)); |
| 44 | return out; |
| 45 | } |
| 46 | |
| 47 | export function prfOutputOf(cred) { |
| 48 | const first = cred.getClientExtensionResults().prf?.results?.first; |
| 49 | return first ? new Uint8Array(first) : null; |
| 50 | } |
| 51 | |
| 52 | // beginRegister/beginLogin fetch the ceremony options WITHOUT touching the |
| 53 | // authenticator. Callers pre-fetch these on page load so the button click can |
| 54 | // reach navigator.credentials.* synchronously — Safari/WebKit drops the |
| 55 | // transient user activation across an intervening network await and then |
| 56 | // rejects the ceremony with NotAllowedError (the "prompt flashes and dies" |
| 57 | // bug), so the WebAuthn call must fire inside the gesture, not after a fetch. |
| 58 | export function beginRegister() { |
| 59 | return apiJSON("/api/register/begin", { method: "POST" }); |
| 60 | } |
| 61 | |
| 62 | export function beginLogin() { |
| 63 | return apiJSON("/api/login/begin", { method: "POST" }); |
| 64 | } |
| 65 | |
| 66 | // runRegister creates a NEW passkey (a new anonymous account) with PRF, then |
| 67 | // returns { token, credId, prf, proof }. Pass pre-fetched beginRegister() options so |
| 68 | // create() fires within the click (see above); omit to fetch inline (fallback). |
| 69 | // Some authenticators evaluate PRF at create time; the rest only during an |
| 70 | // assertion, which this handles so the caller always gets a usable PRF output. |
| 71 | export async function runRegister(prefetched) { |
| 72 | const { session, options } = prefetched || await apiJSON("/api/register/begin", { method: "POST" }); |
| 73 | const publicKey = decodePublicKeyOptions(options.publicKey); |
| 74 | publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } }; |
| 75 | let cred; |
| 76 | try { |
| 77 | cred = await navigator.credentials.create({ publicKey }); |
| 78 | } catch (e) { e.phase = "create"; throw e; } |
| 79 | const ext = cred.getClientExtensionResults(); |
| 80 | if (!ext.prf?.enabled && !ext.prf?.results) { |
| 81 | throw new Error("no-prf"); // caller shows the "manager can't protect a backup" copy |
| 82 | } |
| 83 | const fin = await apiJSON("/api/register/finish?session=" + encodeURIComponent(session), { |
| 84 | method: "POST", |
| 85 | headers: { "Content-Type": "application/json" }, |
| 86 | body: JSON.stringify(credentialToJSON(cred)), |
| 87 | }); |
| 88 | let prf = prfOutputOf(cred); |
| 89 | if (!prf) { |
| 90 | // Enabled but not evaluated at create time — one assertion gets the output. |
| 91 | // The challenge is local: PRF eval happens on the authenticator, no server check. |
| 92 | let assertion; |
| 93 | try { |
| 94 | assertion = await navigator.credentials.get({ |
| 95 | publicKey: { |
| 96 | challenge: crypto.getRandomValues(new Uint8Array(32)), |
| 97 | allowCredentials: [{ type: "public-key", id: cred.rawId }], |
| 98 | userVerification: "required", |
| 99 | extensions: { prf: { eval: { first: prfEvalInput() } } }, |
| 100 | }, |
| 101 | }); |
| 102 | } catch (e) { e.phase = "prf-assertion"; throw e; } |
| 103 | prf = prfOutputOf(assertion); |
| 104 | } |
| 105 | if (!prf) throw new Error("no PRF output from this passkey"); |
| 106 | // proof: register/finish answers exactly like login/finish (finishCeremony), |
| 107 | // so a fresh ceremony proof rides along for immediate gated ops. |
| 108 | return { token: fin.token, credId: fin.credential_id, prf, proof: fin.proof }; |
| 109 | } |
| 110 | |
| 111 | // runLogin runs a full assertion (discoverable credential) with PRF eval and |
| 112 | // returns { token, credId, prf, proof }. Pass pre-fetched beginLogin() options |
| 113 | // so get() fires within the click gesture (Safari activation — see above). |
| 114 | // proof is a fresh ceremony proof for destructive ops; prf decrypts the blob. |
| 115 | // The server never sees prf. |
| 116 | export async function runLogin(prefetched) { |
| 117 | const { session, options } = prefetched || await apiJSON("/api/login/begin", { method: "POST" }); |
| 118 | const publicKey = decodePublicKeyOptions(options.publicKey); |
| 119 | publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } }; |
| 120 | const cred = await navigator.credentials.get({ publicKey }); |
| 121 | const fin = await apiJSON("/api/login/finish?session=" + encodeURIComponent(session), { |
| 122 | method: "POST", |
| 123 | headers: { "Content-Type": "application/json" }, |
| 124 | body: JSON.stringify(credentialToJSON(cred)), |
| 125 | }); |
| 126 | return { token: fin.token, credId: fin.credential_id, prf: prfOutputOf(cred), proof: fin.proof }; |
| 127 | } |
| 128 | |
| 129 | // fetchBlob returns { bytes, version } or null (404 — no backup yet). |
| 130 | export async function fetchBlob(token) { |
| 131 | const res = await fetch("/api/blob", { headers: { Authorization: "Bearer " + token } }); |
| 132 | if (res.status === 404) return null; |
| 133 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 134 | return { |
| 135 | bytes: new Uint8Array(await res.arrayBuffer()), |
| 136 | version: (res.headers.get("ETag") || '"0"').replace(/"/g, ""), |
| 137 | }; |
| 138 | } |
| 139 | |
| 140 | // putBlob returns the new version, or null on a 409 version race (caller |
| 141 | // re-fetches + retries). |
| 142 | export async function putBlob(token, bytes, ifMatchVersion) { |
| 143 | const res = await fetch("/api/blob", { |
| 144 | method: "PUT", |
| 145 | headers: { |
| 146 | Authorization: "Bearer " + token, |
| 147 | "If-Match": `"${ifMatchVersion}"`, |
| 148 | "Content-Type": "application/octet-stream", |
| 149 | }, |
| 150 | body: bytes, |
| 151 | }); |
| 152 | if (res.status === 409) return null; |
| 153 | if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| 154 | return String((await res.json()).version); |
| 155 | } |
| 156 | |
| 157 | // crypto.js re-exports below so pages import one module. (Keeps import lists |
| 158 | // short and the DEK/envelope helpers alongside the ceremony helpers.) |
| 159 | import { |
| 160 | b64urlToBytes, bytesToB64url, prfEvalInput, |
| 161 | } from "/static/crypto.js"; |
| 162 | |