conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 }