Diese Änderungen liefen bereits live auf dem Server, lagen dort aber ausschließlich als nicht eingecheckte Arbeitskopie — bei jedem Deploy (stash/pull/pop) und bei jedem Serverproblem wären sie verloren gewesen. Deshalb hier unverändert in git übernommen, bevor darauf aufgebaut wird. Enthalten (nicht von mir gebaut, nur gesichert): - Supporter: eigener fester Zugangscode statt Einmalcode-Login (Migration 0009, lib/crypto.js scrypt-Hash, routes/supporter.js, abonnieren.html, supporter.html, i18n-abonnieren/-supporter) - Stimmen: Profilbilder (Migration 0010, routes/testimonials.js) - Event-Bild-Upload: voller Pfad statt relativem (routes/events.js) - Neue/überarbeitete Hintergrundbilder für viele Seiten - Teilen-Funktion (streamplan.js, i18n-index/-streamplan, main.css) Co-Authored-By: Claude Opus 5 <[email protected]>
117 lines
4.8 KiB
JavaScript
117 lines
4.8 KiB
JavaScript
/* =====================================================================
|
|
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$<salt-hex>$<hash-hex>"
|
|
===================================================================== */
|
|
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;
|
|
}
|
|
}
|