eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

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
14import {
15 b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, exportDEK, importDEK, unpackBlob, openBlob, wrapToPublicKey,
16} from "/static/crypto.js";
17import { beginLogin, runLogin, fetchBlob } from "/static/backup-core.js";
18import { formatCeremonyError } from "/static/ceremony-errors.js";
19import { validRestorePayload, cliReturnURL } from "/static/restore-payload.js";
20
21const $ = (id) => document.getElementById(id);
22function 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).
32function 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
45function 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
61const payload = readPayload();
62// Drop the fragment from the address bar as soon as we've read it.
63try { history.replaceState(null, "", location.pathname); } catch {}
64
65const retOrigin = payload && payload.ret ? new URL(payload.ret).origin : null;
66
67// Ceremony options are pre-fetched (Safari activation — see the enrol page).
68let loginBegin = null;
69function 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.
79async 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
99async 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
223const 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).
239const entryName = payload && payload.ret ? new URL(payload.ret).host
240 : payload && payload.cli === 1 ? "your terminal" : "the Eleven app";
241
242if (!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