File size: 2,882 Bytes
ef84ace
 
 
 
 
 
 
1c7b998
 
 
 
 
 
 
 
e830ad4
 
 
1c7b998
 
 
e830ad4
 
 
 
 
 
 
 
1c7b998
 
e830ad4
1c7b998
 
 
 
 
 
 
 
 
 
 
 
ef84ace
1c7b998
 
 
 
 
 
ef84ace
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1c7b998
ef84ace
 
 
 
 
e830ad4
 
 
ef84ace
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
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;
	}
}