gabriel / muse public
test_cmd_auth_keygen_hd.py python
822 lines 35.9 KB
Raw
sha256:c08228badabf977083c0a945db5b8d1fd5292edab0d0726ebf6c349d1692d528 fix: hub-scope the identity key HD derivation path (musehub#221) Sonnet 5 minor ⚠ breaking 5 days ago
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
219 dk = derive_identity_key(seed)
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)
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 dk1 = derive_identity_key(seed)
685 dk2 = derive_identity_key(seed)
686 assert dk1.private_bytes == dk2.private_bytes, \
687 "SLIP-0010 derivation is not deterministic"
688
689 def test_derived_fingerprint_stable_across_invocations(
690 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
691 ) -> None:
692 """Same mnemonic must yield the same fingerprint across two keygen calls."""
693 _patch_home(monkeypatch, tmp_path)
694 fixed = (
695 "abandon abandon abandon abandon abandon abandon "
696 "abandon abandon abandon abandon abandon about"
697 )
698 import muse.core.bip39 as bip39_mod
699 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed)
700 # Isolate keychain so both calls go through generate_mnemonic
701 _kc: dict[str, str] = {}
702 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
703 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
704 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
705
706 result1 = runner.invoke(
707 None,
708 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
709 )
710 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
711
712 result2 = runner.invoke(
713 None,
714 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
715 )
716 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
717
718 assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen"
719
720
721 # ---------------------------------------------------------------------------
722 # Phase 4 — keygen writes no PEM; identity entry has no key_path
723 # ---------------------------------------------------------------------------
724
725
726 _P4_MNEMONIC = (
727 "abandon abandon abandon abandon abandon abandon abandon abandon "
728 "abandon abandon abandon about"
729 )
730 _P4_HUB = "https://localhost:1337"
731
732
733 def _p4_patch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
734 """Patch home + keychain for Phase 4 tests; returns fake_home."""
735 fake_home = _patch_home(monkeypatch, tmp_path)
736 import muse.core.bip39 as bip39_mod
737 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _P4_MNEMONIC)
738 _kc: dict[str, str] = {}
739 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
740 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
741 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
742 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
743 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
744 return fake_home
745
746
747 class TestKeygenPhase4NoPem:
748 """Phase 4: auth keygen must NOT write PEM files and must NOT store key_path."""
749
750 def test_P4_1_no_pem_written_after_keygen(
751 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
752 ) -> None:
753 """P4-1: no *.pem file must exist in ~/.muse/keys/ after keygen."""
754 fake_home = _p4_patch(monkeypatch, tmp_path)
755 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
756 assert result.exit_code == 0, result.output
757 keys_dir = fake_home / ".muse" / "keys"
758 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
759 assert pem_files == [], f"Unexpected PEM files written: {pem_files}"
760
761 def test_P4_2_identity_entry_has_no_key_path(
762 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
763 ) -> None:
764 """P4-2: identity.toml entry must NOT contain key_path after keygen."""
765 import tomllib
766 fake_home = _p4_patch(monkeypatch, tmp_path)
767 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
768 assert result.exit_code == 0, result.output
769 identity_file = fake_home / ".muse" / "identity.toml"
770 assert identity_file.exists(), "identity.toml was not written"
771 parsed = tomllib.loads(identity_file.read_text())
772 hostname = "localhost:1337"
773 assert hostname in parsed, f"No entry for {hostname}"
774 entry = parsed[hostname]
775 assert "key_path" not in entry, f"key_path must not appear in identity entry: {entry}"
776
777 def test_P4_3_identity_entry_has_hd_path(
778 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
779 ) -> None:
780 """P4-3: identity.toml entry must have hd_path after keygen."""
781 import tomllib
782 fake_home = _p4_patch(monkeypatch, tmp_path)
783 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
784 assert result.exit_code == 0, result.output
785 identity_file = fake_home / ".muse" / "identity.toml"
786 parsed = tomllib.loads(identity_file.read_text())
787 entry = parsed["localhost:1337"]
788 assert "hd_path" in entry, f"hd_path missing from identity entry: {entry}"
789 assert entry["hd_path"].startswith("m/"), f"hd_path has wrong format: {entry['hd_path']}"
790
791 def test_P4_4_resolve_signing_identity_works_after_keygen_and_register(
792 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
793 ) -> None:
794 """P4-4: resolve_signing_identity returns a key after keygen + handle set (register step)."""
795 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
796 from muse.core.identity import resolve_signing_identity, load_identity, save_identity
797
798 fake_home = _p4_patch(monkeypatch, tmp_path)
799 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
800 assert result.exit_code == 0, result.output
801
802 # Simulate the handle being set after registration
803 entry = load_identity(_P4_HUB)
804 assert entry is not None
805 entry["handle"] = "gabriel"
806 save_identity(_P4_HUB, entry)
807
808 result2 = resolve_signing_identity(_P4_HUB)
809 assert result2 is not None, "resolve_signing_identity returned None after keygen+register"
810 handle, private_key = result2
811 assert handle == "gabriel"
812 assert isinstance(private_key, Ed25519PrivateKey)
813
814 def test_P4_5_json_output_has_no_key_path(
815 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
816 ) -> None:
817 """P4-5: --json output must not include key_path."""
818 _p4_patch(monkeypatch, tmp_path)
819 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB, "--json"])
820 assert result.exit_code == 0, result.output
821 payload = json.loads(result.output.splitlines()[0])
822 assert "key_path" not in payload, f"key_path must not appear in JSON output: {payload}"
File History 1 commit
sha256:c08228badabf977083c0a945db5b8d1fd5292edab0d0726ebf6c349d1692d528 fix: hub-scope the identity key HD derivation path (musehub#221) Sonnet 5 minor 5 days ago