AUFGERAUMT: Root auf 10 sichtbare Elemente reduziert
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user