# Umsetzungs-Spezifikation: Lizenz-Server + Admin-Sektion „Lizenzen"

**Für den Claude Code im Website-Projekt.** Diese Datei beschreibt vollständig, was in der
bestehenden Website ergänzt werden muss, damit die fertigen Windows-Programme (erste App:
**Convertly**) aktiviert, verwaltet und gesperrt werden können. Implementiere es genau nach
diesem Dokument — besonders der **Krypto-Vertrag** (Abschnitt 1) ist bindend, sonst können die
bereits gebauten Apps die Lizenz-Tokens nicht mehr prüfen.

Es gibt eine lauffähige Referenz-Implementierung im Repo `Convertly/license-server/`
(Node 24 + Express + `node:sqlite`). Du kannst deren Code übernehmen und in die Website
integrieren; dieses Dokument erklärt das Warum/Wie und ergänzt die Admin-Sektion.

---

## 0. Der Vertrag mit der App (was die App schon erwartet)

Die Apps sind bereits gebaut und enthalten diese Annahmen fest:

| Punkt | Wert |
|---|---|
| App-Kennung (`appId`) | `convertly` (jede App hat eine eigene) |
| Server-Basis-URL | wird in der App auf deine Domain gesetzt, z. B. `https://license.kotsch.tech` |
| Öffentliche Endpunkte | `POST /api/activate`, `POST /api/refresh`, `POST /api/deactivate`, `GET /health` |
| Signatur-Public-Key | siehe Abschnitt 1 (die App verifiziert Tokens damit) |
| Token-Format | `base64url(payload).base64url(signatur)` (Abschnitt 1) |

> Sobald der Server live ist, wird in der App nur **eine Konstante** geändert:
> `LicenseConfig.ServerUrl` (Datei `Windows/Licensing/Licensing.cs`). Sonst nichts.

---

## 1. Krypto-Vertrag (BINDEND — muss exakt so bleiben)

- **Schlüssel:** RSA-2048.
- **Signatur:** RSASSA-**PSS**, Hash **SHA-256**, **saltLength = 32** (entspricht .NET
  `RSASignaturePadding.Pss`, das Salt = Hashlänge verwendet). Andere Salt-Länge ⇒ Verifikation
  schlägt fehl.
- **Token:** `tokenString = base64url(JSON) + "." + base64url(signature)`, wobei die Signatur
  über die **Bytes des base64url-kodierten Payload-Strings** (nicht über das rohe JSON) gebildet wird.
- **Payload (JSON, genau diese Feldnamen):**

```json
{
  "sub":  "cust_…",        // Kunden-/Personen-ID
  "lic":  "lic_…",         // Lizenz-ID
  "app":  "convertly",     // muss == appId der App
  "tier": "pro",           // Stufe (frei wählbar: standard/pro/…)
  "name": "Max Mustermann",// wird in der App als Begrüßung gezeigt
  "email":"max@example.com",
  "dev":  "<fingerprint>", // Geräte-Fingerprint, den die App schickt (unverändert zurückgeben)
  "iat":  1782828296,      // issued-at (Unix-Sekunden)
  "exp":  1814364296       // Lease-Ende (iat + 365 Tage). NICHT das Lizenzende — Lizenz ist dauerhaft.
}
```

### Schlüsselpaar — zwei Optionen

**Option A (empfohlen, kein App-Rebuild nötig): vorhandenes Schlüsselpaar weiterverwenden.**
Im lokalen Repo liegt `Convertly/license-server/keys/license_private.pem`. Kopiere diese
**private** PEM-Datei sicher auf den Server (z. B. nach `/etc/kotsch/license_private.pem`, Datei-
rechte `600`, niemals ins Git). Die App enthält bereits den passenden **öffentlichen** Schlüssel:

```
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAn0AzIGB7734bBO2RgUVMowpJVMdA654IUNYIo62/3cO9oJXq0CedGXRmLPS0u1isCPWr3DVOrW21VDd06NcDcu4lJQAoVjS4CZLcU6lQWWz2gygwkdPsEmi1rpiz2wPTWY/rGHV7y+7prb5WomuydFgbZ/n/1GCzIGifVDmVXsERnmJeRJZB1V4fOgGRoVdP7ZCcw8hQU+K+Tj0EYu/LdbrXH4zw1aa8kNWGszOAh55LgzkoAC+2q7ZDh5deO20i9loYvLjlGHDh5MN/bpRCnBx/2ANDp8ScbVg53Op4zomILk2yVG9osTyRz1vLnz2Zz9t1/vcpxURA/FAvbzQKdQIDAQAB
```
(SPKI DER, base64 — identisch zu `LicenseConfig.PublicKeyB64` in der App.)

