TESM 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Der Lizenzserver liegt in seinem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger, jeweils an der Stelle im Code kommentiert, an der der Fehler entstanden ist: * 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. * Die nginx-Site wird bei jedem Lauf inhaltlich verglichen und erneuert. * Jede erzeugte Konfiguration wird vor dem Uebernehmen geprueft (nginx, Kea). * Kein Hostname im Lizenz-Fingerabdruck. * Zwei Installationen auf einem Host stoeren sich nicht (eigener SITE_KEY). * 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