TESM-Lizenzserver 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem
eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit,
damit sich jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger:

* Keine doppelte licensing.py -- ein Paket, das beide Anwendungen
  installieren, statt zweier Dateien, die byte-identisch bleiben sollen.
* Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird
  abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als
  "Aktivierung offen" markiert, bis sie zurueckkommt.
* Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung,
  seq gegen das Zurueckrollen auf eine aeltere Lizenz.
* Kein Hostname im Maschinen-Fingerabdruck.
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:01:23 +02:00
co-authored by Claude Opus 5
parent c130c85b17
commit f7805a2180
211 changed files with 31099 additions and 14759 deletions
+166
View File
@@ -0,0 +1,166 @@
# Architektur
Zwei Anwendungen, ein gemeinsamer Kern, ein gemeinsames Lizenzprotokoll.
```
packages/
tesm-licensing/ Lizenzformat und -protokoll (Ed25519). Keine Web-Abhaengigkeit.
tesm-core/ Der Kern: Datenbank, Rechte, Anmeldung, Sitzungen, Protokolle,
Einstellungen, Systemzugriff, Oberflaechenbausteine.
apps/
tesm/ Geraete- und Switchverwaltung (PoE, DHCP, Dateifreigaben, Wartung).
tesm-license/ Lizenzserver (Kunden, Tickets, Lizenzen, Portal).
deploy/ Helfer, sudoers, systemd, nginx, logrotate, install.sh
tests/ Unit- und Integrationstests, dazu drei E2E-Skripte fuer den Zielhost.
```
## Warum genau diese Schnitte
**`tesm-licensing` ist ein eigenes Paket, keine kopierte Datei.**
Im Vorgaengerprojekt lag `licensing.py` zweimal im Repo -- einmal beim Client,
einmal beim Server -- mit der Auflage, beide byte-identisch zu halten. Das ist
keine Invariante, die ein Mensch dauerhaft einhaelt. Hier gibt es das Modul
einmal; beide Anwendungen installieren dasselbe Paket.
`tests/test_repo_hygiene.py` schlaegt Alarm, sobald irgendwo wieder eine Datei
namens `licensing.py` auftaucht.
**`tesm-core` enthaelt alles, was beide Anwendungen gleich machen.**
Nicht nur Technik (Datenbank, Sitzungen, CSRF), sondern auch ganze Seiten:
Benutzer- und Gruppenverwaltung, Protokollansichten, Papierkorb, Diagnose,
Netzwerk-, Webserver- und Verzeichnisdiensteinstellungen. Eine Seite, die in
beiden Anwendungen gleich aussehen soll, existiert genau einmal.
**Die Apps bringen nur ihre Fachlichkeit mit** -- plus vier Beschreibungen, die
der Kern auswertet: `permissions.py` (Rechtebaum), `navigation.py`,
`schema.py` (Migrationen), `settings_spec.py`.
## Datenbank
Rohes `sqlite3`, kein ORM. Begruendung: das Datenmodell ist klein und stabil,
die Abfragen sind lesbar, und es gibt keine Schicht, die im Fehlerfall erst
verstanden werden muss.
Die Klasse `tesm_core.db.Database` erzwingt drei Dinge:
* WAL-Modus, `foreign_keys=ON`, `busy_timeout=15000`.
* Verschachtelte Transaktionen laufen ueber SAVEPOINTs.
* Ein `connect()` **waehrend** einer offenen Schreibtransaktion loest einen
`NestedConnectionError` aus, statt in einen Deadlock zu laufen. Das ist der
strukturelle Riegel gegen die haeufigste Fehlerklasse des Vorgaengers: eine
Funktion oeffnete waehrend eines Schreibvorgangs eine zweite Verbindung, und
die Anwendung blieb stehen, bis das Zeitlimit zuschlug.
Migrationen sind versioniert nach `(komponente, version)` mit Pruefsumme. Sie
laufen ueber `split_statements()`, nicht ueber `executescript()` -- letzteres
committet implizit und zerlegt die umgebende Transaktion.
## Rechte
Ein Rechtebaum aus **Bereich -> Ressource -> Aktion**. Die Aktionen sind ein
festes Vokabular: `VIEW`, `CREATE`, `EDIT`, `DELETE`, `EXECUTE`, `EXPORT`,
`SECRETS`.
* `SECRETS` und `EXPORT` sind eigene Rechte. Wer Zugangsdaten bearbeiten darf,
darf sie damit noch nicht im Klartext sehen oder ausfuehren.
* Bereiche haben einen Hauptschalter: ein abgeschalteter Bereich ist weg,
unabhaengig von Einzelrechten.
* **Keine Rechteausweitung:** niemand kann einem anderen Konto ein Recht geben,
das er selbst nicht hat (`guard_no_amplification`). Ebenso wenig laesst sich
der letzte Administrator entfernen oder sperren (`guard_last_admin`).
* Jede Pruefung ist serverseitig. Das Template blendet zusaetzlich aus, was
nicht erlaubt ist -- aber es *entscheidet* nichts. Im Vorgaenger wurden
Verzeichnisdienst und Webserver nur im Template geprueft; ein direkter POST
kam durch.
## Lizenzierung
Ed25519, mit den Haertungen, die ein Signaturprotokoll erst brauchbar machen:
* **Getrennte Signaturkontexte** fuer Lizenz, Anfrage und Antwort. Eine
Signatur aus dem einen Kontext ist im anderen wertlos.
* **Kanonisches JSON** -- die Signatur haengt nicht an der Schluesselreihenfolge.
* **Nonce gegen Wiedereinspielung**, Antwort an die Anfrage gebunden.
* **Frischefenster** gegen alte, mitgeschnittene Antworten.
* **`seq` gegen Rueckstufung:** eine aeltere Revision wird abgelehnt.
* **Aussteller-Pinning (Trust on first use).** Die erste eingespielte Lizenz
pinnt den oeffentlichen Schluessel ihres Ausstellers. Ein Wechsel verlangt
eine ausdrueckliche Bestaetigung durch einen Administrator.
* **Fingerabdruck ohne Hostnamen.** Er bildet sich aus Maschinen-ID und
DMI-UUID. Der Hostname ist bewusst **nicht** enthalten: im Vorgaenger brach
jedes Umbenennen des Rechners die Bindung.
Der Ablauf: Kunde -> Ticket -> Lizenz. Das Ticket haelt die Parameter (Typ,
Module, Laufzeit, Rolle); die Lizenz ist das signierte Ergebnis. Ein zweiter
Klick auf "Ausstellen" erzeugt keine zweite Lizenz, sondern zeigt die offene.
Aenderungen an einer laufenden Lizenz brauchen keine Neuinstallation: der
Anbieter signiert eine neue Revision, der Client holt sie beim naechsten
Heartbeat.
## Systemzugriff
Der Webprozess laeuft unprivilegiert. Root-Aktionen gehen ausschliesslich durch
`deploy/tesm-helper`: ein Skript mit fester Verbliste, Argumentpruefung gegen
Positivlisten und ohne jede Weitergabe an eine Shell. sudoers gibt genau diesen
einen Pfad frei.
Aktionen, die mehrere Schritte brauchen, sind **ein** Verb -- nicht drei.
`nginx-apply` etwa schreibt, prueft und laedt neu; scheitert `nginx -t`, stellt
es den vorherigen Stand wieder her. Getrennte Verben hatten hier eine echte
Luecke: `nginx-write` verlinkt sofort nach `sites-enabled`, und eine
anschliessend abgelehnte Datei blieb aktiviert liegen. Der laufende nginx merkte
nichts -- der naechste Reload durch irgendwen legte den ganzen Webserver lahm.
## Mehrere Installationen auf einem Host
Zwei Schluessel, mit klarer Aufgabenteilung:
* **`app_key`** (`tesm`, `tesm-license`) -- was die Anwendung *ist*. Bestimmt
Env-Praefix, Rechtebaum, Migrationsnamensraum.
* **`site_key`** (`tesm`, `tesm-opus`, ...) -- welche Installation es *ist*.
Bestimmt alles ausserhalb von `/srv/<name>`: nginx-Site, netplan-Datei,
logrotate-Regel, Zertifikatsverzeichnis und den Namen des Sitzungscookies.
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben -- und
beide Installationen teilten sich dasselbe Sitzungscookie, weil Cookies keine
Ports unterscheiden.
Die Adresse des Anwendungsservers (`BIND`) steht in derselben Umgebungsdatei,
aus der auch gunicorn seinen `--bind` bekommt. Sie ist zugleich das
`proxy_pass`-Ziel der erzeugten nginx-Site. Vorher war die `5000` fest im Code:
eine Instanz auf Port 5100 erzeugte damit eine Konfiguration, die auf die
*andere* Installation zeigte.
## Oberflaeche
Handgeschriebenes CSS in `@layer`n mit semantischen Tokens, drei Themen (hell,
dunkel, System). JavaScript als ES-Module, angebunden ueber
`data-behavior`-Attribute -- kein Inline-Script, kein Build-Schritt, und damit
vertraeglich mit einer strikten CSP, die `unsafe-inline` fuer Skripte nicht
erlaubt.
## Tests
| Datei | Was sie absichert |
|---|---|
| `test_licensing.py` | Format, Signaturen, Protokoll, Fingerabdruck |
| `test_core_smoke.py` | Kern: Datenbank, Migrationen, Schluesselspeicher, Sitzungen |
| `test_core_security.py` | Passwoerter, TOTP, Ratenbegrenzung, CSRF, Rechte |
| `test_webserver.py` | nginx-Vorlage, TLS, Zertifikate, Instanztrennung |
| `test_tesm_smoke.py` | TESM: Seiten, Validierung, Rechtematrix |
| `test_license_server.py` | Lizenzserver: Ausstellung, Portal, API |
| `test_repo_hygiene.py` | Zeilenenden, Geheimnisse im Repo, Helferpruefungen, sudoers |
Dazu drei Skripte, die **auf dem Zielhost gegen die echte Installation** laufen
(nginx, gunicorn, systemd, TLS -- keine Attrappen):
```bash
python3 tests/e2e_smoke.py https://host admin 'Passwort'
python3 tests/e2e_license_server.py https://lizenz.host admin 'Passwort'
python3 tests/e2e_license.py https://lizenz.host https://host admin 'Passwort' http://127.0.0.1:5001
```
Das dritte spielt den vollstaendigen Lizenz-Lebenszyklus ueber beide
Anwendungen hinweg durch: ausstellen, herunterladen, einspielen, aktivieren,
Module pruefen, Heartbeat, Revision, Widerruf.
+295
View File
@@ -0,0 +1,295 @@
# Betrieb
Dieses Dokument richtet sich an die Person, die die Anlage installiert,
aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf
dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird.
---
## 1. Was wo liegt
| Zweck | Pfad |
|---|---|
| Anwendung (Code, venv, statische Dateien) | `/srv/<name>/` |
| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv/<name>/data/` |
| Protokolle | `/var/log/<name>/` |
| Umgebungsdatei | `/etc/tesm/<name>.env` |
| Zertifikate (selbstsigniert) | `/etc/tesm/certs/<name>/` |
| nginx-Site | `/etc/nginx/sites-available/<name>` |
| systemd-Unit | `/etc/systemd/system/<name>.service` |
| Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` |
| Verwaltungswerkzeug | `/usr/local/bin/<name>-admin` |
`<name>` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen
Instanz, `tesm-<instanz>`. Dieser Name (der **SITE_KEY**) benennt alles, was
ausserhalb von `/srv/<name>` liegt. Deshalb koennen zwei Installationen
derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die
nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben.
Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis
wird von `install.sh` nie angefasst.
---
## 2. Installation
```bash
sudo bash deploy/install.sh --app tesm
sudo bash deploy/install.sh --app tesm-license
```
Wichtige Optionen:
| Option | Wirkung |
|---|---|
| `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. |
| `--domain <name>` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. |
| `--acme-email <a>` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. |
| `--instance <name>` | Zusaetzliche Instanz neben der Hauptinstallation. |
| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). |
| `--skip-packages` | Keine Systempakete installieren. |
Danach:
```bash
sudo -u tesm tesm-admin create-admin
```
Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**:
```bash
sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern!
sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz
sudo -u tesm-license tesm-license-admin create-admin
```
> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht
> `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr
> pruefbar. Sichern Sie ihn getrennt vom Server.
### Zwei Anwendungen auf einem Host
Beide wollen Port 80 und 443. nginx unterscheidet sie am Namen:
```bash
sudo bash deploy/install.sh --app tesm --https
sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.local
```
Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und
beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf
demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort
sichtbar.
---
## 3. Aktualisierung
```bash
sudo bash deploy/install.sh --app tesm
```
Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine
Aktualisierung ist, und tut dann zusaetzlich:
1. **Vollstaendige Sicherung** nach `/srv/<name>-backup-<zeitstempel>` --
unabhaengig davon, ob sich das Schema geaendert hat.
2. Dateiabgleich; `data/` bleibt unangetastet.
3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme).
4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand
automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter
`/srv/<name>-failed-<zeitstempel>` zur Analyse liegen.
Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne
`--https`.
---
## 4. HTTPS
Der Zielzustand ist immer: **Port 443 mit TLS, Port 80 leitet um, ausser dem
ACME-Pfad.**
Drei Wege dorthin:
**a) Bei der Installation** -- siehe oben, `--https`.
**b) Nachtraeglich ueber die Kommandozeile:**
```bash
sudo -u tesm tesm-admin web-setup --https --self-signed --apply
sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \
--acme --acme-email it@firma.de --hsts --apply
```
`--print-config` zeigt die Datei, ohne etwas zu schreiben.
**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*.
Danach muss `COOKIE_SECURE` in `/etc/tesm/<name>.env` auf `1` stehen (alle drei
Wege erledigen das selbst) und der Dienst neu gestartet werden.
### Die Site-Datei wird immer neu erzeugt
Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig
aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain,
Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der
Anwendung.
Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn
sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes
Update, und die Oberflaeche kam die ganze Zeit ohne Design.
### Selbstsigniert oder Let's Encrypt?
* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS
sinnvoll.
* **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die
Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure`
tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe
monatelang und die Warnung laesst sich dann nicht mehr wegklicken.
Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot`
(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben
wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar.
---
## 5. Dienste
```bash
systemctl status tesm.service # Weboberflaeche (gunicorn)
systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM)
systemctl status tesm-license.service
journalctl -u tesm -n 100 -f
```
Die Units laufen unprivilegiert (`User=tesm`) mit
`NoNewPrivileges`, `ProtectSystem=strict`, `PrivateTmp` und einer engen
`RestrictAddressFamilies`-Liste. Schreibrechte gibt es nur auf `/srv/<name>/data`
und `/var/log/<name>`.
---
## 6. Root-Rechte
Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad:
```
/usr/local/lib/tesm/tesm-helper <verb> [argumente]
```
Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen
Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine
Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein
`sh -c`).
Verben ansehen:
```bash
sudo /usr/local/lib/tesm/tesm-helper
grep -n ')$' /usr/local/lib/tesm/tesm-helper
```
Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.**
`tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb
im Helfer vorkommt und dass jede Pfadpruefung `..` abweist.
---
## 7. Sicherung und Wiederherstellung
In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id +
AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine
Vorschau, bevor irgendetwas geschrieben wird.
Auf dem Host reicht:
```bash
sudo systemctl stop tesm
sudo tar -czf /root/tesm-data-$(date +%F).tar.gz -C /srv/tesm data
sudo systemctl start tesm
```
Unverzichtbar:
* `data/app.db` -- alle Daten
* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig
* `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren**
* `data/license.json` und `data/license_key.json`
* Lizenzserver zusaetzlich: `data/master_signing_key.json`
---
## 8. Wenn etwas nicht geht
**Die Anwendung antwortet nicht.**
```bash
journalctl -u tesm -n 200 --no-pager
curl -s http://127.0.0.1:5000/gesundheit
```
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
**nginx laesst sich nicht neu laden.**
```bash
sudo nginx -t
```
Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst
zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft
`install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt.
**Anmeldung schlaegt ohne Fehlermeldung fehl.**
Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie,
das er ueber HTTP nie zuruecksendet.
```bash
grep COOKIE_SECURE /etc/tesm/tesm.env
```
**"Die Anfrage kam von einer fremden Herkunft (Origin/Referer)."**
Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss
`proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft
den Port.
**Statische Dateien fehlen, die Oberflaeche ist unformatiert.**
```bash
grep alias /etc/nginx/sites-available/tesm
ls /srv/tesm/static /srv/tesm/static-core
```
Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen.
**Zwei Instanzen stoeren sich.**
`SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich
unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie.
**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".**
Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden
Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf
der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*):
erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle
vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen
Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe
Freigabe auf der Switch-Detailseite.
Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM
verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP
eines Geraets, ist der Schluessel erneut freizugeben.
**Die Pruefkette des Aenderungsprotokolls ist gebrochen.**
Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst,
dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es
nicht mehr stimmt, wird genannt.
---
## 9. Zurueck auf eine aeltere Version
```bash
sudo systemctl stop tesm
sudo mv /srv/tesm /srv/tesm-kaputt
sudo cp -a /srv/tesm-backup-<zeitstempel> /srv/tesm
sudo systemctl start tesm
```
Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine
Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung --
genau das liegt im Backup-Verzeichnis.
+183
View File
@@ -0,0 +1,183 @@
# Sicherheit
Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind.
---
## 1. Anmeldung
| | |
|---|---|
| Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session
Fixation) und das CSRF-Token gewechselt.
### Erneute Bestaetigung ("sudo-Modus")
Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt,
verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten,
Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation,
2FA-Aenderungen, Deaktivierung einer Lizenz.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht.
---
## 2. Sitzungen
Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine
Zufallskennung.
* `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist
* Leerlaufzeit **und** absolute Hoechstdauer
* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host
koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies
unterscheiden keine Ports)
* Aktive Sitzungen sind einsehbar und einzeln beendbar
---
## 3. CSRF
Drei voneinander unabhaengige Schichten:
1. `SameSite=Lax` am Sitzungscookie.
2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen.
3. Origin-/Referer-Pruefung gegen den Host der Anfrage.
Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die
Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf
demselben Rechner ist ein anderer Ursprung.
Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im
Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen.
Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich
**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne
CSRF-Schutz waere eine Luecke.
---
## 4. Header
Bei jeder Antwort:
* `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein**
`unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein
Inline-Script ohne Nonce, und JavaScript haengt ueber
`data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil
einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt
werden; Style-Attribute fuehren keinen Code aus.
* `X-Content-Type-Options: nosniff`
* `X-Frame-Options: DENY`, `frame-ancestors 'none'`
* `Referrer-Policy: same-origin`
* `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet
* `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`
* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird
> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser
> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr
> wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt.
---
## 5. Geheimnisse im Ruhezustand
Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600):
* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung
(`v1.<id>.<nonce>.<ct>`) und bleiben nach einer Rotation lesbar.
* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat
aus einem Kontext laesst sich in einem anderen nicht entschluesseln.
* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien
noch stimmen; Abweichungen erscheinen unter *Diagnose*.
Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der
sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste)
und nie ueber die Umgebung (`/proc/<pid>/environ`).
---
## 6. Rechtetrennung auf dem Host
Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch
genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per
sudoers ausschliesslich fuer diesen Pfad freigegeben ist.
Regeln, die im Repository durch Tests erzwungen werden:
* Jedes Verb, das die Anwendung kennt, existiert im Helfer.
* Kein `eval`, kein `bash -c`, kein `sh -c`.
* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber
den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat
den zulaessigen Basisnamen `x`).
* Der Instanzname ist auf `tesm[-<instanz>]` begrenzt; er landet in Pfaden.
* Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht.
Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`,
`PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste.
Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine
einzige Luecke in irgendeinem Pfad haette den Host bedeutet.
---
## 7. Aenderungsprotokoll
Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein
nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose*
angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht.
Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich
auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`).
Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen.
---
## 8. Lizenzprotokoll
Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz:
getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung,
Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden,
Aussteller-Pinning.
Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit
selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische
Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im
Aenderungsprotokoll.
---
## 9. Ein- und Ausfuhr
Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst
gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben
wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb
braucht der Dienst kein `--workers 1` mehr.
---
## 10. Bewusst nicht getan
* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn
einmal setzt, setzt ihn dauerhaft.
* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.**
* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend".
* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
---
## 11. Wenn Sie eine Schwachstelle finden
Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des
Lizenzservers hinterlegte Adresse.