File
Blob: tests/worker/argon2.test.ts
| 1 | import { describe, expect, it } from "vitest"; |
| 2 | |
| 3 | import { hashArgon2id, verifyArgon2id } from "@/worker/auth/argon2"; |
| 4 | |
| 5 | const PHC_PATTERN = /^\$argon2id\$v=19\$m=19456,t=2,p=1\$[A-Za-z0-9+/]+\$[A-Za-z0-9+/]+$/; |
| 6 | |
| 7 | // Argon2id at m=19456,t=2 takes ~1–3s per hash on dev hardware (pure-JS noble |
| 8 | // path on workerd). Keep these tests well past Vitest's 5s default. |
| 9 | const HASH_TIMEOUT = 30_000; |
| 10 | |
| 11 | describe("Argon2id hash + verify", () => { |
| 12 | it( |
| 13 | "emits a PHC string with the expected shape", |
| 14 | async () => { |
| 15 | const hash = await hashArgon2id("hunter2"); |
| 16 | expect(hash).toMatch(PHC_PATTERN); |
| 17 | }, |
| 18 | HASH_TIMEOUT, |
| 19 | ); |
| 20 | |
| 21 | it( |
| 22 | "verifies the correct password", |
| 23 | async () => { |
| 24 | const password = "correct-horse-battery-staple"; |
| 25 | const hash = await hashArgon2id(password); |
| 26 | expect(await verifyArgon2id({ hash, password })).toBe(true); |
| 27 | }, |
| 28 | HASH_TIMEOUT, |
| 29 | ); |
| 30 | |
| 31 | it( |
| 32 | "rejects the wrong password", |
| 33 | async () => { |
| 34 | const hash = await hashArgon2id("right-password"); |
| 35 | expect(await verifyArgon2id({ hash, password: "wrong-password" })).toBe(false); |
| 36 | }, |
| 37 | HASH_TIMEOUT, |
| 38 | ); |
| 39 | |
| 40 | it("rejects malformed PHC strings", async () => { |
| 41 | expect(await verifyArgon2id({ hash: "not-a-phc-string", password: "x" })).toBe(false); |
| 42 | expect(await verifyArgon2id({ hash: "$argon2id$v=19$m=19456,t=2,p=1$tooshort", password: "x" })).toBe(false); |
| 43 | }); |
| 44 | |
| 45 | // tessera always emits dkLen=32 PHC strings. Anything else is by |
| 46 | // definition not produced by hashArgon2id; rejecting non-canonical |
| 47 | // lengths removes the trivial truncation collision surface |
| 48 | // (1/256 brute-force at dkLen=1) that an attacker with arbitrary D1 |
| 49 | // write could otherwise exploit. Non-canonical lengths short-circuit |
| 50 | // before invoking argon2id, so this is a fast assertion. |
| 51 | it("rejects PHC strings with non-canonical stored-digest lengths", async () => { |
| 52 | const encodeBase64Phc = (bytes: Uint8Array): string => { |
| 53 | let binary = ""; |
| 54 | for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); |
| 55 | return btoa(binary).replace(/=+$/, ""); |
| 56 | }; |
| 57 | const salt = new Uint8Array(16); |
| 58 | const make = (storedLen: number): string => { |
| 59 | const stored = new Uint8Array(storedLen); |
| 60 | return `$argon2id$v=19$m=19456,t=2,p=1$${encodeBase64Phc(salt)}$${encodeBase64Phc(stored)}`; |
| 61 | }; |
| 62 | expect(await verifyArgon2id({ hash: make(16), password: "x" })).toBe(false); |
| 63 | expect(await verifyArgon2id({ hash: make(40), password: "x" })).toBe(false); |
| 64 | expect(await verifyArgon2id({ hash: make(1), password: "x" })).toBe(false); |
| 65 | }); |
| 66 | |
| 67 | it( |
| 68 | "produces unique salts so two hashes of the same password differ", |
| 69 | async () => { |
| 70 | const a = await hashArgon2id("same"); |
| 71 | const b = await hashArgon2id("same"); |
| 72 | expect(a).not.toBe(b); |
| 73 | }, |
| 74 | HASH_TIMEOUT, |
| 75 | ); |
| 76 | }); |