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
@@ -0,0 +1,115 @@
"""Kanonisierung und domaenengetrennte Ed25519-Signaturen.
Warum eine eigene Datei: Signieren und Verifizieren muessen auf Client- und
Master-Seite bitidentisch arbeiten. Im Vorgaengerprojekt wurde das durch eine
handkopierte ``licensing.py`` in beiden Repos geloest -- hier ist es *ein*
installierbares Paket, das beide Anwendungen als Abhaengigkeit einbinden.
Sicherheitsrelevante Abweichung vom Vorgaenger: jede Signatur ist
**domaenengetrennt**. Vor die kanonischen Bytes wird ein Kontext-Praefix
gestellt (``SIG_CONTEXT_*``). Eine Signatur ueber eine Lizenz-Payload kann
dadurch nie als Signatur ueber eine Aktivierungsantwort durchgehen, selbst wenn
ein Angreifer Feldnamen so waehlen koennte, dass zwei Strukturen identische
kanonische Bytes ergeben.
"""
from __future__ import annotations
import base64
import binascii
import json
from typing import Any, Mapping
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric import ed25519
# Domaenen-Praefixe. Aenderungen hieran sind ein Protokollbruch.
SIG_CONTEXT_LICENSE = b"tesm/license-file/v2\x00"
SIG_CONTEXT_REQUEST = b"tesm/client-request/v2\x00"
SIG_CONTEXT_RESPONSE = b"tesm/master-response/v2\x00"
def canonical_bytes(payload: Mapping[str, Any]) -> bytes:
"""Deterministische JSON-Serialisierung: sortierte Schluessel, keine Whitespaces.
``ensure_ascii=True`` (Default) ist hier gewollt: die Bytes bleiben damit
unabhaengig von der Unicode-Normalform der Eingabe reproduzierbar.
"""
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def _b64d(value: str, *, field: str) -> bytes:
try:
return base64.b64decode(value, validate=True)
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
def generate_keypair() -> tuple[str, str]:
"""Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``."""
private = ed25519.Ed25519PrivateKey.generate()
return (
base64.b64encode(private.private_bytes_raw()).decode("ascii"),
base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"),
)
def public_from_private(private_key_b64: str) -> str:
"""Leitet den oeffentlichen Teil aus einem privaten Schluessel ab."""
key = ed25519.Ed25519PrivateKey.from_private_bytes(
_b64d(private_key_b64, field="private_key")
)
return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
def sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str:
"""Signiert ``payload`` domaenengetrennt. Rueckgabe: Base64-Signatur."""
key = ed25519.Ed25519PrivateKey.from_private_bytes(
_b64d(private_key_b64, field="private_key")
)
return base64.b64encode(key.sign(context + canonical_bytes(payload))).decode("ascii")
def verify(
payload: Mapping[str, Any],
signature_b64: str,
public_key_b64: str,
context: bytes,
) -> bool:
"""Verifiziert eine Signatur.
Gibt ``False`` zurueck statt zu werfen -- der Aufrufer kann "ungueltig" so
nie mit "abgestuerzt" verwechseln. Das war im Vorgaengerprojekt bereits eine
bewusste Entscheidung und bleibt es.
"""
if not signature_b64 or not public_key_b64:
return False
try:
key = ed25519.Ed25519PublicKey.from_public_bytes(
_b64d(public_key_b64, field="public_key")
)
key.verify(_b64d(signature_b64, field="signature"), context + canonical_bytes(payload))
return True
except (InvalidSignature, ValueError, TypeError, KeyError):
return False
def encode_code(data: Mapping[str, Any]) -> str:
"""Kodiert ein Protokollobjekt als kopierbaren Offline-Code (Base64url, ohne Padding)."""
raw = base64.urlsafe_b64encode(canonical_bytes(data)).decode("ascii")
return raw.rstrip("=")
def decode_code(code: str) -> dict[str, Any]:
"""Dekodiert einen Offline-Code. Toleriert fehlendes Padding und Whitespace/Umbrueche."""
cleaned = "".join(code.split())
if not cleaned:
raise ValueError("Leerer Code")
cleaned += "=" * (-len(cleaned) % 4)
try:
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
raise ValueError("Code ist unlesbar oder beschaedigt") from exc
if not isinstance(data, dict):
raise ValueError("Code enthaelt kein Objekt")
return data