eleven / messenger Public

home: one-button "Save securely" enrolment (sign-in first, create as fallback) (#117)

The enrol page forced an up-front choice between "Create a passkey" and "I
already have a backup" — and picking Create when you already had one silently
started a SECOND, separate backup (fragmentation footgun).

Now a single "Save securely" button signs in to an existing backup first and
adds the chat to it; only if no backup is found does it offer to create one
(with an "I already have a backup — sign in" escape so a dismissed prompt can't
fragment). A passkey that exists but has no backup yet starts one under that
same credential rather than making another. createBlobFor is shared by the
create path and that no-blob case.

Copy reframed to match the banner: "Save this chat securely" / "Save securely".
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell af469561fe672756ef96b87df95323c6ef89e705 parent c58c900
2 files changed, +88 −35
  • home/web/pages/enroll.html +5 −5
  • home/web/static/enroll.js +83 −30
diff --git a/home/web/pages/enroll.html b/home/web/pages/enroll.html
index 02ee5c1..fbc122f 100644
--- a/home/web/pages/enroll.html
+++ b/home/web/pages/enroll.html
@@ -42,9 +42,10 @@
</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>
+ <h1>Save this chat securely</h1>
+ <p class="sub">Saved to your passkey-protected backup so you can get back in on
+ a new device. Protected by a passkey — nothing to remember. This server can't
+ read your backup.</p>
<div class="entry" id="entry">
<div class="name" id="entry-name">This chat</div>
@@ -52,8 +53,7 @@
</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>
+ <button class="primary" id="save">Save securely</button>
</div>
<p class="status" id="status"></p>
diff --git a/home/web/static/enroll.js b/home/web/static/enroll.js
index 163b1eb..dca2455 100644
--- a/home/web/static/enroll.js
+++ b/home/web/static/enroll.js
@@ -95,39 +95,63 @@ function prefetch() {
beginLogin().then((v) => { loginBegin = v; }).catch(() => { loginBegin = null; });
}
-// createAndAdd: a brand-new backup whose first (and only) server is this entry.
-async function createAndAdd() {
+// saveSecurely is the one button. Sign in to an existing backup FIRST and add
+// this chat to it; only if no backup is found do we offer to create one. This
+// removes the up-front "create vs. sign in" choice — and, crucially, a returning
+// user always adds to their one backup instead of accidentally starting a second.
+async function saveSecurely() {
disable();
- setStatus("Waiting for your passkey…");
- let reg;
+ setStatus("Checking for your saved backup…");
+ let login;
try {
- reg = await runRegister(regBegin);
+ login = await runLogin(loginBegin);
} 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;
- }
+ // NotAllowedError here means no backup on an authenticator (or the sign-in
+ // prompt was dismissed). Offer to create one — with a way back to sign-in so
+ // a returning user who dismissed doesn't accidentally start a second backup.
+ if (e && e.name === "NotAllowedError") { offerCreate(); return; }
throw e;
}
- const kek = await kekFromPRF(reg.prf);
+ await addWithLogin(login);
+}
+
+// createBlobFor writes a fresh backup whose only server is this entry, keyed to
+// the given credential. Shared by "create a new passkey" and the "signed in but
+// no backup yet" case, so both start a backup rather than fragmenting.
+async function createBlobFor({ token, credId, prf }) {
+ const kek = await kekFromPRF(prf);
const dek = await generateDEK();
const vault = {
obj: { v: 1, servers: [payload.entry] },
- keys: { [reg.credId]: await wrapDEK(dek, kek) },
+ keys: { [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'");
+ const v = await putBlob(token, sealed, vault.version);
+ if (v === null) throw new Error("a backup already exists for this passkey — sign in instead");
succeed();
}
-// addToExisting: sign in to an existing backup and add this entry to it.
-async function addToExisting() {
+// createAndAdd: register a brand-new passkey, then start a backup under it.
+async function createAndAdd() {
disable();
setStatus("Waiting for your passkey…");
- const { token, credId, prf } = await runLogin(loginBegin);
+ let reg;
+ try {
+ 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");
+ enable();
+ return;
+ }
+ throw e;
+ }
+ await createBlobFor(reg);
+}
+
+// addWithLogin adds this chat to the backup behind an already-signed-in passkey.
+async function addWithLogin({ token, credId, prf }) {
if (!prf) {
setStatus("Signed in, but this browser didn't return a PRF result, so the backup can't be unlocked here.", "error");
enable();
@@ -135,8 +159,9 @@ async function addToExisting() {
}
const got = await fetchBlob(token);
if (!got) {
- setStatus("That passkey has no backup yet. Use 'Create a backup passkey' instead.", "error");
- enable();
+ // A passkey that has no backup yet — start one under it (don't send the user
+ // off to make a second passkey).
+ await createBlobFor({ token, credId, prf });
return;
}
const kek = await kekFromPRF(prf);
@@ -150,9 +175,9 @@ async function addToExisting() {
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
+ // the credentials the user already trusts. Anyone can open this page 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.
+ // real saved entry with junk — a silent add 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);
@@ -160,8 +185,8 @@ async function addToExisting() {
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.
+ // The replace prompt replaced the action buttons, so this is terminal:
+ // leave a clear message; the user goes back / closes the window.
$("actions").classList.add("hidden");
setStatus("Kept your existing backup for this chat — nothing changed.", "ok");
return;
@@ -183,8 +208,37 @@ async function addToExisting() {
// ---- UI plumbing --------------------------------------------------------------
-function disable() { $("create").disabled = true; $("existing").disabled = true; }
-function enable() { $("create").disabled = false; $("existing").disabled = false; }
+// Disable/enable every button currently in #actions — the set changes as the
+// flow morphs (save → create/retry, or the replace prompt), so target them all.
+function setBusy(busy) {
+ $("actions").querySelectorAll("button").forEach((b) => { b.disabled = busy; });
+}
+function disable() { setBusy(true); }
+function enable() { setBusy(false); }
+
+// offerCreate: shown only after sign-in found no backup. Creating is the
+// secondary path here (it starts a NEW backup), with a prominent way back to
+// sign-in so a returning user who dismissed the prompt can't fragment.
+function offerCreate() {
+ const actions = $("actions");
+ actions.textContent = "";
+ setStatus("No saved backup found on this device.");
+
+ const create = document.createElement("button");
+ create.className = "primary";
+ create.textContent = "Create a new secure backup";
+ create.addEventListener("click", () => createAndAdd().catch(onError));
+
+ const retry = document.createElement("button");
+ retry.textContent = "I already have a backup — sign in";
+ retry.addEventListener("click", () => saveSecurely().catch(onError));
+
+ const back = document.createElement("button");
+ back.textContent = "Cancel";
+ back.addEventListener("click", () => goBack(false));
+
+ actions.append(create, retry, back);
+}
// confirmReplace shows a distinct, host-specific prompt before overwriting an
// existing entry (never the generic "add" copy), and resolves the user's choice.
@@ -208,8 +262,8 @@ function confirmReplace(existing) {
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.
+ // Disable both so a double-click can't resolve twice; these replaced the
+ // action buttons, so setBusy/enable won't touch them anyway.
const settle = (v) => { replace.disabled = true; cancel.disabled = true; resolve(v); };
replace.addEventListener("click", () => settle(true));
cancel.addEventListener("click", () => settle(false));
@@ -256,10 +310,9 @@ if (!payload) {
// 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));
+ $("save").addEventListener("click", () => saveSecurely().catch(onError));
- // A way back without enrolling (same-tab navigation replaced the chat).
+ // A way back without saving (same-tab navigation replaced the chat).
const back = document.createElement("button");
back.textContent = "Cancel";
back.addEventListener("click", () => goBack(false));