import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' import { ipSchluessel } from '../termine/limits.js' import { heute } from '../termine/zeit.js' import { plusTage } from './datum.js' /** * Tokens des Buchungskalenders. Buchungslink-Token: 32 Byte zufaellig (base64url), gespeichert wird nur der SHA-256-Hash. * Formular-Token: Kopie von shop/token.js mit eigener Beschriftung 'uk-form' ("." je siteKey). */ const FORM_MAX_ALTER_MS = 2 * 60 * 60 * 1000 const FORM_TOKEN_MAX_LAENGE = 100 const MAC_RE = /^[A-Za-z0-9_-]{43}$/ const ZEIT_RE = /^[0-9a-z]{1,13}$/ const MIN_STANDARD_SEKUNDEN = 3 const sha256Hex = (text) => createHash('sha256').update(text).digest('hex') export function neuesToken() { const token = randomBytes(32).toString('base64url') return { token, hash: sha256Hex(token) } } /** Zeitkonstanter Vergleich des gespeicherten Hashes mit dem Hash des vorgelegten Tokens. */ export function tokenPasst(hash, token) { if (typeof hash !== 'string' || hash === '' || typeof token !== 'string' || token === '' || token.length > 200) return false const erwartet = Buffer.from(hash) const gegeben = Buffer.from(sha256Hex(token)) return erwartet.length === gegeben.length && timingSafeEqual(erwartet, gegeben) } const LINK_TAGE_NACH_ABREISE = 90 const LINK_TAGE_NACH_ABSAGE = 30 const TAG_RE = /^\d{4}-\d{2}-\d{2}/ /** * Gilt der Buchungslink noch? Bis 90 Tage nach der Abreise (bis, einschliesslich dieses Tages, Berliner Tag); bei abgelehnten und abgelaufenen * Anfragen (nicht zustande gekommen) nur 30 Tage nach dem Statuswechsel. Danach antwortet die Buchungsseite wie bei einem unbekannten Link (404). * Ohne lesbares Datum gilt der Link (kein Grund, ihn zu sperren). */ export function linkGueltig(anfrage, now = new Date()) { const heuteTag = heute(now instanceof Date ? now : new Date(Number(now))) const status = anfrage?.status if (status === 'abgelehnt' || status === 'abgelaufen') { const seit = String((status === 'abgelehnt' ? anfrage.abgelehntAt : anfrage.abgelaufenAt) || anfrage.$updatedAt || '') if (!TAG_RE.test(seit)) return true return heuteTag <= plusTage(heute(new Date(seit)), LINK_TAGE_NACH_ABSAGE) } const bis = String(anfrage?.bis ?? '') if (!TAG_RE.test(bis)) return true return heuteTag <= plusTage(bis.slice(0, 10), LINK_TAGE_NACH_ABREISE) } export const formMinSekunden = () => { const wert = config.unterkunft?.formMinSekunden return Number.isFinite(wert) && wert >= 0 ? wert : MIN_STANDARD_SEKUNDEN } function schluessel() { const geheimnis = config.sessionSecret if (typeof geheimnis !== 'string' || geheimnis.length < 32) { throw new Error('Buchungskalender-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).') } return createHmac('sha256', geheimnis).update('uk-form').digest() } const zeitMs = (now, was) => { const ms = now instanceof Date ? now.getTime() : Number(now) if (!Number.isFinite(ms) || ms < 0) throw new TypeError(`${was}: now ist kein gueltiger Zeitpunkt`) return ms } const formMac = (siteKey, zeit) => createHmac('sha256', schluessel()).update(`${siteKey}|${zeit}`).digest('base64url') export function formToken(siteKey, now = new Date()) { if (typeof siteKey !== 'string' || siteKey === '') throw new TypeError('formToken: siteKey fehlt') const zeit = zeitMs(now, 'formToken').toString(36) return `${zeit}.${formMac(siteKey, zeit)}` } /** 'ok' | 'zu_schnell' | 'abgelaufen' (aelter als 2 h) | 'ungueltig'. */ export function pruefeFormToken(siteKey, token, now = new Date()) { if (typeof siteKey !== 'string' || siteKey === '') return 'ungueltig' if (typeof token !== 'string' || token.length > FORM_TOKEN_MAX_LAENGE) return 'ungueltig' const teile = token.split('.') if (teile.length !== 2) return 'ungueltig' const [zeit, mac] = teile if (!ZEIT_RE.test(zeit) || !MAC_RE.test(mac)) return 'ungueltig' const erwartet = Buffer.from(formMac(siteKey, zeit)) const gegeben = Buffer.from(mac) if (gegeben.length !== erwartet.length || !timingSafeEqual(gegeben, erwartet)) return 'ungueltig' const alter = zeitMs(now, 'pruefeFormToken') - parseInt(zeit, 36) if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen' if (alter < formMinSekunden() * 1000) return 'zu_schnell' return 'ok' } /** Gesalzener Kurz-Hash der IP (16 Hex-Zeichen); die Adresse selbst wird nie gespeichert. */ export function ipHashVon(ip) { return createHash('sha256').update(`uk-ip:${config.sessionSecret}:${ipSchluessel(ip)}`).digest('hex').slice(0, 16) }