Nur noch, was zum Installieren und Betreiben von TESM noetig ist
Entfernt: Tests, Architekturdokumentation und die Serverhaelfte des Lizenzprotokolls. Beides liegt im Entwicklungsrepository alientim/TESM-DEV. Das Lizenzprotokoll ist geteilt. TESM braucht nur die gemeinsame Haelfte (tesm-licensing): Lizenzen verifizieren, Status bewerten, Anfragen stellen, Antworten pruefen. Ausstellen, erneuern, Antworten signieren und Schluesselerzeugung liegen jetzt in tesm-licensing-server und damit ausschliesslich beim Lizenzserver -- ein Client soll den Code zum Ausstellen nicht einmal mitbringen. Nachgeprueft: kein Modul von TESM oder tesm-core importiert eine der verschobenen Funktionen. install.sh installiert entsprechend je Anwendung nur die noetigen Pakete und bricht mit klarer Meldung ab, wenn die verlangte Anwendung nicht im Baum liegt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,6 @@ from .canonical import (
|
||||
canonical_bytes,
|
||||
decode_code,
|
||||
encode_code,
|
||||
generate_keypair,
|
||||
public_from_private,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
@@ -44,12 +42,10 @@ from .license import (
|
||||
ROLE_LABELS,
|
||||
TRIAL_DEFAULT_DAYS,
|
||||
LicenseStatus,
|
||||
issue_license,
|
||||
iso,
|
||||
key_id,
|
||||
license_status,
|
||||
parse_iso,
|
||||
resign_license,
|
||||
split_bundle,
|
||||
utcnow,
|
||||
verify_license,
|
||||
@@ -62,9 +58,7 @@ from .protocol import (
|
||||
REQUEST_MAX_AGE_SECONDS,
|
||||
RESPONSE_STATES,
|
||||
build_client_request,
|
||||
build_master_response,
|
||||
new_nonce,
|
||||
verify_client_request,
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
@@ -100,26 +94,20 @@ __all__ = [
|
||||
"TRIAL_DEFAULT_DAYS",
|
||||
"UntrustedIssuer",
|
||||
"build_client_request",
|
||||
"build_master_response",
|
||||
"canonical_bytes",
|
||||
"decode_code",
|
||||
"encode_code",
|
||||
"generate_keypair",
|
||||
"issue_license",
|
||||
"iso",
|
||||
"key_id",
|
||||
"license_status",
|
||||
"machine_identity",
|
||||
"new_nonce",
|
||||
"parse_iso",
|
||||
"public_from_private",
|
||||
"resign_license",
|
||||
"sign",
|
||||
"split_bundle",
|
||||
"system_fingerprint",
|
||||
"utcnow",
|
||||
"verify",
|
||||
"verify_client_request",
|
||||
"verify_license",
|
||||
"verify_master_response",
|
||||
]
|
||||
|
||||
@@ -45,21 +45,8 @@ def _b64d(value: str, *, field: str) -> bytes:
|
||||
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
|
||||
|
||||
|
||||
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 sign(payload: Mapping[str, Any], private_key_b64: str, context: bytes) -> str:
|
||||
|
||||
@@ -34,7 +34,7 @@ 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 .canonical import SIG_CONTEXT_LICENSE, sign, verify
|
||||
from .errors import InvalidLicenseFile, SignatureInvalid, UntrustedIssuer
|
||||
|
||||
FORMAT_VERSION = 2
|
||||
@@ -147,110 +147,10 @@ class LicenseStatus:
|
||||
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]:
|
||||
|
||||
@@ -95,7 +95,7 @@ def build_client_request(
|
||||
return payload
|
||||
|
||||
|
||||
def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
def check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
try:
|
||||
stamp = parse_iso(timestamp)
|
||||
except (ValueError, TypeError) as exc:
|
||||
@@ -106,89 +106,8 @@ def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
raise ReplayDetected(f"{what}: Zeitstempel ist zu alt")
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def verify_master_response(
|
||||
@@ -230,7 +149,7 @@ def verify_master_response(
|
||||
if str(response.get("status") or "") not in RESPONSE_STATES:
|
||||
raise ProtocolViolation("Unbekannter Antwortstatus")
|
||||
|
||||
_check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
|
||||
check_freshness(str(response.get("timestamp") or ""), now or utcnow(), "Antwort")
|
||||
|
||||
update = response.get("license_update")
|
||||
if update is not None:
|
||||
|
||||
Reference in New Issue
Block a user