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,13 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "tesm-licensing"
|
||||
version = "2.0.0"
|
||||
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["cryptography>=42"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""TESM-Lizenzprotokoll -- gemeinsames Paket von TESM und TESM-Lizenzserver.
|
||||
|
||||
Im Vorgaengerprojekt lag dieses Modul als handkopierte, byte-identisch zu
|
||||
haltende ``licensing.py`` in beiden Repos. Jede unabhaengige Aenderung an nur
|
||||
einer Kopie brach die gegenseitige Signaturpruefung, ohne dass das lokal
|
||||
auffiel. Hier ist es ein installierbares Paket mit eigener Version -- die
|
||||
Invariante ist damit strukturell erfuellt statt durch Disziplin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .canonical import (
|
||||
SIG_CONTEXT_LICENSE,
|
||||
SIG_CONTEXT_REQUEST,
|
||||
SIG_CONTEXT_RESPONSE,
|
||||
canonical_bytes,
|
||||
decode_code,
|
||||
encode_code,
|
||||
generate_keypair,
|
||||
public_from_private,
|
||||
sign,
|
||||
verify,
|
||||
)
|
||||
from .errors import (
|
||||
InvalidLicenseFile,
|
||||
LicensingError,
|
||||
ProtocolViolation,
|
||||
ReplayDetected,
|
||||
SignatureInvalid,
|
||||
UntrustedIssuer,
|
||||
)
|
||||
from .fingerprint import machine_identity, system_fingerprint
|
||||
from .license import (
|
||||
ALL_MODULES,
|
||||
EXPIRY_WARNING_DAYS,
|
||||
FORMAT_VERSION,
|
||||
GRACE_PERIOD_DAYS,
|
||||
HEARTBEAT_WARNING_DAYS,
|
||||
LICENSE_ROLES,
|
||||
LICENSE_TYPE_LABELS,
|
||||
LICENSE_TYPES,
|
||||
LIFETIME_EXPIRES_AT,
|
||||
MODULE_LABELS,
|
||||
ROLE_LABELS,
|
||||
TRIAL_DEFAULT_DAYS,
|
||||
LicenseStatus,
|
||||
issue_license,
|
||||
iso,
|
||||
key_id,
|
||||
license_status,
|
||||
parse_iso,
|
||||
resign_license,
|
||||
split_bundle,
|
||||
utcnow,
|
||||
verify_license,
|
||||
)
|
||||
from .protocol import (
|
||||
ACTIONS,
|
||||
CLOCK_SKEW_SECONDS,
|
||||
HEARTBEAT_INTERVAL_SECONDS,
|
||||
MAX_REPORT_ROWS,
|
||||
REQUEST_MAX_AGE_SECONDS,
|
||||
RESPONSE_STATES,
|
||||
build_client_request,
|
||||
build_master_response,
|
||||
new_nonce,
|
||||
verify_client_request,
|
||||
verify_master_response,
|
||||
)
|
||||
|
||||
__version__ = "2.0.0"
|
||||
|
||||
__all__ = [
|
||||
"ACTIONS",
|
||||
"ALL_MODULES",
|
||||
"CLOCK_SKEW_SECONDS",
|
||||
"EXPIRY_WARNING_DAYS",
|
||||
"FORMAT_VERSION",
|
||||
"GRACE_PERIOD_DAYS",
|
||||
"HEARTBEAT_INTERVAL_SECONDS",
|
||||
"HEARTBEAT_WARNING_DAYS",
|
||||
"InvalidLicenseFile",
|
||||
"LICENSE_ROLES",
|
||||
"LICENSE_TYPES",
|
||||
"LICENSE_TYPE_LABELS",
|
||||
"LIFETIME_EXPIRES_AT",
|
||||
"LicenseStatus",
|
||||
"LicensingError",
|
||||
"MAX_REPORT_ROWS",
|
||||
"MODULE_LABELS",
|
||||
"ProtocolViolation",
|
||||
"REQUEST_MAX_AGE_SECONDS",
|
||||
"RESPONSE_STATES",
|
||||
"ROLE_LABELS",
|
||||
"ReplayDetected",
|
||||
"SIG_CONTEXT_LICENSE",
|
||||
"SIG_CONTEXT_REQUEST",
|
||||
"SIG_CONTEXT_RESPONSE",
|
||||
"SignatureInvalid",
|
||||
"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",
|
||||
]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Kanonisierung und domaenengetrennte Ed25519-Signaturen.
|
||||
|
||||
Warum eine eigene Datei: Signieren und Verifizieren muessen auf Client- und
|
||||
Master-Seite bitidentisch arbeiten. Im Vorgaengerprojekt wurde das durch eine
|
||||
handkopierte ``licensing.py`` in beiden Repos geloest -- hier ist es *ein*
|
||||
installierbares Paket, das beide Anwendungen als Abhaengigkeit einbinden.
|
||||
|
||||
Sicherheitsrelevante Abweichung vom Vorgaenger: jede Signatur ist
|
||||
**domaenengetrennt**. Vor die kanonischen Bytes wird ein Kontext-Praefix
|
||||
gestellt (``SIG_CONTEXT_*``). Eine Signatur ueber eine Lizenz-Payload kann
|
||||
dadurch nie als Signatur ueber eine Aktivierungsantwort durchgehen, selbst wenn
|
||||
ein Angreifer Feldnamen so waehlen koennte, dass zwei Strukturen identische
|
||||
kanonische Bytes ergeben.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
from typing import Any, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
|
||||
# Domaenen-Praefixe. Aenderungen hieran sind ein Protokollbruch.
|
||||
SIG_CONTEXT_LICENSE = b"tesm/license-file/v2\x00"
|
||||
SIG_CONTEXT_REQUEST = b"tesm/client-request/v2\x00"
|
||||
SIG_CONTEXT_RESPONSE = b"tesm/master-response/v2\x00"
|
||||
|
||||
|
||||
def canonical_bytes(payload: Mapping[str, Any]) -> bytes:
|
||||
"""Deterministische JSON-Serialisierung: sortierte Schluessel, keine Whitespaces.
|
||||
|
||||
``ensure_ascii=True`` (Default) ist hier gewollt: die Bytes bleiben damit
|
||||
unabhaengig von der Unicode-Normalform der Eingabe reproduzierbar.
|
||||
"""
|
||||
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
|
||||
|
||||
def _b64d(value: str, *, field: str) -> bytes:
|
||||
try:
|
||||
return base64.b64decode(value, validate=True)
|
||||
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
|
||||
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:
|
||||
"""Signiert ``payload`` domaenengetrennt. Rueckgabe: Base64-Signatur."""
|
||||
key = ed25519.Ed25519PrivateKey.from_private_bytes(
|
||||
_b64d(private_key_b64, field="private_key")
|
||||
)
|
||||
return base64.b64encode(key.sign(context + canonical_bytes(payload))).decode("ascii")
|
||||
|
||||
|
||||
def verify(
|
||||
payload: Mapping[str, Any],
|
||||
signature_b64: str,
|
||||
public_key_b64: str,
|
||||
context: bytes,
|
||||
) -> bool:
|
||||
"""Verifiziert eine Signatur.
|
||||
|
||||
Gibt ``False`` zurueck statt zu werfen -- der Aufrufer kann "ungueltig" so
|
||||
nie mit "abgestuerzt" verwechseln. Das war im Vorgaengerprojekt bereits eine
|
||||
bewusste Entscheidung und bleibt es.
|
||||
"""
|
||||
if not signature_b64 or not public_key_b64:
|
||||
return False
|
||||
try:
|
||||
key = ed25519.Ed25519PublicKey.from_public_bytes(
|
||||
_b64d(public_key_b64, field="public_key")
|
||||
)
|
||||
key.verify(_b64d(signature_b64, field="signature"), context + canonical_bytes(payload))
|
||||
return True
|
||||
except (InvalidSignature, ValueError, TypeError, KeyError):
|
||||
return False
|
||||
|
||||
|
||||
def encode_code(data: Mapping[str, Any]) -> str:
|
||||
"""Kodiert ein Protokollobjekt als kopierbaren Offline-Code (Base64url, ohne Padding)."""
|
||||
raw = base64.urlsafe_b64encode(canonical_bytes(data)).decode("ascii")
|
||||
return raw.rstrip("=")
|
||||
|
||||
|
||||
def decode_code(code: str) -> dict[str, Any]:
|
||||
"""Dekodiert einen Offline-Code. Toleriert fehlendes Padding und Whitespace/Umbrueche."""
|
||||
cleaned = "".join(code.split())
|
||||
if not cleaned:
|
||||
raise ValueError("Leerer Code")
|
||||
cleaned += "=" * (-len(cleaned) % 4)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
|
||||
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
||||
raise ValueError("Code ist unlesbar oder beschaedigt") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Code enthaelt kein Objekt")
|
||||
return data
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Fehlerklassen des Lizenzprotokolls.
|
||||
|
||||
Alle Fehler sind bewusst so gebaut, dass sie *keine* Geheimnisse (Schluessel,
|
||||
Signaturen, Payload-Inhalte) in ihrer Textform tragen -- sie landen in Logs und
|
||||
teilweise in HTTP-Antworten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class LicensingError(Exception):
|
||||
"""Basisklasse aller Protokollfehler."""
|
||||
|
||||
|
||||
class InvalidLicenseFile(LicensingError):
|
||||
"""Lizenzdatei ist strukturell kaputt, unvollstaendig oder falsch versioniert."""
|
||||
|
||||
|
||||
class UntrustedIssuer(LicensingError):
|
||||
"""Signatur ist in sich gueltig, der Aussteller aber nicht vertrauenswuerdig (Pinning)."""
|
||||
|
||||
|
||||
class SignatureInvalid(LicensingError):
|
||||
"""Signatur passt nicht zur Payload."""
|
||||
|
||||
|
||||
class ReplayDetected(LicensingError):
|
||||
"""Nonce wurde schon gesehen oder Zeitstempel liegt ausserhalb des Frischefensters."""
|
||||
|
||||
|
||||
class ProtocolViolation(LicensingError):
|
||||
"""Anfrage/Antwort verletzt das erwartete Format oder die erwartete Aktion."""
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Maschinen-Fingerprint der lizenzierten Instanz.
|
||||
|
||||
Jede Bindungs-/Autorisierungsentscheidung des Protokolls laeuft ueber diesen
|
||||
Wert -- nie ueber ``hostname`` oder ``license_id`` allein.
|
||||
|
||||
Aenderung gegenueber dem Vorgaenger (bewusst): der Hostname ist **nicht** mehr
|
||||
Teil des Hashes. Im Vorgaengerprojekt war ``sha256(machine_id + ":" + hostname)``
|
||||
im Einsatz, wodurch eine reine Umbenennung des Hosts den Fingerprint aenderte
|
||||
und die Lizenz "already_bound_elsewhere" lief -- ein Support-Fall ohne
|
||||
Sicherheitsgewinn. Jetzt zaehlt ausschliesslich die stabile Maschinenidentitaet
|
||||
(``/etc/machine-id``, ergaenzt um die DMI-Produkt-UUID, falls lesbar).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
_MACHINE_ID_PATHS = (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id"))
|
||||
_DMI_UUID_PATH = Path("/sys/class/dmi/id/product_uuid")
|
||||
|
||||
FINGERPRINT_CONTEXT = b"tesm/machine-fingerprint/v2\x00"
|
||||
|
||||
|
||||
def _read_first(paths: tuple[Path, ...]) -> str:
|
||||
for path in paths:
|
||||
try:
|
||||
value = path.read_text(encoding="utf-8", errors="strict").strip()
|
||||
except OSError:
|
||||
continue
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def machine_identity() -> str:
|
||||
"""Rohe, ungehashte Maschinenidentitaet -- nur fuer Diagnose/Tests."""
|
||||
machine_id = _read_first(_MACHINE_ID_PATHS)
|
||||
dmi_uuid = ""
|
||||
try:
|
||||
dmi_uuid = _DMI_UUID_PATH.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
pass
|
||||
if machine_id or dmi_uuid:
|
||||
return f"{machine_id}|{dmi_uuid}"
|
||||
# Letzter Ausweg (Windows/Container ohne machine-id): Hostname. Produktivziel
|
||||
# ist Linux, dort greift dieser Zweig nicht.
|
||||
return f"hostname:{socket.gethostname()}"
|
||||
|
||||
|
||||
def system_fingerprint() -> str:
|
||||
"""SHA-256-Hex-Digest der Maschinenidentitaet, domaenengetrennt gehasht."""
|
||||
return hashlib.sha256(FINGERPRINT_CONTEXT + machine_identity().encode("utf-8")).hexdigest()
|
||||
@@ -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),
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Aktivierungs-, Deaktivierungs- und Heartbeat-Protokoll.
|
||||
|
||||
Beide Transportwege -- HTTPS-JSON und kopierbarer Offline-Code -- tragen exakt
|
||||
dieselben signierten Objekte. Es gibt deshalb nur *einen* Satz
|
||||
Verifikationsfunktionen; der Transport ist reine Verpackung.
|
||||
|
||||
Haertungen gegenueber dem Vorgaengerprojekt:
|
||||
|
||||
* **Domaenengetrennte Signaturen** (siehe ``canonical``): eine Lizenz-Signatur
|
||||
kann nie als Antwort-Signatur durchgehen und umgekehrt.
|
||||
* **Frischefenster**: Anfragen und Antworten tragen einen Zeitstempel, der
|
||||
gegen ein Fenster geprueft wird (``REQUEST_MAX_AGE_SECONDS``, plus
|
||||
``CLOCK_SKEW_SECONDS`` Toleranz in die Zukunft). V1 pruefte den Zeitstempel
|
||||
gar nicht -- eine abgefangene Aktivierungsanfrage war unbegrenzt
|
||||
wiederverwendbar.
|
||||
* **Nonce-Replay-Schutz**: der Master kann ueber ``seen_nonce`` eine
|
||||
Persistenzschicht einhaengen; ein zweites Mal gesehene Nonce wird abgewiesen.
|
||||
* **Antwort-an-Anfrage-Bindung**: die Master-Antwort echot die Nonce der Anfrage
|
||||
in ihrer signierten Payload. Eine alte "ok"-Antwort kann dadurch nicht gegen
|
||||
eine neue Anfrage eingespielt werden (relevant vor allem im Offline-Code-Weg,
|
||||
wo Codes per Copy-Paste durch die Gegend wandern).
|
||||
* **Downgrade-Schutz fuer ``license_update``**: das nachgelieferte Dokument muss
|
||||
eine hoehere ``seq`` tragen als das aktuell installierte.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from .canonical import SIG_CONTEXT_REQUEST, SIG_CONTEXT_RESPONSE, sign, verify
|
||||
from .errors import ProtocolViolation, ReplayDetected, SignatureInvalid
|
||||
from .license import FORMAT_VERSION, iso, parse_iso, utcnow
|
||||
from .fingerprint import system_fingerprint
|
||||
|
||||
#: Erlaubte Aktionen des Protokolls.
|
||||
ACTIONS: tuple[str, ...] = ("activate", "deactivate", "heartbeat")
|
||||
|
||||
#: Antwortstatus, die der Master senden kann.
|
||||
RESPONSE_STATES: tuple[str, ...] = ("ok", "revoked", "expired", "rejected")
|
||||
|
||||
#: Wie alt eine Anfrage/Antwort maximal sein darf (Offline-Codes brauchen Luft).
|
||||
REQUEST_MAX_AGE_SECONDS = 7 * 24 * 3600
|
||||
#: Toleranz fuer Uhren, die leicht vorlaufen.
|
||||
CLOCK_SKEW_SECONDS = 300
|
||||
#: Regulaerer Heartbeat-Abstand des Clients.
|
||||
HEARTBEAT_INTERVAL_SECONDS = 6 * 3600
|
||||
#: Obergrenze fuer Zeilen in einem Sub-Lizenzserver-Bericht.
|
||||
MAX_REPORT_ROWS = 5000
|
||||
|
||||
_NONCE_RE = re.compile(r"^[A-Za-z0-9_-]{16,64}$")
|
||||
_FINGERPRINT_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def new_nonce() -> str:
|
||||
return secrets.token_urlsafe(18)
|
||||
|
||||
|
||||
def build_client_request(
|
||||
action: str,
|
||||
document: Mapping[str, Any],
|
||||
client_private_key: str,
|
||||
*,
|
||||
hostname: str = "",
|
||||
nonce: str | None = None,
|
||||
report: Mapping[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Baut eine signierte Client-Anfrage.
|
||||
|
||||
``report`` wird ausschliesslich von Heartbeats einer Instanz mit
|
||||
``role="license_server"`` gesetzt (Sub-Lizenzserver-Reporting). Alle anderen
|
||||
Aufrufer lassen das Feld weg.
|
||||
"""
|
||||
if action not in ACTIONS:
|
||||
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
|
||||
if not client_private_key:
|
||||
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"fmt": FORMAT_VERSION,
|
||||
"license_id": str(document.get("license_id") or ""),
|
||||
"seq": int(document.get("seq") or 1),
|
||||
"fingerprint": system_fingerprint(),
|
||||
"hostname": hostname,
|
||||
"action": action,
|
||||
"nonce": nonce or new_nonce(),
|
||||
"timestamp": iso(now or utcnow()),
|
||||
}
|
||||
if report is not None:
|
||||
payload["report"] = dict(report)
|
||||
payload["signature"] = sign(payload, client_private_key, SIG_CONTEXT_REQUEST)
|
||||
return payload
|
||||
|
||||
|
||||
def _check_freshness(timestamp: str, now: datetime, what: str) -> None:
|
||||
try:
|
||||
stamp = parse_iso(timestamp)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ProtocolViolation(f"{what}: Zeitstempel unlesbar") from exc
|
||||
if stamp > now + timedelta(seconds=CLOCK_SKEW_SECONDS):
|
||||
raise ReplayDetected(f"{what}: Zeitstempel liegt in der Zukunft")
|
||||
if stamp < now - timedelta(seconds=REQUEST_MAX_AGE_SECONDS):
|
||||
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(
|
||||
response: Mapping[str, Any],
|
||||
master_public_key: str,
|
||||
*,
|
||||
expected_license_id: str,
|
||||
expected_fingerprint: str,
|
||||
expected_action: str,
|
||||
expected_nonce: str,
|
||||
current_seq: int = 0,
|
||||
now: datetime | None = None,
|
||||
) -> None:
|
||||
"""Verifiziert eine Master-Antwort clientseitig, komplett offline.
|
||||
|
||||
Prueft neben der Signatur, dass die Antwort tatsaechlich zu *dieser* Anfrage
|
||||
gehoert (Lizenz-ID, Fingerprint, Aktion, Nonce) und dass ein mitgeliefertes
|
||||
``license_update`` keine aeltere Revision ist.
|
||||
"""
|
||||
if not isinstance(response, Mapping):
|
||||
raise ProtocolViolation("Antwort ist kein Objekt")
|
||||
if int(response.get("fmt") or 0) != FORMAT_VERSION:
|
||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
||||
|
||||
payload = {k: v for k, v in response.items() if k != "signature"}
|
||||
if not verify(
|
||||
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
|
||||
):
|
||||
raise SignatureInvalid("Signatur der Antwort ist ungueltig")
|
||||
|
||||
if str(response.get("license_id") or "") != expected_license_id:
|
||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz")
|
||||
if str(response.get("fingerprint") or "") != expected_fingerprint:
|
||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine")
|
||||
if str(response.get("action") or "") != expected_action:
|
||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion")
|
||||
if str(response.get("nonce") or "") != expected_nonce:
|
||||
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage")
|
||||
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")
|
||||
|
||||
update = response.get("license_update")
|
||||
if update is not None:
|
||||
if not isinstance(update, Mapping):
|
||||
raise ProtocolViolation("license_update ist kein Objekt")
|
||||
if int(update.get("seq") or 0) < int(current_seq or 0):
|
||||
raise ReplayDetected("license_update ist aelter als die installierte Lizenz")
|
||||
Reference in New Issue
Block a user