| 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 | |
| 12 | import { net } from "../crypto/net.js"; |
| 13 | import { parseCode, repairCode, TAG_ACCOUNT_KEY } from "../crypto/code.js"; |
| 14 | import * as link from "../crypto/link.js"; |
| 15 | import { wireScanning } from "./devicescan.js"; |
| 16 | import { accountKeys, opener } from "../crypto/vaultkeys.js"; |
| 17 | |
| 18 | const $ = (sel, root = document) => root.querySelector(sel); |
| 19 | |
| 20 | function 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 | |
| 28 | function 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. |
| 34 | function 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 | |
| 46 | async 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 | |
| 75 | function 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. |
| 133 | async 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 | |
| 166 | function 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 | |
| 187 | function 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 | |
| 262 | main().catch((err) => { |
| 263 | console.error("CONTRA device page failed to start", err); |
| 264 | }); |
| 265 | |