argon2id.kat.test.ts
1// T-019: Argon2id known-answer test (KAT) + parameter assertion2//3// Why this test exists: the Argon2id KDF in vault.ts uses m=65536 (64 MiB),4// t=3 (iterations), p=4 (parallelism), hashLength=32. These parameters5// determine the real-world cost for an attacker brute-forcing a stolen vault.6// There is no existing test that:7// 1. Pins a known (password, salt) to an expected 32-byte output, and8// 2. Asserts that the parameters match the documented values.9//10// A regression that silently reduces e.g. m=65536 → m=1024 would be11// invisible to users, significantly weaken offline attack resistance, and12// pass all existing tests because the vault still unlocks (the key changes but13// re-encrypts everything on save).14//15// The KAT approach:16// - Pin a deterministic production-profile (password, salt) pair to the17// same output in hash-wasm and Android's independent Bouncy Castle path.18// - Assert the actual output matches byte-for-byte.19// - If any parameter silently changes (m, t, p, or hashLength), the output20// hash changes and this test fails.21//22// Reference KAT vector:23// password = "testpassword" (NFC-normalized — same as ASCII, no change)24// salt = 16 × 0xAB25// m=65536, t=3, p=4, hashLength=3226// Expected output: cross-checked by peach-android's27// OfficialCryptoVectorsTest using the same UTF-8 input and parameters.28//29// To re-derive (run once in Node to get the canonical value):30// import { argon2id } from "hash-wasm";31// const h = await argon2id({32// password: "testpassword", salt: new Uint8Array(16).fill(0xAB),33// parallelism: 4, iterations: 3, memorySize: 65536,34// hashLength: 32, outputType: "binary",35// });36// console.log(Buffer.from(h).toString("hex"));3738import { describe, expect, it } from "vitest";39import { deriveKey } from "./argon2";4041// ---------------------------------------------------------------------------42// KAT vector43// ---------------------------------------------------------------------------4445// This hex string is the coordinated browser/Android production-profile46// output. A change breaks vault compatibility and must never be accepted by47// regenerating only one implementation's expected value.48//49// RFC 9106's published Argon2id vector includes associated data, which50// hash-wasm cannot represent. Android separately executes that full RFC vector;51// this cross-implementation vector covers Peach's actual no-associated-data52// production profile on both platforms.53const KAT_EXPECTED_HEX = "df187c8d241e8f3fd42810e1379660605aad1119dcb133d8ff916f87160d6a33";5455// If you have not yet run the generation script, mark this test as skipped56// and uncomment the KAT vector once generated.57const KAT_AVAILABLE = (KAT_EXPECTED_HEX as string) !== "REPLACE_ME_WITH_ACTUAL_HASH_FROM_GEN_SCRIPT";5859describe("T-019: Argon2id known-answer test + parameter assertion", () => {60 // T-019a: deriveKey output matches the KAT vector exactly.61 // If any of m, t, p, hashLength, or the NFC normalization step changes, this62 // test fails — alerting the developer before a parameter regression ships.63 it(64 "T-019a: deriveKey('testpassword', salt=0xAB×16) matches KAT vector (m=65536, t=3, p=4)",65 KAT_AVAILABLE ? undefined : { skip: true },66 async () => {67 if (!KAT_AVAILABLE) return;68 const salt = new Uint8Array(16).fill(0xab);69 const key = await deriveKey("testpassword", salt);70 expect(Array.from(key).map(b => b.toString(16).padStart(2, "0")).join("")).toBe(KAT_EXPECTED_HEX);71 },72 );7374 // T-019b: parameter assertion — output length is exactly 32 bytes.75 // hashLength: 32 is the documented value. A change to 16 or 64 would change76 // the output but might not cause test failures in the vault suite since77 // AES-256-GCM needs exactly 32 bytes and a wrong length would produce78 // only a key import error (caught at unlock, not at derive time).79 it("T-019b: deriveKey output is exactly 32 bytes", async () => {80 const salt = new Uint8Array(16).fill(0x01);81 const key = await deriveKey("any-password", salt);82 expect(key).toBeInstanceOf(Uint8Array);83 expect(key.length).toBe(32);84 });8586 // T-019c: same (password, salt) → same output (determinism).87 // Argon2id is deterministic; two calls with the same inputs must produce88 // identical bytes. This guards against accidental CSPRNG injection into89 // the KDF path.90 it("T-019c: deriveKey is deterministic for the same (password, salt)", async () => {91 const salt = new Uint8Array(16).fill(0x33);92 const key1 = await deriveKey("determinism-check", salt);93 const key2 = await deriveKey("determinism-check", salt);94 expect(key1).toEqual(key2);95 });9697 // T-019d: different salt → different output.98 // Guards against the salt being ignored (a common KDF implementation bug).99 // If the salt is ignored, two vaults with the same master password would100 // use the same derived key — precomputation attacks become feasible.101 it("T-019d: different salts produce different keys", async () => {102 const salt1 = new Uint8Array(16).fill(0x11);103 const salt2 = new Uint8Array(16).fill(0x22);104 const key1 = await deriveKey("same-password", salt1);105 const key2 = await deriveKey("same-password", salt2);106 expect(key1).not.toEqual(key2);107 });108109 // T-019e: NFC normalization — NFC("café") and NFC("café") produce the110 // same key. The LV-1 fix normalized passwords before the KDF; without it,111 // a macOS dead-key typed "café" would produce a different key than a Windows112 // precomposed "café" (U+00E9).113 it("T-019e: NFC-equivalent passwords derive the same key (LV-1 normalization)", async () => {114 const salt = new Uint8Array(16).fill(0x55);115 const composed = "café"; // U+00E9 (precomposed)116 const decomposed = "café"; // e + combining acute accent (NFD)117 // deriveKey applies .normalize("NFC") before the KDF118 const keyComposed = await deriveKey(composed, salt);119 const keyDecomposed = await deriveKey(decomposed, salt);120 expect(keyComposed).toEqual(keyDecomposed);121 });122123});