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>
16 files changed,
+847
−192
changelog/2026-07-03-passkey-backup.md+1 −0docs/onboarding-and-home-server.md+15 −6handlers_test.go+28 −3home/home_test.go+21 −0home/main.go+29 −7home/web/pages/enroll.html+63 −0home/web/static/backup-core.js+136 −0home/web/static/enroll.js+233 −0home/web/static/home.js+35 −155main.go+21 −6package.json+1 −1test/backup.test.mjs+44 −0web/pages/index.html+9 −0web/static/backup.js+190 −0web/static/chat.js+2 −14web/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). */ |