web: say which step failed, and stop a storage refusal losing the kit
A signup on Safari answered "Data provided to an operation does not meet requirements" and nothing else. That is a WebCrypto DOMException message, and by itself it names none of the dozen operations that could have produced it — the difference between a bug report and a guess. The failure path now says which step was running, and puts the whole error in the console, which is where somebody can copy it from. The same look found a worse bug beside it. The Account Key was cached with a bare `accountKeys(storage).set(...)`, after the account had been created and before the kit was shown. Safari refuses localStorage outright in some configurations, and merely touching `window.localStorage` throws there — so that line could take down the one screen on which the recovery code is ever displayed, for an optimisation, leaving an account nobody can ever recover. Caching is best effort now, and the access itself is probed rather than assumed. A device that cannot remember the Account Key is a device that asks for the card next time, which is a working state. A 409 also gets its own type and its own sentence. A half-finished signup leaves the account created and the kit unshown, so the retry lands exactly there — and being told to sign in is more use than being told a status code. Headless WebKit 2336 completes this signup, so the reported failure is not reproducible here yet; this makes the next report say where to look.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+60
−8
internal/contra/static/crypto/account.js+22 −2internal/contra/static/pages/auth.js+36 −4internal/contratest/jsmodules_test.go+2 −2
Intent
diff --git a/internal/contra/static/crypto/account.js b/internal/contra/static/crypto/account.js| index c465908..c6f0281 100644 |
| --- a/internal/contra/static/crypto/account.js |
| +++ b/internal/contra/static/crypto/account.js |
| @@ -18,6 +18,8 @@ import { accountKeys, provision } from "./vaultkeys.js"; |
| const CHALLENGE_CONTEXT = "contra/v1/login-challenge"; |
| +export class AlreadyRegisteredError extends Error {} |
| + |
| async function postJSON(path, body) { |
| const res = await fetch(path, { |
| method: "POST", |
| @@ -25,6 +27,13 @@ async function postJSON(path, body) { |
| body: JSON.stringify(body), |
| }); |
| const data = await res.json().catch(() => ({})); |
| + if (res.status === 409) { |
| + // Worth its own type. A half-finished signup leaves the account created |
| + // and the kit unshown, and the retry lands here — so this is exactly the |
| + // moment somebody needs to be told to sign in rather than told a status |
| + // code. |
| + throw new AlreadyRegisteredError("There is already an account for that address."); |
| + } |
| if (!res.ok) throw new Error(data.error || `request to ${path} failed`); |
| return data; |
| } |
| @@ -122,9 +131,20 @@ export async function signup(email, passphrase, storage, net, onProgress = () => |
| // Cache the Account Key for this device. From here the passphrase alone |
| // unlocks on this browser; a fresh one asks for the card. DESIGN.md's |
| // ladder — handoff, scan, type — is web/5. |
| - accountKeys(storage).set(toHex(accountId), accountKey); |
| + // |
| + // Best-effort, and deliberately so. Safari refuses localStorage outright in |
| + // some configurations, and a throw here would happen AFTER the account |
| + // exists — losing the one screen on which the recovery code is ever shown, |
| + // for an optimisation. A device that cannot remember the Account Key is a |
| + // device that asks for the card next time, which is a working state. |
| + let cached = true; |
| + try { |
| + accountKeys(storage).set(toHex(accountId), accountKey); |
| + } catch { |
| + cached = false; |
| + } |
| - return { accountId, accountKey, recoveryCode, rk, vault }; |
| + return { accountId, accountKey, recoveryCode, rk, vault, cached }; |
| } |
| // signIn runs the ceremony: ask for a challenge, stretch, derive, sign. |
diff --git a/internal/contra/static/pages/auth.js b/internal/contra/static/pages/auth.js| index 663928c..b312e62 100644 |
| --- a/internal/contra/static/pages/auth.js |
| +++ b/internal/contra/static/pages/auth.js |
| @@ -5,7 +5,7 @@ |
| // nodes rather than assigning innerHTML — untrusted content never becomes |
| // markup here, and the habit is cheaper to keep than to acquire. |
| -import { signIn, signup } from "../crypto/account.js"; |
| +import { AlreadyRegisteredError, signIn, signup } from "../crypto/account.js"; |
| import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js"; |
| import { toHex } from "../crypto/bytes.js"; |
| import { net } from "../crypto/net.js"; |
| @@ -106,6 +106,21 @@ function showKit(form, accountKeyPrinted, recoveryPrinted, accountIdHex) { |
| }); |
| } |
| +// storageFor hands back a key-value store that works, or one that quietly |
| +// does nothing. Safari refuses localStorage entirely in some configurations, |
| +// and merely TOUCHING window.localStorage can throw there — so the access |
| +// itself is guarded, not just the write. |
| +function storageFor() { |
| + try { |
| + const s = window.localStorage; |
| + s.setItem("contra/v1/probe", "1"); |
| + s.removeItem("contra/v1/probe"); |
| + return s; |
| + } catch { |
| + return { getItem: () => null, setItem: () => {}, removeItem: () => {} }; |
| + } |
| +} |
| + |
| function wireSignup(form) { |
| form.addEventListener("submit", async (e) => { |
| e.preventDefault(); |
| @@ -118,15 +133,32 @@ function wireSignup(form) { |
| return; |
| } |
| busy(form, true); |
| + |
| + // The step being attempted when something throws. A bare DOMException |
| + // message — "Data provided to an operation does not meet requirements" — |
| + // says nothing about which of a dozen operations produced it, and that is |
| + // the difference between a bug report and a guess. |
| + let step = "starting"; |
| try { |
| - const kit = await signup(email, passphrase, window.localStorage, net, |
| - (m) => status(form, m + "…")); |
| + const kit = await signup(email, passphrase, storageFor(), net, (m) => { |
| + step = m; |
| + status(form, m + "…"); |
| + }); |
| showKit(form, |
| await formatCode(TAG_ACCOUNT_KEY, kit.accountKey), |
| await formatCode(TAG_RECOVERY, kit.recoveryCode), |
| toHex(kit.accountId)); |
| } catch (err) { |
| - status(form, err.message, "error"); |
| + // Everything, to the console, because the message alone is not enough |
| + // to act on and this is the one place a person can copy it from. |
| + console.error("CONTRA signup failed while", step, err); |
| + if (err instanceof AlreadyRegisteredError) { |
| + status(form, `${err.message} Sign in instead, or use another address.`, "error"); |
| + } else { |
| + const name = err && err.name && err.name !== "Error" ? `${err.name}: ` : ""; |
| + status(form, `Failed while ${step}. ${name}${err.message} ` + |
| + "(the browser console has the full detail).", "error"); |
| + } |
| busy(form, false); |
| } |
| }); |
diff --git a/internal/contratest/jsmodules_test.go b/internal/contratest/jsmodules_test.go| index aac00e0..ac9a9e4 100644 |
| --- a/internal/contratest/jsmodules_test.go |
| +++ b/internal/contratest/jsmodules_test.go |
| @@ -26,7 +26,7 @@ const jsModuleCap = 300 |
| // arrive without somebody noticing. |
| var jsHighWater = map[string]int{ |
| // The crypto core. |
| - "account.js": 168, |
| + "account.js": 188, |
| "argon2id.js": 292, |
| "blake2b.js": 99, |
| "bytes.js": 61, |
| @@ -45,7 +45,7 @@ var jsHighWater = map[string]int{ |
| // Page controllers. Thin on purpose: every decision that matters lives in |
| // a crypto module, and these only move text between a form and one. |
| - "pages/auth.js": 159, |
| + "pages/auth.js": 191, |
| "pages/vault.js": 170, |
| } |