import { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual, } from "node:crypto"; function required(name: string, min = 32): Buffer { const value = process.env[name]; if (!value) throw new Error(`${name} is required`); const decoded = name === "PHONE_ENCRYPTION_KEY" ? Buffer.from(value, "base64") : Buffer.from(value); if (decoded.length < min) throw new Error(`${name} must be at least ${min} bytes`); return decoded; } const REQUIRED_KEYS = [ "PHONE_ENCRYPTION_KEY", "PHONE_HMAC_KEY", "VERIFICATION_CODE_HMAC_KEY", "REFRESH_TOKEN_HMAC_KEY", "JWT_SECRET", ] as const; type AuthSecrets = Record<(typeof REQUIRED_KEYS)[number], Buffer>; let secrets: AuthSecrets | undefined; let configuredWebOrigin: string | undefined; function parseWebOrigin(): string { const raw = process.env.WEB_ORIGIN; if (!raw) throw new Error("WEB_ORIGIN is required"); let url: URL; try { url = new URL(raw); } catch { throw new Error("WEB_ORIGIN must be an absolute HTTP(S) origin"); } if ( !["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash || url.origin !== raw ) throw new Error( "WEB_ORIGIN must be one absolute HTTP(S) origin without credentials or path", ); return url.origin; } export function validateAuthEnvironment(): void { const loaded = Object.fromEntries( REQUIRED_KEYS.map((name) => [name, required(name)]), ) as unknown as AuthSecrets; if (loaded.PHONE_ENCRYPTION_KEY.length !== 32) throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes"); const fingerprints = REQUIRED_KEYS.map((name) => loaded[name].toString("hex"), ); if (new Set(fingerprints).size !== fingerprints.length) throw new Error("Auth secrets must be independent"); const origin = parseWebOrigin(); if (process.env.DEMO_SMS_CODE_ENABLED === "true" && !demoEnvironmentAllowed()) throw new Error( "DEMO_SMS_CODE_ENABLED is forbidden outside test or local development", ); secrets = loaded; configuredWebOrigin = origin; } export function resetAuthEnvironmentForTests(): void { if (process.env.NODE_ENV !== "test") throw new Error("Auth environment reset is test-only"); secrets = undefined; configuredWebOrigin = undefined; } function authSecrets(): AuthSecrets { if (!secrets) validateAuthEnvironment(); return secrets!; } export const jwtSecret = (): Buffer => authSecrets().JWT_SECRET; export const webOrigin = (): string => { if (!configuredWebOrigin) validateAuthEnvironment(); return configuredWebOrigin!; }; const demoEnvironmentAllowed = (): boolean => process.env.NODE_ENV === "test" || (process.env.NODE_ENV === "development" && process.env.APP_ENV === "local"); export const demoSmsCodeEnabled = (): boolean => process.env.DEMO_SMS_CODE_ENABLED === "true" && demoEnvironmentAllowed(); export const phoneHmac = (phone: string): string => createHmac("sha256", authSecrets().PHONE_HMAC_KEY) .update(phone) .digest("hex"); export const codeHmac = (phoneDigest: string, code: string): string => createHmac("sha256", authSecrets().VERIFICATION_CODE_HMAC_KEY) .update(`${phoneDigest}:${code}`) .digest("hex"); export const refreshHmac = (token: string): string => createHmac("sha256", authSecrets().REFRESH_TOKEN_HMAC_KEY) .update(token) .digest("hex"); export const safeEqual = (a: string, b: string): boolean => { const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y); }; export function encryptPhone(phone: string): Buffer { const key = authSecrets().PHONE_ENCRYPTION_KEY; if (key.length !== 32) throw new Error("PHONE_ENCRYPTION_KEY must decode to exactly 32 bytes"); const nonce = randomBytes(12); const cipher = createCipheriv("aes-256-gcm", key, nonce); const body = Buffer.concat([cipher.update(phone, "utf8"), cipher.final()]); return Buffer.concat([nonce, cipher.getAuthTag(), body]); } export function decryptPhone(value: Buffer): string { const key = authSecrets().PHONE_ENCRYPTION_KEY; const decipher = createDecipheriv("aes-256-gcm", key, value.subarray(0, 12)); decipher.setAuthTag(value.subarray(12, 28)); return Buffer.concat([ decipher.update(value.subarray(28)), decipher.final(), ]).toString(); } export const envInt = (name: string, fallback: number): number => { const n = Number(process.env[name] ?? fallback); return Number.isInteger(n) && n > 0 ? n : fallback; };