import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
const N = 16384;
const R = 8;
const P = 1;
const KEY_BYTES = 32;
const SALT_BYTES = 16;
const MAX_MEMORY = 64 * 1024 * 1024;
function derive(password: string, salt: Buffer, n: number, r: number, p: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password.normalize("NFKC"), salt, KEY_BYTES, { N: n, r, p, maxmem: MAX_MEMORY }, (error, key) =>
error ? reject(error) : resolve(key),
);
});
}
/** `scrypt$N$r$p$salt$hash`, base64url fields. */
export async function hashSitePassword(password: string): Promise<string> {
const salt = randomBytes(SALT_BYTES);
const hash = await derive(password, salt, N, R, P);
return `scrypt$${N}$${R}$${P}$${salt.toString("base64url")}$${hash.toString("base64url")}`;
}
export async function verifySitePassword(password: string, stored: string): Promise<boolean> {
const [scheme, n, r, p, salt, hash] = stored.split("$");
if (scheme !== "scrypt" || !salt || !hash) return false;
const cost = Number(n);
if (cost !== N || Number(r) !== R || Number(p) !== P) return false;
const expected = Buffer.from(hash, "base64url");
const actual = await derive(password, Buffer.from(salt, "base64url"), cost, R, P);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}