馃 distributed transcription service
thistle.dunkirk.sh
1/**
2 * Client-side password hashing using PBKDF2.
3 * Uses aggressive iteration count to waste client CPU instead of server CPU.
4 * Server will apply lightweight Argon2 on top for storage.
5 */
6
7const ITERATIONS = 1_000_000; // ~1-2 seconds on modern devices
8
9/**
10 * PBKDF2 implementation with fallback for non-secure contexts.
11 */
12async function pbkdf2(
13 password: Uint8Array,
14 salt: Uint8Array,
15 iterations: number,
16): Promise<Uint8Array> {
17 if (crypto.subtle) {
18 // Use native crypto.subtle when available (secure contexts)
19 const keyMaterial = await crypto.subtle.importKey(
20 "raw",
21 password,
22 { name: "PBKDF2" },
23 false,
24 ["deriveBits"],
25 );
26
27 const hashBuffer = await crypto.subtle.deriveBits(
28 {
29 name: "PBKDF2",
30 salt,
31 iterations,
32 hash: "SHA-256",
33 },
34 keyMaterial,
35 256,
36 );
37
38 return new Uint8Array(hashBuffer);
39 }
40
41 // Fallback: lazy-load pure JS implementation for non-secure contexts
42 const { pbkdf2Fallback } = await import("./crypto-fallback");
43 return pbkdf2Fallback(password, salt, iterations);
44}
45
46/**
47 * Hash password client-side using PBKDF2.
48 * @param password - Plaintext password
49 * @param email - Email address (used as salt)
50 * @returns Hex-encoded hash
51 */
52export async function hashPasswordClient(
53 password: string,
54 email: string,
55): Promise<string> {
56 const encoder = new TextEncoder();
57
58 // Use email as salt (deterministic, unique per user)
59 const passwordBytes = encoder.encode(password);
60 const salt = encoder.encode(email.toLowerCase());
61
62 // Derive 256 bits using PBKDF2 (with fallback for non-secure contexts)
63 const hashBuffer = await pbkdf2(passwordBytes, salt, ITERATIONS);
64
65 // Convert to hex string
66 const hashArray = Array.from(hashBuffer);
67 return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
68}