"""Security tests — TOFU hub certificate fingerprint pinning. Verifies that hub_trust.py correctly: - Stores fingerprint on first connection (TOFU) - Increments verified_count on subsequent matching connections - Raises HubFingerprintMismatchError when fingerprint changes - Stores HTTP sentinel for plain-HTTP connections with a warning - CLI: hub-list and hub-reset commands """ from __future__ import annotations from contextlib import AbstractContextManager import pathlib from unittest.mock import patch import pytest from muse.core.errors import HubFingerprintMismatchError from muse.core.hub_trust import ( HTTP_NO_TLS_SENTINEL, HubTrustRecord, HubTrustStore, _normalise_hostname, check_and_pin, load_hub_trust_store, remove_hub_record, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _fake_fingerprint(value: str = "sha256:abc123def456") -> str: return value def _patch_cert_fp(fp: str) -> "AbstractContextManager[None]": """Context manager that patches _cert_fingerprint_from_response to return *fp*.""" return patch("muse.core.hub_trust._cert_fingerprint_from_response", return_value=fp) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_first_connection_pins_fingerprint( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """New host → record created in hub_trust.toml.""" trust_file = tmp_path / "hub_trust.toml" monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file) fp = "sha256:deadbeef1234567890abcdef" with _patch_cert_fp(fp): check_and_pin("https://musehub.ai") store = load_hub_trust_store() assert "musehub.ai" in store record = store["musehub.ai"] assert record["fingerprint"] == fp assert record["verified_count"] == 1 assert record["first_seen"] != "" def test_second_connection_increments_count( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Same fingerprint on second connection → verified_count incremented.""" trust_file = tmp_path / "hub_trust.toml" monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file) fp = "sha256:cafebabe00112233" with _patch_cert_fp(fp): check_and_pin("https://musehub.ai") check_and_pin("https://musehub.ai") store = load_hub_trust_store() assert store["musehub.ai"]["verified_count"] == 2 def test_fingerprint_mismatch_raises( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """Stored fingerprint != actual → raises HubFingerprintMismatchError.""" trust_file = tmp_path / "hub_trust.toml" monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file) fp_original = "sha256:original1111" fp_changed = "sha256:changed99999" # First connection — pins original. with _patch_cert_fp(fp_original): check_and_pin("https://musehub.ai") # Second connection — different cert. with _patch_cert_fp(fp_changed): with pytest.raises(HubFingerprintMismatchError) as exc_info: check_and_pin("https://musehub.ai") err = exc_info.value assert err.stored_fingerprint == fp_original assert err.actual_fingerprint == fp_changed assert "musehub.ai" in err.hostname # Error message must include the reset command. assert "hub-reset" in str(err) def test_http_stores_sentinel( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """HTTP URL → fingerprint = 'http-no-tls', warning emitted to stderr.""" trust_file = tmp_path / "hub_trust.toml" monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file) check_and_pin("http://localhost:1337") store = load_hub_trust_store() assert "localhost:1337" in store assert store["localhost:1337"]["fingerprint"] == HTTP_NO_TLS_SENTINEL captured = capsys.readouterr() assert "http" in captured.err.lower() or "tls" in captured.err.lower() or "unencrypted" in captured.err.lower() def test_normalise_hostname_https() -> None: """HTTPS URL without explicit port → bare hostname.""" assert _normalise_hostname("https://musehub.ai") == "musehub.ai" def test_normalise_hostname_http_with_port() -> None: """HTTP URL with port → host:port.""" assert _normalise_hostname("https://localhost:1337/api/v1/repos") == "localhost:1337" def test_hub_trust_list_cli( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """muse trust hub-list shows pinned hubs.""" import argparse trust_file = tmp_path / "hub_trust.toml" monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file) fp = "sha256:aabbccdd11223344" with _patch_cert_fp(fp): check_and_pin("https://musehub.ai") # Import and invoke the CLI handler directly. from muse.cli.commands.trust import _run_hub_list args = argparse.Namespace() _run_hub_list(args) captured = capsys.readouterr() assert "musehub.ai" in captured.out assert fp in captured.out def test_hub_reset_cli( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: """muse trust hub-reset removes record; next connection re-pins.""" import argparse trust_file = tmp_path / "hub_trust.toml" monkeypatch.setattr("muse.core.hub_trust._HUB_TRUST_FILE", trust_file) fp1 = "sha256:original0000" with _patch_cert_fp(fp1): check_and_pin("https://musehub.ai") # Reset. from muse.cli.commands.trust import _run_hub_reset args = argparse.Namespace(hostname="musehub.ai") _run_hub_reset(args) captured = capsys.readouterr() assert "reset" in captured.out.lower() or "removed" in captured.out.lower() # Store should be empty now. store = load_hub_trust_store() assert "musehub.ai" not in store # Next connection re-pins with new cert. fp2 = "sha256:newcert9999" with _patch_cert_fp(fp2): check_and_pin("https://musehub.ai") store = load_hub_trust_store() assert store["musehub.ai"]["fingerprint"] == fp2 assert store["musehub.ai"]["verified_count"] == 1