| 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 | |
| 19 | import { encode } from "./qr.js"; |
| 20 | import { 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. |
| 25 | const EC = "Q"; |
| 26 | |
| 27 | const MARGIN = 56; |
| 28 | const 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. |
| 38 | export function handle(accountIdHex) { |
| 39 | return accountIdHex.slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, "$1-$2"); |
| 40 | } |
| 41 | |
| 42 | function 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. |
| 49 | function 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. |
| 61 | const CARD_PAD = 12; |
| 62 | export 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. |
| 66 | export 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. |
| 75 | export 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. |
| 148 | export 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 | |