eleven / messenger Public

enrolment: same-tab redirect instead of popup (Safari WebAuthn), verified return (#114)

Safari blocks WebAuthn in a window opened by a cross-origin page, so the enrol
popup's create()/get() aborted instantly with NotAllowedError (confirmed: the
same ceremony works in a top-level tab). Replace the popup with a same-tab
navigation to home /enroll — a plain top-level page where the ceremony works —
and redirect back to the instance on success.

- backup.js: launchEnrol navigates (location.assign) with the session in the
  fragment plus a one-time nonce (stored locally); checkEnrolReturn verifies the
  nonce echoed in the return fragment before marking enrolled, so no other site
  can forge a "backed up" marker to suppress the reminder. Removes the popup +
  postMessage machinery. New pure parseReturnNonce + tests.
- enroll.js: no window.opener/postMessage; on success redirect back to the
  instance origin with the nonce; a Cancel returns without it. Pre-fetch begin
  options so the ceremony fires inside the click (Safari activation). Clearer,
  phase-tagged errors (create vs prf-assertion).
- backup-core.js: beginRegister/beginLogin + runRegister/runLogin accept
  pre-fetched options.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8dc135fe2a0b334065c94e4964e79348829e70d6 parent 3eeb5d7
4 files changed, +191 −122
  • home/web/static/backup-core.js +41 −18
  • home/web/static/enroll.js +60 −34
  • test/backup.test.mjs +17 −1
  • web/static/backup.js +73 −69
