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