Files

89 lines
4.3 KiB
JavaScript

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: "<Ausstellzeit base36>.<HMAC>" 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'
}