Files
electric-cherry/src/lib/server/auth/passwords.js
T
2026-10-09 16:34:32 -04:00

101 lines
3.3 KiB
JavaScript

import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
// OWASP's scrypt baseline: 128 MiB, r=8, p=1. Bound concurrent work so
// unauthenticated password attempts cannot allocate unlimited memory.
const parameters = { N: 131072, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
const maxActive = 2;
const maxQueued = 16;
const queueTimeoutMs = 2000;
let active = 0;
/** @type {Array<{resolve: () => void, timer: ReturnType<typeof setTimeout>}>} */
const queue = [];
export class PasswordServiceBusyError extends Error {
constructor() {
super('Password service is busy. Try again shortly.');
this.name = 'PasswordServiceBusyError';
this.retryAfterSeconds = 2;
}
}
/** @returns {Promise<void>} */
function acquireSlot() {
if (active < maxActive) {
active++;
return Promise.resolve();
}
// Bound waiting requests as well as running hashes during sustained overload.
if (queue.length >= maxQueued) return Promise.reject(new PasswordServiceBusyError());
return new Promise((resolve, reject) => {
const entry = {
resolve,
timer: setTimeout(() => {
queue.splice(queue.indexOf(entry), 1);
reject(new PasswordServiceBusyError());
}, queueTimeoutMs)
};
queue.push(entry);
});
}
function releaseSlot() {
const next = queue.shift();
if (next) {
clearTimeout(next.timer);
// Transfer the occupied slot directly so new requests cannot jump the queue.
next.resolve();
} else {
active--;
}
}
/** @param {unknown} password */
export function validatePassword(password) {
if (typeof password !== 'string' || [...password].length < 8 || [...password].length > 128) {
throw new Error('Use a password or passphrase between 8 and 128 characters.');
}
return password;
}
/** @param {string} password @param {string} salt @returns {Promise<Buffer>} */
async function derive(password, salt) {
await acquireSlot();
try {
return await new Promise((resolve, reject) => {
scrypt(password, salt, 64, parameters, (error, result) => {
if (error) reject(error);
else resolve(result);
});
});
} finally {
releaseSlot();
}
}
/** @param {unknown} password */
export async function hashPassword(password) {
const validated = validatePassword(password);
const salt = randomBytes(32).toString('hex');
const key = await derive(validated, salt);
return `scrypt$131072$8$1$${salt}$${key.toString('hex')}`;
}
/** @param {unknown} password @param {string | null} encoded */
export async function verifyPassword(password, encoded) {
if (typeof password !== 'string' || [...password].length > 128) return false;
const parts = encoded?.split('$');
const valid =
parts?.length === 6 &&
parts[0] === 'scrypt' &&
parts[1] === '131072' &&
parts[2] === '8' &&
parts[3] === '1' &&
/^[a-f0-9]{64}$/.test(parts[4]) &&
/^[a-f0-9]{128}$/.test(parts[5]);
// Missing accounts perform the same expensive operation as real accounts.
const result = await derive(password, valid ? parts[4] : '0'.repeat(64));
return (
timingSafeEqual(result, Buffer.from(valid ? parts[5] : '0'.repeat(128), 'hex')) && !!valid
);
}