Nur noch, was zum Installieren und Betreiben des Lizenzservers noetig ist

Entfernt: Tests, Architekturdokumentation und die Anwendung TESM. Beides liegt
im Entwicklungsrepository alientim/TESM-DEV.

Neu: packages/tesm-licensing-server -- die Serverhaelfte des Lizenzprotokolls
(issue_license, resign_license, build_master_response, verify_client_request,
Schluesselerzeugung). Sie war bisher Teil von tesm-licensing und lag damit auch
in jeder Client-Installation. Die gemeinsame Haelfte bleibt in tesm-licensing
und wird von beiden Seiten benutzt.

install.sh installiert diese Serverhaelfte ausschliesslich fuer
--app tesm-license und bricht ab, wenn sie fehlt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:37:12 +02:00
co-authored by Claude Opus 5
parent f7805a2180
commit 6ac2ed0a62
24 changed files with 344 additions and 4336 deletions
@@ -0,0 +1,13 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.0.0"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.0.0", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,263 @@
"""Die Serverhaelfte des Lizenzprotokolls: ausstellen, erneuern, antworten.
Dieses Paket liegt **nur** beim Lizenzserver. Ein Client verifiziert Lizenzen
und stellt Anfragen -- ausstellen kann er nichts, und er soll den Code dafuer
auch nicht mitbringen. Die gemeinsame Haelfte (kanonisches JSON, Signieren und
Pruefen, Fingerabdruck, Statusbewertung, Client-Anfragen) steht in
``tesm_licensing`` und wird von beiden Seiten benutzt.
Schluesselerzeugung gehoert ebenfalls hierher: der Server erzeugt seinen
Signaturschluessel und je Lizenz ein Client-Schluesselpaar. Der Client
*empfaengt* seinen Schluessel mit dem Bundle; er erzeugt nie einen.
"""
from __future__ import annotations
import base64
import uuid
from datetime import datetime, timedelta
from typing import Any, Callable, Iterable, Mapping, Sequence
from cryptography.hazmat.primitives.asymmetric import ed25519
from tesm_licensing import (
ACTIONS,
ALL_MODULES,
FORMAT_VERSION,
LICENSE_ROLES,
LICENSE_TYPES,
LIFETIME_EXPIRES_AT,
MAX_REPORT_ROWS,
RESPONSE_STATES,
SIG_CONTEXT_LICENSE,
SIG_CONTEXT_REQUEST,
SIG_CONTEXT_RESPONSE,
ProtocolViolation,
ReplayDetected,
SignatureInvalid,
canonical_bytes,
iso,
key_id,
new_nonce,
parse_iso,
utcnow,
)
from tesm_licensing.canonical import _b64d, sign, verify
from tesm_licensing.protocol import _FINGERPRINT_RE, _NONCE_RE, check_freshness
__all__ = [
"build_master_response",
"generate_keypair",
"issue_license",
"public_from_private",
"resign_license",
"verify_client_request",
]
def generate_keypair() -> tuple[str, str]:
"""Erzeugt ein Ed25519-Schluesselpaar. Rueckgabe: ``(private_b64, public_b64)``."""
private = ed25519.Ed25519PrivateKey.generate()
return (
base64.b64encode(private.private_bytes_raw()).decode("ascii"),
base64.b64encode(private.public_key().public_bytes_raw()).decode("ascii"),
)
def public_from_private(private_key_b64: str) -> str:
"""Leitet den oeffentlichen Teil aus einem privaten Schluessel ab."""
key = ed25519.Ed25519PrivateKey.from_private_bytes(
_b64d(private_key_b64, field="private_key")
)
return base64.b64encode(key.public_key().public_bytes_raw()).decode("ascii")
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 verify_client_request(
request: Mapping[str, Any],
license_public_key: str,
*,
expected_action: str | None = None,
now: datetime | None = None,
seen_nonce: Callable[[str], bool] | None = None,
) -> None:
"""Verifiziert eine Client-Anfrage serverseitig. Wirft bei Problemen.
``seen_nonce`` ist ein Callback, der ``True`` zurueckgibt, wenn die Nonce
bereits verbraucht ist. Der Aufrufer entscheidet ueber die Persistenz
(in dieser Codebasis: Tabelle ``license_nonces``).
"""
if not isinstance(request, Mapping):
raise ProtocolViolation("Anfrage ist kein Objekt")
if int(request.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
action = str(request.get("action") or "")
if action not in ACTIONS:
raise ProtocolViolation("Unbekannte Aktion")
if expected_action and action != expected_action:
raise ProtocolViolation("Aktion passt nicht zum Endpunkt")
nonce = str(request.get("nonce") or "")
if not _NONCE_RE.match(nonce):
raise ProtocolViolation("Nonce hat ein unerwartetes Format")
fingerprint = str(request.get("fingerprint") or "")
if not _FINGERPRINT_RE.match(fingerprint):
raise ProtocolViolation("Fingerprint hat ein unerwartetes Format")
payload = {k: v for k, v in request.items() if k != "signature"}
if not verify(
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
):
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
moment = now or utcnow()
check_freshness(str(request.get("timestamp") or ""), moment, "Anfrage")
if seen_nonce is not None and seen_nonce(nonce):
raise ReplayDetected("Diese Anfrage wurde bereits verarbeitet")
report = request.get("report")
if report is not None:
if not isinstance(report, Mapping):
raise ProtocolViolation("Bericht ist kein Objekt")
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
if total > MAX_REPORT_ROWS:
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
def build_master_response(
action: str,
*,
license_id: str,
fingerprint: str,
request_nonce: str,
master_private_key: str,
status: str = "ok",
detail: str = "",
license_update: Mapping[str, Any] | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Baut eine signierte Master-Antwort, gebunden an die Nonce der Anfrage."""
if status not in RESPONSE_STATES:
raise ProtocolViolation(f"Unbekannter Antwortstatus: {status!r}")
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
"license_id": license_id,
"fingerprint": fingerprint,
"action": action,
"status": status,
"detail": detail,
"nonce": request_nonce,
"timestamp": iso(now or utcnow()),
}
if license_update is not None:
payload["license_update"] = dict(license_update)
payload["signature"] = sign(payload, master_private_key, SIG_CONTEXT_RESPONSE)
return payload