| 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)); |