gabriel / muse public
test_cmd_auth_keygen_hd.py python
800 lines 34.1 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 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 base64
58 import hashlib
59 import json
60 import os
61 import pathlib
62 import stat
63 import time
64
65 import pytest
66
67 from tests.cli_test_helper import CliRunner
68 from muse.core import keypair as kp_module
69 from muse.core import identity as id_module
70 from muse.core.identity import IdentityEntry, _dump_identity
71 from muse.core.bip39 import validate_mnemonic, word_count, STRENGTH_PARANOID
72 from muse.core.hdkeys import (
73 derive_identity_key,
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 public_key_fingerprint
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):
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 raw = base64.urlsafe_b64decode(pub_b64 + "==")
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.output.lower() or len(result.output.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)
298 # Default strength=256 → 24-word mnemonic
299 all_text = result.output
300 mnemonic_line = None
301 for line in all_text.splitlines():
302 words = line.strip().split()
303 if len(words) == 24 and all(w.isalpha() for w in words):
304 mnemonic_line = line.strip()
305 break
306 assert mnemonic_line is not None, f"No 24-word line found in output:\n{all_text}"
307 assert validate_mnemonic(mnemonic_line), f"24-word line is not a valid mnemonic: {mnemonic_line!r}"
308
309 def test_strength_256_produces_24_words(
310 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
311 ) -> None:
312 _, result = _keygen_hd(monkeypatch, tmp_path, ["--strength", "256"])
313 assert result.exit_code == 0, result.output
314 all_text = result.output
315 mnemonic_line = None
316 for line in all_text.splitlines():
317 words = line.strip().split()
318 if len(words) == 24 and all(w.isalpha() for w in words):
319 mnemonic_line = line.strip()
320 break
321 assert mnemonic_line is not None, f"No 24-word line found:\n{all_text}"
322 assert validate_mnemonic(mnemonic_line)
323
324 def test_language_spanish(
325 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
326 ) -> None:
327 _, result = _keygen_hd(monkeypatch, tmp_path, ["--language", "spanish"])
328 assert result.exit_code == 0, result.output
329 all_text = result.output
330 mnemonic_line = None
331 for line in all_text.splitlines():
332 words = line.strip().split()
333 if len(words) == 24: # default strength=256 → 24 words
334 mnemonic_line = line.strip()
335 break
336 assert mnemonic_line is not None
337 assert validate_mnemonic(mnemonic_line, language="spanish")
338
339 def test_json_output_no_mnemonic_in_stdout(
340 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
341 ) -> None:
342 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
343 assert result.exit_code == 0, result.output
344 # First line of output is JSON
345 json_line = result.output.splitlines()[0]
346 payload = json.loads(json_line)
347 assert "mnemonic" not in payload, "mnemonic must never appear in JSON stdout"
348
349 def test_json_output_has_hd_path(
350 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
351 ) -> None:
352 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
353 json_line = result.output.splitlines()[0]
354 payload = json.loads(json_line)
355 assert "hd_path" in payload
356 assert str(MUSE_PURPOSE) in payload["hd_path"]
357
358 def test_json_output_has_mnemonic_word_count(
359 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
360 ) -> None:
361 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
362 json_line = result.output.splitlines()[0]
363 payload = json.loads(json_line)
364 assert payload.get("mnemonic_word_count") == 24 # default strength=256
365
366 def test_json_output_standard_fields(
367 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
368 ) -> None:
369 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
370 json_line = result.output.splitlines()[0]
371 payload = json.loads(json_line)
372 for field in ("status", "hub", "hostname", "public_key_b64", "fingerprint"):
373 assert field in payload, f"Missing field: {field}"
374 assert "key_path" not in payload, "key_path must not appear in JSON output"
375
376 def test_force_overwrites_existing(
377 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
378 ) -> None:
379 fake_home = _patch_home(monkeypatch, tmp_path)
380 args_base = ["auth", "keygen", "--hub", "https://localhost:1337"]
381 runner.invoke(None, args_base)
382 result = runner.invoke(None, args_base + ["--force"])
383 assert result.exit_code == 0, result.output
384
385 def test_second_keygen_without_force_rejected(
386 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
387 ) -> None:
388 """Repeated keygen without --force must fail — identity already exists."""
389 _patch_home(monkeypatch, tmp_path)
390 args_base = ["auth", "keygen", "--hub", "https://localhost:1337"]
391 r1 = runner.invoke(None, args_base)
392 assert r1.exit_code == 0, r1.output
393 r2 = runner.invoke(None, args_base)
394 assert r2.exit_code != 0, "Second keygen without --force should fail"
395
396
397 # ---------------------------------------------------------------------------
398 # End-to-end
399 # ---------------------------------------------------------------------------
400
401
402 class TestKeygenHdEndToEnd:
403 """Full derivation round-trip: generate → verify → re-derive."""
404
405 def test_derived_key_reproducible_from_stored_mnemonic(
406 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
407 ) -> None:
408 """Key written to PEM must match manual re-derivation from the mnemonic."""
409 fake_home = _patch_home(monkeypatch, tmp_path)
410 result = runner.invoke(
411 None,
412 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
413 )
414 assert result.exit_code == 0, result.output
415
416 json_line = result.output.splitlines()[0]
417 payload = json.loads(json_line)
418 stored_fingerprint = payload["fingerprint"]
419 stored_pub_b64 = payload["public_key_b64"]
420
421 # Extract mnemonic from stderr (non-JSON lines)
422 mnemonic_line = None
423 for line in result.output.splitlines()[1:]: # skip JSON first line
424 words = line.strip().split()
425 if 12 <= len(words) <= 24 and all(w.isalpha() for w in words):
426 mnemonic_line = line.strip()
427 break
428 assert mnemonic_line is not None, f"No mnemonic line found:\n{result.output}"
429
430 # Re-derive the key from the mnemonic
431 seed = mnemonic_to_seed(mnemonic_line)
432 dk = derive_identity_key(seed)
433 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
434 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
435 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
436 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
437 recomputed_fp = public_key_fingerprint(pub_raw)
438
439 assert recomputed_fp == stored_fingerprint, "Re-derived fingerprint does not match stored"
440
441 def test_mnemonic_derives_and_signs(
442 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
443 ) -> None:
444 """Mnemonic stored in keychain must produce a key that signs and verifies."""
445 fixed_mnemonic = "abandon " * 11 + "about"
446 import muse.core.bip39 as bip39_mod
447 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic.strip())
448 _kc: dict[str, str] = {}
449 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
450 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
451 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
452
453 _patch_home(monkeypatch, tmp_path)
454 runner.invoke(None, ["auth", "keygen", "--hub", "https://localhost:1337"])
455
456 mnemonic = _kc.get("mnemonic")
457 assert mnemonic is not None, "mnemonic not stored in keychain"
458 seed = mnemonic_to_seed(mnemonic)
459 dk = derive_identity_key(seed)
460 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
461 key = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
462 dk.zero()
463 sig = key.sign(b"muse test message")
464 key.public_key().verify(sig, b"muse test message")
465
466
467 # ---------------------------------------------------------------------------
468 # Security
469 # ---------------------------------------------------------------------------
470
471
472 class TestKeygenHdSecurity:
473 """Security properties of HD keygen."""
474
475 def test_mnemonic_not_in_json_stdout(
476 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
477 ) -> None:
478 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
479 json_line = result.output.splitlines()[0]
480 payload = json.loads(json_line)
481 assert "mnemonic" not in payload
482
483 def test_unsupported_strength_exits_nonzero(
484 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
485 ) -> None:
486 _patch_home(monkeypatch, tmp_path)
487 result = runner.invoke(
488 None,
489 ["auth", "keygen", "--hub", "https://localhost:1337", "--strength", "64"],
490 )
491 assert result.exit_code != 0
492
493 def test_unsupported_language_exits_nonzero(
494 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
495 ) -> None:
496 _patch_home(monkeypatch, tmp_path)
497 result = runner.invoke(
498 None,
499 ["auth", "keygen", "--hub", "https://localhost:1337", "--language", "klingon"],
500 )
501 assert result.exit_code != 0
502
503 def test_no_pem_on_disk_after_keygen(
504 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
505 ) -> None:
506 """Keygen must not write any PEM private key to disk."""
507 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
508 assert result.exit_code == 0
509 keys_dir = fake_home / ".muse" / "keys"
510 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
511 assert pem_files == [], f"PEM files found on disk: {pem_files}"
512
513
514 # ---------------------------------------------------------------------------
515 # Performance
516 # ---------------------------------------------------------------------------
517
518
519 class TestKeygenHdPerformance:
520 """HD keygen must complete quickly enough for interactive use."""
521
522 def test_keygen_hd_under_2s(
523 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
524 ) -> None:
525 _patch_home(monkeypatch, tmp_path)
526 start = time.monotonic()
527 result = runner.invoke(
528 None,
529 ["auth", "keygen", "--hub", "https://localhost:1337"],
530 )
531 elapsed = time.monotonic() - start
532 assert result.exit_code == 0, result.output
533 assert elapsed < 2.0, f"keygen --hd took {elapsed:.2f}s — too slow"
534
535
536 # ---------------------------------------------------------------------------
537 # Docstrings
538 # ---------------------------------------------------------------------------
539
540
541 class TestDocstrings:
542 def test_derive_hd_public_info_has_docstring(self) -> None:
543 from muse.core.keypair import derive_hd_public_info
544 assert derive_hd_public_info.__doc__, "derive_hd_public_info is missing a docstring"
545
546 def test_run_keygen_mentions_hd(self) -> None:
547 from muse.cli.commands.auth import run_keygen
548 doc = run_keygen.__doc__ or ""
549 assert "HD" in doc or "BIP39" in doc or "mnemonic" in doc.lower()
550
551
552 # ---------------------------------------------------------------------------
553 # Stress
554 # ---------------------------------------------------------------------------
555
556
557 class TestKeygenHdStress:
558 """HD keygen must be robust under repeated and varied invocations."""
559
560 def test_10_successive_force_keygens_produce_valid_keys(
561 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
562 ) -> None:
563 """Repeated --force keygen must each produce a valid, loadable PEM."""
564 _patch_home(monkeypatch, tmp_path)
565 seen_fingerprints: set[str] = set()
566 for _ in range(10):
567 result = runner.invoke(
568 None,
569 ["auth", "keygen", "--hub", "https://localhost:1337",
570 "--force", "--json"],
571 )
572 assert result.exit_code == 0, result.output
573 json_line = result.output.splitlines()[0]
574 payload = json.loads(json_line)
575 fp = payload["fingerprint"]
576 # Each successive keygen without fixing the mnemonic uses new entropy
577 seen_fingerprints.add(fp)
578 # All 10 keys must be independently valid (distinct fingerprints)
579 assert len(seen_fingerprints) == 10, "Repeated keygen produced duplicate keys"
580
581 def test_all_entropy_strengths_produce_valid_keys(
582 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
583 ) -> None:
584 """All 5 supported strength values (128–256 bits) must succeed."""
585 strengths = [128, 160, 192, 224, 256]
586 expected_word_counts = [12, 15, 18, 21, 24]
587 fake_home = _patch_home(monkeypatch, tmp_path)
588 for strength, n_words in zip(strengths, expected_word_counts):
589 result = runner.invoke(
590 None,
591 ["auth", "keygen", "--hub", "https://localhost:1337",
592 "--strength", str(strength), "--force", "--json"],
593 )
594 assert result.exit_code == 0, f"strength={strength}: {result.output}"
595 json_line = result.output.splitlines()[0]
596 payload = json.loads(json_line)
597 assert payload["mnemonic_word_count"] == n_words, \
598 f"strength={strength}: expected {n_words} words, got {payload['mnemonic_word_count']}"
599 assert "fingerprint" in payload, f"fingerprint missing for strength={strength}"
600
601
602 # ---------------------------------------------------------------------------
603 # Data integrity
604 # ---------------------------------------------------------------------------
605
606
607 class TestKeygenHdDataIntegrity:
608 """Derived keys and stored mnemonics must be byte-for-byte stable."""
609
610 def test_keygen_hd_key_derives_correctly(
611 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
612 ) -> None:
613 """Keygen --hd must write a PEM key consistent with the generated mnemonic."""
614 from muse.core import bip39 as bip39_mod
615 fixed_mnemonic = (
616 "abandon abandon abandon abandon abandon abandon "
617 "abandon abandon abandon abandon abandon about"
618 )
619 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
620
621 _kc: dict[str, str] = {}
622 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
623 monkeypatch.setattr("muse.core.keychain.store",
624 lambda m: _kc.__setitem__("mnemonic", m))
625 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
626
627 fake_home = _patch_home(monkeypatch, tmp_path)
628 result = runner.invoke(
629 None,
630 ["auth", "keygen", "--hub", "https://localhost:1337"],
631 )
632 assert result.exit_code == 0
633
634 # Mnemonic must be in keychain, not TOML
635 stored_mnemonic = _kc.get("mnemonic")
636 assert stored_mnemonic == fixed_mnemonic, "Mnemonic not stored in keychain"
637
638 # Fingerprint in JSON output must match re-derivation from the mnemonic
639 result_json = runner.invoke(
640 None,
641 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
642 )
643 assert result_json.exit_code == 0
644 payload = json.loads(result_json.output.splitlines()[0])
645 reported_fp = payload["fingerprint"]
646
647 seed = mnemonic_to_seed(stored_mnemonic)
648 dk = derive_identity_key(seed)
649 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
650 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
651 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
652 dk.zero()
653 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
654 recomputed_fp = public_key_fingerprint(pub_raw)
655
656 assert recomputed_fp == reported_fp, \
657 "Re-derived fingerprint from mnemonic does not match keygen output"
658
659 def test_slip010_child_key_identical_on_repeated_calls(self) -> None:
660 """derive_identity_key with the same seed must produce the same bytes every time."""
661 seed = b"\xab\xcd\xef" * 21 + b"\x00" # 64 bytes
662 dk1 = derive_identity_key(seed)
663 dk2 = derive_identity_key(seed)
664 assert dk1.private_bytes == dk2.private_bytes, \
665 "SLIP-0010 derivation is not deterministic"
666
667 def test_derived_fingerprint_stable_across_invocations(
668 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
669 ) -> None:
670 """Same mnemonic must yield the same fingerprint across two keygen calls."""
671 _patch_home(monkeypatch, tmp_path)
672 fixed = (
673 "abandon abandon abandon abandon abandon abandon "
674 "abandon abandon abandon abandon abandon about"
675 )
676 import muse.core.bip39 as bip39_mod
677 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed)
678 # Isolate keychain so both calls go through generate_mnemonic
679 _kc: dict[str, str] = {}
680 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
681 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
682 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
683
684 result1 = runner.invoke(
685 None,
686 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
687 )
688 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
689
690 result2 = runner.invoke(
691 None,
692 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
693 )
694 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
695
696 assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen"
697
698
699 # ---------------------------------------------------------------------------
700 # Phase 4 — keygen writes no PEM; identity entry has no key_path
701 # ---------------------------------------------------------------------------
702
703
704 _P4_MNEMONIC = (
705 "abandon abandon abandon abandon abandon abandon abandon abandon "
706 "abandon abandon abandon about"
707 )
708 _P4_HUB = "https://localhost:1337"
709
710
711 def _p4_patch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
712 """Patch home + keychain for Phase 4 tests; returns fake_home."""
713 fake_home = _patch_home(monkeypatch, tmp_path)
714 import muse.core.bip39 as bip39_mod
715 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _P4_MNEMONIC)
716 _kc: dict[str, str] = {}
717 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
718 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
719 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
720 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
721 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
722 return fake_home
723
724
725 class TestKeygenPhase4NoPem:
726 """Phase 4: auth keygen must NOT write PEM files and must NOT store key_path."""
727
728 def test_P4_1_no_pem_written_after_keygen(
729 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
730 ) -> None:
731 """P4-1: no *.pem file must exist in ~/.muse/keys/ after keygen."""
732 fake_home = _p4_patch(monkeypatch, tmp_path)
733 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
734 assert result.exit_code == 0, result.output
735 keys_dir = fake_home / ".muse" / "keys"
736 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
737 assert pem_files == [], f"Unexpected PEM files written: {pem_files}"
738
739 def test_P4_2_identity_entry_has_no_key_path(
740 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
741 ) -> None:
742 """P4-2: identity.toml entry must NOT contain key_path after keygen."""
743 import tomllib
744 fake_home = _p4_patch(monkeypatch, tmp_path)
745 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
746 assert result.exit_code == 0, result.output
747 identity_file = fake_home / ".muse" / "identity.toml"
748 assert identity_file.exists(), "identity.toml was not written"
749 parsed = tomllib.loads(identity_file.read_text())
750 hostname = "localhost:1337"
751 assert hostname in parsed, f"No entry for {hostname}"
752 entry = parsed[hostname]
753 assert "key_path" not in entry, f"key_path must not appear in identity entry: {entry}"
754
755 def test_P4_3_identity_entry_has_hd_path(
756 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
757 ) -> None:
758 """P4-3: identity.toml entry must have hd_path after keygen."""
759 import tomllib
760 fake_home = _p4_patch(monkeypatch, tmp_path)
761 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
762 assert result.exit_code == 0, result.output
763 identity_file = fake_home / ".muse" / "identity.toml"
764 parsed = tomllib.loads(identity_file.read_text())
765 entry = parsed["localhost:1337"]
766 assert "hd_path" in entry, f"hd_path missing from identity entry: {entry}"
767 assert entry["hd_path"].startswith("m/"), f"hd_path has wrong format: {entry['hd_path']}"
768
769 def test_P4_4_resolve_signing_identity_works_after_keygen_and_register(
770 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
771 ) -> None:
772 """P4-4: resolve_signing_identity returns a key after keygen + handle set (register step)."""
773 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
774 from muse.core.identity import resolve_signing_identity, load_identity, save_identity
775
776 fake_home = _p4_patch(monkeypatch, tmp_path)
777 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
778 assert result.exit_code == 0, result.output
779
780 # Simulate the handle being set after registration
781 entry = load_identity(_P4_HUB)
782 assert entry is not None
783 entry["handle"] = "gabriel"
784 save_identity(_P4_HUB, entry)
785
786 result2 = resolve_signing_identity(_P4_HUB)
787 assert result2 is not None, "resolve_signing_identity returned None after keygen+register"
788 handle, private_key = result2
789 assert handle == "gabriel"
790 assert isinstance(private_key, Ed25519PrivateKey)
791
792 def test_P4_5_json_output_has_no_key_path(
793 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
794 ) -> None:
795 """P4-5: --json output must not include key_path."""
796 _p4_patch(monkeypatch, tmp_path)
797 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB, "--json"])
798 assert result.exit_code == 0, result.output
799 payload = json.loads(result.output.splitlines()[0])
800 assert "key_path" not in payload, f"key_path must not appear in JSON output: {payload}"
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 132 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 141 days ago