fix: harden tenant auth and quality gates
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } from 'node:crypto';
|
||||
const VERSION = 1;
|
||||
const KEY_LENGTH = 32;
|
||||
const DEFAULT_N = 32768;
|
||||
const DEFAULT_R = 8;
|
||||
const DEFAULT_P = 1;
|
||||
const MAX_MEMORY = 64 * 1024 * 1024;
|
||||
const LEGACY_SHA256 = /^[a-f0-9]{64}$/i;
|
||||
|
||||
export async function hashPassword(password: string) {
|
||||
const salt = randomBytes(16);
|
||||
const derived = await deriveScrypt(password, salt, KEY_LENGTH, {
|
||||
N: DEFAULT_N,
|
||||
r: DEFAULT_R,
|
||||
p: DEFAULT_P,
|
||||
maxmem: MAX_MEMORY,
|
||||
});
|
||||
return `$scrypt$v=${VERSION}$N=${DEFAULT_N},r=${DEFAULT_R},p=${DEFAULT_P}$${salt.toString('base64url')}$${derived.toString('base64url')}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, encoded: string) {
|
||||
if (isLegacySha256(encoded)) {
|
||||
const candidate = Buffer.from(legacyHashPassword(password), 'hex');
|
||||
const expected = Buffer.from(encoded, 'hex');
|
||||
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
|
||||
}
|
||||
|
||||
const parsed = parseScryptHash(encoded);
|
||||
if (!parsed) return false;
|
||||
const derived = await deriveScrypt(password, parsed.salt, parsed.hash.length, {
|
||||
N: parsed.N,
|
||||
r: parsed.r,
|
||||
p: parsed.p,
|
||||
maxmem: MAX_MEMORY,
|
||||
});
|
||||
return derived.length === parsed.hash.length && timingSafeEqual(derived, parsed.hash);
|
||||
}
|
||||
|
||||
export function passwordNeedsRehash(encoded: string) {
|
||||
if (isLegacySha256(encoded)) return true;
|
||||
const parsed = parseScryptHash(encoded);
|
||||
return !parsed || parsed.version !== VERSION || parsed.N !== DEFAULT_N || parsed.r !== DEFAULT_R || parsed.p !== DEFAULT_P;
|
||||
}
|
||||
|
||||
export function isLegacySha256(encoded: string) {
|
||||
return LEGACY_SHA256.test(encoded);
|
||||
}
|
||||
|
||||
export function legacyHashPassword(password: string) {
|
||||
return createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
function parseScryptHash(encoded: string) {
|
||||
const match = /^\$scrypt\$v=(\d+)\$N=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9_-]+)\$([A-Za-z0-9_-]+)$/.exec(encoded);
|
||||
if (!match) return undefined;
|
||||
const [, version, N, r, p, salt, hash] = match;
|
||||
const params = { version: Number(version), N: Number(N), r: Number(r), p: Number(p) };
|
||||
if (!Number.isInteger(params.N) || params.N < 2 || params.N > DEFAULT_N
|
||||
|| !Number.isInteger(params.r) || params.r < 1 || params.r > DEFAULT_R
|
||||
|| !Number.isInteger(params.p) || params.p < 1 || params.p > DEFAULT_P) return undefined;
|
||||
try {
|
||||
const decodedSalt = Buffer.from(salt, 'base64url');
|
||||
const decodedHash = Buffer.from(hash, 'base64url');
|
||||
if (decodedSalt.length < 16 || decodedHash.length !== KEY_LENGTH) return undefined;
|
||||
return { ...params, salt: decodedSalt, hash: decodedHash };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveScrypt(password: string, salt: Buffer, keyLength: number, options: { N: number; r: number; p: number; maxmem: number }) {
|
||||
return new Promise<Buffer>((resolve, reject) => {
|
||||
scryptCallback(password, salt, keyLength, options, (error, derivedKey) => {
|
||||
if (error) reject(error);
|
||||
else resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user