"""Tests for muse.core.keychain — OS keychain integration — Tier 2. The keychain module is the only place mnemonics are stored at rest. Plaintext TOML storage of mnemonics is permanently retired. Coverage -------- I keychain module API I1 store → load round-trip returns the stored phrase I2 load returns None when no entry exists I3 delete removes the entry; load returns None afterward I4 delete on missing entry returns False without raising I5 is_available returns False when MUSE_KEYCHAIN_BACKEND=disabled II identity.toml never contains mnemonic II1 save_identity with mnemonic kwarg stores in keychain, not TOML II2 TOML written by save_identity has no "mnemonic" key II3 load_identity retrieves mnemonic from keychain, not TOML II4 identity TOML has no key_source field (derivation is always HD) III keygen stores mnemonic in keychain III1 muse auth keygen --json stdout has no "mnemonic" key III2 identity.toml written after keygen has no mnemonic field III3 keychain holds the mnemonic after keygen IV keychain disabled (MUSE_KEYCHAIN_BACKEND=disabled) IV1 is_available() is False IV2 store() returns False without raising IV3 load() returns None without raising IV4 muse auth keygen still succeeds (mnemonic is ephemeral) """ from __future__ import annotations import json import os import pathlib import pytest try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] from tests.cli_test_helper import CliRunner cli = None runner = CliRunner() _TEST_HUB = "http://localhost:10003" _TEST_HOSTNAME = "localhost:10003" _TEST_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def keychain_in_memory(monkeypatch: pytest.MonkeyPatch) -> dict: """Patch keyring to use an in-memory dict as the backend. Returns the dict so tests can inspect it directly. """ store: dict[tuple[str, str], str] = {} import keyring monkeypatch.setattr(keyring, "set_password", lambda svc, usr, pwd: store.__setitem__((svc, usr), pwd)) monkeypatch.setattr(keyring, "get_password", lambda svc, usr: store.get((svc, usr))) import keyring.errors def _delete(svc: str, usr: str) -> None: if (svc, usr) not in store: raise keyring.errors.PasswordDeleteError("not found") del store[(svc, usr)] monkeypatch.setattr(keyring, "delete_password", _delete) # Patch is_available to return True since we have a working in-memory backend import muse.core.keychain as kc_mod monkeypatch.setattr(kc_mod, "is_available", lambda: True) monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False) return store # type: ignore[return-value] @pytest.fixture() def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: fake_dir = tmp_path / "dot_muse" fake_dir.mkdir() monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml") return fake_dir @pytest.fixture() def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: keys_dir = tmp_path / "keys" keys_dir.mkdir() monkeypatch.setattr("muse.core.keypair._KEYS_DIR", keys_dir) return keys_dir @pytest.fixture() def repo_with_hub(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: muse_dir = tmp_path / ".muse" muse_dir.mkdir() (muse_dir / "HEAD").write_text("ref: refs/heads/main\n") (muse_dir / "refs" / "heads").mkdir(parents=True) (muse_dir / "objects").mkdir() (muse_dir / "commits").mkdir() (muse_dir / "snapshots").mkdir() (muse_dir / "config.toml").write_text(f'[hub]\nurl = "{_TEST_HUB}"\n') monkeypatch.chdir(tmp_path) return tmp_path # --------------------------------------------------------------------------- # I keychain module API # --------------------------------------------------------------------------- class TestKeychainApiI: def test_I1_store_load_roundtrip( self, keychain_in_memory: dict, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core.keychain import store, load assert store(_TEST_HUB, _TEST_MNEMONIC) assert load(_TEST_HUB) == _TEST_MNEMONIC def test_I2_load_missing_returns_none( self, keychain_in_memory: dict ) -> None: from muse.core.keychain import load assert load("http://not-registered.example.com") is None def test_I3_delete_removes_entry( self, keychain_in_memory: dict ) -> None: from muse.core.keychain import store, load, delete store(_TEST_HUB, _TEST_MNEMONIC) assert delete(_TEST_HUB) assert load(_TEST_HUB) is None def test_I4_delete_missing_returns_false( self, keychain_in_memory: dict ) -> None: from muse.core.keychain import delete assert not delete("http://not-registered.example.com") def test_I5_disabled_backend_not_available( self, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") from muse.core import keychain import importlib importlib.reload(keychain) assert not keychain.is_available() # --------------------------------------------------------------------------- # II identity.toml never contains mnemonic # --------------------------------------------------------------------------- class TestIdentityNoMnemonicII: def test_II1_save_stores_mnemonic_in_keychain( self, isolated_identity: pathlib.Path, keychain_in_memory: dict, ) -> None: """save_identity with mnemonic puts it in keychain, not TOML.""" from muse.core.identity import save_identity, IdentityEntry entry: IdentityEntry = { "type": "human", "handle": "gabriel", "key_path": "/fake/key.pem", "algorithm": "ed25519", "fingerprint": "a" * 64, } save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC) # Keychain has the mnemonic from muse.core.keychain import load as kc_load assert kc_load(_TEST_HUB) == _TEST_MNEMONIC def test_II2_toml_has_no_mnemonic_key( self, isolated_identity: pathlib.Path, keychain_in_memory: dict, ) -> None: """The TOML file written by save_identity must not contain 'mnemonic'.""" from muse.core.identity import save_identity, IdentityEntry entry: IdentityEntry = { "type": "human", "handle": "gabriel", "key_path": "/fake/key.pem", "algorithm": "ed25519", "fingerprint": "a" * 64, } save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC) toml_text = (isolated_identity / "identity.toml").read_text() assert "mnemonic" not in toml_text.lower(), ( f"'mnemonic' found in TOML:\n{toml_text}" ) def test_II3_load_retrieves_mnemonic_from_keychain( self, isolated_identity: pathlib.Path, keychain_in_memory: dict, ) -> None: """load_identity fetches the mnemonic from keychain and injects it.""" from muse.core.identity import save_identity, load_identity, IdentityEntry entry: IdentityEntry = { "type": "human", "handle": "gabriel", "key_path": "/fake/key.pem", "algorithm": "ed25519", "fingerprint": "a" * 64, } save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC) loaded = load_identity(_TEST_HUB) assert loaded is not None assert loaded.get("mnemonic") == _TEST_MNEMONIC def test_II4_toml_has_no_key_source_field( self, isolated_identity: pathlib.Path, keychain_in_memory: dict, ) -> None: """TOML must not contain a key_source field — derivation method is implied.""" from muse.core.identity import save_identity, IdentityEntry entry: IdentityEntry = { "type": "human", "handle": "gabriel", "key_path": "/fake/key.pem", "algorithm": "ed25519", "fingerprint": "a" * 64, } save_identity(_TEST_HUB, entry, mnemonic=_TEST_MNEMONIC) toml_text = (isolated_identity / "identity.toml").read_text() assert "key_source" not in toml_text # --------------------------------------------------------------------------- # III keygen stores mnemonic in keychain # --------------------------------------------------------------------------- class TestKeygenUsesKeychainIII: def test_III1_keygen_json_stdout_no_mnemonic( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_in_memory: dict, monkeypatch: pytest.MonkeyPatch, ) -> None: """III1: muse auth keygen --json stdout must not contain 'mnemonic'.""" from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC) result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"]) assert result.exit_code == 0, f"keygen failed:\n{result.output}" json_lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] assert json_lines, "No JSON output found" for line in json_lines: data = json.loads(line) assert "mnemonic" not in data, f"'mnemonic' key in JSON output: {data}" def test_III2_keygen_toml_has_no_mnemonic( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_in_memory: dict, monkeypatch: pytest.MonkeyPatch, ) -> None: """III2: identity.toml after keygen must not have mnemonic in plaintext.""" from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC) runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"]) toml_file = isolated_identity / "identity.toml" assert toml_file.exists(), "identity.toml not created" content = toml_file.read_text() assert "mnemonic" not in content.lower(), f"mnemonic in TOML:\n{content}" def test_III3_keychain_holds_mnemonic_after_keygen( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_in_memory: dict, monkeypatch: pytest.MonkeyPatch, ) -> None: """III3: the keychain has the generated mnemonic after keygen.""" from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC) runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"]) from muse.core.keychain import load as kc_load stored = kc_load(_TEST_HUB) assert stored == _TEST_MNEMONIC, f"Keychain does not have mnemonic, got: {stored!r}" # --------------------------------------------------------------------------- # IV keychain disabled # --------------------------------------------------------------------------- class TestKeychainDisabledIV: def test_IV1_is_available_false(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") from muse.core import keychain import importlib importlib.reload(keychain) assert not keychain.is_available() def test_IV2_store_returns_false(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") from muse.core import keychain import importlib importlib.reload(keychain) assert not keychain.store(_TEST_HUB, _TEST_MNEMONIC) def test_IV3_load_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") from muse.core import keychain import importlib importlib.reload(keychain) assert keychain.load(_TEST_HUB) is None def test_IV4_keygen_succeeds_without_keychain( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """IV4: keygen still works when keychain is disabled (mnemonic is ephemeral).""" monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _TEST_MNEMONIC) result = runner.invoke(cli, ["auth", "keygen", "--hub", _TEST_HUB, "--json"]) assert result.exit_code == 0, f"keygen failed with disabled keychain:\n{result.output}" # --------------------------------------------------------------------------- # V keychain unavailable — operator must be warned (CRITICAL-1) # --------------------------------------------------------------------------- class TestKeychainUnavailableWarnsV: """V When the keychain is truly unavailable (not intentionally disabled), save_identity must warn the operator that the mnemonic is ephemeral. MUSE_KEYCHAIN_BACKEND=disabled is CI/test mode and must stay silent. Any other cause of is_available()==False is an operational failure and demands a log.warning so the operator knows their root key is not persisted. """ _entry: dict = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "a" * 64, } def test_V1_warns_when_keychain_unavailable( self, isolated_identity: pathlib.Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """V1: save_identity logs a warning when keychain is unavailable for a non-intentional reason (no backend, library not installed, etc.). Simulate: is_available() returns False but MUSE_KEYCHAIN_BACKEND is not set. """ import logging from unittest.mock import patch from muse.core import keychain as kc_mod from muse.core.identity import save_identity monkeypatch.delenv("MUSE_KEYCHAIN_BACKEND", raising=False) with patch.object(kc_mod, "is_available", return_value=False): with caplog.at_level(logging.WARNING, logger="muse.core.identity"): save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type] warning_messages = [ r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING ] assert warning_messages, ( "Expected a warning about unavailable keychain — got none.\n" f"All log records: {[r.getMessage() for r in caplog.records]}" ) combined = " ".join(warning_messages).lower() assert "keychain" in combined or "ephemeral" in combined, ( f"Warning must mention 'keychain' or 'ephemeral': {warning_messages}" ) def test_V2_silent_when_keychain_intentionally_disabled( self, isolated_identity: pathlib.Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, ) -> None: """V2: no keychain warning when MUSE_KEYCHAIN_BACKEND=disabled (CI/test mode). The disabled env var signals intentional ephemeral operation — the operator has opted out of keychain storage on purpose, so no warning should fire. """ import logging from muse.core.identity import save_identity monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") with caplog.at_level(logging.WARNING, logger="muse.core.identity"): save_identity(_TEST_HUB, self._entry, mnemonic=_TEST_MNEMONIC) # type: ignore[arg-type] keychain_warnings = [ r.getMessage() for r in caplog.records if r.levelno >= logging.WARNING and ("keychain" in r.getMessage().lower() or "ephemeral" in r.getMessage().lower()) ] assert not keychain_warnings, ( f"Unexpected keychain warning in intentional CI/disabled mode: {keychain_warnings}" )