Queue password checks and shorten busy retries (Refs #95)
CI / checks (push) Successful in 4m36s

This commit is contained in:
2026-10-09 16:34:32 -04:00
parent 5cf284f413
commit 4059fc30d6
5 changed files with 270 additions and 20 deletions
+10 -2
View File
@@ -1,5 +1,6 @@
import { error, isHttpError, json, type Cookies } from '@sveltejs/kit';
import { AuthError, SESSION_COOKIE, type AuthResult } from './index.js';
import { PasswordServiceBusyError } from './passwords.js';
import type { AuthUser } from '../../auth.js';
export function sessionCookie(url: URL): string {
@@ -85,7 +86,7 @@ export function authError(cause: unknown): Response {
const status =
cause instanceof AuthError
? cause.status
: message.includes('busy')
: cause instanceof PasswordServiceBusyError
? 429
: message.startsWith('Use a password')
? 400
@@ -96,7 +97,14 @@ export function authError(cause: unknown): Response {
status,
headers: {
'Cache-Control': 'no-store',
...(status === 429 ? { 'Retry-After': '900' } : {})
...(status === 429
? {
'Retry-After':
cause instanceof PasswordServiceBusyError
? String(cause.retryAfterSeconds)
: '900'
}
: {})
}
}
);
+55 -10
View File
@@ -3,7 +3,51 @@ 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) {
@@ -14,17 +58,18 @@ export function validatePassword(password) {
}
/** @param {string} password @param {string} salt @returns {Promise<Buffer>} */
function derive(password, salt) {
if (active >= 2)
return Promise.reject(new Error('Password service is busy. Try again shortly.'));
active++;
return new Promise((resolve, reject) => {
scrypt(password, salt, 64, parameters, (error, result) => {
active--;
if (error) reject(error);
else resolve(result);
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 */