"""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