gabriel / muse public

test_cmd_auth_keygen_hd.py file-level

at sha256:9 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:5 docs: add local source-of-truth for muse#100 (no way to retrieve own pu… · gabriel · Sep 19, 2026
1 """Tests for ``muse auth keygen`` — BIP39/SLIP-0010 HD key generation.
2
3 Coverage matrix
4 ---------------
5 Unit
6 - IdentityEntry accepts hd_path, algorithm, fingerprint fields
7 - _dump_identity serialises HD fields correctly
8 - _dump_identity round-trips through tomllib
9 - derive_hd_public_info returns correct public_key_b64 and fingerprint
10 - derive_hd_public_info derived key matches hdkeys.derive_identity_key
11
12 Integration (full CLI round-trips via CliRunner)
13 - ``muse auth keygen`` exits 0
14 - no PEM file written to disk
15 - mnemonic printed exactly once on stderr
16 - mnemonic has correct word count (12 words default, 24 with --strength 256)
17 - mnemonic passes BIP39 validation
18 - public_key_b64 and fingerprint in stderr output
19 - --hd --force overwrites existing key
20 - --hd --force rejected when key exists without --force
21 - --json output: no mnemonic in stdout, has key_source/hd_path/mnemonic_word_count
22 - --strength 256 produces 24-word mnemonic
23 - --language spanish generates a valid Spanish mnemonic
24 - JBOK key and HD key are different private keys (different derivation paths)
25 - HD key is deterministic: same mnemonic → same fingerprint
26
27 End-to-end
28 - Full flow: keygen --hd → verify PEM → derive same key from stored mnemonic
29 - identity.toml written with key_source, mnemonic, hd_path after keygen
30
31 Stress
32 - 10 successive keygen --hd --force calls all produce valid, distinct keys
33 - keygen --hd for all 5 supported entropy strengths (128–256 bits)
34
35 Data integrity
36 - mnemonic stored in identity.toml round-trips byte-for-byte
37 - derived fingerprint is stable across multiple muse_path invocations
38 - SLIP-0010 child key from the same seed is identical on repeated calls
39
40 Security
41 - mnemonic never appears in JSON stdout (stdout is machine-readable-only)
42 - mnemonic not in key_path or fingerprint
43 - --hd with unsupported --strength exits 1
44 - --hd with unsupported --language exits 1
45 - PEM mode is 0o600 (no group/world bits)
46
47 Performance
48 - keygen --hd completes in < 2 s (PBKDF2 + SLIP-0010 are fast)
49
50 Docstrings
51 - generate_hd_keypair has a docstring
52 - run_keygen docstring mentions --hd flag
53 """
54
55 from __future__ import annotations
56
57 import hashlib
58 import json
59 import os
60 import pathlib
61 import stat
62 import time
63
64 import pytest
65
66 from tests.cli_test_helper import CliRunner, InvokeResult
67 from muse.core import keypair as kp_module
68 from muse.core import identity as id_module
69 from muse.core.identity import IdentityEntry, _dump_identity
70 from muse.core.bip39 import validate_mnemonic, word_count, STRENGTH_PARANOID
71 from muse.core.hdkeys import (
72 derive_identity_key,
73 hub_index,
74 MUSE_PURPOSE,
75 DOMAIN_IDENTITY,
76 ENTITY_HUMAN,
77 ROLE_SIGN,
78 muse_path,
79 )
80 from muse.core.slip010 import master_key
81 from muse.core.bip39 import mnemonic_to_seed
82 from muse.core.types import b64url_decode, public_key_fingerprint, split_pubkey
83
84 runner = CliRunner()
85
86
87 # ---------------------------------------------------------------------------
88 # Helpers
89 # ---------------------------------------------------------------------------
90
91
92 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
93 """Redirect ~/.muse to a temp dir for this test."""
94 fake_home = tmp_path / "home"
95 fake_home.mkdir(parents=True, exist_ok=True)
96 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
97 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
98 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
99 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
100 # Simulate a TTY so the mnemonic is printed rather than suppressed.
101 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: True)
102 return fake_home
103
104
105 def _keygen_hd(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path,
106 extra_args: list[str] | None = None) -> "tuple[pathlib.Path, InvokeResult]":
107 """Run ``muse auth keygen --hub https://localhost:1337`` and return (fake_home, result)."""
108 fake_home = _patch_home(monkeypatch, tmp_path)
109 # Isolate the keychain so tests start with no existing mnemonic.
110 _kc: dict[str, str] = {}
111 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
112 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
113 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
114 args = ["auth", "keygen", "--hub", "https://localhost:1337"] + (extra_args or [])
115 result = runner.invoke(None, args)
116 return fake_home, result
117
118
119 # ---------------------------------------------------------------------------
120 # Unit — IdentityEntry HD fields
121 # ---------------------------------------------------------------------------
122
123
124 class TestIdentityEntryHdFields:
125 """IdentityEntry TypedDict must accept HD provenance fields."""
126
127 def test_mnemonic_field_accepted(self) -> None:
128 entry: IdentityEntry = {
129 "type": "human",
130 "handle": "gabriel",
131 "algorithm": "ed25519",
132 "fingerprint": "abc123",
133 "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
134 }
135 assert entry["mnemonic"].startswith("abandon")
136
137 def test_hd_path_field_accepted(self) -> None:
138 entry: IdentityEntry = {
139 "type": "human",
140 "handle": "gabriel",
141 "algorithm": "ed25519",
142 "fingerprint": "abc123",
143 "hd_path": f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'",
144 }
145 assert MUSE_PURPOSE > 0
146
147
148 class TestDumpIdentityHdFields:
149 """_dump_identity must serialise HD fields when present."""
150
151 def test_hd_path_serialised(self) -> None:
152 hd_path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'"
153 entry: IdentityEntry = {
154 "type": "human", "handle": "gabriel",
155 "algorithm": "ed25519",
156 "fingerprint": "abc", "hd_path": hd_path,
157 }
158 toml = _dump_identity({"localhost:1337": entry})
159 assert "hd_path" in toml
160 assert str(MUSE_PURPOSE) in toml
161
162 def test_hd_fields_round_trip_through_tomllib(self) -> None:
163 import tomllib
164 hd_path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'"
165 entry: IdentityEntry = {
166 "type": "human", "handle": "gabriel",
167 "algorithm": "ed25519",
168 "fingerprint": "abc", "hd_path": hd_path,
169 }
170 toml = _dump_identity({"localhost:1337": entry})
171 parsed = tomllib.loads(toml)
172 restored = parsed["localhost:1337"]
173 assert restored["hd_path"] == hd_path
174 assert "key_source" not in restored
175 assert "mnemonic" not in restored
176
177 def test_entry_no_spurious_fields(self) -> None:
178 """Entries must not have key_source or mnemonic written to TOML."""
179 entry: IdentityEntry = {
180 "type": "human", "handle": "gabriel",
181 "algorithm": "ed25519",
182 "fingerprint": "abc",
183 }
184 toml = _dump_identity({"localhost:1337": entry})
185 assert "key_source" not in toml
186 assert "mnemonic" not in toml
187 assert "hd_path" not in toml
188
189
190 # ---------------------------------------------------------------------------
191 # Unit — generate_hd_keypair
192 # ---------------------------------------------------------------------------
193
194
195 class TestGenerateHdKeypair:
196 """Unit tests for keypair.derive_hd_public_info."""
197
198 def test_returns_pub_b64_and_fingerprint(self) -> None:
199 from muse.core.keypair import derive_hd_public_info
200 seed = mnemonic_to_seed("abandon " * 11 + "about")
201 pub_b64, fp = derive_hd_public_info(seed)
202 assert isinstance(pub_b64, str) and len(pub_b64) > 0
203 assert isinstance(fp, str) and fp.startswith("sha256:")
204
205 def test_fingerprint_is_sha256_hex(self) -> None:
206 from muse.core.keypair import derive_hd_public_info
207 seed = mnemonic_to_seed("abandon " * 11 + "about")
208 pub_b64, fp = derive_hd_public_info(seed)
209 _, b64_part = split_pubkey(pub_b64)
210 raw = b64url_decode(b64_part)
211 assert public_key_fingerprint(raw) == fp
212
213 def test_derived_key_matches_hdkeys(self) -> None:
214 from muse.core.keypair import derive_hd_public_info
215 seed = mnemonic_to_seed("abandon " * 11 + "about")
216 pub_b64, fp = derive_hd_public_info(seed)
217
218 # Reproduce derivation manually (hub=None matches derive_hd_public_info's default)
219 dk = derive_identity_key(seed, hub=None)
220 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
221 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
222 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
223 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
224 expected_fp = public_key_fingerprint(pub_raw)
225 assert fp == expected_fp
226
227 def test_derive_hd_public_info_returns_pub_b64_and_fingerprint(
228 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
229 ) -> None:
230 """derive_hd_public_info returns (pub_b64, fingerprint) without writing any file."""
231 _patch_home(monkeypatch, tmp_path)
232 from muse.core.keypair import derive_hd_public_info
233 seed = mnemonic_to_seed("abandon " * 11 + "about")
234 pub_b64, fingerprint = derive_hd_public_info(seed)
235 assert pub_b64 and len(pub_b64) > 0
236 assert fingerprint.startswith("sha256:")
237
238 def test_no_pem_written_by_derive_hd_public_info(
239 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
240 ) -> None:
241 """derive_hd_public_info must not write any PEM file."""
242 fake_home = _patch_home(monkeypatch, tmp_path)
243 from muse.core.keypair import derive_hd_public_info
244 seed = mnemonic_to_seed("abandon " * 11 + "about")
245 derive_hd_public_info(seed)
246 keys_dir = fake_home / ".muse" / "keys"
247 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
248 assert pem_files == [], f"Unexpected PEM files: {pem_files}"
249
250 def test_deterministic_same_seed(self) -> None:
251 from muse.core.keypair import derive_hd_public_info
252 seed = mnemonic_to_seed("abandon " * 11 + "about")
253 _, fp1 = derive_hd_public_info(seed)
254 _, fp2 = derive_hd_public_info(seed)
255 assert fp1 == fp2
256
257 def test_jbok_generate_keypair_does_not_exist(self) -> None:
258 """JBOK mode is deleted — generate_keypair must not be importable."""
259 import importlib
260 kp = importlib.import_module("muse.core.keypair")
261 assert not hasattr(kp, "generate_keypair"), \
262 "generate_keypair still exists — JBOK was not fully removed"
263
264
265 # ---------------------------------------------------------------------------
266 # Integration — CLI
267 # ---------------------------------------------------------------------------
268
269
270 class TestKeygenHdCli:
271 """Full CLI round-trips for ``muse auth keygen --hd``."""
272
273 def test_exits_zero(
274 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
275 ) -> None:
276 _, result = _keygen_hd(monkeypatch, tmp_path)
277 assert result.exit_code == 0, result.output
278
279 def test_no_pem_written(
280 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
281 ) -> None:
282 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
283 assert result.exit_code == 0, result.output
284 keys_dir = fake_home / ".muse" / "keys"
285 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
286 assert pem_files == [], f"Unexpected PEM files: {pem_files}"
287
288 def test_mnemonic_in_stderr(
289 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
290 ) -> None:
291 _, result = _keygen_hd(monkeypatch, tmp_path)
292 # Mnemonic words appear in combined output (CliRunner merges streams)
293 assert "mnemonic" in result.stderr.lower() or len(result.stderr.split()) >= 12
294
295 def test_mnemonic_is_24_words_by_default(
296 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
297 ) -> None:
298 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
299 assert result.exit_code == 0, result.output
300 # Default strength=256 → 24-word mnemonic; mnemonic never printed,
301 # word count reported via JSON.
302 payload = json.loads(result.output.splitlines()[0])
303 assert payload.get("mnemonic_word_count") == 24, (
304 f"Expected mnemonic_word_count=24, got {payload.get('mnemonic_word_count')}"
305 )
306
307 def test_strength_256_produces_24_words(
308 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
309 ) -> None:
310 _, result = _keygen_hd(monkeypatch, tmp_path, ["--strength", "256", "--json"])
311 assert result.exit_code == 0, result.output
312 # Mnemonic never printed; word count confirmed via JSON.
313 payload = json.loads(result.output.splitlines()[0])
314 assert payload.get("mnemonic_word_count") == 24
315
316 def test_language_spanish(
317 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
318 ) -> None:
319 # Inline keychain setup so we can read the stored mnemonic for validation.
320 _kc: dict[str, str] = {}
321 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
322 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
323 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
324 _patch_home(monkeypatch, tmp_path)
325 result = runner.invoke(
326 None,
327 ["auth", "keygen", "--hub", "https://localhost:1337", "--language", "spanish"],
328 )
329 assert result.exit_code == 0, result.output
330 # Mnemonic is never printed; validate it from the keychain.
331 mnemonic = _kc.get("mnemonic")
332 assert mnemonic is not None, "mnemonic not stored in keychain"
333 assert validate_mnemonic(mnemonic, language="spanish"), (
334 f"Stored mnemonic is not a valid Spanish mnemonic: {mnemonic!r}"
335 )
336
337 def test_json_output_no_mnemonic_in_stdout(
338 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
339 ) -> None:
340 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
341 assert result.exit_code == 0, result.output
342 # First line of output is JSON
343 json_line = result.output.splitlines()[0]
344 payload = json.loads(json_line)
345 assert "mnemonic" not in payload, "mnemonic must never appear in JSON stdout"
346
347 def test_json_output_has_hd_path(
348 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
349 ) -> None:
350 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
351 json_line = result.output.splitlines()[0]
352 payload = json.loads(json_line)
353 assert "hd_path" in payload
354 assert str(MUSE_PURPOSE) in payload["hd_path"]
355
356 def test_hd_path_uses_hash_derived_identity_domain_not_legacy_zero(
357 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
358 ) -> None:
359 """Keygen must write hd_path with the hash-derived DOMAIN_IDENTITY (1660078172), not legacy 0.
360
361 Regression guard for the staging gabriel/muse key, which was generated with the old
362 auto-increment identity segment (0) before hash-derived domain integers were introduced.
363 """
364 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
365 assert result.exit_code == 0, result.output
366 payload = json.loads(result.output.splitlines()[0])
367 hd_path = payload["hd_path"]
368 assert str(DOMAIN_IDENTITY) in hd_path, (
369 f"hd_path does not contain DOMAIN_IDENTITY ({DOMAIN_IDENTITY}): {hd_path!r}"
370 )
371 # The path must be the exact canonical form — hub-scoped (musehub#221).
372 expected_hub = hub_index("localhost:1337")
373 assert hd_path == f"m/{MUSE_PURPOSE}'/{DOMAIN_IDENTITY}'/0'/0'/0'/{expected_hub}'/0'", (
374 f"keygen produced wrong hd_path (legacy segment?): {hd_path!r}"
375 )
376
377 def test_json_output_has_mnemonic_word_count(
378 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
379 ) -> None:
380 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
381 json_line = result.output.splitlines()[0]
382 payload = json.loads(json_line)
383 assert payload.get("mnemonic_word_count") == 24 # default strength=256
384
385 def test_json_output_standard_fields(
386 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
387 ) -> None:
388 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
389 json_line = result.output.splitlines()[0]
390 payload = json.loads(json_line)
391 for field in ("status", "hub", "hostname", "public_key_b64", "fingerprint"):
392 assert field in payload, f"Missing field: {field}"
393 assert "key_path" not in payload, "key_path must not appear in JSON output"
394
395 def test_force_overwrites_existing(
396 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
397 ) -> None:
398 fake_home = _patch_home(monkeypatch, tmp_path)
399 args_base = ["auth", "keygen", "--hub", "https://localhost:1337"]
400 runner.invoke(None, args_base)
401 result = runner.invoke(None, args_base + ["--force"])
402 assert result.exit_code == 0, result.output
403
404 def test_second_keygen_without_force_rejected(
405 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
406 ) -> None:
407 """Repeated keygen without --force must fail — identity already exists."""
408 _patch_home(monkeypatch, tmp_path)
409 args_base = ["auth", "keygen", "--hub", "https://localhost:1337"]
410 r1 = runner.invoke(None, args_base)
411 assert r1.exit_code == 0, r1.output
412 r2 = runner.invoke(None, args_base)
413 assert r2.exit_code != 0, "Second keygen without --force should fail"
414
415
416 # ---------------------------------------------------------------------------
417 # End-to-end
418 # ---------------------------------------------------------------------------
419
420
421 class TestKeygenHdEndToEnd:
422 """Full derivation round-trip: generate → verify → re-derive."""
423
424 def test_derived_key_reproducible_from_stored_mnemonic(
425 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
426 ) -> None:
427 """Fingerprint from keygen must match manual re-derivation from keychain mnemonic."""
428 # Patch keychain so we can read back what keygen stored.
429 _kc: dict[str, str] = {}
430 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
431 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
432 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
433 _patch_home(monkeypatch, tmp_path)
434
435 result = runner.invoke(
436 None,
437 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
438 )
439 assert result.exit_code == 0, result.output
440
441 payload = json.loads(result.output.splitlines()[0])
442 stored_fingerprint = payload["fingerprint"]
443
444 # Read mnemonic from the in-memory keychain (never from terminal output).
445 mnemonic = _kc.get("mnemonic")
446 assert mnemonic is not None, "mnemonic not stored in keychain"
447
448 # Re-derive the key from the mnemonic and compare fingerprints.
449 # Hub-scoped (musehub#221) — keygen derives against localhost:1337.
450 seed = mnemonic_to_seed(mnemonic)
451 dk = derive_identity_key(seed, hub=hub_index("localhost:1337"))
452 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
453 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
454 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
455 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
456 recomputed_fp = public_key_fingerprint(pub_raw)
457
458 assert recomputed_fp == stored_fingerprint, "Re-derived fingerprint does not match stored"
459
460 def test_mnemonic_derives_and_signs(
461 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
462 ) -> None:
463 """Mnemonic stored in keychain must produce a key that signs and verifies."""
464 fixed_mnemonic = "abandon " * 11 + "about"
465 import muse.core.bip39 as bip39_mod
466 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic.strip())
467 _kc: dict[str, str] = {}
468 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
469 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
470 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
471
472 _patch_home(monkeypatch, tmp_path)
473 runner.invoke(None, ["auth", "keygen", "--hub", "https://localhost:1337"])
474
475 mnemonic = _kc.get("mnemonic")
476 assert mnemonic is not None, "mnemonic not stored in keychain"
477 seed = mnemonic_to_seed(mnemonic)
478 dk = derive_identity_key(seed, hub=hub_index("localhost:1337"))
479 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
480 key = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
481 dk.zero()
482 sig = key.sign(b"muse test message")
483 key.public_key().verify(sig, b"muse test message")
484
485
486 # ---------------------------------------------------------------------------
487 # Security
488 # ---------------------------------------------------------------------------
489
490
491 class TestKeygenHdSecurity:
492 """Security properties of HD keygen."""
493
494 def test_mnemonic_not_in_json_stdout(
495 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
496 ) -> None:
497 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
498 json_line = result.output.splitlines()[0]
499 payload = json.loads(json_line)
500 assert "mnemonic" not in payload
501
502 def test_unsupported_strength_exits_nonzero(
503 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
504 ) -> None:
505 _patch_home(monkeypatch, tmp_path)
506 result = runner.invoke(
507 None,
508 ["auth", "keygen", "--hub", "https://localhost:1337", "--strength", "64"],
509 )
510 assert result.exit_code != 0
511
512 def test_unsupported_language_exits_nonzero(
513 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
514 ) -> None:
515 _patch_home(monkeypatch, tmp_path)
516 result = runner.invoke(
517 None,
518 ["auth", "keygen", "--hub", "https://localhost:1337", "--language", "klingon"],
519 )
520 assert result.exit_code != 0
521
522 def test_no_pem_on_disk_after_keygen(
523 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
524 ) -> None:
525 """Keygen must not write any PEM private key to disk."""
526 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
527 assert result.exit_code == 0
528 keys_dir = fake_home / ".muse" / "keys"
529 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
530 assert pem_files == [], f"PEM files found on disk: {pem_files}"
531
532
533 # ---------------------------------------------------------------------------
534 # Performance
535 # ---------------------------------------------------------------------------
536
537
538 class TestKeygenHdPerformance:
539 """HD keygen must complete quickly enough for interactive use."""
540
541 def test_keygen_hd_under_2s(
542 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
543 ) -> None:
544 _patch_home(monkeypatch, tmp_path)
545 start = time.monotonic()
546 result = runner.invoke(
547 None,
548 ["auth", "keygen", "--hub", "https://localhost:1337"],
549 )
550 elapsed = time.monotonic() - start
551 assert result.exit_code == 0, result.output
552 assert elapsed < 2.0, f"keygen --hd took {elapsed:.2f}s — too slow"
553
554
555 # ---------------------------------------------------------------------------
556 # Docstrings
557 # ---------------------------------------------------------------------------
558
559
560 class TestDocstrings:
561 def test_derive_hd_public_info_has_docstring(self) -> None:
562 from muse.core.keypair import derive_hd_public_info
563 assert derive_hd_public_info.__doc__, "derive_hd_public_info is missing a docstring"
564
565 def test_run_keygen_mentions_hd(self) -> None:
566 from muse.cli.commands.auth import run_keygen
567 doc = run_keygen.__doc__ or ""
568 assert "HD" in doc or "BIP39" in doc or "mnemonic" in doc.lower()
569
570
571 # ---------------------------------------------------------------------------
572 # Stress
573 # ---------------------------------------------------------------------------
574
575
576 class TestKeygenHdStress:
577 """HD keygen must be robust under repeated and varied invocations."""
578
579 def test_10_successive_force_keygens_produce_valid_keys(
580 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
581 ) -> None:
582 """Repeated --force --destroy-mnemonic keygen must each produce a distinct key."""
583 _patch_home(monkeypatch, tmp_path)
584 seen_fingerprints: set[str] = set()
585 for _ in range(10):
586 result = runner.invoke(
587 None,
588 ["auth", "keygen", "--hub", "https://localhost:1337",
589 "--force", "--destroy-mnemonic", "--json"],
590 )
591 assert result.exit_code == 0, result.output
592 json_line = result.output.splitlines()[0]
593 payload = json.loads(json_line)
594 fp = payload["fingerprint"]
595 # Each successive keygen with --destroy-mnemonic uses new entropy
596 seen_fingerprints.add(fp)
597 # All 10 keys must be independently valid (distinct fingerprints)
598 assert len(seen_fingerprints) == 10, "Repeated keygen produced duplicate keys"
599
600 def test_all_entropy_strengths_produce_valid_keys(
601 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
602 ) -> None:
603 """All 5 supported strength values (128–256 bits) must succeed."""
604 strengths = [128, 160, 192, 224, 256]
605 expected_word_counts = [12, 15, 18, 21, 24]
606 fake_home = _patch_home(monkeypatch, tmp_path)
607 for strength, n_words in zip(strengths, expected_word_counts):
608 # --destroy-mnemonic ensures fresh entropy for each strength iteration,
609 # rather than reusing the mnemonic stored by the previous iteration.
610 result = runner.invoke(
611 None,
612 ["auth", "keygen", "--hub", "https://localhost:1337",
613 "--strength", str(strength), "--force", "--destroy-mnemonic", "--json"],
614 )
615 assert result.exit_code == 0, f"strength={strength}: {result.output}"
616 json_line = result.output.splitlines()[0]
617 payload = json.loads(json_line)
618 assert payload["mnemonic_word_count"] == n_words, \
619 f"strength={strength}: expected {n_words} words, got {payload['mnemonic_word_count']}"
620 assert "fingerprint" in payload, f"fingerprint missing for strength={strength}"
621
622
623 # ---------------------------------------------------------------------------
624 # Data integrity
625 # ---------------------------------------------------------------------------
626
627
628 class TestKeygenHdDataIntegrity:
629 """Derived keys and stored mnemonics must be byte-for-byte stable."""
630
631 def test_keygen_hd_key_derives_correctly(
632 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
633 ) -> None:
634 """Keygen --hd must write a PEM key consistent with the generated mnemonic."""
635 from muse.core import bip39 as bip39_mod
636 fixed_mnemonic = (
637 "abandon abandon abandon abandon abandon abandon "
638 "abandon abandon abandon abandon abandon about"
639 )
640 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
641
642 _kc: dict[str, str] = {}
643 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
644 monkeypatch.setattr("muse.core.keychain.store",
645 lambda m: _kc.__setitem__("mnemonic", m))
646 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
647
648 fake_home = _patch_home(monkeypatch, tmp_path)
649 result = runner.invoke(
650 None,
651 ["auth", "keygen", "--hub", "https://localhost:1337"],
652 )
653 assert result.exit_code == 0
654
655 # Mnemonic must be in keychain, not TOML
656 stored_mnemonic = _kc.get("mnemonic")
657 assert stored_mnemonic == fixed_mnemonic, "Mnemonic not stored in keychain"
658
659 # Fingerprint in JSON output must match re-derivation from the mnemonic
660 result_json = runner.invoke(
661 None,
662 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
663 )
664 assert result_json.exit_code == 0
665 payload = json.loads(result_json.output.splitlines()[0])
666 reported_fp = payload["fingerprint"]
667
668 # Hub-scoped (musehub#221) — keygen derives against localhost:1337.
669 seed = mnemonic_to_seed(stored_mnemonic)
670 dk = derive_identity_key(seed, hub=hub_index("localhost:1337"))
671 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
672 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
673 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
674 dk.zero()
675 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
676 recomputed_fp = public_key_fingerprint(pub_raw)
677
678 assert recomputed_fp == reported_fp, \
679 "Re-derived fingerprint from mnemonic does not match keygen output"
680
681 def test_slip010_child_key_identical_on_repeated_calls(self) -> None:
682 """derive_identity_key with the same seed must produce the same bytes every time."""
683 seed = b"\xab\xcd\xef" * 21 + b"\x00" # 64 bytes
684 hub = hub_index("localhost:1337")
685 dk1 = derive_identity_key(seed, hub=hub)
686 dk2 = derive_identity_key(seed, hub=hub)
687 assert dk1.private_bytes == dk2.private_bytes, \
688 "SLIP-0010 derivation is not deterministic"
689
690 def test_derived_fingerprint_stable_across_invocations(
691 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
692 ) -> None:
693 """Same mnemonic must yield the same fingerprint across two keygen calls."""
694 _patch_home(monkeypatch, tmp_path)
695 fixed = (
696 "abandon abandon abandon abandon abandon abandon "
697 "abandon abandon abandon abandon abandon about"
698 )
699 import muse.core.bip39 as bip39_mod
700 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed)
701 # Isolate keychain so both calls go through generate_mnemonic
702 _kc: dict[str, str] = {}
703 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
704 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
705 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
706
707 result1 = runner.invoke(
708 None,
709 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
710 )
711 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
712
713 result2 = runner.invoke(
714 None,
715 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
716 )
717 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
718
719 assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen"
720
721
722 # ---------------------------------------------------------------------------
723 # Phase 4 — keygen writes no PEM; identity entry has no key_path
724 # ---------------------------------------------------------------------------
725
726
727 _P4_MNEMONIC = (
728 "abandon abandon abandon abandon abandon abandon abandon abandon "
729 "abandon abandon abandon about"
730 )
731 _P4_HUB = "https://localhost:1337"
732
733
734 def _p4_patch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
735 """Patch home + keychain for Phase 4 tests; returns fake_home."""
736 fake_home = _patch_home(monkeypatch, tmp_path)
737 import muse.core.bip39 as bip39_mod
738 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _P4_MNEMONIC)
739 _kc: dict[str, str] = {}
740 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
741 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
742 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
743 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
744 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
745 return fake_home
746
747
748 class TestKeygenPhase4NoPem:
749 """Phase 4: auth keygen must NOT write PEM files and must NOT store key_path."""
750
751 def test_P4_1_no_pem_written_after_keygen(
752 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
753 ) -> None:
754 """P4-1: no *.pem file must exist in ~/.muse/keys/ after keygen."""
755 fake_home = _p4_patch(monkeypatch, tmp_path)
756 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
757 assert result.exit_code == 0, result.output
758 keys_dir = fake_home / ".muse" / "keys"
759 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
760 assert pem_files == [], f"Unexpected PEM files written: {pem_files}"
761
762 def test_P4_2_identity_entry_has_no_key_path(
763 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
764 ) -> None:
765 """P4-2: identity.toml entry must NOT contain key_path after keygen."""
766 import tomllib
767 fake_home = _p4_patch(monkeypatch, tmp_path)
768 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
769 assert result.exit_code == 0, result.output
770 identity_file = fake_home / ".muse" / "identity.toml"
771 assert identity_file.exists(), "identity.toml was not written"
772 parsed = tomllib.loads(identity_file.read_text())
773 hostname = "localhost:1337"
774 assert hostname in parsed, f"No entry for {hostname}"
775 entry = parsed[hostname]
776 assert "key_path" not in entry, f"key_path must not appear in identity entry: {entry}"
777
778 def test_P4_3_identity_entry_has_hd_path(
779 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
780 ) -> None:
781 """P4-3: identity.toml entry must have hd_path after keygen."""
782 import tomllib
783 fake_home = _p4_patch(monkeypatch, tmp_path)
784 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
785 assert result.exit_code == 0, result.output
786 identity_file = fake_home / ".muse" / "identity.toml"
787 parsed = tomllib.loads(identity_file.read_text())
788 entry = parsed["localhost:1337"]
789 assert "hd_path" in entry, f"hd_path missing from identity entry: {entry}"
790 assert entry["hd_path"].startswith("m/"), f"hd_path has wrong format: {entry['hd_path']}"
791
792 def test_P4_4_resolve_signing_identity_works_after_keygen_and_register(
793 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
794 ) -> None:
795 """P4-4: resolve_signing_identity returns a key after keygen + handle set (register step)."""
796 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
797 from muse.core.identity import resolve_signing_identity, load_identity, save_identity
798
799 fake_home = _p4_patch(monkeypatch, tmp_path)
800 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
801 assert result.exit_code == 0, result.output
802
803 # Simulate the handle being set after registration
804 entry = load_identity(_P4_HUB)
805 assert entry is not None
806 entry["handle"] = "gabriel"
807 save_identity(_P4_HUB, entry)
808
809 result2 = resolve_signing_identity(_P4_HUB)
810 assert result2 is not None, "resolve_signing_identity returned None after keygen+register"
811 handle, private_key = result2
812 assert handle == "gabriel"
813 assert isinstance(private_key, Ed25519PrivateKey)
814
815 def test_P4_5_json_output_has_no_key_path(
816 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
817 ) -> None:
818 """P4-5: --json output must not include key_path."""
819 _p4_patch(monkeypatch, tmp_path)
820 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB, "--json"])
821 assert result.exit_code == 0, result.output
822 payload = json.loads(result.output.splitlines()[0])
823 assert "key_path" not in payload, f"key_path must not appear in JSON output: {payload}"