Spaces:
Sleeping
Sleeping
assafvayner HF Staff
fix(app): 503 on storage failures, validated currency, lock release on shutdown, per-IP login limit, unit-only npm test
e830ad4 | 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; | |
| } | |
| } | |