gabriel / muse public
test_security_no_pem_on_disk.py python
208 lines 8.2 KB
Raw
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 12 days ago
1 """Security tests — no PEM files ever touch disk in the current architecture.
2
3 Invariants
4 ----------
5 NP-1 muse auth keygen produces no *.pem files in ~/.muse/keys/.
6 NP-2 muse auth keygen --agent-id produces no *.pem files.
7 NP-3 resolve_signing_identity derives the key from the keychain mnemonic
8 without reading any file from ~/.muse/keys/.
9 NP-4 muse auth security-check reports no_pem_files=True after keygen.
10 NP-5 The ~/.muse/keys/ directory is either absent or empty of *.pem files
11 on a fresh install (no legacy cleanup needed).
12 """
13
14 from __future__ import annotations
15
16 import pathlib
17 import json
18
19 import pytest
20
21 from tests.cli_test_helper import CliRunner
22
23 _HUB = "https://localhost:1337"
24 _HOSTNAME = "localhost:1337"
25 _MNEMONIC = (
26 "abandon abandon abandon abandon abandon abandon abandon abandon "
27 "abandon abandon abandon about"
28 )
29
30 runner = CliRunner()
31
32
33 # ---------------------------------------------------------------------------
34 # Fixtures / helpers
35 # ---------------------------------------------------------------------------
36
37
38 def _patch_env(
39 monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
40 ) -> pathlib.Path:
41 import muse.core.keypair as kp_module
42 import muse.core.identity as id_module
43
44 fake_home = tmp_path / "home"
45 fake_home.mkdir(parents=True, exist_ok=True)
46 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
47 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
48 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
49 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
50
51 _kc: dict[str, str] = {}
52 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
53 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
54 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
55 monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None))
56 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
57 return fake_home
58
59
60 def _pem_files(home: pathlib.Path) -> list[pathlib.Path]:
61 keys_dir = home / ".muse" / "keys"
62 if not keys_dir.exists():
63 return []
64 return list(keys_dir.glob("*.pem"))
65
66
67 # ---------------------------------------------------------------------------
68 # NP-1 — human keygen produces no PEM
69 # ---------------------------------------------------------------------------
70
71
72 class TestNoPemAfterHumanKeygen:
73 def test_NP_1_no_pem_in_keys_dir(
74 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
75 ) -> None:
76 """NP-1: muse auth keygen must not write any *.pem file."""
77 home = _patch_env(monkeypatch, tmp_path)
78 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
79 assert result.exit_code == 0, result.output
80 assert _pem_files(home) == [], f"PEM files found after keygen: {_pem_files(home)}"
81
82 def test_NP_1b_hd_path_written_instead(
83 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
84 ) -> None:
85 """Identity entry must have hd_path (not key_path) after keygen."""
86 import tomllib
87 home = _patch_env(monkeypatch, tmp_path)
88 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
89 assert result.exit_code == 0
90 data = tomllib.loads((home / ".muse" / "identity.toml").read_text())
91 entry = data[_HOSTNAME]
92 assert "hd_path" in entry, "hd_path not written"
93 assert "key_path" not in entry, "key_path must not be written"
94
95
96 # ---------------------------------------------------------------------------
97 # NP-2 — agent keygen produces no PEM
98 # ---------------------------------------------------------------------------
99
100
101 class TestNoPemAfterAgentKeygen:
102 def test_NP_2_no_pem_in_keys_dir(
103 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
104 ) -> None:
105 """NP-2: muse auth keygen --agent-id must not write any *.pem file."""
106 home = _patch_env(monkeypatch, tmp_path)
107 runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
108 result = runner.invoke(
109 None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"]
110 )
111 assert result.exit_code == 0, result.output
112 assert _pem_files(home) == [], f"PEM files found after agent keygen: {_pem_files(home)}"
113
114
115 # ---------------------------------------------------------------------------
116 # NP-3 — resolve_signing_identity reads no file from ~/.muse/keys/
117 # ---------------------------------------------------------------------------
118
119
120 class TestResolveNoPemRead:
121 def test_NP_3_no_keys_dir_read(
122 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
123 ) -> None:
124 """NP-3: resolve_signing_identity must not touch ~/.muse/keys/."""
125 from muse.core.identity import save_identity, resolve_signing_identity
126 from muse.core.keypair import derive_hd_public_info
127 from muse.core.bip39 import mnemonic_to_seed
128
129 home = _patch_env(monkeypatch, tmp_path)
130 # Pre-seed keychain with our fixed mnemonic
131 monkeypatch.setattr("muse.core.keychain.load", lambda: _MNEMONIC)
132 seed = mnemonic_to_seed(_MNEMONIC)
133 _, fingerprint = derive_hd_public_info(seed)
134
135 save_identity(_HUB, {
136 "type": "human",
137 "handle": "gabriel",
138 "algorithm": "ed25519",
139 "fingerprint": fingerprint,
140 "hd_path": "m/1075233755'/0'/0'/0'/0'/0'",
141 })
142
143 # Make sure keys/ dir is absent — any open() inside it would FileNotFoundError
144 keys_dir = home / ".muse" / "keys"
145 assert not keys_dir.exists(), "keys/ dir should not exist"
146
147 result = resolve_signing_identity(_HUB)
148 assert result is not None, "resolve_signing_identity returned None — key not derived"
149
150 def test_NP_3b_works_even_if_keys_dir_empty(
151 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
152 ) -> None:
153 """NP-3b: resolve_signing_identity works even if keys/ dir is empty."""
154 from muse.core.identity import save_identity, resolve_signing_identity
155 from muse.core.keypair import derive_hd_public_info
156 from muse.core.bip39 import mnemonic_to_seed
157
158 home = _patch_env(monkeypatch, tmp_path)
159 monkeypatch.setattr("muse.core.keychain.load", lambda: _MNEMONIC)
160 seed = mnemonic_to_seed(_MNEMONIC)
161 _, fingerprint = derive_hd_public_info(seed)
162
163 save_identity(_HUB, {
164 "type": "human",
165 "handle": "gabriel",
166 "algorithm": "ed25519",
167 "fingerprint": fingerprint,
168 "hd_path": "m/1075233755'/0'/0'/0'/0'/0'",
169 })
170
171 # Create keys/ dir but leave it empty
172 (home / ".muse" / "keys").mkdir(parents=True)
173
174 result = resolve_signing_identity(_HUB)
175 assert result is not None
176
177
178 # ---------------------------------------------------------------------------
179 # NP-4 — security-check reports no_pem_files after keygen
180 # ---------------------------------------------------------------------------
181
182
183 class TestSecurityCheckNoPem:
184 def test_NP_4_security_check_passes_after_keygen(
185 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
186 ) -> None:
187 """NP-4: muse auth security-check exits 0 and no_pem_files=True after keygen."""
188 _patch_env(monkeypatch, tmp_path)
189 runner.invoke(None, ["auth", "keygen", "--hub", _HUB])
190 result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"])
191 assert result.exit_code == 0, result.output
192 payload = json.loads(result.output.splitlines()[0])
193 assert payload["no_pem_files"] is True, f"no_pem_files not True: {payload}"
194
195
196 # ---------------------------------------------------------------------------
197 # NP-5 — fresh install has no PEM files
198 # ---------------------------------------------------------------------------
199
200
201 class TestFreshInstallNoPem:
202 def test_NP_5_fresh_keys_dir_has_no_pem(
203 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
204 ) -> None:
205 """NP-5: a freshly patched home directory has no PEM files."""
206 home = _patch_env(monkeypatch, tmp_path)
207 pems = _pem_files(home)
208 assert pems == [], f"PEM files found in fresh home: {pems}"
File History 5 commits
sha256:c5131d76c6eada02939111fda4aa8e51b0c1456b9983727cfd6be101916de14e merge: pull local/dev — resolve trivial _EXT_MAP symbol con… Sonnet 4.6 patch 12 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 19 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 28 days ago