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,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
|
||||
Reference in New Issue
Block a user