/* ===================================================================== crypto.js — Verschlüsselung & Hashing für das Zugangssystem. 1:1 portiert aus cloudflare-worker/src/lib/crypto.js (Web Crypto API, in Node 20+ global verfügbar wie im Cloudflare Worker — keine Änderung nötig). ===================================================================== */ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1 export function generateFriendlyCode() { const bytes = crypto.getRandomValues(new Uint8Array(12)); let raw = ""; for (const b of bytes) raw += CODE_ALPHABET[b % CODE_ALPHABET.length]; return `${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`; } export function generateSessionToken() { const bytes = crypto.getRandomValues(new Uint8Array(32)); return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); } export function generateId() { return crypto.randomUUID(); } export async function sha256Hex(text) { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } async function getAesKey() { const raw = Uint8Array.from(atob(process.env.ENCRYPTION_KEY), (c) => c.charCodeAt(0)); return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]); } function bufToBase64(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))); } function base64ToBuf(b64) { return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); } /** Verschlüsselt einen Klartext-Code reversibel. Rückgabe: "iv.ciphertext" (base64). */ export async function encryptCode(plaintext) { const key = await getAesKey(); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plaintext)); return `${bufToBase64(iv)}.${bufToBase64(ciphertext)}`; } /** Entschlüsselt einen mit encryptCode() erzeugten Wert. */ export async function decryptCode(encoded) { const [ivB64, ctB64] = encoded.split("."); const key = await getAesKey(); const iv = base64ToBuf(ivB64); const ciphertext = base64ToBuf(ctB64); const plainBuf = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext); return new TextDecoder().decode(plainBuf); } export function nowIso() { return new Date().toISOString(); } /* ===================================================================== Persönlicher Zugangscode der Supporter (21.08.2026) BEWUSST NICHT sha256Hex() wie bei den Einmalcodes oben: ein Einmalcode lebt 15 Minuten und ist zufällig, da genügt ein schneller Hash. Ein selbst gewählter, dauerhafter Code ist dagegen oft kurz und erratbar — dagegen hilft nur ein absichtlich LANGSAMES Verfahren, das massenhaftes Durchprobieren unbezahlbar macht. Dafür ist scrypt gemacht (in Node eingebaut, keine zusätzliche Abhängigkeit nötig). Jeder Code bekommt zusätzlich einen eigenen Zufalls-Salt: dadurch haben zwei Personen mit demselben Code trotzdem völlig verschiedene Hashes, und vorberechnete Tabellen sind wertlos. Gespeichertes Format: "scrypt$$" ===================================================================== */ import { scrypt as _scrypt, randomBytes, timingSafeEqual as _timingSafeEqual } from "node:crypto"; const SCRYPT_KEYLEN = 64; // N=16384 ist der Node-Standard und ein guter Kompromiss: für eine einzelne // Anmeldung kaum spürbar, für Angreifer aber millionenfach teuer. const SCRYPT_OPTIONEN = { N: 16384, r: 8, p: 1 }; function scryptAsync(code, salt) { return new Promise((resolve, reject) => { _scrypt(code, salt, SCRYPT_KEYLEN, SCRYPT_OPTIONEN, (err, key) => (err ? reject(err) : resolve(key))); }); } /** Erzeugt den zu speichernden Wert für einen selbst gewählten Zugangscode. */ export async function hashAccessCode(code) { const salt = randomBytes(16); const key = await scryptAsync(String(code), salt); return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`; } /** Prüft einen eingegebenen Code gegen den gespeicherten Wert. Der Vergleich läuft zeitkonstant (timingSafeEqual), damit sich aus der Antwortdauer nicht ableiten lässt, wie viele Zeichen gestimmt haben. */ export async function verifyAccessCode(code, gespeichert) { if (!gespeichert || typeof gespeichert !== "string") return false; const teile = gespeichert.split("$"); if (teile.length !== 3 || teile[0] !== "scrypt") return false; try { const salt = Buffer.from(teile[1], "hex"); const erwartet = Buffer.from(teile[2], "hex"); const tatsaechlich = await scryptAsync(String(code), salt); if (erwartet.length !== tatsaechlich.length) return false; return _timingSafeEqual(erwartet, tatsaechlich); } catch { return false; } }