carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The JS half of the crypto contract, against vectors/contra-v1.json.
2//
3// AGENTS.md: "Crypto is byte-identical in Go and JS, pinned by shared golden
4// vectors. Change one side and you must change the other." This suite is
5// what makes that enforceable rather than aspirational — it runs the same
6// file internal/contra/crypto's Go tests run, and the file was generated by
7// neither of them (vectors/gen/gen.c, against OpenSSL).
8//
9// This is a HARD gate in CI. A security product must not quietly stop
10// checking that the browser's crypto matches the server's, so `make test-js`
11// fails when node is missing rather than skipping.
12//
13// node --test test/
14
15import { test } from "node:test";
16import assert from "node:assert/strict";
17import { readFileSync } from "node:fs";
18import { fileURLToPath } from "node:url";
19import { dirname, join } from "node:path";
20
21const here = dirname(fileURLToPath(import.meta.url));
22const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name);
23
24const { toHex, fromHex } = await import(mod("bytes.js"));
25const kdf = await import(mod("kdf.js"));
26const code = await import(mod("code.js"));
27const sealedMod = await import(mod("sealed.js"));
28const { deriveP256, signChallenge, verifyChallenge } = await import(mod("p256.js"));
29const { argon2id } = await import(mod("argon2id.js"));
30
31const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8"));
32assert.equal(V.format, "contra-golden-vectors");
33assert.equal(V.version, 1);
34
35test("HKDF: every contra/v1 label", async () => {
36 const seen = new Set();
37 for (const c of V.hkdf.cases) {
38 const salt = c.salt_hex ? fromHex(c.salt_hex) : null;
39 const got = await kdf.hkdf(fromHex(c.ikm_hex), salt, c.info, c.l);
40 assert.equal(toHex(got), c.okm_hex, `HKDF ${c.name} (${c.info})`);
41 seen.add(c.info);
42 }
43 for (const label of [
44 kdf.LABEL_LOGIN, kdf.LABEL_KEK, kdf.LABEL_RC_LOGIN,
45 kdf.LABEL_RC_KEK, kdf.LABEL_PRF_KEK,
46 ]) {
47 assert.ok(seen.has(label), `no vector covers HKDF label ${label}`);
48 }
49});
50
51test("KCV", async () => {
52 for (const c of V.kcv.cases) {
53 assert.equal(toHex(await kdf.kcv(fromHex(c.kek_hex))), c.kcv_hex, c.name);
54 }
55});
56
57test("P-256 from a seed, including the rejection branch", async () => {
58 let sawRejection = false;
59 for (const c of V.p256_from_seed.cases) {
60 const key = await deriveP256(fromHex(c.seed_hex));
61 assert.equal(toHex(key.scalar), c.d_hex, `${c.name} scalar`);
62 assert.equal(toHex(key.publicKey), c.public_uncompressed_hex, `${c.name} public key`);
63 assert.equal(key.candidatesTried, c.candidates_tried, `${c.name} candidates tried`);
64 if (c.candidates_tried > 1) sawRejection = true;
65 }
66 // Without a rejecting case the loop past i=0 is dead code here too, and
67 // the JS side would agree with Go right up until a real user hit it.
68 assert.ok(sawRejection, "no vector exercises the rejection branch");
69});
70
71test("signed challenge round-trips against the derived public key", async () => {
72 const c = V.p256_from_seed.cases[0];
73 const key = await deriveP256(fromHex(c.seed_hex));
74 const challenge = new TextEncoder().encode("contra/v1/challenge/test");
75 const sig = await signChallenge(key.privateKey, challenge);
76 assert.ok(await verifyChallenge(key.publicKey, challenge, sig));
77 challenge[0] ^= 1;
78 assert.equal(await verifyChallenge(key.publicKey, challenge, sig), false);
79});
80
81test("Crockford: alphabet, encoding, normalisation, strict decoding", async () => {
82 assert.equal(code.ALPHABET, V.crockford.alphabet);
83
84 for (const c of V.crockford.encode_cases) {
85 assert.equal(code.base32Encode(fromHex(c.bytes_hex)), c.symbols);
86 }
87 for (const c of V.crockford.normalize_cases) {
88 if (c.error) {
89 assert.throws(() => code.normalize(c.input), undefined, `${c.input} must not normalise`);
90 } else {
91 assert.equal(code.normalize(c.input), c.normalized, c.input);
92 }
93 }
94 for (const c of V.crockford.decode_cases) {
95 if (c.error) {
96 assert.throws(() => code.base32Decode(c.symbols, 16), undefined, c.symbols);
97 } else {
98 assert.equal(toHex(code.base32Decode(c.symbols, 16)), c.bytes_hex);
99 }
100 }
101});
102
103test("the printed code, and the O/I/L confusions it has to survive", async () => {
104 for (const c of V.printed_code.cases) {
105 const payload = fromHex(c.payload_hex);
106 assert.equal(await code.checksum20(payload), c.checksum20, `${c.name} checksum`);
107 assert.equal(await code.normalizedCode(c.tag, payload), c.normalized, `${c.name} normalized`);
108 assert.equal(await code.formatCode(c.tag, payload), c.printed, `${c.name} printed`);
109
110 assert.equal(toHex(await code.parseCode(c.tag, c.printed)), c.payload_hex);
111
112 // What aged paper and OCR actually hand back.
113 const confused = c.printed.toLowerCase().replace(/0/g, "o").replace(/1/g, "l");
114 assert.equal(toHex(await code.parseCode(c.tag, confused)), c.payload_hex, confused);
115 }
116});
117
118test("repair recovers one substituted character", async () => {
119 const r = V.printed_code.repair;
120 await assert.rejects(() => code.parseCode(code.TAG_RECOVERY, r.typo_normalized));
121 const got = await code.repairCode(code.TAG_RECOVERY, r.typo_normalized);
122 assert.deepEqual(got, r.candidates);
123 assert.ok(got.includes(r.correct_normalized), "repair lost the correct code");
124});
125
126test("AEAD: the AAD binding and every wrap", async () => {
127 const accountId = fromHex(V.aead.account_id_hex);
128 const rk = fromHex(V.aead.rk_hex);
129
130 for (const w of V.aead.wraps) {
131 const a = sealedMod.aad(accountId, w.object_type, fromHex(w.wrap_id_hex), w.version);
132 assert.equal(toHex(a), w.aad_hex, `${w.name} AAD`);
133 assert.equal(toHex(await kdf.kcv(fromHex(w.kek_hex))), w.kcv_hex, `${w.name} KCV`);
134
135 const out = await sealedMod.seal(fromHex(w.kek_hex), fromHex(w.nonce_hex), a, rk);
136 assert.equal(toHex(out.slice(0, out.length - 16)), w.ciphertext_hex, `${w.name} ciphertext`);
137 assert.equal(toHex(out.slice(out.length - 16)), w.tag_hex, `${w.name} tag`);
138
139 const back = await sealedMod.open(fromHex(w.kek_hex), fromHex(w.nonce_hex), a, out);
140 assert.equal(toHex(back), V.aead.rk_hex, `${w.name} did not open to RK`);
141 }
142});
143
144test("AEAD: the item, scoped to its vault", async () => {
145 const it = V.aead.item;
146 const a = sealedMod.aad(
147 fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version);
148 assert.equal(toHex(a), it.aad_hex);
149
150 const out = await sealedMod.seal(
151 fromHex(it.k_vault_hex), fromHex(it.nonce_hex), a, fromHex(it.plaintext_hex));
152 assert.equal(toHex(out.slice(0, out.length - 16)), it.ciphertext_hex);
153 assert.equal(toHex(out.slice(out.length - 16)), it.tag_hex);
154});
155
156// The binding is only worth anything if breaking it breaks decryption.
157test("a transplanted ciphertext does not open", async () => {
158 const it = V.aead.item;
159 const key = fromHex(it.k_vault_hex);
160 const nonce = fromHex(it.nonce_hex);
161 const good = sealedMod.aad(
162 fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version);
163 const out = await sealedMod.seal(key, nonce, good, fromHex(it.plaintext_hex));
164
165 const otherItem = fromHex(it.item_id_hex);
166 otherItem[0] ^= 1;
167 const bad = [
168 ["different item", sealedMod.aad(fromHex(it.vault_id_hex), it.object_type, otherItem, it.version)],
169 ["different version", sealedMod.aad(fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version + 1)],
170 ["different type", sealedMod.aad(fromHex(it.vault_id_hex), "wrap.pw", fromHex(it.item_id_hex), it.version)],
171 ];
172 for (const [name, a] of bad) {
173 await assert.rejects(() => sealedMod.open(key, nonce, a, out), undefined,
174 `${name}: transplant succeeded, so the AAD binding is not holding`);
175 }
176});
177
178test("padding quantises into 256-byte buckets and round-trips", () => {
179 for (const n of [0, 1, 100, 255, 256, 257, 1000]) {
180 const b = new Uint8Array(n).fill(0x41);
181 const p = sealedMod.pad(b);
182 assert.equal(p.length % sealedMod.PAD_BUCKET, 0, `pad(${n}) is not bucket-aligned`);
183 assert.ok(p.length > n, `pad(${n}) must always add at least one byte`);
184 assert.equal(toHex(sealedMod.unpad(p)), toHex(b), `unpad(pad(${n}))`);
185 }
186});
187
188test("the KDF parameter floor refuses what a malicious server would offer", () => {
189 const ok = { alg: "argon2id", m_kib: 65536, t: 3, p: 1 };
190 assert.deepEqual(kdf.checkParams(ok), ok);
191 assert.deepEqual(kdf.checkParams({ alg: "argon2id", m_kib: 131072, t: 4, p: 1 }).t, 4);
192
193 // The attack this exists to stop: cheap parameters, one harvested
194 // challenge signature, offline cracking.
195 for (const bad of [
196 { alg: "argon2id", m_kib: 8, t: 1, p: 1 },
197 { alg: "argon2id", m_kib: 65535, t: 3, p: 1 },
198 { alg: "argon2id", m_kib: 65536, t: 2, p: 1 },
199 { alg: "argon2id", m_kib: 65536, t: 3, p: 4 },
200 { alg: "pbkdf2", m_kib: 65536, t: 3, p: 1 },
201 null,
202 ]) {
203 assert.throws(() => kdf.checkParams(bad), kdf.WeakParametersError,
204 `accepted ${JSON.stringify(bad)}`);
205 }
206});
207
208test("Argon2id matches the published PHC reference vector", async () => {
209 // Anchored outside this repo before anything of ours is checked. If this is
210 // wrong, agreeing with our own vectors would only mean two of our
211 // implementations are wrong in the same way.
212 const p = new TextEncoder().encode("password");
213 const s = new TextEncoder().encode("somesalt");
214 assert.equal(
215 toHex(await argon2id(p, s, { m_kib: 65536, t: 2, p: 1 }, 32)),
216 "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7",
217 "PHC Argon2id t=2 m=65536 p=1",
218 );
219});
220
221// The parameters CONTRA runs at are one point in a large space, and the
222// corners are where a hand-written Argon2 goes wrong: a segment shorter than
223// one address block, a pass count above two, an empty password, a tag longer
224// than 32 bytes. Each of these was cross-checked against OpenSSL 3.5 when it
225// was written; pinning them here means a refactor cannot quietly break a path
226// the 64 MiB case does not reach.
227test("Argon2id at the corners the 64 MiB case does not reach", async () => {
228 const enc = new TextEncoder();
229 for (const [pw, salt, m, t, len, want] of [
230 ["password", "somesalt", 8, 1, 32,
231 "f137f8e186a403a679ccd0606e5ab5dcdafe43c1640855ac8c6e33e9bd63eeb3"],
232 ["password", "somesalt", 256, 2, 32,
233 "9dfeb910e80bad0311fee20f9c0e2b12c17987b4cac90c2ef54d5b3021c68bfe"],
234 ["password", "somesalt", 512, 3, 32,
235 "da62c0cfaa06b86aa353ebb1fbab1d0a4c997fb6d1e8e5a1f2fe536753284d7a"],
236 ["password", "somesalt", 1024, 4, 32,
237 "b0f18b5aca2c3df1b819c16c26369f14e92fddf90bcf160f4bc1abb3f6624174"],
238 ["password", "somesalt", 4096, 2, 32,
239 "8da7091d0f8fa01b2d07f66854d93cadedecd72cd500166e8dc7868b054d2679"],
240 ]) {
241 assert.equal(
242 toHex(await argon2id(enc.encode(pw), enc.encode(salt), { m_kib: m, t, p: 1 }, len)),
243 want, `m=${m} t=${t} len=${len}`);
244 }
245});
246
247test("Argon2id matches the golden vector at the parameters PROTOCOL.md pins", async () => {
248 const c = V.argon2id.cases[0];
249 const got = await argon2id(
250 new TextEncoder().encode(c.passphrase_utf8),
251 fromHex(c.salt_hex),
252 { m_kib: V.argon2id.params.m_kib, t: V.argon2id.params.t, p: V.argon2id.params.p },
253 V.argon2id.params.tag_len,
254 );
255 assert.equal(toHex(got), c.out_hex, c.name);
256});
257
258// The whole of PROTOCOL.md's "The shape", browser side, so a divergence can be
259// bisected rather than stared at.
260test("the account chain, end to end", async () => {
261 const c = V.account_chain;
262 const sPW = await argon2id(
263 new TextEncoder().encode(c.passphrase_utf8),
264 fromHex(c.salt_pw_hex),
265 { m_kib: 65536, t: 3, p: 1 },
266 32,
267 );
268 assert.equal(toHex(sPW), c.s_pw_hex, "S_pw");
269
270 const seedLogin = await kdf.hkdf(sPW, null, kdf.LABEL_LOGIN, 32);
271 assert.equal(toHex(seedLogin), c.seed_login_hex, "seed_login");
272 assert.equal(toHex((await deriveP256(seedLogin)).publicKey), c.lk_pw_public_hex, "LK_pw");
273
274 const kekPW = await kdf.hkdf(sPW, fromHex(c.account_key_hex), kdf.LABEL_KEK, 32);
275 assert.equal(toHex(kekPW), c.kek_pw_hex, "KEK_pw");
276
277 const rc = fromHex(c.recovery_code_hex);
278 const seedRC = await kdf.hkdf(rc, null, kdf.LABEL_RC_LOGIN, 32);
279 assert.equal(toHex(seedRC), c.seed_rc_login_hex, "seed_rc_login");
280 assert.equal(toHex((await deriveP256(seedRC)).publicKey), c.lk_rc_public_hex, "LK_rc");
281 assert.equal(toHex(await kdf.hkdf(rc, null, kdf.LABEL_RC_KEK, 32)), c.kek_rc_hex, "KEK_rc");
282
283 const kekPRF = await kdf.hkdf(
284 fromHex(c.prf_output_hex), fromHex(c.cred_salt_hex), kdf.LABEL_PRF_KEK, 32);
285 assert.equal(toHex(kekPRF), c.kek_prf_hex, "KEK_prf");
286
287 // The three wraps are peers: each opens RK on its own.
288 for (const w of V.aead.wraps) {
289 const a = sealedMod.aad(
290 fromHex(V.aead.account_id_hex), w.object_type, fromHex(w.wrap_id_hex), w.version);
291 const back = await sealedMod.open(
292 fromHex(w.kek_hex), fromHex(w.nonce_hex), a,
293 fromHex(w.ciphertext_hex + w.tag_hex));
294 assert.equal(toHex(back), c.rk_hex, `${w.name} did not open to RK`);
295 }
296});
297