test_security_no_pem_on_disk.py
python
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