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:
@@ -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