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