import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; /** * Customer-portal identity primitives: password hashing (scrypt), TOTP * two-factor secrets (RFC 6238), and portal sessions. Pure functions plus * repo interfaces; SQLite adapters live in src/db/. */ /* ---------------- password hashing (scrypt) ---------------- */ const SCRYPT_N = 16384; const SCRYPT_R = 8; const SCRYPT_P = 1; const KEY_LEN = 32; /** Format: scrypt:N:r:p:: */ export function hashPassword(password: string): string { const salt = randomBytes(16); const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P, }); return `scrypt:${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}:${salt.toString('base64')}:${hash.toString('base64')}`; } export function verifyPassword(password: string, stored: string): boolean { const parts = stored.split(':'); if (parts.length !== 6 || parts[0] !== 'scrypt') return false; const [, n, r, p, saltB64, hashB64] = parts; const expected = Buffer.from(hashB64, 'base64'); if (expected.length === 0) return false; const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, { N: Number(n), r: Number(r), p: Number(p), }); return timingSafeEqual(actual, expected); } /* ---------------- base32 (RFC 4648, no padding) ---------------- */ const B32_ALPHABET = '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_ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31]; return out; } export function base32Decode(s: string): Buffer { let bits = 0; let value = 0; const out: number[] = []; for (const ch of s.toUpperCase().replace(/=+$/, '')) { const idx = B32_ALPHABET.indexOf(ch); if (idx < 0) throw new Error(`invalid base32 character: ${ch}`); value = (value << 5) | idx; bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Buffer.from(out); } /* ---------------- TOTP (RFC 6238, HMAC-SHA1, 30 s step, 6 digits) ---------------- */ export function hotp(secret: string, counter: number, digits = 6): string { const key = base32Decode(secret); const msg = Buffer.alloc(8); msg.writeBigUInt64BE(BigInt(counter)); const h = createHmac('sha1', key).update(msg).digest(); const offset = h[h.length - 1] & 0x0f; const code = (((h[offset] & 0x7f) << 24) | (h[offset + 1] << 16) | (h[offset + 2] << 8) | h[offset + 3]) % 10 ** digits; return String(code).padStart(digits, '0'); } export function totp(secret: string, atMs: number, stepSec = 30, digits = 6): string { return hotp(secret, Math.floor(atMs / 1000 / stepSec), digits); } export function verifyTotp( secret: string, code: string, atMs: number, window = 1, ): boolean { if (!/^\d{6}$/.test(code)) return false; for (let w = -window; w <= window; w++) { if (totp(secret, atMs + w * 30_000) === code) return true; } return false; } /** 160-bit secret, base32 without padding (authenticator-app standard). */ export function generateTotpSecret(): string { return base32Encode(randomBytes(20)); } export function totpUri(secret: string, email: string, issuer = 'Zappier'): string { return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`; } /* ---------------- portal sessions ---------------- */ export interface PortalSession { token: string; customerId: string; createdMs: number; expiresMs: number; } export interface SessionRepo { create(customerId: string, ttlMs: number): PortalSession; /** Returns the session, or undefined when unknown or expired at nowMs. */ get(token: string, nowMs?: number): PortalSession | undefined; delete(token: string): void; } export function newSessionToken(): string { return randomBytes(24).toString('hex'); } export class InMemorySessionRepo implements SessionRepo { private sessions = new Map(); create(customerId: string, ttlMs: number): PortalSession { const now = Date.now(); const session: PortalSession = { token: newSessionToken(), customerId, createdMs: now, expiresMs: now + ttlMs, }; this.sessions.set(session.token, session); return session; } get(token: string, nowMs = Date.now()): PortalSession | undefined { const s = this.sessions.get(token); if (!s || s.expiresMs <= nowMs) return undefined; return s; } delete(token: string): void { this.sessions.delete(token); } }