import { BinaryLike, randomBytes, scrypt, ScryptOptions, timingSafeEqual, } from "node:crypto"; import { promisify } from "node:util"; import { Injectable } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { CURRENT_ALGORITHM, CURRENT_VERSION, KEY_LENGTH, KEY_LENGTH_FOR_OTP, SCRYPT_PARAMS, SCRYPT_PARAMS_FOR_OTP, } from "src/common/auth/constants"; import { PasswordHash } from "src/common/auth/types"; const scryptAsync = promisify< BinaryLike, BinaryLike, number, ScryptOptions, Buffer >(scrypt); @Injectable() export class HashService { private readonly pepper: string; private readonly current_alogrithm: string; private readonly current_version: number; constructor(private readonly configService: ConfigService) { this.pepper = this.configService.get("HASH_PEPPER") ?? ""; this.current_alogrithm = CURRENT_ALGORITHM; this.current_version = CURRENT_VERSION; } private applyPepper(input: string): string { return this.pepper ? `${input}${this.pepper}` : input; } private generateSalt(bytes: number = 16): string { return randomBytes(bytes).toString("hex"); } private isLegacyPasswordFormat(stored: unknown): stored is string { return typeof stored === "string" && stored.includes(":"); } private isVersionedPasswordFormat(stored: unknown): stored is PasswordHash { return ( typeof stored === "object" && stored !== null && "hash" in stored && "salt" in stored && "algorithm" in stored && "version" in stored ); } private async scrypt(input: string): Promise<[string, string]> { const salt = this.generateSalt(); const pepperedInput = this.applyPepper(input); const derived = await scryptAsync( pepperedInput, salt, KEY_LENGTH, SCRYPT_PARAMS, ); return [salt, derived.toString("hex")]; } private async verifyScrypt( password: string, salt: string, hashHex: string, ): Promise { try { const pepperedInput = this.applyPepper(password); const derived = await scryptAsync( pepperedInput, salt, KEY_LENGTH, SCRYPT_PARAMS, ); const storedBuffer = Buffer.from(hashHex, "hex"); if (derived.length !== storedBuffer.length) return false; return timingSafeEqual(derived, storedBuffer); } catch { return false; } } async needsRehash(stored: string | PasswordHash): Promise { if (this.isLegacyPasswordFormat(stored)) return true; if (!this.isVersionedPasswordFormat(stored)) return true; return ( stored.algorithm !== this.current_alogrithm || stored.version !== this.current_version ); } async hashPassword(password: string): Promise { const [salt, hash] = await this.scrypt(password); return { hash, salt, algorithm: CURRENT_ALGORITHM, version: CURRENT_VERSION, params: { ...SCRYPT_PARAMS, keyLength: KEY_LENGTH }, createdAt: new Date(), }; } async verifyPassword( password: string, stored: string | PasswordHash, ): Promise { if (this.isLegacyPasswordFormat(stored)) { const [salt, hash] = stored.split(":"); return this.verifyScrypt(password, salt, hash); } if (!this.isVersionedPasswordFormat(stored)) return false; switch (stored.algorithm) { case "scrypt": return this.verifyScrypt(password, stored.salt, stored.hash); default: return false; } } async hashOtp(otp: string): Promise { const salt = this.generateSalt(8); const derived = await scryptAsync( otp, salt, KEY_LENGTH_FOR_OTP, SCRYPT_PARAMS_FOR_OTP, ); return `${salt}:${derived.toString("hex")}`; } async verifyOtp(otp: string, stored: string): Promise { const [salt, hashHex] = stored.split(":"); if (!salt || !hashHex) return false; try { const derived = await scryptAsync( otp, salt, KEY_LENGTH_FOR_OTP, SCRYPT_PARAMS_FOR_OTP, ); const storedBuffer = Buffer.from(hashHex, "hex"); if (derived.length !== storedBuffer.length) return false; return timingSafeEqual(derived, storedBuffer); } catch { return false; } } }