1
19
20import { pbkdf2Sync, randomBytes, createHash, timingSafeEqual } from "node:crypto";
21import { LIMITS } from "@factory/shared/constants";
22
23const DEFAULT_COST = 12;
24const SALT_BYTES = 16;
25const KEY_BYTES = 32;
26
27
29export interface PasswordPolicyResult {
30 ok: boolean;
31 errors: string[];
32}
33
34export function passwordPolicy(plain: string): PasswordPolicyResult {
35 const errors: string[] = [];
36 if (plain.length < LIMITS.MIN_PASSWORD) errors.push("Password must be at least 10 characters");
37 if (plain.length > LIMITS.MAX_PASSWORD) errors.push(`Password must be at most ${LIMITS.MAX_PASSWORD} characters`);
38 if (!/[A-Za-z]/.test(plain)) errors.push("Password must contain at least one letter");
39 if (!/[0-9]/.test(plain)) errors.push("Password must contain at least one number");
40 return { ok: errors.length === 0, errors };
41}
42
43
47export interface PasswordHasher {
48 hash(plain: string, cost?: number): Promise<string>;
49 verify(plain: string, stored: string): Promise<boolean>;
50 readonly algorithm: string;
51}
52
53class Pbkdf2Hasher implements PasswordHasher {
54 readonly algorithm = "pbkdf2-sha512";
55
56 async hash(plain: string, cost: number = DEFAULT_COST): Promise<string> {
57 const salt = randomBytes(SALT_BYTES);
58 const key = pbkdf2Sync(plain, salt, cost, KEY_BYTES, "sha512");
59 return `$factory$${this.algorithm}$${cost}$${salt.toString("base64")}$${key.toString("base64")}`;
60 }
61
62 async verify(plain: string, stored: string): Promise<boolean> {
63 const parts = stored.split("$");
64
65 if (parts.length !== 6 || parts[1] !== "factory" || parts[2] !== this.algorithm) {
66
67 if (globalThis.__factoryBcryptVerifier) {
68 return globalThis.__factoryBcryptVerifier(plain, stored);
69 }
70 return false;
71 }
72 const cost = Number(parts[3]);
73 const salt = Buffer.from(parts[4]!, "base64");
74 const expected = Buffer.from(parts[5]!, "base64");
75 if (!Number.isInteger(cost) || cost < 1 || cost > 20 || salt.length !== SALT_BYTES || expected.length !== KEY_BYTES) {
76 return false;
77 }
78 const actual = pbkdf2Sync(plain, salt, cost, KEY_BYTES, "sha512");
79 return actual.length === expected.length && timingSafeEqual(actual, expected);
80 }
81}
82
83declare global {
84
85
86 var __factoryBcryptVerifier: ((plain: string, stored: string) => boolean) | undefined;
87}
88
89
91export const passwordHasher: PasswordHasher = new Pbkdf2Hasher();
92
93export const hashPassword = (plain: string, cost?: number): Promise<string> => passwordHasher.hash(plain, cost);
94
95export const verifyPassword = (plain: string, stored: string): Promise<boolean> =>
96 passwordHasher.verify(plain, stored);
97
98
99export function storedHashAlgorithm(stored: string): "pbkdf2-sha512" | "bcrypt" | "unknown" {
100 if (stored.startsWith("$factory$pbkdf2-sha512$")) return "pbkdf2-sha512";
101 if (/^\$2[aby]\$\d{2}\$/.test(stored)) return "bcrypt";
102 return "unknown";
103}
104
105
106
107export const sha256Hex = (s: string): string => createHash("sha256").update(s).digest("hex");
108