import { createPublicKey, createVerify } from 'node:crypto'; import type { PoolConnection } from 'mysql2/promise'; import { config } from './config.js'; import { one, query, run } from './db.js'; import { encrypt, decrypt } from './crypto.js'; /** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com (Meilenstein * "Kundencenter lizenzierbar machen"). Nicht zu verwechseln mit dem "licensing"-Connector, der * KUNDEN-Lizenzen verwaltet, die das Kundencenter weiterverkauft. */ const OFFLINE_GRACE_DAYS = Number(process.env.LICENSE_OFFLINE_GRACE_DAYS ?? 7); export interface VerifyResult { valid: boolean; message: string | null; plan: string | null; modules: string[]; expiresAt: string | null; userLimit: number | null; customerLimit: number | null; entitlementVersion: number | null; trial: boolean; addons: unknown[]; serverTime: string | null; signature: string | null; keyId: string | null; } interface SigningKey { kid: string; alg: string; n: string; e: string; status: string } // Buffer.from(hex, 'hex') verwirft bei ungerader Länge stillschweigend die letzte Ziffer (z. B. würde der // Exponent "10001" = 65537 fälschlich zu 0x1000 statt 0x010001) - deshalb vorher auf gerade Länge auffüllen. const hexToB64url = (hex: string): string => Buffer.from(hex.length % 2 ? `0${hex}` : hex, 'hex').toString('base64url'); /** FLS1.., RSA-2048 PKCS#1 v1.5 SHA-256 über die ASCII-Bytes * des Payload-Teils (siehe licensing INTEGRATION.md, Abschnitt "Signierte Antworten"). */ function verifyFls1(token: string, keys: SigningKey[]): { ok: boolean; payload?: Record } { const parts = token.split('.'); if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false }; const [, payloadB64, sigB64] = parts; let payload: Record; try { payload = JSON.parse(Buffer.from(payloadB64!, 'base64url').toString('utf8')); } catch { return { ok: false }; } const key = keys.find((k) => k.kid === payload.kid); if (!key) return { ok: false }; try { const pub = createPublicKey({ key: { kty: 'RSA', n: hexToB64url(key.n), e: hexToB64url(key.e) }, format: 'jwk' }); const v = createVerify('RSA-SHA256'); v.update(payloadB64!, 'ascii'); v.end(); return { ok: v.verify(pub, Buffer.from(sigB64!, 'base64url')), payload }; } catch { return { ok: false }; } } async function loadSigningKeys(): Promise { const rows = await query('SELECT kid, alg, n_hex, e_hex, status FROM license_signing_keys'); return rows.map((r) => ({ kid: r.kid, alg: r.alg, n: r.n_hex, e: r.e_hex, status: r.status })); } /** Schlüssel nur ERGÄNZEN, nie anhand der Serverantwort löschen (sonst könnte ein vorgetäuschter * Server die Liste auf nur noch seine eigenen Schlüssel "bereinigen"). */ async function refreshSigningKeys(): Promise { const r = await fetch(`${config.licenseApiBaseUrl}/license/signing-keys`, { signal: AbortSignal.timeout(10000) }); if (!r.ok) return; const data = await r.json() as { format?: string; keys?: { kid: string; alg: string; n: string; e: string; status: string }[] }; if (data.format !== 'FLS1' || !Array.isArray(data.keys)) return; for (const k of data.keys) await run('INSERT INTO license_signing_keys (kid, alg, n_hex, e_hex, status) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE alg=VALUES(alg), n_hex=VALUES(n_hex), e_hex=VALUES(e_hex), status=VALUES(status)', [k.kid, k.alg, k.n, k.e, k.status]); } export class LicenseCheckError extends Error {} /** Fragt /license/verify live ab, prüft die Signatur und schreibt das Ergebnis in license_state. * Wirft bei fehlender Konfiguration oder Netzwerkfehler (ruft NICHT selbst den gecachten Zustand ab - * das macht getEntitlement()). */ export async function checkLicenseNow(): Promise { if (!config.licenseProgramKey) throw new LicenseCheckError('LICENSE_PROGRAM_KEY ist nicht konfiguriert'); const row = await one('SELECT license_key_enc, instance_id FROM license_state WHERE id = 1'); if (!row) throw new LicenseCheckError('license_state fehlt (Migration nicht angewendet?)'); if (!row.license_key_enc) throw new LicenseCheckError('Kein Lizenzschlüssel hinterlegt (Einstellungen > Lizenz)'); const licenseKey = decrypt(row.license_key_enc); await run('UPDATE license_state SET last_attempt_at = UTC_TIMESTAMP(3) WHERE id = 1'); let res: Response; try { res = await fetch(`${config.licenseApiBaseUrl}/license/verify`, { method: 'POST', headers: { 'content-type': 'application/json', 'X-Program-Key': config.licenseProgramKey }, body: JSON.stringify({ licenseKey, instanceId: row.instance_id }), signal: AbortSignal.timeout(15000), }); } catch (e) { await run('UPDATE license_state SET last_error = ? WHERE id = 1', [`Netzwerkfehler: ${(e as Error).message}`.slice(0, 500)]); throw new LicenseCheckError(`Lizenzserver nicht erreichbar: ${(e as Error).message}`); } if (res.status === 401) { await run('UPDATE license_state SET last_error = ? WHERE id = 1', ['Program-Key ungültig (LICENSE_PROGRAM_KEY)']); throw new LicenseCheckError('Program-Key ungültig'); } if (!res.ok) { const msg = `Lizenzserver: HTTP ${res.status}`; await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]); throw new LicenseCheckError(msg); } const data = await res.json() as VerifyResult; if (data.signature) { let keys = await loadSigningKeys(); let v = verifyFls1(data.signature, keys); if (!v.ok) { await refreshSigningKeys().catch(() => undefined); keys = await loadSigningKeys(); v = verifyFls1(data.signature, keys); } // evtl. rotierter, noch unbekannter Schlüssel if (!v.ok || v.payload?.licenseKey !== licenseKey) { const msg = 'Signaturprüfung der Lizenzantwort fehlgeschlagen'; await run('UPDATE license_state SET last_error = ? WHERE id = 1', [msg]); throw new LicenseCheckError(msg); } } await run( `UPDATE license_state SET valid=?, message=?, plan=?, modules_json=?, expires_at=?, user_limit=?, customer_limit=?, entitlement_version=?, is_trial=?, addons_json=?, server_time=?, checked_at=UTC_TIMESTAMP(3), last_error=NULL WHERE id=1`, [data.valid, data.message ?? null, data.plan ?? null, JSON.stringify(data.modules ?? []), data.expiresAt ? new Date(data.expiresAt) : null, data.userLimit ?? null, data.customerLimit ?? null, data.entitlementVersion ?? null, !!data.trial, JSON.stringify(data.addons ?? []), data.serverTime ? new Date(data.serverTime) : null], ); return data; } export interface Entitlement { valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null; trial: boolean; expiresAt: Date | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured'; checkedAt: Date | null; message: string | null; } /** Liefert den aktuell nutzbaren Entitlement-Stand: zuletzt geprüfter (signaturgeprüfter) Snapshot, solange * er innerhalb des Offline-Fensters liegt (min. Ablaufdatum der Lizenz, min. eigenes Offline-Fenster ab der * letzten erfolgreichen Prüfung) - verlängert nie eine tatsächlich abgelaufene Lizenz. Ruft NICHT selbst * die Live-API auf (das übernimmt der Worker periodisch bzw. checkLicenseNow() explizit). */ export async function getEntitlement(): Promise { if (!config.licenseProgramKey) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'LICENSE_PROGRAM_KEY nicht konfiguriert' }; const row = await one('SELECT * FROM license_state WHERE id = 1'); if (!row || !row.license_key_enc) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: false, source: 'unconfigured', checkedAt: null, message: 'Kein Lizenzschlüssel hinterlegt' }; if (!row.checked_at) return { valid: false, plan: null, modules: [], customerLimit: null, userLimit: null, trial: false, expiresAt: null, configured: true, source: 'unchecked', checkedAt: null, message: 'Noch nicht geprüft' }; const checkedAt = new Date(row.checked_at as Date); const graceUntil = new Date(checkedAt.getTime() + OFFLINE_GRACE_DAYS * 86400000); const expiresAt = row.expires_at ? new Date(row.expires_at as Date) : null; const now = new Date(); const withinOfflineGrace = now <= graceUntil && (expiresAt === null || now <= expiresAt); const modules = (typeof row.modules_json === 'string' ? JSON.parse(row.modules_json) : row.modules_json) ?? []; return { valid: !!row.valid && withinOfflineGrace, plan: row.plan, modules, customerLimit: row.customer_limit, userLimit: row.user_limit, trial: !!row.is_trial, expiresAt, configured: true, source: withinOfflineGrace ? 'live' : 'offline-grace', checkedAt, message: row.message, }; } /** Wie assertCustomerCapacity: solange Lizenzierung nicht konfiguriert/geprüft ist, nichts einschränken. */ export async function hasModule(key: string): Promise { const e = await getEntitlement(); if (e.source === 'unconfigured' || e.source === 'unchecked') return true; return e.valid && e.modules.includes(key); } export interface CapacityCheck { allowed: boolean; reason?: string; count: number; limit: number | null } /** Transaktionale Kundengrenze: in DERSELBEN DB-Verbindung/Transaktion wie die Kundenanlage/-reaktivierung * aufrufen. GET_LOCK serialisiert gegen gleichzeitige Anfragen auf den letzten freien Platz. Zählt aktive * und gesperrte (suspended) Organisationen; beendete (closed) zählen nicht mehr. * * Solange die Lizenzierung gar nicht konfiguriert ist (kein LICENSE_PROGRAM_KEY/-schlüssel) oder noch nie * erfolgreich geprüft wurde, wird NICHT durchgesetzt - sonst wäre jede frische Installation (und jede Test- * umgebung) ab dem ersten Kunden blockiert. Erst eine tatsächlich geprüfte, ungültige/überschrittene Lizenz * sperrt neue Kunden. */ export async function assertCustomerCapacity(c: PoolConnection): Promise { const ent = await getEntitlement(); if (ent.source === 'unconfigured' || ent.source === 'unchecked') return { allowed: true, count: 0, limit: null }; if (!ent.valid) return { allowed: false, reason: ent.message ?? 'Keine gültige Lizenz', count: 0, limit: null }; await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_customer_limit']); try { const row = await one('SELECT COUNT(*) AS n FROM organizations WHERE status IN (\'active\',\'suspended\')', [], c); const count = Number(row?.n ?? 0); if (ent.customerLimit !== null && count >= ent.customerLimit) return { allowed: false, reason: `Kundengrenze erreicht (${count}/${ent.customerLimit}). Bitte Lizenz upgraden.`, count, limit: ent.customerLimit }; return { allowed: true, count, limit: ent.customerLimit }; } finally { await c.query('SELECT RELEASE_LOCK(?)', ['kc_customer_limit']); } } export async function setLicenseKey(licenseKey: string): Promise { await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]); }