**Option B: neues Schlüsselpaar erzeugen.** Dann muss der neue Public-Key (SPKI DER base64) in
`Windows/Licensing/Licensing.cs` → `LicenseConfig.PublicKeyB64` eingetragen und die App neu
gebaut/verteilt werden. Erzeugung:
```js
import crypto from 'node:crypto'; import fs from 'node:fs';
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
fs.writeFileSync('license_private.pem', privateKey.export({type:'pkcs8',format:'pem'}));
console.log(publicKey.export({type:'spki',format:'der'}).toString('base64')); // → in die App
```

### Signier-Code (Referenz, exakt so)
```js
import crypto from 'node:crypto';
const PRIVATE_KEY = fs.readFileSync(process.env.LICENSE_PRIVATE_KEY_PATH, 'utf8');
const b64url = (buf) => Buffer.from(buf).toString('base64url');

function signToken(payload) {
  const p = b64url(JSON.stringify(payload));
  const sig = crypto.sign('sha256', Buffer.from(p), {
    key: PRIVATE_KEY,
    padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
    saltLength: 32,
  });
  return p + '.' + b64url(sig);
}
function readPayload(token) {            // für /refresh, /deactivate
  return JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8'));
}
```

---

## 2. Datenmodell

SQL (SQLite-Syntax; für Postgres `TEXT`→`text`, `INTEGER`→`integer`, AUTOINCREMENT→`serial`).
Wenn die Website bereits eine DB hat, lege diese Tabellen dort an (Präfix `lic_` optional).

```sql
CREATE TABLE customers (
  id TEXT PRIMARY KEY, email TEXT NOT NULL, name TEXT, created TEXT NOT NULL);
CREATE TABLE apps (
  appId TEXT PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE licenses (
  id TEXT PRIMARY KEY,
  key_hash TEXT NOT NULL,              -- sha256 des normalisierten Keys (NIE Klartext speichern)
  customerId TEXT NOT NULL,            -- Pflicht: jede Lizenz gehört zu einer Person
  appId TEXT NOT NULL,
  tier TEXT NOT NULL DEFAULT 'standard',
  status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked'
  maxDevices INTEGER NOT NULL DEFAULT 3,
  source TEXT,                         -- 'admin' | 'shop'
  orderId TEXT,                        -- Shop-Order (Phase 3), für Idempotenz
  created TEXT NOT NULL,
  FOREIGN KEY (customerId) REFERENCES customers(id));
CREATE TABLE devices (
  id TEXT PRIMARY KEY, licenseId TEXT NOT NULL, fingerprint TEXT NOT NULL,
  name TEXT, firstSeen TEXT NOT NULL, lastSeen TEXT NOT NULL);
CREATE UNIQUE INDEX ux_device ON devices(licenseId, fingerprint);  -- verhindert Doppelbindung
CREATE TABLE events (                  -- lückenloses Audit-Log
  id INTEGER PRIMARY KEY AUTOINCREMENT, ts TEXT NOT NULL, type TEXT NOT NULL,
  licenseId TEXT, detail TEXT);
```

---

## 3. Öffentliche API (App-seitig, ohne Login, aber HTTPS + Rate-Limit)

### Key-Normalisierung & Hashing (überall identisch verwenden)
```js
const hashKey = (key) =>
  crypto.createHash('sha256')
        .update(key.replace(/[^A-Za-z0-9]/g, '').toUpperCase())
        .digest('hex');
```

