| 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 | |
| 15 | import { test } from "node:test"; |
| 16 | import assert from "node:assert/strict"; |
| 17 | import { readFileSync } from "node:fs"; |
| 18 | import { fileURLToPath } from "node:url"; |
| 19 | import { dirname, join } from "node:path"; |
| 20 | |
| 21 | const here = dirname(fileURLToPath(import.meta.url)); |
| 22 | const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); |
| 23 | |
| 24 | const { toHex, fromHex } = await import(mod("bytes.js")); |
| 25 | const kdf = await import(mod("kdf.js")); |
| 26 | const code = await import(mod("code.js")); |
| 27 | const sealedMod = await import(mod("sealed.js")); |
| 28 | const { deriveP256, signChallenge, verifyChallenge } = await import(mod("p256.js")); |
| 29 | const { argon2id } = await import(mod("argon2id.js")); |
| 30 | |
| 31 | const V = JSON.parse(readFileSync(join(here, "..", "vectors", "contra-v1.json"), "utf8")); |
| 32 | assert.equal(V.format, "contra-golden-vectors"); |
| 33 | assert.equal(V.version, 1); |
| 34 | |
| 35 | test("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 | |
| 51 | test("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 | |
| 57 | test("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 | |
| 71 | test("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 | |
| 81 | test("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 | |
| 103 | test("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 | |
| 118 | test("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 | |
| 126 | test("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 | |
| 144 | test("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. |
| 157 | test("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 | |
| 178 | test("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 | |
| 188 | test("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 | |
| 208 | test("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. |
| 227 | test("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 | |
| 247 | test("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. |
| 260 | test("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 | |