Files
hms-mediaengine/packages/cluster/hms_cluster/pairing.py
T
HMS MediaEngine Agent c4574927fd Phase 1: Cluster-Fundament – Nachrichten, Registry, Paarung, Discovery
- hms_cluster: ClusterMessage (§6.5: Pflichtfelder, Sequenzen, Revisionen,
  execute_at, Trace-ID), CommandTracker (idempotent, vorwärts-only:
  accepted->armed->executed/failed)
- NodeRegistry: online/degraded/stale/offline über Schwellen, UI-Kategorien
  discovered/paired/unknown/incompatible/offline, Doppel-Node-ID blockiert,
  IP-Wechsel erhaelt node_id (§6.3, §6.5)
- Pairing: PIN (TTL 120s, Versuchslimit), Fingerprint (SHA-256 gruppiert),
  Token nur als Hash, Scopes read/control/content_sync/admin, Ablauf +
  sofortiger Widerruf (§6.3, §27.1, ADR-0010)
- Discovery-Modell: _hmsmedia._tcp.local. TXT ohne Secrets, Capability-
  Digest, persistente manuelle Fallback-Liste (ADR-0009)
- ADR-0009 (mDNS + Fallback) und ADR-0010 (Paarung) dokumentiert
- 25 Unit-Tests; Gesamtsuite 183 Tests gruen, Ruff gruen
2026-09-11 01:06:26 +02:00

176 lines
5.5 KiB
Python

"""Node-Paarung: PIN, Fingerprint, Token-Scopes, Widerruf (§6.3, §27.1)."""
from __future__ import annotations
import hashlib
import hmac
import secrets
import time
from dataclasses import dataclass, field
from enum import StrEnum
PIN_TTL_S = 120.0
class Scope(StrEnum):
"""Getrennte Berechtigungsscopes (§27.1)."""
READ = "read"
CONTROL = "control"
CONTENT_SYNC = "content_sync"
ADMIN = "admin"
_ALL_SCOPES = frozenset(s.value for s in Scope)
@dataclass(frozen=True)
class PairingPin:
"""Kurzlebige Paarungs-PIN (§6.3)."""
value: str
created_ns: int
@property
def expires_ns(self) -> int:
return self.created_ns + int(PIN_TTL_S * 1_000_000_000)
def generate_pin() -> PairingPin:
"""6-stellige PIN, kryptographisch erzeugt (§6.3)."""
return PairingPin(
value=f"{secrets.randbelow(1_000_000):06d}",
created_ns=time.monotonic_ns(),
)
def pin_valid(pin: PairingPin, now_ns: int | None = None) -> bool:
now = now_ns if now_ns is not None else time.monotonic_ns()
return now <= pin.expires_ns
def identity_fingerprint(
node_id: str,
display_name: str,
public_key_pem: str | None = None,
) -> str:
"""Sichtbarer Fingerprint über öffentliche Identitätsdaten (§6.3).
Format: 8 Gruppen à 4 Hex-Zeichen (128 Bits des SHA-256), für Menschen
vergleichbar.
"""
material = f"{node_id}|{display_name}".encode()
if public_key_pem:
material += b"|" + public_key_pem.encode("ascii", errors="replace")
digest = hashlib.sha256(material).hexdigest()
groups = [digest[i : i + 4] for i in range(0, 32, 4)]
return ":".join(groups)
@dataclass(frozen=True)
class PairedToken:
"""Ausgestelltes Token; nur der Hash wird gespeichert (§27.1)."""
token_hash: str
scopes: frozenset[str]
expires_ns: int | None
created_ns: int
def hash_token(token: str) -> str:
"""Token-Hash; Klartext existiert nur beim Besitzer."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def new_token(scopes: frozenset[str], ttl_s: float | None = None) -> tuple[str, PairedToken]:
"""Erzeugt Token + gespeicherte Repräsentation; Klartext genau einmal."""
unknown = scopes - _ALL_SCOPES
if unknown:
raise ValueError(f"unknown scopes: {sorted(unknown)}")
if not scopes:
raise ValueError("scopes must not be empty")
now = time.monotonic_ns()
token = secrets.token_urlsafe(32)
expires = now + int(ttl_s * 1_000_000_000) if ttl_s is not None else None
return token, PairedToken(
token_hash=hash_token(token),
scopes=scopes,
expires_ns=expires,
created_ns=now,
)
@dataclass
class PairingStore:
"""PINs, Paarungszustand und Tokens je Node (§6.3, §27.1, ADR-0010)."""
_pins: dict[str, PairingPin] = field(default_factory=dict)
_tokens: dict[str, PairedToken] = field(default_factory=dict)
_failed_attempts: dict[str, int] = field(default_factory=dict)
max_pin_attempts: int = 3
def issue_pin(self, node_id: str) -> PairingPin:
pin = generate_pin()
self._pins[node_id] = pin
self._failed_attempts.pop(node_id, None)
return pin
def complete_pairing(
self,
node_id: str,
entered_pin: str,
fingerprint_seen: str,
expected_fingerprint: str,
scopes: frozenset[str],
token_ttl_s: float | None = None,
now_ns: int | None = None,
) -> str:
"""Prüft PIN + Fingerprint, stellt Token aus; gibt Klartext zurück.
Fehlversuche erhöhen den Zähler; nach max_pin_attempts wird die PIN
gesperrt (Neuausstellung nötig). Vergleiche konstantzeit über
hmac.compare_digest.
"""
pin = self._pins.get(node_id)
now = now_ns if now_ns is not None else time.monotonic_ns()
if pin is None:
raise PermissionError("keine PIN ausgestellt")
if self._failed_attempts.get(node_id, 0) >= self.max_pin_attempts:
raise PermissionError("PIN gesperrt; neu ausstellen")
if not pin_valid(pin, now_ns=now) or not hmac.compare_digest(pin.value, entered_pin):
self._failed_attempts[node_id] = self._failed_attempts.get(node_id, 0) + 1
raise PermissionError("PIN falsch oder abgelaufen")
if not hmac.compare_digest(
fingerprint_seen.strip().lower(), expected_fingerprint.strip().lower()
):
self._failed_attempts[node_id] = self._failed_attempts.get(node_id, 0) + 1
raise PermissionError("Fingerprint stimmt nicht ueberein")
token, stored = new_token(scopes, ttl_s=token_ttl_s)
self._tokens[node_id] = stored
self._pins.pop(node_id, None) # PIN nur einmal verwendbar
self._failed_attempts.pop(node_id, None)
return token
def revoke(self, node_id: str) -> None:
"""Sofortiger Widerruf (§27.1)."""
self._tokens.pop(node_id, None)
self._pins.pop(node_id, None)
def verify(
self,
node_id: str,
token: str,
required_scope: Scope,
now_ns: int | None = None,
) -> bool:
"""Token- und Scope-Prüfung; Hash-Vergleich konstantzeit."""
stored = self._tokens.get(node_id)
if stored is None:
return False
now = now_ns if now_ns is not None else time.monotonic_ns()
if stored.expires_ns is not None and now > stored.expires_ns:
return False
if not hmac.compare_digest(stored.token_hash, hash_token(token)):
return False
return required_scope.value in stored.scopes