eleven / messenger Public

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>
Paul Campbell 38478d5565253e828fb18423195c8d30eb9a1f5f parent 2975449
11 files changed, +389 −13
  • changelog/2026-07-03-restore-passkey.md +1 −0
  • docs/onboarding-and-home-server.md +6 −3
  • home/home_test.go +17 −0
  • home/main.go +24 −5
  • home/web/pages/restore.html +59 −0
  • home/web/static/crypto.js +34 −0
  • home/web/static/restore.js +121 −0
  • test/backup.test.mjs +20 −1
  • web/pages/index.html +3 −0
  • web/static/backup.js +84 −1
  • web/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) {