"""Tests for ``muse auth keygen --hd`` — BIP39/SLIP-0010 HD key generation. Coverage matrix --------------- Unit - IdentityEntry accepts key_source, mnemonic, hd_path fields - _dump_identity serialises HD fields correctly - _dump_identity round-trips through tomllib - generate_hd_keypair returns correct public_key_b64 and fingerprint - generate_hd_keypair derived key matches hdkeys.derive_identity_key - generate_hd_keypair writes PEM to the expected path - generate_hd_keypair PEM is a valid Ed25519 key Integration (full CLI round-trips via CliRunner) - ``muse auth keygen --hd`` exits 0 - PEM file written at expected path - PEM file has 0o600 permissions - 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 base64 import hashlib import json import os import pathlib import stat import time import pytest from cryptography.hazmat.primitives.serialization import load_pem_private_key from tests.cli_test_helper import CliRunner 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 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") return fake_home def _keygen_hd(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path, extra_args: list[str] | None = None) -> tuple[pathlib.Path, object]: """Run ``muse auth keygen --hub http://localhost:10003`` and return (fake_home, result).""" fake_home = _patch_home(monkeypatch, tmp_path) args = ["auth", "keygen", "--hub", "http://localhost:10003"] + (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", "key_path": "/tmp/test.pem", "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", "key_path": "/tmp/test.pem", "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", "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": "abc", "hd_path": hd_path, } toml = _dump_identity({"localhost:10003": 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", "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": "abc", "hd_path": hd_path, } toml = _dump_identity({"localhost:10003": entry}) parsed = tomllib.loads(toml) restored = parsed["localhost:10003"] 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", "key_path": "/tmp/k.pem", "algorithm": "ed25519", "fingerprint": "abc", } toml = _dump_identity({"localhost:10003": 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.generate_hd_keypair.""" def test_returns_pub_b64_and_fingerprint( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) from muse.core.keypair import generate_hd_keypair seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fp = generate_hd_keypair("localhost:10003", seed) assert isinstance(pub_b64, str) and len(pub_b64) > 0 assert isinstance(fp, str) and len(fp) == 64 def test_fingerprint_is_sha256_hex( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) from muse.core.keypair import generate_hd_keypair seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fp = generate_hd_keypair("localhost:10003", seed) raw = base64.urlsafe_b64decode(pub_b64 + "==") assert hashlib.sha256(raw).hexdigest() == fp def test_derived_key_matches_hdkeys( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) from muse.core.keypair import generate_hd_keypair seed = mnemonic_to_seed("abandon " * 11 + "about") pub_b64, fp = generate_hd_keypair("localhost:10003", 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 = hashlib.sha256(pub_raw).hexdigest() assert fp == expected_fp def test_pem_file_written( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) from muse.core.keypair import generate_hd_keypair, key_path_for seed = mnemonic_to_seed("abandon " * 11 + "about") generate_hd_keypair("localhost:10003", seed) pem_path = key_path_for("localhost:10003") assert pem_path.is_file() def test_pem_is_valid_ed25519_key( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) from muse.core.keypair import generate_hd_keypair, key_path_for from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey seed = mnemonic_to_seed("abandon " * 11 + "about") generate_hd_keypair("localhost:10003", seed) pem_path = key_path_for("localhost:10003") key = load_pem_private_key(pem_path.read_bytes(), password=None) assert isinstance(key, Ed25519PrivateKey) def test_deterministic_same_seed( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _patch_home(monkeypatch, tmp_path) from muse.core.keypair import generate_hd_keypair seed = mnemonic_to_seed("abandon " * 11 + "about") _, fp1 = generate_hd_keypair("localhost:10003", seed) _, fp2 = generate_hd_keypair("localhost:10003", 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_pem_written( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home, result = _keygen_hd(monkeypatch, tmp_path) pem = fake_home / ".muse" / "keys" / "localhost_10003.pem" assert pem.is_file(), result.output def test_pem_permissions_600( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home, result = _keygen_hd(monkeypatch, tmp_path) pem = fake_home / ".muse" / "keys" / "localhost_10003.pem" mode = pem.stat().st_mode & 0o777 assert mode == 0o600, f"PEM mode is {oct(mode)}, expected 0o600" 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.output.lower() or len(result.output.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) # Default strength=256 → 24-word mnemonic all_text = result.output mnemonic_line = None for line in all_text.splitlines(): words = line.strip().split() if len(words) == 24 and all(w.isalpha() for w in words): mnemonic_line = line.strip() break assert mnemonic_line is not None, f"No 24-word line found in output:\n{all_text}" assert validate_mnemonic(mnemonic_line), f"24-word line is not a valid mnemonic: {mnemonic_line!r}" def test_strength_256_produces_24_words( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--strength", "256"]) assert result.exit_code == 0, result.output all_text = result.output mnemonic_line = None for line in all_text.splitlines(): words = line.strip().split() if len(words) == 24 and all(w.isalpha() for w in words): mnemonic_line = line.strip() break assert mnemonic_line is not None, f"No 24-word line found:\n{all_text}" assert validate_mnemonic(mnemonic_line) def test_language_spanish( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: _, result = _keygen_hd(monkeypatch, tmp_path, ["--language", "spanish"]) assert result.exit_code == 0, result.output all_text = result.output mnemonic_line = None for line in all_text.splitlines(): words = line.strip().split() if len(words) == 24: # default strength=256 → 24 words mnemonic_line = line.strip() break assert mnemonic_line is not None assert validate_mnemonic(mnemonic_line, language="spanish") 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_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", "key_path", "public_key_b64", "fingerprint"): assert field in payload, f"Missing field: {field}" 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", "http://localhost:10003"] runner.invoke(None, args_base) result = runner.invoke(None, args_base + ["--force"]) assert result.exit_code == 0, result.output def test_no_force_rejects_existing( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home = _patch_home(monkeypatch, tmp_path) args_base = ["auth", "keygen", "--hub", "http://localhost:10003"] runner.invoke(None, args_base) result = runner.invoke(None, args_base) # second time, no --force assert result.exit_code != 0 assert "already exists" in result.output.lower() or "force" in result.output.lower() # --------------------------------------------------------------------------- # 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: """Key written to PEM must match manual re-derivation from the mnemonic.""" fake_home = _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003", "--json"], ) assert result.exit_code == 0, result.output json_line = result.output.splitlines()[0] payload = json.loads(json_line) stored_fingerprint = payload["fingerprint"] stored_pub_b64 = payload["public_key_b64"] # Extract mnemonic from stderr (non-JSON lines) mnemonic_line = None for line in result.output.splitlines()[1:]: # skip JSON first line words = line.strip().split() if 12 <= len(words) <= 24 and all(w.isalpha() for w in words): mnemonic_line = line.strip() break assert mnemonic_line is not None, f"No mnemonic line found:\n{result.output}" # Re-derive the key from the mnemonic seed = mnemonic_to_seed(mnemonic_line) 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 = hashlib.sha256(pub_raw).hexdigest() assert recomputed_fp == stored_fingerprint, "Re-derived fingerprint does not match stored" def test_pem_loads_and_signs( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: """PEM file must load as Ed25519 and produce a valid signature.""" fake_home = _patch_home(monkeypatch, tmp_path) runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003"], ) pem_path = fake_home / ".muse" / "keys" / "localhost_10003.pem" from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey key = load_pem_private_key(pem_path.read_bytes(), password=None) assert isinstance(key, Ed25519PrivateKey) sig = key.sign(b"muse test message") key.public_key().verify(sig, b"muse test message") # raises on bad sig # --------------------------------------------------------------------------- # 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", "http://localhost:10003", "--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", "http://localhost:10003", "--language", "klingon"], ) assert result.exit_code != 0 def test_pem_mode_is_600( self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ) -> None: fake_home, result = _keygen_hd(monkeypatch, tmp_path) pem = fake_home / ".muse" / "keys" / "localhost_10003.pem" assert result.exit_code == 0 mode = pem.stat().st_mode & 0o777 assert not (mode & 0o177), f"PEM has unsafe mode {oct(mode)}" # --------------------------------------------------------------------------- # 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", "http://localhost:10003"], ) 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_generate_hd_keypair_has_docstring(self) -> None: from muse.core.keypair import generate_hd_keypair assert generate_hd_keypair.__doc__, "generate_hd_keypair 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 keygen must each produce a valid, loadable PEM.""" _patch_home(monkeypatch, tmp_path) seen_fingerprints: set[str] = set() for _ in range(10): result = runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003", "--force", "--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 without fixing the 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): result = runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003", "--strength", str(strength), "--force", "--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']}" pem = fake_home / ".muse" / "keys" / "localhost_10003.pem" assert pem.exists(), f"PEM not written 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 hub, m: _kc.__setitem__(hub, m)) monkeypatch.setattr("muse.core.keychain.load", lambda hub: _kc.get(hub)) fake_home = _patch_home(monkeypatch, tmp_path) result = runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003"], ) assert result.exit_code == 0 # Mnemonic must be in keychain, not TOML stored_mnemonic = _kc.get("http://localhost:10003") assert stored_mnemonic == fixed_mnemonic, "Mnemonic not stored in keychain" # PEM key must match re-derivation from the mnemonic 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) pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) recomputed_fp = hashlib.sha256(pub_raw).hexdigest() pem = fake_home / ".muse" / "keys" / "localhost_10003.pem" loaded_key = load_pem_private_key(pem.read_bytes(), password=None) pub_raw_loaded = loaded_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw) stored_fp = hashlib.sha256(pub_raw_loaded).hexdigest() assert recomputed_fp == stored_fp, \ "Re-derived fingerprint from keychain mnemonic does not match stored PEM key" 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) # Patch generate_mnemonic to return a fixed mnemonic both times from muse.core.bip39 import mnemonic_to_seed as _mts 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) result1 = runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003", "--json"], ) fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"] result2 = runner.invoke( None, ["auth", "keygen", "--hub", "http://localhost:10003", "--force", "--json"], ) fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"] assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen"