eleven / messenger Public

Passkey backup enrolment: fragility banner + home /enroll popup (phase 3a) (#113)

* Passkey backup enrolment: fragility banner + home /enroll popup

Phase 3a of docs/onboarding-and-home-server.md — the first user-facing payoff
of the home server. From within a chat, a member can protect their access with
a passkey so a lost device/browser doesn't mean lost chats.

Home server (home/):
- backup-core.js: shared WebAuthn + blob plumbing (register/login/fetch/put,
  PRF-stripping credential serializer), extracted from home.js so the main page
  and the popup share one audited path.
- enroll.html + enroll.js: the enrolment popup. Reads the {url, name,
  credentials} entry from its OWN url fragment (never hits a server, same model
  as invite secrets), clears it, shows an explicit "Add <name> (<host>)?"
  confirmation, runs create-a-passkey or sign-in, merges the entry into the
  encrypted blob, and posts ONLY {ok} back to the opener at the entry origin.
  No inbound postMessage receiver — the injection surface is gone by design.
- GET /enroll route + CSP + test.

Messenger:
- Inline fragility banner (backup.js) shown when a session exists and this
  browser isn't enrolled: double-dismiss (remind-me-later ~7d, then a confirm
  "I understand — I can lose access"). Replaces the maybeNudgeBackup toast.
- "Back up with a passkey" opens the home /enroll popup with the payload in the
  fragment; the result postMessage is accepted only when event.origin ===
  window.__homeOrigin. LCHAT_HOME_ORIGIN env (default home.elevenmessenger.com)
  templated as __HOME_ORIGIN__.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: confirm before overwriting an existing backup entry (security review)

addToExisting merged by URL last-writer-wins, so a page that knows a victim's
instance host could open the popup with junk credentials for that host and, if
the user completed a real sign-in ceremony, silently clobber their genuine
saved credentials — the generic "add" copy hid that it was a replace.

