1
0
forked from Yara724/api
Files
yara724-api/src/features/auth/providers/hash.provider.ts
SepehrYahyaee 4fabed77e5 YARA-1035
2026-06-20 14:51:01 +03:30

168 lines
4.3 KiB
TypeScript

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<string>("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<boolean> {
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<boolean> {
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<PasswordHash> {
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<boolean> {
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<string> {
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<boolean> {
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;
}
}
}