TESM 2.0.0 -- Neubau

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

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

Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code
kommentiert, an der der Fehler entstanden ist:

* 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.
* Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert.
* Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea).
* Kein Hostname im Lizenz-Fingerabdruck.
* Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY).
* 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:00:06 +02:00
co-authored by Claude Opus 5
parent a193b9d6e9
commit 7354ff352b
227 changed files with 37605 additions and 21187 deletions
@@ -0,0 +1,240 @@
"""Aktivierungs-, Deaktivierungs- und Heartbeat-Protokoll.
Beide Transportwege -- HTTPS-JSON und kopierbarer Offline-Code -- tragen exakt
dieselben signierten Objekte. Es gibt deshalb nur *einen* Satz
Verifikationsfunktionen; der Transport ist reine Verpackung.
Haertungen gegenueber dem Vorgaengerprojekt:
* **Domaenengetrennte Signaturen** (siehe ``canonical``): eine Lizenz-Signatur
kann nie als Antwort-Signatur durchgehen und umgekehrt.
* **Frischefenster**: Anfragen und Antworten tragen einen Zeitstempel, der
gegen ein Fenster geprueft wird (``REQUEST_MAX_AGE_SECONDS``, plus
``CLOCK_SKEW_SECONDS`` Toleranz in die Zukunft). V1 pruefte den Zeitstempel
gar nicht -- eine abgefangene Aktivierungsanfrage war unbegrenzt
wiederverwendbar.
* **Nonce-Replay-Schutz**: der Master kann ueber ``seen_nonce`` eine
Persistenzschicht einhaengen; ein zweites Mal gesehene Nonce wird abgewiesen.
* **Antwort-an-Anfrage-Bindung**: die Master-Antwort echot die Nonce der Anfrage
in ihrer signierten Payload. Eine alte "ok"-Antwort kann dadurch nicht gegen
eine neue Anfrage eingespielt werden (relevant vor allem im Offline-Code-Weg,
wo Codes per Copy-Paste durch die Gegend wandern).
* **Downgrade-Schutz fuer ``license_update``**: das nachgelieferte Dokument muss
eine hoehere ``seq`` tragen als das aktuell installierte.
"""
from __future__ import annotations
import re
import secrets
from datetime import datetime, timedelta
from typing import Any, Callable, Mapping
from .canonical import SIG_CONTEXT_REQUEST, SIG_CONTEXT_RESPONSE, sign, verify
from .errors import ProtocolViolation, ReplayDetected, SignatureInvalid
from .license import FORMAT_VERSION, iso, parse_iso, utcnow
from .fingerprint import system_fingerprint
#: Erlaubte Aktionen des Protokolls.
ACTIONS: tuple[str, ...] = ("activate", "deactivate", "heartbeat")
#: Antwortstatus, die der Master senden kann.
RESPONSE_STATES: tuple[str, ...] = ("ok", "revoked", "expired", "rejected")
#: Wie alt eine Anfrage/Antwort maximal sein darf (Offline-Codes brauchen Luft).
REQUEST_MAX_AGE_SECONDS = 7 * 24 * 3600
#: Toleranz fuer Uhren, die leicht vorlaufen.
CLOCK_SKEW_SECONDS = 300
#: Regulaerer Heartbeat-Abstand des Clients.
HEARTBEAT_INTERVAL_SECONDS = 6 * 3600
#: Obergrenze fuer Zeilen in einem Sub-Lizenzserver-Bericht.
MAX_REPORT_ROWS = 5000
_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$")
def new_nonce() -> str:
return secrets.token_urlsafe(18)
def build_client_request(
action: str,
document: Mapping[str, Any],
client_private_key: str,
*,
hostname: str = "",
nonce: str | None = None,
report: Mapping[str, Any] | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Baut eine signierte Client-Anfrage.
``report`` wird ausschliesslich von Heartbeats einer Instanz mit
``role="license_server"`` gesetzt (Sub-Lizenzserver-Reporting). Alle anderen
Aufrufer lassen das Feld weg.
"""
if action not in ACTIONS:
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
if not client_private_key:
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert")
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": str(document.get("license_id") or ""),
"seq": int(document.get("seq") or 1),
"fingerprint": system_fingerprint(),
"hostname": hostname,
"action": action,
"nonce": nonce or new_nonce(),
"timestamp": iso(now or utcnow()),
}
if report is not None:
payload["report"] = dict(report)
payload["signature"] = sign(payload, client_private_key, SIG_CONTEXT_REQUEST)
return payload
def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
try:
stamp = parse_iso(timestamp)
except (ValueError, TypeError) as exc:
raise ProtocolViolation(f"{what}: Zeitstempel unlesbar") from exc
if stamp > now + timedelta(seconds=CLOCK_SKEW_SECONDS):
raise ReplayDetected(f"{what}: Zeitstempel liegt in der Zukunft")
if stamp < now - timedelta(seconds=REQUEST_MAX_AGE_SECONDS):
raise ReplayDetected(f"{what}: Zeitstempel ist zu alt")
def verify_client_request(
request: Mapping[str, Any],
license_public_key: str,
*,
expected_action: str | None = None,
now: datetime | None = None,
seen_nonce: Callable[[str], bool] | None = None,
) -> None:
"""Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen.
``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce
bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz
(in dieser Codebasis: Tabelle ``license_nonces``).
"""
if not isinstance(request, Mapping):
raise ProtocolViolation("Anfrage ist kein Objekt")
if int(request.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
action = str(request.get("action") or "")
if action not in ACTIONS:
raise ProtocolViolation("Unbekannte Aktion")
if expected_action and action != expected_action:
raise ProtocolViolation("Aktion passt nicht zum Endpunkt")
nonce = str(request.get("nonce") or "")
if not _NONCE_RE.match(nonce):
raise ProtocolViolation("Nonce hat ein unerwartetes Format")
fingerprint = str(request.get("fingerprint") or "")
if not _FINGERPRINT_RE.match(fingerprint):
raise ProtocolViolation("Fingerprint hat ein unerwartetes Format")
payload = {k: v for k, v in request.items() if k != "signature"}
if not verify(
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
):
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
moment = now or utcnow()
_check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage")
if seen_nonce is not None and seen_nonce(nonce):
raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet")
report = request.get("report")
if report is not None:
if not isinstance(report, Mapping):
raise ProtocolViolation("Bericht ist kein Objekt")
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
if total > MAX_REPORT_ROWS:
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
def build_master_response(
action: str,
*,
license_id: str,
fingerprint: str,
request_nonce: str,
master_private_key: str,
status: str = "ok",
detail: str = "",
license_update: Mapping[str, Any] | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage."""
if status not in RESPONSE_STATES:
raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}")
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"detail": detail,
"nonce": request_nonce,
"timestamp": iso(now or utcnow()),
}
if license_update is not None:
payload["license_update"] = dict(license_update)
payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE)
return payload
def verify_master_response(
response: Mapping[str, Any],
master_public_key: str,
*,
expected_license_id: str,
expected_fingerprint: str,
expected_action: str,
expected_nonce: str,
current_seq: int = 0,
now: datetime | None = None,
) -> None:
"""Verifiziert eine Master-Antwort clientseitig, komplett offline.
Prueft neben der Signatur, dass die Antwort tatsaechlich zu *dieser* Anfrage
gehoert (Lizenz-ID, Fingerprint, Aktion, Nonce) und dass ein mitgeliefertes
``license_update`` keine aeltere Revision ist.
"""
if not isinstance(response, Mapping):
raise ProtocolViolation("Antwort ist kein Objekt")
if int(response.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
payload = {k: v for k, v in response.items() if k != "signature"}
if not verify(
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
):
raise SignatureInvalid("Signatur der Antwort ist ungueltig")
if str(response.get("license_id") or "") != expected_license_id:
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz")
if str(response.get("fingerprint") or "") != expected_fingerprint:
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine")
if str(response.get("action") or "") != expected_action:
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion")
if str(response.get("nonce") or "") != expected_nonce:
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage")
if str(response.get("status") or "") not in RESPONSE_STATES:
raise ProtocolViolation("Unbekannter Antwortstatus")
_check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
update = response.get("license_update")
if update is not None:
if not isinstance(update, Mapping):
raise ProtocolViolation("license_update ist kein Objekt")
if int(update.get("seq") or 0) < int(current_seq or 0):
raise ReplayDetected("license_update ist aelter als die installierte Lizenz")