c4574927fd
- 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
176 lines
5.5 KiB
Python
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
|