Files
tesm-license/packages/tesm-licensing/src/tesm_licensing/license.py
T

339 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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, 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 enthält 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 unvollständig: {', '.join(missing)}")
if int(document.get("fmt") or 0) != FORMAT_VERSION:
raise InvalidLicenseFile(
f"Nicht unterstützte 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 Schlüssel")
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 ungültig")
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
raise UntrustedIssuer(
"Die Lizenz wurde nicht von einem vertrauenswürdigen Lizenzserver ausgestellt"
)
def tage_bis(expires_at: datetime, *, now: datetime | None = None) -> int:
"""Restlaufzeit in **angefangenen** Tagen.
Aufgerundet, und das ist eine Entscheidung: eine Lizenz, die in zwei
Stunden ablaeuft, hat "1 Tag" und nicht "0". Null bedeutet hier
ausschliesslich *abgelaufen* -- eine gueltige Lizenz, die mit "0 Tage"
dasteht, liest sich wie eine tote.
**Es gibt genau diese eine Rechnung.** Der Lizenzserver hatte eine zweite,
abgerundete: dieselbe Lizenz zeigte in seiner Oberflaeche "21 Tage" und im
Abruf beim Kunden "22". Zwei Zahlen fuer dieselbe Frage sind schlimmer als
eine ungenaue -- sie kosten jemanden einen Nachmittag mit der Suche nach
einem Fehler, den es nicht gibt.
"""
moment = now or utcnow()
sekunden = int((expires_at - moment).total_seconds())
if sekunden <= 0:
return 0
return -(-sekunden // 86400)
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 geprüft")
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 gültiges 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 = tage_bis(expires_at, now=moment)
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),
)