diff --git a/home/web/static/backup-core.js b/home/web/static/backup-core.js
index 2958286..d4ca81c 100644
--- a/home/web/static/backup-core.js
+++ b/home/web/static/backup-core.js
@@ -49,15 +49,33 @@ export function prfOutputOf(cred) {
return first ? new Uint8Array(first) : null;
}
+// beginRegister/beginLogin fetch the ceremony options WITHOUT touching the
+// authenticator. Callers pre-fetch these on page load so the button click can
+// reach navigator.credentials.* synchronously — Safari/WebKit drops the
+// transient user activation across an intervening network await and then
+// rejects the ceremony with NotAllowedError (the "prompt flashes and dies"
+// bug), so the WebAuthn call must fire inside the gesture, not after a fetch.
+export function beginRegister() {
+ return apiJSON("/api/register/begin", { method: "POST" });
+}
+
+export function beginLogin() {
+ return apiJSON("/api/login/begin", { method: "POST" });
+}
+
// 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" });
+// returns { token, credId, prf }. Pass pre-fetched beginRegister() options so
+// create() fires within the click (see above); omit to fetch inline (fallback).
+// 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.
+export async function runRegister(prefetched) {
+ const { session, options } = prefetched || 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 });
+ let cred;
+ try {
+ cred = await navigator.credentials.create({ publicKey });
+ } catch (e) { e.phase = "create"; throw e; }
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
@@ -71,14 +89,17 @@ export async function runRegister() {
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() } } },
- },
- });
+ let assertion;
+ try {
+ 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() } } },
+ },
+ });
+ } catch (e) { e.phase = "prf-assertion"; throw e; }
prf = prfOutputOf(assertion);
}
if (!prf) throw new Error("no PRF output from this passkey");
@@ -86,10 +107,12 @@ export async function runRegister() {
}
// 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" });
+// returns { token, credId, prf, proof }. Pass pre-fetched beginLogin() options
+// so get() fires within the click gesture (Safari activation — see above).
+// proof is a fresh ceremony proof for destructive ops; prf decrypts the blob.
+// The server never sees prf.
+export async function runLogin(prefetched) {
+ const { session, options } = prefetched || 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 });
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index a5531df..55a50a4 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -1,21 +1,23 @@
-// 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
+// The enrolment page. An instance (e.g. family.luan.chat) navigates here (same
+// tab) 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.
+// being added, run the passkey ceremony here (a plain top-level page — Safari
+// blocks WebAuthn in a cross-origin-opened popup, which is why this is a
+// navigation, not window.open), merge it into the encrypted blob, and redirect
+// back to the instance with a one-time nonce it can verify.
//
-// 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.
+// No inbound postMessage listener and no secret ever leaves here — the handoff
+// in is one-way via the fragment, the handoff back is a redirect carrying only
+// the nonce. The single trust control on the entry is the confirmation card
+// below — the user sees what's being added 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";
+import { beginRegister, beginLogin, runRegister, runLogin, fetchBlob, putBlob } from "/static/backup-core.js";
const $ = (id) => document.getElementById(id);
function setStatus(msg, kind) {
@@ -33,7 +35,7 @@ function readPayload() {
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.
+ // The entry URL must parse — it's where we send the user back to.
new URL(p.entry.url);
return p;
} catch {
@@ -45,19 +47,17 @@ 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;
+// Where to send the user back to. The instance passes its origin as `ret`; fall
+// back to the entry URL's origin. Only ever a bare origin, and we append only a
+// non-secret nonce marker — never blob contents, tokens, or keys leave here.
+const returnOrigin = payload ? new URL(payload.ret || payload.entry.url).origin : null;
-// ---- 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 {}
+// goBack returns to the chat. On success we carry the one-time nonce the
+// instance gave us, so it can trust the redirect is genuine; on cancel, nothing.
+function goBack(withNonce) {
+ if (!returnOrigin) return;
+ const frag = withNonce && payload.nonce ? "#lchat_backup=" + encodeURIComponent(payload.nonce) : "";
+ location.replace(returnOrigin + "/" + frag);
}
// ---- merge one entry into the (possibly new) blob ----------------------------
@@ -77,13 +77,23 @@ function credFingerprint(entry) {
return JSON.stringify([c.token || "", c.userPub || "", c.userPriv || ""]);
}
+// Ceremony options are pre-fetched on load (see boot) so a button click reaches
+// navigator.credentials.* WITHOUT an intervening network await — Safari drops
+// transient activation across a fetch and then rejects the ceremony. Held as
+// resolved values; refreshed after any failure so a retry has a fresh challenge.
+let regBegin = null, loginBegin = null;
+function prefetch() {
+ beginRegister().then((v) => { regBegin = v; }).catch(() => { regBegin = null; });
+ beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
+}
+
// 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();
+ reg = await runRegister(regBegin);
} 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");
@@ -109,7 +119,7 @@ async function createAndAdd() {
async function addToExisting() {
disable();
setStatus("Waiting for your passkey…");
- const { token, credId, prf } = await runLogin();
+ 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");
enable();
@@ -201,25 +211,32 @@ function confirmReplace(existing) {
}
function succeed() {
- postResult(true);
$("actions").classList.add("hidden");
- setStatus("Added to your backup. You can close this window.", "ok");
+ setStatus("Added to your backup. Taking you back to your chat…", "ok");
+ goBack(true); // returns with the one-time nonce so the instance trusts it
}
const onError = (err) => {
- if (err.name === "NotAllowedError") { setStatus("Passkey prompt was dismissed."); enable(); return; }
- setStatus(err.message || String(err), "error");
+ try { console.error("[enrol] error", err && err.phase, err); } catch {}
+ const phase = err && err.phase ? ` [${err.phase}]` : "";
+ if (err && err.name === "NotAllowedError") {
+ // NotAllowedError is WebAuthn's catch-all — a real user cancel, or the
+ // browser aborting the ceremony. Surface the phase so we can tell which; a
+ // bland "dismissed" hid the real signal.
+ setStatus(`Passkey step blocked or cancelled${phase} (NotAllowedError). If you didn't cancel, try again.`, "error");
+ enable();
+ prefetch(); // refresh options so the retry has a live challenge
+ return;
+ }
+ setStatus(`${(err && err.name) || "Error"}${phase}: ${(err && err.message) || String(err)}`, "error");
enable();
- postResult(false);
+ prefetch(); // a used/failed ceremony's options are spent — refresh for a retry
};
// ---- 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");
+ setStatus("This page was opened without a valid request. Go back to your chat and use the “Back up” button.", "error");
$("actions").classList.add("hidden");
} else if (!window.PublicKeyCredential) {
setStatus("This browser doesn't support passkeys.", "error");
@@ -228,6 +245,15 @@ if (!payload) {
const host = new URL(payload.entry.url).host;
$("entry-name").textContent = payload.entry.name || host;
$("entry-host").textContent = host;
+ // Pre-fetch ceremony options now, while the user reads the card — so the click
+ // reaches the authenticator inside the gesture (Safari activation, see prefetch).
+ prefetch();
$("create").addEventListener("click", () => createAndAdd().catch(onError));
$("existing").addEventListener("click", () => addToExisting().catch(onError));
+
+ // A way back without enrolling (same-tab navigation replaced the chat).
+ const back = document.createElement("button");
+ back.textContent = "Cancel";
+ back.addEventListener("click", () => goBack(false));
+ $("actions").append(back);
}
diff --git a/test/backup.test.mjs b/test/backup.test.mjs
index 9166f6c..6d4d37a 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 } from "../web/static/backup.js";
+import { backupBannerState, parseReturnNonce } from "../web/static/backup.js";
let failures = 0;
async function test(name, fn) {
@@ -40,5 +40,21 @@ await test("enrolled wins even if the snooze has expired", () => {
assert.equal(backupBannerState({ enrolled: true, dismissed: false, snoozedUntil: now - DAY, now }), "hidden");
});
+// parseReturnNonce: pull our one-time nonce out of the /enroll return fragment.
+await test("return nonce parses from a lone marker", () => {
+ assert.equal(parseReturnNonce("#lchat_backup=abc123"), "abc123");
+});
+await test("return nonce is URL-decoded", () => {
+ assert.equal(parseReturnNonce("#lchat_backup=a%2Fb%2Bc"), "a/b+c");
+});
+await test("return nonce parses when not first in the fragment", () => {
+ assert.equal(parseReturnNonce("#foo=1&lchat_backup=xyz"), "xyz");
+});
+await test("no marker → empty", () => {
+ assert.equal(parseReturnNonce("#something-else"), "");
+ assert.equal(parseReturnNonce(""), "");
+ assert.equal(parseReturnNonce("#lchat_backupZZZ=nope"), "");
+});
+
console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
process.exit(failures === 0 ? 0 : 1);
diff --git a/web/static/backup.js b/web/static/backup.js
index 9a9d741..e6ccf4f 100644
--- a/web/static/backup.js
+++ b/web/static/backup.js
@@ -1,10 +1,12 @@
-// Passkey backup enrolment (the messenger side of the cross-origin contract).
+// Passkey backup enrolment (the messenger side of the cross-origin handoff).
//
// 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.
+// ceremony itself — it navigates (same tab) to the home server's /enroll page,
+// handing over the current session in the URL *fragment* (fragments never reach
+// any server, same model as invite secrets). /enroll runs the ceremony as a
+// plain top-level page — Safari blocks WebAuthn in a cross-origin-opened popup —
+// then redirects back here with a one-time nonce we can verify. No secret ever
+// returns; only the nonce marker.
//
// 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).
@@ -19,6 +21,8 @@ import { bytesToB64url } from "./crypto.js";
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 PENDING_KEY = "lchat_home_enrol_pending"; // nonce for the in-flight enrolment
+const RETURN_MARK = "lchat_backup"; // return fragment key: #lchat_backup=<nonce>
const SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; // "remind me later" = ~7 days
@@ -69,6 +73,9 @@ function removeBanner() {
// 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
+ // If we've just returned from the home server's /enroll page, record the
+ // backup (and scrub the marker) before deciding whether to nudge.
+ checkEnrolReturn();
const el = bannerEl();
if (!el) return;
const state = backupBannerState({
@@ -96,9 +103,7 @@ function renderBanner(el, session) {
const cta = document.createElement("button");
cta.className = "backup-banner-cta";
cta.textContent = "Back up with a passkey";
- cta.addEventListener("click", () => {
- launchEnrol(session).catch(() => {});
- });
+ cta.addEventListener("click", () => launchEnrol(session));
// 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
@@ -123,68 +128,67 @@ function renderBanner(el, session) {
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.
+// launchEnrol hands off to the home server's /enroll page via a SAME-TAB
+// navigation, with the session in the URL fragment (never sent to a server).
+//
+// Why same-tab and not a popup: Safari blocks WebAuthn in a window opened by a
+// cross-origin page (the popup's create() aborts with NotAllowedError the
+// instant it opens). A same-tab navigation makes /enroll a plain top-level page
+// — the context where the ceremony works — and the home server redirects back
+// here on success. A one-time nonce (stored locally, echoed in the return
+// fragment) makes that return signal unforgeable: another site can't send you
+// back with a fake success marker to suppress the backup reminder.
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 home = window.__homeOrigin;
+ if (!home) return;
+ const nonce = randomNonce();
+ localStorage.setItem(PENDING_KEY, nonce);
+ const payload = {
+ v: 1,
+ nonce, // echoed back in the return fragment to prove the redirect is genuine
+ ret: location.origin, // where /enroll sends the user back to
+ 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;
- }
+ },
+ };
+ const encoded = bytesToB64url(new TextEncoder().encode(JSON.stringify(payload)));
+ // The payload rides in the #fragment — it never reaches the home server.
+ location.assign(`${home}/enroll#${encoded}`);
+}
- 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);
- });
+function randomNonce() {
+ const b = new Uint8Array(16);
+ crypto.getRandomValues(b);
+ return bytesToB64url(b);
+}
+
+// parseReturnNonce pulls our nonce out of a return fragment like
+// "#lchat_backup=<nonce>" (pure, so it's unit-testable). Returns "" if absent.
+export function parseReturnNonce(hash) {
+ const m = new RegExp("(?:^#|[#&])" + RETURN_MARK + "=([^&]+)").exec(hash || "");
+ return m ? decodeURIComponent(m[1]) : "";
+}
+
+// checkEnrolReturn runs on load: if we've come back from /enroll with a nonce
+// that matches the one we stored before leaving, record enrolment and scrub the
+// marker from the URL. A mismatched/forged marker is ignored (but still scrubbed).
+export function checkEnrolReturn() {
+ const got = parseReturnNonce(location.hash);
+ if (!got) return false;
+ const pending = localStorage.getItem(PENDING_KEY);
+ const ok = !!pending && got === pending;
+ if (ok) {
+ localStorage.setItem(ENROLLED_KEY, "1");
+ localStorage.removeItem(PENDING_KEY);
+ }
+ // Drop the marker fragment either way (the return URL carries only our marker).
+ try { history.replaceState(null, "", location.pathname + location.search); } catch {}
+ return ok;
}