"""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": , "client_key": }``, 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, 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 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), )