"""Security tests — no PEM files ever touch disk in the current architecture. Invariants ---------- NP-1 muse auth keygen produces no *.pem files in ~/.muse/keys/. NP-2 muse auth keygen --agent-id produces no *.pem files. NP-3 resolve_signing_identity derives the key from the keychain mnemonic without reading any file from ~/.muse/keys/. NP-4 muse auth security-check reports no_pem_files=True after keygen. NP-5 The ~/.muse/keys/ directory is either absent or empty of *.pem files on a fresh install (no legacy cleanup needed). """ from __future__ import annotations import pathlib import json import pytest from tests.cli_test_helper import CliRunner _HUB = "https://localhost:1337" _HOSTNAME = "localhost:1337" _MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) runner = CliRunner() # --------------------------------------------------------------------------- # Fixtures / helpers # --------------------------------------------------------------------------- def _patch_env( monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> pathlib.Path: import muse.core.keypair as kp_module import muse.core.identity as id_module fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) return fake_home def _pem_files(home: pathlib.Path) -> list[pathlib.Path]: keys_dir = home / ".muse" / "keys" if not keys_dir.exists(): return [] return list(keys_dir.glob("*.pem")) # --------------------------------------------------------------------------- # NP-1 — human keygen produces no PEM # --------------------------------------------------------------------------- class TestNoPemAfterHumanKeygen: def test_NP_1_no_pem_in_keys_dir( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """NP-1: muse auth keygen must not write any *.pem file.""" home = _patch_env(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB]) assert result.exit_code == 0, result.output assert _pem_files(home) == [], f"PEM files found after keygen: {_pem_files(home)}" def test_NP_1b_hd_path_written_instead( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Identity entry must have hd_path (not key_path) after keygen.""" import tomllib home = _patch_env(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB]) assert result.exit_code == 0 data = tomllib.loads((home / ".muse" / "identity.toml").read_text()) entry = data[_HOSTNAME] assert "hd_path" in entry, "hd_path not written" assert "key_path" not in entry, "key_path must not be written" # --------------------------------------------------------------------------- # NP-2 — agent keygen produces no PEM # --------------------------------------------------------------------------- class TestNoPemAfterAgentKeygen: def test_NP_2_no_pem_in_keys_dir( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """NP-2: muse auth keygen --agent-id must not write any *.pem file.""" home = _patch_env(monkeypatch, tmp_path) runner.invoke(None, ["auth", "keygen", "--hub", _HUB]) result = runner.invoke( None, ["auth", "keygen", "--hub", _HUB, "--agent-id", "bot-alpha"] ) assert result.exit_code == 0, result.output assert _pem_files(home) == [], f"PEM files found after agent keygen: {_pem_files(home)}" # --------------------------------------------------------------------------- # NP-3 — resolve_signing_identity reads no file from ~/.muse/keys/ # --------------------------------------------------------------------------- class TestResolveNoPemRead: def test_NP_3_no_keys_dir_read( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """NP-3: resolve_signing_identity must not touch ~/.muse/keys/.""" from muse.core.identity import save_identity, resolve_signing_identity from muse.core.keypair import derive_hd_public_info from muse.core.bip39 import mnemonic_to_seed home = _patch_env(monkeypatch, tmp_path) # Pre-seed keychain with our fixed mnemonic monkeypatch.setattr("muse.core.keychain.load", lambda: _MNEMONIC) seed = mnemonic_to_seed(_MNEMONIC) _, fingerprint = derive_hd_public_info(seed) save_identity(_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": fingerprint, "hd_path": "m/1075233755'/0'/0'/0'/0'/0'", }) # Make sure keys/ dir is absent — any open() inside it would FileNotFoundError keys_dir = home / ".muse" / "keys" assert not keys_dir.exists(), "keys/ dir should not exist" result = resolve_signing_identity(_HUB) assert result is not None, "resolve_signing_identity returned None — key not derived" def test_NP_3b_works_even_if_keys_dir_empty( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """NP-3b: resolve_signing_identity works even if keys/ dir is empty.""" from muse.core.identity import save_identity, resolve_signing_identity from muse.core.keypair import derive_hd_public_info from muse.core.bip39 import mnemonic_to_seed home = _patch_env(monkeypatch, tmp_path) monkeypatch.setattr("muse.core.keychain.load", lambda: _MNEMONIC) seed = mnemonic_to_seed(_MNEMONIC) _, fingerprint = derive_hd_public_info(seed) save_identity(_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": fingerprint, "hd_path": "m/1075233755'/0'/0'/0'/0'/0'", }) # Create keys/ dir but leave it empty (home / ".muse" / "keys").mkdir(parents=True) result = resolve_signing_identity(_HUB) assert result is not None # --------------------------------------------------------------------------- # NP-4 — security-check reports no_pem_files after keygen # --------------------------------------------------------------------------- class TestSecurityCheckNoPem: def test_NP_4_security_check_passes_after_keygen( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """NP-4: muse auth security-check exits 0 and no_pem_files=True after keygen.""" _patch_env(monkeypatch, tmp_path) runner.invoke(None, ["auth", "keygen", "--hub", _HUB]) result = runner.invoke(None, ["auth", "security-check", "--hub", _HUB, "--json"]) assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) assert payload["no_pem_files"] is True, f"no_pem_files not True: {payload}" # --------------------------------------------------------------------------- # NP-5 — fresh install has no PEM files # --------------------------------------------------------------------------- class TestFreshInstallNoPem: def test_NP_5_fresh_keys_dir_has_no_pem( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """NP-5: a freshly patched home directory has no PEM files.""" home = _patch_env(monkeypatch, tmp_path) pems = _pem_files(home) assert pems == [], f"PEM files found in fresh home: {pems}"