Entfernt: Tests, Architekturdokumentation und die Anwendung TESM. Beides liegt im Entwicklungsrepository alientim/TESM-DEV. Neu: packages/tesm-licensing-server -- die Serverhaelfte des Lizenzprotokolls (issue_license, resign_license, build_master_response, verify_client_request, Schluesselerzeugung). Sie war bisher Teil von tesm-licensing und lag damit auch in jeder Client-Installation. Die gemeinsame Haelfte bleibt in tesm-licensing und wird von beiden Seiten benutzt. install.sh installiert diese Serverhaelfte ausschliesslich fuer --app tesm-license und bricht ab, wenn sie fehlt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
103 lines
3.8 KiB
Python
103 lines
3.8 KiB
Python
"""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 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
|