feat(auth): verify local ThothII users in the backend
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
import { argon2Sync, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
const MAXIMUM_PHC_BYTES = 256;
|
||||
const MAXIMUM_MEMORY_KIB = 256 * 1024;
|
||||
const MAXIMUM_PASSES = 10;
|
||||
const MAXIMUM_PARALLELISM = 4;
|
||||
const MINIMUM_SALT_BYTES = 16;
|
||||
const MAXIMUM_SALT_BYTES = 64;
|
||||
const MINIMUM_KEY_BYTES = 16;
|
||||
const MAXIMUM_KEY_BYTES = 64;
|
||||
const MINIMUM_PASSWORD_BYTES = 12;
|
||||
const MAXIMUM_PASSWORD_BYTES = 1024;
|
||||
|
||||
interface Argon2Parameters {
|
||||
memory: number;
|
||||
passes: number;
|
||||
parallelism: number;
|
||||
salt: Buffer;
|
||||
digest: Buffer;
|
||||
}
|
||||
|
||||
function parseDecimal(value: string, maximum: number): number | undefined {
|
||||
if (!/^\d+$/.test(value) || (value.length > 1 && value[0] === "0") || value.length > 10) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed <= maximum ? parsed : 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;
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function parsePHC(encoded: string): Argon2Parameters | undefined {
|
||||
if (typeof encoded !== "string" || encoded.length === 0 || Buffer.byteLength(encoded, "utf8") > MAXIMUM_PHC_BYTES) return undefined;
|
||||
const parts = encoded.split("$");
|
||||
if (parts.length !== 6 || parts[0] !== "" || parts[1] !== "argon2id" || parts[2] !== "v=19") return undefined;
|
||||
|
||||
const parameterParts = parts[3].split(",");
|
||||
if (parameterParts.length !== 3 || !parameterParts[0].startsWith("m=") || !parameterParts[1].startsWith("t=") || !parameterParts[2].startsWith("p=")) return undefined;
|
||||
const memory = parseDecimal(parameterParts[0].slice(2), MAXIMUM_MEMORY_KIB);
|
||||
const passes = parseDecimal(parameterParts[1].slice(2), MAXIMUM_PASSES);
|
||||
const parallelism = parseDecimal(parameterParts[2].slice(2), MAXIMUM_PARALLELISM);
|
||||
if (memory === undefined || memory < 8 || passes === undefined || passes === 0 || parallelism === undefined || parallelism === 0 || memory < 8 * parallelism) return 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;
|
||||
return { memory, passes, parallelism, salt, digest };
|
||||
}
|
||||
|
||||
function passwordBytes(password: string): Buffer | undefined {
|
||||
if (typeof password !== "string") return undefined;
|
||||
const bytes = Buffer.from(password, "utf8");
|
||||
return bytes.length >= MINIMUM_PASSWORD_BYTES && bytes.length <= MAXIMUM_PASSWORD_BYTES ? bytes : undefined;
|
||||
}
|
||||
|
||||
export function isValidPasswordHash(encoded: string): boolean {
|
||||
return parsePHC(encoded) !== undefined;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, encoded: string): boolean {
|
||||
const parameters = parsePHC(encoded);
|
||||
const message = passwordBytes(password);
|
||||
if (!message || !parameters) return false;
|
||||
try {
|
||||
const derived = argon2Sync("argon2id", {
|
||||
message,
|
||||
nonce: parameters.salt,
|
||||
memory: parameters.memory,
|
||||
passes: parameters.passes,
|
||||
parallelism: parameters.parallelism,
|
||||
tagLength: parameters.digest.length,
|
||||
});
|
||||
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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(/=+$/, "")}`;
|
||||
|
||||
export function verifyWithDummy(password: string): void {
|
||||
verifyPassword(passwordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
|
||||
}
|
||||
Reference in New Issue
Block a user