hub_trust.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
| 1 | """TOFU (Trust On First Use) hub certificate fingerprint pinning. |
| 2 | |
| 3 | Threat model |
| 4 | ------------ |
| 5 | When Muse connects to a remote hub over HTTPS, it has no built-in way to |
| 6 | verify that the server is the *same* server it connected to previously. |
| 7 | Standard TLS certificate validation (via the OS trust store) guards against |
| 8 | completely unknown CAs, but does not guard against: |
| 9 | |
| 10 | - Legitimate certificate rotations (the cert changes, the server is the same). |
| 11 | - Compromised intermediate CAs (cert is valid but issued to an impostor). |
| 12 | - Self-signed certificates used in local development. |
| 13 | |
| 14 | TOFU pinning adds a second layer: on the first ever connection to a hostname, |
| 15 | the server's certificate fingerprint is stored in ``~/.muse/hub_trust.toml``. |
| 16 | All subsequent connections compare the live fingerprint against the stored one. |
| 17 | |
| 18 | If they match: ``verified_count`` is incremented — no user interaction needed. |
| 19 | If they differ: :class:`~muse.core.errors.HubFingerprintMismatchError` is |
| 20 | raised with instructions to run ``muse trust hub-reset <hostname>`` after |
| 21 | manually verifying the certificate change. |
| 22 | |
| 23 | For plain HTTP connections: a sentinel fingerprint ``"http-no-tls"`` is stored |
| 24 | and a one-time warning is emitted. This records that the operator knowingly |
| 25 | chose an unencrypted transport. |
| 26 | |
| 27 | Storage format (``~/.muse/hub_trust.toml``) |
| 28 | -------------------------------------------- |
| 29 | :: |
| 30 | |
| 31 | ["localhost:10003"] |
| 32 | fingerprint = "sha256:abc123..." |
| 33 | first_seen = "2026-04-08T12:00:00" |
| 34 | verified_count = 42 |
| 35 | |
| 36 | ["musehub.ai"] |
| 37 | fingerprint = "http-no-tls" |
| 38 | first_seen = "2026-04-08T11:00:00" |
| 39 | verified_count = 1 |
| 40 | """ |
| 41 | |
| 42 | from __future__ import annotations |
| 43 | |
| 44 | import logging |
| 45 | import os |
| 46 | import pathlib |
| 47 | import threading |
| 48 | import tomllib |
| 49 | from typing import TypedDict |
| 50 | |
| 51 | logger = logging.getLogger(__name__) |
| 52 | |
| 53 | _HUB_TRUST_FILE = pathlib.Path.home() / ".muse" / "hub_trust.toml" |
| 54 | |
| 55 | # Process-wide lock protecting all reads and writes of the trust store file. |
| 56 | # Without this, parallel pack uploads (8+ threads) would all call check_and_pin |
| 57 | # simultaneously, each writing to the same .toml.tmp file and racing on |
| 58 | # os.replace — producing "[Errno 2] No such file or directory" warnings. |
| 59 | _TRUST_LOCK: threading.Lock = threading.Lock() |
| 60 | |
| 61 | #: Sentinel fingerprint stored for plain-HTTP (non-TLS) connections. |
| 62 | HTTP_NO_TLS_SENTINEL = "http-no-tls" |
| 63 | |
| 64 | |
| 65 | class HubTrustRecord(TypedDict): |
| 66 | """A single pinned hub entry in ``~/.muse/hub_trust.toml``.""" |
| 67 | |
| 68 | hub_url: str # Normalised base URL (scheme + host + port) |
| 69 | fingerprint: str # "sha256:<hex>" or "http-no-tls" |
| 70 | first_seen: str # ISO-8601 datetime string |
| 71 | verified_count: int # Number of times this fingerprint was confirmed |
| 72 | |
| 73 | |
| 74 | type HubTrustStore = dict[str, HubTrustRecord] # keyed by normalised hostname |
| 75 | |
| 76 | |
| 77 | def _normalise_hostname(url: str) -> str: |
| 78 | """Return the normalised ``host:port`` key for *url*. |
| 79 | |
| 80 | Strips scheme and path, keeping only host and port (if non-default). |
| 81 | Examples: |
| 82 | "https://musehub.ai" → "musehub.ai" |
| 83 | "http://localhost:10003/foo" → "localhost:10003" |
| 84 | "https://musehub.ai:443" → "musehub.ai:443" |
| 85 | """ |
| 86 | import urllib.parse |
| 87 | parsed = urllib.parse.urlparse(url) |
| 88 | host = parsed.hostname or url |
| 89 | port = parsed.port |
| 90 | if port is not None: |
| 91 | return f"{host}:{port}" |
| 92 | return host |
| 93 | |
| 94 | |
| 95 | def load_hub_trust_store() -> HubTrustStore: |
| 96 | """Read ``~/.muse/hub_trust.toml`` and return the full trust store. |
| 97 | |
| 98 | Returns an empty dict when the file is absent or unparseable. |
| 99 | """ |
| 100 | if not _HUB_TRUST_FILE.is_file(): |
| 101 | return {} |
| 102 | try: |
| 103 | with _HUB_TRUST_FILE.open("rb") as fh: |
| 104 | raw: dict[str, object] = tomllib.load(fh) |
| 105 | except Exception as exc: # noqa: BLE001 |
| 106 | logger.warning("⚠️ Failed to parse %s: %s", _HUB_TRUST_FILE, exc) |
| 107 | return {} |
| 108 | |
| 109 | store: HubTrustStore = {} |
| 110 | for hostname, entry_raw in raw.items(): |
| 111 | if not isinstance(entry_raw, dict): |
| 112 | continue |
| 113 | fp = entry_raw.get("fingerprint") |
| 114 | first_seen = entry_raw.get("first_seen") |
| 115 | verified_count = entry_raw.get("verified_count") |
| 116 | hub_url = entry_raw.get("hub_url") |
| 117 | if ( |
| 118 | isinstance(fp, str) |
| 119 | and isinstance(first_seen, str) |
| 120 | and isinstance(verified_count, int) |
| 121 | and isinstance(hub_url, str) |
| 122 | ): |
| 123 | store[hostname] = HubTrustRecord( |
| 124 | hub_url=hub_url, |
| 125 | fingerprint=fp, |
| 126 | first_seen=first_seen, |
| 127 | verified_count=verified_count, |
| 128 | ) |
| 129 | return store |
| 130 | |
| 131 | |
| 132 | def _save_hub_trust_store(store: HubTrustStore) -> None: |
| 133 | """Write *store* to ``~/.muse/hub_trust.toml``.""" |
| 134 | _HUB_TRUST_FILE.parent.mkdir(parents=True, exist_ok=True) |
| 135 | |
| 136 | lines: list[str] = [] |
| 137 | for hostname in sorted(store): |
| 138 | record = store[hostname] |
| 139 | # Escape double-quotes in the key for TOML table header safety. |
| 140 | safe_key = hostname.replace('"', '\\"') |
| 141 | lines.append(f'["{safe_key}"]') |
| 142 | lines.append(f'hub_url = "{record["hub_url"]}"') |
| 143 | lines.append(f'fingerprint = "{record["fingerprint"]}"') |
| 144 | lines.append(f'first_seen = "{record["first_seen"]}"') |
| 145 | lines.append(f'verified_count = {record["verified_count"]}') |
| 146 | lines.append("") |
| 147 | |
| 148 | content = "\n".join(lines) |
| 149 | tmp = _HUB_TRUST_FILE.with_suffix(".toml.tmp") |
| 150 | tmp.write_text(content, encoding="utf-8") |
| 151 | os.replace(tmp, _HUB_TRUST_FILE) |
| 152 | |
| 153 | |
| 154 | def remove_hub_record(hostname: str) -> bool: |
| 155 | """Remove the trust record for *hostname* from the store. |
| 156 | |
| 157 | Args: |
| 158 | hostname: Exact hostname key (e.g. ``"musehub.ai"`` or ``"localhost:10003"``). |
| 159 | |
| 160 | Returns: |
| 161 | ``True`` when a record was removed, ``False`` when not found. |
| 162 | """ |
| 163 | store = load_hub_trust_store() |
| 164 | if hostname not in store: |
| 165 | return False |
| 166 | del store[hostname] |
| 167 | _save_hub_trust_store(store) |
| 168 | return True |
| 169 | |
| 170 | |
| 171 | def _cert_fingerprint_from_response(url: str) -> str: |
| 172 | """Extract the SHA-256 certificate fingerprint from an active HTTPS connection. |
| 173 | |
| 174 | Uses ``ssl.get_server_certificate`` to fetch the DER-encoded certificate |
| 175 | and compute its SHA-256 digest. Returns ``HTTP_NO_TLS_SENTINEL`` for |
| 176 | plain-HTTP URLs. |
| 177 | |
| 178 | This function opens a brief socket connection purely to fetch the cert — |
| 179 | it does not transmit any application data. |
| 180 | |
| 181 | Args: |
| 182 | url: The full URL being contacted (used to extract host and port). |
| 183 | |
| 184 | Returns: |
| 185 | ``"sha256:<lowercase-hex>"`` or ``"http-no-tls"``. |
| 186 | """ |
| 187 | import hashlib |
| 188 | import ssl |
| 189 | import urllib.parse |
| 190 | |
| 191 | parsed = urllib.parse.urlparse(url) |
| 192 | scheme = parsed.scheme.lower() |
| 193 | if scheme != "https": |
| 194 | return HTTP_NO_TLS_SENTINEL |
| 195 | |
| 196 | host = parsed.hostname or "" |
| 197 | port = parsed.port or 443 |
| 198 | try: |
| 199 | cert_pem = ssl.get_server_certificate((host, port)) |
| 200 | # Convert PEM → DER for consistent fingerprinting. |
| 201 | der_bytes = ssl.PEM_cert_to_DER_cert(cert_pem) |
| 202 | digest = hashlib.sha256(der_bytes).hexdigest() |
| 203 | return f"sha256:{digest}" |
| 204 | except Exception as exc: # noqa: BLE001 |
| 205 | logger.warning("⚠️ Could not fetch certificate for %s:%s: %s", host, port, exc) |
| 206 | return HTTP_NO_TLS_SENTINEL |
| 207 | |
| 208 | |
| 209 | def check_and_pin(url: str) -> None: |
| 210 | """TOFU-pin the hub at *url* or verify its fingerprint matches the stored one. |
| 211 | |
| 212 | On first connection: stores fingerprint and prints a notice to stderr. |
| 213 | On subsequent connections with matching fingerprint: increments verified_count silently. |
| 214 | On fingerprint mismatch: raises :class:`~muse.core.errors.HubFingerprintMismatchError`. |
| 215 | For HTTP: stores sentinel and emits a one-time warning. |
| 216 | |
| 217 | This function is idempotent — calling it multiple times for the same URL with |
| 218 | the same certificate is safe and only increments the counter. It is also |
| 219 | thread-safe: ``_TRUST_LOCK`` prevents parallel pack-upload threads from |
| 220 | racing on the ``.toml.tmp → hub_trust.toml`` atomic rename. |
| 221 | |
| 222 | Args: |
| 223 | url: The hub URL being contacted. |
| 224 | |
| 225 | Raises: |
| 226 | HubFingerprintMismatchError: When a stored fingerprint does not match the |
| 227 | live server certificate. |
| 228 | """ |
| 229 | import datetime |
| 230 | from muse.core.errors import HubFingerprintMismatchError |
| 231 | |
| 232 | hostname = _normalise_hostname(url) |
| 233 | # Fetch the certificate fingerprint BEFORE acquiring the lock — this is the |
| 234 | # slow part (TLS handshake) and has no shared state to protect. |
| 235 | actual_fp = _cert_fingerprint_from_response(url) |
| 236 | |
| 237 | with _TRUST_LOCK: |
| 238 | store = load_hub_trust_store() |
| 239 | |
| 240 | if hostname not in store: |
| 241 | # First connection — TOFU: store the fingerprint. |
| 242 | now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") |
| 243 | record = HubTrustRecord( |
| 244 | hub_url=url, |
| 245 | fingerprint=actual_fp, |
| 246 | first_seen=now, |
| 247 | verified_count=1, |
| 248 | ) |
| 249 | store[hostname] = record |
| 250 | _save_hub_trust_store(store) |
| 251 | if actual_fp == HTTP_NO_TLS_SENTINEL: |
| 252 | print( |
| 253 | f"⚠️ Hub {hostname} connected over plain HTTP (no TLS).\n" |
| 254 | " This connection is unencrypted. Consider using HTTPS.", |
| 255 | file=__import__("sys").stderr, |
| 256 | ) |
| 257 | else: |
| 258 | print( |
| 259 | f"✅ Pinned hub {hostname} ({actual_fp}) — first connection", |
| 260 | file=__import__("sys").stderr, |
| 261 | ) |
| 262 | return |
| 263 | |
| 264 | existing = store[hostname] |
| 265 | stored_fp = existing["fingerprint"] |
| 266 | |
| 267 | if stored_fp != actual_fp: |
| 268 | raise HubFingerprintMismatchError( |
| 269 | hostname=hostname, |
| 270 | stored=stored_fp, |
| 271 | actual=actual_fp, |
| 272 | ) |
| 273 | |
| 274 | # Fingerprints match — increment counter and save. |
| 275 | existing["verified_count"] += 1 |
| 276 | _save_hub_trust_store(store) |
| 277 | logger.debug( |
| 278 | "✅ Hub %s fingerprint verified (%s), count=%d", |
| 279 | hostname, |
| 280 | actual_fp[:20], |
| 281 | existing["verified_count"], |
| 282 | ) |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
33 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
33 days ago