| 1 | // Signup, one screen per step. |
| 2 | // |
| 3 | // The screen this replaced did six seconds of work behind one button and, when |
| 4 | // anything went wrong, printed whichever DOMException happened to surface. So |
| 5 | // the shape here is deliberate: each step is a screen of its own, each says |
| 6 | // what it is doing while it does it, and each failure names the operation it |
| 7 | // died in and leaves the whole error in the console. |
| 8 | |
| 9 | import { signup, AlreadyRegisteredError } from "../crypto/account.js"; |
| 10 | import { accountKeyCard, recoverySheet } from "../crypto/kit.js"; |
| 11 | import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js"; |
| 12 | import { net, RequestError } from "../crypto/net.js"; |
| 13 | import { toHex } from "../crypto/bytes.js"; |
| 14 | |
| 15 | function el(tag, attrs = {}, ...children) { |
| 16 | const node = document.createElement(tag); |
| 17 | for (const [k, v] of Object.entries(attrs)) { |
| 18 | if (k === "class") node.className = v; |
| 19 | else node.setAttribute(k, v); |
| 20 | } |
| 21 | for (const c of children) { |
| 22 | if (c == null) continue; |
| 23 | node.append(typeof c === "string" ? document.createTextNode(c) : c); |
| 24 | } |
| 25 | return node; |
| 26 | } |
| 27 | |
| 28 | // replace drops absent children rather than letting the DOM turn them into |
| 29 | // the word "null" on the screen. |
| 30 | function replace(node, ...children) { |
| 31 | node.replaceChildren(...children.filter((c) => c != null)); |
| 32 | } |
| 33 | |
| 34 | function say(form, text, kind = "working") { |
| 35 | const box = form.querySelector("[data-status]"); |
| 36 | if (box) replace(box, el("p", { "data-kind": kind }, text)); |
| 37 | } |
| 38 | |
| 39 | function busy(form, on) { |
| 40 | for (const c of form.querySelectorAll("input, button")) c.disabled = on; |
| 41 | } |
| 42 | |
| 43 | // show switches which screen of this document is the screen. One at a time, |
| 44 | // and back to the top each time — a screen that appears underneath the one |
| 45 | // you were reading is not a new screen, it is a longer old one. |
| 46 | function show(name) { |
| 47 | for (const s of document.querySelectorAll("[data-screen]")) { |
| 48 | s.hidden = s.dataset.screen !== name; |
| 49 | } |
| 50 | window.scrollTo(0, 0); |
| 51 | } |
| 52 | |
| 53 | // report is the failure path every step shares. |
| 54 | // |
| 55 | // A bare DOMException message — "Data provided to an operation does not meet |
| 56 | // requirements" — names none of the dozen operations that could have raised |
| 57 | // it. Saying which step died is the difference between a bug report and a |
| 58 | // guess, and the console keeps the stack for the report. |
| 59 | function report(form, step, err) { |
| 60 | console.error("CONTRA signup failed while", step, err); |
| 61 | const name = err && err.name && err.name !== "Error" ? `${err.name}: ` : ""; |
| 62 | say(form, `Could not ${step}. ${name}${err.message} ` + |
| 63 | "The browser console has the full detail.", "error"); |
| 64 | busy(form, false); |
| 65 | } |
| 66 | |
| 67 | // Storage that works, or storage that quietly does nothing. Safari refuses |
| 68 | // localStorage entirely in some configurations, and merely touching |
| 69 | // window.localStorage throws there — so the access is probed, not assumed. |
| 70 | function storageFor() { |
| 71 | try { |
| 72 | const s = window.localStorage; |
| 73 | s.setItem("contra/v1/probe", "1"); |
| 74 | s.removeItem("contra/v1/probe"); |
| 75 | return s; |
| 76 | } catch { |
| 77 | return { getItem: () => null, setItem: () => {}, removeItem: () => {} }; |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // ---------------------------------------------------- step 1: the address |
| 82 | |
| 83 | const beginForm = document.querySelector("[data-begin]"); |
| 84 | if (beginForm) { |
| 85 | beginForm.addEventListener("submit", async (e) => { |
| 86 | e.preventDefault(); |
| 87 | const email = beginForm.querySelector("[name=email]").value.trim(); |
| 88 | busy(beginForm, true); |
| 89 | say(beginForm, "Sending your link…"); |
| 90 | try { |
| 91 | await net.post("/auth/begin", { email }); |
| 92 | // Remembered for this tab so the passkey step can label the credential |
| 93 | // with the address rather than with nothing. sessionStorage, not local: |
| 94 | // it dies with the tab, it never leaves the browser, and it is the |
| 95 | // address the person just typed into this same window. |
| 96 | // |
| 97 | // The server cannot supply this — it holds a blind index and the link |
| 98 | // carries only a token — so if signup finishes in a different browser |
| 99 | // the passkey falls back to the account handle. |
| 100 | try { |
| 101 | window.sessionStorage.setItem("contra/v1/signup-email", email); |
| 102 | } catch { |
| 103 | // A browser that refuses session storage just gets the fallback. |
| 104 | } |
| 105 | window.location.assign("/signup/sent"); |
| 106 | } catch (err) { |
| 107 | if (err instanceof RequestError && err.status === 400) { |
| 108 | say(beginForm, err.message, "error"); |
| 109 | busy(beginForm, false); |
| 110 | return; |
| 111 | } |
| 112 | report(beginForm, "send that link", err); |
| 113 | } |
| 114 | }); |
| 115 | } |
| 116 | |
| 117 | // ------------------------------------------------- step 2: the passphrase |
| 118 | |
| 119 | // download hands the browser one file. Both documents are built here and the |
| 120 | // bytes never leave: a kit that touched the server would put the recovery |
| 121 | // code somewhere it must never be, for the length of one request. |
| 122 | function download(bytes, filename) { |
| 123 | const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" })); |
| 124 | const a = el("a", { href: url, download: filename }); |
| 125 | document.body.append(a); |
| 126 | a.click(); |
| 127 | a.remove(); |
| 128 | // Revoked on a later tick: some browsers have not started reading the blob |
| 129 | // by the time click() returns. |
| 130 | setTimeout(() => URL.revokeObjectURL(url), 30_000); |
| 131 | } |
| 132 | |
| 133 | // The kit is shown exactly once. The recovery code is not stored by anyone, |
| 134 | // including us — only a sealed copy of the vault key that it opens — so this |
| 135 | // is the single moment it exists outside the person's hands. |
| 136 | function showKit(accountKeyPrinted, recoveryPrinted, accountIdHex, cached) { |
| 137 | const body = document.querySelector("[data-kit-body]"); |
| 138 | const short = accountIdHex.slice(0, 8); |
| 139 | |
| 140 | // One button per document, not one button for both. |
| 141 | // |
| 142 | // Browsers refuse a second programmatic download from a single gesture — |
| 143 | // Safari silently, which is how "Save both documents" delivered one file |
| 144 | // and said nothing. Two buttons is also the truer shape: the whole reason |
| 145 | // there are two documents is that they belong in two different places, and |
| 146 | // handing them over as one action quietly argues the opposite. |
| 147 | const saver = (label, make, name) => { |
| 148 | const button = el("button", { type: "button", "data-save": name }, label); |
| 149 | button.addEventListener("click", () => { |
| 150 | download(make(), `contra-${name}-${short}.pdf`); |
| 151 | button.replaceChildren(document.createTextNode(label + " — saved")); |
| 152 | button.setAttribute("data-saved", ""); |
| 153 | }); |
| 154 | return button; |
| 155 | }; |
| 156 | |
| 157 | replace(body, |
| 158 | el("p", {}, |
| 159 | "Two documents, shown once. Nobody can reissue the recovery code, " + |
| 160 | "because nobody stores it. Save them to two different places."), |
| 161 | |
| 162 | el("h2", {}, "Account Key card — store somewhere safe"), |
| 163 | el("p", {}, |
| 164 | "Keep it where you can find it and nobody else goes: a drawer, a " + |
| 165 | "wallet, a folder with your documents. It is useless to anyone who " + |
| 166 | "does not also know your passphrase. A new device asks for it."), |
| 167 | el("pre", { "data-kit": "account-key" }, accountKeyPrinted), |
| 168 | el("p", {}, saver("Save the Account Key card", |
| 169 | () => accountKeyCard(accountIdHex, accountKeyPrinted), "account-key")), |
| 170 | |
| 171 | el("h2", {}, "Recovery sheet — store securely"), |
| 172 | el("p", {}, |
| 173 | "Keep it where you would keep a passport or a will: a safe, a deposit " + |
| 174 | "box, a trusted person's house. Never photograph it. This sheet alone " + |
| 175 | "can open your vault."), |
| 176 | el("pre", { "data-kit": "recovery" }, recoveryPrinted), |
| 177 | el("p", {}, saver("Save the Recovery sheet", |
| 178 | () => recoverySheet(accountIdHex, recoveryPrinted), "recovery-sheet")), |
| 179 | |
| 180 | cached ? null : el("p", { "data-kind": "warning" }, |
| 181 | "This browser will not remember your Account Key, so it will ask for " + |
| 182 | "the card next time. That is safe — just keep the card."), |
| 183 | |
| 184 | el("p", {}, |
| 185 | "If you lose both the kit and your passphrase, the data is gone. There " + |
| 186 | "is no reset link and no way for us to let you back in."), |
| 187 | el("p", {}, |
| 188 | el("label", {}, |
| 189 | el("input", { type: "checkbox", "data-confirmed": "" }), |
| 190 | " I have saved both documents somewhere I can find them.")), |
| 191 | el("p", { "data-onward": "", hidden: "hidden" }, |
| 192 | el("a", { href: "/signup/passkey" }, "Continue")), |
| 193 | ); |
| 194 | |
| 195 | // No way onward until the person says they have the documents. Walking past |
| 196 | // this screen without the recovery code is the one mistake in this product |
| 197 | // that cannot be undone later. |
| 198 | const onward = body.querySelector("[data-onward]"); |
| 199 | body.querySelector("[data-confirmed]").addEventListener("change", (e) => { |
| 200 | onward.hidden = !e.target.checked; |
| 201 | }); |
| 202 | |
| 203 | show("kit"); |
| 204 | } |
| 205 | |
| 206 | // markProgress moves the working screen's list along. The names come from the |
| 207 | // crypto module itself, so the list cannot drift out of step with the work it |
| 208 | // claims to be describing. |
| 209 | function markProgress(task) { |
| 210 | for (const li of document.querySelectorAll("[data-progress] li")) { |
| 211 | if (li.dataset.task === task) li.setAttribute("data-state", "doing"); |
| 212 | else if (li.getAttribute("data-state") === "doing") li.setAttribute("data-state", "done"); |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | const passphraseForm = document.querySelector("[data-passphrase]"); |
| 217 | if (passphraseForm) { |
| 218 | passphraseForm.addEventListener("submit", async (e) => { |
| 219 | e.preventDefault(); |
| 220 | const passphrase = passphraseForm.querySelector("[name=passphrase]").value; |
| 221 | const again = passphraseForm.querySelector("[name=passphrase_again]").value; |
| 222 | if (passphrase !== again) { |
| 223 | say(passphraseForm, "The two passphrases do not match.", "error"); |
| 224 | return; |
| 225 | } |
| 226 | busy(passphraseForm, true); |
| 227 | show("working"); |
| 228 | |
| 229 | let step = "start"; |
| 230 | try { |
| 231 | const kit = await signup(passphrase, passphraseForm.dataset.kdfParams, |
| 232 | storageFor(), net, (m) => { |
| 233 | step = m; |
| 234 | markProgress(m); |
| 235 | }); |
| 236 | showKit( |
| 237 | await formatCode(TAG_ACCOUNT_KEY, kit.accountKey), |
| 238 | await formatCode(TAG_RECOVERY, kit.recoveryCode), |
| 239 | toHex(kit.accountId), kit.cached); |
| 240 | } catch (err) { |
| 241 | // Back to the form: it is the screen with somewhere to put an error and |
| 242 | // something to try again with. |
| 243 | show("form"); |
| 244 | busy(passphraseForm, false); |
| 245 | if (err instanceof AlreadyRegisteredError) { |
| 246 | say(passphraseForm, `${err.message} Sign in instead.`, "error"); |
| 247 | return; |
| 248 | } |
| 249 | if (err instanceof RequestError && err.status === 403) { |
| 250 | say(passphraseForm, |
| 251 | "That link has expired or was already used. Start again from /signup.", |
| 252 | "error"); |
| 253 | return; |
| 254 | } |
| 255 | report(passphraseForm, step, err); |
| 256 | } |
| 257 | }); |
| 258 | } |
| 259 | |