Now: if an entry for the same URL already exists with DIFFERENT credentials,
show a distinct host-specific "Replace <existing> for <host>?" confirmation
before overwriting. An identical re-save is a no-op and skips the prompt. All
prompt text is textContent (names come from the attacker-influenced fragment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3eeb5d777ad5fde106a3aa009d3e9d2ea6504b3d parent 2c54615
16 files changed, +847 −192
  • changelog/2026-07-03-passkey-backup.md +1 −0
  • docs/onboarding-and-home-server.md +15 −6
  • handlers_test.go +28 −3
  • home/home_test.go +21 −0
  • home/main.go +29 −7
  • home/web/pages/enroll.html +63 −0
  • home/web/static/backup-core.js +136 −0
  • home/web/static/enroll.js +233 −0
  • home/web/static/home.js +35 −155
  • main.go +21 −6
  • package.json +1 −1
  • test/backup.test.mjs +44 −0
  • web/pages/index.html +9 −0
  • web/static/backup.js +190 −0
  • web/static/chat.js +2 −14
  • web/static/style.css +19 −0
diff --git a/changelog/2026-07-03-passkey-backup.md b/changelog/2026-07-03-passkey-backup.md
new file mode 100644
index 0000000..14b14f1
--- /dev/null
+++ b/changelog/2026-07-03-passkey-backup.md
@@ -0,0 +1 @@
+You can now protect your access with a passkey, so you won't lose your chats if you lose your device or browser — look for the "Back up with a passkey" prompt in your chat list.
diff --git a/docs/onboarding-and-home-server.md b/docs/onboarding-and-home-server.md
index 76782e0..39819a6 100644
--- a/docs/onboarding-and-home-server.md
+++ b/docs/onboarding-and-home-server.md
@@ -143,12 +143,21 @@ adopt changes. On the **web** the default is session-only DEK caching — a
"Trust this browser" checkbox opts into persistence, so sign-in on a shared
computer doesn't durably enrol it.
-**Auto-add flow** (the payoff): join a new instance in any browser → the
-welcome page hands the new entry `{instance URL, credentials}` to the home
-origin via `postMessage`/`#fragment` (never through a server in the clear) →
-one Face ID (or a cached web session) → blob updated → your Mac and iPhone
-poll, decrypt, and the server **appears in the rail on its own** — the Mac app
-seeds the instance webview's session, iOS adopts the credentials natively.
+**Enrolment / auto-add flow** (the payoff): from a chat (the fragility banner's
+"Back up with a passkey", or later the welcome page) the instance opens the home
+server's `/enroll` popup with the new entry `{instance URL, name, credentials}`
+in the **popup's URL fragment** — client-to-client, never through a server in
+the clear (same model as invite secrets). The handoff is deliberately one-way:
+the home server ships **no inbound `postMessage` receiver** (that was an
+injection surface — any site can `window.open` the popup), so there is nothing
+cross-origin to spoof. The popup shows exactly what's being added (name + host)
+as the confirmation gate, runs one Face ID (create a backup passkey, or sign in
+to an existing one), merges the entry into the blob, and posts **only a boolean
+result** back to the opener (never blob contents), which the opener accepts only
+when `event.origin === HOME_ORIGIN`. Later, your Mac and iPhone poll, decrypt,
+and the server **appears in the rail on its own** — the Mac app seeds the
+instance webview's session, iOS adopts the credentials natively. *(Implemented
+phase 3a: enrolment + fragility banner. Silent multi-device sync is phase 3c.)*
Revocation: lost phone → any enrolled device (fresh ceremony) revokes its link
token; per-instance measures (admin roster) unchanged.
diff --git a/handlers_test.go b/handlers_test.go
index ba4bef7..298c40d 100644
--- a/handlers_test.go
+++ b/handlers_test.go
@@ -25,7 +25,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *Server) {
}
hub := newHub()
go hub.run()
- s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv}
+ s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv, homeOrigin: defaultHomeOrigin}
ts := httptest.NewServer(s.handler())
t.Cleanup(ts.Close)
return ts, s
@@ -136,9 +136,15 @@ func TestMessagesIncludeReactions(t *testing.T) {
if len(out.Messages) != 1 {
t.Fatalf("want 1 message, got %d", len(out.Messages))
}
- seen := map[string]struct{ count int; mine bool }{}
+ seen := map[string]struct {
+ count int
+ mine bool
+ }{}
for _, r := range out.Messages[0].Reactions {
- seen[r.Emoji] = struct{ count int; mine bool }{r.Count, r.Mine}
+ seen[r.Emoji] = struct {
+ count int
+ mine bool
+ }{r.Count, r.Mine}
}
if seen["👍"].count != 1 || seen["👍"].mine {
t.Fatalf("👍 = %+v (want count 1, mine false)", seen["👍"])
@@ -855,3 +861,22 @@ func TestJoinPageAppStoreURL(t *testing.T) {
t.Fatal("join page missing configured app store URL")
}
}
+
+// The chat page exposes the passkey-backup home origin to JS (window.__homeOrigin).
+// The default must be substituted and the placeholder must never leak to the client.
+func TestChatPageHomeOrigin(t *testing.T) {
+ ts, _ := newTestServer(t)
+ resp, err := http.Get(ts.URL + "/")
+ if err != nil {
+ t.Fatalf("get chat page: %v", err)
+ }
+ defer resp.Body.Close()
+ b, _ := io.ReadAll(resp.Body)
+ body := string(b)
+ if strings.Contains(body, "__HOME_ORIGIN__") {
+ t.Fatal("chat page leaks the __HOME_ORIGIN__ placeholder")
+ }
+ if !strings.Contains(body, `window.__homeOrigin = "`+defaultHomeOrigin+`"`) {
+ t.Fatal("chat page missing the default home origin")
+ }
+}
diff --git a/home/home_test.go b/home/home_test.go
index 509fb9b..db4fdbf 100644
--- a/home/home_test.go
+++ b/home/home_test.go
@@ -406,3 +406,24 @@ func TestBeginEndpointsRateLimit(t *testing.T) {
t.Fatalf("login/begin should share the exhausted bucket: want 429, got %d", r.StatusCode)
}
}
+
+// The enrolment popup must serve, be HTML with a locked-down CSP (frame-ancestors
+// none — it must never be iframed), and carry no secrets (the entry payload
+// arrives client-side in the URL fragment, so a plain GET returns a static page).
+func TestServeEnrollPage(t *testing.T) {
+ ts, _ := newTestServer(t)
+ r := do(t, "GET", ts.URL+"/enroll", "", nil, nil)
+ if r.StatusCode != http.StatusOK {
+ t.Fatalf("GET /enroll: want 200, got %d", r.StatusCode)
+ }
+ if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
+ t.Fatalf("/enroll content-type = %q", ct)
+ }
+ if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") {
+ t.Fatalf("/enroll CSP too loose: %q", csp)
+ }
+ body, _ := io.ReadAll(r.Body)
+ if !strings.Contains(string(body), "/static/enroll.js") {
+ t.Fatalf("/enroll page missing enroll.js")
+ }
+}
diff --git a/home/main.go b/home/main.go
index 852d2bf..75eda8a 100644
--- a/home/main.go
+++ b/home/main.go
@@ -43,8 +43,9 @@ type Server struct {
sessions *sessionStore
// limiter rate-limits the ceremony begin endpoints per IP — the abuse
// surface of an open, anonymous registration endpoint.
- limiter *rateLimiter
- page []byte
+ limiter *rateLimiter
+ page []byte
+ enrollPage []byte
}
func main() {
@@ -81,12 +82,17 @@ func newServer(db *DB, origin string) (*Server, error) {
if err != nil {
return nil, err
}
+ enrollPage, err := webFS.ReadFile("web/pages/enroll.html")
+ if err != nil {
+ return nil, err
+ }
return &Server{
- db: db,
- wa: wa,
- sessions: newSessionStore(),
- limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
- page: page,
+ db: db,
+ enrollPage: enrollPage,
+ wa: wa,
+ sessions: newSessionStore(),
+ limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
+ page: page,
}, nil
}
@@ -100,6 +106,10 @@ func (s *Server) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /", s.servePage)
+ // The enrolment popup: an instance opens this to add itself to the backup.
+ // The server-entry payload rides in the URL fragment (never reaches here);
+ // this just serves the page.
+ mux.HandleFunc("GET /enroll", s.serveEnroll)
mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))))
// WebAuthn ceremonies. begin endpoints are rate-limited: they're the open,
@@ -155,6 +165,18 @@ func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
w.Write(s.page)
}
+// serveEnroll serves the enrolment popup. Same CSP as the main page — no inline
+// scripts, no cross-origin fetches; the only cross-origin move is a postMessage
+// back to the opener, which CSP doesn't govern.
+func (s *Server) serveEnroll(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Security-Policy",
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
+ "img-src 'self' data:; connect-src 'self'; "+
+ "object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write(s.enrollPage)
+}
+
func noCache(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
diff --git a/home/web/pages/enroll.html b/home/web/pages/enroll.html
new file mode 100644
index 0000000..02ee5c1
--- /dev/null
+++ b/home/web/pages/enroll.html
@@ -0,0 +1,63 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Add to your Eleven backup</title>
+<style>
+ :root { color-scheme: light dark; }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; padding: 24px 16px 48px;
+ font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: Canvas; color: CanvasText;
+ display: flex; justify-content: center;
+ }
+ main { width: 100%; max-width: 26rem; }
+ h1 { font-size: 1.25rem; margin: 0 0 .25rem; }
+ .sub { margin: 0 0 1.25rem; opacity: .7; font-size: .95rem; }
+ /* The card names exactly what's about to be added, so an injected/spoofed
+ entry is visible before anything is written (the confirmation gate). */
+ .entry {
+ border: 1px solid color-mix(in srgb, CanvasText 18%, transparent);
+ border-radius: 12px; padding: 14px 16px; margin-bottom: 16px;
+ }
+ .entry .name { font-weight: 600; font-size: 1.05rem; }
+ .entry .host { opacity: .7; font-size: .9rem; overflow-wrap: anywhere; }
+ .actions { display: flex; flex-direction: column; gap: .6rem; }
+ button {
+ font: inherit; padding: .6rem 1rem; border-radius: 8px; cursor: pointer;
+ border: 1px solid color-mix(in srgb, CanvasText 25%, transparent);
+ background: color-mix(in srgb, CanvasText 6%, Canvas); color: inherit;
+ }
+ button.primary { background: #4457ff; border-color: #4457ff; color: #fff; }
+ button:disabled { opacity: .5; cursor: default; }
+ /* Inline status only — never a floating toast (product-wide rule). */
+ .status { margin: 1rem 0 0; font-size: .9rem; }
+ .status.error { color: #d33; }
+ .status.ok { color: #2a8a4a; }
+ .status:empty { display: none; }
+ .hidden { display: none; }
+</style>
+</head>
+<body>
+<main>
+ <h1>Add to your Eleven backup</h1>
+ <p class="sub">Save this chat to your passkey-protected backup so you can get
+ back in on a new device. This server can't read your backup.</p>
+
+ <div class="entry" id="entry">
+ <div class="name" id="entry-name">This chat</div>
+ <div class="host" id="entry-host"></div>
+ </div>
+
+ <div class="actions" id="actions">
+ <button class="primary" id="create">Create a backup passkey</button>
+ <button id="existing">I already have a backup — sign in</button>
+ </div>
+
+ <p class="status" id="status"></p>
+</main>
+<script type="module" src="/static/enroll.js"></script>
+</body>
+</html>
diff --git a/home/web/static/backup-core.js b/home/web/static/backup-core.js
new file mode 100644
index 0000000..2958286
--- /dev/null
+++ b/home/web/static/backup-core.js
@@ -0,0 +1,136 @@
+// Shared WebAuthn + blob plumbing for the home server's pages (home.js and the
+// enrolment popup enroll.js). Kept framework-free and storage-free: each page
+// owns its own session state; these helpers just run ceremonies and move bytes.
+// All crypto lives in crypto.js — this file never touches key material beyond
+// forwarding a ceremony's PRF output back to the caller.
+
+async function api(path, opts = {}) {
+ const res = await fetch(path, opts);
+ if (!res.ok) throw new Error((await res.text().catch(() => "")).trim() || `HTTP ${res.status}`);
+ return res;
+}
+
+export async function apiJSON(path, opts) {
+ return (await api(path, opts)).json();
+}
+
+// The server speaks the WebAuthn JSON wire format (base64url strings); the
+// browser API wants ArrayBuffers. Decode just the fields we use.
+export function decodePublicKeyOptions(publicKey) {
+ const out = { ...publicKey, challenge: b64urlToBytes(publicKey.challenge) };
+ if (publicKey.user) out.user = { ...publicKey.user, id: b64urlToBytes(publicKey.user.id) };
+ for (const k of ["excludeCredentials", "allowCredentials"]) {
+ if (publicKey[k]) out[k] = publicKey[k].map((c) => ({ ...c, id: b64urlToBytes(c.id) }));
+ }
+ return out;
+}
+
+// Serialize a credential for the server, by hand: it guarantees the PRF
+// results (secret key material!) never ride along — only `enabled` survives.
+export function credentialToJSON(cred) {
+ const r = cred.response;
+ const ext = cred.getClientExtensionResults();
+ const out = {
+ id: cred.id,
+ rawId: bytesToB64url(new Uint8Array(cred.rawId)),
+ type: cred.type,
+ clientExtensionResults: ext.prf ? { prf: { enabled: !!ext.prf.enabled } } : {},
+ response: { clientDataJSON: bytesToB64url(new Uint8Array(r.clientDataJSON)) },
+ };
+ if (r.attestationObject) out.response.attestationObject = bytesToB64url(new Uint8Array(r.attestationObject));
+ if (r.authenticatorData) out.response.authenticatorData = bytesToB64url(new Uint8Array(r.authenticatorData));
+ if (r.signature) out.response.signature = bytesToB64url(new Uint8Array(r.signature));
+ if (r.userHandle) out.response.userHandle = bytesToB64url(new Uint8Array(r.userHandle));
+ return out;
+}
+
+export function prfOutputOf(cred) {
+ const first = cred.getClientExtensionResults().prf?.results?.first;
+ return first ? new Uint8Array(first) : null;
+}
+
+// runRegister creates a NEW passkey (a new anonymous account) with PRF, then
+// returns { token, credId, prf }. Some authenticators evaluate PRF at create
+// time; the rest only during an assertion, which this handles so the caller
+// always gets a usable PRF output (or it throws).
+export async function runRegister() {
+ const { session, options } = await apiJSON("/api/register/begin", { method: "POST" });
+ const publicKey = decodePublicKeyOptions(options.publicKey);
+ publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } };
+ const cred = await navigator.credentials.create({ publicKey });
+ const ext = cred.getClientExtensionResults();
+ if (!ext.prf?.enabled && !ext.prf?.results) {
+ throw new Error("no-prf"); // caller shows the "manager can't protect a backup" copy
+ }
+ const fin = await apiJSON("/api/register/finish?session=" + encodeURIComponent(session), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(credentialToJSON(cred)),
+ });
+ let prf = prfOutputOf(cred);
+ if (!prf) {
+ // Enabled but not evaluated at create time — one assertion gets the output.
+ // The challenge is local: PRF eval happens on the authenticator, no server check.
+ const assertion = await navigator.credentials.get({
+ publicKey: {
+ challenge: crypto.getRandomValues(new Uint8Array(32)),
+ allowCredentials: [{ type: "public-key", id: cred.rawId }],
+ userVerification: "required",
+ extensions: { prf: { eval: { first: prfEvalInput() } } },
+ },
+ });
+ prf = prfOutputOf(assertion);
+ }
+ if (!prf) throw new Error("no PRF output from this passkey");
+ return { token: fin.token, credId: fin.credential_id, prf };
+}
+
+// runLogin runs a full assertion (discoverable credential) with PRF eval and
+// returns { token, credId, prf, proof }. proof is a fresh ceremony proof for
+// destructive ops; prf decrypts the blob. The server never sees prf.
+export async function runLogin() {
+ const { session, options } = await apiJSON("/api/login/begin", { method: "POST" });
+ const publicKey = decodePublicKeyOptions(options.publicKey);
+ publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } };
+ const cred = await navigator.credentials.get({ publicKey });
+ const fin = await apiJSON("/api/login/finish?session=" + encodeURIComponent(session), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(credentialToJSON(cred)),
+ });
+ return { token: fin.token, credId: fin.credential_id, prf: prfOutputOf(cred), proof: fin.proof };
+}
+
+// fetchBlob returns { bytes, version } or null (404 — no backup yet).
+export async function fetchBlob(token) {
+ const res = await fetch("/api/blob", { headers: { Authorization: "Bearer " + token } });
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return {
+ bytes: new Uint8Array(await res.arrayBuffer()),
+ version: (res.headers.get("ETag") || '"0"').replace(/"/g, ""),
+ };
+}
+
+// putBlob returns the new version, or null on a 409 version race (caller
+// re-fetches + retries).
+export async function putBlob(token, bytes, ifMatchVersion) {
+ const res = await fetch("/api/blob", {
+ method: "PUT",
+ headers: {
+ Authorization: "Bearer " + token,
+ "If-Match": `"${ifMatchVersion}"`,
+ "Content-Type": "application/octet-stream",
+ },
+ body: bytes,
+ });
+ if (res.status === 409) return null;
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return String((await res.json()).version);
+}
+
+// crypto.js re-exports below so pages import one module. (Keeps import lists
+// short and the DEK/envelope helpers alongside the ceremony helpers.)
+import {
+ b64urlToBytes, bytesToB64url, prfEvalInput,
+} from "/static/crypto.js";
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
new file mode 100644
index 0000000..a5531df
--- /dev/null
+++ b/home/web/static/enroll.js
@@ -0,0 +1,233 @@
+// The enrolment popup. An instance page (e.g. family.luan.chat) opens this as a
+// popup 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, merge it into the encrypted blob,
+// and post only a boolean result back to the opener.
+//
+// Deliberately NO inbound postMessage listener: the handoff is one-way via the
+// fragment, so there's no cross-origin receiver to spoof (see the removed stub
+// in home.js history + the design doc). The single trust control is the
+// confirmation card below — the user sees the entry before anything is written.
+
+import {
+ b64urlToBytes, kekFromPRF, generateDEK, wrapDEK, unwrapDEK,
+ sealBlob, unpackBlob, openBlob,
+} from "/static/crypto.js";
+import { runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.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 — we derive the postMessage target origin from it.
+ 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 {}
+
+let targetOrigin = null;
+if (payload) targetOrigin = new URL(payload.entry.url).origin;
+
+// ---- result channel: popup -> opener (the ONLY postMessage) ------------------
+
+let resultSent = false;
+function postResult(ok) {
+ if (resultSent) return;
+ resultSent = true;
+ // Target the entry's origin explicitly: postMessage only delivers if the
+ // opener's origin matches, and we never broadcast to "*". We send just the
+ // boolean — never blob contents, tokens, or keys.
+ try { window.opener?.postMessage({ type: "eleven-enroll-result", ok: !!ok }, targetOrigin); } catch {}
+}
+
+// ---- merge one entry into the (possibly new) blob ----------------------------
+
+// Upsert the entry into a servers list by url (last-writer-wins per url).
+function upsert(servers, entry) {
+ const list = (servers || []).filter((s) => s.url !== entry.url);
+ list.push(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 || ""]);
+}
+
+// createAndAdd: a brand-new backup whose first (and only) server is this entry.
+async function createAndAdd() {
+ disable();
+ setStatus("Waiting for your passkey…");
+ let reg;
+ try {
+ reg = await runRegister();
+ } catch (e) {
+ if (e.message === "no-prf") {
+ setStatus("This passkey manager can't protect a backup (no PRF support). You can still use add-device links to move between devices.", "error");
+ enable();
+ return;
+ }
+ throw e;
+ }
+ const kek = await kekFromPRF(reg.prf);
+ const dek = await generateDEK();
+ const vault = {
+ obj: { v: 1, servers: [payload.entry] },
+ keys: { [reg.credId]: await wrapDEK(dek, kek) },
+ version: "0",
+ };
+ const sealed = await sealBlob(vault.obj, dek, vault.keys);
+ const v = await putBlob(reg.token, sealed, vault.version);
+ if (v === null) throw new Error("a backup already exists for this passkey — try 'I already have a backup'");
+ succeed();
+}
+
+// addToExisting: sign in to an existing backup and add this entry to it.
+async function addToExisting() {
+ disable();
+ setStatus("Waiting for your passkey…");
+ const { token, credId, prf } = await runLogin();
+ if (!prf) {
+ setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error");
+ enable();
+ return;
+ }
+ const got = await fetchBlob(token);
+ if (!got) {
+ setStatus("That passkey has no backup yet. Use 'Create a backup passkey' instead.", "error");
+ enable();
+ return;
+ }
+ const kek = await kekFromPRF(prf);
+ const { keys } = unpackBlob(got.bytes);
+ const wrapped = keys[credId];
+ if (!wrapped) {
+ setStatus("This passkey isn't one of the backup's keys.", "error");
+ enable();
+ return;
+ }
+ const dek = await unwrapDEK(wrapped, kek);
+
+ // If a DIFFERENT entry already exists for this host, adding would overwrite
+ // the credentials the user already trusts. Anyone can open this popup with an
+ // arbitrary fragment, so a page that knows the host could try to clobber a
+ // real saved entry with junk — the generic "add" copy wouldn't reveal that.
+ // Require an explicit, host-specific replace confirmation. An identical
+ // re-save (same credentials) is a harmless no-op and skips the prompt.
+ let cur = got, opened = await openBlob(got.bytes, dek);
+ const existing = (opened.obj.servers || []).find((s) => s.url === payload.entry.url);
+ if (existing && credFingerprint(existing) !== credFingerprint(payload.entry)) {
+ const ok = await confirmReplace(existing);
+ if (!ok) {
+ // The original create/existing buttons were replaced by the prompt, so
+ // this is terminal: leave a clear message; the user closes the window.
+ $("actions").classList.add("hidden");
+ setStatus("Kept your existing backup for this chat — nothing changed.", "ok");
+ return;
+ }
+ }
+
+ // Add the entry and save, retrying once on a version race.
+ for (let attempt = 0; attempt < 2; attempt++) {
+ const obj = { ...opened.obj, servers: upsert(opened.obj.servers, payload.entry) };
+ 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 --------------------------------------------------------------
+
+function disable() { $("create").disabled = true; $("existing").disabled = true; }
+function enable() { $("create").disabled = false; $("existing").disabled = false; }
+
+// 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.className = "primary";
+ replace.textContent = "Replace saved backup";
+
+ const cancel = document.createElement("button");
+ cancel.textContent = "Keep what I have";
+
+ // Disable both so a double-click can't resolve twice; the original
+ // create/existing buttons were replaced by these, so we never touch them.
+ 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() {
+ postResult(true);
+ $("actions").classList.add("hidden");
+ setStatus("Added to your backup. You can close this window.", "ok");
+}
+
+const onError = (err) => {
+ if (err.name === "NotAllowedError") { setStatus("Passkey prompt was dismissed."); enable(); return; }
+ setStatus(err.message || String(err), "error");
+ enable();
+ postResult(false);
+};
+
+// ---- boot ---------------------------------------------------------------------
+
+if (!payload) {
+ setStatus("This window was opened without a valid request. Close it and try again from your chat.", "error");
+ $("actions").classList.add("hidden");
+} else if (!window.opener) {
+ setStatus("This page is meant to open from your chat's “Back up” button.", "error");
+ $("actions").classList.add("hidden");
+} else if (!window.PublicKeyCredential) {
+ setStatus("This browser doesn't support passkeys.", "error");
+ $("actions").classList.add("hidden");
+} else {
+ const host = new URL(payload.entry.url).host;
+ $("entry-name").textContent = payload.entry.name || host;
+ $("entry-host").textContent = host;
+ $("create").addEventListener("click", () => createAndAdd().catch(onError));
+ $("existing").addEventListener("click", () => addToExisting().catch(onError));
+}
diff --git a/home/web/static/home.js b/home/web/static/home.js
index a3077ae..ab82bbd 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -1,13 +1,15 @@
// The home page: create a backup passkey, restore, manage devices. All crypto
-// is client-side (see crypto.js); this file owns the WebAuthn ceremonies and
-// the (deliberately minimal) DOM. Status and errors always render inline in
-// each section — never a floating toast.
+// is client-side (see crypto.js); the WebAuthn ceremonies + blob I/O live in
+// backup-core.js (shared with the enrolment popup). Status and errors always
+// render inline in each section — never a floating toast.
import {
- b64urlToBytes, bytesToB64url, prfEvalInput, kekFromPRF,
- generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
+ kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
sealBlob, unpackBlob, openBlob,
} from "/static/crypto.js";
+import {
+ apiJSON, runRegister, runLogin, fetchBlob, putBlob,
+} from "/static/backup-core.js";
// ---- session state ----------------------------------------------------------
// Session-only on purpose (the design doc's web default): closing the tab
@@ -34,97 +36,7 @@ function setStatus(id, msg, kind) {
el.className = "status" + (kind ? " " + kind : "");
}
-// ---- WebAuthn plumbing ---------------------------------------------------------
-
-async function api(path, opts = {}) {
- const res = await fetch(path, opts);
- if (!res.ok) throw new Error((await res.text().catch(() => "")).trim() || `HTTP ${res.status}`);
- return res;
-}
-
-async function apiJSON(path, opts) {
- return (await api(path, opts)).json();
-}
-
-// The server speaks the WebAuthn JSON wire format (base64url strings); the
-// browser API wants ArrayBuffers. Decode just the fields we use.
-function decodePublicKeyOptions(publicKey) {
- const out = { ...publicKey, challenge: b64urlToBytes(publicKey.challenge) };
- if (publicKey.user) out.user = { ...publicKey.user, id: b64urlToBytes(publicKey.user.id) };
- for (const k of ["excludeCredentials", "allowCredentials"]) {
- if (publicKey[k]) out[k] = publicKey[k].map((c) => ({ ...c, id: b64urlToBytes(c.id) }));
- }
- return out;
-}
-
-// Serialize a credential for the server, by hand: it guarantees the PRF
-// results (secret key material!) never ride along — only `enabled` survives.
-function credentialToJSON(cred) {
- const r = cred.response;
- const ext = cred.getClientExtensionResults();
- const out = {
- id: cred.id,
- rawId: bytesToB64url(new Uint8Array(cred.rawId)),
- type: cred.type,
- clientExtensionResults: ext.prf ? { prf: { enabled: !!ext.prf.enabled } } : {},
- response: { clientDataJSON: bytesToB64url(new Uint8Array(r.clientDataJSON)) },
- };
- if (r.attestationObject) out.response.attestationObject = bytesToB64url(new Uint8Array(r.attestationObject));
- if (r.authenticatorData) out.response.authenticatorData = bytesToB64url(new Uint8Array(r.authenticatorData));
- if (r.signature) out.response.signature = bytesToB64url(new Uint8Array(r.signature));
- if (r.userHandle) out.response.userHandle = bytesToB64url(new Uint8Array(r.userHandle));
- return out;
-}
-
-function prfOutputOf(cred) {
- const first = cred.getClientExtensionResults().prf?.results?.first;
- return first ? new Uint8Array(first) : null;
-}
-
-// login runs a full assertion ceremony (with PRF eval) and stores the
-// resulting link token. Returns { prf, proof, credId }.
-async function login() {
- const { session, options } = await apiJSON("/api/login/begin", { method: "POST" });
- const publicKey = decodePublicKeyOptions(options.publicKey);
- // PRF eval is a client<->authenticator matter; the server never sees the output.
- publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } };
- const cred = await navigator.credentials.get({ publicKey });
- const fin = await apiJSON("/api/login/finish?session=" + encodeURIComponent(session), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(credentialToJSON(cred)),
- });
- state.token = fin.token;
- state.credId = fin.credential_id;
- return { prf: prfOutputOf(cred), proof: fin.proof, credId: fin.credential_id };
-}
-
-// ---- blob I/O -------------------------------------------------------------------
-
-async function fetchBlob() {
- const res = await fetch("/api/blob", { headers: { Authorization: "Bearer " + state.token } });
- if (res.status === 404) return null;
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return {
- bytes: new Uint8Array(await res.arrayBuffer()),
- version: (res.headers.get("ETag") || '"0"').replace(/"/g, ""),
- };
-}
-
-async function putBlob(bytes, ifMatchVersion) {
- const res = await fetch("/api/blob", {
- method: "PUT",
- headers: {
- Authorization: "Bearer " + state.token,
- "If-Match": `"${ifMatchVersion}"`,
- "Content-Type": "application/octet-stream",
- },
- body: bytes,
- });
- if (res.status === 409) return null; // caller re-fetches and retries
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- return String((await res.json()).version);
-}
+// ---- blob save ----------------------------------------------------------------
// saveVault seals the current vault and PUTs it, retrying once on a version
// race (re-fetch, re-apply our object over the merge base, re-seal). The list
@@ -132,12 +44,12 @@ async function putBlob(bytes, ifMatchVersion) {
async function saveVault() {
for (let attempt = 0; attempt < 2; attempt++) {
const sealed = await sealBlob(vault.obj, dekKey, vault.keys);
- const v = await putBlob(sealed, vault.version);
+ const v = await putBlob(state.token, sealed, vault.version);
if (v !== null) {
vault.version = v;
return;
}
- const cur = await fetchBlob();
+ const cur = await fetchBlob(state.token);
if (!cur) throw new Error("backup disappeared");
const opened = await openBlob(cur.bytes, dekKey);
const merged = new Map(opened.obj.servers.map((s) => [s.url, s]));
@@ -147,53 +59,29 @@ async function saveVault() {
throw new Error("could not save after retry");
}
-// ---- create ---------------------------------------------------------------------
+// ---- create -------------------------------------------------------------------
async function createBackup() {
setStatus("create-status", "Waiting for your passkey…");
- const { session, options } = await apiJSON("/api/register/begin", { method: "POST" });
- const publicKey = decodePublicKeyOptions(options.publicKey);
- // Ask for PRF at creation. Some authenticators evaluate it right here; the
- // rest only during an assertion (handled below).
- publicKey.extensions = { ...publicKey.extensions, prf: { eval: { first: prfEvalInput() } } };
- const cred = await navigator.credentials.create({ publicKey });
- const ext = cred.getClientExtensionResults();
- if (!ext.prf?.enabled && !ext.prf?.results) {
- setStatus("create-status", "This passkey manager doesn't support the PRF extension, so it can't protect a backup here. Add-device links remain the fallback.", "error");
- return;
- }
- const fin = await apiJSON("/api/register/finish?session=" + encodeURIComponent(session), {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(credentialToJSON(cred)),
- });
- state.token = fin.token;
- state.credId = fin.credential_id;
-
- let prf = prfOutputOf(cred);
- if (!prf) {
- // The authenticator enabled PRF but didn't evaluate at create time — one
- // assertion gets the output. The challenge is local: PRF eval happens on
- // the authenticator and needs no server verification.
- setStatus("create-status", "One more passkey check to derive your encryption key…");
- const assertion = await navigator.credentials.get({
- publicKey: {
- challenge: crypto.getRandomValues(new Uint8Array(32)),
- allowCredentials: [{ type: "public-key", id: cred.rawId }],
- userVerification: "required",
- extensions: { prf: { eval: { first: prfEvalInput() } } },
- },
- });
- prf = prfOutputOf(assertion);
+ let reg;
+ try {
+ reg = await runRegister();
+ } catch (e) {
+ if (e.message === "no-prf") {
+ setStatus("create-status", "This passkey manager doesn't support the PRF extension, so it can't protect a backup here. Add-device links remain the fallback.", "error");
+ return;
+ }
+ throw e;
}
- if (!prf) throw new Error("no PRF output from this passkey");
+ state.token = reg.token;
+ state.credId = reg.credId;
- const kek = await kekFromPRF(prf);
+ const kek = await kekFromPRF(reg.prf);
dekKey = await generateDEK();
state.dek = await exportDEK(dekKey);
vault = {
obj: { v: 1, servers: [] },
- keys: { [fin.credential_id]: await wrapDEK(dekKey, kek) },
+ keys: { [reg.credId]: await wrapDEK(dekKey, kek) },
version: "0",
};
await saveVault();
@@ -201,12 +89,14 @@ async function createBackup() {
renderVault();
}
-// ---- restore ---------------------------------------------------------------------
+// ---- restore ------------------------------------------------------------------
async function restore() {
setStatus("restore-status", "Waiting for your passkey…");
- const { prf, credId } = await login();
- const got = await fetchBlob();
+ const { token, credId, prf } = await runLogin();
+ state.token = token;
+ state.credId = credId;
+ const got = await fetchBlob(token);
if (!got) {
setStatus("restore-status", "Signed in, but there's no backup stored yet.", "error");
return;
@@ -253,7 +143,7 @@ function renderVault() {
}
}
-// ---- devices ----------------------------------------------------------------------
+// ---- devices ------------------------------------------------------------------
async function showDevices() {
if (!state.token) {
@@ -288,8 +178,8 @@ async function revokeDevice(id) {
// Destructive: needs a FRESH ceremony, not the link token — a stolen token
// must never be able to lock you out.
setStatus("devices-status", "Confirm with your passkey to revoke…");
- const { proof } = await login();
- await api("/api/devices/" + encodeURIComponent(id), {
+ const { proof } = await runLogin();
+ await apiJSON("/api/devices/" + encodeURIComponent(id), {
method: "DELETE",
headers: { "X-Ceremony-Proof": proof },
});
@@ -297,7 +187,7 @@ async function revokeDevice(id) {
await showDevices();
}
-// ---- add-server form -----------------------------------------------------------------
+// ---- add-server form ----------------------------------------------------------
async function addServer(e) {
e.preventDefault();
@@ -315,17 +205,7 @@ async function addServer(e) {
renderVault();
}
-// ---- postMessage handshake (not yet implemented) -----------------------------------------
-//
-// Later, instance welcome pages open this page as a popup and hand over a new
-// server entry ({ url, credentials }) via postMessage — client to client, never
-// through a server in the clear (the design doc's "auto-add flow"). NO receiver
-// ships until the sender does: a message listener that merges entries into the
-// synced backup is an injection point (any site can window.open this page), so
-// when it lands it must enforce a strict e.origin allowlist AND show the user
-// the entry for confirmation before anything is merged into the blob.
-
-// ---- wiring ---------------------------------------------------------------------------
+// ---- wiring -------------------------------------------------------------------
const fail = (id) => (err) => {
// A dismissed passkey sheet is a user choice, not an error worth shouting about.
@@ -349,7 +229,7 @@ $("add-form").addEventListener("submit", (e) => addServer(e).catch(fail("vault-s
if (state.token && state.dek) {
(async () => {
try {
- const got = await fetchBlob();
+ const got = await fetchBlob(state.token);
if (!got) return;
dekKey = await importDEK(state.dek);
const opened = await openBlob(got.bytes, dekKey);
diff --git a/main.go b/main.go
index 86970aa..da74d0a 100644
--- a/main.go
+++ b/main.go
@@ -48,9 +48,13 @@ type Server struct {
// Where the welcome page's "Get the app" button points (TestFlight / App
// Store link). Empty hides the button — set LCHAT_APP_STORE_URL per instance.
appStoreURL string
- vapidPub string
- vapidPriv string
- apns *apnsClient // nil unless LCHAT_APNS_* env is configured
+ // The home server that hosts passkey backup enrolment. The chat page opens
+ // <homeOrigin>/enroll as a user-initiated popup to back this instance up (see
+ // the enrolment cross-origin contract); set LCHAT_HOME_ORIGIN per deployment.
+ homeOrigin string
+ vapidPub string
+ vapidPriv string
+ apns *apnsClient // nil unless LCHAT_APNS_* env is configured
// inflight de-duplicates concurrent unfurls of the same URL.
inflight sync.Map
// active tracks the last time each device token reported being open + focused
@@ -101,8 +105,14 @@ func main() {
}
hub := newHub()
go hub.run()
+ // TrimSpace guards against env-injection paths that add surrounding whitespace;
+ // fall back to the shared home server when unset.
+ homeOrigin := strings.TrimSpace(os.Getenv("LCHAT_HOME_ORIGIN"))
+ if homeOrigin == "" {
+ homeOrigin = defaultHomeOrigin
+ }
s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), version: frontendVersion(),
- appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL"))}
+ appStoreURL: strings.TrimSpace(os.Getenv("LCHAT_APP_STORE_URL")), homeOrigin: homeOrigin}
// Web Push: self-generated VAPID keypair (no third-party account).
if pub, priv, err := db.ensureVAPID(); err != nil {
@@ -290,11 +300,17 @@ func (s *Server) servePage(name string) http.HandlerFunc {
page = strings.ReplaceAll(page, "__USER_INVITES__", boolStr(s.db.usersCanInvite()))
// The welcome page's "Get the app" destination; empty hides the button.
page = strings.ReplaceAll(page, "__APP_STORE_URL__", html.EscapeString(s.appStoreURL))
+ // The home server the chat page enrols into for passkey backup.
+ page = strings.ReplaceAll(page, "__HOME_ORIGIN__", html.EscapeString(s.homeOrigin))
page = strings.ReplaceAll(page, "__CSP_NONCE__", nonce)
w.Write([]byte(page))
}
}
+// The home server that hosts passkey backup enrolment when LCHAT_HOME_ORIGIN is
+// unset. See the enrolment cross-origin contract (web/static/backup.js).
+const defaultHomeOrigin = "https://home.elevenmessenger.com"
+
// The iOS app this deployment's instances associate with (team + bundle id) —
// public by definition (it ships in every AASA file and in the app itself).
const appleAppID = "J5NDF5PCD8.com.elevenmessenger.messenger"
@@ -1222,7 +1238,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) {
return
}
var body struct {
- Label string `json:"label"`
+ Label string `json:"label"`
UserID string `json:"user_id"`
ID string `json:"id"`
Wrapped string `json:"wrapped"`
@@ -1667,7 +1683,6 @@ func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
return false
}
-
// ---- push notifications -------------------------------------------------
func (s *Server) getVAPID(w http.ResponseWriter, r *http.Request) {
diff --git a/package.json b/package.json
index dc36286..b83afaa 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,6 @@
"private": true,
"type": "module",
"scripts": {
- "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs && node home/test/crypto.test.mjs"
+ "test": "node test/crypto.test.mjs && node test/ui.test.mjs && node test/format.test.mjs && node test/message.test.mjs && node test/threadkey.test.mjs && node test/actions.test.mjs && node test/invite.test.mjs && node test/backup.test.mjs && node home/test/crypto.test.mjs"
}
}
diff --git a/test/backup.test.mjs b/test/backup.test.mjs
new file mode 100644
index 0000000..9166f6c
--- /dev/null
+++ b/test/backup.test.mjs
@@ -0,0 +1,44 @@
+// Tests for the passkey-backup banner visibility rule. node test/backup.test.mjs
+import assert from "node:assert/strict";
+import { backupBannerState } from "../web/static/backup.js";
+
+let failures = 0;
+async function test(name, fn) {
+ try {
+ await fn();
+ console.log("PASS " + name);
+ } catch (e) {
+ console.log("FAIL " + name + " — " + (e && e.message));
+ failures++;
+ }
+}
+
+const DAY = 24 * 60 * 60 * 1000;
+const now = 1_700_000_000_000; // a fixed "now" so snooze comparisons are deterministic
+
+await test("not enrolled + not snoozed → show", () => {
+ assert.equal(backupBannerState({ enrolled: false, dismissed: false, snoozedUntil: 0, now }), "show");
+});
+
+await test("enrolled → hidden", () => {
+ assert.equal(backupBannerState({ enrolled: true, dismissed: false, snoozedUntil: 0, now }), "hidden");
+});
+
+await test("snoozed in the future → hidden", () => {
+ assert.equal(backupBannerState({ enrolled: false, dismissed: false, snoozedUntil: now + 7 * DAY, now }), "hidden");
+});
+
+await test("snooze expired → show", () => {
+ assert.equal(backupBannerState({ enrolled: false, dismissed: false, snoozedUntil: now - DAY, now }), "show");
+});
+
+await test("permanently dismissed → hidden", () => {
+ assert.equal(backupBannerState({ enrolled: false, dismissed: true, snoozedUntil: 0, now }), "hidden");
+});
+
+await test("enrolled wins even if the snooze has expired", () => {
+ assert.equal(backupBannerState({ enrolled: true, dismissed: false, snoozedUntil: now - DAY, now }), "hidden");
+});
+
+console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/web/pages/index.html b/web/pages/index.html
index 5840efb..95d3445 100644
--- a/web/pages/index.html
+++ b/web/pages/index.html
@@ -12,6 +12,12 @@
if (navigator.userAgent.includes("lchatMac"))
document.documentElement.classList.add("native");
</script>
+<!-- The home server that hosts passkey backup enrolment (server-configured via
+ LCHAT_HOME_ORIGIN). backup.js reads it to open the enrol popup — the origin
+ is public, so exposing it here is fine. -->
+<script nonce="__CSP_NONCE__">
+ window.__homeOrigin = "__HOME_ORIGIN__";
+</script>
<link rel="stylesheet" href="/static/style.css" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/static/icon-192.png" />
@@ -86,6 +92,9 @@
<div class="chat-search hidden" id="chat-search">
<input type="search" id="chat-filter" placeholder="Search chats" aria-label="Search chats" autocomplete="off" />
</div>
+ <!-- Inline passkey-backup nudge (backup.js fills it in). It occupies layout
+ space in the sidebar — never a floating toast. -->
+ <div class="backup-banner hidden" id="backup-banner"></div>
<div class="thread-list" id="thread-list"></div>
</aside>
<section class="thread-pane">
diff --git a/web/static/backup.js b/web/static/backup.js
new file mode 100644
index 0000000..9a9d741
--- /dev/null
+++ b/web/static/backup.js
@@ -0,0 +1,190 @@
+// Passkey backup enrolment (the messenger side of the cross-origin contract).
+//
+// Passkeys are bound to the HOME origin, so this instance can't run the WebAuthn
+// ceremony itself — it opens the home server's /enroll page as a user-initiated
+// popup, handing over the current session in the URL *fragment* (fragments never
+// reach any server, same model as invite secrets). The popup posts back only a
+// boolean result; it never returns any secret. See enroll-contract.md.
+//
+// A small inline banner in the sidebar nudges un-backed-up users to enrol. It
+// occupies layout space — never a floating toast (see CLAUDE.md).
+
+// Relative specifier (not /static/) so this module resolves both in the browser
+// — where it loads as /static/backup.js, making ./crypto.js == /static/crypto.js
+// — and in Node unit tests that import it from web/static/.
+import { bytesToB64url } from "./crypto.js";
+
+// localStorage keys. localStorage is already per-origin, so "enrolled" naturally
+// means "this instance is backed up" without us keying by origin ourselves.
+const ENROLLED_KEY = "lchat_home_enrolled"; // "1" once the home server confirms
+const SNOOZE_KEY = "lchat_backup_snooze"; // unix-ms timestamp the snooze expires at
+const DISMISSED_KEY = "lchat_backup_dismissed"; // "1" = opted out for good
+
+const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; // "remind me later" = ~7 days
+
+// backupBannerState is the PURE visibility rule (no DOM, no storage), so it's
+// unit-testable. Enrolment and a permanent dismissal hide it forever; a live
+// snooze hides it until it expires; otherwise we nudge.
+export function backupBannerState({ enrolled, dismissed, snoozedUntil, now }) {
+ if (enrolled) return "hidden";
+ if (dismissed) return "hidden";
+ if (snoozedUntil && now < snoozedUntil) return "hidden";
+ return "show";
+}
+
+// isEnrolled reads the local record set once the home server confirms a backup.
+export function isEnrolled() {
+ return localStorage.getItem(ENROLLED_KEY) === "1";
+}
+
+function isDismissed() {
+ return localStorage.getItem(DISMISSED_KEY) === "1";
+}
+
+// Snooze expiry as unix-ms (0 when unset / unparseable → treated as not snoozed).
+function snoozedUntil() {
+ const n = parseInt(localStorage.getItem(SNOOZE_KEY) || "", 10);
+ return Number.isFinite(n) ? n : 0;
+}
+
+// The group name rides along for the home server's confirmation UI. chat.js puts
+// the (substituted) group name in the page title; fall back to the host.
+function groupName() {
+ const t = (document.title || "").trim();
+ return t || location.host;
+}
+
+function bannerEl() {
+ return document.getElementById("backup-banner");
+}
+
+function removeBanner() {
+ const el = bannerEl();
+ if (!el) return;
+ el.textContent = "";
+ el.classList.add("hidden");
+}
+
+// initBackupBanner renders the inline nudge into #backup-banner when the state
+// rule says to show it. opts.now lets a caller pin "now" (defaults to real time).
+export function initBackupBanner(session, opts = {}) {
+ if (!session || !session.token) return; // nothing to back up
+ const el = bannerEl();
+ if (!el) return;
+ const state = backupBannerState({
+ enrolled: isEnrolled(),
+ dismissed: isDismissed(),
+ snoozedUntil: snoozedUntil(),
+ now: opts.now ?? Date.now(),
+ });
+ if (state !== "show") return; // leaves it .hidden
+ renderBanner(el, session);
+}
+
+function renderBanner(el, session) {
+ el.textContent = "";
+
+ const title = document.createElement("div");
+ title.className = "backup-banner-title";
+ title.textContent = "Back up your access";
+
+ const body = document.createElement("div");
+ body.className = "backup-banner-body";
+ body.textContent =
+ "Save a passkey so you don't lose your chats if you lose this device or browser.";
+
+ const cta = document.createElement("button");
+ cta.className = "backup-banner-cta";
+ cta.textContent = "Back up with a passkey";
+ cta.addEventListener("click", () => {
+ launchEnrol(session).catch(() => {});
+ });
+
+ // Double-dismiss: the first click snoozes for a week and escalates the label to
+ // spell out the consequence; a second click on the escalated control opts out
+ // for good. This makes "I never want this" a deliberate, informed choice.
+ const dismiss = document.createElement("button");
+ dismiss.className = "backup-banner-dismiss linkish";
+ dismiss.textContent = "Remind me later";
+ let escalated = false;
+ dismiss.addEventListener("click", () => {
+ if (!escalated) {
+ localStorage.setItem(SNOOZE_KEY, String(Date.now() + SNOOZE_MS));
+ dismiss.textContent = "I understand — I can lose access to this chat";
+ dismiss.classList.add("danger");
+ escalated = true;
+ } else {
+ localStorage.setItem(DISMISSED_KEY, "1");
+ removeBanner();
+ }
+ });
+
+ el.append(title, body, cta, dismiss);
+ el.classList.remove("hidden");
+}
+
+// launchEnrol opens the home server's /enroll popup with the session in the URL
+// fragment and waits for the boolean result. Must be called from a user gesture
+// (the CTA click) so the popup isn't blocked. Resolves true on a confirmed backup.
+export function launchEnrol(session) {
+ return new Promise((resolve) => {
+ const home = window.__homeOrigin;
+ if (!home) {
+ resolve(false);
+ return;
+ }
+ const payload = {
+ v: 1,
+ entry: {
+ url: location.origin, // the instance being enrolled
+ name: groupName(), // best-effort, for the confirmation UI
+ credentials: {
+ token: session.token,
+ userPub: session.userPub,
+ userPriv: session.userPriv,
+ name: session.name,
+ },
+ },
+ };
+ const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload)));
+ // The payload rides in the #fragment — it never reaches the home server; the
+ // popup reads its own location.hash. So there's no inbound secret to receive.
+ const popup = window.open(`${home}/enroll#${encoded}`, "eleven-enroll");
+ if (!popup) {
+ resolve(false); // popup blocked
+ return;
+ }
+
+ let done = false;
+ const cleanup = () => {
+ done = true;
+ window.removeEventListener("message", onMessage);
+ clearInterval(poll);
+ };
+ // Accept a result ONLY from the home origin, from the popup we opened, of the
+ // agreed type — the home server posts back just { ok }, never any secret.
+ const onMessage = (e) => {
+ if (e.origin !== home || e.source !== popup) return;
+ const data = e.data;
+ if (!data || data.type !== "eleven-enroll-result") return;
+ cleanup();
+ if (data.ok) {
+ localStorage.setItem(ENROLLED_KEY, "1");
+ removeBanner();
+ resolve(true);
+ } else {
+ resolve(false);
+ }
+ };
+ window.addEventListener("message", onMessage);
+ // If the user just closes the popup (no result posted), tear down so we don't
+ // leak the listener or leave this promise pending forever.
+ const poll = setInterval(() => {
+ if (done) return;
+ if (popup.closed) {
+ cleanup();
+ resolve(false);
+ }
+ }, 500);
+ });
+}
diff --git a/web/static/chat.js b/web/static/chat.js
index 447126a..4166fa6 100644
--- a/web/static/chat.js
+++ b/web/static/chat.js
@@ -38,6 +38,7 @@ import { emojify, emojiMatches } from "/static/emoji.js";
import { MESSAGE_TYPE_TYPING } from "/static/message.js";
import { parseInviteFragment, buildInviteFragment } from "/static/invite.js";
import { qrCanvas } from "/static/qrcode.js";
+import { initBackupBanner } from "/static/backup.js";
import {
needsHomeScreen,
pushAvailable,
@@ -391,7 +392,7 @@ async function boot() {
connectWS();
registerSW(session); // best-effort; subscribes if notifications already on
startHeartbeat();
- setTimeout(maybeNudgeBackup, 2500); // once, nudge single-device users to back up
+ initBackupBanner(session); // inline sidebar nudge to back up access with a passkey
// Tapping a notification while a tab is already open posts us the thread to
// open (the SW can't navigate a live SPA) — jump straight to it.
if (navigator.serviceWorker) {
@@ -4588,19 +4589,6 @@ async function leave() {
card.appendChild(foot);
}
-// Once, gently nudge a single-device user to save a backup, so a lost device doesn't
-// mean lost chats. Cheap: skips the network check entirely after the first nudge.
-async function maybeNudgeBackup() {
- if (localStorage.getItem("lchat_backup_nudged")) return;
- try {
- const me = await (await fetch(`/api/me?token=${encodeURIComponent(session.token)}`)).json();
- if (me && me.device_count === 1) {
- localStorage.setItem("lchat_backup_nudged", "1");
- toast("Tip: back up your account via the ⋮ menu → “Add a device / back up”, so you never lose your chats.");
- }
- } catch {}
-}
-
// Revoke this device's token server-side (so it stops counting / receiving push) and
// clear the local session.
function doSignout() {
diff --git a/web/static/style.css b/web/static/style.css
index 1c5ad5a..a14a9b2 100644
--- a/web/static/style.css
+++ b/web/static/style.css
@@ -1192,6 +1192,25 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr
}
.conn-status.hidden { display: none; }
.sidebar { width: 320px; flex: none; display: flex; flex-direction: column; background: var(--sidebar); border-right: 1px solid var(--border); min-height: 0; }
+/* Inline passkey-backup nudge: sits in the sidebar between the search box and the
+ chat list, occupying its own layout band (never a floating toast). A subtle
+ accent tint sets it apart without shouting. Contents are built in backup.js. */
+.backup-banner {
+ flex: none;
+ margin: 8px 12px;
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+.backup-banner.hidden { display: none; }
+.backup-banner-title { font-weight: 700; font-size: 14px; color: var(--ink); }
+.backup-banner-body { font-size: 13px; line-height: 1.4; color: var(--muted); }
+.backup-banner-cta { align-self: flex-start; padding: 8px 14px; font-size: 13px; }
+.backup-banner-dismiss { align-self: flex-start; font-size: 13px; }
/* Both headers share a height so the thread pane's header lines up with the
sidebar's. Only the thread header carries a divider — the sidebar's "Chats"
header flows straight into the chat list (with the search field below it). */