eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The enrolment page. An instance (e.g. family.example.com) navigates here (same
2// tab) to add itself to the user's passkey-protected backup. Passkeys are bound
3// to THIS origin, so the instance can't run the ceremony — it hands us the
4// server entry (its URL + the user's session) in our URL FRAGMENT, which never
5// reaches any server (same model as invite secrets). We show exactly what's
6// being added, run the passkey ceremony here (a plain top-level page — Safari
7// blocks WebAuthn in a cross-origin-opened popup, which is why this is a
8// navigation, not window.open), merge it into the encrypted blob, and redirect
9// back to the instance with a one-time nonce it can verify.
10//
11// No inbound postMessage listener and no secret ever leaves here — the handoff
12// in is one-way via the fragment, the handoff back is a redirect carrying only
13// the nonce. The single trust control on the entry is the confirmation card
14// below — the user sees what's being added before anything is written.
15
16import {
17 b64urlToBytes, kekFromPRF, generateDEK, importDEK, wrapDEK, unwrapDEK,
18 sealBlob, unpackBlob, openBlob,
19} from "/static/crypto.js";
20import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js";
21import { reuseCandidate } from "/static/sessionreuse.js";
22import { formatCeremonyError } from "/static/ceremony-errors.js";
23import { clearTombstone } from "/static/serverlist.js";
24
25const $ = (id) => document.getElementById(id);
26function setStatus(msg, kind) {
27 const el = $("status");
28 el.textContent = msg || "";
29 el.className = "status" + (kind ? " " + kind : "");
30}
31
32// ---- read the entry from our fragment ----------------------------------------
33
34function readPayload() {
35 const raw = location.hash.slice(1);
36 if (!raw) return null;
37 try {
38 const json = new TextDecoder().decode(b64urlToBytes(raw));
39 const p = JSON.parse(json);
40 if (!p || !p.entry || typeof p.entry.url !== "string") return null;
41 // The entry URL must parse — it's where we send the user back to.
42 new URL(p.entry.url);
43 return p;
44 } catch {
45 return null;
46 }
47}
48
49const payload = readPayload();
50// Drop the secret-bearing fragment from the address bar as soon as we've read it.
51try { history.replaceState(null, "", location.pathname); } catch {}
52
53// The instance may pass a small branding photo for the confirmation card. It's
54// display-only chrome and untrusted (anyone can craft a fragment), so we accept
55// it only if it's an inline image data: URI, render it via .src (never innerHTML),
56// and keep it OUT of the persisted entry — the backup blob is size-capped, and the
57// photo is public branding, not part of the credentials.
58let entryIcon = null;
59if (payload && payload.entry) {
60 if (typeof payload.entry.icon === "string" &&
61 /^data:image\/(png|jpeg|webp);base64,/.test(payload.entry.icon)) {
62 entryIcon = payload.entry.icon;
63 }
64 delete payload.entry.icon;
65}
66
67// Where to send the user back to. The instance passes its origin as `ret`; fall
68// back to the entry URL's origin. Only ever a bare origin, and we append only a
69// non-secret nonce marker — never blob contents, tokens, or keys leave here.
70const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin : null;
71
72// goBack returns to the chat. On success we carry the one-time nonce the
73// instance gave us, so it can trust the redirect is genuine; on cancel, nothing.
74// In the native case there's no useful redirect target (this opened in the
75// system browser, which has no session for the instance), so we just end here.
76function goBack(withNonce) {
77 if (payload && payload.native) {
78 setStatus(withNonce
79 ? "All saved. You can close this tab and return to Eleven."
80 : "You can close this tab and return to Eleven.", withNonce ? "ok" : "");
81 return;
82 }
83 if (!returnOrigin) return;
84 const frag = withNonce && payload.nonce ? "#lchat_backup=" + encodeURIComponent(payload.nonce) : "";
85 location.replace(returnOrigin + "/" + frag);
86}
87
88// ---- merge one entry into the (possibly new) blob ----------------------------
89
90// Upsert the entry into a servers list by url (last-writer-wins per url),
91// replacing IN PLACE — enrolment must never shuffle a curated list, and the
92// starred flag survives the upgrade.
93function upsert(servers, entry) {
94 const list = (servers || []).slice();
95 const i = list.findIndex((s) => s.url === entry.url);
96 if (i < 0) return [...list, entry];
97 list[i] = list[i].starred ? { ...entry, starred: true } : entry;
98 return list;
99}
100
101// The credentials that actually grant access to an instance — what a replace
102// would swap out. Compared to decide whether an incoming entry is a benign
103// re-save (identical) or a genuine change worth an explicit confirmation.
104function credFingerprint(entry) {
105 const c = entry.credentials || {};
106 return JSON.stringify([c.token || "", c.userPub || "", c.userPriv || ""]);
107}
108
109// Ceremony options are pre-fetched on load (see boot) so a button click reaches
110// navigator.credentials.* WITHOUT an intervening network await — Safari drops
111// transient activation across a fetch and then rejects the ceremony. Held as
112// resolved values; refreshed after any failure so a retry has a fresh challenge.
113let regBegin = null, loginBegin = null;
114function prefetch() {
115 beginRegister().then((v) => { regBegin = v; }).catch(() => { regBegin = null; });
116 beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
117}
118
119// saveSecurely is the primary path: sign in to the account you already have and
120// add this chat to it. Sign-in stays first so a returning user always adds to
121// their one account instead of accidentally starting a second — but someone who
122// KNOWS they're new never has to fail a ceremony to find their path: the
123// up-front "I'm new" button (createAndAdd) runs the register path directly.
124async function saveSecurely() {
125 disable();
126 // The live-session fast path (#1378): the create→setup→enrol journey is
127 // same-tab by design, so the tab that just created this space usually still
128 // holds a live session — the link token plus the tab-scoped DEK
129 // (home.js § session state). Both were minted by a full ceremony in this
130 // very tab, and both are validated against the server below, so reusing
131 // them grants nothing a fresh assertion wouldn't — it just spares the user
132 // a Face ID for an account this tab is already signed into, and closes the
133 // second-account trapdoor a dismissed assertion opened (offerCreate).
134 if (await trySessionReuse()) return;
135 setStatus("Looking for your sign-in…", "busy");
136 let login;
137 try {
138 login = await runLogin(loginBegin);
139 } catch (e) {
140 // NotAllowedError here means no sign-in on an authenticator (or the prompt
141 // was dismissed). Offer to create one — with a way back to sign-in so a
142 // returning user who dismissed doesn't accidentally start a second account.
143 if (e && e.name === "NotAllowedError") { offerCreate(); return; }
144 throw e;
145 }
146 await addWithLogin(login);
147}
148
149// trySessionReuse: false = no usable live session, run the ceremony. True =
150// the save was handled end-to-end (or genuinely failed — those errors
151// propagate to onError like the ceremony path's). Every soft failure —
152// missing halves, a dead token, a stale DEK that can't open the blob — falls
153// back to the ceremony rather than guessing.
154async function trySessionReuse() {
155 let cand = null;
156 try { cand = reuseCandidate(sessionStorage); } catch { return false; }
157 if (!cand) return false;
158 let got, dek, opened;
159 try {
160 setStatus("Checking your sign-in…", "busy");
161 got = await fetchBlob(cand.token); // throws on a dead token (401)
162 // Signed in but no backup yet (a fresh sign-up — the first blob needs a
163 // ceremony's PRF, which reuse can't supply). Clear the busy status before
164 // falling back: in auto mode nothing else repaints, so "Checking your
165 // sign-in…" would sit under the re-enabled buttons like a stuck spinner.
166 if (!got) { setStatus("", ""); return false; }
167 dek = await importDEK(cand.dek);
168 opened = await openBlob(got.bytes, dek);
169 } catch {
170 setStatus("", "");
171 return false;
172 }
173 await applyEntry({ token: cand.token, dek, got, opened });
174 return true;
175}
176
177// createBlobFor writes a fresh backup whose only server is this entry, keyed to
178// the given credential. Shared by "create a new passkey" and the "signed in but
179// no backup yet" case, so both start a backup rather than fragmenting.
180async function createBlobFor({ token, credId, prf }) {
181 const kek = await kekFromPRF(prf);
182 const dek = await generateDEK();
183 const vault = {
184 obj: { v: 1, servers: [payload.entry] },
185 keys: { [credId]: await wrapDEK(dek, kek) },
186 version: "0",
187 };
188 const sealed = await sealBlob(vault.obj, dek, vault.keys);
189 const v = await putBlob(token, sealed, vault.version);
190 if (v === null) throw new Error("That's already set up — sign in instead.");
191 succeed();
192}
193
194// createAndAdd: register a brand-new sign-in, then save this chat under it.
195async function createAndAdd() {
196 disable();
197 setStatus("Creating your sign-in…", "busy");
198 let reg;
199 try {
200 reg = await runRegister(regBegin);
201 } catch (e) {
202 if (e.message === "no-prf") {
203 setStatus("This device can't protect your sign-in here (no PRF support). You can still use add-device links to move between devices.", "error");
204 enable();
205 return;
206 }
207 throw e;
208 }
209 await createBlobFor(reg);
210}
211
212// addWithLogin adds this chat to the backup behind an already-signed-in passkey.
213async function addWithLogin({ token, credId, prf }) {
214 if (!prf) {
215 setStatus("Signed in, but this browser couldn't unlock your saved spaces here (no PRF result).", "error");
216 enable();
217 return;
218 }
219 const got = await fetchBlob(token);
220 if (!got) {
221 // A passkey that has no backup yet — start one under it (don't send the user
222 // off to make a second passkey).
223 await createBlobFor({ token, credId, prf });
224 return;
225 }
226 const kek = await kekFromPRF(prf);
227 const { keys } = unpackBlob(got.bytes);
228 const wrapped = keys[credId];
229 if (!wrapped) {
230 setStatus("That sign-in can't unlock these spaces.", "error");
231 enable();
232 return;
233 }
234 const dek = await unwrapDEK(wrapped, kek);
235 await applyEntry({ token, dek, got, opened: await openBlob(got.bytes, dek) });
236}
237
238// applyEntry merges the payload entry into an unlocked blob and saves it —
239// the shared tail of the ceremony path (addWithLogin) and the live-session
240// fast path (trySessionReuse). Ends in succeed(), a terminal keep-what-I-have
241// message, or a thrown error for onError.
242async function applyEntry({ token, dek, got, opened }) {
243 // If an entry with DIFFERENT credentials already exists for this host, adding
244 // would overwrite the credentials the user already trusts. Anyone can open
245 // this page with an arbitrary fragment, so a page that knows the host could
246 // try to clobber a real saved entry with junk — a silent add wouldn't reveal
247 // that. Require an explicit, host-specific replace confirmation.
248 // Two cases merge silently instead: an identical re-save (harmless no-op),
249 // and an existing entry with NO credentials — the address-only stub the
250 // create-a-space flow saves. There's nothing to clobber; enrolment upgrades
251 // the stub with this space's key (and its real name).
252 let cur = got;
253 const existing = (opened.obj.servers || []).find((s) => s.url === payload.entry.url);
254 if (existing && existing.credentials && credFingerprint(existing) !== credFingerprint(payload.entry)) {
255 const ok = await confirmReplace(existing);
256 if (!ok) {
257 // The replace prompt replaced the action buttons, so this is terminal:
258 // leave a clear message; the user goes back / closes the window.
259 $("actions").classList.add("hidden");
260 setStatus("Kept your existing access to this chat — nothing changed.", "ok");
261 return;
262 }
263 }
264
265 // Add the entry and save, retrying once on a version race. An explicit save
266 // beats a removal tombstone (serverlist.js): re-enrolling a space the user
267 // deleted on the home page must actually bring it back.
268 for (let attempt = 0; attempt < 2; attempt++) {
269 const obj = { ...opened.obj, servers: upsert(opened.obj.servers, payload.entry) };
270 obj.removed = clearTombstone(obj.removed, payload.entry.url);
271 if (!obj.removed) delete obj.removed;
272 const sealed = await sealBlob(obj, dek, opened.keys);
273 const v = await putBlob(token, sealed, cur.version);
274 if (v !== null) return succeed();
275 cur = await fetchBlob(token);
276 if (!cur) throw new Error("backup disappeared");
277 opened = await openBlob(cur.bytes, dek);
278 }
279 throw new Error("couldn't save after a version conflict — try again");
280}
281
282// ---- UI plumbing --------------------------------------------------------------
283
284// Disable/enable every button currently in #actions — the set changes as the
285// flow morphs (save → create/retry, or the replace prompt), so target them all.
286function setBusy(busy) {
287 $("actions").querySelectorAll("button").forEach((b) => { b.disabled = busy; });
288}
289function disable() { setBusy(true); }
290function enable() { setBusy(false); }
291
292// offerCreate: the fallback after a sign-in that found nothing (the up-front
293// "I'm new" button skips straight to createAndAdd). Creating leads here — the
294// sign-in just came back empty — with a way back to sign-in so a returning
295// user who dismissed the prompt can't fragment into a second account.
296function offerCreate() {
297 const actions = $("actions");
298 actions.textContent = "";
299 setStatus("No sign-in was found on this device — or the prompt was cancelled.");
300
301 const create = document.createElement("button");
302 create.id = "create-fallback";
303 create.textContent = "Create my sign-in";
304 create.addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create")));
305
306 const retry = document.createElement("button");
307 retry.className = "secondary";
308 retry.textContent = "I already have a sign-in — try again";
309 retry.addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin")));
310
311 const back = document.createElement("button");
312 back.className = "secondary";
313 back.textContent = "Cancel";
314 back.addEventListener("click", () => goBack(false));
315
316 actions.append(create, retry, back);
317}
318
319// confirmReplace shows a distinct, host-specific prompt before overwriting an
320// existing entry (never the generic "add" copy), and resolves the user's choice.
321// All text is set via textContent — the names come from the attacker-influenced
322// fragment, so they must never be interpolated as HTML.
323function confirmReplace(existing) {
324 return new Promise((resolve) => {
325 const host = new URL(payload.entry.url).host;
326 const actions = $("actions");
327 actions.textContent = "";
328 setStatus("");
329
330 const q = document.createElement("p");
331 q.textContent = `You already have "${existing.name || host}" saved for ${host}. ` +
332 `Replace it with "${payload.entry.name || host}"? Your current saved access for this chat will be overwritten.`;
333
334 const replace = document.createElement("button");
335 replace.textContent = "Replace my saved access";
336
337 const cancel = document.createElement("button");
338 cancel.className = "secondary";
339 cancel.textContent = "Keep what I have";
340
341 // Disable both so a double-click can't resolve twice; these replaced the
342 // action buttons, so setBusy/enable won't touch them anyway.
343 const settle = (v) => { replace.disabled = true; cancel.disabled = true; resolve(v); };
344 replace.addEventListener("click", () => settle(true));
345 cancel.addEventListener("click", () => settle(false));
346
347 actions.append(q, replace, cancel);
348 });
349}
350
351function succeed() {
352 $("actions").classList.add("hidden");
353 // Seal the tile's key badge (→ green check) and hold a short beat before
354 // leaving, so success is legible instead of a flash before the redirect.
355 $("badge").classList.add("sealed");
356 // Report completion to the oracle (enrolstatus.go): the page that launched
357 // this enrolment may never receive the redirect (iOS home-screen apps open
358 // cross-origin links in an in-app overlay; the native wrapper hands off to
359 // the system browser) — it polls /api/enroll-status with this same nonce
360 // instead. Fire-and-forget, keepalive so it survives the redirect below.
361 if (payload.nonce) {
362 try {
363 fetch("/api/enroll-done", {
364 method: "POST",
365 headers: { "Content-Type": "application/json" },
366 body: JSON.stringify({ nonce: String(payload.nonce) }),
367 keepalive: true,
368 }).catch(() => {});
369 } catch {}
370 }
371 setStatus("Saved. Taking you back to your chat…", "ok");
372 setTimeout(() => goBack(true), 700); // returns with the one-time nonce so the instance trusts it
373}
374
375// onError renders a friendly, specific sentence for whichever ceremony the
376// user was in (ceremony-errors.js), keeping the name+phase crumb parenthesised
377// — that detail is deliberate; a bland "dismissed" hid the real signal.
378const onError = (err, ceremony) => {
379 try { console.error("[enrol] error", err && err.phase, err); } catch {}
380 setStatus(formatCeremonyError(err, ceremony), "error");
381 enable();
382 prefetch(); // a used/failed ceremony's options are spent — refresh for a retry
383};
384
385// ---- boot ---------------------------------------------------------------------
386
387if (!payload) {
388 setStatus("This page was opened without a valid request. Go back to your chat and try again.", "error");
389 $("actions").classList.add("hidden");
390 $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead
391} else {
392 // The payload is valid, so the tile can name the chat even when the ceremony
393 // can't run (a placeholder "This chat" under an error would mislead).
394 const host = new URL(payload.entry.url).host;
395 $("entry-name").textContent = payload.entry.name || host;
396 $("entry-host").textContent = host;
397 if (entryIcon) {
398 const img = $("entry-icon");
399 img.src = entryIcon; // validated data:image URI; set via .src, never innerHTML
400 img.classList.remove("hidden");
401 $("entry-glyph").classList.add("hidden"); // the photo takes the glyph's spot
402 }
403}
404if (payload && !window.PublicKeyCredential) {
405 setStatus("This browser can't sign you in — try another browser.", "error");
406 $("actions").classList.add("hidden");
407} else if (payload) {
408 // Pre-fetch ceremony options now, while the user reads the card — so either
409 // button's click reaches the authenticator inside the gesture (Safari
410 // activation, see prefetch): regBegin covers "I'm new", loginBegin the sign-in.
411 prefetch();
412 $("save").addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin")));
413 $("create-new").addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create")));
414
415 // A way back without saving (same-tab navigation replaced the chat).
416 const back = document.createElement("button");
417 back.className = "secondary";
418 back.textContent = "Cancel";
419 back.addEventListener("click", () => goBack(false));
420 $("actions").append(back);
421
422 // A post-claim handoff from the signup-first join (payload.auto): the tab
423 // signed in at home moments ago, so try the live-session fast path without
424 // a tap. Auto NEVER runs a ceremony and never skips the replace guard — if
425 // the session can't complete the save silently, the normal card stands and
426 // the buttons above do their usual work.
427 if (payload.auto) {
428 disable();
429 trySessionReuse()
430 .then((done) => { if (!done) enable(); })
431 .catch((e) => onError(e, "signin"));
432 }
433}
434