// The restore page. A signed-out instance (e.g. chat.paulca.com) navigates here // (same tab) to sign back in from the user's passkey-protected backup. Passkeys // are bound to THIS origin, so the instance can't run the ceremony — it hands us // { ret, nonce, ephPub } in the URL fragment (ret = which instance, ephPub = an // ephemeral public key it just generated). We unlock the blob with the passkey, // find that instance's entry, ECIES-wrap its credentials to ephPub (so they're // never plaintext in the return URL), and redirect back to the instance with the // wrapped blob + nonce. The instance verifies the nonce and unwraps with the // ephemeral private key it kept locally. // // We return ONLY the entry matching ret — never the whole list. A page that // sends us a ret we don't have in the backup gets nothing back. import { b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, importDEK, unpackBlob, openBlob, wrapToPublicKey, } from "/static/crypto.js"; import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js"; import { formatCeremonyError } from "/static/ceremony-errors.js"; import { validRestorePayload, cliReturnURL } from "/static/restore-payload.js"; const $ = (id) => document.getElementById(id); function setStatus(msg, kind) { const el = $("status"); el.textContent = msg || ""; el.className = "status" + (kind ? " " + kind : ""); } // The tab's final resting state after an app handoff: the user's next glance // at this window should read "finished", not a stuck spinner. Headline and sub // flip to done-tense and the busy status clears (the sealed badge keeps the // green check as the visual full stop). function allDone() { document.title = "Signed in"; for (const [id, text] of [ ["headline", "You’re all done"], ["sub", "You’re signed in to the Eleven app. You can close this window now."], ]) { const el = $(id); el.textContent = text; el.classList.add("swap-in"); } setStatus(""); } function readPayload() { const raw = location.hash.slice(1); if (!raw) return null; try { // Shape rules live in restore-payload.js (pure, Node-tested). App handoff: // the native app generated {nonce, ephPub} and opened this page because its // own passkey provider can't do PRF (e.g. 1Password's native macOS // provider) — its return target is the FIXED custom scheme in restore(), // never caller-supplied. CLI handoff: the same idea from a terminal, // returned to a loopback port (or shown as a paste code for SSH sessions). return validRestorePayload(JSON.parse(new TextDecoder().decode(b64urlToBytes(raw)))); } catch { return null; } } const payload = readPayload(); // Drop the fragment from the address bar as soon as we've read it. try { history.replaceState(null, "", location.pathname); } catch {} const retOrigin = payload && payload.ret ? new URL(payload.ret).origin : null; // Ceremony options are pre-fetched (Safari activation — see the enrol page). let loginBegin = null; function prefetch() { return beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); } // A click in home's decrypted space list stays in the same tab, so home's // origin-scoped sessionStorage survives the round trip home → space → /restore. // Reusing its token + DEK makes that click one smooth handoff without another // passkey prompt. Both are still browser-only secrets; the server sees neither // the DEK nor any decrypted vault content. A stale session simply falls back to // the normal ceremony below. async function unlockedHomeSession() { const token = sessionStorage.getItem("home_token") || ""; const rawDEK = sessionStorage.getItem("home_dek") || ""; // The credential id rides along for the handoff session (the app stores // which passkey wraps its DEK); missing is fine — "" decodes downstream. const credId = sessionStorage.getItem("home_cred") || ""; if (!token || !rawDEK) return null; try { const dek = await importDEK(rawDEK); const got = await fetchBlob(token); if (!got) return null; // Validate the saved DEK now and let the instance restore reuse the opened // vault instead of decrypting it twice. const opened = await openBlob(got.bytes, dek); return { token, credId, dek, got, opened }; } catch { return null; } } async function restore() { $("restore").disabled = true; let token, credId, dek, got, opened; // Only an origin-bound instance restore may reuse the unlocked list. App and // CLI payloads export the whole home session, so they always retain their // explicit passkey ceremony and fixed-target security boundary. const unlocked = payload && payload.ret ? await unlockedHomeSession() : null; if (unlocked) { ({ token, credId, dek, got, opened } = unlocked); setStatus("Unlocked. Opening your chat…", "busy"); } else { setStatus("Confirming it’s you…", "busy"); const login = await runLogin(loginBegin); token = login.token; credId = login.credId; if (!login.prf) { setStatus("Signed in, but this browser can't unlock your spaces here. Try another browser.", "error"); $("restore").disabled = false; return; } got = await fetchBlob(token); if (!got) { setStatus("Nothing saved for this sign-in yet.", "error"); $("restore").disabled = false; return; } const kek = await kekFromPRF(login.prf); const { keys } = unpackBlob(got.bytes); const wrapped = keys[login.credId]; if (!wrapped) { setStatus("That sign-in can't unlock these spaces.", "error"); $("restore").disabled = false; return; } dek = await unwrapDEK(wrapped, kek); } // App handoff: hand the SESSION (link token + blob key) to the native app, // ECIES-wrapped to the ephemeral public key it generated — nothing is ever // plaintext in the return URL, and only the initiating process holds the // matching private key. The custom scheme is hardcoded, so a crafted page // payload can never redirect the session anywhere else. if (payload.app === 1) { const dekB64 = await exportDEK(dek); const session = JSON.stringify({ v: 1, token, credId, dek: dekB64 }); const sealed = await wrapToPublicKey(bytesToB64url(new TextEncoder().encode(session)), payload.ephPub); const back = JSON.stringify({ nonce: payload.nonce, wrapped: sealed }); setStatus("Unlocked. Handing over to the Eleven app…", "ok"); $("actions").classList.add("hidden"); $("badge").classList.add("sealed"); setTimeout(() => { // The initiating build names its own registered scheme (dev alts are // suffixed); validated in restore-payload.js, plain-app fallback. location.replace((payload.scheme || "elevenmessenger") + "://home-session#" + bytesToB64url(new TextEncoder().encode(back))); // A custom-scheme redirect switches to the app but never navigates this // tab, so it would sit on "Signing in…" forever. Settle on a final state // a beat later — long enough that the handover line registers first. setTimeout(allDone, 700); }, 600); return; } // CLI handoff: same session hand-over as the app path, different last hop — // a redirect to the loopback listener `eleven signin` opened (127.0.0.1 is // hardcoded in cliReturnURL, so a crafted payload can only pick the port), // or, when the payload carries no port (a terminal SSH'd into another // machine can't receive a local redirect), a code the user copies back into // the terminal. Either way the session is ciphertext to everything but the // process holding the ephemeral private key. if (payload.cli === 1) { const dekB64 = await exportDEK(dek); const session = JSON.stringify({ v: 1, token, credId, dek: dekB64 }); const sealed = await wrapToPublicKey(bytesToB64url(new TextEncoder().encode(session)), payload.ephPub); const back = bytesToB64url(new TextEncoder().encode( JSON.stringify({ nonce: payload.nonce, wrapped: sealed }))); $("actions").classList.add("hidden"); $("badge").classList.add("sealed"); if (payload.port) { setStatus("Unlocked. Handing back to your terminal…", "ok"); setTimeout(() => location.replace(cliReturnURL(payload.port, back)), 600); return; } setStatus("Unlocked. Paste this code into your terminal:", "ok"); $("code").value = back; $("code-wrap").classList.remove("hidden"); $("copy").addEventListener("click", async () => { try { await navigator.clipboard.writeText(back); $("copy").textContent = "Copied"; } catch { $("code").select(); // clipboard blocked — leave the text selected to copy by hand } }); return; } const { obj } = opened || await openBlob(got.bytes, dek); // Only the entry whose origin matches the requester (ret) is ever returned. const host = new URL(payload.ret).host; const entry = (obj.servers || []).find((s) => { try { return new URL(s.url).origin === retOrigin; } catch { return false; } }); if (!entry || !entry.credentials) { setStatus(`Your Eleven account doesn't include ${host}. Open the chat from an invite instead.`, "error"); $("restore").disabled = false; return; } // Wrap the credentials to the instance's ephemeral public key — ciphertext in // the return URL, decryptable only by the ephemeral private key it kept. const credsB64 = bytesToB64url(new TextEncoder().encode(JSON.stringify(entry.credentials))); const sealed = await wrapToPublicKey(credsB64, payload.ephPub); const back = JSON.stringify({ nonce: payload.nonce, wrapped: sealed }); const frag = "#lchat_restore=" + encodeURIComponent(bytesToB64url(new TextEncoder().encode(back))); setStatus("Unlocked. Taking you back to your chat…", "ok"); $("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"); setTimeout(() => location.replace(retOrigin + "/" + frag), 600); } const onError = (err) => { // Friendly, specific failure copy (ceremony-errors.js) with the technical // crumb parenthesised — kept on purpose for debugging member reports. setStatus(formatCeremonyError(err, "signin"), "error"); // Reveal the retry button — the auto-run keeps it hidden until it's needed // (a cancelled sheet, or a browser that refuses a no-gesture ceremony). $("actions").classList.remove("hidden"); $("restore").disabled = false; prefetch(); // spent options — refresh for a retry }; // ---- boot --------------------------------------------------------------------- // The tile names what's being signed in: the requesting chat's host, or the // native app / terminal for a handoff (those carry no ret URL — reading .ret // unguarded here threw before the ceremony could start). const entryName = payload && payload.ret ? new URL(payload.ret).host : payload && payload.cli === 1 ? "your terminal" : "the Eleven app"; if (!payload) { setStatus("This page was opened without a valid request. Go back to your chat and tap Sign in.", "error"); $("actions").classList.add("hidden"); $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead } else if (!window.PublicKeyCredential) { // The payload is valid, so the tile can still name the chat under the error. $("entry-host").textContent = entryName; setStatus("This browser can't sign you in — try another browser.", "error"); $("actions").classList.add("hidden"); } else { $("entry-host").textContent = entryName; $("restore").addEventListener("click", () => restore().catch(onError)); // The user already clicked "Sign in" on the instance — run the ceremony // immediately; this page is a loading screen. If the browser refuses a // no-gesture ceremony (Safari can, as NotAllowedError) or the sheet is // cancelled, onError reveals the button so one tap retries with real // user activation. prefetch().then(() => restore()).catch(onError); }