import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' import { config } from '../../config.js' import { pruefeNow, pruefeText } from './util.js' /** * Terminplaner-Tokens (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 8). * * - Bestaetigungstoken: 32 Zufallsbytes (base64url), gespeichert wird nur der SHA-256-Hex (verifyHash). * - Verwaltungstoken: base64url(HMAC-SHA256(Schluessel, bookingId)); jederzeit neu berechenbar, damit spaetere Mails * (Erinnerung) denselben Link tragen, gespeichert wird nur sein Hash (manageHash). Wie lange er gilt (Terminende * + 1 Tag), entscheidet der Aufrufer anhand der Buchung. * - Buchungsnummer: 8 Zeichen A-Z0-9, nur aus der Buchungs-ID berechnet (kein eigenes Feld); steht in der Bestaetigung * und gehoert beim Widerruf ohne Link zusammen mit der E-Mail-Adresse. * - Formular-Token: "." je siteKey, beweist nur Alter und Herkunft (kein Geheimnis fuer * den Besucher). Zu schnell abgeschickt (config.termine.formMinSekunden) = Bot, aelter als 2 Stunden = neu laden. * * Die HMAC-Schluessel werden je Zweck aus config.sessionSecret abgeleitet (Beschriftung 'termine-manage' bzw. * 'termine-form'), damit ein Token nie fuer einen anderen Zweck gilt. config wird bei jedem Aufruf gelesen. */ 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 sha256Hex = (text) => createHash('sha256').update(text).digest('hex') /** Schluessel fuer genau einen Zweck; ohne (ausreichend langes) Sitzungsgeheimnis gibt es keine Tokens. */ function schluessel(zweck) { const geheimnis = config.sessionSecret if (typeof geheimnis !== 'string' || geheimnis.length < 32) { throw new Error('Terminplaner-Token: SESSION_SECRET fehlt oder ist zu kurz (mindestens 32 Zeichen).') } return createHmac('sha256', geheimnis).update(zweck).digest() } /** Neues Bestaetigungstoken: token geht in den Link, hash (SHA-256-Hex) in die Buchung. */ export function neuesToken() { const token = randomBytes(32).toString('base64url') return { token, hash: hashToken(token) } } /** SHA-256-Hex eines Tokens. Fremde Eingaben (kein Text) ergeben den Hash des leeren Textes und treffen nichts. */ export function hashToken(token) { return sha256Hex(typeof token === 'string' ? token : '') } /** Verwaltungstoken einer Buchung (stabil; bei geaendertem Sitzungsgeheimnis ein anderer Wert). */ export function verwaltungsToken(bookingId) { pruefeText(bookingId, 'bookingId') return createHmac('sha256', schluessel('termine-manage')).update(bookingId).digest('base64url') } /** Buchungsnummer aus der Buchungs-ID: 8 Zeichen A-Z0-9 (die ersten 64 Bit des SHA-256 zur Basis 36). */ export function buchungsNummer(bookingId) { pruefeText(bookingId, 'bookingId') const zahl = createHash('sha256').update(`termine-nummer|${bookingId}`).digest().readBigUInt64BE(0) return (zahl % 36n ** 8n).toString(36).toUpperCase().padStart(8, '0') } function formMac(siteKey, zeit) { return createHmac('sha256', schluessel('termine-form')).update(`${siteKey}|${zeit}`).digest('base64url') } /** Formular-Token fuer ein Widget dieses siteKeys, ausgestellt zum Zeitpunkt now. */ export function formToken(siteKey, now = new Date()) { pruefeText(siteKey, 'siteKey') const zeit = pruefeNow(now, 'formToken').toString(36) return `${zeit}.${formMac(siteKey, zeit)}` } /** 'ok' | 'zu_schnell' (juenger als formMinSekunden) | 'abgelaufen' (aelter als 2 h) | 'ungueltig' (fremd/gefaelscht). */ 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 = pruefeNow(now, 'pruefeFormToken') - parseInt(zeit, 36) if (alter > FORM_MAX_ALTER_MS) return 'abgelaufen' if (alter < config.termine.formMinSekunden * 1000) return 'zu_schnell' return 'ok' }