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