import argon2 from "argon2"; import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto"; import { RECOVERY_CODE_PREFIX, ROUND_ID_PREFIX } from "@spinza/shared"; import { config } from "../config"; /* ------------------------------------------------------------- passwords */ const ARGON_OPTS: argon2.Options = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 }; export async function hashPassword(password: string): Promise { return argon2.hash(password, ARGON_OPTS); } export async function verifyPassword(hash: string, password: string): Promise { try { return await argon2.verify(hash, password); } catch { return false; } } /* ---------------------------------------------------------------- tokens */ export function randomToken(bytes = 32): string { return randomBytes(bytes).toString("base64url"); } export function sha256(input: string): string { return createHash("sha256").update(input).digest("hex"); } export function hmac(input: string): string { return createHmac("sha256", config.sessionSecret).update(input).digest("hex"); } /** Round ids: spz_rnd_ + 12 chars from an unambiguous alphabet. */ const ROUND_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; export function newRoundId(): string { let s = ""; for (let i = 0; i < 12; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)]; return `${ROUND_ID_PREFIX}${s}`; } /* --------------------------------------------------------- recovery code */ export function generateRecoveryCode(): string { const groups: string[] = []; for (let g = 0; g < 3; g++) { let s = ""; for (let i = 0; i < 4; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)]; groups.push(s); } return `${RECOVERY_CODE_PREFIX}-${groups.join("-")}`; } export function normalizeRecoveryCode(code: string): string { return code.trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); } export async function hashRecoveryCode(code: string): Promise { return argon2.hash(normalizeRecoveryCode(code), ARGON_OPTS); } export async function verifyRecoveryCode(hash: string, code: string): Promise { try { return await argon2.verify(hash, normalizeRecoveryCode(code)); } catch { return false; } } /* -------------------------------------------------- symmetric encryption */ function key(): Buffer { return createHash("sha256").update(config.sessionSecret).digest(); } export function encrypt(plain: string): string { const iv = randomBytes(12); const c = createCipheriv("aes-256-gcm", key(), iv); const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]); return `${iv.toString("base64url")}.${enc.toString("base64url")}.${c.getAuthTag().toString("base64url")}`; } export function decrypt(payload: string): string { const [iv, enc, tag] = payload.split(".").map((p) => Buffer.from(p, "base64url")); const d = createDecipheriv("aes-256-gcm", key(), iv); d.setAuthTag(tag); return Buffer.concat([d.update(enc), d.final()]).toString("utf8"); } /* ------------------------------------------------------------------ TOTP */ const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; export function base32Encode(buf: Buffer): string { let bits = 0; let value = 0; let out = ""; for (const byte of buf) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += B32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32[(value << (5 - bits)) & 31]; return out; } export function base32Decode(s: string): Buffer { const clean = s.toUpperCase().replace(/=+$/, "").replace(/[^A-Z2-7]/g, ""); let bits = 0; let value = 0; const out: number[] = []; for (const ch of clean) { value = (value << 5) | B32.indexOf(ch); bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(out); } export function generateTotpSecret(): string { return base32Encode(randomBytes(20)); } export function totpCode(secret: string, time = Date.now(), step = 30, digits = 6): string { const counter = Math.floor(time / 1000 / step); const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter)); const h = createHmac("sha1", base32Decode(secret)).update(msg).digest(); const offset = h[h.length - 1] & 0xf; const bin = ((h[offset] & 0x7f) << 24) | (h[offset + 1] << 16) | (h[offset + 2] << 8) | h[offset + 3]; return String(bin % 10 ** digits).padStart(digits, "0"); } export function verifyTotp(secret: string, code: string, window = 1): boolean { const c = code.replace(/\s+/g, ""); if (!/^\d{6}$/.test(c)) return false; for (let w = -window; w <= window; w++) { const expected = totpCode(secret, Date.now() + w * 30_000); if (timingSafeEqual(Buffer.from(expected), Buffer.from(c))) return true; } return false; } export function otpauthUrl(username: string, secret: string): string { return `otpauth://totp/Spinza%20Admin:${encodeURIComponent(username)}?secret=${secret}&issuer=Spinza%20Admin&algorithm=SHA1&digits=6&period=30`; }