carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The fresh-device ladder, PROTOCOL.md's rung 1, 2 and 3.
2//
3// This page has two jobs and shows both, because a person arriving here knows
4// which one they are doing and the app does not: a browser that needs the
5// Account Key, and a browser that already has it and is being asked to pass
6// it on. The rule this is shaped by is that no screen dead-ends — every rung
7// that can work here is offered, and the last one always can.
8//
9// Everything expensive lives in a crypto module. This file moves text between
10// a form and one.
11
12import { net } from "../crypto/net.js";
13import { parseCode, repairCode, TAG_ACCOUNT_KEY } from "../crypto/code.js";
14import * as link from "../crypto/link.js";
15import { wireScanning } from "./devicescan.js";
16import { accountKeys, opener } from "../crypto/vaultkeys.js";
17
18const $ = (sel, root = document) => root.querySelector(sel);
19
20function say(el, text, kind = "working") {
21 if (!el) return;
22 const p = document.createElement("p");
23 p.setAttribute("data-kind", kind);
24 p.append(document.createTextNode(text));
25 el.replaceChildren(p);
26}
27
28function busy(form, on) {
29 for (const c of form.querySelectorAll("input, button")) c.disabled = on;
30}
31
32// arrived is the one exit this page has: the key is on this device now, so
33// send them where they were trying to go.
34function arrived(keys, accountIdHex, accountKey, status) {
35 try {
36 keys.set(accountIdHex, accountKey);
37 } catch {
38 // Safari refuses localStorage in some configurations. Refusing to
39 // continue here would be worse than a device that asks again next time.
40 say(status, "This device could not remember the key, but it will work " +
41 "for now. You will be asked for it again next visit.", "error");
42 }
43 window.location.assign("/vault");
44}
45
46async function main() {
47 const need = $("[data-need]");
48 if (!need) return;
49
50 const bootstrap = await net.get("/api/bootstrap");
51 const accountIdHex = bootstrap.account_id_hex;
52 const keys = accountKeys(window.localStorage);
53 const status = $("[data-need-status]");
54
55 // A device that already holds the key is here to give it away, not to get
56 // it. Both sections stay reachable, because a person may be on the wrong
57 // one and must not have to guess a URL to fix that.
58 const cached = keys.get(accountIdHex);
59 if (cached) {
60 $("[data-have]").hidden = false;
61 say(status, "This device already has your Account Key. You can approve " +
62 "another device below.", "ok");
63 }
64
65 wireHandoff(need, accountIdHex, keys, status);
66 wireTyping(need, bootstrap, accountIdHex, keys, status);
67 await wireScanning(need, status, say,
68 (text, el) => useCode(text, bootstrap, el, null),
69 (accountKey) => arrived(keys, accountIdHex, accountKey, status));
70 wireApproval(bootstrap, accountIdHex, keys);
71}
72
73// ------------------------------------------------------------ rung 1: handoff
74
75function wireHandoff(need, accountIdHex, keys, status) {
76 const panel = $("[data-handoff]", need);
77 const state = { pending: null, timer: null };
78
79 const stop = () => {
80 if (state.timer) clearTimeout(state.timer);
81 state.timer = null;
82 };
83
84 $("[data-start-handoff]", need).addEventListener("click", async () => {
85 try {
86 state.pending = await link.begin(net, accountIdHex);
87 } catch (err) {
88 console.error("CONTRA handoff could not start", err);
89 say(status, err.message || "Could not start a handoff. The other rungs " +
90 "below still work.", "error");
91 return;
92 }
93 $("[data-code]", panel).textContent = link.formatCode(state.pending.code);
94 panel.hidden = false;
95 poll();
96 });
97
98 // Two seconds, as slopbox polls. The window is ten minutes; a person who
99 // walks away and comes back finds it expired rather than still open.
100 async function poll() {
101 stop();
102 try {
103 const accountKey = await link.claim(net, state.pending);
104 if (accountKey) {
105 arrived(keys, accountIdHex, accountKey, status);
106 return;
107 }
108 } catch (err) {
109 console.error("CONTRA handoff failed while claiming", err);
110 say($("[data-handoff-status]", panel),
111 "That handoff could not be completed. Start another, or use your " +
112 "card below.", "error");
113 return;
114 }
115 state.timer = setTimeout(poll, 2000);
116 }
117
118 $("[data-cancel-handoff]", need).addEventListener("click", async () => {
119 stop();
120 panel.hidden = true;
121 if (state.pending) {
122 // Best effort: a code left behind expires by itself in ten minutes.
123 await link.cancel(net, state.pending.code).catch(() => {});
124 state.pending = null;
125 }
126 });
127}
128
129// ------------------------------------------------- rungs 2 and 3: card and QR
130
131// useCode is what both the camera and the text box end at: a string off the
132// card, checked and — if a character is wrong — repaired against the KCV.
133async function useCode(text, bootstrap, status, form) {
134 try {
135 return await parseCode(TAG_ACCOUNT_KEY, text);
136 } catch (err) {
137 if (!form) throw err;
138 }
139
140 // The checksum failed. Twenty bits does not identify the typo, so the
141 // repair produces candidates and the KCV picks between them — and that
142 // needs the passphrase, which is why it is only asked for now.
143 const repair = $("[data-repair]", form);
144 const passphrase = $("#repair-passphrase", form).value;
145 if (!passphrase) {
146 repair.hidden = false;
147 say(status, "That code did not check out. Add your passphrase and this " +
148 "can try the single-character corrections.", "error");
149 return null;
150 }
151
152 say(status, "Checking your passphrase…");
153 const o = await opener(bootstrap, passphrase);
154
155 say(status, "Trying corrections…");
156 const candidates = await repairCode(TAG_ACCOUNT_KEY, text);
157 for (const candidate of candidates) {
158 const payload = await parseCode(TAG_ACCOUNT_KEY, candidate);
159 if (await o.accepts(payload)) return payload;
160 }
161 say(status, "That is not a code for this account, even allowing for one " +
162 "mistyped character. Check it against the card.", "error");
163 return null;
164}
165
166function wireTyping(need, bootstrap, accountIdHex, keys, status) {
167 const form = $("[data-type-form]", need);
168 form.addEventListener("submit", async (e) => {
169 e.preventDefault();
170 busy(form, true);
171 try {
172 const accountKey = await useCode(
173 $("#account-key", form).value, bootstrap, status, form);
174 if (accountKey) arrived(keys, accountIdHex, accountKey, status);
175 } catch (err) {
176 console.error("CONTRA could not read a typed Account Key", err);
177 say(status, "That does not look like an Account Key. It starts with " +
178 "A1 and has thirty characters after it.", "error");
179 } finally {
180 busy(form, false);
181 }
182 });
183}
184
185// ---------------------------------------------------------------- approving
186
187function wireApproval(bootstrap, accountIdHex, keys) {
188 const have = $("[data-have]");
189 const form = $("[data-approve-form]", have);
190 const confirm = $("[data-confirm]", have);
191 const status = $("[data-approve-status]", have);
192 const state = { code: null, devicePublicHex: null };
193
194 form.addEventListener("submit", async (e) => {
195 e.preventDefault();
196 busy(form, true);
197 try {
198 const code = link.readCode($("#link-code", form).value);
199 const pending = await link.lookup(net, code);
200 if (pending.approved) {
201 say(status, "That code has already been approved.", "error");
202 return;
203 }
204 state.code = code;
205 state.devicePublicHex = pending.device_public_hex;
206 confirm.hidden = false;
207 say(status, "");
208 } catch (err) {
209 console.error("CONTRA could not look up a code", err);
210 say(status, "No device is waiting with that code. Check it, and that " +
211 "it has not been more than ten minutes.", "error");
212 } finally {
213 busy(form, false);
214 }
215 });
216
217 $("[data-do-approve]", have).addEventListener("click", async () => {
218 const passphrase = $("#approve-passphrase", have).value;
219 if (!passphrase) {
220 say(status, "Your passphrase is needed to unlock this device's copy.",
221 "error");
222 return;
223 }
224 busy(confirm, true);
225 try {
226 const accountKey = keys.get(accountIdHex);
227 if (!accountKey) throw new Error("this device has no Account Key cached");
228
229 // Prove the passphrase really is this account's before handing a key
230 // over. Without it, anyone at an unlocked screen could enrol a device.
231 say(status, "Checking your passphrase…");
232 const o = await opener(bootstrap, passphrase);
233 if (!await o.accepts(accountKey)) {
234 say(status, "That passphrase does not open this account.", "error");
235 return;
236 }
237
238 say(status, "Sealing your Account Key to that device…");
239 await link.approve(net, state.code, accountIdHex, state.devicePublicHex, accountKey);
240 confirm.hidden = true;
241 say(status, "Approved. The other device should open in a moment.", "ok");
242 } catch (err) {
243 console.error("CONTRA could not approve a device", err);
244 say(status, err.message || "Could not approve that device.", "error");
245 } finally {
246 busy(confirm, false);
247 }
248 });
249
250 // Deliberately does not revoke: there is no cancel-someone-else's-code
251 // route, and saying "we stopped it" when we did not would be worse than
252 // saying what actually happens.
253 $("[data-not-me]", have).addEventListener("click", () => {
254 confirm.hidden = true;
255 state.code = null;
256 say(status, "Nothing was sent. That code expires by itself within ten " +
257 "minutes. If you did not start it, nobody else can use it either — it " +
258 "only works for someone already signed in to your account.", "ok");
259 });
260}
261
262main().catch((err) => {
263 console.error("CONTRA device page failed to start", err);
264});
265