carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The printed credentials: Crockford Base32, the checksum, and the repair
2// that makes a mistyped character survivable.
3//
4// This is what a person reads off aged paper at the worst moment they will
5// ever use this product. Pinned by vectors/contra-v1.json.
6
7import { sha256 } from "./kdf.js";
8
9// Crockford's alphabet: no I, L, O or U. Decoding maps O to 0 and I and L to
10// 1 — the exact confusions humans and OCR make reading print.
11export const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
12
13export const TAG_ACCOUNT_KEY = "A1";
14export const TAG_RECOVERY = "C1";
15
16const DATA_SYMBOLS = 26;
17const CHECK_SYMBOLS = 4;
18const CODE_SYMBOLS = 2 + DATA_SYMBOLS + CHECK_SYMBOLS;
19
20function symbolValue(ch) {
21 let c = ch.toUpperCase();
22 if (c === "O") c = "0";
23 if (c === "I" || c === "L") c = "1";
24 const i = ALPHABET.indexOf(c);
25 return c === "" ? -1 : i;
26}
27
28// normalize strips hyphens and whitespace, uppercases, and applies the
29// O/I/L mappings. The two-character tag survives it unchanged, so callers
30// normalise first and match the tag afterwards.
31export function normalize(s) {
32 let out = "";
33 for (const ch of s) {
34 if (ch === "-" || /\s/.test(ch)) continue;
35 const v = symbolValue(ch);
36 if (v < 0) throw new Error("contra: character outside the Crockford alphabet");
37 out += ALPHABET[v];
38 }
39 return out;
40}
41
42// base32Encode packs bytes MSB-first and left-aligned, so 16 bytes become 26
43// symbols whose last one carries two zero pad bits.
44//
45// Byte packing rather than Crockford's big-integer form: no language should
46// need a bignum to read a recovery code, and this module is capped at 300
47// lines by test.
48export function base32Encode(b) {
49 const n = Math.ceil((b.length * 8) / 5);
50 let out = "";
51 for (let i = 0; i < n; i++) {
52 const bit = i * 5;
53 const by = bit >> 3;
54 const off = bit & 7;
55 let v = b[by] << 8;
56 if (by + 1 < b.length) v |= b[by + 1];
57 out += ALPHABET[(v >> (11 - off)) & 0x1f];
58 }
59 return out;
60}
61
62// base32Decode is strict: it rejects a non-zero pad in the final symbol, so
63// every value has exactly one encoding. Without that, two different strings
64// decode to one key and the checksum stops meaning what it says.
65export function base32Decode(sym, nbytes) {
66 if (sym.length !== Math.ceil((nbytes * 8) / 5)) {
67 throw new Error("contra: wrong number of symbols");
68 }
69 const out = new Uint8Array(nbytes);
70 let acc = 0;
71 let bits = 0;
72 let o = 0;
73 for (const ch of sym) {
74 const v = symbolValue(ch);
75 if (v < 0) throw new Error("contra: character outside the Crockford alphabet");
76 acc = (acc << 5) | v;
77 bits += 5;
78 if (bits >= 8) {
79 bits -= 8;
80 out[o++] = (acc >> bits) & 0xff;
81 }
82 }
83 if ((acc & ((1 << bits) - 1)) !== 0) {
84 throw new Error("contra: non-zero pad bits in the final symbol");
85 }
86 return out;
87}
88
89// checksum20 is the first 20 bits, MSB-first, of SHA-256 over the raw
90// payload bytes — not over the string. Twenty bits is enough to catch a
91// mistyped character and small enough to fit four symbols on a printed line.
92export async function checksum20(payload) {
93 const d = await sha256(payload);
94 return (d[0] << 12) | (d[1] << 4) | (d[2] >> 4);
95}
96
97function checksumSymbols(c) {
98 let out = "";
99 for (let i = 0; i < CHECK_SYMBOLS; i++) out += ALPHABET[(c >> (15 - 5 * i)) & 0x1f];
100 return out;
101}
102
103export async function normalizedCode(tag, payload) {
104 return tag + base32Encode(payload) + checksumSymbols(await checksum20(payload));
105}
106
107// formatCode renders what goes on the paper:
108// TAG-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX.
109export async function formatCode(tag, payload) {
110 const norm = await normalizedCode(tag, payload);
111 let out = tag;
112 for (let i = 2; i < norm.length; i++) {
113 if ((i - 2) % 5 === 0) out += "-";
114 out += norm[i];
115 }
116 return out;
117}
118
119async function parseNormalized(tag, norm) {
120 if (norm.length !== CODE_SYMBOLS) throw new Error("contra: wrong number of symbols");
121 if (norm.slice(0, 2) !== tag) throw new Error("contra: wrong tag");
122 const payload = base32Decode(norm.slice(2, 2 + DATA_SYMBOLS), 16);
123 if (checksumSymbols(await checksum20(payload)) !== norm.slice(2 + DATA_SYMBOLS)) {
124 throw new Error("contra: checksum does not match");
125 }
126 return payload;
127}
128
129// parseCode normalises, checks the tag and the checksum, and returns the
130// payload. A checksum failure is where the caller reaches for repairCode.
131export async function parseCode(tag, s) {
132 return parseNormalized(tag, normalize(s));
133}
134
135// repairCode brute-forces every single-character substitution over the 30
136// payload positions — about a thousand candidates — and returns every one
137// whose checksum passes, in position-then-alphabet order.
138//
139// It deliberately does not pick a winner. Twenty bits collides roughly once
140// in eleven hundred attempts, so the caller tries each candidate against the
141// KCV, which does not collide. Choosing here would sometimes choose wrong
142// and hand back the wrong key without saying so.
143export async function repairCode(tag, s) {
144 const norm = normalize(s);
145 if (norm.length !== CODE_SYMBOLS) throw new Error("contra: wrong number of symbols");
146 const out = [];
147 const buf = norm.split("");
148 for (let pos = 2; pos < CODE_SYMBOLS; pos++) {
149 const orig = buf[pos];
150 for (const ch of ALPHABET) {
151 if (ch === orig) continue;
152 buf[pos] = ch;
153 const candidate = buf.join("");
154 try {
155 await parseNormalized(tag, candidate);
156 out.push(candidate);
157 } catch {
158 // Not a repair. The overwhelming majority of the ~930 candidates
159 // land here; a throw is the expected outcome, not an error path.
160 }
161 }
162 buf[pos] = orig;
163 }
164 return out;
165}
166