eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Passkey backup enrolment (the messenger side of the cross-origin handoff).
2//
3// Passkeys are bound to the HOME origin, so this instance can't run the WebAuthn
4// ceremony itself — it navigates (same tab) to the home server's /enroll page,
5// handing over the current session in the URL *fragment* (fragments never reach
6// any server, same model as invite secrets). /enroll runs the ceremony as a
7// plain top-level page — Safari blocks WebAuthn in a cross-origin-opened popup —
8// then redirects back here with a one-time nonce we can verify. No secret ever
9// returns; only the nonce marker.
10//
11// The reverse — RESTORE — brings a signed-out browser back in: it navigates to
12// /restore with an ephemeral public key, the home server wraps the stored
13// credentials to it (never plaintext in the return URL), and we unwrap and adopt
14// the session here. A one-time nonce ties the return to a restore we started.
15//
16// A small inline banner in the sidebar nudges un-backed-up users to enrol. It
17// occupies layout space — never a floating toast (see CLAUDE.md).
18
19// Relative specifier (not /static/) so this module resolves both in the browser
20// — where it loads as /static/backup.js, making ./crypto.js == /static/crypto.js
21// — and in Node unit tests that import it from web/static/.
22import { bytesToB64url, b64urlToBytes, generateUserKeypair, unwrapThreadKey } from "./crypto.js";
23
24const SESSION_KEY = "lchat_session"; // where shell.js keeps the signed-in session
25
26// localStorage keys. localStorage is already per-origin, so "enrolled" naturally
27// means "this instance is backed up" without us keying by origin ourselves.
28const ENROLLED_KEY = "lchat_home_enrolled"; // "1" once the home server confirms
29const SNOOZE_KEY = "lchat_backup_snooze"; // unix-ms timestamp the snooze expires at
30const DISMISSED_KEY = "lchat_backup_dismissed"; // "1" = opted out for good
31const PENDING_KEY = "lchat_home_enrol_pending"; // nonce for the in-flight enrolment
32const RETURN_MARK = "lchat_backup"; // return fragment key: #lchat_backup=<nonce>
33const RESTORE_PENDING_KEY = "lchat_home_restore_pending"; // {nonce, ephPriv} in flight
34const RESTORE_MARK = "lchat_restore"; // return fragment key: #lchat_restore=<blob>
35
36const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; // "remind me later" = ~7 days
37
38// The escalating reminder schedule after someone SKIPS the key backup in the
39// setup conversation: quiet for a day, then a nudge, again at three days, and a
40// final one at a week — after which the classic snooze/opt-out cycle takes
41// over. Eleven already delivered the double-prompt in chat, so the banner
42// doesn't pile on immediately.
43export const REMIND_RUNGS_MS = [24, 72, 168].map((h) => h * 60 * 60 * 1000);
44const DEFERRED_KEY = "lchat_backup_deferred_at"; // unix-ms the setup skip happened
45const STAGE_KEY = "lchat_backup_remind_stage"; // reminders dismissed so far (0..)
46
47// backupBannerState is the PURE visibility rule (no DOM, no storage), so it's
48// unit-testable. Enrolment and a permanent dismissal hide it forever; an active
49// deferral schedule (a setup-time skip) hides it until its next rung is due; a
50// live snooze hides it until it expires; otherwise we nudge.
51export function backupBannerState({ enrolled, dismissed, snoozedUntil, deferredAt, remindStage, now }) {
52 if (enrolled) return "hidden";
53 if (dismissed) return "hidden";
54 if (deferredAt && (remindStage || 0) < REMIND_RUNGS_MS.length) {
55 return now < deferredAt + REMIND_RUNGS_MS[remindStage || 0] ? "hidden" : "show";
56 }
57 if (snoozedUntil && now < snoozedUntil) return "hidden";
58 return "show";
59}
60
61// deferBackupNudges starts the reminder schedule: called when the setup
62// conversation's key step is skipped, so the banner stays quiet for the first
63// day instead of contradicting the conversation that just happened.
64export function deferBackupNudges() {
65 localStorage.setItem(DEFERRED_KEY, String(Date.now()));
66 localStorage.setItem(STAGE_KEY, "0");
67}
68
69function deferredAt() {
70 const n = parseInt(localStorage.getItem(DEFERRED_KEY) || "", 10);
71 return Number.isFinite(n) ? n : 0;
72}
73function remindStage() {
74 const n = parseInt(localStorage.getItem(STAGE_KEY) || "", 10);
75 return Number.isFinite(n) ? n : 0;
76}
77
78// isEnrolled reads the local record set once the home server confirms a backup.
79export function isEnrolled() {
80 return localStorage.getItem(ENROLLED_KEY) === "1";
81}
82
83function isDismissed() {
84 return localStorage.getItem(DISMISSED_KEY) === "1";
85}
86
87// Snooze expiry as unix-ms (0 when unset / unparseable → treated as not snoozed).
88function snoozedUntil() {
89 const n = parseInt(localStorage.getItem(SNOOZE_KEY) || "", 10);
90 return Number.isFinite(n) ? n : 0;
91}
92
93// The group name rides along for the home server's confirmation UI. shell.js puts
94// the (substituted) group name in the page title; fall back to the host.
95function groupName() {
96 const t = (document.title || "").trim();
97 return t || location.host;
98}
99
100// The instance branding photo as a small data: URI, prefetched when the backup
101// banner renders so launchEnrol can hand it to the home server *synchronously*
102// (awaiting a fetch inside launchEnrol would risk the click gesture the native
103// window.open relies on). Stays null when this instance has no photo set.
104let serverIconDataURI = null;
105
106async function prefetchServerIcon() {
107 if (serverIconDataURI) return;
108 try {
109 const g = await (await fetch("/api/group")).json();
110 if (!g.avatar) return;
111 const blob = await (await fetch("/api/server-avatar")).blob();
112 serverIconDataURI = await shrinkToDataURI(blob, 96);
113 } catch {}
114}
115
116// Shrink an image blob to a small JPEG data: URI for the handoff fragment (kept
117// tiny so the URL stays short — it's display-only chrome). Returns null on any
118// failure, so the icon is simply omitted.
119async function shrinkToDataURI(blob, maxDim) {
120 try {
121 const bitmap = await createImageBitmap(blob);
122 const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
123 const w = Math.round(bitmap.width * scale);
124 const h = Math.round(bitmap.height * scale);
125 const canvas = document.createElement("canvas");
126 canvas.width = w;
127 canvas.height = h;
128 canvas.getContext("2d").drawImage(bitmap, 0, 0, w, h);
129 return canvas.toDataURL("image/jpeg", 0.8);
130 } catch {
131 return null;
132 }
133}
134
135function bannerEl() {
136 return document.getElementById("backup-banner");
137}
138
139function removeBanner() {
140 const el = bannerEl();
141 if (!el) return;
142 el.textContent = "";
143 el.classList.add("hidden");
144}
145
146// initBackupBanner renders the inline nudge into #backup-banner when the state
147// rule says to show it. opts.now lets a caller pin "now" (defaults to real time).
148export function initBackupBanner(session, opts = {}) {
149 if (!session || !session.token) return; // nothing to back up
150 // If we've just returned from the home server's /enroll page, record the
151 // backup (and scrub the marker) before deciding whether to nudge.
152 checkEnrolReturn();
153 // An enrolment that's still pending (the redirect never reached us — see
154 // startEnrolPoll) may have finished in another browsing context: ask the
155 // home server. Runs before the suppress bail so a mid-conversation reload
156 // still picks the completion up.
157 startEnrolPoll();
158 // While the setup conversation is running, backup messaging is Eleven's job —
159 // one voice, not a sidebar banner competing with the in-chat key ceremony.
160 if (opts.suppress) return;
161 const el = bannerEl();
162 if (!el) return;
163 const state = backupBannerState({
164 enrolled: isEnrolled(),
165 dismissed: isDismissed(),
166 snoozedUntil: snoozedUntil(),
167 deferredAt: deferredAt(),
168 remindStage: remindStage(),
169 now: opts.now ?? Date.now(),
170 });
171 if (state !== "show") return; // leaves it .hidden
172 renderBanner(el, session);
173}
174
175function renderBanner(el, session) {
176 el.textContent = "";
177 prefetchServerIcon(); // ready the branding photo before the user taps "Sign in"
178
179 const title = document.createElement("div");
180 title.className = "backup-banner-title";
181 title.textContent = "Keep your access safe";
182
183 const body = document.createElement("div");
184 body.className = "backup-banner-body";
185 body.textContent =
186 "Sign in with Eleven and you can get back to your chats anytime, on any device — one tap, nothing to remember.";
187
188 const cta = document.createElement("button");
189 cta.className = "backup-banner-cta";
190 cta.textContent = "Sign in";
191 cta.addEventListener("click", () => launchEnrol(session));
192
193 // Dismissal. On the deferral schedule's early rungs (24h / 72h), "later" just
194 // advances to the next rung. The final rung — and the no-schedule case — keeps
195 // the double-dismiss: first click snoozes for a week and escalates the label
196 // to spell out the consequence; a second click on the escalated control opts
197 // out for good. "I never want this" stays a deliberate, informed choice.
198 const dismiss = document.createElement("button");
199 dismiss.className = "backup-banner-dismiss linkish";
200 dismiss.textContent = "Remind me later";
201 let escalated = false;
202 dismiss.addEventListener("click", () => {
203 const stage = remindStage();
204 if (deferredAt() && stage < REMIND_RUNGS_MS.length - 1) {
205 // Re-anchor the schedule at THIS dismissal: the rungs are minimum gaps
206 // from the last time the user waved it away, not absolute deadlines — or
207 // a user who first sees the banner days late would dismiss it only to
208 // have an already-elapsed rung bring it straight back.
209 localStorage.setItem(DEFERRED_KEY, String(Date.now()));
210 localStorage.setItem(STAGE_KEY, String(stage + 1));
211 removeBanner();
212 return;
213 }
214 if (!escalated) {
215 localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
216 // Leaving the final rung: the classic snooze cycle owns it from here.
217 if (deferredAt()) localStorage.setItem(STAGE_KEY, String(REMIND_RUNGS_MS.length));
218 dismiss.textContent = "I understand — I can lose access to this chat";
219 dismiss.classList.add("danger");
220 escalated = true;
221 } else {
222 localStorage.setItem(DISMISSED_KEY, "1");
223 removeBanner();
224 }
225 });
226
227 el.append(title, body, cta, dismiss);
228 el.classList.remove("hidden");
229}
230
231// launchEnrol hands off to the home server's /enroll page via a SAME-TAB
232// navigation, with the session in the URL fragment (never sent to a server).
233//
234// Why same-tab and not a popup: Safari blocks WebAuthn in a window opened by a
235// cross-origin page (the popup's create() aborts with NotAllowedError the
236// instant it opens). A same-tab navigation makes /enroll a plain top-level page
237// — the context where the ceremony works — and the home server redirects back
238// here on success. A one-time nonce (stored locally, echoed in the return
239// fragment) makes that return signal unforgeable: another site can't send you
240// back with a fake success marker to suppress the backup reminder.
241export function launchEnrol(session, opts = {}) {
242 const home = window.__homeOrigin;
243 if (!home) return;
244 const native = isNativeWrapper();
245 // "Stay" contexts keep THIS page alive and run the ceremony elsewhere: the
246 // native wrapper opens the system browser, and an iOS home-screen app opens
247 // cross-origin links in an in-app overlay whose storage we don't share — a
248 // redirect back would land in the wrong context (and, in the overlay, boot
249 // the whole app inside it). In stay mode /enroll ends on a "done" screen
250 // instead of redirecting, and completion reaches us via startEnrolPoll.
251 const stay = native || isStandalonePWA();
252 const nonce = randomNonce();
253 localStorage.setItem(PENDING_KEY, nonce);
254 const payload = {
255 v: 1,
256 nonce, // echoed back in the return fragment to prove the redirect is genuine
257 ret: location.origin, // where /enroll sends the user back to (web only)
258 native: stay, // stay mode: /enroll ends on a "done" screen, no redirect back
259 // auto: a post-claim handoff from the signup-first join — /enroll tries its
260 // live-session fast path without a tap (and only that; see enroll.js).
261 auto: !!opts.auto,
262 entry: {
263 url: location.origin, // the instance being enrolled
264 name: groupName(), // best-effort, for the confirmation UI
265 // Small data: URI of the instance photo for the confirmation card. Omitted
266 // when unset/not-yet-prefetched (JSON.stringify drops undefined). Display
267 // only — enroll.js strips it before persisting the backup blob.
268 icon: serverIconDataURI || undefined,
269 credentials: {
270 token: session.token,
271 userPub: session.userPub,
272 userPriv: session.userPriv,
273 name: session.name,
274 },
275 },
276 };
277 const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload)));
278 const url = `${home}/enroll#${encoded}`; // payload rides in the #fragment — never sent to a server
279 if (stay) {
280 // Native wrapper: the ceremony can't run in the WKWebView, and navigating
281 // the instance's own webview cross-origin trips the app's join fallback —
282 // the app routes window.open to the system browser, where passkeys work.
283 // Standalone PWA: window.open keeps the app page alive under the overlay.
284 // Either way this page survives, so the poller below picks up completion
285 // and flips the flag the conversation flows are settling on. The native
286 // snooze stays as a belt-and-braces fallback should the poll miss.
287 if (native) localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
288 window.open(url, "_blank");
289 startEnrolPoll();
290 } else {
291 location.assign(url); // same-tab redirect: Safari blocks WebAuthn in a popup
292 }
293}
294
295function isNativeWrapper() {
296 return document.documentElement.classList.contains("native");
297}
298
299// Standalone (installed / home-screen) PWA display context — where cross-origin
300// navigations open an in-app browser overlay instead of leaving the page.
301function isStandalonePWA() {
302 try {
303 return (window.matchMedia && window.matchMedia("(display-mode: standalone)").matches) ||
304 window.navigator.standalone === true;
305 } catch {
306 return false;
307 }
308}
309
310// ---- enrolment completion poll ------------------------------------------------
311// The success redirect can land in a DIFFERENT browsing context than the page
312// that launched the enrolment: iOS home-screen apps open cross-origin links in
313// an in-app overlay with its own storage, and the native wrapper hands off to
314// the system browser. So the redirect's nonce marker (checkEnrolReturn) can't be
315// the only completion signal. While an enrolment is pending, ask the home
316// server's completion oracle — keyed by the same client-random nonce — and flip
317// the local flag the moment it confirms; the conversation flows' settle polls
318// (and the banner rules) advance from there on their own. An old home server
319// without the oracle 404s — we stop quietly and behave exactly as before.
320let enrolPollActive = false;
321
322export function startEnrolPoll() {
323 const home = window.__homeOrigin;
324 if (enrolPollActive || !home || isEnrolled() || !localStorage.getItem(PENDING_KEY)) return;
325 enrolPollActive = true;
326 const deadline = Date.now() + 10 * 60 * 1000; // long enough for a slow ceremony, not forever
327 let timer = null;
328 let misses = 0; // consecutive 404s → the home server has no oracle
329 const stop = () => {
330 enrolPollActive = false;
331 clearTimeout(timer);
332 document.removeEventListener("visibilitychange", kick);
333 };
334 const schedule = () => {
335 timer = setTimeout(tick, 3000);
336 };
337 // A hidden tab pauses the loop; coming back (tabbing back from the ceremony)
338 // checks immediately — that's the moment completion is most likely.
339 const kick = () => {
340 if (!document.hidden) {
341 clearTimeout(timer);
342 tick();
343 }
344 };
345 const tick = async () => {
346 if (!enrolPollActive) return;
347 const nonce = localStorage.getItem(PENDING_KEY);
348 if (!nonce || isEnrolled() || Date.now() > deadline) return stop();
349 if (document.hidden) return; // resumes via the visibilitychange kick
350 try {
351 const r = await fetch(`${home}/api/enroll-status?nonce=${encodeURIComponent(nonce)}`);
352 if (r.status === 404) {
353 if (++misses >= 3) return stop();
354 } else if (r.ok) {
355 misses = 0;
356 if ((await r.json()).done) {
357 localStorage.setItem(ENROLLED_KEY, "1");
358 localStorage.removeItem(PENDING_KEY);
359 removeBanner();
360 return stop();
361 }
362 }
363 } catch {}
364 schedule();
365 };
366 document.addEventListener("visibilitychange", kick);
367 schedule();
368}
369
370function randomNonce() {
371 const b = new Uint8Array(16);
372 crypto.getRandomValues(b);
373 return bytesToB64url(b);
374}
375
376// parseReturnNonce pulls our nonce out of a return fragment like
377// "#lchat_backup=<nonce>" (pure, so it's unit-testable). Returns "" if absent.
378export function parseReturnNonce(hash) {
379 const m = new RegExp("(?:^#|[#&])" + RETURN_MARK + "=([^&]+)").exec(hash || "");
380 return m ? decodeURIComponent(m[1]) : "";
381}
382
383// checkEnrolReturn runs on load: if we've come back from /enroll with a nonce
384// that matches the one we stored before leaving, record enrolment and scrub the
385// marker from the URL. A mismatched/forged marker is ignored (but still scrubbed).
386export function checkEnrolReturn() {
387 const got = parseReturnNonce(location.hash);
388 if (!got) return false;
389 const pending = localStorage.getItem(PENDING_KEY);
390 const ok = !!pending && got === pending;
391 if (ok) {
392 localStorage.setItem(ENROLLED_KEY, "1");
393 localStorage.removeItem(PENDING_KEY);
394 }
395 // Drop the marker fragment either way (the return URL carries only our marker).
396 try { history.replaceState(null, "", location.pathname + location.search); } catch {}
397 return ok;
398}
399
400// ---- restore (get back in on a fresh browser) --------------------------------
401
402// autoRestoreRequested recognises links from home's unlocked space list. Keep
403// this exact-value check in sync with the tiny pre-paint gate in index.html.
404export function autoRestoreRequested(search) {
405 return new URLSearchParams(search || "").get("restore") === "1";
406}
407
408// canRestore: offer restore only when a home server is configured and we're in a
409// real browser. In the native wrapper the passkey ceremony can't run in the
410// webview (native restore is a separate, later flow), so we don't offer it there.
411export function canRestore() {
412 return !!window.__homeOrigin && !isNativeWrapper();
413}
414
415// launchRestore navigates (same tab) to the home server's /restore page. We send
416// our origin (which instance to restore), a one-time nonce, and a freshly
417// generated ephemeral ECDH public key. The home server wraps our credentials to
418// that key so they never travel back in the clear; the private half stays here,
419// stashed across the navigation so we can unwrap on return.
420export async function launchRestore() {
421 const home = window.__homeOrigin;
422 if (!home) return;
423 const nonce = randomNonce();
424 const eph = await generateUserKeypair();
425 // sessionStorage, not localStorage: the ephemeral PRIVATE key is tab-scoped and
426 // an abandoned restore (tab closed mid-ceremony) is auto-cleared. It survives
427 // the same-tab navigation to /restore and back.
428 sessionStorage.setItem(RESTORE_PENDING_KEY, JSON.stringify({ nonce, ephPriv: eph.privateKey }));
429 const payload = { v: 1, ret: location.origin, nonce, ephPub: eph.publicKey };
430 const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload)));
431 location.assign(`${home}/restore#${encoded}`);
432}
433
434// parseRestoreReturn pulls { nonce, wrapped } out of a "#lchat_restore=<b64json>"
435// return fragment (pure, unit-testable). Returns null if absent or malformed.
436export function parseRestoreReturn(hash) {
437 const m = new RegExp("(?:^#|[#&])" + RESTORE_MARK + "=([^&]+)").exec(hash || "");
438 if (!m) return null;
439 try {
440 const obj = JSON.parse(new TextDecoder().decode(b64urlToBytes(decodeURIComponent(m[1]))));
441 if (!obj || typeof obj.nonce !== "string" || typeof obj.wrapped !== "string") return null;
442 return obj;
443 } catch {
444 return null;
445 }
446}
447
448// checkRestoreReturn runs on load BEFORE routing: if we've come back from
449// /restore, verify the nonce (so a foreign page can't inject a session), unwrap
450// the credentials with our ephemeral private key, and write the session. Returns
451// the restored session object, or null. Always scrubs the marker.
452export async function checkRestoreReturn() {
453 // Clean up unconditionally on every exit: scrub the marker from the URL and
454 // drop the ephemeral private key (it's single-use, useless without the
455 // matching ciphertext, and must not linger on a failed/forged return).
456 const scrub = () => { try { history.replaceState(null, "", location.pathname + location.search); } catch {} };
457 const done = () => { scrub(); sessionStorage.removeItem(RESTORE_PENDING_KEY); };
458 const ret = parseRestoreReturn(location.hash);
459 if (!ret) { done(); return null; }
460 let pending;
461 try { pending = JSON.parse(sessionStorage.getItem(RESTORE_PENDING_KEY) || "null"); } catch { pending = null; }
462 if (!pending || !pending.ephPriv || ret.nonce !== pending.nonce) { done(); return null; }
463 try {
464 const credsB64 = await unwrapThreadKey(ret.wrapped, pending.ephPriv);
465 const creds = JSON.parse(new TextDecoder().decode(b64urlToBytes(credsB64)));
466 if (!creds || !creds.token || !creds.userPub || !creds.userPriv) return null;
467 const session = { token: creds.token, name: creds.name || "", userPub: creds.userPub, userPriv: creds.userPriv };
468 localStorage.setItem(SESSION_KEY, JSON.stringify(session));
469 // This session came straight from the backup, so this browser is already
470 // backed up — don't nag with the "save securely" banner.
471 localStorage.setItem(ENROLLED_KEY, "1");
472 return session;
473 } catch {
474 return null;
475 } finally {
476 done();
477 }
478}
479