115 lines · 2.9 KB
1 /**
2 * Authentication utilities: password hashing and token generation.
3 * Uses Web Crypto API (available in Workers runtime).
4 */
5
6 const encoder = new TextEncoder();
7
8 const PBKDF2_ITERATIONS = 100_000;
9 const SALT_LENGTH = 16;
10 const HASH_LENGTH = 32;
11
12 /** Hash a password using PBKDF2-SHA256. Returns "salt:hash" in hex. */
13 export async function hashPassword(password: string): Promise<string> {
14 const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
15 const keyMaterial = await crypto.subtle.importKey(
16 'raw',
17 encoder.encode(password),
18 'PBKDF2',
19 false,
20 ['deriveBits']
21 );
22
23 const hashBits = await crypto.subtle.deriveBits(
24 {
25 name: 'PBKDF2',
26 salt,
27 iterations: PBKDF2_ITERATIONS,
28 hash: 'SHA-256',
29 },
30 keyMaterial,
31 HASH_LENGTH * 8
32 );
33
34 const saltHex = hexEncode(salt);
35 const hashHex = hexEncode(new Uint8Array(hashBits));
36 return `${saltHex}:${hashHex}`;
37 }
38
39 /** Verify a password against a stored "salt:hash" string. */
40 export async function verifyPassword(password: string, stored: string): Promise<boolean> {
41 const [saltHex, expectedHashHex] = stored.split(':');
42 const salt = hexDecode(saltHex);
43
44 const keyMaterial = await crypto.subtle.importKey(
45 'raw',
46 encoder.encode(password),
47 'PBKDF2',
48 false,
49 ['deriveBits']
50 );
51
52 const hashBits = await crypto.subtle.deriveBits(
53 {
54 name: 'PBKDF2',
55 salt,
56 iterations: PBKDF2_ITERATIONS,
57 hash: 'SHA-256',
58 },
59 keyMaterial,
60 HASH_LENGTH * 8
61 );
62
63 const actualHashHex = hexEncode(new Uint8Array(hashBits));
64 return timingSafeEqual(expectedHashHex, actualHashHex);
65 }
66
67 /** Generate a random personal access token. Returns the plaintext token. */
68 export function generateToken(): string {
69 const bytes = crypto.getRandomValues(new Uint8Array(32));
70 return 'gf_' + base62Encode(bytes);
71 }
72
73 /** Get the display prefix of a token (first 8 chars after "gf_"). */
74 export function tokenPrefix(token: string): string {
75 return token.slice(0, 11); // "gf_" + 8 chars
76 }
77
78 // --- Helpers ---
79
80 const BASE62_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
81
82 function base62Encode(bytes: Uint8Array): string {
83 let result = '';
84 for (const byte of bytes) {
85 result += BASE62_CHARS[byte % 62];
86 }
87 return result;
88 }
89
90 function hexEncode(bytes: Uint8Array): string {
91 let hex = '';
92 for (let i = 0; i < bytes.length; i++) {
93 hex += bytes[i].toString(16).padStart(2, '0');
94 }
95 return hex;
96 }
97
98 function hexDecode(hex: string): Uint8Array {
99 const bytes = new Uint8Array(hex.length / 2);
100 for (let i = 0; i < hex.length; i += 2) {
101 bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
102 }
103 return bytes;
104 }
105
106 /** Timing-safe string comparison to prevent timing attacks. */
107 function timingSafeEqual(a: string, b: string): boolean {
108 if (a.length !== b.length) return false;
109 let result = 0;
110 for (let i = 0; i < a.length; i++) {
111 result |= a.charCodeAt(i) ^ b.charCodeAt(i);
112 }
113 return result === 0;
114 }
115