carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The Emergency Kit: two documents, generated here, never sent anywhere.
2//
3// Two files rather than one page with a fold, because the two values have
4// different blast radii and the risk being managed is *storage*. The Account
5// Key alone is nearly harmless — one input to the passphrase branch, useless
6// without the passphrase. The recovery code alone is full takeover. People
7// save PDFs, and one file puts both in the same folder forever; two files can
8// go to two places.
9//
10// The wording is PROTOCOL.md's, close to verbatim, and it does not lean on the
11// adjectives. "Store securely" and "store somewhere safe" are near-synonyms
12// in ordinary English and nobody derives two standards of care from them, so
13// the concrete places and behaviours do the work.
14//
15// ASCII only: the base-14 fonts have no glyph for a curly quote here, and a
16// document that renders "don't" as "donOt" is not one to hand somebody at the
17// worst moment of their year. pdf.js refuses anything else.
18
19import { encode } from "./qr.js";
20import { A4, CARD, document as pdfDocument, Page, textWidth } from "./pdf.js";
21
22// EC-Q, 25% recovery. Higher than the usual default on purpose: this is
23// print, kept for years, read off paper that may be creased, faded or
24// photocopied. The extra four modules across cost nothing on a page.
25const EC = "Q";
26
27const MARGIN = 56;
28const COLUMN = A4.width - MARGIN * 2;
29
30// handle identifies which account a sheet belongs to, without being useful to
31// anyone who finds it.
32//
33// PROTOCOL.md says the "blind-index handle", but the blind index is an HMAC
34// under a pepper the server holds and the browser deliberately never sees —
35// so the browser cannot compute one. The account id is already a random UUID
36// that identifies nothing by itself, so its first eight characters do the
37// same job. Noted on discussion 19 rather than quietly substituted.
38export function handle(accountIdHex) {
39 return accountIdHex.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, "$1-$2");
40}
41
42function today(now) {
43 const d = now ?? new Date();
44 return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-` +
45 String(d.getUTCDate()).padStart(2, "0");
46}
47
48// codeBlock draws a printed code as monospace text with its QR beside it.
49function codeBlock(page, x, y, code, moduleSize, size = 11) {
50 const qr = encode(code, { ec: EC });
51 page.qr(qr.modules, x, y - qr.modules.length * moduleSize, moduleSize);
52 const qrWidth = qr.modules.length * moduleSize;
53 page.text(x + qrWidth + 18, y - 14, code, { font: "mono", size });
54 return { bottom: y - qr.modules.length * moduleSize, qrWidth, version: qr.version };
55}
56
57// CARD_PAD is the margin inside the cut line. The code is set to fit within
58// it on one line: splitting a recovery-style code across two lines invites
59// somebody to lose the join, and shrinking it below 9pt invites a misread
60// between 8 and B on a photocopy.
61const CARD_PAD = 12;
62export const CARD_CODE_SIZE = 9;
63
64// cardCodeFits is the invariant the layout depends on, exported so the test
65// asserts it rather than trusting the numbers above to stay in step.
66export function cardCodeFits(code) {
67 return textWidth(code, { font: "mono", size: CARD_CODE_SIZE })
68 <= CARD.width - CARD_PAD * 2;
69}
70
71// accountKeyCard is document 1: wallet-sized, and meant to stay reachable.
72//
73// It is drawn as a cut-out on an A4 page rather than as a card-sized page,
74// because home printers handle A4 and do not handle 85.6 x 54 mm.
75export function accountKeyCard(accountIdHex, printedAccountKey, now) {
76 const page = new Page();
77 let y = A4.height - MARGIN;
78
79 page.text(MARGIN, y, "CONTRA Account Key card", { font: "bold", size: 18 });
80 y -= 26;
81 page.text(MARGIN, y, "Store somewhere safe.", { font: "bold", size: 12 });
82 y -= 20;
83
84 y = page.wrap(MARGIN, y, COLUMN,
85 "Keep it where you can find it and nobody else goes - a drawer, a wallet, " +
86 "a folder with your documents. It is useless to anyone who does not also " +
87 "know your passphrase.", { size: 11, leading: 15 });
88 y -= 6;
89 y = page.wrap(MARGIN, y, COLUMN,
90 "A device you have already used does not need this. A brand-new one does: " +
91 "it asks for this key before it will open your vault.", { size: 11, leading: 15 });
92 y -= 10;
93
94 // The card itself, on a cut line.
95 //
96 // The code runs UNDER the QR at full card width rather than beside it. Set
97 // beside a 29-module symbol there is nowhere near enough room for 38
98 // monospaced characters, and the first version of this document proved it
99 // by printing them straight through the dotted line somebody is meant to
100 // cut along.
101 if (!cardCodeFits(printedAccountKey)) {
102 throw new Error("kit: the Account Key does not fit inside the card's cut line");
103 }
104 const cardY = y - CARD.height;
105 const inner = MARGIN + CARD_PAD;
106 // The content is about 115pt tall in a 153pt card; this centres it rather
107 // than hanging it from the top edge with the slack all at the bottom.
108 const cardTop = cardY + CARD.height - 14;
109 page.dashedRect(MARGIN, cardY, CARD.width, CARD.height);
110
111 page.text(inner, cardTop - 17, "CONTRA Account Key", { font: "bold", size: 9 });
112
113 const qr = encode(printedAccountKey, { ec: EC });
114 // 2.2pt a module is about 0.78mm on paper. Bigger than it needs to be on a
115 // clean print, and that is the point: this gets scanned off a card that has
116 // been in a drawer, by whatever phone is to hand.
117 const moduleSize = 2.2;
118 const qrSide = qr.modules.length * moduleSize;
119 page.qr(qr.modules, inner, cardTop - 26 - qrSide, moduleSize);
120
121 page.text(inner + qrSide + 12, cardTop - 34, `Account ${handle(accountIdHex)}`,
122 { size: 8, gray: 0.35 });
123
124 // The code and the one-line reassurance run the full width of the card,
125 // under the symbol. Set in the column beside it they wrap, and a sentence
126 // that wraps is one the text layer no longer contains in one piece — which
127 // is how this test caught it.
128 page.text(inner, cardTop - 34 - qrSide, printedAccountKey,
129 { font: "mono", size: CARD_CODE_SIZE });
130 page.text(inner, cardTop - 48 - qrSide, "Opens nothing without your passphrase.",
131 { size: 8, gray: 0.35 });
132
133 y = cardY - 26;
134 page.text(MARGIN, y, "Cut along the dotted line.", { size: 9, gray: 0.45 });
135 y -= 22;
136 page.wrap(MARGIN, y, COLUMN,
137 "Do not photograph this. It is the second factor protecting the copy of " +
138 "your vault we hold, and passphrases get phished - a picture of it in a " +
139 "cloud photo library quietly undoes the protection it exists to provide. " +
140 "The square is for scanning at setup, not for archiving to a camera roll.",
141 { size: 10, leading: 13, gray: 0.25 });
142
143 page.text(MARGIN, 48, `Printed ${today(now)} - CONTRA`, { size: 8, gray: 0.5 });
144 return pdfDocument([page], { title: "CONTRA Account Key card" });
145}
146
147// recoverySheet is document 2: archival, and the higher bar of the two.
148export function recoverySheet(accountIdHex, printedRecoveryCode, now) {
149 const page = new Page();
150 let y = A4.height - MARGIN;
151
152 page.text(MARGIN, y, "CONTRA Recovery sheet", { font: "bold", size: 18 });
153 y -= 26;
154 page.text(MARGIN, y, "Store securely.", { font: "bold", size: 12 });
155 y -= 20;
156
157 y = page.wrap(MARGIN, y, COLUMN,
158 "Keep it where you'd keep a passport or a will: a safe, a deposit box, a " +
159 "trusted person's house. Never photograph it. You should not need to " +
160 "touch this again.", { size: 11, leading: 15 });
161 y -= 16;
162
163 page.rect(MARGIN, y - 4, COLUMN, 1, { gray: 0.75 });
164 y -= 26;
165
166 const block = codeBlock(page, MARGIN, y, printedRecoveryCode, 3.2);
167 page.text(MARGIN + block.qrWidth + 18, y - 34,
168 `Account ${handle(accountIdHex)}`, { size: 9, gray: 0.35 });
169 page.text(MARGIN + block.qrWidth + 18, y - 48, `Printed ${today(now)}`,
170 { size: 9, gray: 0.35 });
171 y = block.bottom - 26;
172
173 page.rect(MARGIN, y + 8, COLUMN, 1, { gray: 0.75 });
174 y -= 12;
175
176 page.text(MARGIN, y, "What this sheet is", { font: "bold", size: 12 });
177 y -= 18;
178 y = page.wrap(MARGIN, y, COLUMN,
179 "This sheet alone can open your vault. It is the one way back in if you " +
180 "forget your passphrase.", { size: 11, leading: 15 });
181 y -= 6;
182 y = page.wrap(MARGIN, y, COLUMN,
183 "Nobody can reissue it. We do not have it - only a sealed copy of your " +
184 "vault key that it opens - so if you lose this sheet and forget your " +
185 "passphrase, your data is gone. There is no reset link, no support " +
186 "override, and no way for us to let you back in. That is the product, " +
187 "not a gap in it: a server that could give your vault back is a server " +
188 "that could give it to someone else.", { size: 11, leading: 15 });
189 y -= 6;
190 y = page.wrap(MARGIN, y, COLUMN,
191 "If you print a new one, this one stops working. Reprinting the recovery " +
192 "code generates a new code - the old sheet becomes waste paper, and you " +
193 "should destroy it.", { size: 11, leading: 15 });
194 y -= 6;
195 page.wrap(MARGIN, y, COLUMN,
196 "Anyone holding this sheet can reach your vault, so treat it as the thing " +
197 "itself rather than as a note about it. Using it online tells every device " +
198 "you have enrolled, and the account address, that it was used.",
199 { size: 11, leading: 15 });
200
201 page.text(MARGIN, 48, `Printed ${today(now)} - CONTRA`, { size: 8, gray: 0.5 });
202 return pdfDocument([page], { title: "CONTRA Recovery sheet" });
203}
204