fix(auth): harden async login boundaries

This commit is contained in:
2026-08-17 00:00:34 +02:00
parent 29bfb41363
commit c34e01e9b9
8 changed files with 426 additions and 124 deletions
+80 -33
View File
@@ -1,4 +1,4 @@
import { argon2Sync, randomBytes, timingSafeEqual } from "node:crypto";
import { argon2, timingSafeEqual } from "node:crypto";
const MAXIMUM_PHC_BYTES = 256;
const MAXIMUM_MEMORY_KIB = 256 * 1024;
@@ -28,8 +28,14 @@ function parseDecimal(value: string, maximum: number): number | undefined {
function decodeRawBase64(value: string, minimum: number, maximum: number): Buffer | undefined {
if (!/^[A-Za-z0-9+/]+$/.test(value)) return undefined;
const decoded = Buffer.from(value, "base64");
if (decoded.length < minimum || decoded.length > maximum) return undefined;
if (decoded.toString("base64").replace(/=+$/, "") !== value) return undefined;
if (decoded.length < minimum || decoded.length > maximum) {
decoded.fill(0);
return undefined;
}
if (decoded.toString("base64").replace(/=+$/, "") !== value) {
decoded.fill(0);
return undefined;
}
return decoded;
}
@@ -47,58 +53,99 @@ function parsePHC(encoded: string): Argon2Parameters | undefined {
const salt = decodeRawBase64(parts[4], MINIMUM_SALT_BYTES, MAXIMUM_SALT_BYTES);
const digest = decodeRawBase64(parts[5], MINIMUM_KEY_BYTES, MAXIMUM_KEY_BYTES);
if (!salt || !digest) return undefined;
if (!salt || !digest) {
salt?.fill(0);
digest?.fill(0);
return undefined;
}
return { memory, passes, parallelism, salt, digest };
}
function passwordBytes(password: string): Buffer | undefined {
if (typeof password !== "string") return undefined;
function hasValidPasswordBytes(password: string): boolean {
if (typeof password !== "string") return false;
const typedPassword = password as string & { isWellFormed?: () => boolean };
if (typeof typedPassword.isWellFormed === "function") {
if (!typedPassword.isWellFormed()) return undefined;
if (!typedPassword.isWellFormed()) return false;
} else if (/[\uD800-\uDFFF]/.test(password)) {
return undefined;
return false;
}
const byteLength = Buffer.byteLength(password, "utf8");
if (byteLength < MINIMUM_PASSWORD_BYTES || byteLength > MAXIMUM_PASSWORD_BYTES) return undefined;
return Buffer.from(password, "utf8");
return byteLength >= MINIMUM_PASSWORD_BYTES && byteLength <= MAXIMUM_PASSWORD_BYTES;
}
function passwordBytes(password: string): Buffer | undefined {
return hasValidPasswordBytes(password) ? Buffer.from(password, "utf8") : undefined;
}
function clearParameters(parameters: Argon2Parameters): void {
parameters.salt.fill(0);
parameters.digest.fill(0);
}
function deriveArgon2(message: Buffer, parameters: Argon2Parameters): Promise<Buffer> {
return new Promise<Buffer>((resolve, reject) => {
let settled = false;
const complete = (error: Error | null, derived?: Buffer): void => {
if (settled) {
derived?.fill(0);
return;
}
settled = true;
if (error || !derived) {
reject(error ?? new Error("argon2_failed"));
return;
}
resolve(derived);
};
try {
argon2("argon2id", {
message,
nonce: parameters.salt,
memory: parameters.memory,
passes: parameters.passes,
parallelism: parameters.parallelism,
tagLength: parameters.digest.length,
}, complete);
} catch (error) {
if (!settled) {
settled = true;
reject(error);
}
}
});
}
export function isValidPasswordHash(encoded: string): boolean {
return parsePHC(encoded) !== undefined;
const parameters = parsePHC(encoded);
if (!parameters) return false;
clearParameters(parameters);
return true;
}
export function verifyPassword(password: string, encoded: string): boolean {
export async function verifyPassword(password: string, encoded: string): Promise<boolean> {
const parameters = parsePHC(encoded);
const message = passwordBytes(password);
if (!message || !parameters) return false;
if (!message || !parameters) {
message?.fill(0);
if (parameters) clearParameters(parameters);
return false;
}
let derived: Buffer | undefined;
try {
const derived = argon2Sync("argon2id", {
message,
nonce: parameters.salt,
memory: parameters.memory,
passes: parameters.passes,
parallelism: parameters.parallelism,
tagLength: parameters.digest.length,
});
derived = await deriveArgon2(message, parameters);
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
} catch {
return false;
} finally {
message.fill(0);
derived?.fill(0);
clearParameters(parameters);
}
}
const DUMMY_PASSWORD = "thothii-process-local-dummy-password";
const DUMMY_SALT = randomBytes(MINIMUM_SALT_BYTES);
const DUMMY_DIGEST = argon2Sync("argon2id", {
message: Buffer.from(DUMMY_PASSWORD, "utf8"),
nonce: DUMMY_SALT,
memory: 65536,
passes: 3,
parallelism: 1,
tagLength: 32,
});
const DUMMY_HASH = `$argon2id$v=19$m=65536,t=3,p=1$${DUMMY_SALT.toString("base64").replace(/=+$/, "")}$${DUMMY_DIGEST.toString("base64").replace(/=+$/, "")}`;
const DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=1$ABEiM0RVZneImaq7zN3u/w$/YuK14HV5biXcVIYqaJs07meu0nRgo+d62KnM02MSoU";
export function verifyWithDummy(password: string): void {
verifyPassword(passwordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
export async function verifyWithDummy(password: string): Promise<void> {
await verifyPassword(hasValidPasswordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
}