import crypto from 'node:crypto'; /* ------------------------------------------------------------ passwords */ // scrypt is built into Node, so there is no native module to compile in the image. export function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(String(password), salt, 64, { N: 16384, r: 8, p: 1 }); return `scrypt$${salt.toString('base64')}$${hash.toString('base64')}`; } export function verifyPassword(password, stored) { try { const [scheme, saltB64, hashB64] = String(stored).split('$'); if (scheme !== 'scrypt') return false; const expected = Buffer.from(hashB64, 'base64'); const actual = crypto.scryptSync(String(password), Buffer.from(saltB64, 'base64'), expected.length, { N: 16384, r: 8, p: 1, }); return crypto.timingSafeEqual(expected, actual); } catch { return false; } } export function passwordProblem(password) { const value = String(password || ''); if (value.length < 12) return 'Use at least 12 characters.'; if (!/[a-z]/.test(value) || !/[A-Z]/.test(value)) return 'Mix upper and lower case.'; if (!/\d/.test(value)) return 'Include at least one number.'; return null; } export function randomPassword() { // Readable enough to hand over verbally, still 60+ bits of entropy. const words = crypto.randomBytes(9).toString('base64url').replace(/[-_]/g, ''); return `Vs${words}9`; } /* ----------------------------------------------------------- base32 */ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; export function base32Encode(buffer) { let bits = 0; let value = 0; let output = ''; for (const byte of buffer) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { output += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31]; return output; } export function base32Decode(input) { const clean = String(input).toUpperCase().replace(/[^A-Z2-7]/g, ''); let bits = 0; let value = 0; const bytes = []; for (const char of clean) { value = (value << 5) | ALPHABET.indexOf(char); bits += 5; if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(bytes); } /* -------------------------------------------------------------- TOTP */ export function generateTotpSecret() { return base32Encode(crypto.randomBytes(20)); } function hotp(secretBuffer, counter) { const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(counter)); const digest = crypto.createHmac('sha1', secretBuffer).update(buf).digest(); const offset = digest[digest.length - 1] & 0x0f; const code = ((digest[offset] & 0x7f) << 24) | ((digest[offset + 1] & 0xff) << 16) | ((digest[offset + 2] & 0xff) << 8) | (digest[offset + 3] & 0xff); return String(code % 1_000_000).padStart(6, '0'); } export function totpCode(secret, atMs = Date.now(), stepSeconds = 30) { return hotp(base32Decode(secret), Math.floor(atMs / 1000 / stepSeconds)); } /** Allows one step either side, which covers a phone clock that has drifted a little. */ export function verifyTotp(secret, token, window = 1) { const candidate = String(token || '').replace(/\D/g, ''); if (candidate.length !== 6) return false; const counter = Math.floor(Date.now() / 1000 / 30); const buffer = base32Decode(secret); for (let drift = -window; drift <= window; drift += 1) { const expected = hotp(buffer, counter + drift); if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(candidate))) return true; } return false; } export function otpauthUrl({ secret, email, issuer }) { const label = encodeURIComponent(`${issuer}:${email}`); const params = new URLSearchParams({ secret, issuer, algorithm: 'SHA1', digits: '6', period: '30' }); return `otpauth://totp/${label}?${params}`; } /* --------------------------------------------------- recovery codes */ export function generateRecoveryCodes(count = 8) { return Array.from({ length: count }, () => crypto.randomBytes(5).toString('hex').replace(/(.{5})(.{5})/, '$1-$2') ); } const digest = (code) => crypto.createHash('sha256').update(String(code).toLowerCase().replace(/[^a-z0-9]/g, '')).digest('hex'); export function hashRecoveryCodes(codes) { return JSON.stringify(codes.map(digest)); } /** Returns the remaining codes if one matched, or null. Used codes are burnt. */ export function consumeRecoveryCode(storedJson, candidate) { try { const hashes = JSON.parse(storedJson || '[]'); const target = digest(candidate); const index = hashes.indexOf(target); if (index === -1) return null; hashes.splice(index, 1); return JSON.stringify(hashes); } catch { return null; } }