// Bearbeitungsschutz: Betrachter sehen den Kalender, Bearbeiten erfordert das Passwort aus EDIT_PASSWORD. // Nach erfolgreicher Anmeldung bekommt der Browser ein signiertes Cookie (HMAC, 30 Tage). import fs from 'node:fs'; import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; export const COOKIE_NAME = 'rotatory_edit'; const TTL_MS = 30 * 24 * 60 * 60 * 1000; function safeEqual(a, b) { const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b)); return ba.length === bb.length && timingSafeEqual(ba, bb); } export function createAuth({ password = '', secretFile = null, secret = null, now = () => Date.now() } = {}) { const pw = String(password || '').trim(); let key = secret; if (!key && secretFile) { if (fs.existsSync(secretFile)) key = fs.readFileSync(secretFile, 'utf8').trim(); if (!key) { key = randomBytes(32).toString('hex'); fs.writeFileSync(secretFile, key, { mode: 0o600 }); } } if (!key) key = randomBytes(32).toString('hex'); const sign = (payload) => createHmac('sha256', key).update(payload).digest('base64url'); // Bremse gegen Raten: nach 5 Fehlversuchen je Adresse 60 Sekunden Sperre const failures = new Map(); return { enabled: pw.length > 0, ttlMs: TTL_MS, checkPassword(given) { return this.enabled && safeEqual(given ?? '', pw); }, issue() { const exp = String(now() + TTL_MS); return `${exp}.${sign(exp)}`; }, verify(token) { if (!token || typeof token !== 'string') return false; const dot = token.indexOf('.'); if (dot < 0) return false; const exp = token.slice(0, dot); const sig = token.slice(dot + 1); if (!/^\d+$/.test(exp) || Number(exp) < now()) return false; return safeEqual(sig, sign(exp)); }, /** Liefert die Restsperre in Sekunden (0 = Versuch erlaubt). */ blockedFor(ip) { const f = failures.get(ip); if (!f) return 0; if (f.until && f.until > now()) return Math.ceil((f.until - now()) / 1000); return 0; }, noteFailure(ip) { const f = failures.get(ip) || { count: 0, until: 0 }; f.count += 1; if (f.count >= 5) { f.until = now() + 60_000; f.count = 0; } failures.set(ip, f); }, noteSuccess(ip) { failures.delete(ip); }, }; } export function readCookie(header, name = COOKIE_NAME) { for (const part of String(header || '').split(';')) { const [k, ...rest] = part.trim().split('='); if (k === name) return decodeURIComponent(rest.join('=')); } return null; } export function cookieHeader(token, { secure = false, maxAgeMs = TTL_MS } = {}) { const parts = [`${COOKIE_NAME}=${encodeURIComponent(token)}`, 'Path=/', 'HttpOnly', 'SameSite=Lax', `Max-Age=${Math.floor(maxAgeMs / 1000)}`]; if (secure) parts.push('Secure'); return parts.join('; '); }