import crypto from "node:crypto"; const PBKDF2_ITERATIONS = 100_000; const PBKDF2_KEYLEN = 32; const PBKDF2_DIGEST = "sha512"; const SALT_BYTES = 32; /** * Hash a password using pbkdf2 with a random salt. * Returns `salt:hash` in hex. */ export async function hashPassword(password: string): Promise { const salt = crypto.randomBytes(SALT_BYTES).toString("hex"); return new Promise((resolve, reject) => { crypto.pbkdf2( password, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST, (err, derivedKey) => { if (err) return reject(err); resolve(`${salt}:${derivedKey.toString("hex")}`); }, ); }); } /** * Verify a password against a stored `salt:hash` string. * Uses timing-safe comparison. */ export async function verifyPassword( password: string, stored: string, ): Promise { const [salt, originalHash] = stored.split(":"); if (!salt || !originalHash) return false; return new Promise((resolve, reject) => { crypto.pbkdf2( password, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST, (err, derivedKey) => { if (err) return reject(err); const hashBuf = Buffer.from(originalHash, "hex"); if (hashBuf.length !== derivedKey.length) { resolve(false); return; } resolve(crypto.timingSafeEqual(hashBuf, derivedKey)); }, ); }); } /** * Generate a random hex token. */ export function generateToken(bytes: number = 32): string { return crypto.randomBytes(bytes).toString("hex"); } /** * Encrypt a session ID using AES-256-GCM. * SESSION_SECRET must be a hex-encoded 32-byte key. * Returns `iv:authTag:ciphertext` in hex. */ export function encryptSessionId(sessionId: string): string { const key = Buffer.from(process.env.SESSION_SECRET!, "hex"); const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv("aes-256-gcm", key, iv); const encrypted = Buffer.concat([ cipher.update(sessionId, "utf8"), cipher.final(), ]); const authTag = cipher.getAuthTag(); return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`; } /** * Decrypt a session ID encrypted with encryptSessionId. * Returns null on any error. */ export function decryptSessionId(encrypted: string): string | null { try { const key = Buffer.from(process.env.SESSION_SECRET!, "hex"); const [ivHex, authTagHex, ciphertextHex] = encrypted.split(":"); if (!ivHex || !authTagHex || !ciphertextHex) return null; const iv = Buffer.from(ivHex, "hex"); const authTag = Buffer.from(authTagHex, "hex"); const ciphertext = Buffer.from(ciphertextHex, "hex"); const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]); return decrypted.toString("utf8"); } catch { return null; } }