splitwise / src /lib /server /auth /password.ts
assafvayner's picture
assafvayner HF Staff
fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test
e830ad4
Raw
History Blame Contribute Delete
2.88 kB
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
const N = 32768;
const R = 8;
const P = 1;
const KEYLEN = 32;
// Upper bounds on scrypt params accepted from a stored hash. A crafted users.json must not
// be able to turn verification into an unbounded CPU sink.
const MAX_N = 65536;
const MAX_R = 16;
const MAX_P = 4;
// Cap concurrent scrypt calls so a burst of logins can't exhaust CPU/memory; extra callers queue.
const MAX_CONCURRENT_SCRYPT = 4;
// …and cap the queue itself, so a flood parks an unbounded number of requests instead of
// being shed. Callers past the cap fail fast rather than waiting minutes for a slot.
const MAX_QUEUED_SCRYPT = 64;
let activeScryptCalls = 0;
const scryptWaitQueue: Array<() => void> = [];
/** Thrown when the scrypt wait queue is full; callers surface it as a 503, not a bad password. */
export class ScryptOverloadError extends Error {
constructor() {
super('Too many concurrent login attempts');
this.name = 'ScryptOverloadError';
}
}
async function withScryptSlot<T>(fn: () => Promise<T>): Promise<T> {
if (activeScryptCalls >= MAX_CONCURRENT_SCRYPT) {
if (scryptWaitQueue.length >= MAX_QUEUED_SCRYPT) throw new ScryptOverloadError();
await new Promise<void>((resolve) => scryptWaitQueue.push(resolve));
}
activeScryptCalls++;
try {
return await fn();
} finally {
activeScryptCalls--;
const next = scryptWaitQueue.shift();
if (next) next();
}
}
function scryptAsync(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
return withScryptSlot(
() =>
new Promise((resolve, reject) => {
scrypt(password, salt, KEYLEN, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => (err ? reject(err) : resolve(key)));
})
);
}
export const MIN_PASSWORD_LENGTH = 8;
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16);
const key = await scryptAsync(password, salt, N, R, P);
return ['scrypt', N, R, P, salt.toString('base64'), key.toString('base64')].join('$');
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split('$');
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
const n = Number(parts[1]);
const r = Number(parts[2]);
const p = Number(parts[3]);
if (![n, r, p].every((x) => Number.isInteger(x) && x > 0)) return false;
if (n > MAX_N || r > MAX_R || p > MAX_P) return false;
try {
const salt = Buffer.from(parts[4], 'base64');
const expected = Buffer.from(parts[5], 'base64');
const actual = await scryptAsync(password, salt, n, r, p);
return expected.length === actual.length && timingSafeEqual(expected, actual);
} catch (e) {
// Overload is a server condition, not a wrong password: let the caller turn it into a 503.
if (e instanceof ScryptOverloadError) throw e;
return false;
}
}