carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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
9import { signup, AlreadyRegisteredError } from "../crypto/account.js";
10import { accountKeyCard, recoverySheet } from "../crypto/kit.js";
11import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js";
12import { net, RequestError } from "../crypto/net.js";
13import { toHex } from "../crypto/bytes.js";
14
15function 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.
30function replace(node, ...children) {
31 node.replaceChildren(...children.filter((c) => c != null));
32}
33
34function 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
39function 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.
46function 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.
59function 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.
70function 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
83const beginForm = document.querySelector("[data-begin]");
84if (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.
122function 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.
136function 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.
209function 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
216const passphraseForm = document.querySelector("[data-passphrase]");
217if (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