gabriel / muse public
test_security_hub_trust.py python
199 lines 6.4 KB
Raw
1 """Security tests — TOFU hub certificate fingerprint pinning.
2
3 Verifies that hub_trust.py correctly:
4 - Stores fingerprint on first connection (TOFU)
5 - Increments verified_count on subsequent matching connections
6 - Raises HubFingerprintMismatchError when fingerprint changes
7 - Stores HTTP sentinel for plain-HTTP connections with a warning
8 - CLI: hub-list and hub-reset commands
9 """
10
11 from __future__ import annotations
12
13 from contextlib import AbstractContextManager
14 import pathlib
15 from unittest.mock import patch
16
17 import pytest
18
19 from muse.core.errors import HubFingerprintMismatchError
20 from muse.core.hub_trust import (
21 HTTP_NO_TLS_SENTINEL,
22 HubTrustRecord,
23 HubTrustStore,
24 _normalise_hostname,
25 check_and_pin,
26 load_hub_trust_store,
27 remove_hub_record,
28 )
29
30
31 # ---------------------------------------------------------------------------
32 # Helpers
33 # ---------------------------------------------------------------------------
34
35 def _fake_fingerprint(value: str = "sha256:abc123def456") -> str:
36 return value
37
38
39 def _patch_cert_fp(fp: str) -> "AbstractContextManager[None]":
40 """Context manager that patches _cert_fingerprint_from_response to return *fp*."""
41 return patch("muse.core.hub_trust._cert_fingerprint_from_response", return_value=fp)
42
43
44 # ---------------------------------------------------------------------------
45 # Tests
46 # ---------------------------------------------------------------------------
47
48
49 def test_first_connection_pins_fingerprint(
50 tmp_path: pathlib.Path,
51 monkeypatch: pytest.MonkeyPatch,
52 ) -> None:
53 """New host → record created in hub_trust.toml."""
54 trust_file = tmp_path / "hub_trust.toml"
55 monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file)
56
57 fp = "sha256:deadbeef1234567890abcdef"
58 with _patch_cert_fp(fp):
59 check_and_pin("https://musehub.ai")
60
61 store = load_hub_trust_store()
62 assert "musehub.ai" in store
63 record = store["musehub.ai"]
64 assert record["fingerprint"] == fp
65 assert record["verified_count"] == 1
66 assert record["first_seen"] != ""
67
68
69 def test_second_connection_increments_count(
70 tmp_path: pathlib.Path,
71 monkeypatch: pytest.MonkeyPatch,
72 ) -> None:
73 """Same fingerprint on second connection → verified_count incremented."""
74 trust_file = tmp_path / "hub_trust.toml"
75 monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file)
76
77 fp = "sha256:cafebabe00112233"
78 with _patch_cert_fp(fp):
79 check_and_pin("https://musehub.ai")
80 check_and_pin("https://musehub.ai")
81
82 store = load_hub_trust_store()
83 assert store["musehub.ai"]["verified_count"] == 2
84
85
86 def test_fingerprint_mismatch_raises(
87 tmp_path: pathlib.Path,
88 monkeypatch: pytest.MonkeyPatch,
89 ) -> None:
90 """Stored fingerprint != actual → raises HubFingerprintMismatchError."""
91 trust_file = tmp_path / "hub_trust.toml"
92 monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file)
93
94 fp_original = "sha256:original1111"
95 fp_changed = "sha256:changed99999"
96
97 # First connection — pins original.
98 with _patch_cert_fp(fp_original):
99 check_and_pin("https://musehub.ai")
100
101 # Second connection — different cert.
102 with _patch_cert_fp(fp_changed):
103 with pytest.raises(HubFingerprintMismatchError) as exc_info:
104 check_and_pin("https://musehub.ai")
105
106 err = exc_info.value
107 assert err.stored_fingerprint == fp_original
108 assert err.actual_fingerprint == fp_changed
109 assert "musehub.ai" in err.hostname
110 # Error message must include the reset command.
111 assert "hub-reset" in str(err)
112
113
114 def test_http_stores_sentinel(
115 tmp_path: pathlib.Path,
116 monkeypatch: pytest.MonkeyPatch,
117 capsys: pytest.CaptureFixture[str],
118 ) -> None:
119 """HTTP URL → fingerprint = 'http-no-tls', warning emitted to stderr."""
120 trust_file = tmp_path / "hub_trust.toml"
121 monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file)
122
123 check_and_pin("http://localhost:1337")
124
125 store = load_hub_trust_store()
126 assert "localhost:1337" in store
127 assert store["localhost:1337"]["fingerprint"] == HTTP_NO_TLS_SENTINEL
128
129 captured = capsys.readouterr()
130 assert "http" in captured.err.lower() or "tls" in captured.err.lower() or "unencrypted" in captured.err.lower()
131
132
133 def test_normalise_hostname_https() -> None:
134 """HTTPS URL without explicit port → bare hostname."""
135 assert _normalise_hostname("https://musehub.ai") == "musehub.ai"
136
137
138 def test_normalise_hostname_http_with_port() -> None:
139 """HTTP URL with port → host:port."""
140 assert _normalise_hostname("https://localhost:1337/api/v1/repos") == "localhost:1337"
141
142
143 def test_hub_trust_list_cli(
144 tmp_path: pathlib.Path,
145 monkeypatch: pytest.MonkeyPatch,
146 capsys: pytest.CaptureFixture[str],
147 ) -> None:
148 """muse trust hub-list shows pinned hubs."""
149 import argparse
150 trust_file = tmp_path / "hub_trust.toml"
151 monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file)
152
153 fp = "sha256:aabbccdd11223344"
154 with _patch_cert_fp(fp):
155 check_and_pin("https://musehub.ai")
156
157 # Import and invoke the CLI handler directly.
158 from muse.cli.commands.trust import _run_hub_list
159 args = argparse.Namespace()
160 _run_hub_list(args)
161
162 captured = capsys.readouterr()
163 assert "musehub.ai" in captured.out
164 assert fp in captured.out
165
166
167 def test_hub_reset_cli(
168 tmp_path: pathlib.Path,
169 monkeypatch: pytest.MonkeyPatch,
170 capsys: pytest.CaptureFixture[str],
171 ) -> None:
172 """muse trust hub-reset removes record; next connection re-pins."""
173 import argparse
174 trust_file = tmp_path / "hub_trust.toml"
175 monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file)
176
177 fp1 = "sha256:original0000"
178 with _patch_cert_fp(fp1):
179 check_and_pin("https://musehub.ai")
180
181 # Reset.
182 from muse.cli.commands.trust import _run_hub_reset
183 args = argparse.Namespace(hostname="musehub.ai")
184 _run_hub_reset(args)
185 captured = capsys.readouterr()
186 assert "reset" in captured.out.lower() or "removed" in captured.out.lower()
187
188 # Store should be empty now.
189 store = load_hub_trust_store()
190 assert "musehub.ai" not in store
191
192 # Next connection re-pins with new cert.
193 fp2 = "sha256:newcert9999"
194 with _patch_cert_fp(fp2):
195 check_and_pin("https://musehub.ai")
196
197 store = load_hub_trust_store()
198 assert store["musehub.ai"]["fingerprint"] == fp2
199 assert store["musehub.ai"]["verified_count"] == 1
File History 1 commit