hub_trust.py python
282 lines 9.9 KB
Raw
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