"""Tests for ``muse auth keygen`` — BIP39/SLIP-0010 HD key generation. Coverage matrix --------------- Unit - IdentityEntry accepts hd_path, algorithm, fingerprint fields - _dump_identity serialises HD fields correctly - _dump_identity round-trips through tomllib - derive_hd_public_info returns correct public_key_b64 and fingerprint - derive_hd_public_info derived key matches hdkeys.derive_identity_key Integration (full CLI round-trips via CliRunner) - ``muse auth keygen`` exits 0 - no PEM file written to disk - mnemonic printed exactly once on stderr - mnemonic has correct word count (12 words default, 24 with --strength 256) - mnemonic passes BIP39 validation - public_key_b64 and fingerprint in stderr output - --hd --force overwrites existing key - --hd --force rejected when key exists without --force - --json output: no mnemonic in stdout, has key_source/hd_path/mnemonic_word_count - --strength 256 produces 24-word mnemonic - --language spanish generates a valid Spanish mnemonic - JBOK key and HD key are different private keys (different derivation paths) - HD key is deterministic: same mnemonic → same fingerprint End-to-end - Full flow: keygen --hd → verify PEM → derive same key from stored mnemonic - identity.toml written with key_source, mnemonic, hd_path after keygen Stress - 10 successive keygen --hd --force calls all produce valid, distinct keys - keygen --hd for all 5 supported entropy strengths (128–256 bits) Data integrity - mnemonic stored in identity.toml round-trips byte-for-byte - derived fingerprint is stable across multiple muse_path invocations - SLIP-0010 child key from the same seed is identical on repeated calls Security - mnemonic never appears in JSON stdout (stdout is machine-readable-only) - mnemonic not in key_path or fingerprint - --hd with unsupported --strength exits 1 - --hd with unsupported --language exits 1 - PEM mode is 0o600 (no group/world bits) Performance - keygen --hd completes in < 2 s (PBKDF2 + SLIP-0010 are fast) Docstrings - generate_hd_keypair has a docstring - run_keygen docstring mentions --hd flag """ from __future__ import annotations import hashlib import json import os import pathlib import stat import time import pytest 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.identity import IdentityEntry, _dump_identity from muse.core.bip39 import validate_mnemonic, word_count, STRENGTH_PARANOID from muse.core.hdkeys import ( derive_identity_key, MUSE_PURPOSE, DOMAIN_IDENTITY, ENTITY_HUMAN, ROLE_SIGN, muse_path, ) from muse.core.slip010 import master_key from muse.core.bip39 import mnemonic_to_seed from muse.core.types import b64url_decode, public_key_fingerprint, split_pubkey runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: """Redirect ~/.muse to a temp dir for this test.""" 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") # Simulate a TTY so the mnemonic is printed rather than suppressed. monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: True) return fake_home def _keygen_hd(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, extra_args: list[str] | None = None) -> "tuple[pathlib.Path, InvokeResult]": """Run ``muse auth keygen --hub https://localhost:1337`` and return (fake_home, result).""" fake_home = _patch_home(monkeypatch, tmp_path) # Isolate the keychain so tests start with no existing mnemonic. _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) args = ["auth", "keygen", "--hub", "https://localhost:1337"] + (extra_args or []) result = runner.invoke(None, args) return fake_home, result # --------------------------------------------------------------------------- # Unit — IdentityEntry HD fields # --------------------------------------------------------------------------- class TestIdentityEntryHdFields: """IdentityEntry TypedDict must accept HD provenance fields.""" def test_mnemonic_field_accepted(self) -> None: entry: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "abc123", "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", } assert entry["mnemonic"].startswith("abandon") def test_hd_path_field_accepted(self) -> None: entry: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "abc123", "hd_path": f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'", } assert MUSE_PURPOSE > 0 class TestDumpIdentityHdFields: """_dump_identity must serialise HD fields when present.""" def test_hd_path_serialised(self) -> None: hd_path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'" entry: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "abc", "hd_path": hd_path, } toml = _dump_identity({"localhost:1337": entry}) assert "hd_path" in toml assert str(MUSE_PURPOSE) in toml def test_hd_fields_round_trip_through_tomllib(self) -> None: import tomllib hd_path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'" entry: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "abc", "hd_path": hd_path, } toml = _dump_identity({"localhost:1337": entry}) parsed = tomllib.loads(toml) restored = parsed["localhost:1337"] assert restored["hd_path"] == hd_path assert "key_source" not in restored assert "mnemonic" not in restored def test_entry_no_spurious_fields(self) -> None: """Entries must not have key_source or mnemonic written to TOML.""" entry: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "abc", } toml = _dump_identity({"localhost:1337": entry}) assert "key_source" not in toml assert "mnemonic" not in toml assert "hd_path" not in toml # --------------------------------------------------------------------------- # Unit — generate_hd_keypair # --------------------------------------------------------------------------- class TestGenerateHdKeypair: """Unit tests for keypair.derive_hd_public_info.""" def test_returns_pub_b64_and_fingerprint(self) -> None: from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fp = derive_hd_public_info(seed) assert isinstance(pub_b64, str) and len(pub_b64) > 0 assert isinstance(fp, str) and fp.startswith("sha256:") def test_fingerprint_is_sha256_hex(self) -> None: from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fp = derive_hd_public_info(seed) _, b64_part = split_pubkey(pub_b64) raw = b64url_decode(b64_part) assert public_key_fingerprint(raw) == fp def test_derived_key_matches_hdkeys(self) -> None: from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fp = derive_hd_public_info(seed) # Reproduce derivation manually dk = derive_identity_key(seed) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes) pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) expected_fp = public_key_fingerprint(pub_raw) assert fp == expected_fp def test_derive_hd_public_info_returns_pub_b64_and_fingerprint( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """derive_hd_public_info returns (pub_b64, fingerprint) without writing any file.""" _patch_home(monkeypatch, tmp_path) from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fingerprint = derive_hd_public_info(seed) assert pub_b64 and len(pub_b64) > 0 assert fingerprint.startswith("sha256:") def test_no_pem_written_by_derive_hd_public_info( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """derive_hd_public_info must not write any PEM file.""" fake_home = _patch_home(monkeypatch, tmp_path) from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed("abandon " * 11 + "about") derive_hd_public_info(seed) keys_dir = fake_home / ".muse" / "keys" pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] assert pem_files == [], f"Unexpected PEM files: {pem_files}" def test_deterministic_same_seed(self) -> None: from muse.core.keypair import derive_hd_public_info seed = mnemonic_to_seed("abandon " * 11 + "about") _, fp1 = derive_hd_public_info(seed) _, fp2 = derive_hd_public_info(seed) assert fp1 == fp2 def test_jbok_generate_keypair_does_not_exist(self) -> None: """JBOK mode is deleted — generate_keypair must not be importable.""" import importlib kp = importlib.import_module("muse.core.keypair") assert not hasattr(kp, "generate_keypair"), \ "generate_keypair still exists — JBOK was not fully removed" # --------------------------------------------------------------------------- # Integration — CLI # --------------------------------------------------------------------------- class TestKeygenHdCli: """Full CLI round-trips for ``muse auth keygen --hd``.""" def test_exits_zero( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path) assert result.exit_code == 0, result.output def test_no_pem_written( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home, result = _keygen_hd(monkeypatch, tmp_path) assert result.exit_code == 0, result.output keys_dir = fake_home / ".muse" / "keys" pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] assert pem_files == [], f"Unexpected PEM files: {pem_files}" def test_mnemonic_in_stderr( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path) # Mnemonic words appear in combined output (CliRunner merges streams) assert "mnemonic" in result.stderr.lower() or len(result.stderr.split()) >= 12 def test_mnemonic_is_24_words_by_default( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) assert result.exit_code == 0, result.output # Default strength=256 → 24-word mnemonic; mnemonic never printed, # word count reported via JSON. payload = json.loads(result.output.splitlines()[0]) assert payload.get("mnemonic_word_count") == 24, ( f"Expected mnemonic_word_count=24, got {payload.get('mnemonic_word_count')}" ) def test_strength_256_produces_24_words( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--strength", "256", "--json"]) assert result.exit_code == 0, result.output # Mnemonic never printed; word count confirmed via JSON. payload = json.loads(result.output.splitlines()[0]) assert payload.get("mnemonic_word_count") == 24 def test_language_spanish( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: # Inline keychain setup so we can read the stored mnemonic for validation. _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--language", "spanish"], ) assert result.exit_code == 0, result.output # Mnemonic is never printed; validate it from the keychain. mnemonic = _kc.get("mnemonic") assert mnemonic is not None, "mnemonic not stored in keychain" assert validate_mnemonic(mnemonic, language="spanish"), ( f"Stored mnemonic is not a valid Spanish mnemonic: {mnemonic!r}" ) def test_json_output_no_mnemonic_in_stdout( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) assert result.exit_code == 0, result.output # First line of output is JSON json_line = result.output.splitlines()[0] payload = json.loads(json_line) assert "mnemonic" not in payload, "mnemonic must never appear in JSON stdout" def test_json_output_has_hd_path( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) json_line = result.output.splitlines()[0] payload = json.loads(json_line) assert "hd_path" in payload assert str(MUSE_PURPOSE) in payload["hd_path"] def test_hd_path_uses_hash_derived_identity_domain_not_legacy_zero( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Keygen must write hd_path with the hash-derived DOMAIN_IDENTITY (1660078172), not legacy 0. Regression guard for the staging gabriel/muse key, which was generated with the old auto-increment identity segment (0) before hash-derived domain integers were introduced. """ _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) hd_path = payload["hd_path"] assert str(DOMAIN_IDENTITY) in hd_path, ( f"hd_path does not contain DOMAIN_IDENTITY ({DOMAIN_IDENTITY}): {hd_path!r}" ) # The path must be the exact canonical form. assert hd_path == f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/0'/0'/0'/0'", ( f"keygen produced wrong hd_path (legacy segment?): {hd_path!r}" ) def test_json_output_has_mnemonic_word_count( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) json_line = result.output.splitlines()[0] payload = json.loads(json_line) assert payload.get("mnemonic_word_count") == 24 # default strength=256 def test_json_output_standard_fields( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) json_line = result.output.splitlines()[0] payload = json.loads(json_line) for field in ("status", "hub", "hostname", "public_key_b64", "fingerprint"): assert field in payload, f"Missing field: {field}" assert "key_path" not in payload, "key_path must not appear in JSON output" def test_force_overwrites_existing( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) args_base = ["auth", "keygen", "--hub", "https://localhost:1337"] runner.invoke(None, args_base) result = runner.invoke(None, args_base + ["--force"]) assert result.exit_code == 0, result.output def test_second_keygen_without_force_rejected( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Repeated keygen without --force must fail — identity already exists.""" _patch_home(monkeypatch, tmp_path) args_base = ["auth", "keygen", "--hub", "https://localhost:1337"] r1 = runner.invoke(None, args_base) assert r1.exit_code == 0, r1.output r2 = runner.invoke(None, args_base) assert r2.exit_code != 0, "Second keygen without --force should fail" # --------------------------------------------------------------------------- # End-to-end # --------------------------------------------------------------------------- class TestKeygenHdEndToEnd: """Full derivation round-trip: generate → verify → re-derive.""" def test_derived_key_reproducible_from_stored_mnemonic( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Fingerprint from keygen must match manual re-derivation from keychain mnemonic.""" # Patch keychain so we can read back what keygen stored. _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--json"], ) assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) stored_fingerprint = payload["fingerprint"] # Read mnemonic from the in-memory keychain (never from terminal output). mnemonic = _kc.get("mnemonic") assert mnemonic is not None, "mnemonic not stored in keychain" # Re-derive the key from the mnemonic and compare fingerprints. seed = mnemonic_to_seed(mnemonic) dk = derive_identity_key(seed) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes) pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) recomputed_fp = public_key_fingerprint(pub_raw) assert recomputed_fp == stored_fingerprint, "Re-derived fingerprint does not match stored" def test_mnemonic_derives_and_signs( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Mnemonic stored in keychain must produce a key that signs and verifies.""" fixed_mnemonic = "abandon " * 11 + "about" import muse.core.bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic.strip()) _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) _patch_home(monkeypatch, tmp_path) runner.invoke(None, ["auth", "keygen", "--hub", "https://localhost:1337"]) mnemonic = _kc.get("mnemonic") assert mnemonic is not None, "mnemonic not stored in keychain" seed = mnemonic_to_seed(mnemonic) dk = derive_identity_key(seed) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey key = Ed25519PrivateKey.from_private_bytes(dk.private_bytes) dk.zero() sig = key.sign(b"muse test message") key.public_key().verify(sig, b"muse test message") # --------------------------------------------------------------------------- # Security # --------------------------------------------------------------------------- class TestKeygenHdSecurity: """Security properties of HD keygen.""" def test_mnemonic_not_in_json_stdout( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"]) json_line = result.output.splitlines()[0] payload = json.loads(json_line) assert "mnemonic" not in payload def test_unsupported_strength_exits_nonzero( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--strength", "64"], ) assert result.exit_code != 0 def test_unsupported_language_exits_nonzero( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--language", "klingon"], ) assert result.exit_code != 0 def test_no_pem_on_disk_after_keygen( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Keygen must not write any PEM private key to disk.""" fake_home, result = _keygen_hd(monkeypatch, tmp_path) assert result.exit_code == 0 keys_dir = fake_home / ".muse" / "keys" pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] assert pem_files == [], f"PEM files found on disk: {pem_files}" # --------------------------------------------------------------------------- # Performance # --------------------------------------------------------------------------- class TestKeygenHdPerformance: """HD keygen must complete quickly enough for interactive use.""" def test_keygen_hd_under_2s( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) start = time.monotonic() result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337"], ) elapsed = time.monotonic() - start assert result.exit_code == 0, result.output assert elapsed < 2.0, f"keygen --hd took {elapsed:.2f}s — too slow" # --------------------------------------------------------------------------- # Docstrings # --------------------------------------------------------------------------- class TestDocstrings: def test_derive_hd_public_info_has_docstring(self) -> None: from muse.core.keypair import derive_hd_public_info assert derive_hd_public_info.__doc__, "derive_hd_public_info is missing a docstring" def test_run_keygen_mentions_hd(self) -> None: from muse.cli.commands.auth import run_keygen doc = run_keygen.__doc__ or "" assert "HD" in doc or "BIP39" in doc or "mnemonic" in doc.lower() # --------------------------------------------------------------------------- # Stress # --------------------------------------------------------------------------- class TestKeygenHdStress: """HD keygen must be robust under repeated and varied invocations.""" def test_10_successive_force_keygens_produce_valid_keys( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Repeated --force --destroy-mnemonic keygen must each produce a distinct key.""" _patch_home(monkeypatch, tmp_path) seen_fingerprints: set[str] = set() for _ in range(10): result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--destroy-mnemonic", "--json"], ) assert result.exit_code == 0, result.output json_line = result.output.splitlines()[0] payload = json.loads(json_line) fp = payload["fingerprint"] # Each successive keygen with --destroy-mnemonic uses new entropy seen_fingerprints.add(fp) # All 10 keys must be independently valid (distinct fingerprints) assert len(seen_fingerprints) == 10, "Repeated keygen produced duplicate keys" def test_all_entropy_strengths_produce_valid_keys( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """All 5 supported strength values (128–256 bits) must succeed.""" strengths = [128, 160, 192, 224, 256] expected_word_counts = [12, 15, 18, 21, 24] fake_home = _patch_home(monkeypatch, tmp_path) for strength, n_words in zip(strengths, expected_word_counts): # --destroy-mnemonic ensures fresh entropy for each strength iteration, # rather than reusing the mnemonic stored by the previous iteration. result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--strength", str(strength), "--force", "--destroy-mnemonic", "--json"], ) assert result.exit_code == 0, f"strength={strength}: {result.output}" json_line = result.output.splitlines()[0] payload = json.loads(json_line) assert payload["mnemonic_word_count"] == n_words, \ f"strength={strength}: expected {n_words} words, got {payload['mnemonic_word_count']}" assert "fingerprint" in payload, f"fingerprint missing for strength={strength}" # --------------------------------------------------------------------------- # Data integrity # --------------------------------------------------------------------------- class TestKeygenHdDataIntegrity: """Derived keys and stored mnemonics must be byte-for-byte stable.""" def test_keygen_hd_key_derives_correctly( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Keygen --hd must write a PEM key consistent with the generated mnemonic.""" from muse.core import bip39 as bip39_mod fixed_mnemonic = ( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about" ) monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic) _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) fake_home = _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337"], ) assert result.exit_code == 0 # Mnemonic must be in keychain, not TOML stored_mnemonic = _kc.get("mnemonic") assert stored_mnemonic == fixed_mnemonic, "Mnemonic not stored in keychain" # Fingerprint in JSON output must match re-derivation from the mnemonic result_json = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"], ) assert result_json.exit_code == 0 payload = json.loads(result_json.output.splitlines()[0]) reported_fp = payload["fingerprint"] seed = mnemonic_to_seed(stored_mnemonic) dk = derive_identity_key(seed) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes) dk.zero() pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) recomputed_fp = public_key_fingerprint(pub_raw) assert recomputed_fp == reported_fp, \ "Re-derived fingerprint from mnemonic does not match keygen output" def test_slip010_child_key_identical_on_repeated_calls(self) -> None: """derive_identity_key with the same seed must produce the same bytes every time.""" seed = b"\xab\xcd\xef" * 21 + b"\x00" # 64 bytes dk1 = derive_identity_key(seed) dk2 = derive_identity_key(seed) assert dk1.private_bytes == dk2.private_bytes, \ "SLIP-0010 derivation is not deterministic" def test_derived_fingerprint_stable_across_invocations( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """Same mnemonic must yield the same fingerprint across two keygen calls.""" _patch_home(monkeypatch, tmp_path) fixed = ( "abandon abandon abandon abandon abandon abandon " "abandon abandon abandon abandon abandon about" ) import muse.core.bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed) # Isolate keychain so both calls go through generate_mnemonic _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) result1 = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--json"], ) fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"] result2 = runner.invoke( None, ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"], ) fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"] assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen" # --------------------------------------------------------------------------- # Phase 4 — keygen writes no PEM; identity entry has no key_path # --------------------------------------------------------------------------- _P4_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _P4_HUB = "https://localhost:1337" def _p4_patch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: """Patch home + keychain for Phase 4 tests; returns fake_home.""" fake_home = _patch_home(monkeypatch, tmp_path) import muse.core.bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _P4_MNEMONIC) _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") return fake_home class TestKeygenPhase4NoPem: """Phase 4: auth keygen must NOT write PEM files and must NOT store key_path.""" def test_P4_1_no_pem_written_after_keygen( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """P4-1: no *.pem file must exist in ~/.muse/keys/ after keygen.""" fake_home = _p4_patch(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB]) assert result.exit_code == 0, result.output keys_dir = fake_home / ".muse" / "keys" pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] assert pem_files == [], f"Unexpected PEM files written: {pem_files}" def test_P4_2_identity_entry_has_no_key_path( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """P4-2: identity.toml entry must NOT contain key_path after keygen.""" import tomllib fake_home = _p4_patch(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB]) assert result.exit_code == 0, result.output identity_file = fake_home / ".muse" / "identity.toml" assert identity_file.exists(), "identity.toml was not written" parsed = tomllib.loads(identity_file.read_text()) hostname = "localhost:1337" assert hostname in parsed, f"No entry for {hostname}" entry = parsed[hostname] assert "key_path" not in entry, f"key_path must not appear in identity entry: {entry}" def test_P4_3_identity_entry_has_hd_path( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """P4-3: identity.toml entry must have hd_path after keygen.""" import tomllib fake_home = _p4_patch(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB]) assert result.exit_code == 0, result.output identity_file = fake_home / ".muse" / "identity.toml" parsed = tomllib.loads(identity_file.read_text()) entry = parsed["localhost:1337"] assert "hd_path" in entry, f"hd_path missing from identity entry: {entry}" assert entry["hd_path"].startswith("m/"), f"hd_path has wrong format: {entry['hd_path']}" def test_P4_4_resolve_signing_identity_works_after_keygen_and_register( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """P4-4: resolve_signing_identity returns a key after keygen + handle set (register step).""" from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.identity import resolve_signing_identity, load_identity, save_identity fake_home = _p4_patch(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB]) assert result.exit_code == 0, result.output # Simulate the handle being set after registration entry = load_identity(_P4_HUB) assert entry is not None entry["handle"] = "gabriel" save_identity(_P4_HUB, entry) result2 = resolve_signing_identity(_P4_HUB) assert result2 is not None, "resolve_signing_identity returned None after keygen+register" handle, private_key = result2 assert handle == "gabriel" assert isinstance(private_key, Ed25519PrivateKey) def test_P4_5_json_output_has_no_key_path( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """P4-5: --json output must not include key_path.""" _p4_patch(monkeypatch, tmp_path) result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB, "--json"]) assert result.exit_code == 0, result.output payload = json.loads(result.output.splitlines()[0]) assert "key_path" not in payload, f"key_path must not appear in JSON output: {payload}"