🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: use PBKDF2 to hash passwords user side

+265 -31
+6 -2
src/components/auth.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 + import { hashPasswordClient } from "../lib/client-auth"; 3 4 4 5 interface User { 5 6 email: string; ··· 287 288 this.isSubmitting = true; 288 289 289 290 try { 291 + // Hash password client-side with expensive PBKDF2 292 + const passwordHash = await hashPasswordClient(this.password, this.email); 293 + 290 294 if (this.needsRegistration) { 291 295 const response = await fetch("/api/auth/register", { 292 296 method: "POST", ··· 295 299 }, 296 300 body: JSON.stringify({ 297 301 email: this.email, 298 - password: this.password, 302 + password: passwordHash, 299 303 name: this.name || null, 300 304 }), 301 305 }); ··· 318 322 }, 319 323 body: JSON.stringify({ 320 324 email: this.email, 321 - password: this.password, 325 + password: passwordHash, 322 326 }), 323 327 }); 324 328
+8 -1
src/components/user-settings.ts
··· 1 1 import { css, html, LitElement } from "lit"; 2 2 import { customElement, state } from "lit/decorators.js"; 3 3 import { UAParser } from "ua-parser-js"; 4 + import { hashPasswordClient } from "../lib/client-auth"; 4 5 5 6 interface User { 6 7 email: string; ··· 502 503 } 503 504 504 505 try { 506 + // Hash password client-side before sending 507 + const passwordHash = await hashPasswordClient( 508 + this.newPassword, 509 + this.user?.email ?? "", 510 + ); 511 + 505 512 const response = await fetch("/api/user/password", { 506 513 method: "PUT", 507 514 headers: { "Content-Type": "application/json" }, 508 - body: JSON.stringify({ password: this.newPassword }), 515 + body: JSON.stringify({ password: passwordHash }), 509 516 }); 510 517 511 518 if (!response.ok) {
+13 -4
src/index.ts
··· 94 94 { status: 400 }, 95 95 ); 96 96 } 97 - if (password.length < 8) { 97 + // Password is client-side hashed (PBKDF2), should be 64 char hex 98 + if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 98 99 return Response.json( 99 - { error: "Password must be at least 8 characters" }, 100 + { error: "Invalid password format" }, 100 101 { status: 400 }, 101 102 ); 102 103 } ··· 138 139 if (!email || !password) { 139 140 return Response.json( 140 141 { error: "Email and password required" }, 142 + { status: 400 }, 143 + ); 144 + } 145 + // Password is client-side hashed (PBKDF2), should be 64 char hex 146 + if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 147 + return Response.json( 148 + { error: "Invalid password format" }, 141 149 { status: 400 }, 142 150 ); 143 151 } ··· 319 327 if (!password) { 320 328 return Response.json({ error: "Password required" }, { status: 400 }); 321 329 } 322 - if (password.length < 8) { 330 + // Password is client-side hashed (PBKDF2), should be 64 char hex 331 + if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) { 323 332 return Response.json( 324 - { error: "Password must be at least 8 characters" }, 333 + { error: "Invalid password format" }, 325 334 { status: 400 }, 326 335 ); 327 336 }
+132
src/lib/auth.test.ts
··· 1 + import { test, expect } from "bun:test"; 2 + import { 3 + createSession, 4 + getSession, 5 + deleteSession, 6 + getSessionFromRequest, 7 + } from "./auth"; 8 + import db from "../db/schema"; 9 + 10 + test("createSession generates UUID and stores in database", () => { 11 + const userId = 1; 12 + const ipAddress = "192.168.1.1"; 13 + const userAgent = "Mozilla/5.0"; 14 + 15 + const sessionId = createSession(userId, ipAddress, userAgent); 16 + 17 + // UUID format 18 + expect(sessionId).toMatch( 19 + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, 20 + ); 21 + 22 + // Verify stored in database 23 + const session = getSession(sessionId); 24 + expect(session).not.toBeNull(); 25 + expect(session?.user_id).toBe(userId); 26 + expect(session?.ip_address).toBe(ipAddress); 27 + expect(session?.user_agent).toBe(userAgent); 28 + 29 + // Cleanup 30 + deleteSession(sessionId); 31 + }); 32 + 33 + test("getSession returns null for expired session", () => { 34 + const userId = 1; 35 + const sessionId = createSession(userId); 36 + 37 + // Manually set expiration to past 38 + db.run("UPDATE sessions SET expires_at = ? WHERE id = ?", [ 39 + Math.floor(Date.now() / 1000) - 1000, 40 + sessionId, 41 + ]); 42 + 43 + const session = getSession(sessionId); 44 + expect(session).toBeNull(); 45 + 46 + // Cleanup 47 + deleteSession(sessionId); 48 + }); 49 + 50 + test("getSession returns null for non-existent session", () => { 51 + const session = getSession("non-existent-session-id"); 52 + expect(session).toBeNull(); 53 + }); 54 + 55 + test("deleteSession removes session from database", () => { 56 + const userId = 1; 57 + const sessionId = createSession(userId); 58 + 59 + const sessionBefore = getSession(sessionId); 60 + expect(sessionBefore).not.toBeNull(); 61 + 62 + deleteSession(sessionId); 63 + 64 + const sessionAfter = getSession(sessionId); 65 + expect(sessionAfter).toBeNull(); 66 + }); 67 + 68 + test("getSessionFromRequest extracts session from cookie", () => { 69 + const sessionId = "test-session-id"; 70 + const req = new Request("http://localhost", { 71 + headers: { 72 + cookie: `session=${sessionId}; other=value`, 73 + }, 74 + }); 75 + 76 + const extracted = getSessionFromRequest(req); 77 + expect(extracted).toBe(sessionId); 78 + }); 79 + 80 + test("getSessionFromRequest returns null when no cookie", () => { 81 + const req = new Request("http://localhost"); 82 + 83 + const extracted = getSessionFromRequest(req); 84 + expect(extracted).toBeNull(); 85 + }); 86 + 87 + test("getSessionFromRequest returns null when session cookie missing", () => { 88 + const req = new Request("http://localhost", { 89 + headers: { 90 + cookie: "other=value; foo=bar", 91 + }, 92 + }); 93 + 94 + const extracted = getSessionFromRequest(req); 95 + expect(extracted).toBeNull(); 96 + }); 97 + 98 + test("prevents directory traversal in session IDs", () => { 99 + const maliciousIds = [ 100 + "../../../etc/passwd", 101 + "..\\..\\..\\windows\\system32", 102 + "test/../../../secret", 103 + "/etc/passwd", 104 + "C:\\Windows\\System32", 105 + ]; 106 + 107 + for (const id of maliciousIds) { 108 + const session = getSession(id); 109 + expect(session).toBeNull(); 110 + } 111 + }); 112 + 113 + test("prevents SQL injection in session lookup", () => { 114 + const maliciousIds = [ 115 + "' OR '1'='1", 116 + "'; DROP TABLE sessions; --", 117 + "1' UNION SELECT * FROM users --", 118 + "test' OR 1=1 --", 119 + ]; 120 + 121 + for (const id of maliciousIds) { 122 + // Should not throw or return unexpected data 123 + const session = getSession(id); 124 + expect(session).toBeNull(); 125 + } 126 + 127 + // Verify sessions table still exists 128 + const result = db 129 + .query("SELECT COUNT(*) as count FROM sessions") 130 + .get() as { count: number }; 131 + expect(typeof result.count).toBe("number"); 132 + });
+20 -24
src/lib/auth.ts
··· 19 19 expires_at: number; 20 20 } 21 21 22 - export async function hashPassword(password: string): Promise<string> { 23 - return await Bun.password.hash(password, { 24 - algorithm: "argon2id", 25 - memoryCost: 19456, 26 - timeCost: 2, 27 - }); 28 - } 29 - 30 - export async function verifyPassword( 31 - password: string, 32 - hash: string, 33 - ): Promise<boolean> { 34 - return await Bun.password.verify(password, hash, "argon2id"); 35 - } 36 - 37 22 export function createSession( 38 23 userId: number, 39 24 ipAddress?: string, ··· 89 74 password: string, 90 75 name?: string, 91 76 ): Promise<User> { 92 - const passwordHash = await hashPassword(password); 93 - 94 77 const result = db.run( 95 78 "INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)", 96 - [email, passwordHash, name ?? null], 79 + [email, password, name ?? null], 97 80 ); 98 81 99 82 const user = db 100 - .query<User, [number]>("SELECT id, email, name, avatar, created_at FROM users WHERE id = ?") 83 + .query<User, [number]>( 84 + "SELECT id, email, name, avatar, created_at FROM users WHERE id = ?", 85 + ) 101 86 .get(Number(result.lastInsertRowid)); 102 87 103 88 if (!user) { ··· 112 97 password: string, 113 98 ): Promise<User | null> { 114 99 const result = db 115 - .query<{ id: number; email: string; name: string | null; avatar: string; password_hash: string; created_at: number }, [string]>( 100 + .query< 101 + { 102 + id: number; 103 + email: string; 104 + name: string | null; 105 + avatar: string; 106 + password_hash: string; 107 + created_at: number; 108 + }, 109 + [string] 110 + >( 116 111 "SELECT id, email, name, avatar, password_hash, created_at FROM users WHERE email = ?", 117 112 ) 118 113 .get(email); 119 114 120 115 if (!result) return null; 121 116 122 - const isValid = await verifyPassword(password, result.password_hash); 123 - if (!isValid) return null; 117 + if (password !== result.password_hash) return null; 124 118 125 119 return { 126 120 id: result.id, ··· 171 165 userId: number, 172 166 newPassword: string, 173 167 ): Promise<void> { 174 - const hash = await hashPassword(newPassword); 175 - db.run("UPDATE users SET password_hash = ? WHERE id = ?", [hash, userId]); 168 + db.run("UPDATE users SET password_hash = ? WHERE id = ?", [ 169 + newPassword, 170 + userId, 171 + ]); 176 172 }
+38
src/lib/client-auth.test.ts
··· 1 + import { test, expect } from "bun:test"; 2 + import { hashPasswordClient } from "./client-auth"; 3 + 4 + test("hashPasswordClient produces consistent output", async () => { 5 + const hash1 = await hashPasswordClient("password123", "user@example.com"); 6 + const hash2 = await hashPasswordClient("password123", "user@example.com"); 7 + 8 + expect(hash1).toBe(hash2); 9 + expect(hash1).toHaveLength(64); // 32 bytes * 2 hex chars 10 + }); 11 + 12 + test("hashPasswordClient produces different hashes for different passwords", async () => { 13 + const hash1 = await hashPasswordClient("password123", "user@example.com"); 14 + const hash2 = await hashPasswordClient("different", "user@example.com"); 15 + 16 + expect(hash1).not.toBe(hash2); 17 + }); 18 + 19 + test("hashPasswordClient produces different hashes for different emails", async () => { 20 + const hash1 = await hashPasswordClient("password123", "user1@example.com"); 21 + const hash2 = await hashPasswordClient("password123", "user2@example.com"); 22 + 23 + expect(hash1).not.toBe(hash2); 24 + }); 25 + 26 + test("hashPasswordClient is case-insensitive for email", async () => { 27 + const hash1 = await hashPasswordClient("password123", "User@Example.Com"); 28 + const hash2 = await hashPasswordClient("password123", "user@example.com"); 29 + 30 + expect(hash1).toBe(hash2); 31 + }); 32 + 33 + test("hashPasswordClient produces hex-encoded output", async () => { 34 + const hash = await hashPasswordClient("test", "test@test.com"); 35 + 36 + // Should only contain hex characters 37 + expect(hash).toMatch(/^[0-9a-f]+$/); 38 + });
+48
src/lib/client-auth.ts
··· 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 + 7 + const ITERATIONS = 1_000_000; // ~1-2 seconds on modern devices 8 + 9 + /** 10 + * Hash password client-side using PBKDF2. 11 + * @param password - Plaintext password 12 + * @param email - Email address (used as salt) 13 + * @returns Hex-encoded hash 14 + */ 15 + export async function hashPasswordClient( 16 + password: string, 17 + email: string, 18 + ): Promise<string> { 19 + const encoder = new TextEncoder(); 20 + 21 + // Import password as key 22 + const keyMaterial = await crypto.subtle.importKey( 23 + "raw", 24 + encoder.encode(password), 25 + { name: "PBKDF2" }, 26 + false, 27 + ["deriveBits"], 28 + ); 29 + 30 + // Use email as salt (deterministic, unique per user) 31 + const salt = encoder.encode(email.toLowerCase()); 32 + 33 + // Derive 256 bits using PBKDF2 34 + const hashBuffer = await crypto.subtle.deriveBits( 35 + { 36 + name: "PBKDF2", 37 + salt, 38 + iterations: ITERATIONS, 39 + hash: "SHA-256", 40 + }, 41 + keyMaterial, 42 + 256, // 256 bits = 32 bytes 43 + ); 44 + 45 + // Convert to hex string 46 + const hashArray = Array.from(new Uint8Array(hashBuffer)); 47 + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); 48 + }