// lib/auth.js — multi-user auth: per-user scrypt password check, HMAC-signed // session cookie carrying the user id, in-memory login rate limit. // Constant-time comparisons throughout. import { createHash, createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; import { config } from './config.js'; export const COOKIE_NAME = 'panel_session'; const SESSION_TTL_MS = 7 * 24 * 3600 * 1000; // 7 days function hmac(payload) { return createHmac('sha256', config.sessionSecret).update(payload).digest('hex'); } // Constant-time string comparison that does not leak length either // (compares SHA-256 digests of both inputs). export function safeEqual(a, b) { const ha = createHash('sha256').update(String(a)).digest(); const hb = createHash('sha256').update(String(b)).digest(); return timingSafeEqual(ha, hb); } export function createSession(userId) { const expiry = Date.now() + SESSION_TTL_MS; const payload = `u${Number(userId)}.${expiry}`; return `${payload}.${hmac(payload)}`; } // Returns the numeric user id, or 0 when the token is invalid/expired. export function verifySession(token) { if (typeof token !== 'string' || token.length === 0) return 0; const parts = token.split('.'); if (parts.length !== 3 || !/^u\d+$/.test(parts[0])) return 0; const expiry = Number(parts[1]); if (!Number.isFinite(expiry) || Date.now() > expiry) return 0; if (!safeEqual(parts[2], hmac(`${parts[0]}.${parts[1]}`))) return 0; return Number(parts[0].slice(1)); } // password_hash format: scrypt:: (64-byte key). export function verifyHash(passwordHash, password) { const parts = String(passwordHash || '').split(':'); if (parts.length !== 3 || parts[0] !== 'scrypt') return false; let salt; let expected; try { salt = Buffer.from(parts[1], 'hex'); expected = Buffer.from(parts[2], 'hex'); } catch { return false; } if (salt.length === 0 || expected.length !== 64) return false; const derived = scryptSync(String(password), salt, 64); return timingSafeEqual(derived, expected); } export function hashPassword(password) { const salt = randomBytes(16); const key = scryptSync(String(password), salt, 64); return `scrypt:${salt.toString('hex')}:${key.toString('hex')}`; } // ---- login rate limit: 5 failures per 15 minutes per IP ---- const WINDOW_MS = 15 * 60 * 1000; const MAX_FAILS = 5; const attempts = new Map(); // ip -> { fails, resetAt } function prune() { const now = Date.now(); for (const [ip, a] of attempts) { if (now > a.resetAt) attempts.delete(ip); } } export function loginBlocked(ip) { prune(); const a = attempts.get(ip); return Boolean(a && a.fails >= MAX_FAILS); } export function loginFailed(ip) { const a = attempts.get(ip); if (!a || Date.now() > a.resetAt) { attempts.set(ip, { fails: 1, resetAt: Date.now() + WINDOW_MS }); } else { a.fails += 1; } } export function loginSucceeded(ip) { attempts.delete(ip); }