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:
@@ -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),
|
||||
)
|
||||
Reference in New Issue
Block a user