164 lines
11 KiB
TypeScript
164 lines
11 KiB
TypeScript
|
|
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.<base64url(Payload)>.<base64url(Signatur)>, 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<string, unknown> } {
|
||
|
|
const parts = token.split('.');
|
||
|
|
if (parts.length !== 3 || parts[0] !== 'FLS1') return { ok: false };
|
||
|
|
const [, payloadB64, sigB64] = parts;
|
||
|
|
let payload: Record<string, unknown>;
|
||
|
|
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<SigningKey[]> {
|
||
|
|
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<void> {
|
||
|
|
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<VerifyResult> {
|
||
|
|
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<Entitlement> {
|
||
|
|
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<boolean> {
|
||
|
|
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<CapacityCheck> {
|
||
|
|
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<void> {
|
||
|
|
await run('UPDATE license_state SET license_key_enc = ?, checked_at = NULL, last_error = NULL WHERE id = 1', [encrypt(licenseKey)]);
|
||
|
|
}
|