| 1 | // The restore page. A signed-out instance (e.g. chat.paulca.com) navigates here |
| 2 | // (same tab) to sign back in from the user's passkey-protected backup. Passkeys |
| 3 | // are bound to THIS origin, so the instance can't run the ceremony — it hands us |
| 4 | // { ret, nonce, ephPub } in the URL fragment (ret = which instance, ephPub = an |
| 5 | // ephemeral public key it just generated). We unlock the blob with the passkey, |
| 6 | // find that instance's entry, ECIES-wrap its credentials to ephPub (so they're |
| 7 | // never plaintext in the return URL), and redirect back to the instance with the |
| 8 | // wrapped blob + nonce. The instance verifies the nonce and unwraps with the |
| 9 | // ephemeral private key it kept locally. |
| 10 | // |
| 11 | // We return ONLY the entry matching ret — never the whole list. A page that |
| 12 | // sends us a ret we don't have in the backup gets nothing back. |
| 13 | |
| 14 | import { |
| 15 | b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, importDEK, unpackBlob, openBlob, wrapToPublicKey, |
| 16 | } from "/static/crypto.js"; |
| 17 | import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js"; |
| 18 | import { formatCeremonyError } from "/static/ceremony-errors.js"; |
| 19 | import { validRestorePayload, cliReturnURL } from "/static/restore-payload.js"; |
| 20 | |
| 21 | const $ = (id) => document.getElementById(id); |
| 22 | function setStatus(msg, kind) { |
| 23 | const el = $("status"); |
| 24 | el.textContent = msg || ""; |
| 25 | el.className = "status" + (kind ? " " + kind : ""); |
| 26 | } |
| 27 | |
| 28 | // The tab's final resting state after an app handoff: the user's next glance |
| 29 | // at this window should read "finished", not a stuck spinner. Headline and sub |
| 30 | // flip to done-tense and the busy status clears (the sealed badge keeps the |
| 31 | // green check as the visual full stop). |
| 32 | function allDone() { |
| 33 | document.title = "Signed in"; |
| 34 | for (const [id, text] of [ |
| 35 | ["headline", "You’re all done"], |
| 36 | ["sub", "You’re signed in to the Eleven app. You can close this window now."], |
| 37 | ]) { |
| 38 | const el = $(id); |
| 39 | el.textContent = text; |
| 40 | el.classList.add("swap-in"); |
| 41 | } |
| 42 | setStatus(""); |
| 43 | } |
| 44 | |
| 45 | function readPayload() { |
| 46 | const raw = location.hash.slice(1); |
| 47 | if (!raw) return null; |
| 48 | try { |
| 49 | // Shape rules live in restore-payload.js (pure, Node-tested). App handoff: |
| 50 | // the native app generated {nonce, ephPub} and opened this page because its |
| 51 | // own passkey provider can't do PRF (e.g. 1Password's native macOS |
| 52 | // provider) — its return target is the FIXED custom scheme in restore(), |
| 53 | // never caller-supplied. CLI handoff: the same idea from a terminal, |
| 54 | // returned to a loopback port (or shown as a paste code for SSH sessions). |
| 55 | return validRestorePayload(JSON.parse(new TextDecoder().decode(b64urlToBytes(raw)))); |
| 56 | } catch { |
| 57 | return null; |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | const payload = readPayload(); |
| 62 | // Drop the fragment from the address bar as soon as we've read it. |
| 63 | try { history.replaceState(null, "", location.pathname); } catch {} |
| 64 | |
| 65 | const retOrigin = payload && payload.ret ? new URL(payload.ret).origin : null; |
| 66 | |
| 67 | // Ceremony options are pre-fetched (Safari activation — see the enrol page). |
| 68 | let loginBegin = null; |
| 69 | function prefetch() { |
| 70 | return beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); |
| 71 | } |
| 72 | |
| 73 | // A click in home's decrypted space list stays in the same tab, so home's |
| 74 | // origin-scoped sessionStorage survives the round trip home → space → /restore. |
| 75 | // Reusing its token + DEK makes that click one smooth handoff without another |
| 76 | // passkey prompt. Both are still browser-only secrets; the server sees neither |
| 77 | // the DEK nor any decrypted vault content. A stale session simply falls back to |
| 78 | // the normal ceremony below. |
| 79 | async function unlockedHomeSession() { |
| 80 | const token = sessionStorage.getItem("home_token") || ""; |
| 81 | const rawDEK = sessionStorage.getItem("home_dek") || ""; |
| 82 | // The credential id rides along for the handoff session (the app stores |
| 83 | // which passkey wraps its DEK); missing is fine — "" decodes downstream. |
| 84 | const credId = sessionStorage.getItem("home_cred") || ""; |
| 85 | if (!token || !rawDEK) return null; |
| 86 | try { |
| 87 | const dek = await importDEK(rawDEK); |
| 88 | const got = await fetchBlob(token); |
| 89 | if (!got) return null; |
| 90 | // Validate the saved DEK now and let the instance restore reuse the opened |
| 91 | // vault instead of decrypting it twice. |
| 92 | const opened = await openBlob(got.bytes, dek); |
| 93 | return { token, credId, dek, got, opened }; |
| 94 | } catch { |
| 95 | return null; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | async function restore() { |
| 100 | $("restore").disabled = true; |
| 101 | |
| 102 | let token, credId, dek, got, opened; |
| 103 | // Only an origin-bound instance restore may reuse the unlocked list. App and |
| 104 | // CLI payloads export the whole home session, so they always retain their |
| 105 | // explicit passkey ceremony and fixed-target security boundary. |
| 106 | const unlocked = payload && payload.ret ? await unlockedHomeSession() : null; |
| 107 | if (unlocked) { |
| 108 | ({ token, credId, dek, got, opened } = unlocked); |
| 109 | setStatus("Unlocked. Opening your chat…", "busy"); |
| 110 | } else { |
| 111 | setStatus("Confirming it’s you…", "busy"); |
| 112 | const login = await runLogin(loginBegin); |
| 113 | token = login.token; |
| 114 | credId = login.credId; |
| 115 | if (!login.prf) { |
| 116 | setStatus("Signed in, but this browser can't unlock your spaces here. Try another browser.", "error"); |
| 117 | $("restore").disabled = false; |
| 118 | return; |
| 119 | } |
| 120 | got = await fetchBlob(token); |
| 121 | if (!got) { |
| 122 | setStatus("Nothing saved for this sign-in yet.", "error"); |
| 123 | $("restore").disabled = false; |
| 124 | return; |
| 125 | } |
| 126 | const kek = await kekFromPRF(login.prf); |
| 127 | const { keys } = unpackBlob(got.bytes); |
| 128 | const wrapped = keys[login.credId]; |
| 129 | if (!wrapped) { |
| 130 | setStatus("That sign-in can't unlock these spaces.", "error"); |
| 131 | $("restore").disabled = false; |
| 132 | return; |
| 133 | } |
| 134 | dek = await unwrapDEK(wrapped, kek); |
| 135 | } |
| 136 | |
| 137 | // App handoff: hand the SESSION (link token + blob key) to the native app, |
| 138 | // ECIES-wrapped to the ephemeral public key it generated — nothing is ever |
| 139 | // plaintext in the return URL, and only the initiating process holds the |
| 140 | // matching private key. The custom scheme is hardcoded, so a crafted page |
| 141 | // payload can never redirect the session anywhere else. |
| 142 | if (payload.app === 1) { |
| 143 | const dekB64 = await exportDEK(dek); |
| 144 | const session = JSON.stringify({ v: 1, token, credId, dek: dekB64 }); |
| 145 | const sealed = await wrapToPublicKey(bytesToB64url(new TextEncoder().encode(session)), payload.ephPub); |
| 146 | const back = JSON.stringify({ nonce: payload.nonce, wrapped: sealed }); |
| 147 | setStatus("Unlocked. Handing over to the Eleven app…", "ok"); |
| 148 | $("actions").classList.add("hidden"); |
| 149 | $("badge").classList.add("sealed"); |
| 150 | setTimeout(() => { |
| 151 | // The initiating build names its own registered scheme (dev alts are |
| 152 | // suffixed); validated in restore-payload.js, plain-app fallback. |
| 153 | location.replace((payload.scheme || "elevenmessenger") + "://home-session#" + |
| 154 | bytesToB64url(new TextEncoder().encode(back))); |
| 155 | // A custom-scheme redirect switches to the app but never navigates this |
| 156 | // tab, so it would sit on "Signing in…" forever. Settle on a final state |
| 157 | // a beat later — long enough that the handover line registers first. |
| 158 | setTimeout(allDone, 700); |
| 159 | }, 600); |
| 160 | return; |
| 161 | } |
| 162 | |
| 163 | // CLI handoff: same session hand-over as the app path, different last hop — |
| 164 | // a redirect to the loopback listener `eleven signin` opened (127.0.0.1 is |
| 165 | // hardcoded in cliReturnURL, so a crafted payload can only pick the port), |
| 166 | // or, when the payload carries no port (a terminal SSH'd into another |
| 167 | // machine can't receive a local redirect), a code the user copies back into |
| 168 | // the terminal. Either way the session is ciphertext to everything but the |
| 169 | // process holding the ephemeral private key. |
| 170 | if (payload.cli === 1) { |
| 171 | const dekB64 = await exportDEK(dek); |
| 172 | const session = JSON.stringify({ v: 1, token, credId, dek: dekB64 }); |
| 173 | const sealed = await wrapToPublicKey(bytesToB64url(new TextEncoder().encode(session)), payload.ephPub); |
| 174 | const back = bytesToB64url(new TextEncoder().encode( |
| 175 | JSON.stringify({ nonce: payload.nonce, wrapped: sealed }))); |
| 176 | $("actions").classList.add("hidden"); |
| 177 | $("badge").classList.add("sealed"); |
| 178 | if (payload.port) { |
| 179 | setStatus("Unlocked. Handing back to your terminal…", "ok"); |
| 180 | setTimeout(() => location.replace(cliReturnURL(payload.port, back)), 600); |
| 181 | return; |
| 182 | } |
| 183 | setStatus("Unlocked. Paste this code into your terminal:", "ok"); |
| 184 | $("code").value = back; |
| 185 | $("code-wrap").classList.remove("hidden"); |
| 186 | $("copy").addEventListener("click", async () => { |
| 187 | try { |
| 188 | await navigator.clipboard.writeText(back); |
| 189 | $("copy").textContent = "Copied"; |
| 190 | } catch { |
| 191 | $("code").select(); // clipboard blocked — leave the text selected to copy by hand |
| 192 | } |
| 193 | }); |
| 194 | return; |
| 195 | } |
| 196 | const { obj } = opened || await openBlob(got.bytes, dek); |
| 197 | |
| 198 | // Only the entry whose origin matches the requester (ret) is ever returned. |
| 199 | const host = new URL(payload.ret).host; |
| 200 | const entry = (obj.servers || []).find((s) => { |
| 201 | try { return new URL(s.url).origin === retOrigin; } catch { return false; } |
| 202 | }); |
| 203 | if (!entry || !entry.credentials) { |
| 204 | setStatus(`Your Eleven account doesn't include ${host}. Open the chat from an invite instead.`, "error"); |
| 205 | $("restore").disabled = false; |
| 206 | return; |
| 207 | } |
| 208 | |
| 209 | // Wrap the credentials to the instance's ephemeral public key — ciphertext in |
| 210 | // the return URL, decryptable only by the ephemeral private key it kept. |
| 211 | const credsB64 = bytesToB64url(new TextEncoder().encode(JSON.stringify(entry.credentials))); |
| 212 | const sealed = await wrapToPublicKey(credsB64, payload.ephPub); |
| 213 | const back = JSON.stringify({ nonce: payload.nonce, wrapped: sealed }); |
| 214 | const frag = "#lchat_restore=" + encodeURIComponent(bytesToB64url(new TextEncoder().encode(back))); |
| 215 | setStatus("Unlocked. Taking you back to your chat…", "ok"); |
| 216 | $("actions").classList.add("hidden"); |
| 217 | // Seal the tile's key badge (→ green check) and hold a short beat before |
| 218 | // leaving, so success is legible instead of a flash before the redirect. |
| 219 | $("badge").classList.add("sealed"); |
| 220 | setTimeout(() => location.replace(retOrigin + "/" + frag), 600); |
| 221 | } |
| 222 | |
| 223 | const onError = (err) => { |
| 224 | // Friendly, specific failure copy (ceremony-errors.js) with the technical |
| 225 | // crumb parenthesised — kept on purpose for debugging member reports. |
| 226 | setStatus(formatCeremonyError(err, "signin"), "error"); |
| 227 | // Reveal the retry button — the auto-run keeps it hidden until it's needed |
| 228 | // (a cancelled sheet, or a browser that refuses a no-gesture ceremony). |
| 229 | $("actions").classList.remove("hidden"); |
| 230 | $("restore").disabled = false; |
| 231 | prefetch(); // spent options — refresh for a retry |
| 232 | }; |
| 233 | |
| 234 | // ---- boot --------------------------------------------------------------------- |
| 235 | |
| 236 | // The tile names what's being signed in: the requesting chat's host, or the |
| 237 | // native app / terminal for a handoff (those carry no ret URL — reading .ret |
| 238 | // unguarded here threw before the ceremony could start). |
| 239 | const entryName = payload && payload.ret ? new URL(payload.ret).host |
| 240 | : payload && payload.cli === 1 ? "your terminal" : "the Eleven app"; |
| 241 | |
| 242 | if (!payload) { |
| 243 | setStatus("This page was opened without a valid request. Go back to your chat and tap Sign in.", "error"); |
| 244 | $("actions").classList.add("hidden"); |
| 245 | $("entry").classList.add("hidden"); // no entry to show — a placeholder tile would mislead |
| 246 | } else if (!window.PublicKeyCredential) { |
| 247 | // The payload is valid, so the tile can still name the chat under the error. |
| 248 | $("entry-host").textContent = entryName; |
| 249 | setStatus("This browser can't sign you in — try another browser.", "error"); |
| 250 | $("actions").classList.add("hidden"); |
| 251 | } else { |
| 252 | $("entry-host").textContent = entryName; |
| 253 | $("restore").addEventListener("click", () => restore().catch(onError)); |
| 254 | // The user already clicked "Sign in" on the instance — run the ceremony |
| 255 | // immediately; this page is a loading screen. If the browser refuses a |
| 256 | // no-gesture ceremony (Safari can, as NotAllowedError) or the sheet is |
| 257 | // cancelled, onError reveals the button so one tap retries with real |
| 258 | // user activation. |
| 259 | prefetch().then(() => restore()).catch(onError); |
| 260 | } |
| 261 | |