158 lines
5.4 KiB
TypeScript
158 lines
5.4 KiB
TypeScript
import { argon2, timingSafeEqual } from "node:crypto";
|
|
|
|
const MAXIMUM_PHC_BYTES = 256;
|
|
const ARGON2_MEMORY_KIB = 65_536;
|
|
const ARGON2_PASSES = 3;
|
|
const ARGON2_PARALLELISM = 1;
|
|
const ARGON2_SALT_BYTES = 16;
|
|
const ARGON2_KEY_BYTES = 32;
|
|
const MINIMUM_PASSWORD_BYTES = 12;
|
|
const MAXIMUM_PASSWORD_BYTES = 1024;
|
|
|
|
interface Argon2Parameters {
|
|
memory: number;
|
|
passes: number;
|
|
parallelism: number;
|
|
salt: Buffer;
|
|
digest: Buffer;
|
|
}
|
|
|
|
/** Internal-only signal for unavailable native Argon2 work; never expose its cause. */
|
|
export class LocalPasswordVerificationError extends Error {
|
|
constructor() {
|
|
super("local_password_verification_failed");
|
|
}
|
|
}
|
|
|
|
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) {
|
|
decoded.fill(0);
|
|
return undefined;
|
|
}
|
|
if (decoded.toString("base64").replace(/=+$/, "") !== value) {
|
|
decoded.fill(0);
|
|
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), ARGON2_MEMORY_KIB);
|
|
const passes = parseDecimal(parameterParts[1].slice(2), ARGON2_PASSES);
|
|
const parallelism = parseDecimal(parameterParts[2].slice(2), ARGON2_PARALLELISM);
|
|
if (memory !== ARGON2_MEMORY_KIB || passes !== ARGON2_PASSES || parallelism !== ARGON2_PARALLELISM) return undefined;
|
|
|
|
const salt = decodeRawBase64(parts[4], ARGON2_SALT_BYTES, ARGON2_SALT_BYTES);
|
|
const digest = decodeRawBase64(parts[5], ARGON2_KEY_BYTES, ARGON2_KEY_BYTES);
|
|
if (!salt || !digest) {
|
|
salt?.fill(0);
|
|
digest?.fill(0);
|
|
return undefined;
|
|
}
|
|
return { memory, passes, parallelism, salt, digest };
|
|
}
|
|
|
|
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 false;
|
|
} else if (/[\uD800-\uDFFF]/.test(password)) {
|
|
return false;
|
|
}
|
|
const byteLength = Buffer.byteLength(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) {
|
|
derived?.fill(0);
|
|
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 {
|
|
const parameters = parsePHC(encoded);
|
|
if (!parameters) return false;
|
|
clearParameters(parameters);
|
|
return true;
|
|
}
|
|
|
|
export async function verifyPassword(password: string, encoded: string): Promise<boolean> {
|
|
const parameters = parsePHC(encoded);
|
|
const message = passwordBytes(password);
|
|
if (!message || !parameters) {
|
|
message?.fill(0);
|
|
if (parameters) clearParameters(parameters);
|
|
return false;
|
|
}
|
|
let derived: Buffer | undefined;
|
|
try {
|
|
derived = await deriveArgon2(message, parameters);
|
|
return derived.length === parameters.digest.length && timingSafeEqual(derived, parameters.digest);
|
|
} catch {
|
|
throw new LocalPasswordVerificationError();
|
|
} finally {
|
|
message.fill(0);
|
|
derived?.fill(0);
|
|
clearParameters(parameters);
|
|
}
|
|
}
|
|
|
|
const DUMMY_PASSWORD = "thothii-process-local-dummy-password";
|
|
const DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=1$ABEiM0RVZneImaq7zN3u/w$/YuK14HV5biXcVIYqaJs07meu0nRgo+d62KnM02MSoU";
|
|
|
|
export async function verifyWithDummy(password: string): Promise<void> {
|
|
await verifyPassword(hasValidPasswordBytes(password) ? password : DUMMY_PASSWORD, DUMMY_HASH);
|
|
}
|