File
Blob: src/worker/auth/argon2.ts
| 1 | import { argon2id } from "@noble/hashes/argon2.js"; |
| 2 | |
| 3 | import { timingSafeEqualBytes } from "@/worker/security/timing-safe"; |
| 4 | |
| 5 | const PARAMS = { m: 19456, t: 2, p: 1, dkLen: 32 } as const; |
| 6 | const SALT_BYTES = 16; |
| 7 | const PHC_PREFIX = "$argon2id$v=19$"; |
| 8 | |
| 9 | const encodeBase64Phc = (bytes: Uint8Array): string => { |
| 10 | let binary = ""; |
| 11 | for (let i = 0; i < bytes.length; i += 1) { |
| 12 | binary += String.fromCharCode(bytes[i]); |
| 13 | } |
| 14 | return btoa(binary).replace(/=+$/, ""); |
| 15 | }; |
| 16 | |
| 17 | const decodeBase64Phc = (value: string): Uint8Array => { |
| 18 | const padded = value + "=".repeat((4 - (value.length % 4)) % 4); |
| 19 | const binary = atob(padded); |
| 20 | const bytes = new Uint8Array(binary.length); |
| 21 | for (let i = 0; i < binary.length; i += 1) { |
| 22 | bytes[i] = binary.charCodeAt(i); |
| 23 | } |
| 24 | return bytes; |
| 25 | }; |
| 26 | |
| 27 | export const hashArgon2id = async (password: string): Promise<string> => { |
| 28 | const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES)); |
| 29 | const hash = argon2id(password, salt, PARAMS); |
| 30 | return `${PHC_PREFIX}m=${PARAMS.m},t=${PARAMS.t},p=${PARAMS.p}$${encodeBase64Phc(salt)}$${encodeBase64Phc(hash)}`; |
| 31 | }; |
| 32 | |
| 33 | // Defense-in-depth bounds on PHC parameters parsed from stored hashes. |
| 34 | // argon2id's memory parameter is allocated up-front and OOMs the worker |
| 35 | // if pushed past isolate limits; verifying a password should never |
| 36 | // require more cost than hashing it. An attacker with arbitrary D1 |
| 37 | // write would have worse problems available, but the clamp removes a |
| 38 | // trivial DoS surface. |
| 39 | const PHC_LIMITS = { m: 65536, t: 8, p: 4 } as const; |
| 40 | const PHC_MIN = { m: 1, t: 1, p: 1 } as const; |
| 41 | |
| 42 | export const verifyArgon2id = async ({ hash, password }: { hash: string; password: string }): Promise<boolean> => { |
| 43 | const parts = hash.split("$"); |
| 44 | if (parts.length !== 6 || parts[1] !== "argon2id" || parts[2] !== "v=19") { |
| 45 | return false; |
| 46 | } |
| 47 | |
| 48 | const params: Record<string, number> = {}; |
| 49 | for (const pair of parts[3].split(",")) { |
| 50 | const [key, value] = pair.split("="); |
| 51 | const numeric = Number.parseInt(value ?? "", 10); |
| 52 | if (!Number.isFinite(numeric)) { |
| 53 | return false; |
| 54 | } |
| 55 | params[key] = numeric; |
| 56 | } |
| 57 | if (params.m === undefined || params.t === undefined || params.p === undefined) { |
| 58 | return false; |
| 59 | } |
| 60 | if ( |
| 61 | params.m < PHC_MIN.m || |
| 62 | params.m > PHC_LIMITS.m || |
| 63 | params.t < PHC_MIN.t || |
| 64 | params.t > PHC_LIMITS.t || |
| 65 | params.p < PHC_MIN.p || |
| 66 | params.p > PHC_LIMITS.p |
| 67 | ) { |
| 68 | return false; |
| 69 | } |
| 70 | |
| 71 | let salt: Uint8Array; |
| 72 | let stored: Uint8Array; |
| 73 | try { |
| 74 | salt = decodeBase64Phc(parts[4]); |
| 75 | stored = decodeBase64Phc(parts[5]); |
| 76 | } catch { |
| 77 | return false; |
| 78 | } |
| 79 | // tessera-generated PHC strings always have dkLen=PARAMS.dkLen (32). |
| 80 | // Any other length is by definition not produced by hashArgon2id — |
| 81 | // reject without invoking argon2id so a corrupted or hand-crafted |
| 82 | // shortened digest can't reduce the brute-force cost of a match. |
| 83 | if (stored.length !== PARAMS.dkLen) { |
| 84 | return false; |
| 85 | } |
| 86 | |
| 87 | const recomputed = argon2id(password, salt, { m: params.m, t: params.t, p: params.p, dkLen: PARAMS.dkLen }); |
| 88 | return timingSafeEqualBytes(recomputed, stored); |
| 89 | }; |