Skip to content
File

Blob: src/worker/auth/argon2.ts

typescript90 lines
1import { argon2id } from "@noble/hashes/argon2.js";
2 
3import { timingSafeEqualBytes } from "@/worker/security/timing-safe";
4 
5const PARAMS = { m: 19456, t: 2, p: 1, dkLen: 32 } as const;
6const SALT_BYTES = 16;
7const PHC_PREFIX = "$argon2id$v=19$";
8 
9const 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 
17const 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 
27export 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.
39const PHC_LIMITS = { m: 65536, t: 8, p: 4 } as const;
40const PHC_MIN = { m: 1, t: 1, p: 1 } as const;
41 
42export 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};