import { createHash, randomUUID, timingSafeEqual, createHmac, randomFillSync } from 'node:crypto' import { readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { parseCookies } from './helpers.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const TOTP_SECRET_FILE = path.join(__dirname, '..', 'data', 'totp-secret.json') // Pending sessions: password verified, waiting for TOTP code // Map const TOTP_PENDING_TTL_MS = 5 * 60 * 1000 const totpPendingSessions = new Map() const ADMIN_SESSION_COOKIE = 'vbn_admin_session' const ADMIN_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000 const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD const adminSessions = new Map() function cookieFlags() { return process.env.NODE_ENV === 'production' ? '; Secure' : '' } export function sha256(value) { return createHash('sha256').update(String(value)).digest('hex') } export function isAdminPasswordConfigured() { return Boolean(ADMIN_PASSWORD) } export function validateAdminPasswordSetup() { if (!isAdminPasswordConfigured() && process.env.NODE_ENV === 'production') { throw new Error('ADMIN_PASSWORD is required in production.') } if (!isAdminPasswordConfigured()) { console.warn('ADMIN_PASSWORD is not configured; admin routes will remain disabled until the environment is configured.') } } export function isAdminPasswordValid(password) { if (!isAdminPasswordConfigured()) return false const a = Buffer.from(sha256(password), 'utf8') const b = Buffer.from(sha256(ADMIN_PASSWORD), 'utf8') if (a.length !== b.length) return false return timingSafeEqual(a, b) } // ── TOTP (RFC 6238) — implemented with Node built-in crypto ───────────────── const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' function base32Decode(str) { const s = str.toUpperCase().replace(/=+$/, '') let bits = 0 let value = 0 const output = [] for (const char of s) { const idx = BASE32_CHARS.indexOf(char) if (idx === -1) continue value = (value << 5) | idx bits += 5 if (bits >= 8) { output.push((value >>> (bits - 8)) & 0xff) bits -= 8 } } return Buffer.from(output) } function base32Encode(buf) { let bits = 0 let value = 0 let output = '' for (const byte of buf) { value = (value << 8) | byte bits += 8 while (bits >= 5) { output += BASE32_CHARS[(value >>> (bits - 5)) & 0x1f] bits -= 5 } } if (bits > 0) output += BASE32_CHARS[(value << (5 - bits)) & 0x1f] return output } function totpToken(secret, counter) { const key = base32Decode(secret) const msg = Buffer.alloc(8) // Write 64-bit big-endian counter const hi = Math.floor(counter / 0x100000000) const lo = counter >>> 0 msg.writeUInt32BE(hi, 0) msg.writeUInt32BE(lo, 4) const hmac = createHmac('sha1', key).update(msg).digest() const offset = hmac[hmac.length - 1] & 0x0f const code = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) | (hmac[offset + 2] << 8) | hmac[offset + 3] return String(code % 1000000).padStart(6, '0') } export function generateTotpSecret() { const buf = Buffer.allocUnsafe(20) randomFillSync(buf) return base32Encode(buf) } export async function loadTotpState() { try { const raw = await readFile(TOTP_SECRET_FILE, 'utf8') return JSON.parse(raw) } catch { return null } } export async function saveTotpState(state) { await writeFile(TOTP_SECRET_FILE, JSON.stringify(state, null, 2), 'utf8') } export async function isTotpEnabled() { const state = await loadTotpState() return Boolean(state?.secret && state?.verified) } function randomBytesForRecovery(n) { const buf = Buffer.allocUnsafe(n) randomFillSync(buf) return buf } export function getTotpUri(secret, label = 'Siteforge Admin') { const issuer = 'Siteforge' return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(label)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` } export function verifyTotpCode(secret, code) { try { const token = String(code).replace(/\s/g, '') const step = Math.floor(Date.now() / 1000 / 30) // Accept current step and one step either side (±30 seconds clock skew) for (const offset of [-1, 0, 1]) { if (totpToken(secret, step + offset) === token) return true } return false } catch { return false } } // ── Recovery Codes ────────────────────────────────────────────────────────── const RECOVERY_CODE_COUNT = 8 function generateRecoveryCode() { // Format: XXXX-XXXX-XXXX (uppercase alphanumeric, no ambiguous chars) const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' const randBytes = randomBytesForRecovery(12) let code = '' for (let i = 0; i < 12; i++) { if (i > 0 && i % 4 === 0) code += '-' code += chars[randBytes[i] % chars.length] } return code } export function generateRecoveryCodes() { const codes = [] for (let i = 0; i < RECOVERY_CODE_COUNT; i++) { codes.push(generateRecoveryCode()) } return codes } export function hashRecoveryCode(code) { return sha256(code.replace(/-/g, '').toUpperCase()) } // Returns the matched code if valid, null otherwise. Mutates state.hashedRecoveryCodes. export function consumeRecoveryCode(state, inputCode) { if (!Array.isArray(state.hashedRecoveryCodes) || state.hashedRecoveryCodes.length === 0) return false const normalized = inputCode.replace(/[-\s]/g, '').toUpperCase() const inputHash = sha256(normalized) const idx = state.hashedRecoveryCodes.findIndex(h => { const a = Buffer.from(h, 'utf8') const b = Buffer.from(inputHash, 'utf8') return a.length === b.length && timingSafeEqual(a, b) }) if (idx === -1) return false state.hashedRecoveryCodes.splice(idx, 1) return true } // ── Pending (password-ok, awaiting TOTP) sessions ─────────────────────────── export function createPendingSession() { const token = randomUUID() totpPendingSessions.set(token, { expiresAt: Date.now() + TOTP_PENDING_TTL_MS }) return token } export function consumePendingSession(token) { if (!token) return false const entry = totpPendingSessions.get(token) if (!entry || entry.expiresAt <= Date.now()) { totpPendingSessions.delete(token) return false } totpPendingSessions.delete(token) return true } // ── Admin Sessions ─────────────────────────────────────────────────────────── export function createAdminSession() { const token = randomUUID() adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS) return token } export function deleteAdminSession(token) { if (token) { adminSessions.delete(token) } } export function isValidAdminSession(req) { if (!isAdminPasswordConfigured()) return false const cookies = parseCookies(req.headers.cookie) const sessionToken = cookies[ADMIN_SESSION_COOKIE] if (!sessionToken) return false const expiresAt = adminSessions.get(sessionToken) if (!expiresAt || expiresAt <= Date.now()) { adminSessions.delete(sessionToken) return false } adminSessions.set(sessionToken, Date.now() + ADMIN_SESSION_TTL_MS) return true } export function setAdminSessionCookie(res, token) { res.append( 'Set-Cookie', `${ADMIN_SESSION_COOKIE}=${encodeURIComponent(token)}; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, ) } export function clearAdminSessionCookie(res) { res.append( 'Set-Cookie', `${ADMIN_SESSION_COOKIE}=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax${cookieFlags()}`, ) } export function requireAdminAuth(req, res, next) { if (!isValidAdminSession(req)) { res.status(401).json({ message: 'Unauthorized' }) return } next() }