// Password hashing using Node's built-in scrypt + a per-hash random salt. // // No external dependency (bcrypt would need a native build step). scrypt is // memory-hard and well suited for interactive logins. Hash format: // ":" (salt is 16 bytes, hash is 64 bytes) // // verify() is async (crypto.scrypt, non-blocking) so a login attempt cannot pin // the event loop and stall every other request — a synchronous scrypt was a // cheap CPU-DoS vector. DUMMY_HASH is a real hash for the "unknown user" path so // login timing does not leak which usernames exist. const crypto = require('crypto'); const KEYLEN = 64; const DUMMY_SALT = '00000000000000000000000000000000'; const DUMMY_HASH = `${DUMMY_SALT}:${crypto.scryptSync('nextjobs-dummy', DUMMY_SALT, KEYLEN).toString('hex')}`; function hash(password) { const salt = crypto.randomBytes(16).toString('hex'); const out = crypto.scryptSync(password, salt, KEYLEN).toString('hex'); return `${salt}:${out}`; } // Resolve to true/false. Always runs a full scrypt (constant work) before // deciding, so the caller cannot tell a wrong password from a bad hash format. function verify(password, stored) { return new Promise((resolve) => { if (typeof stored !== 'string' || !stored.includes(':')) return resolve(false); const idx = stored.indexOf(':'); const salt = stored.slice(0, idx); const expected = stored.slice(idx + 1); if (!salt || !expected) return resolve(false); crypto.scrypt(String(password == null ? '' : password), salt, KEYLEN, (err, buf) => { if (err) return resolve(false); const computed = buf.toString('hex'); if (computed.length !== expected.length) return resolve(false); // Constant-time compare to avoid timing side channels. resolve(crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expected))); }); }); } module.exports = { hash, verify, DUMMY_HASH };