"""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_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")