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>
116 lines
4.5 KiB
Python
116 lines
4.5 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 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
|