// The enrolment page. An instance (e.g. family.example.com) navigates here (same // tab) to add itself to the user's passkey-protected backup. Passkeys are bound // to THIS origin, so the instance can't run the ceremony — it hands us the // server entry (its URL + the user's session) in our URL FRAGMENT, which never // reaches any server (same model as invite secrets). We show exactly what's // being added, run the passkey ceremony here (a plain top-level page — Safari // blocks WebAuthn in a cross-origin-opened popup, which is why this is a // navigation, not window.open), merge it into the encrypted blob, and redirect // back to the instance with a one-time nonce it can verify. // // No inbound postMessage listener and no secret ever leaves here — the handoff // in is one-way via the fragment, the handoff back is a redirect carrying only // the nonce. The single trust control on the entry is the confirmation card // below — the user sees what's being added before anything is written. import { b64urlToBytes, kekFromPRF, generateDEK, importDEK, wrapDEK, unwrapDEK, sealBlob, unpackBlob, openBlob, } from "/static/crypto.js"; import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js"; import { reuseCandidate } from "/static/sessionreuse.js"; import { formatCeremonyError } from "/static/ceremony-errors.js"; import { clearTombstone } from "/static/serverlist.js"; const $ = (id) => document.getElementById(id); function setStatus(msg, kind) { const el = $("status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // ---- read the entry from our fragment ---------------------------------------- function readPayload() { const raw = location.hash.slice(1); if (!raw) return null; try { const json = new TextDecoder().decode(b64urlToBytes(raw)); const p = JSON.parse(json); if (!p || !p.entry || typeof p.entry.url !== "string") return null; // The entry URL must parse — it's where we send the user back to. new URL(p.entry.url); return p; } catch { return null; } } const payload = readPayload(); // Drop the secret-bearing fragment from the address bar as soon as we've read it. try { history.replaceState(null, "", location.pathname); } catch {} // The instance may pass a small branding photo for the confirmation card. It's // display-only chrome and untrusted (anyone can craft a fragment), so we accept // it only if it's an inline image data: URI, render it via .src (never innerHTML), // and keep it OUT of the persisted entry — the backup blob is size-capped, and the // photo is public branding, not part of the credentials. let entryIcon = null; if (payload && payload.entry) { if (typeof payload.entry.icon === "string" && /^data:image\/(png|jpeg|webp);base64,/.test(payload.entry.icon)) { entryIcon = payload.entry.icon; } delete payload.entry.icon; } // Where to send the user back to. The instance passes its origin as `ret`; fall // back to the entry URL's origin. Only ever a bare origin, and we append only a // non-secret nonce marker — never blob contents, tokens, or keys leave here. const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin : null; // goBack returns to the chat. On success we carry the one-time nonce the // instance gave us, so it can trust the redirect is genuine; on cancel, nothing. // In the native case there's no useful redirect target (this opened in the // system browser, which has no session for the instance), so we just end here. function goBack(withNonce) { if (payload && payload.native) { setStatus(withNonce ? "All saved. You can close this tab and return to Eleven." : "You can close this tab and return to Eleven.", withNonce ? "ok" : ""); return; } if (!returnOrigin) return; const frag = withNonce && payload.nonce ? "#lchat_backup=" + encodeURIComponent(payload.nonce) : ""; location.replace(returnOrigin + "/" + frag); } // ---- merge one entry into the (possibly new) blob ---------------------------- // Upsert the entry into a servers list by url (last-writer-wins per url), // replacing IN PLACE — enrolment must never shuffle a curated list, and the // starred flag survives the upgrade. function upsert(servers, entry) { const list = (servers || []).slice(); const i = list.findIndex((s) => s.url === entry.url); if (i < 0) return [...list, entry]; list[i] = list[i].starred ? { ...entry, starred: true } : entry; return list; } // The credentials that actually grant access to an instance — what a replace // would swap out. Compared to decide whether an incoming entry is a benign // re-save (identical) or a genuine change worth an explicit confirmation. function credFingerprint(entry) { const c = entry.credentials || {}; return JSON.stringify([c.token || "", c.userPub || "", c.userPriv || ""]); } // Ceremony options are pre-fetched on load (see boot) so a button click reaches // navigator.credentials.* WITHOUT an intervening network await — Safari drops // transient activation across a fetch and then rejects the ceremony. Held as // resolved values; refreshed after any failure so a retry has a fresh challenge. let regBegin = null, loginBegin = null; function prefetch() { beginRegister().then((v) => { regBegin = v; }).catch(() => { regBegin = null; }); beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); } // saveSecurely is the primary path: sign in to the account you already have and // add this chat to it. Sign-in stays first so a returning user always adds to // their one account instead of accidentally starting a second — but someone who // KNOWS they're new never has to fail a ceremony to find their path: the // up-front "I'm new" button (createAndAdd) runs the register path directly. async function saveSecurely() { disable(); // The live-session fast path (#1378): the create→setup→enrol journey is // same-tab by design, so the tab that just created this space usually still // holds a live session — the link token plus the tab-scoped DEK // (home.js § session state). Both were minted by a full ceremony in this // very tab, and both are validated against the server below, so reusing // them grants nothing a fresh assertion wouldn't — it just spares the user // a Face ID for an account this tab is already signed into, and closes the // second-account trapdoor a dismissed assertion opened (offerCreate). if (await trySessionReuse()) return; setStatus("Looking for your sign-in…", "busy"); let login; try { login = await runLogin(loginBegin); } catch (e) { // NotAllowedError here means no sign-in on an authenticator (or the prompt // was dismissed). Offer to create one — with a way back to sign-in so a // returning user who dismissed doesn't accidentally start a second account. if (e && e.name === "NotAllowedError") { offerCreate(); return; } throw e; } await addWithLogin(login); } // trySessionReuse: false = no usable live session, run the ceremony. True = // the save was handled end-to-end (or genuinely failed — those errors // propagate to onError like the ceremony path's). Every soft failure — // missing halves, a dead token, a stale DEK that can't open the blob — falls // back to the ceremony rather than guessing. async function trySessionReuse() { let cand = null; try { cand = reuseCandidate(sessionStorage); } catch { return false; } if (!cand) return false; let got, dek, opened; try { setStatus("Checking your sign-in…", "busy"); got = await fetchBlob(cand.token); // throws on a dead token (401) // Signed in but no backup yet (a fresh sign-up — the first blob needs a // ceremony's PRF, which reuse can't supply). Clear the busy status before // falling back: in auto mode nothing else repaints, so "Checking your // sign-in…" would sit under the re-enabled buttons like a stuck spinner. if (!got) { setStatus("", ""); return false; } dek = await importDEK(cand.dek); opened = await openBlob(got.bytes, dek); } catch { setStatus("", ""); return false; } await applyEntry({ token: cand.token, dek, got, opened }); return true; } // createBlobFor writes a fresh backup whose only server is this entry, keyed to // the given credential. Shared by "create a new passkey" and the "signed in but // no backup yet" case, so both start a backup rather than fragmenting. async function createBlobFor({ token, credId, prf }) { const kek = await kekFromPRF(prf); const dek = await generateDEK(); const vault = { obj: { v: 1, servers: [payload.entry] }, keys: { [credId]: await wrapDEK(dek, kek) }, version: "0", }; const sealed = await sealBlob(vault.obj, dek, vault.keys); const v = await putBlob(token, sealed, vault.version); if (v === null) throw new Error("That's already set up — sign in instead."); succeed(); } // createAndAdd: register a brand-new sign-in, then save this chat under it. async function createAndAdd() { disable(); setStatus("Creating your sign-in…", "busy"); let reg; try { reg = await runRegister(regBegin); } catch (e) { if (e.message === "no-prf") { 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"); enable(); return; } throw e; } await createBlobFor(reg); } // addWithLogin adds this chat to the backup behind an already-signed-in passkey. async function addWithLogin({ token, credId, prf }) { if (!prf) { setStatus("Signed in, but this browser couldn't unlock your saved spaces here (no PRF result).", "error"); enable(); return; } const got = await fetchBlob(token); if (!got) { // A passkey that has no backup yet — start one under it (don't send the user // off to make a second passkey). await createBlobFor({ token, credId, prf }); return; } const kek = await kekFromPRF(prf); const { keys } = unpackBlob(got.bytes); const wrapped = keys[credId]; if (!wrapped) { setStatus("That sign-in can't unlock these spaces.", "error"); enable(); return; } const dek = await unwrapDEK(wrapped, kek); await applyEntry({ token, dek, got, opened: await openBlob(got.bytes, dek) }); } // applyEntry merges the payload entry into an unlocked blob and saves it — // the shared tail of the ceremony path (addWithLogin) and the live-session // fast path (trySessionReuse). Ends in succeed(), a terminal keep-what-I-have // message, or a thrown error for onError. async function applyEntry({ token, dek, got, opened }) { // If an entry with DIFFERENT credentials already exists for this host, adding // would overwrite the credentials the user already trusts. Anyone can open // this page with an arbitrary fragment, so a page that knows the host could // try to clobber a real saved entry with junk — a silent add wouldn't reveal // that. Require an explicit, host-specific replace confirmation. // Two cases merge silently instead: an identical re-save (harmless no-op), // and an existing entry with NO credentials — the address-only stub the // create-a-space flow saves. There's nothing to clobber; enrolment upgrades // the stub with this space's key (and its real name). let cur = got; const existing = (opened.obj.servers || []).find((s) => s.url === payload.entry.url); if (existing && existing.credentials && credFingerprint(existing) !== credFingerprint(payload.entry)) { const ok = await confirmReplace(existing); if (!ok) { // The replace prompt replaced the action buttons, so this is terminal: // leave a clear message; the user goes back / closes the window. $("actions").classList.add("hidden"); setStatus("Kept your existing access to this chat — nothing changed.", "ok"); return; } } // Add the entry and save, retrying once on a version race. An explicit save // beats a removal tombstone (serverlist.js): re-enrolling a space the user // deleted on the home page must actually bring it back. for (let attempt = 0; attempt < 2; attempt++) { const obj = { ...opened.obj, servers: upsert(opened.obj.servers, payload.entry) }; obj.removed = clearTombstone(obj.removed, payload.entry.url); if (!obj.removed) delete obj.removed; const sealed = await sealBlob(obj, dek, opened.keys); const v = await putBlob(token, sealed, cur.version); if (v !== null) return succeed(); cur = await fetchBlob(token); if (!cur) throw new Error("backup disappeared"); opened = await openBlob(cur.bytes, dek); } throw new Error("couldn't save after a version conflict — try again"); } // ---- UI plumbing -------------------------------------------------------------- // Disable/enable every button currently in #actions — the set changes as the // flow morphs (save → create/retry, or the replace prompt), so target them all. function setBusy(busy) { $("actions").querySelectorAll("button").forEach((b) => { b.disabled = busy; }); } function disable() { setBusy(true); } function enable() { setBusy(false); } // offerCreate: the fallback after a sign-in that found nothing (the up-front // "I'm new" button skips straight to createAndAdd). Creating leads here — the // sign-in just came back empty — with a way back to sign-in so a returning // user who dismissed the prompt can't fragment into a second account. function offerCreate() { const actions = $("actions"); actions.textContent = ""; setStatus("No sign-in was found on this device — or the prompt was cancelled."); const create = document.createElement("button"); create.id = "create-fallback"; create.textContent = "Create my sign-in"; create.addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create"))); const retry = document.createElement("button"); retry.className = "secondary"; retry.textContent = "I already have a sign-in — try again"; retry.addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin"))); const back = document.createElement("button"); back.className = "secondary"; back.textContent = "Cancel"; back.addEventListener("click", () => goBack(false)); actions.append(create, retry, back); } // confirmReplace shows a distinct, host-specific prompt before overwriting an // existing entry (never the generic "add" copy), and resolves the user's choice. // All text is set via textContent — the names come from the attacker-influenced // fragment, so they must never be interpolated as HTML. function confirmReplace(existing) { return new Promise((resolve) => { const host = new URL(payload.entry.url).host; const actions = $("actions"); actions.textContent = ""; setStatus(""); const q = document.createElement("p"); q.textContent = `You already have "${existing.name || host}" saved for ${host}. ` + `Replace it with "${payload.entry.name || host}"? Your current saved access for this chat will be overwritten.`; const replace = document.createElement("button"); replace.textContent = "Replace my saved access"; const cancel = document.createElement("button"); cancel.className = "secondary"; cancel.textContent = "Keep what I have"; // Disable both so a double-click can't resolve twice; these replaced the // action buttons, so setBusy/enable won't touch them anyway. const settle = (v) => { replace.disabled = true; cancel.disabled = true; resolve(v); }; replace.addEventListener("click", () => settle(true)); cancel.addEventListener("click", () => settle(false)); actions.append(q, replace, cancel); }); } function succeed() { $("actions").classList.add("hidden"); // Seal the tile's key badge (→ green check) and hold a short beat before // leaving, so success is legible instead of a flash before the redirect. $("badge").classList.add("sealed"); // Report completion to the oracle (enrolstatus.go): the page that launched // this enrolment may never receive the redirect (iOS home-screen apps open // cross-origin links in an in-app overlay; the native wrapper hands off to // the system browser) — it polls /api/enroll-status with this same nonce // instead. Fire-and-forget, keepalive so it survives the redirect below. if (payload.nonce) { try { fetch("/api/enroll-done", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nonce: String(payload.nonce) }), keepalive: true, }).catch(() => {}); } catch {} } setStatus("Saved. Taking you back to your chat…", "ok"); setTimeout(() => goBack(true), 700); // returns with the one-time nonce so the instance trusts it } // onError renders a friendly, specific sentence for whichever ceremony the // user was in (ceremony-errors.js), keeping the name+phase crumb parenthesised // — that detail is deliberate; a bland "dismissed" hid the real signal. const onError = (err, ceremony) => { try { console.error("[enrol] error", err && err.phase, err); } catch {} setStatus(formatCeremonyError(err, ceremony), "error"); enable(); prefetch(); // a used/failed ceremony's options are spent — refresh for a retry }; // ---- boot --------------------------------------------------------------------- if (!payload) { setStatus("This page was opened without a valid request. Go back to your chat and try again.", "error"); $("actions").classList.add("hidden"); $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead } else { // The payload is valid, so the tile can name the chat even when the ceremony // can't run (a placeholder "This chat" under an error would mislead). const host = new URL(payload.entry.url).host; $("entry-name").textContent = payload.entry.name || host; $("entry-host").textContent = host; if (entryIcon) { const img = $("entry-icon"); img.src = entryIcon; // validated data:image URI; set via .src, never innerHTML img.classList.remove("hidden"); $("entry-glyph").classList.add("hidden"); // the photo takes the glyph's spot } } if (payload && !window.PublicKeyCredential) { setStatus("This browser can't sign you in — try another browser.", "error"); $("actions").classList.add("hidden"); } else if (payload) { // Pre-fetch ceremony options now, while the user reads the card — so either // button's click reaches the authenticator inside the gesture (Safari // activation, see prefetch): regBegin covers "I'm new", loginBegin the sign-in. prefetch(); $("save").addEventListener("click", () => saveSecurely().catch((e) => onError(e, "signin"))); $("create-new").addEventListener("click", () => createAndAdd().catch((e) => onError(e, "create"))); // A way back without saving (same-tab navigation replaced the chat). const back = document.createElement("button"); back.className = "secondary"; back.textContent = "Cancel"; back.addEventListener("click", () => goBack(false)); $("actions").append(back); // A post-claim handoff from the signup-first join (payload.auto): the tab // signed in at home moments ago, so try the live-session fast path without // a tap. Auto NEVER runs a ceremony and never skips the replace guard — if // the session can't complete the save silently, the normal card stands and // the buttons above do their usual work. if (payload.auto) { disable(); trySessionReuse() .then((done) => { if (!done) enable(); }) .catch((e) => onError(e, "signin")); } }