"""TOFU (Trust On First Use) hub certificate fingerprint pinning. Threat model ------------ When Muse connects to a remote hub over HTTPS, it has no built-in way to verify that the server is the *same* server it connected to previously. Standard TLS certificate validation (via the OS trust store) guards against completely unknown CAs, but does not guard against: - Legitimate certificate rotations (the cert changes, the server is the same). - Compromised intermediate CAs (cert is valid but issued to an impostor). - Self-signed certificates used in local development. TOFU pinning adds a second layer: on the first ever connection to a hostname, the server's certificate fingerprint is stored in ``~/.muse/hub_trust.toml``. All subsequent connections compare the live fingerprint against the stored one. If they match: ``verified_count`` is incremented — no user interaction needed. If they differ: :class:`~muse.core.errors.HubFingerprintMismatchError` is raised with instructions to run ``muse trust hub-reset `` after manually verifying the certificate change. For plain HTTP connections: a sentinel fingerprint ``"http-no-tls"`` is stored and a one-time warning is emitted. This records that the operator knowingly chose an unencrypted transport. Storage format (``~/.muse/hub_trust.toml``) -------------------------------------------- :: ["localhost:10003"] fingerprint = "sha256:abc123..." first_seen = "2026-04-08T12:00:00" verified_count = 42 ["musehub.ai"] fingerprint = "http-no-tls" first_seen = "2026-04-08T11:00:00" verified_count = 1 """ from __future__ import annotations import logging import os import pathlib import threading import tomllib from typing import TypedDict logger = logging.getLogger(__name__) _HUB_TRUST_FILE = pathlib.Path.home() / ".muse" / "hub_trust.toml" # Process-wide lock protecting all reads and writes of the trust store file. # Without this, parallel pack uploads (8+ threads) would all call check_and_pin # simultaneously, each writing to the same .toml.tmp file and racing on # os.replace — producing "[Errno 2] No such file or directory" warnings. _TRUST_LOCK: threading.Lock = threading.Lock() #: Sentinel fingerprint stored for plain-HTTP (non-TLS) connections. HTTP_NO_TLS_SENTINEL = "http-no-tls" class HubTrustRecord(TypedDict): """A single pinned hub entry in ``~/.muse/hub_trust.toml``.""" hub_url: str # Normalised base URL (scheme + host + port) fingerprint: str # "sha256:" or "http-no-tls" first_seen: str # ISO-8601 datetime string verified_count: int # Number of times this fingerprint was confirmed type HubTrustStore = dict[str, HubTrustRecord] # keyed by normalised hostname def _normalise_hostname(url: str) -> str: """Return the normalised ``host:port`` key for *url*. Strips scheme and path, keeping only host and port (if non-default). Examples: "https://musehub.ai" → "musehub.ai" "http://localhost:10003/foo" → "localhost:10003" "https://musehub.ai:443" → "musehub.ai:443" """ import urllib.parse parsed = urllib.parse.urlparse(url) host = parsed.hostname or url port = parsed.port if port is not None: return f"{host}:{port}" return host def load_hub_trust_store() -> HubTrustStore: """Read ``~/.muse/hub_trust.toml`` and return the full trust store. Returns an empty dict when the file is absent or unparseable. """ if not _HUB_TRUST_FILE.is_file(): return {} try: with _HUB_TRUST_FILE.open("rb") as fh: raw: dict[str, object] = tomllib.load(fh) except Exception as exc: # noqa: BLE001 logger.warning("⚠️ Failed to parse %s: %s", _HUB_TRUST_FILE, exc) return {} store: HubTrustStore = {} for hostname, entry_raw in raw.items(): if not isinstance(entry_raw, dict): continue fp = entry_raw.get("fingerprint") first_seen = entry_raw.get("first_seen") verified_count = entry_raw.get("verified_count") hub_url = entry_raw.get("hub_url") if ( isinstance(fp, str) and isinstance(first_seen, str) and isinstance(verified_count, int) and isinstance(hub_url, str) ): store[hostname] = HubTrustRecord( hub_url=hub_url, fingerprint=fp, first_seen=first_seen, verified_count=verified_count, ) return store def _save_hub_trust_store(store: HubTrustStore) -> None: """Write *store* to ``~/.muse/hub_trust.toml``.""" _HUB_TRUST_FILE.parent.mkdir(parents=True, exist_ok=True) lines: list[str] = [] for hostname in sorted(store): record = store[hostname] # Escape double-quotes in the key for TOML table header safety. safe_key = hostname.replace('"', '\\"') lines.append(f'["{safe_key}"]') lines.append(f'hub_url = "{record["hub_url"]}"') lines.append(f'fingerprint = "{record["fingerprint"]}"') lines.append(f'first_seen = "{record["first_seen"]}"') lines.append(f'verified_count = {record["verified_count"]}') lines.append("") content = "\n".join(lines) tmp = _HUB_TRUST_FILE.with_suffix(".toml.tmp") tmp.write_text(content, encoding="utf-8") os.replace(tmp, _HUB_TRUST_FILE) def remove_hub_record(hostname: str) -> bool: """Remove the trust record for *hostname* from the store. Args: hostname: Exact hostname key (e.g. ``"musehub.ai"`` or ``"localhost:10003"``). Returns: ``True`` when a record was removed, ``False`` when not found. """ store = load_hub_trust_store() if hostname not in store: return False del store[hostname] _save_hub_trust_store(store) return True def _cert_fingerprint_from_response(url: str) -> str: """Extract the SHA-256 certificate fingerprint from an active HTTPS connection. Uses ``ssl.get_server_certificate`` to fetch the DER-encoded certificate and compute its SHA-256 digest. Returns ``HTTP_NO_TLS_SENTINEL`` for plain-HTTP URLs. This function opens a brief socket connection purely to fetch the cert — it does not transmit any application data. Args: url: The full URL being contacted (used to extract host and port). Returns: ``"sha256:"`` or ``"http-no-tls"``. """ import hashlib import ssl import urllib.parse parsed = urllib.parse.urlparse(url) scheme = parsed.scheme.lower() if scheme != "https": return HTTP_NO_TLS_SENTINEL host = parsed.hostname or "" port = parsed.port or 443 try: cert_pem = ssl.get_server_certificate((host, port)) # Convert PEM → DER for consistent fingerprinting. der_bytes = ssl.PEM_cert_to_DER_cert(cert_pem) digest = hashlib.sha256(der_bytes).hexdigest() return f"sha256:{digest}" except Exception as exc: # noqa: BLE001 logger.warning("⚠️ Could not fetch certificate for %s:%s: %s", host, port, exc) return HTTP_NO_TLS_SENTINEL def check_and_pin(url: str) -> None: """TOFU-pin the hub at *url* or verify its fingerprint matches the stored one. On first connection: stores fingerprint and prints a notice to stderr. On subsequent connections with matching fingerprint: increments verified_count silently. On fingerprint mismatch: raises :class:`~muse.core.errors.HubFingerprintMismatchError`. For HTTP: stores sentinel and emits a one-time warning. This function is idempotent — calling it multiple times for the same URL with the same certificate is safe and only increments the counter. It is also thread-safe: ``_TRUST_LOCK`` prevents parallel pack-upload threads from racing on the ``.toml.tmp → hub_trust.toml`` atomic rename. Args: url: The hub URL being contacted. Raises: HubFingerprintMismatchError: When a stored fingerprint does not match the live server certificate. """ import datetime from muse.core.errors import HubFingerprintMismatchError hostname = _normalise_hostname(url) # Fetch the certificate fingerprint BEFORE acquiring the lock — this is the # slow part (TLS handshake) and has no shared state to protect. actual_fp = _cert_fingerprint_from_response(url) with _TRUST_LOCK: store = load_hub_trust_store() if hostname not in store: # First connection — TOFU: store the fingerprint. now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") record = HubTrustRecord( hub_url=url, fingerprint=actual_fp, first_seen=now, verified_count=1, ) store[hostname] = record _save_hub_trust_store(store) if actual_fp == HTTP_NO_TLS_SENTINEL: print( f"⚠️ Hub {hostname} connected over plain HTTP (no TLS).\n" " This connection is unencrypted. Consider using HTTPS.", file=__import__("sys").stderr, ) else: print( f"✅ Pinned hub {hostname} ({actual_fp}) — first connection", file=__import__("sys").stderr, ) return existing = store[hostname] stored_fp = existing["fingerprint"] if stored_fp != actual_fp: raise HubFingerprintMismatchError( hostname=hostname, stored=stored_fp, actual=actual_fp, ) # Fingerprints match — increment counter and save. existing["verified_count"] += 1 _save_hub_trust_store(store) logger.debug( "✅ Hub %s fingerprint verified (%s), count=%d", hostname, actual_fp[:20], existing["verified_count"], )