### `POST /api/activate`
Request:
```json
{ "key":"CONV-7RCXE-23852-8XGDX", "appId":"convertly",
  "fingerprint":"<sha256-hex>", "deviceName":"DESKTOP-ABC" }
```
Ablauf (Pseudocode mit dem **race-sicheren** 3-Geräte-Limit):
```js
const lic = db.get('SELECT * FROM licenses WHERE key_hash=? AND appId=?', hashKey(key), appId);
if (!lic) return 404 { error:'invalid_key' };
if (lic.status !== 'active') return 403 { error:'revoked' };
const customer = db.get('SELECT name,email FROM customers WHERE id=?', lic.customerId);

let limited = false;
db.exec('BEGIN IMMEDIATE');                         // atomar gegen gleichzeitige Aktivierungen
try {
  const dev = db.get('SELECT * FROM devices WHERE licenseId=? AND fingerprint=?', lic.id, fingerprint);
  if (!dev) {
    const count = db.get('SELECT COUNT(*) c FROM devices WHERE licenseId=?', lic.id).c;
    if (count >= lic.maxDevices) limited = true;
    else db.run('INSERT INTO devices(...) VALUES(...)', /* neue Geräte-Zeile */);
  } else {
    db.run('UPDATE devices SET lastSeen=? WHERE id=?', now(), dev.id);
  }
  db.exec('COMMIT');
} catch (e) { db.exec('ROLLBACK'); return 500 { error:'server_error' }; }

if (limited) return 403 { error:'device_limit', maxDevices: lic.maxDevices };
logEvent('activate', lic.id, fingerprint.slice(0,12));
return 200 { token: issueToken(lic, fingerprint, customer), tier: lic.tier, name: customer.name };
```
`issueToken` baut das Payload aus Abschnitt 1 (mit `name`,`email`,`dev=fingerprint`,
`iat=now`, `exp=now+365d`) und signiert es.

Fehler-Codes (die App zeigt dafür Texte): `invalid_key` (404), `revoked`/`device_limit` (403),
`missing_fields` (400).

### `POST /api/refresh`  (Lease-Erneuerung + Sperr-Prüfung)
Request: `{ "token":"<aktuelles Token>" }`
```js
const p = readPayload(token);
const lic = db.get('SELECT * FROM licenses WHERE id=?', p.lic);
if (!lic || lic.status !== 'active') return 403 { error:'revoked' };
const dev = db.get('SELECT * FROM devices WHERE licenseId=? AND fingerprint=?', lic.id, p.dev);
if (!dev) return 403 { error:'device_not_bound' };
db.run('UPDATE devices SET lastSeen=? WHERE id=?', now(), dev.id);
const customer = db.get('SELECT name,email FROM customers WHERE id=?', lic.customerId);
return 200 { token: issueToken(lic, p.dev, customer), tier: lic.tier };
```

### `POST /api/deactivate`  (Gerät abmelden / Slot freigeben)
Request: `{ "token":"<token>" }`
```js
const p = readPayload(token);
db.run('DELETE FROM devices WHERE licenseId=? AND fingerprint=?', p.lic, p.dev);
logEvent('deactivate', p.lic, p.dev.slice(0,12));
return 200 { ok:true };
```

### `GET /health` → `{ "ok": true }`

---

## 4. Key-Erzeugung (Admin & Shop nutzen dieselbe Funktion)
```js
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';  // Crockford base32 (ohne I,L,O,U)
const group = () => [...crypto.randomBytes(5)].map(x => ALPHABET[x % 32]).join('');
const genKey = (prefix) => `${prefix}-${group()}-${group()}-${group()}`;  // z. B. CONV-7RCXE-23852-8XGDX
```
Beim Erstellen: Klartext-Key **einmalig** anzeigen/mailen, in der DB nur `hashKey(key)` speichern.
Präfix pro App (Convertly → `CONV`).

---

## 5. Admin-Sektion „Lizenzen" (das eigentliche Ziel)

In den bestehenden, **eingeloggten** Admin-Bereich eine neue Sektion „Lizenzen" einbauen.
Alle Admin-Routen hinter der vorhandenen Admin-Auth (2FA empfohlen). Empfohlene Seiten + die
dahinter liegenden Admin-Endpunkte:

### 5.1 Übersicht  `GET /admin/licenses?query=&app=&status=`
Tabelle: Key (maskiert, z. B. `CONV-…-8XGDX`), Kunde (Name + E-Mail), App, Stufe, Status,
Geräte `2/3`, Quelle (admin/shop), erstellt. Suche nach E-Mail/Name/Key-Endung. Filter App/Status.
> Hinweis: Der Klartext-Key ist nach Erstellung nicht mehr rekonstruierbar (nur Hash gespeichert).
> Deshalb in der Liste maskiert anzeigen; „erneut senden" verschickt einen **neu generierten** Key
> (alten ersetzen) ODER man speichert beim Erstellen zusätzlich die letzten 5 Zeichen für die Anzeige.

