All files / lib password.ts

100% Statements 44/44
100% Branches 10/10
100% Functions 6/6
100% Lines 39/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131                          5x 5x 5x 5x     26x 624x           13x 13x 13x             13x                   13x 13x               13x 9x     4x       9x 9x 7x 7x 7x   6x 6x   6x 6x             6x                   6x     6x 6x 168x   6x               4x 4x     3x 3x                       3x     3x 3x 192x   3x    
// Custom password hashing using Web Crypto PBKDF2
// Replaces Better Auth's default scrypt (N=16384, r=16) which is too CPU-heavy
// for Cloudflare Workers cold starts (~3s CPU time, causing 503 errors).
//
// PBKDF2-SHA256 with 100,000 iterations is NIST-approved and executes in <50ms
// on Workers thanks to native Web Crypto hardware acceleration.
//
// Hash format: "pbkdf2:iterations:salt:key" (all hex-encoded)
// Legacy format (Better Auth default scrypt): "salt:key"
 
import { scryptAsync } from "@noble/hashes/scrypt.js";
import { hexToBytes } from "@noble/hashes/utils.js";
 
const PBKDF2_ITERATIONS = 100_000;
const SALT_LENGTH = 16; // bytes
const KEY_LENGTH = 32; // bytes
const PBKDF2_PREFIX = "pbkdf2";
 
function hexEncode(bytes: Uint8Array): string {
	return Array.from(bytes)
		.map((b) => b.toString(16).padStart(2, "0"))
		.join("");
}
 
/** Hash a password using PBKDF2-SHA256 via Web Crypto */
export async function hashPassword(password: string): Promise<string> {
	const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
	const encoder = new TextEncoder();
	const keyMaterial = await crypto.subtle.importKey(
		"raw",
		encoder.encode(password.normalize("NFKC")),
		"PBKDF2",
		false,
		["deriveBits"],
	);
	const derivedBits = await crypto.subtle.deriveBits(
		{
			name: "PBKDF2",
			salt,
			iterations: PBKDF2_ITERATIONS,
			hash: "SHA-256",
		},
		keyMaterial,
		KEY_LENGTH * 8,
	);
	const key = new Uint8Array(derivedBits);
	return `${PBKDF2_PREFIX}:${PBKDF2_ITERATIONS}:${hexEncode(salt)}:${hexEncode(key)}`;
}
 
/** Verify a password against a hash (supports both PBKDF2 and legacy scrypt) */
export async function verifyPassword({
	hash,
	password,
}: { hash: string; password: string }): Promise<boolean> {
	if (hash.startsWith(`${PBKDF2_PREFIX}:`)) {
		return verifyPbkdf2(hash, password);
	}
	// Legacy Better Auth scrypt format: "salt:key"
	return verifyLegacyScrypt(hash, password);
}
 
async function verifyPbkdf2(hash: string, password: string): Promise<boolean> {
	const parts = hash.split(":");
	if (parts.length !== 4) return false;
	const [, iterStr, saltHex, keyHex] = parts;
	const iterations = Number.parseInt(iterStr, 10);
	if (Number.isNaN(iterations)) return false;
 
	const salt = hexToBytes(saltHex);
	const expectedKey = hexToBytes(keyHex);
 
	const encoder = new TextEncoder();
	const keyMaterial = await crypto.subtle.importKey(
		"raw",
		encoder.encode(password.normalize("NFKC")),
		"PBKDF2",
		false,
		["deriveBits"],
	);
	const derivedBits = await crypto.subtle.deriveBits(
		{
			name: "PBKDF2",
			salt,
			iterations,
			hash: "SHA-256",
		},
		keyMaterial,
		expectedKey.length * 8,
	);
	const derivedKey = new Uint8Array(derivedBits);
 
	// Constant-time comparison
	let diff = 0;
	for (let i = 0; i < derivedKey.length; i++) {
		diff |= derivedKey[i] ^ expectedKey[i];
	}
	return diff === 0;
}
 
/** Verify against Better Auth's default scrypt format (salt:hex-key) */
async function verifyLegacyScrypt(
	hash: string,
	password: string,
): Promise<boolean> {
	const [salt, keyHex] = hash.split(":");
	if (!salt || !keyHex) return false;
 
	// Better Auth default scrypt config
	const config = { N: 16384, r: 16, p: 1, dkLen: 64 };
	const derivedKey = await scryptAsync(
		password.normalize("NFKC"),
		salt,
		{
			N: config.N,
			p: config.p,
			r: config.r,
			dkLen: config.dkLen,
			maxmem: 128 * config.N * config.r * 2,
		},
	);
 
	const expectedKey = hexToBytes(keyHex);
 
	// Constant-time comparison
	let diff = 0;
	for (let i = 0; i < derivedKey.length; i++) {
		diff |= derivedKey[i] ^ expectedKey[i];
	}
	return diff === 0;
}