eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
7async 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
13export 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.
19export 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.
30export 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
47export 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.
58export function beginRegister() {
59 return apiJSON("/api/register/begin", { method: "POST" });
60}
61
62export 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.
71export 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.
116export 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).
130export 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).
142export 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.)
159import {
160 b64urlToBytes, bytesToB64url, prfEvalInput,
161} from "/static/crypto.js";
162