### 5.2 Lizenz erstellen  `POST /admin/licenses`
Formular: App (Dropdown), Stufe, maxDevices (Default 3), Kunde (bestehenden wählen ODER neu:
Name + E-Mail), Notiz. Ablauf:
```js
// Kunde finden/anlegen
let cust = db.get('SELECT * FROM customers WHERE email=?', email)
        ?? insertCustomer({ id:'cust_'+uuid(), email, name });
const key = genKey('CONV');                 // App-Präfix je nach appId
const licId = 'lic_'+uuid();
insertLicense({ id:licId, key_hash:hashKey(key), customerId:cust.id, appId, tier,
                status:'active', maxDevices, source:'admin', created:now() });
logEvent('issue', licId, 'admin');
return { licenseId:licId, key };            // key NUR hier einmalig zurückgeben
```
UI zeigt den Key einmalig groß + Button „Per E-Mail senden" (nutzt denselben Mail-Versand wie der
Shop in Phase 3). **Pflicht:** ohne Kunde keine Lizenz (FK `customerId`).

### 5.3 Lizenz-Detail  `GET /admin/licenses/:id`
Zeigt Kunde, App, Stufe, Status, Quelle, erstellt; Geräte-Liste (Fingerprint gekürzt, Name,
lastSeen) und die letzten Events. Aktionen:
- **Sperren**  `POST /admin/licenses/:id/revoke`  → `status='revoked'`, `logEvent('revoke',…)`.
  (App verliert beim nächsten `/refresh` den Zugriff; Token läuft nach Lease-Ende ab.)
- **Entsperren**  `POST /admin/licenses/:id/unrevoke` → `status='active'`.
- **Alle Geräte zurücksetzen**  `POST /admin/licenses/:id/reset-devices` → `DELETE FROM devices WHERE licenseId=:id`.
- **Einzelnes Gerät entfernen**  `DELETE /admin/devices/:deviceId`.

### 5.4 Kunden  `GET /admin/customers`, `GET /admin/customers/:id`
Personen verwalten; pro Kunde dessen Lizenzen + Geräte sehen. (Jede Lizenz ist hier immer einer
Person zugeordnet.)

### 5.5 Audit-Log  `GET /admin/events`
Globale Event-Liste (activate, refresh, deactivate, issue, revoke, …) mit Zeit + Lizenz.

---

## 6. Sicherheit (Pflicht)
- **HTTPS** überall (TLS via certbot, wie die bestehende Seite).
- **Private Key** nur am Server (env `LICENSE_PRIVATE_KEY_PATH`, Datei `600`, nie ins Git).
- **Keys nur gehasht** in der DB.
- **Admin-Routen** hinter Admin-Auth (+ 2FA empfohlen).
- **Rate-Limiting / Brute-Force-Schutz** auf `/api/activate` (z. B. pro IP + pro Key).
- **Audit-Log** für alle sicherheitsrelevanten Aktionen.
- Öffentliche API braucht **kein CORS** (native App, kein Browser).

---

## 7. Integration & Deployment
1. Code aus `Convertly/license-server/` in die Website übernehmen (Express-Router `/api/*` +
   Admin-Router `/admin/licenses…`), an die vorhandene App/DB anbinden.
2. Tabellen aus Abschnitt 2 anlegen; App `convertly` in `apps` eintragen.
3. Private Key deployen (Abschnitt 1, Option A) bzw. neuen erzeugen (Option B + App-Update).
4. ENV: `LICENSE_PRIVATE_KEY_PATH`, ggf. DB-Connection.
5. Hinter Nginx + PM2 wie die bestehende Seite; eigene Subdomain optional
   (`license.kotsch.tech`).
6. In der App `LicenseConfig.ServerUrl` auf die Live-URL setzen, App neu bauen.

---

## 8. Test-Checkliste (muss grün sein)
- `GET /health` → `{ok:true}`.
- Lizenz im Admin erstellen → Key kommt einmalig zurück, in DB nur Hash.
- `POST /api/activate` mit gültigem Key → Token; Payload-Felder wie Abschnitt 1; `name` gesetzt.
- 3 verschiedene `fingerprint` ok, der 4. → `403 device_limit`.
- `POST /api/refresh` mit Token → neues Token; nach **Sperren** im Admin → `403 revoked`.
- `POST /api/deactivate` → Gerät weg, danach wieder aktivierbar (Slot frei).
- Token-Signatur lässt sich mit dem Public-Key aus Abschnitt 1 verifizieren (RSA-PSS, SHA-256, salt 32).

---

## 9. Phase 3 (Shop) — nur Ausblick, später
PayPal + Stripe Checkout → **verifizierter Webhook** → dieselbe „Lizenz erstellen"-Funktion aus
5.2 (source `shop`, `orderId` für Idempotenz) → Key per E-Mail. Refund/Chargeback-Webhook →
`status='revoked'`. DE/EU: Widerruf-Checkbox für digitale Güter, Impressum + Datenschutz.
