// T-019: Argon2id known-answer test (KAT) + parameter assertion // // Why this test exists: the Argon2id KDF in vault.ts uses m=65536 (64 MiB), // t=3 (iterations), p=4 (parallelism), hashLength=32. These parameters // determine the real-world cost for an attacker brute-forcing a stolen vault. // There is no existing test that: // 1. Pins a known (password, salt) to an expected 32-byte output, and // 2. Asserts that the parameters match the documented values. // // A regression that silently reduces e.g. m=65536 → m=1024 would be // invisible to users, significantly weaken offline attack resistance, and // pass all existing tests because the vault still unlocks (the key changes but // re-encrypts everything on save). // // The KAT approach: // - Pin a deterministic production-profile (password, salt) pair to the // same output in hash-wasm and Android's independent Bouncy Castle path. // - Assert the actual output matches byte-for-byte. // - If any parameter silently changes (m, t, p, or hashLength), the output // hash changes and this test fails. // // Reference KAT vector: // password = "testpassword" (NFC-normalized — same as ASCII, no change) // salt = 16 × 0xAB // m=65536, t=3, p=4, hashLength=32 // Expected output: cross-checked by peach-android's // OfficialCryptoVectorsTest using the same UTF-8 input and parameters. // // To re-derive (run once in Node to get the canonical value): // import { argon2id } from "hash-wasm"; // const h = await argon2id({ // password: "testpassword", salt: new Uint8Array(16).fill(0xAB), // parallelism: 4, iterations: 3, memorySize: 65536, // hashLength: 32, outputType: "binary", // }); // console.log(Buffer.from(h).toString("hex")); import { describe, expect, it } from "vitest"; import { deriveKey } from "./argon2"; // --------------------------------------------------------------------------- // KAT vector // --------------------------------------------------------------------------- // This hex string is the coordinated browser/Android production-profile // output. A change breaks vault compatibility and must never be accepted by // regenerating only one implementation's expected value. // // RFC 9106's published Argon2id vector includes associated data, which // hash-wasm cannot represent. Android separately executes that full RFC vector; // this cross-implementation vector covers Peach's actual no-associated-data // production profile on both platforms. const KAT_EXPECTED_HEX = "df187c8d241e8f3fd42810e1379660605aad1119dcb133d8ff916f87160d6a33"; // If you have not yet run the generation script, mark this test as skipped // and uncomment the KAT vector once generated. const KAT_AVAILABLE = (KAT_EXPECTED_HEX as string) !== "REPLACE_ME_WITH_ACTUAL_HASH_FROM_GEN_SCRIPT"; describe("T-019: Argon2id known-answer test + parameter assertion", () => { // T-019a: deriveKey output matches the KAT vector exactly. // If any of m, t, p, hashLength, or the NFC normalization step changes, this // test fails — alerting the developer before a parameter regression ships. it( "T-019a: deriveKey('testpassword', salt=0xAB×16) matches KAT vector (m=65536, t=3, p=4)", KAT_AVAILABLE ? undefined : { skip: true }, async () => { if (!KAT_AVAILABLE) return; const salt = new Uint8Array(16).fill(0xab); const key = await deriveKey("testpassword", salt); expect(Array.from(key).map(b => b.toString(16).padStart(2, "0")).join("")).toBe(KAT_EXPECTED_HEX); }, ); // T-019b: parameter assertion — output length is exactly 32 bytes. // hashLength: 32 is the documented value. A change to 16 or 64 would change // the output but might not cause test failures in the vault suite since // AES-256-GCM needs exactly 32 bytes and a wrong length would produce // only a key import error (caught at unlock, not at derive time). it("T-019b: deriveKey output is exactly 32 bytes", async () => { const salt = new Uint8Array(16).fill(0x01); const key = await deriveKey("any-password", salt); expect(key).toBeInstanceOf(Uint8Array); expect(key.length).toBe(32); }); // T-019c: same (password, salt) → same output (determinism). // Argon2id is deterministic; two calls with the same inputs must produce // identical bytes. This guards against accidental CSPRNG injection into // the KDF path. it("T-019c: deriveKey is deterministic for the same (password, salt)", async () => { const salt = new Uint8Array(16).fill(0x33); const key1 = await deriveKey("determinism-check", salt); const key2 = await deriveKey("determinism-check", salt); expect(key1).toEqual(key2); }); // T-019d: different salt → different output. // Guards against the salt being ignored (a common KDF implementation bug). // If the salt is ignored, two vaults with the same master password would // use the same derived key — precomputation attacks become feasible. it("T-019d: different salts produce different keys", async () => { const salt1 = new Uint8Array(16).fill(0x11); const salt2 = new Uint8Array(16).fill(0x22); const key1 = await deriveKey("same-password", salt1); const key2 = await deriveKey("same-password", salt2); expect(key1).not.toEqual(key2); }); // T-019e: NFC normalization — NFC("café") and NFC("café") produce the // same key. The LV-1 fix normalized passwords before the KDF; without it, // a macOS dead-key typed "café" would produce a different key than a Windows // precomposed "café" (U+00E9). it("T-019e: NFC-equivalent passwords derive the same key (LV-1 normalization)", async () => { const salt = new Uint8Array(16).fill(0x55); const composed = "café"; // U+00E9 (precomposed) const decomposed = "café"; // e + combining acute accent (NFD) // deriveKey applies .normalize("NFC") before the KDF const keyComposed = await deriveKey(composed, salt); const keyDecomposed = await deriveKey(decomposed, salt); expect(keyComposed).toEqual(keyDecomposed); }); });