// The JS half of the crypto contract, against vectors/contra-v1.json. // // AGENTS.md: "Crypto is byte-identical in Go and JS, pinned by shared golden // vectors. Change one side and you must change the other." This suite is // what makes that enforceable rather than aspirational — it runs the same // file internal/contra/crypto's Go tests run, and the file was generated by // neither of them (vectors/gen/gen.c, against OpenSSL). // // This is a HARD gate in CI. A security product must not quietly stop // checking that the browser's crypto matches the server's, so `make test-js` // fails when node is missing rather than skipping. // // node --test test/ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const here = dirname(fileURLToPath(import.meta.url)); const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); const { toHex, fromHex } = await import(mod("bytes.js")); const kdf = await import(mod("kdf.js")); const code = await import(mod("code.js")); const sealedMod = await import(mod("sealed.js")); const { deriveP256, signChallenge, verifyChallenge } = await import(mod("p256.js")); const { argon2id } = await import(mod("argon2id.js")); const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8")); assert.equal(V.format, "contra-golden-vectors"); assert.equal(V.version, 1); test("HKDF: every contra/v1 label", async () => { const seen = new Set(); for (const c of V.hkdf.cases) { const salt = c.salt_hex ? fromHex(c.salt_hex) : null; const got = await kdf.hkdf(fromHex(c.ikm_hex), salt, c.info, c.l); assert.equal(toHex(got), c.okm_hex, `HKDF ${c.name} (${c.info})`); seen.add(c.info); } for (const label of [ kdf.LABEL_LOGIN, kdf.LABEL_KEK, kdf.LABEL_RC_LOGIN, kdf.LABEL_RC_KEK, kdf.LABEL_PRF_KEK, ]) { assert.ok(seen.has(label), `no vector covers HKDF label ${label}`); } }); test("KCV", async () => { for (const c of V.kcv.cases) { assert.equal(toHex(await kdf.kcv(fromHex(c.kek_hex))), c.kcv_hex, c.name); } }); test("P-256 from a seed, including the rejection branch", async () => { let sawRejection = false; for (const c of V.p256_from_seed.cases) { const key = await deriveP256(fromHex(c.seed_hex)); assert.equal(toHex(key.scalar), c.d_hex, `${c.name} scalar`); assert.equal(toHex(key.publicKey), c.public_uncompressed_hex, `${c.name} public key`); assert.equal(key.candidatesTried, c.candidates_tried, `${c.name} candidates tried`); if (c.candidates_tried > 1) sawRejection = true; } // Without a rejecting case the loop past i=0 is dead code here too, and // the JS side would agree with Go right up until a real user hit it. assert.ok(sawRejection, "no vector exercises the rejection branch"); }); test("signed challenge round-trips against the derived public key", async () => { const c = V.p256_from_seed.cases[0]; const key = await deriveP256(fromHex(c.seed_hex)); const challenge = new TextEncoder().encode("contra/v1/challenge/test"); const sig = await signChallenge(key.privateKey, challenge); assert.ok(await verifyChallenge(key.publicKey, challenge, sig)); challenge[0] ^= 1; assert.equal(await verifyChallenge(key.publicKey, challenge, sig), false); }); test("Crockford: alphabet, encoding, normalisation, strict decoding", async () => { assert.equal(code.ALPHABET, V.crockford.alphabet); for (const c of V.crockford.encode_cases) { assert.equal(code.base32Encode(fromHex(c.bytes_hex)), c.symbols); } for (const c of V.crockford.normalize_cases) { if (c.error) { assert.throws(() => code.normalize(c.input), undefined, `${c.input} must not normalise`); } else { assert.equal(code.normalize(c.input), c.normalized, c.input); } } for (const c of V.crockford.decode_cases) { if (c.error) { assert.throws(() => code.base32Decode(c.symbols, 16), undefined, c.symbols); } else { assert.equal(toHex(code.base32Decode(c.symbols, 16)), c.bytes_hex); } } }); test("the printed code, and the O/I/L confusions it has to survive", async () => { for (const c of V.printed_code.cases) { const payload = fromHex(c.payload_hex); assert.equal(await code.checksum20(payload), c.checksum20, `${c.name} checksum`); assert.equal(await code.normalizedCode(c.tag, payload), c.normalized, `${c.name} normalized`); assert.equal(await code.formatCode(c.tag, payload), c.printed, `${c.name} printed`); assert.equal(toHex(await code.parseCode(c.tag, c.printed)), c.payload_hex); // What aged paper and OCR actually hand back. const confused = c.printed.toLowerCase().replace(/0/g, "o").replace(/1/g, "l"); assert.equal(toHex(await code.parseCode(c.tag, confused)), c.payload_hex, confused); } }); test("repair recovers one substituted character", async () => { const r = V.printed_code.repair; await assert.rejects(() => code.parseCode(code.TAG_RECOVERY, r.typo_normalized)); const got = await code.repairCode(code.TAG_RECOVERY, r.typo_normalized); assert.deepEqual(got, r.candidates); assert.ok(got.includes(r.correct_normalized), "repair lost the correct code"); }); test("AEAD: the AAD binding and every wrap", async () => { const accountId = fromHex(V.aead.account_id_hex); const rk = fromHex(V.aead.rk_hex); for (const w of V.aead.wraps) { const a = sealedMod.aad(accountId, w.object_type, fromHex(w.wrap_id_hex), w.version); assert.equal(toHex(a), w.aad_hex, `${w.name} AAD`); assert.equal(toHex(await kdf.kcv(fromHex(w.kek_hex))), w.kcv_hex, `${w.name} KCV`); const out = await sealedMod.seal(fromHex(w.kek_hex), fromHex(w.nonce_hex), a, rk); assert.equal(toHex(out.slice(0, out.length - 16)), w.ciphertext_hex, `${w.name} ciphertext`); assert.equal(toHex(out.slice(out.length - 16)), w.tag_hex, `${w.name} tag`); const back = await sealedMod.open(fromHex(w.kek_hex), fromHex(w.nonce_hex), a, out); assert.equal(toHex(back), V.aead.rk_hex, `${w.name} did not open to RK`); } }); test("AEAD: the item, scoped to its vault", async () => { const it = V.aead.item; const a = sealedMod.aad( fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version); assert.equal(toHex(a), it.aad_hex); const out = await sealedMod.seal( fromHex(it.k_vault_hex), fromHex(it.nonce_hex), a, fromHex(it.plaintext_hex)); assert.equal(toHex(out.slice(0, out.length - 16)), it.ciphertext_hex); assert.equal(toHex(out.slice(out.length - 16)), it.tag_hex); }); // The binding is only worth anything if breaking it breaks decryption. test("a transplanted ciphertext does not open", async () => { const it = V.aead.item; const key = fromHex(it.k_vault_hex); const nonce = fromHex(it.nonce_hex); const good = sealedMod.aad( fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version); const out = await sealedMod.seal(key, nonce, good, fromHex(it.plaintext_hex)); const otherItem = fromHex(it.item_id_hex); otherItem[0] ^= 1; const bad = [ ["different item", sealedMod.aad(fromHex(it.vault_id_hex), it.object_type, otherItem, it.version)], ["different version", sealedMod.aad(fromHex(it.vault_id_hex), it.object_type, fromHex(it.item_id_hex), it.version + 1)], ["different type", sealedMod.aad(fromHex(it.vault_id_hex), "wrap.pw", fromHex(it.item_id_hex), it.version)], ]; for (const [name, a] of bad) { await assert.rejects(() => sealedMod.open(key, nonce, a, out), undefined, `${name}: transplant succeeded, so the AAD binding is not holding`); } }); test("padding quantises into 256-byte buckets and round-trips", () => { for (const n of [0, 1, 100, 255, 256, 257, 1000]) { const b = new Uint8Array(n).fill(0x41); const p = sealedMod.pad(b); assert.equal(p.length % sealedMod.PAD_BUCKET, 0, `pad(${n}) is not bucket-aligned`); assert.ok(p.length > n, `pad(${n}) must always add at least one byte`); assert.equal(toHex(sealedMod.unpad(p)), toHex(b), `unpad(pad(${n}))`); } }); test("the KDF parameter floor refuses what a malicious server would offer", () => { const ok = { alg: "argon2id", m_kib: 65536, t: 3, p: 1 }; assert.deepEqual(kdf.checkParams(ok), ok); assert.deepEqual(kdf.checkParams({ alg: "argon2id", m_kib: 131072, t: 4, p: 1 }).t, 4); // The attack this exists to stop: cheap parameters, one harvested // challenge signature, offline cracking. for (const bad of [ { alg: "argon2id", m_kib: 8, t: 1, p: 1 }, { alg: "argon2id", m_kib: 65535, t: 3, p: 1 }, { alg: "argon2id", m_kib: 65536, t: 2, p: 1 }, { alg: "argon2id", m_kib: 65536, t: 3, p: 4 }, { alg: "pbkdf2", m_kib: 65536, t: 3, p: 1 }, null, ]) { assert.throws(() => kdf.checkParams(bad), kdf.WeakParametersError, `accepted ${JSON.stringify(bad)}`); } }); test("Argon2id matches the published PHC reference vector", async () => { // Anchored outside this repo before anything of ours is checked. If this is // wrong, agreeing with our own vectors would only mean two of our // implementations are wrong in the same way. const p = new TextEncoder().encode("password"); const s = new TextEncoder().encode("somesalt"); assert.equal( toHex(await argon2id(p, s, { m_kib: 65536, t: 2, p: 1 }, 32)), "09316115d5cf24ed5a15a31a3ba326e5cf32edc24702987c02b6566f61913cf7", "PHC Argon2id t=2 m=65536 p=1", ); }); // The parameters CONTRA runs at are one point in a large space, and the // corners are where a hand-written Argon2 goes wrong: a segment shorter than // one address block, a pass count above two, an empty password, a tag longer // than 32 bytes. Each of these was cross-checked against OpenSSL 3.5 when it // was written; pinning them here means a refactor cannot quietly break a path // the 64 MiB case does not reach. test("Argon2id at the corners the 64 MiB case does not reach", async () => { const enc = new TextEncoder(); for (const [pw, salt, m, t, len, want] of [ ["password", "somesalt", 8, 1, 32, "f137f8e186a403a679ccd0606e5ab5dcdafe43c1640855ac8c6e33e9bd63eeb3"], ["password", "somesalt", 256, 2, 32, "9dfeb910e80bad0311fee20f9c0e2b12c17987b4cac90c2ef54d5b3021c68bfe"], ["password", "somesalt", 512, 3, 32, "da62c0cfaa06b86aa353ebb1fbab1d0a4c997fb6d1e8e5a1f2fe536753284d7a"], ["password", "somesalt", 1024, 4, 32, "b0f18b5aca2c3df1b819c16c26369f14e92fddf90bcf160f4bc1abb3f6624174"], ["password", "somesalt", 4096, 2, 32, "8da7091d0f8fa01b2d07f66854d93cadedecd72cd500166e8dc7868b054d2679"], ]) { assert.equal( toHex(await argon2id(enc.encode(pw), enc.encode(salt), { m_kib: m, t, p: 1 }, len)), want, `m=${m} t=${t} len=${len}`); } }); test("Argon2id matches the golden vector at the parameters PROTOCOL.md pins", async () => { const c = V.argon2id.cases[0]; const got = await argon2id( new TextEncoder().encode(c.passphrase_utf8), fromHex(c.salt_hex), { m_kib: V.argon2id.params.m_kib, t: V.argon2id.params.t, p: V.argon2id.params.p }, V.argon2id.params.tag_len, ); assert.equal(toHex(got), c.out_hex, c.name); }); // The whole of PROTOCOL.md's "The shape", browser side, so a divergence can be // bisected rather than stared at. test("the account chain, end to end", async () => { const c = V.account_chain; const sPW = await argon2id( new TextEncoder().encode(c.passphrase_utf8), fromHex(c.salt_pw_hex), { m_kib: 65536, t: 3, p: 1 }, 32, ); assert.equal(toHex(sPW), c.s_pw_hex, "S_pw"); const seedLogin = await kdf.hkdf(sPW, null, kdf.LABEL_LOGIN, 32); assert.equal(toHex(seedLogin), c.seed_login_hex, "seed_login"); assert.equal(toHex((await deriveP256(seedLogin)).publicKey), c.lk_pw_public_hex, "LK_pw"); const kekPW = await kdf.hkdf(sPW, fromHex(c.account_key_hex), kdf.LABEL_KEK, 32); assert.equal(toHex(kekPW), c.kek_pw_hex, "KEK_pw"); const rc = fromHex(c.recovery_code_hex); const seedRC = await kdf.hkdf(rc, null, kdf.LABEL_RC_LOGIN, 32); assert.equal(toHex(seedRC), c.seed_rc_login_hex, "seed_rc_login"); assert.equal(toHex((await deriveP256(seedRC)).publicKey), c.lk_rc_public_hex, "LK_rc"); assert.equal(toHex(await kdf.hkdf(rc, null, kdf.LABEL_RC_KEK, 32)), c.kek_rc_hex, "KEK_rc"); const kekPRF = await kdf.hkdf( fromHex(c.prf_output_hex), fromHex(c.cred_salt_hex), kdf.LABEL_PRF_KEK, 32); assert.equal(toHex(kekPRF), c.kek_prf_hex, "KEK_prf"); // The three wraps are peers: each opens RK on its own. for (const w of V.aead.wraps) { const a = sealedMod.aad( fromHex(V.aead.account_id_hex), w.object_type, fromHex(w.wrap_id_hex), w.version); const back = await sealedMod.open( fromHex(w.kek_hex), fromHex(w.nonce_hex), a, fromHex(w.ciphertext_hex + w.tag_hex)); assert.equal(toHex(back), c.rk_hex, `${w.name} did not open to RK`); } });