"""HD identity persistence tests. Design invariants under test ------------------------------ - Mnemonic lives in the OS keychain (or is ephemeral in CI); it is NEVER written to ``identity.toml``. - ``hd_path`` and other identity fields ARE written to ``identity.toml``. - There is no ``key_source`` field — all keys are HD-derived; the distinction is meaningless and the field does not exist. - ``muse auth keygen`` always uses HD derivation. - ``muse auth register`` must carry forward ``hd_path`` from the existing provisional entry. Test categories --------------- 1. unit : _load_all field coverage; _dump_identity field coverage 2. integration : run_keygen writes hd_path; mnemonic absent from TOML 3. e2e : keygen → register field preservation round-trip 4. security : mnemonic absent from identity.toml and from JSON stdout 5. data_integrity: HD fields survive repeated register + TOML escaping 6. docstrings : public API has docstrings (smoke) 7. performance : save+load under 100 ms; keygen under 3 s 8. stress : 10 re-registrations; 20 successive saves without corruption """ from __future__ import annotations from collections.abc import Mapping import json import pathlib import time import tomllib import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core import keypair as kp_module cli = None runner = CliRunner() # Use a non-standard port that will never match gabriel's real keychain entries. # load_identity() injects the mnemonic from the OS keychain for the exact hub # URL — using a port that has never been registered ensures clean isolation. HUB = "http://localhost:19007" HOSTNAME = "localhost:19007" FAKE_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about" ) FAKE_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'" FAKE_FINGERPRINT = "a" * 64 FAKE_HANDLE = "gabriel" # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: """Redirect pathlib.Path.home() and module-level constants to a temp dir.""" 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") from muse.core import identity as id_module monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") return fake_home def _mock_hub(monkeypatch: pytest.MonkeyPatch, handle: str = FAKE_HANDLE) -> None: """Patch _json_post_raw to simulate a successful hub challenge-response.""" import muse.cli.commands.auth as auth_mod challenge = {"challenge_token": "deadbeef" * 8, "is_new_key": True, "algorithm": "ed25519"} verify = {"handle": handle, "identity_id": "id-123", "is_new_identity": False, "auth_method": "ed25519"} monkeypatch.setattr( auth_mod, "_json_post_raw", lambda base, path, payload: challenge if "challenge" in path else verify, ) def _mock_bip39(monkeypatch: pytest.MonkeyPatch) -> None: """Patch only mnemonic *generation* to avoid OS entropy. ``mnemonic_to_seed`` and ``derive_hd_public_info`` run for real so that: - The fingerprint stored in identity.toml is the genuine derived value. - SLIP-0010 derivation is exercised, not bypassed. """ import muse.core.bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: FAKE_MNEMONIC) def _identity_file(fake_home: pathlib.Path) -> pathlib.Path: return fake_home / ".muse" / "identity.toml" def _read_identity_toml(fake_home: pathlib.Path) -> Mapping[str, object]: ifile = _identity_file(fake_home) with ifile.open("rb") as fh: return tomllib.load(fh) # --------------------------------------------------------------------------- # 1. Unit — _load_all field coverage # --------------------------------------------------------------------------- class TestLoadAllHdFields: """_load_all must parse hd_path and provisioned_by from TOML. Mnemonic is keychain-only — must NOT be loaded from TOML. key_source does not exist — derivation is always HD.""" def _write(self, path: pathlib.Path, text: str) -> None: path.write_text(text, encoding="utf-8") def test_mnemonic_not_loaded_from_toml(self, tmp_path: pathlib.Path) -> None: """Even when mnemonic appears in a legacy TOML file, _load_all must not surface it.""" p = tmp_path / "identity.toml" self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n' f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n' f'fingerprint="{FAKE_FINGERPRINT}"\nmnemonic="{FAKE_MNEMONIC}"\n') from muse.core.identity import _load_all entry = _load_all(p)[HOSTNAME] assert "mnemonic" not in entry, ( "mnemonic must NOT be surfaced from TOML — it lives in the keychain" ) def test_loads_hd_path(self, tmp_path: pathlib.Path) -> None: p = tmp_path / "identity.toml" self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n' f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n' f'fingerprint="{FAKE_FINGERPRINT}"\nhd_path="{FAKE_HD_PATH}"\n') from muse.core.identity import _load_all assert _load_all(p)[HOSTNAME]["hd_path"] == FAKE_HD_PATH def test_no_key_source_field_exists(self, tmp_path: pathlib.Path) -> None: """key_source is not a valid field — derivation is always HD.""" p = tmp_path / "identity.toml" self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n' f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n' f'fingerprint="{FAKE_FINGERPRINT}"\nhd_path="{FAKE_HD_PATH}"\n') from muse.core.identity import _load_all entry = _load_all(p)[HOSTNAME] assert "key_source" not in entry def test_entry_without_hd_fields_has_no_hd_keys(self, tmp_path: pathlib.Path) -> None: p = tmp_path / "identity.toml" self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n' f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n' f'fingerprint="{FAKE_FINGERPRINT}"\n') from muse.core.identity import _load_all entry = _load_all(p)[HOSTNAME] assert "mnemonic" not in entry assert "hd_path" not in entry assert "key_source" not in entry def test_loads_provisioned_by(self, tmp_path: pathlib.Path) -> None: p = tmp_path / "identity.toml" self._write(p, f'["{HOSTNAME}#agent"]\ntype="agent"\nhandle="agent"\n' f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n' f'fingerprint="{FAKE_FINGERPRINT}"\nprovisioned_by="alice"\n') from muse.core.identity import _load_all entry = _load_all(p)[f"{HOSTNAME}#agent"] assert entry.get("provisioned_by") == "alice" # --------------------------------------------------------------------------- # Unit — _dump_identity field coverage # --------------------------------------------------------------------------- class TestDumpIdentityHdFields: """_dump_identity must write hd_path; mnemonic and key_source are never written.""" def test_hd_path_serialised(self) -> None: from muse.core.identity import _dump_identity entry = { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, "hd_path": FAKE_HD_PATH, } toml_text = _dump_identity({HOSTNAME: entry}) assert "hd_path" in toml_text assert FAKE_HD_PATH in toml_text def test_mnemonic_never_serialised(self) -> None: """_dump_identity must not write mnemonic even if the entry dict contains it.""" from muse.core.identity import _dump_identity entry = { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, "hd_path": FAKE_HD_PATH, "mnemonic": FAKE_MNEMONIC, # must be stripped } toml_text = _dump_identity({HOSTNAME: entry}) assert "mnemonic" not in toml_text assert FAKE_MNEMONIC not in toml_text def test_key_source_never_serialised(self) -> None: """key_source is not a valid field and must never appear in TOML.""" from muse.core.identity import _dump_identity entry = { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, } toml_text = _dump_identity({HOSTNAME: entry}) assert "key_source" not in toml_text def test_hd_path_round_trips(self, tmp_path: pathlib.Path) -> None: """hd_path survives _dump_identity → write → _load_all.""" from muse.core.identity import _dump_identity, _load_all identities = {HOSTNAME: { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, "hd_path": FAKE_HD_PATH, }} p = tmp_path / "identity.toml" p.write_text(_dump_identity(identities), encoding="utf-8") entry = _load_all(p)[HOSTNAME] assert entry["hd_path"] == FAKE_HD_PATH assert "mnemonic" not in entry assert "key_source" not in entry # --------------------------------------------------------------------------- # 2. Integration — run_keygen writes HD fields to identity.toml # --------------------------------------------------------------------------- class TestKeygenWritesIdentity: """run_keygen must persist hd_path to identity.toml. Mnemonic and key_source must NOT appear in identity.toml.""" def _run(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, extra_args: list[str] | None = None) -> tuple[InvokeResult, pathlib.Path]: fake_home = _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) args = ["auth", "keygen", "--hub", HUB] + (extra_args or []) result = runner.invoke(cli, args, catch_exceptions=False) return result, fake_home def test_identity_toml_created(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: result, fake_home = self._run(monkeypatch, tmp_path) assert result.exit_code == 0, result.output assert _identity_file(fake_home).exists() def test_hd_path_written(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: _, fake_home = self._run(monkeypatch, tmp_path) data = _read_identity_toml(fake_home) assert data[HOSTNAME].get("hd_path", "").startswith("m/") def test_mnemonic_not_in_toml(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: """After keygen, identity.toml must contain NO mnemonic field.""" _, fake_home = self._run(monkeypatch, tmp_path) data = _read_identity_toml(fake_home) assert "mnemonic" not in data[HOSTNAME], ( "mnemonic leaked into identity.toml — must live in keychain only" ) raw_text = _identity_file(fake_home).read_text() assert FAKE_MNEMONIC not in raw_text def test_key_source_not_in_toml(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: """key_source is not a field — must not appear in identity.toml.""" _, fake_home = self._run(monkeypatch, tmp_path) data = _read_identity_toml(fake_home) assert "key_source" not in data[HOSTNAME] def test_force_overwrites_writes_hd_path( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """--force overwrites the key; hd_path must be present in the new entry.""" self._run(monkeypatch, tmp_path) result, fake_home = self._run(monkeypatch, tmp_path, extra_args=["--force"]) assert result.exit_code == 0, result.output data = _read_identity_toml(fake_home) assert data[HOSTNAME].get("hd_path", "").startswith("m/") # --------------------------------------------------------------------------- # 3. Security — mnemonic never in JSON stdout # --------------------------------------------------------------------------- class TestMnemonicNeverInJsonObject: """Mnemonic must not appear in any JSON stdout object.""" def test_keygen_json_no_mnemonic_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) result = runner.invoke( cli, ["auth", "keygen", "--hub", HUB, "--json"], catch_exceptions=False, ) assert result.exit_code == 0 json_line = next( (l for l in result.output.splitlines() if l.startswith("{")), None ) assert json_line is not None, "No JSON in output" obj = json.loads(json_line) assert "mnemonic" not in obj def test_keygen_json_mnemonic_content_absent( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """The mnemonic phrase itself must not appear anywhere in the JSON dump.""" _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) result = runner.invoke( cli, ["auth", "keygen", "--hub", HUB, "--json"], catch_exceptions=False, ) json_line = next( (l for l in result.output.splitlines() if l.startswith("{")), None ) obj = json.loads(json_line) assert FAKE_MNEMONIC not in json.dumps(obj) def test_keygen_json_has_mnemonic_word_count( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """JSON has mnemonic_word_count (count, not content); value equals actual word count.""" _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) result = runner.invoke( cli, ["auth", "keygen", "--hub", HUB, "--json"], catch_exceptions=False, ) json_line = next( (l for l in result.output.splitlines() if l.startswith("{")), None ) obj = json.loads(json_line) assert "mnemonic_word_count" in obj assert isinstance(obj["mnemonic_word_count"], int) # FAKE_MNEMONIC has 12 words; run_keygen derives n_words from the # actual mnemonic string so this must match. assert obj["mnemonic_word_count"] == len(FAKE_MNEMONIC.split()) def test_mnemonic_not_in_toml_after_keygen( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """TOML file on disk must contain no mnemonic after keygen.""" fake_home = _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) runner.invoke(cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False) raw = _identity_file(fake_home).read_text() # Check no mnemonic *value* is stored — the key name itself must not # appear as a TOML assignment (key_path may contain the word in its # tmp-directory path, but no `mnemonic = ...` key should be written). import re assert re.search(r'^\s*mnemonic\s*=', raw, re.MULTILINE) is None, ( f"mnemonic key found in identity.toml:\n{raw}" ) assert FAKE_MNEMONIC not in raw # --------------------------------------------------------------------------- # 4. E2E — register preserves HD fields # --------------------------------------------------------------------------- class TestRegisterPreservesHdFields: """run_register must carry forward hd_path from the provisional entry written by keygen.""" def _setup_keygen( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> pathlib.Path: """Run keygen so identity.toml gets HD fields. Return fake_home.""" fake_home = _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) result = runner.invoke(cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False) assert result.exit_code == 0, result.output return fake_home def _run_register(self, monkeypatch: pytest.MonkeyPatch) -> InvokeResult: _mock_hub(monkeypatch) return runner.invoke( cli, ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE], catch_exceptions=False, ) def test_hd_path_preserved_after_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = self._setup_keygen(monkeypatch, tmp_path) result = self._run_register(monkeypatch) assert result.exit_code == 0, result.output data = _read_identity_toml(fake_home) entry = data[HOSTNAME] assert entry.get("hd_path", "").startswith("m/"), "hd_path lost after register" def test_mnemonic_not_in_toml_after_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """register must not write mnemonic to TOML even when carrying forward entry.""" fake_home = self._setup_keygen(monkeypatch, tmp_path) self._run_register(monkeypatch) data = _read_identity_toml(fake_home) assert "mnemonic" not in data[HOSTNAME], ( "register wrote mnemonic to TOML — keychain-only invariant violated" ) def test_handle_updated_after_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Provisional empty handle must be replaced by server-assigned handle.""" fake_home = self._setup_keygen(monkeypatch, tmp_path) pre_data = _read_identity_toml(fake_home) assert pre_data[HOSTNAME].get("handle", "") == "" self._run_register(monkeypatch) post_data = _read_identity_toml(fake_home) assert post_data[HOSTNAME].get("handle") == FAKE_HANDLE # --------------------------------------------------------------------------- # 5. Data integrity — TOML escaping edge cases # --------------------------------------------------------------------------- class TestHdPathTomlEscaping: """HD path with primes and slashes must survive _dump_identity → _load_all.""" def test_hd_path_prime_and_slash_preserved(self, tmp_path: pathlib.Path) -> None: from muse.core.identity import _dump_identity, _load_all entry = { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, "hd_path": FAKE_HD_PATH, } p = tmp_path / "identity.toml" p.write_text(_dump_identity({HOSTNAME: entry}), encoding="utf-8") assert _load_all(p)[HOSTNAME]["hd_path"] == FAKE_HD_PATH def test_handle_with_special_chars_round_trips(self, tmp_path: pathlib.Path) -> None: from muse.core.identity import _dump_identity, _load_all entry = { "type": "human", "handle": 'alice "the dev"', "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, } p = tmp_path / "identity.toml" p.write_text(_dump_identity({HOSTNAME: entry}), encoding="utf-8") assert _load_all(p)[HOSTNAME]["handle"] == 'alice "the dev"' # --------------------------------------------------------------------------- # 6. Docstring smoke tests # --------------------------------------------------------------------------- class TestDocstrings: def test_load_all_has_docstring(self) -> None: from muse.core.identity import _load_all assert _load_all.__doc__ def test_save_identity_has_docstring(self) -> None: from muse.core.identity import save_identity assert save_identity.__doc__ def test_load_identity_has_docstring(self) -> None: from muse.core.identity import load_identity assert load_identity.__doc__ # --------------------------------------------------------------------------- # 7. Performance # --------------------------------------------------------------------------- class TestPersistencePerformance: """Identity TOML read/write must add negligible latency.""" def test_save_and_load_identity_under_100ms( self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: from muse.core import identity as id_module identity_file = tmp_path / "identity.toml" monkeypatch.setattr(id_module, "_IDENTITY_DIR", tmp_path) monkeypatch.setattr(id_module, "_IDENTITY_FILE", identity_file) entry = { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, "hd_path": FAKE_HD_PATH, } start = time.monotonic() id_module.save_identity(HUB, entry) # Use _load_all directly to avoid keychain injection for an isolated test. from muse.core.identity import _load_all loaded = _load_all(identity_file).get(HOSTNAME) elapsed = time.monotonic() - start assert loaded is not None assert elapsed < 0.1, f"save+load took {elapsed * 1000:.1f}ms" def test_keygen_then_load_identity_under_3s( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Full keygen including SLIP-0010 HD derivation must complete in under 3 s.""" _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) start = time.monotonic() result = runner.invoke( cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False, ) elapsed = time.monotonic() - start assert result.exit_code == 0, result.output assert elapsed < 3.0, f"keygen took {elapsed:.2f}s" # --------------------------------------------------------------------------- # 8. Stress # --------------------------------------------------------------------------- class TestPersistenceStress: """HD field persistence must hold under repeated writes and re-loads.""" def test_10_successive_registers_preserve_hd_fields( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """hd_path must survive 10 consecutive register calls.""" fake_home = _patch_home(monkeypatch, tmp_path) _mock_bip39(monkeypatch) runner.invoke(cli, ["auth", "keygen", "--hub", HUB], catch_exceptions=False) _mock_hub(monkeypatch) for i in range(10): result = runner.invoke( cli, ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE], catch_exceptions=False, ) assert result.exit_code == 0, f"iteration {i}: {result.output}" data = _read_identity_toml(fake_home) assert data[HOSTNAME].get("hd_path", "").startswith("m/"), \ f"hd_path lost after {i + 1} register calls" assert "mnemonic" not in data[HOSTNAME], \ f"mnemonic leaked into TOML after {i + 1} register calls" def test_20_successive_saves_preserve_hd_path( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """20 successive save_identity writes must not corrupt hd_path.""" from muse.core import identity as id_module from muse.core.identity import _load_all identity_file = tmp_path / "identity.toml" monkeypatch.setattr(id_module, "_IDENTITY_DIR", tmp_path) monkeypatch.setattr(id_module, "_IDENTITY_FILE", identity_file) base_entry = { "type": "human", "handle": FAKE_HANDLE, "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": FAKE_FINGERPRINT, "hd_path": FAKE_HD_PATH, } for i in range(20): entry = {**base_entry, "handle": f"user_{i}"} id_module.save_identity(HUB, entry) # Use _load_all to avoid keychain injection for the stress hub URL. loaded = _load_all(identity_file).get(HOSTNAME) assert loaded is not None assert loaded.get("hd_path") == FAKE_HD_PATH assert "mnemonic" not in loaded assert "key_source" not in loaded