🪻 distributed transcription service thistle.dunkirk.sh

feat: use PBKDF2 to hash passwords user side

dunkirk.sh 81620df2 c1a25049

verified
+6 -2
src/components/auth.ts
···
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
+
import { hashPasswordClient } from "../lib/client-auth";
interface User {
email: string;
···
this.isSubmitting = true;
try {
+
// Hash password client-side with expensive PBKDF2
+
const passwordHash = await hashPasswordClient(this.password, this.email);
+
if (this.needsRegistration) {
const response = await fetch("/api/auth/register", {
method: "POST",
···
},
body: JSON.stringify({
email: this.email,
-
password: this.password,
+
password: passwordHash,
name: this.name || null,
}),
});
···
},
body: JSON.stringify({
email: this.email,
-
password: this.password,
+
password: passwordHash,
}),
});
+8 -1
src/components/user-settings.ts
···
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { UAParser } from "ua-parser-js";
+
import { hashPasswordClient } from "../lib/client-auth";
interface User {
email: string;
···
}
try {
+
// Hash password client-side before sending
+
const passwordHash = await hashPasswordClient(
+
this.newPassword,
+
this.user?.email ?? "",
+
);
+
const response = await fetch("/api/user/password", {
method: "PUT",
headers: { "Content-Type": "application/json" },
-
body: JSON.stringify({ password: this.newPassword }),
+
body: JSON.stringify({ password: passwordHash }),
});
if (!response.ok) {
+13 -4
src/index.ts
···
{ status: 400 },
);
}
-
if (password.length < 8) {
+
// Password is client-side hashed (PBKDF2), should be 64 char hex
+
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
return Response.json(
-
{ error: "Password must be at least 8 characters" },
+
{ error: "Invalid password format" },
{ status: 400 },
);
}
···
if (!email || !password) {
return Response.json(
{ error: "Email and password required" },
+
{ status: 400 },
+
);
+
}
+
// Password is client-side hashed (PBKDF2), should be 64 char hex
+
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
+
return Response.json(
+
{ error: "Invalid password format" },
{ status: 400 },
);
}
···
if (!password) {
return Response.json({ error: "Password required" }, { status: 400 });
}
-
if (password.length < 8) {
+
// Password is client-side hashed (PBKDF2), should be 64 char hex
+
if (password.length !== 64 || !/^[0-9a-f]+$/.test(password)) {
return Response.json(
-
{ error: "Password must be at least 8 characters" },
+
{ error: "Invalid password format" },
{ status: 400 },
);
}
+132
src/lib/auth.test.ts
···
+
import { test, expect } from "bun:test";
+
import {
+
createSession,
+
getSession,
+
deleteSession,
+
getSessionFromRequest,
+
} from "./auth";
+
import db from "../db/schema";
+
+
test("createSession generates UUID and stores in database", () => {
+
const userId = 1;
+
const ipAddress = "192.168.1.1";
+
const userAgent = "Mozilla/5.0";
+
+
const sessionId = createSession(userId, ipAddress, userAgent);
+
+
// UUID format
+
expect(sessionId).toMatch(
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
+
);
+
+
// Verify stored in database
+
const session = getSession(sessionId);
+
expect(session).not.toBeNull();
+
expect(session?.user_id).toBe(userId);
+
expect(session?.ip_address).toBe(ipAddress);
+
expect(session?.user_agent).toBe(userAgent);
+
+
// Cleanup
+
deleteSession(sessionId);
+
});
+
+
test("getSession returns null for expired session", () => {
+
const userId = 1;
+
const sessionId = createSession(userId);
+
+
// Manually set expiration to past
+
db.run("UPDATE sessions SET expires_at = ? WHERE id = ?", [
+
Math.floor(Date.now() / 1000) - 1000,
+
sessionId,
+
]);
+
+
const session = getSession(sessionId);
+
expect(session).toBeNull();
+
+
// Cleanup
+
deleteSession(sessionId);
+
});
+
+
test("getSession returns null for non-existent session", () => {
+
const session = getSession("non-existent-session-id");
+
expect(session).toBeNull();
+
});
+
+
test("deleteSession removes session from database", () => {
+
const userId = 1;
+
const sessionId = createSession(userId);
+
+
const sessionBefore = getSession(sessionId);
+
expect(sessionBefore).not.toBeNull();
+
+
deleteSession(sessionId);
+
+
const sessionAfter = getSession(sessionId);
+
expect(sessionAfter).toBeNull();
+
});
+
+
test("getSessionFromRequest extracts session from cookie", () => {
+
const sessionId = "test-session-id";
+
const req = new Request("http://localhost", {
+
headers: {
+
cookie: `session=${sessionId}; other=value`,
+
},
+
});
+
+
const extracted = getSessionFromRequest(req);
+
expect(extracted).toBe(sessionId);
+
});
+
+
test("getSessionFromRequest returns null when no cookie", () => {
+
const req = new Request("http://localhost");
+
+
const extracted = getSessionFromRequest(req);
+
expect(extracted).toBeNull();
+
});
+
+
test("getSessionFromRequest returns null when session cookie missing", () => {
+
const req = new Request("http://localhost", {
+
headers: {
+
cookie: "other=value; foo=bar",
+
},
+
});
+
+
const extracted = getSessionFromRequest(req);
+
expect(extracted).toBeNull();
+
});
+
+
test("prevents directory traversal in session IDs", () => {
+
const maliciousIds = [
+
"../../../etc/passwd",
+
"..\\..\\..\\windows\\system32",
+
"test/../../../secret",
+
"/etc/passwd",
+
"C:\\Windows\\System32",
+
];
+
+
for (const id of maliciousIds) {
+
const session = getSession(id);
+
expect(session).toBeNull();
+
}
+
});
+
+
test("prevents SQL injection in session lookup", () => {
+
const maliciousIds = [
+
"' OR '1'='1",
+
"'; DROP TABLE sessions; --",
+
"1' UNION SELECT * FROM users --",
+
"test' OR 1=1 --",
+
];
+
+
for (const id of maliciousIds) {
+
// Should not throw or return unexpected data
+
const session = getSession(id);
+
expect(session).toBeNull();
+
}
+
+
// Verify sessions table still exists
+
const result = db
+
.query("SELECT COUNT(*) as count FROM sessions")
+
.get() as { count: number };
+
expect(typeof result.count).toBe("number");
+
});
+20 -24
src/lib/auth.ts
···
expires_at: number;
}
-
export async function hashPassword(password: string): Promise<string> {
-
return await Bun.password.hash(password, {
-
algorithm: "argon2id",
-
memoryCost: 19456,
-
timeCost: 2,
-
});
-
}
-
-
export async function verifyPassword(
-
password: string,
-
hash: string,
-
): Promise<boolean> {
-
return await Bun.password.verify(password, hash, "argon2id");
-
}
-
export function createSession(
userId: number,
ipAddress?: string,
···
password: string,
name?: string,
): Promise<User> {
-
const passwordHash = await hashPassword(password);
-
const result = db.run(
"INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)",
-
[email, passwordHash, name ?? null],
+
[email, password, name ?? null],
);
const user = db
-
.query<User, [number]>("SELECT id, email, name, avatar, created_at FROM users WHERE id = ?")
+
.query<User, [number]>(
+
"SELECT id, email, name, avatar, created_at FROM users WHERE id = ?",
+
)
.get(Number(result.lastInsertRowid));
if (!user) {
···
password: string,
): Promise<User | null> {
const result = db
-
.query<{ id: number; email: string; name: string | null; avatar: string; password_hash: string; created_at: number }, [string]>(
+
.query<
+
{
+
id: number;
+
email: string;
+
name: string | null;
+
avatar: string;
+
password_hash: string;
+
created_at: number;
+
},
+
[string]
+
>(
"SELECT id, email, name, avatar, password_hash, created_at FROM users WHERE email = ?",
)
.get(email);
if (!result) return null;
-
const isValid = await verifyPassword(password, result.password_hash);
-
if (!isValid) return null;
+
if (password !== result.password_hash) return null;
return {
id: result.id,
···
userId: number,
newPassword: string,
): Promise<void> {
-
const hash = await hashPassword(newPassword);
-
db.run("UPDATE users SET password_hash = ? WHERE id = ?", [hash, userId]);
+
db.run("UPDATE users SET password_hash = ? WHERE id = ?", [
+
newPassword,
+
userId,
+
]);
}
+38
src/lib/client-auth.test.ts
···
+
import { test, expect } from "bun:test";
+
import { hashPasswordClient } from "./client-auth";
+
+
test("hashPasswordClient produces consistent output", async () => {
+
const hash1 = await hashPasswordClient("password123", "user@example.com");
+
const hash2 = await hashPasswordClient("password123", "user@example.com");
+
+
expect(hash1).toBe(hash2);
+
expect(hash1).toHaveLength(64); // 32 bytes * 2 hex chars
+
});
+
+
test("hashPasswordClient produces different hashes for different passwords", async () => {
+
const hash1 = await hashPasswordClient("password123", "user@example.com");
+
const hash2 = await hashPasswordClient("different", "user@example.com");
+
+
expect(hash1).not.toBe(hash2);
+
});
+
+
test("hashPasswordClient produces different hashes for different emails", async () => {
+
const hash1 = await hashPasswordClient("password123", "user1@example.com");
+
const hash2 = await hashPasswordClient("password123", "user2@example.com");
+
+
expect(hash1).not.toBe(hash2);
+
});
+
+
test("hashPasswordClient is case-insensitive for email", async () => {
+
const hash1 = await hashPasswordClient("password123", "User@Example.Com");
+
const hash2 = await hashPasswordClient("password123", "user@example.com");
+
+
expect(hash1).toBe(hash2);
+
});
+
+
test("hashPasswordClient produces hex-encoded output", async () => {
+
const hash = await hashPasswordClient("test", "test@test.com");
+
+
// Should only contain hex characters
+
expect(hash).toMatch(/^[0-9a-f]+$/);
+
});
+48
src/lib/client-auth.ts
···
+
/**
+
* Client-side password hashing using PBKDF2.
+
* Uses aggressive iteration count to waste client CPU instead of server CPU.
+
* Server will apply lightweight Argon2 on top for storage.
+
*/
+
+
const ITERATIONS = 1_000_000; // ~1-2 seconds on modern devices
+
+
/**
+
* Hash password client-side using PBKDF2.
+
* @param password - Plaintext password
+
* @param email - Email address (used as salt)
+
* @returns Hex-encoded hash
+
*/
+
export async function hashPasswordClient(
+
password: string,
+
email: string,
+
): Promise<string> {
+
const encoder = new TextEncoder();
+
+
// Import password as key
+
const keyMaterial = await crypto.subtle.importKey(
+
"raw",
+
encoder.encode(password),
+
{ name: "PBKDF2" },
+
false,
+
["deriveBits"],
+
);
+
+
// Use email as salt (deterministic, unique per user)
+
const salt = encoder.encode(email.toLowerCase());
+
+
// Derive 256 bits using PBKDF2
+
const hashBuffer = await crypto.subtle.deriveBits(
+
{
+
name: "PBKDF2",
+
salt,
+
iterations: ITERATIONS,
+
hash: "SHA-256",
+
},
+
keyMaterial,
+
256, // 256 bits = 32 bytes
+
);
+
+
// Convert to hex string
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
+
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
+
}