carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Reprinting the kit, and the nag that tries to stop anyone needing to.
2//
3// The two documents behave differently and this file keeps them apart on
4// purpose. The Account Key card is a pure reprint — same key, new paper, no
5// server, no consequence. The recovery sheet cannot be reprinted at all, only
6// reissued, and reissuing destroys the old one. Sharing a code path between
7// those two would be how a "print" button one day silently invalidated a
8// sheet in somebody's safe.
9
10import { net } from "../crypto/net.js";
11import { formatCode, TAG_ACCOUNT_KEY, TAG_RECOVERY } from "../crypto/code.js";
12import { accountKeyCard, recoverySheet } from "../crypto/kit.js";
13import { reissueRecovery } from "../crypto/reissue.js";
14import { accountKeys } from "../crypto/vaultkeys.js";
15
16const $ = (sel, root = document) => root.querySelector(sel);
17
18function el(tag, attrs = {}, ...kids) {
19 const node = document.createElement(tag);
20 for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
21 node.append(...kids.filter((k) => k !== null && k !== undefined));
22 return node;
23}
24
25function say(box, text, kind = "working") {
26 box.replaceChildren(el("p", { "data-kind": kind }, text));
27}
28
29// download is signup's, and one button per document for the same reason:
30// browsers refuse a second programmatic save from one gesture, Safari
31// silently.
32function download(bytes, filename) {
33 const url = URL.createObjectURL(new Blob([bytes], { type: "application/pdf" }));
34 const a = el("a", { href: url, download: filename });
35 document.body.append(a);
36 a.click();
37 a.remove();
38 setTimeout(() => URL.revokeObjectURL(url), 10000);
39}
40
41async function main() {
42 const cardSection = $("[data-card]");
43 if (!cardSection) return;
44
45 const bootstrap = await net.get("/api/bootstrap");
46 const accountIdHex = bootstrap.account_id_hex;
47 const short = accountIdHex.slice(0, 8);
48 const keys = accountKeys(window.localStorage);
49 const accountKey = keys.get(accountIdHex);
50
51 wireCard(accountIdHex, short, accountKey);
52 wireReissue(bootstrap, accountIdHex, short, accountKey);
53
54 // The nag, PROTOCOL.md's row six. Shown when this browser holds the key and
55 // has no way of knowing another one does — which is the honest state, since
56 // nothing about other devices is stored anywhere we could ask.
57 if (accountKey) $("[data-nag]").hidden = false;
58}
59
60function wireCard(accountIdHex, short, accountKey) {
61 const status = $("[data-card-status]");
62 if (!accountKey) {
63 $("[data-card-missing]").hidden = false;
64 return;
65 }
66 $("[data-card-ready]").hidden = false;
67
68 $("[data-print-card]").addEventListener("click", async () => {
69 try {
70 const printed = await formatCode(TAG_ACCOUNT_KEY, accountKey);
71 download(accountKeyCard(accountIdHex, printed),
72 `contra-account-key-${short}.pdf`);
73 say(status, "Saved. It is the same key as the card you already have — " +
74 "both work.", "ok");
75 } catch (err) {
76 console.error("CONTRA could not reprint the Account Key card", err);
77 say(status, "Could not build the card. The browser console has the " +
78 "detail.", "error");
79 }
80 });
81}
82
83function wireReissue(bootstrap, accountIdHex, short, accountKey) {
84 const form = $("[data-recovery-form]");
85 const status = $("[data-recovery-status]");
86 const button = $("[data-regenerate]");
87 const check = $("[data-confirm-recovery]");
88
89 // The button stays disabled until the checkbox is ticked. A destructive
90 // action a person can reach by pressing Enter in a password field is a
91 // destructive action somebody will reach by accident.
92 check.addEventListener("change", () => {
93 button.disabled = !check.checked;
94 });
95
96 form.addEventListener("submit", async (e) => {
97 e.preventDefault();
98 if (!check.checked) return;
99 if (!accountKey) {
100 say(status, "This device does not have your Account Key, so it cannot " +
101 "open your vault key to seal a new code to it. Give it the key first.",
102 "error");
103 return;
104 }
105
106 button.disabled = true;
107 let step = "start";
108 try {
109 const { recoveryCode } = await reissueRecovery(
110 bootstrap, $("#recovery-passphrase").value, accountKey, net,
111 (m) => {
112 step = m;
113 say(status, m + "…");
114 });
115
116 const printed = await formatCode(TAG_RECOVERY, recoveryCode);
117 showNewSheet(printed, accountIdHex, short);
118 say(status, "Done. The old sheet no longer opens this vault.", "ok");
119 form.hidden = true;
120 } catch (err) {
121 // Nothing was sent unless the last step reached the server, and the
122 // failure modes before that leave the old sheet working — which is
123 // what the person needs told, first.
124 console.error("CONTRA could not reissue the recovery code while", step, err);
125 say(status, `Could not issue a new sheet (failed while: ${step}). ` +
126 "Your existing recovery sheet is unchanged and still works.", "error");
127 button.disabled = false;
128 }
129 });
130}
131
132// The new sheet, shown exactly once — the same contract signup makes, and for
133// the same reason: nobody stores this, including us.
134function showNewSheet(printed, accountIdHex, short) {
135 const box = $("[data-new-sheet]");
136 const save = el("button", { type: "button", "data-save": "recovery-sheet" },
137 "Save the new recovery sheet");
138 save.addEventListener("click", () => {
139 download(recoverySheet(accountIdHex, printed),
140 `contra-recovery-sheet-${short}.pdf`);
141 save.replaceChildren(document.createTextNode("Save the new recovery sheet — saved"));
142 save.setAttribute("data-saved", "");
143 });
144
145 box.replaceChildren(
146 el("h3", {}, "Your new recovery sheet"),
147 el("p", {}, "Shown once. Save it now and put it where the old one was — " +
148 "then destroy the old one, which no longer opens anything."),
149 el("pre", { "data-kit": "recovery" }, printed),
150 el("p", {}, save));
151 box.hidden = false;
152}
153
154main().catch((err) => {
155 console.error("CONTRA kit page failed to start", err);
156});
157