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,417 @@
"""Lizenzdatei-Format, Ausstellung, Verifikation und Statusberechnung.
Format-Version 2. Unterschiede zur Version 1 des Vorgaengerprojekts, jeweils mit
Begruendung:
1. **Der private Client-Schluessel steckt nicht mehr in der Lizenz-Payload.**
V1 lieferte ``license_privkey`` als Feld neben der signierten Payload in
derselben Datei aus. Wer diese Datei zu Supportzwecken weitergab, gab damit
das Aktivierungs-Credential mit. V2 liefert ein Bundle aus
``{"license": <signiertes Dokument>, "client_key": <privater Schluessel>}``,
das der Client beim Import aufteilt: Lizenzdokument nach ``license.json``,
Schluessel nach ``license_key.json`` mit Modus 0600.
2. **``lifetime`` ist ein signiertes Feld**, nicht mehr nur aus dem
Sentinel-Datum abgeleitet. Der Sentinel bleibt zusaetzlich erhalten, damit
jede Datumsvergleichslogik ohne Sonderfall auskommt.
3. **``not_before``** existiert und wird geprueft -- eine Lizenz, die versehentlich
mit Zukunftsdatum ausgestellt wurde, gilt nicht rueckwirkend.
4. **``master_key_id``** (Fingerprint des Master-Public-Keys) erlaubt Pinning und
Schluesselrotation auf Client-Seite.
5. **``seq``** (monoton steigende Revision) erlaubt es dem Client, ein per
Heartbeat nachgeliefertes ``license_update`` gegen Downgrade-Replay zu
schuetzen.
6. **Verifikation gegen einen gepinnten Aussteller-Schluessel** ist vorgesehen
(``trusted_master_keys``). V1 pruefte die Signatur ausschliesslich gegen den in
derselben Datei eingebetteten ``master_pubkey`` -- das beweist nur innere
Selbstkonsistenz, nicht die Herkunft.
"""
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any, Iterable, Mapping, Sequence
from .canonical import SIG_CONTEXT_LICENSE, generate_keypair, sign, verify
from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer
FORMAT_VERSION = 2
LICENSE_TYPES: tuple[str, ...] = ("trial", "standard", "custom", "enterprise")
LICENSE_ROLES: tuple[str, ...] = ("master", "license_server", "customer")
ALL_MODULES: tuple[str, ...] = ("dhcp", "fileshare", "maintenance")
LICENSE_TYPE_LABELS: dict[str, str] = {
"trial": "Testlizenz",
"standard": "Standard",
"custom": "Individuell",
"enterprise": "Enterprise",
}
MODULE_LABELS: dict[str, str] = {
"dhcp": "DHCP-Server (Kea)",
"fileshare": "Dateifreigaben",
"maintenance": "Wartung (SSH-Bulk)",
}
ROLE_LABELS: dict[str, str] = {
"master": "Master-Lizenzserver",
"license_server": "Lizenzserver",
"customer": "Kundeninstanz",
}
#: Kulanzzeitraum nach Ablauf, in dem Module weiterlaufen.
GRACE_PERIOD_DAYS = 30
#: Ab wann "laeuft bald ab" gemeldet wird.
EXPIRY_WARNING_DAYS = 30
#: Ab wann ein ausgebliebener Heartbeat gemeldet wird (schaltet nichts ab).
HEARTBEAT_WARNING_DAYS = 14
#: Standardlaufzeit einer Trial-Ausstellung.
TRIAL_DEFAULT_DAYS = 30
#: Sentinel-Ablaufdatum fuer Lifetime-Lizenzen.
LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
_PAYLOAD_FIELDS = (
"fmt",
"license_id",
"seq",
"customer",
"type",
"role",
"modules",
"lifetime",
"issued_at",
"not_before",
"expires_at",
"license_pubkey",
"master_pubkey",
"master_key_id",
"master_endpoint",
"vendor",
)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def iso(value: datetime) -> str:
"""ISO-8601 in UTC mit ``Z``-Suffix, sekundengenau (keine Mikrosekunden)."""
return value.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def parse_iso(value: str) -> datetime:
"""Parst ISO-8601. Ohne Zeitzone wird UTC angenommen."""
text = (value or "").strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
parsed = datetime.fromisoformat(text)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def key_id(public_key_b64: str) -> str:
"""Kurzer, stabiler Fingerprint eines Public Keys -- fuer Pinning und Anzeige."""
return hashlib.sha256((public_key_b64 or "").encode("ascii")).hexdigest()[:16]
@dataclass(frozen=True, slots=True)
class LicenseStatus:
"""Auswertung einer Lizenzdatei zu einem Zeitpunkt. Rein rechnerisch, ohne I/O."""
license_id: str
seq: int
type: str
role: str
customer: Mapping[str, Any]
vendor: Mapping[str, Any]
master_endpoint: str
master_key_id: str
modules: frozenset[str]
lifetime: bool
issued_at: datetime | None
expires_at: datetime | None
expired: bool
not_yet_valid: bool
days_left: int | None
days_since_expiry: int | None
expiring_soon: bool
grace_active: bool
modules_active: bool
warnings: tuple[str, ...] = field(default=())
def has_module(self, name: str) -> bool:
return self.modules_active and name in self.modules
def _validate_issue_args(
license_type: str, role: str, modules: Sequence[str], lifetime: bool
) -> None:
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r}")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r}")
unknown = sorted(set(modules) - set(ALL_MODULES))
if unknown:
raise ValueError(f"Unbekannte Module: {', '.join(unknown)}")
if lifetime and license_type == "trial":
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
# Lizenz ohne Module -- ein sinnloser Zustand.
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
if role != "customer" and modules:
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
def issue_license(
*,
customer: Mapping[str, Any],
license_type: str,
modules: Iterable[str],
valid_days: int,
master_private_key_b64: str,
master_public_key_b64: str,
master_endpoint: str,
vendor: Mapping[str, Any],
license_id: str | None = None,
role: str = "customer",
lifetime: bool = False,
seq: int = 1,
now: datetime | None = None,
) -> tuple[dict[str, Any], str]:
"""Stellt eine Lizenz aus.
Rueckgabe: ``(bundle, license_public_key)``. ``bundle`` ist die Datei, die
an den Kunden geht; ``license_public_key`` wandert in die Master-Datenbank,
damit der Master spaeter Client-Anfragen dieser Lizenz verifizieren kann.
"""
module_list = sorted({str(m) for m in modules})
_validate_issue_args(license_type, role, module_list, lifetime)
if not lifetime and int(valid_days) <= 0:
raise ValueError("valid_days muss positiv sein")
moment = now or utcnow()
client_private, client_public = generate_keypair()
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id or str(uuid.uuid4()),
"seq": int(seq),
"customer": dict(customer),
"type": license_type,
"role": role,
"modules": module_list,
"lifetime": bool(lifetime),
"issued_at": iso(moment),
"not_before": iso(moment),
"expires_at": LIFETIME_EXPIRES_AT
if lifetime
else iso(moment + timedelta(days=int(valid_days))),
"license_pubkey": client_public,
"master_pubkey": master_public_key_b64,
"master_key_id": key_id(master_public_key_b64),
"master_endpoint": (master_endpoint or "").rstrip("/"),
"vendor": dict(vendor),
}
signature = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
document = {**payload, "signature": signature}
bundle = {
"kind": "tesm-license-bundle",
"fmt": FORMAT_VERSION,
"license": document,
"client_key": client_private,
}
return bundle, client_public
def resign_license(
document: Mapping[str, Any],
*,
master_private_key_b64: str,
changes: Mapping[str, Any],
now: datetime | None = None,
) -> dict[str, Any]:
"""Erzeugt eine neu signierte Revision eines bestehenden Lizenzdokuments.
Wird fuer ``license_update`` im Heartbeat gebraucht (z. B. nachtraeglich
erweiterte Module oder verlaengerte Laufzeit). ``seq`` steigt dabei zwingend,
damit der Client eine aeltere Revision nicht als Update akzeptiert.
"""
payload = {k: v for k, v in document.items() if k != "signature"}
payload.update(changes)
payload["seq"] = int(document.get("seq") or 1) + 1
payload["issued_at"] = iso(now or utcnow())
_validate_issue_args(
str(payload.get("type")),
str(payload.get("role")),
[str(m) for m in payload.get("modules") or ()],
bool(payload.get("lifetime")),
)
payload["signature"] = sign(payload, master_private_key_b64, SIG_CONTEXT_LICENSE)
return payload
def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
"""Zerlegt ein Auslieferungs-Bundle in ``(lizenzdokument, client_private_key)``.
Akzeptiert zusaetzlich ein bereits aufgeteiltes Dokument (dann ist der
zweite Rueckgabewert leer) sowie -- fuer Migrationen -- eine V1-Datei mit
eingebettetem ``license_privkey``.
"""
if not isinstance(bundle, Mapping):
raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt")
inner = bundle.get("license")
if isinstance(inner, Mapping):
return dict(inner), str(bundle.get("client_key") or "")
if "license_privkey" in bundle: # V1-Kompatibilitaet
document = {k: v for k, v in bundle.items() if k != "license_privkey"}
return document, str(bundle.get("license_privkey") or "")
if "signature" in bundle and "license_id" in bundle:
return dict(bundle), ""
raise InvalidLicenseFile("Unbekanntes Lizenzdatei-Format")
def verify_license(
document: Mapping[str, Any],
*,
trusted_master_keys: Sequence[str] | None = None,
) -> None:
"""Verifiziert ein Lizenzdokument. Wirft bei Problemen, gibt sonst ``None``.
``trusted_master_keys`` sind Base64-Public-Keys, denen der Client vertraut
(im Build mitgeliefert oder beim ersten Import per Trust-on-First-Use
gepinnt). Ist die Liste leer/``None``, wird nur die innere Konsistenz der
Datei geprueft -- dann traegt der Status eine entsprechende Warnung.
"""
if not isinstance(document, Mapping):
raise InvalidLicenseFile("Lizenzdokument ist kein Objekt")
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
if missing:
raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}")
if int(document.get("fmt") or 0) != FORMAT_VERSION:
raise InvalidLicenseFile(
f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}"
f" (erwartet {FORMAT_VERSION})"
)
if document.get("type") not in LICENSE_TYPES:
raise InvalidLicenseFile("Unbekannter Lizenztyp")
if document.get("role") not in LICENSE_ROLES:
raise InvalidLicenseFile("Unbekannte Rolle")
master_pubkey = str(document.get("master_pubkey") or "")
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel")
payload = {k: v for k, v in document.items() if k != "signature"}
if not verify(
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
):
raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig")
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
raise UntrustedIssuer(
"Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt"
)
def license_status(
document: Mapping[str, Any],
*,
now: datetime | None = None,
issuer_pinned: bool = False,
) -> LicenseStatus:
"""Berechnet den Status. Verifiziert nicht selbst -- das macht ``verify_license``.
Bewusst getrennt gehalten, damit hier niemand versehentlich den Status einer
unverifizierten Datei berechnet und weiterverwendet.
"""
moment = now or utcnow()
lifetime = bool(document.get("lifetime"))
warnings: list[str] = []
if not issuer_pinned:
warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft")
issued_at: datetime | None = None
expires_at: datetime | None = None
not_before: datetime | None = None
for name in ("issued_at", "expires_at", "not_before"):
raw = document.get(name)
if not raw:
continue
try:
parsed = parse_iso(str(raw))
except ValueError:
warnings.append(f"Feld {name} ist kein gueltiges Datum")
continue
if name == "issued_at":
issued_at = parsed
elif name == "expires_at":
expires_at = parsed
else:
not_before = parsed
not_yet_valid = bool(not_before and moment < not_before)
days_left: int | None
days_since_expiry: int | None
if lifetime:
days_left = None
days_since_expiry = None
expired = False
elif expires_at is None:
# Fail-safe: unlesbares Ablaufdatum gilt als abgelaufen, nicht als gueltig.
days_left = None
days_since_expiry = None
expired = True
warnings.append("Ablaufdatum fehlt oder ist unlesbar")
else:
delta = expires_at - moment
expired = delta.total_seconds() <= 0
if expired:
days_left = 0
days_since_expiry = int((moment - expires_at).total_seconds() // 86400)
else:
days_left = -(-int(delta.total_seconds()) // 86400)
days_since_expiry = None
grace_active = bool(
expired and days_since_expiry is not None and days_since_expiry < GRACE_PERIOD_DAYS
)
modules_active = not not_yet_valid and (lifetime or not expired or grace_active)
expiring_soon = bool(
not lifetime
and not expired
and days_left is not None
and days_left <= EXPIRY_WARNING_DAYS
)
return LicenseStatus(
license_id=str(document.get("license_id") or ""),
seq=int(document.get("seq") or 1),
type=str(document.get("type") or ""),
# Fehlt die Rolle, gilt "customer" -- niemals ein privilegierterer Wert.
role=str(document.get("role") or "customer"),
customer=dict(document.get("customer") or {}),
vendor=dict(document.get("vendor") or {}),
master_endpoint=str(document.get("master_endpoint") or ""),
master_key_id=str(document.get("master_key_id") or ""),
modules=frozenset(str(m) for m in (document.get("modules") or ())),
lifetime=lifetime,
issued_at=issued_at,
expires_at=expires_at,
expired=expired,
not_yet_valid=not_yet_valid,
days_left=days_left,
days_since_expiry=days_since_expiry,
expiring_soon=expiring_soon,
grace_active=grace_active,
modules_active=modules_active,
warnings=tuple(warnings),
)