"""Tests for the mnemonic-destruction guard in ``muse auth keygen``. Background ---------- ``muse auth keygen --force`` used to silently generate fresh entropy and overwrite the existing OS keychain mnemonic. Losing the mnemonic means permanent, irrecoverable loss of every key ever derived from it — all registered hub identities become unrecoverable. Fix --- ``--force`` now only overwrites the identity *entry* in identity.toml. Destroying the mnemonic requires the explicit ``--destroy-mnemonic`` flag *in addition to* ``--force``. Neither flag alone is sufficient. Coverage -------- I Unit — mnemonic reuse / guard I1 keygen with no existing mnemonic generates fresh entropy (baseline) I2 keygen --force with existing mnemonic reuses it (no destruction) I3 keygen --destroy-mnemonic without --force exits non-zero (blocked by the "existing identity" guard before reaching the mnemonic guard) I4 keygen --destroy-mnemonic --force generates fresh entropy (escape hatch) I5 keygen --force fingerprint is stable (same mnemonic → same key) I6 keygen --destroy-mnemonic --force fingerprint changes (new entropy) II CLI output / flags II1 --force alone emits "reused from keychain" in output II2 --destroy-mnemonic --force emits "generated and saved to keychain" III Guard message quality III1 --destroy-mnemonic without --force mentions --force in the error III2 --force alone never prints "destroy" or "overwrite" in mnemonic context IV Data integrity IV1 identity.toml fingerprint unchanged after --force (mnemonic reused) IV2 identity.toml fingerprint changes after --destroy-mnemonic --force IV3 keychain mnemonic unchanged after --force IV4 keychain mnemonic changes after --destroy-mnemonic --force """ from __future__ import annotations import pathlib from typing import Generator from unittest.mock import patch import pytest from collections.abc import Mapping from tests.cli_test_helper import CliRunner, InvokeResult from muse.core import keypair as kp_module from muse.core import identity as id_module from muse.core.paths import muse_dir runner = CliRunner() _HUB = "https://localhost:1337" _HOSTNAME = "localhost:1337" _MNEMONIC_A = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _MNEMONIC_B = ( "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong" ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: """Isolated home dir + keychain + hub stubs for keygen tests. Patches: - ``~/.muse/`` → ``tmp_path/home/.muse/`` - OS keychain → in-memory dict (no macOS Keychain I/O) - ``_json_post_raw`` → stub returning valid challenge/verify responses - ``_hub_delete`` → no-op - ``muse.core.bip39.generate_mnemonic`` → returns _MNEMONIC_B by default (tests that need the real generator can override this) """ fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) fake_muse = muse_dir(fake_home) fake_muse.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_muse / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_muse) monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_muse / "identity.toml") monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) _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)) # generate_mnemonic → deterministic fresh entropy (distinct from _MNEMONIC_A) import muse.core.bip39 as _bip39 monkeypatch.setattr(_bip39, "generate_mnemonic", lambda **kw: _MNEMONIC_B) import muse.cli.commands.auth as _auth_mod _challenge = {"challenge_token": "deadbeef" * 16, "is_new_key": True, "algorithm": "ed25519"} _verify = {"handle": "gabriel", "identity_id": "id-123", "is_new_identity": True, "auth_method": "ed25519"} monkeypatch.setattr( _auth_mod, "_json_post_raw", lambda base, path, payload, extra_headers=None: _challenge if "challenge" in path else _verify, ) monkeypatch.setattr(_auth_mod, "_hub_delete", lambda url, auth_header, ssl_ctx=None: None) return fake_home def _keygen(*extra_args: str) -> InvokeResult: return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json", *extra_args]) def _get_kc(monkeypatch_kc_dict: Mapping[str, str]) -> str | None: # pragma: no cover return monkeypatch_kc_dict.get("mnemonic") # --------------------------------------------------------------------------- # I Unit — mnemonic reuse / guard # --------------------------------------------------------------------------- class TestMnemonicGuardUnit: def test_I1_no_existing_mnemonic_generates_fresh( self, isolated: pathlib.Path, ) -> None: """I1: first keygen with no keychain mnemonic always generates fresh entropy.""" r = _keygen() assert r.exit_code == 0, r.output try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] toml = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text()) assert _HOSTNAME in toml # The stub generate_mnemonic returns _MNEMONIC_B so the key should be # derived from that, not _MNEMONIC_A. assert toml[_HOSTNAME]["fingerprint"] # non-empty def test_I2_force_with_existing_mnemonic_reuses_it( self, isolated: pathlib.Path, ) -> None: """I2: --force with an existing keychain mnemonic must NOT generate new entropy.""" # Seed keychain with mnemonic A. import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) r_first = _keygen() assert r_first.exit_code == 0, r_first.output fp_first = __import__("json").loads(r_first.output)["fingerprint"] r_force = _keygen("--force") assert r_force.exit_code == 0, r_force.output fp_force = __import__("json").loads(r_force.output)["fingerprint"] assert fp_first == fp_force, ( "--force must reuse the existing mnemonic — fingerprint must not change" ) assert _kc_mod.load() == _MNEMONIC_A, ( "--force must not overwrite the keychain mnemonic" ) def test_I3_destroy_mnemonic_without_force_is_blocked( self, isolated: pathlib.Path, ) -> None: """I3: --destroy-mnemonic without --force is rejected at the identity guard. The identity guard fires first (before the mnemonic guard) when an existing identity is present. Either way, the exit code must be non-zero and the mnemonic must be untouched. """ import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() # establish an identity entry r = _keygen("--destroy-mnemonic") assert r.exit_code != 0, ( "--destroy-mnemonic without --force must exit non-zero" ) assert _kc_mod.load() == _MNEMONIC_A, ( "keychain mnemonic must be untouched when blocked" ) def test_I4_destroy_mnemonic_and_force_generates_fresh( self, isolated: pathlib.Path, ) -> None: """I4: --destroy-mnemonic --force together must generate fresh entropy.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() # establish identity with mnemonic A r = _keygen("--force", "--destroy-mnemonic") assert r.exit_code == 0, r.output new_mnemonic = _kc_mod.load() assert new_mnemonic != _MNEMONIC_A, ( "--destroy-mnemonic --force must generate fresh entropy, " "not reuse the existing mnemonic" ) assert new_mnemonic == _MNEMONIC_B, ( "fresh mnemonic must be what generate_mnemonic() returned" ) def test_I5_force_fingerprint_stable( self, isolated: pathlib.Path, ) -> None: """I5: --force produces the same fingerprint on every call (mnemonic reused).""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) r1 = _keygen() r2 = _keygen("--force") r3 = _keygen("--force") fp1 = __import__("json").loads(r1.output)["fingerprint"] fp2 = __import__("json").loads(r2.output)["fingerprint"] fp3 = __import__("json").loads(r3.output)["fingerprint"] assert fp1 == fp2 == fp3, "--force must produce the same key every time" def test_I6_destroy_mnemonic_force_fingerprint_changes( self, isolated: pathlib.Path, ) -> None: """I6: --destroy-mnemonic --force must produce a different fingerprint.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) r_before = _keygen() fp_before = __import__("json").loads(r_before.output)["fingerprint"] r_destroy = _keygen("--force", "--destroy-mnemonic") assert r_destroy.exit_code == 0, r_destroy.output fp_after = __import__("json").loads(r_destroy.output)["fingerprint"] assert fp_before != fp_after, ( "--destroy-mnemonic --force must derive a new key from fresh entropy" ) # --------------------------------------------------------------------------- # II CLI output / flags # --------------------------------------------------------------------------- class TestMnemonicGuardOutput: def test_II1_force_reports_reused( self, isolated: pathlib.Path, ) -> None: """II1: --force with existing mnemonic reports 'reused from keychain' on stderr.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() r = _keygen("--force") assert r.exit_code == 0, r.output assert "already stored in your OS keychain" in r.stderr, ( "--force must report on stderr that the existing mnemonic was reused" ) def test_II2_destroy_mnemonic_force_reports_generated( self, isolated: pathlib.Path, ) -> None: """II2: --destroy-mnemonic --force reports 'generated and saved to keychain' on stderr.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() r = _keygen("--force", "--destroy-mnemonic") assert r.exit_code == 0, r.output assert "generated and stored in your OS keychain" in r.stderr, ( "--destroy-mnemonic --force must report on stderr that new entropy was generated" ) # --------------------------------------------------------------------------- # III Guard message quality # --------------------------------------------------------------------------- class TestMnemonicGuardMessages: def test_III1_destroy_mnemonic_no_force_mentions_force( self, isolated: pathlib.Path, ) -> None: """III1: the error when --destroy-mnemonic is missing --force must mention --force.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() r = _keygen("--destroy-mnemonic") assert r.exit_code != 0 combined = r.output + r.stderr assert "--force" in combined, ( "error message must mention --force so the user knows the escape hatch" ) def test_III2_force_output_never_says_destroyed( self, isolated: pathlib.Path, ) -> None: """III2: --force alone must not print anything implying the mnemonic was changed.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() r = _keygen("--force") assert r.exit_code == 0, r.output for forbidden in ("destroy", "overwrit", "generat"): assert forbidden not in r.output.lower() or "reused" in r.output.lower(), ( f"--force output must not imply mnemonic was destroyed (found '{forbidden}')" ) # --------------------------------------------------------------------------- # IV Data integrity # --------------------------------------------------------------------------- class TestMnemonicGuardDataIntegrity: def test_IV1_identity_toml_fingerprint_unchanged_after_force( self, isolated: pathlib.Path, ) -> None: """IV1: identity.toml fingerprint must not change when --force reuses mnemonic.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] fp_before = tomllib.loads( (muse_dir(isolated) / "identity.toml").read_text() )[_HOSTNAME]["fingerprint"] _keygen("--force") fp_after = tomllib.loads( (muse_dir(isolated) / "identity.toml").read_text() )[_HOSTNAME]["fingerprint"] assert fp_after == fp_before, ( "--force must not change identity.toml fingerprint when mnemonic is reused" ) def test_IV2_identity_toml_fingerprint_changes_after_destroy( self, isolated: pathlib.Path, ) -> None: """IV2: identity.toml fingerprint must change after --destroy-mnemonic --force.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] fp_before = tomllib.loads( (muse_dir(isolated) / "identity.toml").read_text() )[_HOSTNAME]["fingerprint"] _keygen("--force", "--destroy-mnemonic") fp_after = tomllib.loads( (muse_dir(isolated) / "identity.toml").read_text() )[_HOSTNAME]["fingerprint"] assert fp_after != fp_before, ( "--destroy-mnemonic --force must write a new fingerprint to identity.toml" ) def test_IV3_keychain_mnemonic_unchanged_after_force( self, isolated: pathlib.Path, ) -> None: """IV3: keychain mnemonic must be byte-for-byte identical after --force.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() _keygen("--force") assert _kc_mod.load() == _MNEMONIC_A, ( "--force must not modify the keychain mnemonic" ) def test_IV4_keychain_mnemonic_changes_after_destroy_force( self, isolated: pathlib.Path, ) -> None: """IV4: keychain mnemonic must be replaced after --destroy-mnemonic --force.""" import muse.core.keychain as _kc_mod _kc_mod.store(_MNEMONIC_A) _keygen() _keygen("--force", "--destroy-mnemonic") new_mnemonic = _kc_mod.load() assert new_mnemonic is not None, "mnemonic must be written to keychain" assert new_mnemonic != _MNEMONIC_A, ( "--destroy-mnemonic --force must replace the mnemonic in the keychain" )