Restore: sign back in on a fresh browser from your passkey backup (phase 3b) (#118)
* Restore: sign back in on a fresh browser from your passkey backup (phase 3b)
The other half of the backup: a signed-out instance can now recover its session
from the home-server backup, so the backup you create actually gets you back in.
Flow (same-tab redirect, Safari-safe like enrolment):
- The no-invite screen offers "Already a member? Restore with a passkey" when a
home server is configured (browser only — native restore is a later flow).
- launchRestore navigates to home /restore with {ret, nonce, ephPub}: the
instance origin, a one-time nonce, and a freshly generated ephemeral ECDH
public key. The ephemeral private key stays local, stashed across the nav.
- /restore unlocks the blob with the passkey, finds ONLY the entry matching ret
(never returns the whole list), and ECIES-wraps its credentials to ephPub —
so credentials are never plaintext in the return URL. Redirects back with
{nonce, wrapped}.
- checkRestoreReturn (before routing) verifies the nonce (a foreign page can't
inject a session), unwraps with the ephemeral key, writes the session, and
marks this browser enrolled. chat.js adopts it and boots signed-in.
home/crypto.js gains wrapToPublicKey (ECIES matching the messenger's
wrapThreadKey format exactly — verified round-trip). New /restore page + route +
CSP + test; parseRestoreReturn unit-tested.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* restore: unconditional cleanup on all exit paths (security review)
checkRestoreReturn now scrubs the URL marker AND drops the ephemeral private key
on every exit — previously the no-marker path skipped the scrub and the
ephemeral key was cleared only on the happy path, leaving it in storage after a
failed/forged return (low exploitability — useless without the matching
ciphertext — but contrary to the stated "clean up regardless" invariant). Also
moved the pending restore ({nonce, ephPriv}) to sessionStorage so an abandoned
restore auto-clears when the tab closes. Doc updated to describe the stricter
origin-scoped return (home ships only the matching entry, not the whole blob).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
11 files changed,
+389
−13
changelog/2026-07-03-restore-passkey.md+1 −0docs/onboarding-and-home-server.md+6 −3home/home_test.go+17 −0home/main.go+24 −5home/web/pages/restore.html+59 −0home/web/static/crypto.js+34 −0home/web/static/restore.js+121 −0test/backup.test.mjs+20 −1web/pages/index.html+3 −0web/static/backup.js+84 −1web/static/chat.js+20 −3
diff --git a/changelog/2026-07-03-restore-passkey.md b/changelog/2026-07-03-restore-passkey.md| new file mode 100644 |
| index 0000000..592be23 |
| --- /dev/null |
| +++ b/changelog/2026-07-03-restore-passkey.md |
| @@ -0,0 +1 @@ |
| +Improved: if you've saved a chat with a passkey, you can now get back into it on a new device or browser — open the chat and choose "Already a member? Restore with a passkey". It signs you straight back in, no invite link needed. |
diff --git a/docs/onboarding-and-home-server.md b/docs/onboarding-and-home-server.md| index 39819a6..a958cea 100644 |
| --- a/docs/onboarding-and-home-server.md |
| +++ b/docs/onboarding-and-home-server.md |
| @@ -70,9 +70,12 @@ URL bar. Claim happens only on an explicit choice. |
| **Signed-out server root** (e.g. visiting `chat.paulca.com` with no session) is |
| currently a dead end. It becomes the same card: **Restore with passkey** / |
| -**Open in the app** / *"I have an invite link"*. During restore the home server |
| -just returns the whole blob — the instance page picks out its own entry |
| -client-side, so home never learns which instance prompted the sign-in. |
| +**Open in the app** / *"I have an invite link"*. During restore the instance |
| +passes its origin and an ephemeral public key; after the passkey unlocks the |
| +blob, the home server returns **only the entry matching that origin** (never the |
| +whole list), ECIES-wrapped to the ephemeral key so the credentials are never |
| +plaintext in the return URL. A one-time nonce ties the return to a restore the |
| +instance actually started. *(Implemented: phase 3b.)* |
| **App Store round trip:** invites stay valid until claimed, so "install, come |
| back, tap the link again" works today; the QR flow (shipped on iOS) covers |
diff --git a/home/home_test.go b/home/home_test.go| index db4fdbf..8b9e934 100644 |
| --- a/home/home_test.go |
| +++ b/home/home_test.go |
| @@ -427,3 +427,20 @@ func TestServeEnrollPage(t *testing.T) { |
| t.Fatalf("/enroll page missing enroll.js") |
| } |
| } |
| + |
| +// The restore page must serve with the same locked-down CSP as the others and |
| +// carry no secrets (the request rides in the URL fragment). |
| +func TestServeRestorePage(t *testing.T) { |
| + ts, _ := newTestServer(t) |
| + r := do(t, "GET", ts.URL+"/restore", "", nil, nil) |
| + if r.StatusCode != http.StatusOK { |
| + t.Fatalf("GET /restore: want 200, got %d", r.StatusCode) |
| + } |
| + if csp := r.Header.Get("Content-Security-Policy"); !strings.Contains(csp, "frame-ancestors 'none'") || !strings.Contains(csp, "script-src 'self'") { |
| + t.Fatalf("/restore CSP too loose: %q", csp) |
| + } |
| + body, _ := io.ReadAll(r.Body) |
| + if !strings.Contains(string(body), "/static/restore.js") { |
| + t.Fatalf("/restore page missing restore.js") |
| + } |
| +} |
diff --git a/home/main.go b/home/main.go| index 75eda8a..31e07b0 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -43,9 +43,10 @@ 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 |
| - enrollPage []byte |
| + limiter *rateLimiter |
| + page []byte |
| + enrollPage []byte |
| + restorePage []byte |
| } |
| func main() { |
| @@ -86,9 +87,14 @@ func newServer(db *DB, origin string) (*Server, error) { |
| if err != nil { |
| return nil, err |
| } |
| + restorePage, err := webFS.ReadFile("web/pages/restore.html") |
| + if err != nil { |
| + return nil, err |
| + } |
| return &Server{ |
| - db: db, |
| - enrollPage: enrollPage, |
| + db: db, |
| + enrollPage: enrollPage, |
| + restorePage: restorePage, |
| wa: wa, |
| sessions: newSessionStore(), |
| limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute |
| @@ -110,6 +116,9 @@ func (s *Server) handler() http.Handler { |
| // The server-entry payload rides in the URL fragment (never reaches here); |
| // this just serves the page. |
| mux.HandleFunc("GET /enroll", s.serveEnroll) |
| + // The restore page: a signed-out instance opens this to sign back in from the |
| + // backup. Like /enroll, the request rides in the fragment; this just serves it. |
| + mux.HandleFunc("GET /restore", s.serveRestore) |
| mux.Handle("GET /static/", noCache(http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))) |
| // WebAuthn ceremonies. begin endpoints are rate-limited: they're the open, |
| @@ -177,6 +186,16 @@ func (s *Server) serveEnroll(w http.ResponseWriter, r *http.Request) { |
| w.Write(s.enrollPage) |
| } |
| +// serveRestore serves the restore page (same CSP as the others). |
| +func (s *Server) serveRestore(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.restorePage) |
| +} |
| + |
| 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/restore.html b/home/web/pages/restore.html| new file mode 100644 |
| index 0000000..fe85e20 |
| --- /dev/null |
| +++ b/home/web/pages/restore.html |
| @@ -0,0 +1,59 @@ |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>Get back into your chat</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; } |
| + .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>Get back into your chat</h1> |
| + <p class="sub">Unlock your passkey-protected backup to sign back in on this |
| + device. This server can't read your backup.</p> |
| + |
| + <div class="entry" id="entry"> |
| + <div class="name" id="entry-host"></div> |
| + </div> |
| + |
| + <div class="actions" id="actions"> |
| + <button class="primary" id="restore">Unlock with passkey</button> |
| + </div> |
| + |
| + <p class="status" id="status"></p> |
| +</main> |
| +<script type="module" src="/static/restore.js"></script> |
| +</body> |
| +</html> |
diff --git a/home/web/static/crypto.js b/home/web/static/crypto.js| index 0d631fe..916293d 100644 |
| --- a/home/web/static/crypto.js |
| +++ b/home/web/static/crypto.js |
| @@ -39,6 +39,40 @@ export async function decryptBytes(key, bytes) { |
| return new Uint8Array(pt); |
| } |
| +// ---- ECIES wrap (restore hand-back) ---------------------------------------- |
| +// |
| +// Restore returns a chat's credentials to the instance, so they must NOT sit |
| +// plaintext in the return URL. The instance generates an ephemeral ECDH keypair |
| +// and sends its public key; we wrap the credentials to it here. This mirrors the |
| +// messenger's crypto.js wrapThreadKey EXACTLY (same curve, HKDF info, and |
| +// ephPub(65)||iv||ct layout) so the instance's unwrapThreadKey recovers it. |
| + |
| +const EC = { name: "ECDH", namedCurve: "P-256" }; |
| + |
| +async function eciesKey(privKey, pubKey) { |
| + const bits = await crypto.subtle.deriveBits({ name: "ECDH", public: pubKey }, privKey, 256); |
| + const ikm = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]); |
| + return crypto.subtle.deriveKey( |
| + { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("lchat-thread-wrap") }, |
| + ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] |
| + ); |
| +} |
| + |
| +// wrapToPublicKey ECIES-encrypts raw bytes (base64url) to a recipient's ECDH |
| +// public key (base64url raw). Returns base64url( ephPub(65) || iv(12) || ct ) — |
| +// the messenger's unwrapThreadKey(wrapped, privKey) format. |
| +export async function wrapToPublicKey(bytesB64, recipientPubB64) { |
| + const recipient = await crypto.subtle.importKey("raw", b64urlToBytes(recipientPubB64), EC, false, []); |
| + const eph = await crypto.subtle.generateKey(EC, true, ["deriveBits"]); |
| + const aes = await eciesKey(eph.privateKey, recipient); |
| + const body = await encryptBytes(aes, b64urlToBytes(bytesB64)); // iv||ct |
| + const ephPub = new Uint8Array(await crypto.subtle.exportKey("raw", eph.publicKey)); |
| + const out = new Uint8Array(ephPub.length + body.length); |
| + out.set(ephPub, 0); |
| + out.set(body, ephPub.length); |
| + return bytesToB64url(out); |
| +} |
| + |
| // ---- PRF -> KEK ----------------------------------------------------------- |
| // The PRF eval input is a fixed, public constant: the secrecy lives entirely in |
diff --git a/home/web/static/restore.js b/home/web/static/restore.js| new file mode 100644 |
| index 0000000..31db06b |
| --- /dev/null |
| +++ b/home/web/static/restore.js |
| @@ -0,0 +1,121 @@ |
| +// The restore page. A signed-out instance (e.g. chat.paulca.com) navigates here |
| +// (same tab) to sign back in from the user's passkey-protected backup. Passkeys |
| +// are bound to THIS origin, so the instance can't run the ceremony — it hands us |
| +// { ret, nonce, ephPub } in the URL fragment (ret = which instance, ephPub = an |
| +// ephemeral public key it just generated). We unlock the blob with the passkey, |
| +// find that instance's entry, ECIES-wrap its credentials to ephPub (so they're |
| +// never plaintext in the return URL), and redirect back to the instance with the |
| +// wrapped blob + nonce. The instance verifies the nonce and unwraps with the |
| +// ephemeral private key it kept locally. |
| +// |
| +// We return ONLY the entry matching ret — never the whole list. A page that |
| +// sends us a ret we don't have in the backup gets nothing back. |
| + |
| +import { |
| + b64urlToBytes, bytesToB64url, kekFromPRF, unwrapDEK, unpackBlob, openBlob, wrapToPublicKey, |
| +} from "/static/crypto.js"; |
| +import { beginLogin, runLogin, fetchBlob } 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 : ""); |
| +} |
| + |
| +function readPayload() { |
| + const raw = location.hash.slice(1); |
| + if (!raw) return null; |
| + try { |
| + const p = JSON.parse(new TextDecoder().decode(b64urlToBytes(raw))); |
| + if (!p || typeof p.ret !== "string" || typeof p.nonce !== "string" || typeof p.ephPub !== "string") return null; |
| + new URL(p.ret); // must parse — it's the redirect target |
| + return p; |
| + } catch { |
| + return null; |
| + } |
| +} |
| + |
| +const payload = readPayload(); |
| +// Drop the fragment from the address bar as soon as we've read it. |
| +try { history.replaceState(null, "", location.pathname); } catch {} |
| + |
| +const retOrigin = payload ? new URL(payload.ret).origin : null; |
| + |
| +// Ceremony options are pre-fetched (Safari activation — see the enrol page). |
| +let loginBegin = null; |
| +function prefetch() { |
| + beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; }); |
| +} |
| + |
| +async function restore() { |
| + $("restore").disabled = true; |
| + setStatus("Waiting for your passkey…"); |
| + const { token, credId, prf } = await runLogin(loginBegin); |
| + if (!prf) { |
| + setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error"); |
| + $("restore").disabled = false; |
| + return; |
| + } |
| + const got = await fetchBlob(token); |
| + if (!got) { |
| + setStatus("No backup found for this passkey.", "error"); |
| + $("restore").disabled = false; |
| + 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"); |
| + $("restore").disabled = false; |
| + return; |
| + } |
| + const dek = await unwrapDEK(wrapped, kek); |
| + const { obj } = await openBlob(got.bytes, dek); |
| + |
| + // Only the entry whose origin matches the requester (ret) is ever returned. |
| + const host = new URL(payload.ret).host; |
| + const entry = (obj.servers || []).find((s) => { |
| + try { return new URL(s.url).origin === retOrigin; } catch { return false; } |
| + }); |
| + if (!entry || !entry.credentials) { |
| + setStatus(`Your backup doesn't include ${host}. Open the chat from an invite instead.`, "error"); |
| + $("restore").disabled = false; |
| + return; |
| + } |
| + |
| + // Wrap the credentials to the instance's ephemeral public key — ciphertext in |
| + // the return URL, decryptable only by the ephemeral private key it kept. |
| + const credsB64 = bytesToB64url(new TextEncoder().encode(JSON.stringify(entry.credentials))); |
| + const sealed = await wrapToPublicKey(credsB64, payload.ephPub); |
| + const back = JSON.stringify({ nonce: payload.nonce, wrapped: sealed }); |
| + const frag = "#lchat_restore=" + encodeURIComponent(bytesToB64url(new TextEncoder().encode(back))); |
| + setStatus("Unlocked. Taking you back to your chat…", "ok"); |
| + $("actions").classList.add("hidden"); |
| + location.replace(retOrigin + "/" + frag); |
| +} |
| + |
| +const onError = (err) => { |
| + if (err && err.name === "NotAllowedError") { |
| + setStatus("Passkey prompt was cancelled or blocked. If you didn't cancel, try again.", "error"); |
| + } else { |
| + setStatus((err && err.message) || String(err), "error"); |
| + } |
| + $("restore").disabled = false; |
| + prefetch(); // spent options — refresh for a retry |
| +}; |
| + |
| +// ---- boot --------------------------------------------------------------------- |
| + |
| +if (!payload) { |
| + setStatus("This page was opened without a valid request. Go back to your chat and choose “Restore with passkey”.", "error"); |
| + $("actions").classList.add("hidden"); |
| +} else if (!window.PublicKeyCredential) { |
| + setStatus("This browser doesn't support passkeys.", "error"); |
| + $("actions").classList.add("hidden"); |
| +} else { |
| + $("entry-host").textContent = new URL(payload.ret).host; |
| + prefetch(); |
| + $("restore").addEventListener("click", () => restore().catch(onError)); |
| +} |
diff --git a/test/backup.test.mjs b/test/backup.test.mjs| index 6d4d37a..6ee8a71 100644 |
| --- a/test/backup.test.mjs |
| +++ b/test/backup.test.mjs |
| @@ -1,6 +1,6 @@ |
| // Tests for the passkey-backup banner visibility rule. node test/backup.test.mjs |
| import assert from "node:assert/strict"; |
| -import { backupBannerState, parseReturnNonce } from "../web/static/backup.js"; |
| +import { backupBannerState, parseReturnNonce, parseRestoreReturn } from "../web/static/backup.js"; |
| let failures = 0; |
| async function test(name, fn) { |
| @@ -56,5 +56,24 @@ await test("no marker → empty", () => { |
| assert.equal(parseReturnNonce("#lchat_backupZZZ=nope"), ""); |
| }); |
| +// parseRestoreReturn: pull { nonce, wrapped } out of the /restore return fragment. |
| +function b64url(s) { |
| + return Buffer.from(s, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| +} |
| +await test("restore return parses nonce + wrapped", () => { |
| + const h = "#lchat_restore=" + b64url(JSON.stringify({ nonce: "n1", wrapped: "w1" })); |
| + assert.deepEqual(parseRestoreReturn(h), { nonce: "n1", wrapped: "w1" }); |
| +}); |
| +await test("restore return: no marker → null", () => { |
| + assert.equal(parseRestoreReturn("#something"), null); |
| + assert.equal(parseRestoreReturn(""), null); |
| +}); |
| +await test("restore return: malformed base64 → null", () => { |
| + assert.equal(parseRestoreReturn("#lchat_restore=%%%"), null); |
| +}); |
| +await test("restore return: valid base64 but missing fields → null", () => { |
| + assert.equal(parseRestoreReturn("#lchat_restore=" + b64url(JSON.stringify({ nonce: "n" }))), null); |
| +}); |
| + |
| 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 95d3445..c4b3217 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -164,6 +164,9 @@ |
| <div class="row"><button type="submit">Join the chat</button></div> |
| </form> |
| <p id="join-err" class="banner warn hidden" style="text-align:left;padding:0;margin-top:10px"></p> |
| + <div class="row" id="restore-row" style="display:none;margin-top:14px"> |
| + <button type="button" id="restore-btn" class="secondary">Already a member? Restore with a passkey</button> |
| + </div> |
| </div> |
| </div> |
| </template> |
diff --git a/web/static/backup.js b/web/static/backup.js| index 096ea77..3b24951 100644 |
| --- a/web/static/backup.js |
| +++ b/web/static/backup.js |
| @@ -8,13 +8,20 @@ |
| // then redirects back here with a one-time nonce we can verify. No secret ever |
| // returns; only the nonce marker. |
| // |
| +// The reverse — RESTORE — brings a signed-out browser back in: it navigates to |
| +// /restore with an ephemeral public key, the home server wraps the stored |
| +// credentials to it (never plaintext in the return URL), and we unwrap and adopt |
| +// the session here. A one-time nonce ties the return to a restore we started. |
| +// |
| // 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"; |
| +import { bytesToB64url, b64urlToBytes, generateUserKeypair, unwrapThreadKey } from "./crypto.js"; |
| + |
| +const SESSION_KEY = "lchat_session"; // where chat.js keeps the signed-in session |
| // localStorage keys. localStorage is already per-origin, so "enrolled" naturally |
| // means "this instance is backed up" without us keying by origin ourselves. |
| @@ -23,6 +30,8 @@ const SNOOZE_KEY = "lchat_backup_snooze"; // unix-ms timestamp the snooze expire |
| const DISMISSED_KEY = "lchat_backup_dismissed"; // "1" = opted out for good |
| const PENDING_KEY = "lchat_home_enrol_pending"; // nonce for the in-flight enrolment |
| const RETURN_MARK = "lchat_backup"; // return fragment key: #lchat_backup=<nonce> |
| +const RESTORE_PENDING_KEY = "lchat_home_restore_pending"; // {nonce, ephPriv} in flight |
| +const RESTORE_MARK = "lchat_restore"; // return fragment key: #lchat_restore=<blob> |
| const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; // "remind me later" = ~7 days |
| @@ -210,3 +219,77 @@ export function checkEnrolReturn() { |
| try { history.replaceState(null, "", location.pathname + location.search); } catch {} |
| return ok; |
| } |
| + |
| +// ---- restore (get back in on a fresh browser) -------------------------------- |
| + |
| +// canRestore: offer restore only when a home server is configured and we're in a |
| +// real browser. In the native wrapper the passkey ceremony can't run in the |
| +// webview (native restore is a separate, later flow), so we don't offer it there. |
| +export function canRestore() { |
| + return !!window.__homeOrigin && !isNativeWrapper(); |
| +} |
| + |
| +// launchRestore navigates (same tab) to the home server's /restore page. We send |
| +// our origin (which instance to restore), a one-time nonce, and a freshly |
| +// generated ephemeral ECDH public key. The home server wraps our credentials to |
| +// that key so they never travel back in the clear; the private half stays here, |
| +// stashed across the navigation so we can unwrap on return. |
| +export async function launchRestore() { |
| + const home = window.__homeOrigin; |
| + if (!home) return; |
| + const nonce = randomNonce(); |
| + const eph = await generateUserKeypair(); |
| + // sessionStorage, not localStorage: the ephemeral PRIVATE key is tab-scoped and |
| + // an abandoned restore (tab closed mid-ceremony) is auto-cleared. It survives |
| + // the same-tab navigation to /restore and back. |
| + sessionStorage.setItem(RESTORE_PENDING_KEY, JSON.stringify({ nonce, ephPriv: eph.privateKey })); |
| + const payload = { v: 1, ret: location.origin, nonce, ephPub: eph.publicKey }; |
| + const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload))); |
| + location.assign(`${home}/restore#${encoded}`); |
| +} |
| + |
| +// parseRestoreReturn pulls { nonce, wrapped } out of a "#lchat_restore=<b64json>" |
| +// return fragment (pure, unit-testable). Returns null if absent or malformed. |
| +export function parseRestoreReturn(hash) { |
| + const m = new RegExp("(?:^#|[#&])" + RESTORE_MARK + "=([^&]+)").exec(hash || ""); |
| + if (!m) return null; |
| + try { |
| + const obj = JSON.parse(new TextDecoder().decode(b64urlToBytes(decodeURIComponent(m[1])))); |
| + if (!obj || typeof obj.nonce !== "string" || typeof obj.wrapped !== "string") return null; |
| + return obj; |
| + } catch { |
| + return null; |
| + } |
| +} |
| + |
| +// checkRestoreReturn runs on load BEFORE routing: if we've come back from |
| +// /restore, verify the nonce (so a foreign page can't inject a session), unwrap |
| +// the credentials with our ephemeral private key, and write the session. Returns |
| +// the restored session object, or null. Always scrubs the marker. |
| +export async function checkRestoreReturn() { |
| + // Clean up unconditionally on every exit: scrub the marker from the URL and |
| + // drop the ephemeral private key (it's single-use, useless without the |
| + // matching ciphertext, and must not linger on a failed/forged return). |
| + const scrub = () => { try { history.replaceState(null, "", location.pathname + location.search); } catch {} }; |
| + const done = () => { scrub(); sessionStorage.removeItem(RESTORE_PENDING_KEY); }; |
| + const ret = parseRestoreReturn(location.hash); |
| + if (!ret) { done(); return null; } |
| + let pending; |
| + try { pending = JSON.parse(sessionStorage.getItem(RESTORE_PENDING_KEY) || "null"); } catch { pending = null; } |
| + if (!pending || !pending.ephPriv || ret.nonce !== pending.nonce) { done(); return null; } |
| + try { |
| + const credsB64 = await unwrapThreadKey(ret.wrapped, pending.ephPriv); |
| + const creds = JSON.parse(new TextDecoder().decode(b64urlToBytes(credsB64))); |
| + if (!creds || !creds.token || !creds.userPub || !creds.userPriv) return null; |
| + const session = { token: creds.token, name: creds.name || "", userPub: creds.userPub, userPriv: creds.userPriv }; |
| + localStorage.setItem(SESSION_KEY, JSON.stringify(session)); |
| + // This session came straight from the backup, so this browser is already |
| + // backed up — don't nag with the "save securely" banner. |
| + localStorage.setItem(ENROLLED_KEY, "1"); |
| + return session; |
| + } catch { |
| + return null; |
| + } finally { |
| + done(); |
| + } |
| +} |
diff --git a/web/static/chat.js b/web/static/chat.js| index 4bc17bc..9088ffa 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -38,7 +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 { initBackupBanner, canRestore, launchRestore, checkRestoreReturn } from "/static/backup.js"; |
| import { |
| needsHomeScreen, |
| pushAvailable, |
| @@ -326,7 +326,7 @@ const app = document.getElementById("app"); |
| // never handed to the webview: native unwraps the thread key and passes only it, |
| // so a single thread's page can't reach any other thread's content. |
| const embed = window.__lchatEmbed || null; |
| -const session = embed ? { token: embed.token, name: embed.name } : loadSession(); |
| +let session = embed ? { token: embed.token, name: embed.name } : loadSession(); |
| // Post a message back to the native shell's "lchat" handler (no-op in a browser). |
| function nativePost(msg) { |
| @@ -366,7 +366,17 @@ let wsConnected = false; // is the socket open right now? (gates the send button |
| let pingTimer = null; // keepalive ping interval while the socket is open |
| let reconnectStatusTimer = null; // delays the "Reconnecting…" pill past a brief blip |
| -route(); |
| +// If we've just returned from the home server's /restore page, adopt the |
| +// recovered session before routing (checkRestoreReturn writes it to storage). |
| +// Otherwise route straight away — the extra tick only happens on a real return. |
| +if (!embed && !session && location.hash.includes("lchat_restore=")) { |
| + checkRestoreReturn().then((restored) => { |
| + if (restored) session = loadSession(); |
| + route(); |
| + }); |
| +} else { |
| + route(); |
| +} |
| function route() { |
| if (embed) return bootEmbed(); |
| @@ -4730,6 +4740,13 @@ function showNoInvite() { |
| showErr("This invite has been used or is no longer valid."); |
| } |
| }; |
| + |
| + // Returning member on a fresh browser: offer passkey restore alongside paste. |
| + const restoreRow = document.getElementById("restore-row"); |
| + if (restoreRow && canRestore()) { |
| + restoreRow.style.display = ""; |
| + document.getElementById("restore-btn").onclick = () => { launchRestore(); }; |
| + } |
| } |
| function showA2HS(done) { |