password.js (2544B)
1 // src/lib/auth/password.js - password hashing 2 // 3 // scrypt from node:crypto, so there is no native module to build and no 4 // dependency to audit. Parameters are stored alongside the hash, which lets 5 // them be raised later without invalidating existing passwords. 6 // 7 // Format: scrypt$N$r$p$salt$hash, salt and hash base64. 8 9 import crypto from 'node:crypto'; 10 import { promisify } from 'node:util'; 11 12 const scrypt = promisify(crypto.scrypt); 13 14 // 16384 * 8 * 128 bytes is 16 MB of memory per hash, which is the usual 15 // interactive-login setting and comfortably inside node's default maxmem. 16 export const DEFAULT_PARAMS = { N: 16384, r: 8, p: 1, keylen: 64 }; 17 18 const MIN_LENGTH = 8; 19 const MAX_LENGTH = 1024; 20 21 export function validatePassword(password) { 22 if (typeof password !== 'string') return 'password must be a string'; 23 if (password.length < MIN_LENGTH) return `password must be at least ${MIN_LENGTH} characters`; 24 if (password.length > MAX_LENGTH) return `password must be at most ${MAX_LENGTH} characters`; 25 return null; 26 } 27 28 export async function hashPassword(password, params = DEFAULT_PARAMS) { 29 const problem = validatePassword(password); 30 if (problem) throw new Error(problem); 31 32 const { N, r, p, keylen } = params; 33 const salt = crypto.randomBytes(16); 34 const derived = await scrypt(password, salt, keylen, { N, r, p, maxmem: 256 * N * r }); 35 return ['scrypt', N, r, p, salt.toString('base64'), derived.toString('base64')].join('$'); 36 } 37 38 export async function verifyPassword(password, stored) { 39 if (typeof password !== 'string' || typeof stored !== 'string') return false; 40 41 const parts = stored.split('$'); 42 if (parts.length !== 6 || parts[0] !== 'scrypt') return false; 43 44 const N = parseInt(parts[1], 10); 45 const r = parseInt(parts[2], 10); 46 const p = parseInt(parts[3], 10); 47 if (!Number.isInteger(N) || !Number.isInteger(r) || !Number.isInteger(p)) return false; 48 49 let salt; 50 let expected; 51 try { 52 salt = Buffer.from(parts[4], 'base64'); 53 expected = Buffer.from(parts[5], 'base64'); 54 } catch { 55 return false; 56 } 57 if (salt.length === 0 || expected.length === 0) return false; 58 59 let derived; 60 try { 61 derived = await scrypt(password, salt, expected.length, { N, r, p, maxmem: 256 * N * r }); 62 } catch { 63 return false; 64 } 65 66 return crypto.timingSafeEqual(derived, expected); 67 } 68 69 // Used when generating a bootstrap password, which is shown once and then 70 // only ever stored as a hash. 71 export function generatePassword(bytes = 18) { 72 return crypto.randomBytes(bytes).toString('base64url'); 73 }