forked from Yara724/api
168 lines
4.3 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|