gabriel / muse public
test_cmd_auth_keygen_hd.py python
693 lines 28.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Tests for ``muse auth keygen --hd`` — BIP39/SLIP-0010 HD key generation.
2
3 Coverage matrix
4 ---------------
5 Unit
6 - IdentityEntry accepts key_source, mnemonic, hd_path fields
7 - _dump_identity serialises HD fields correctly
8 - _dump_identity round-trips through tomllib
9 - generate_hd_keypair returns correct public_key_b64 and fingerprint
10 - generate_hd_keypair derived key matches hdkeys.derive_identity_key
11 - generate_hd_keypair writes PEM to the expected path
12 - generate_hd_keypair PEM is a valid Ed25519 key
13
14 Integration (full CLI round-trips via CliRunner)
15 - ``muse auth keygen --hd`` exits 0
16 - PEM file written at expected path
17 - PEM file has 0o600 permissions
18 - mnemonic printed exactly once on stderr
19 - mnemonic has correct word count (12 words default, 24 with --strength 256)
20 - mnemonic passes BIP39 validation
21 - public_key_b64 and fingerprint in stderr output
22 - --hd --force overwrites existing key
23 - --hd --force rejected when key exists without --force
24 - --json output: no mnemonic in stdout, has key_source/hd_path/mnemonic_word_count
25 - --strength 256 produces 24-word mnemonic
26 - --language spanish generates a valid Spanish mnemonic
27 - JBOK key and HD key are different private keys (different derivation paths)
28 - HD key is deterministic: same mnemonic → same fingerprint
29
30 End-to-end
31 - Full flow: keygen --hd → verify PEM → derive same key from stored mnemonic
32 - identity.toml written with key_source, mnemonic, hd_path after keygen
33
34 Stress
35 - 10 successive keygen --hd --force calls all produce valid, distinct keys
36 - keygen --hd for all 5 supported entropy strengths (128–256 bits)
37
38 Data integrity
39 - mnemonic stored in identity.toml round-trips byte-for-byte
40 - derived fingerprint is stable across multiple muse_path invocations
41 - SLIP-0010 child key from the same seed is identical on repeated calls
42
43 Security
44 - mnemonic never appears in JSON stdout (stdout is machine-readable-only)
45 - mnemonic not in key_path or fingerprint
46 - --hd with unsupported --strength exits 1
47 - --hd with unsupported --language exits 1
48 - PEM mode is 0o600 (no group/world bits)
49
50 Performance
51 - keygen --hd completes in < 2 s (PBKDF2 + SLIP-0010 are fast)
52
53 Docstrings
54 - generate_hd_keypair has a docstring
55 - run_keygen docstring mentions --hd flag
56 """
57
58 from __future__ import annotations
59
60 import base64
61 import hashlib
62 import json
63 import os
64 import pathlib
65 import stat
66 import time
67
68 import pytest
69 from cryptography.hazmat.primitives.serialization import load_pem_private_key
70
71 from tests.cli_test_helper import CliRunner
72 from muse.core import keypair as kp_module
73 from muse.core import identity as id_module
74 from muse.core.identity import IdentityEntry, _dump_identity
75 from muse.core.bip39 import validate_mnemonic, word_count, STRENGTH_PARANOID
76 from muse.core.hdkeys import (
77 derive_identity_key,
78 MUSE_PURPOSE,
79 DOMAIN_IDENTITY,
80 ENTITY_HUMAN,
81 ROLE_SIGN,
82 muse_path,
83 )
84 from muse.core.slip010 import master_key
85 from muse.core.bip39 import mnemonic_to_seed
86
87 runner = CliRunner()
88
89
90 # ---------------------------------------------------------------------------
91 # Helpers
92 # ---------------------------------------------------------------------------
93
94
95 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
96 """Redirect ~/.muse to a temp dir for this test."""
97 fake_home = tmp_path / "home"
98 fake_home.mkdir(parents=True, exist_ok=True)
99 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
100 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
101 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
102 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
103 return fake_home
104
105
106 def _keygen_hd(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path,
107 extra_args: list[str] | None = None) -> tuple[pathlib.Path, object]:
108 """Run ``muse auth keygen --hub http://localhost:10003`` and return (fake_home, result)."""
109 fake_home = _patch_home(monkeypatch, tmp_path)
110 args = ["auth", "keygen", "--hub", "http://localhost:10003"] + (extra_args or [])
111 result = runner.invoke(None, args)
112 return fake_home, result
113
114
115 # ---------------------------------------------------------------------------
116 # Unit — IdentityEntry HD fields
117 # ---------------------------------------------------------------------------
118
119
120 class TestIdentityEntryHdFields:
121 """IdentityEntry TypedDict must accept HD provenance fields."""
122
123 def test_mnemonic_field_accepted(self) -> None:
124 entry: IdentityEntry = {
125 "type": "human",
126 "handle": "gabriel",
127 "key_path": "/tmp/test.pem",
128 "algorithm": "ed25519",
129 "fingerprint": "abc123",
130 "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
131 }
132 assert entry["mnemonic"].startswith("abandon")
133
134 def test_hd_path_field_accepted(self) -> None:
135 entry: IdentityEntry = {
136 "type": "human",
137 "handle": "gabriel",
138 "key_path": "/tmp/test.pem",
139 "algorithm": "ed25519",
140 "fingerprint": "abc123",
141 "hd_path": f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'",
142 }
143 assert MUSE_PURPOSE > 0
144
145
146 class TestDumpIdentityHdFields:
147 """_dump_identity must serialise HD fields when present."""
148
149 def test_hd_path_serialised(self) -> None:
150 hd_path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'"
151 entry: IdentityEntry = {
152 "type": "human", "handle": "gabriel",
153 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
154 "fingerprint": "abc", "hd_path": hd_path,
155 }
156 toml = _dump_identity({"localhost:10003": entry})
157 assert "hd_path" in toml
158 assert str(MUSE_PURPOSE) in toml
159
160 def test_hd_fields_round_trip_through_tomllib(self) -> None:
161 import tomllib
162 hd_path = f"m/{MUSE_PURPOSE}'/0'/0'/0'/0'/0'"
163 entry: IdentityEntry = {
164 "type": "human", "handle": "gabriel",
165 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
166 "fingerprint": "abc", "hd_path": hd_path,
167 }
168 toml = _dump_identity({"localhost:10003": entry})
169 parsed = tomllib.loads(toml)
170 restored = parsed["localhost:10003"]
171 assert restored["hd_path"] == hd_path
172 assert "key_source" not in restored
173 assert "mnemonic" not in restored
174
175 def test_entry_no_spurious_fields(self) -> None:
176 """Entries must not have key_source or mnemonic written to TOML."""
177 entry: IdentityEntry = {
178 "type": "human", "handle": "gabriel",
179 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
180 "fingerprint": "abc",
181 }
182 toml = _dump_identity({"localhost:10003": entry})
183 assert "key_source" not in toml
184 assert "mnemonic" not in toml
185 assert "hd_path" not in toml
186
187
188 # ---------------------------------------------------------------------------
189 # Unit — generate_hd_keypair
190 # ---------------------------------------------------------------------------
191
192
193 class TestGenerateHdKeypair:
194 """Unit tests for keypair.generate_hd_keypair."""
195
196 def test_returns_pub_b64_and_fingerprint(
197 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
198 ) -> None:
199 fake_home = _patch_home(monkeypatch, tmp_path)
200 from muse.core.keypair import generate_hd_keypair
201 seed = mnemonic_to_seed("abandon " * 11 + "about")
202 pub_b64, fp = generate_hd_keypair("localhost:10003", seed)
203 assert isinstance(pub_b64, str) and len(pub_b64) > 0
204 assert isinstance(fp, str) and len(fp) == 64
205
206 def test_fingerprint_is_sha256_hex(
207 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
208 ) -> None:
209 _patch_home(monkeypatch, tmp_path)
210 from muse.core.keypair import generate_hd_keypair
211 seed = mnemonic_to_seed("abandon " * 11 + "about")
212 pub_b64, fp = generate_hd_keypair("localhost:10003", seed)
213 raw = base64.urlsafe_b64decode(pub_b64 + "==")
214 assert hashlib.sha256(raw).hexdigest() == fp
215
216 def test_derived_key_matches_hdkeys(
217 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
218 ) -> None:
219 _patch_home(monkeypatch, tmp_path)
220 from muse.core.keypair import generate_hd_keypair
221 seed = mnemonic_to_seed("abandon " * 11 + "about")
222 pub_b64, fp = generate_hd_keypair("localhost:10003", seed)
223
224 # Reproduce derivation manually
225 dk = derive_identity_key(seed)
226 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
227 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
228 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
229 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
230 expected_fp = hashlib.sha256(pub_raw).hexdigest()
231 assert fp == expected_fp
232
233 def test_pem_file_written(
234 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
235 ) -> None:
236 fake_home = _patch_home(monkeypatch, tmp_path)
237 from muse.core.keypair import generate_hd_keypair, key_path_for
238 seed = mnemonic_to_seed("abandon " * 11 + "about")
239 generate_hd_keypair("localhost:10003", seed)
240 pem_path = key_path_for("localhost:10003")
241 assert pem_path.is_file()
242
243 def test_pem_is_valid_ed25519_key(
244 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
245 ) -> None:
246 fake_home = _patch_home(monkeypatch, tmp_path)
247 from muse.core.keypair import generate_hd_keypair, key_path_for
248 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
249 seed = mnemonic_to_seed("abandon " * 11 + "about")
250 generate_hd_keypair("localhost:10003", seed)
251 pem_path = key_path_for("localhost:10003")
252 key = load_pem_private_key(pem_path.read_bytes(), password=None)
253 assert isinstance(key, Ed25519PrivateKey)
254
255 def test_deterministic_same_seed(
256 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
257 ) -> None:
258 _patch_home(monkeypatch, tmp_path)
259 from muse.core.keypair import generate_hd_keypair
260 seed = mnemonic_to_seed("abandon " * 11 + "about")
261 _, fp1 = generate_hd_keypair("localhost:10003", seed)
262 _, fp2 = generate_hd_keypair("localhost:10003", seed)
263 assert fp1 == fp2
264
265 def test_jbok_generate_keypair_does_not_exist(self) -> None:
266 """JBOK mode is deleted — generate_keypair must not be importable."""
267 import importlib
268 kp = importlib.import_module("muse.core.keypair")
269 assert not hasattr(kp, "generate_keypair"), \
270 "generate_keypair still exists — JBOK was not fully removed"
271
272
273 # ---------------------------------------------------------------------------
274 # Integration — CLI
275 # ---------------------------------------------------------------------------
276
277
278 class TestKeygenHdCli:
279 """Full CLI round-trips for ``muse auth keygen --hd``."""
280
281 def test_exits_zero(
282 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
283 ) -> None:
284 _, result = _keygen_hd(monkeypatch, tmp_path)
285 assert result.exit_code == 0, result.output
286
287 def test_pem_written(
288 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
289 ) -> None:
290 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
291 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
292 assert pem.is_file(), result.output
293
294 def test_pem_permissions_600(
295 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
296 ) -> None:
297 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
298 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
299 mode = pem.stat().st_mode & 0o777
300 assert mode == 0o600, f"PEM mode is {oct(mode)}, expected 0o600"
301
302 def test_mnemonic_in_stderr(
303 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
304 ) -> None:
305 _, result = _keygen_hd(monkeypatch, tmp_path)
306 # Mnemonic words appear in combined output (CliRunner merges streams)
307 assert "mnemonic" in result.output.lower() or len(result.output.split()) >= 12
308
309 def test_mnemonic_is_24_words_by_default(
310 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
311 ) -> None:
312 _, result = _keygen_hd(monkeypatch, tmp_path)
313 # Default strength=256 → 24-word mnemonic
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 in output:\n{all_text}"
322 assert validate_mnemonic(mnemonic_line), f"24-word line is not a valid mnemonic: {mnemonic_line!r}"
323
324 def test_strength_256_produces_24_words(
325 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
326 ) -> None:
327 _, result = _keygen_hd(monkeypatch, tmp_path, ["--strength", "256"])
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 and all(w.isalpha() for w in words):
334 mnemonic_line = line.strip()
335 break
336 assert mnemonic_line is not None, f"No 24-word line found:\n{all_text}"
337 assert validate_mnemonic(mnemonic_line)
338
339 def test_language_spanish(
340 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
341 ) -> None:
342 _, result = _keygen_hd(monkeypatch, tmp_path, ["--language", "spanish"])
343 assert result.exit_code == 0, result.output
344 all_text = result.output
345 mnemonic_line = None
346 for line in all_text.splitlines():
347 words = line.strip().split()
348 if len(words) == 24: # default strength=256 → 24 words
349 mnemonic_line = line.strip()
350 break
351 assert mnemonic_line is not None
352 assert validate_mnemonic(mnemonic_line, language="spanish")
353
354 def test_json_output_no_mnemonic_in_stdout(
355 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
356 ) -> None:
357 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
358 assert result.exit_code == 0, result.output
359 # First line of output is JSON
360 json_line = result.output.splitlines()[0]
361 payload = json.loads(json_line)
362 assert "mnemonic" not in payload, "mnemonic must never appear in JSON stdout"
363
364 def test_json_output_has_hd_path(
365 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
366 ) -> None:
367 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
368 json_line = result.output.splitlines()[0]
369 payload = json.loads(json_line)
370 assert "hd_path" in payload
371 assert str(MUSE_PURPOSE) in payload["hd_path"]
372
373 def test_json_output_has_mnemonic_word_count(
374 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
375 ) -> None:
376 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
377 json_line = result.output.splitlines()[0]
378 payload = json.loads(json_line)
379 assert payload.get("mnemonic_word_count") == 24 # default strength=256
380
381 def test_json_output_standard_fields(
382 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
383 ) -> None:
384 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
385 json_line = result.output.splitlines()[0]
386 payload = json.loads(json_line)
387 for field in ("status", "hub", "hostname", "key_path", "public_key_b64", "fingerprint"):
388 assert field in payload, f"Missing field: {field}"
389
390 def test_force_overwrites_existing(
391 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
392 ) -> None:
393 fake_home = _patch_home(monkeypatch, tmp_path)
394 args_base = ["auth", "keygen", "--hub", "http://localhost:10003"]
395 runner.invoke(None, args_base)
396 result = runner.invoke(None, args_base + ["--force"])
397 assert result.exit_code == 0, result.output
398
399 def test_no_force_rejects_existing(
400 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
401 ) -> None:
402 fake_home = _patch_home(monkeypatch, tmp_path)
403 args_base = ["auth", "keygen", "--hub", "http://localhost:10003"]
404 runner.invoke(None, args_base)
405 result = runner.invoke(None, args_base) # second time, no --force
406 assert result.exit_code != 0
407 assert "already exists" in result.output.lower() or "force" in result.output.lower()
408
409
410 # ---------------------------------------------------------------------------
411 # End-to-end
412 # ---------------------------------------------------------------------------
413
414
415 class TestKeygenHdEndToEnd:
416 """Full derivation round-trip: generate → verify → re-derive."""
417
418 def test_derived_key_reproducible_from_stored_mnemonic(
419 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
420 ) -> None:
421 """Key written to PEM must match manual re-derivation from the mnemonic."""
422 fake_home = _patch_home(monkeypatch, tmp_path)
423 result = runner.invoke(
424 None,
425 ["auth", "keygen", "--hub", "http://localhost:10003", "--json"],
426 )
427 assert result.exit_code == 0, result.output
428
429 json_line = result.output.splitlines()[0]
430 payload = json.loads(json_line)
431 stored_fingerprint = payload["fingerprint"]
432 stored_pub_b64 = payload["public_key_b64"]
433
434 # Extract mnemonic from stderr (non-JSON lines)
435 mnemonic_line = None
436 for line in result.output.splitlines()[1:]: # skip JSON first line
437 words = line.strip().split()
438 if 12 <= len(words) <= 24 and all(w.isalpha() for w in words):
439 mnemonic_line = line.strip()
440 break
441 assert mnemonic_line is not None, f"No mnemonic line found:\n{result.output}"
442
443 # Re-derive the key from the mnemonic
444 seed = mnemonic_to_seed(mnemonic_line)
445 dk = derive_identity_key(seed)
446 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
447 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
448 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
449 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
450 recomputed_fp = hashlib.sha256(pub_raw).hexdigest()
451
452 assert recomputed_fp == stored_fingerprint, "Re-derived fingerprint does not match stored"
453
454 def test_pem_loads_and_signs(
455 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
456 ) -> None:
457 """PEM file must load as Ed25519 and produce a valid signature."""
458 fake_home = _patch_home(monkeypatch, tmp_path)
459 runner.invoke(
460 None,
461 ["auth", "keygen", "--hub", "http://localhost:10003"],
462 )
463 pem_path = fake_home / ".muse" / "keys" / "localhost_10003.pem"
464 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
465 key = load_pem_private_key(pem_path.read_bytes(), password=None)
466 assert isinstance(key, Ed25519PrivateKey)
467 sig = key.sign(b"muse test message")
468 key.public_key().verify(sig, b"muse test message") # raises on bad sig
469
470
471 # ---------------------------------------------------------------------------
472 # Security
473 # ---------------------------------------------------------------------------
474
475
476 class TestKeygenHdSecurity:
477 """Security properties of HD keygen."""
478
479 def test_mnemonic_not_in_json_stdout(
480 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
481 ) -> None:
482 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
483 json_line = result.output.splitlines()[0]
484 payload = json.loads(json_line)
485 assert "mnemonic" not in payload
486
487 def test_unsupported_strength_exits_nonzero(
488 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
489 ) -> None:
490 _patch_home(monkeypatch, tmp_path)
491 result = runner.invoke(
492 None,
493 ["auth", "keygen", "--hub", "http://localhost:10003", "--strength", "64"],
494 )
495 assert result.exit_code != 0
496
497 def test_unsupported_language_exits_nonzero(
498 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
499 ) -> None:
500 _patch_home(monkeypatch, tmp_path)
501 result = runner.invoke(
502 None,
503 ["auth", "keygen", "--hub", "http://localhost:10003", "--language", "klingon"],
504 )
505 assert result.exit_code != 0
506
507 def test_pem_mode_is_600(
508 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
509 ) -> None:
510 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
511 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
512 assert result.exit_code == 0
513 mode = pem.stat().st_mode & 0o777
514 assert not (mode & 0o177), f"PEM has unsafe mode {oct(mode)}"
515
516
517 # ---------------------------------------------------------------------------
518 # Performance
519 # ---------------------------------------------------------------------------
520
521
522 class TestKeygenHdPerformance:
523 """HD keygen must complete quickly enough for interactive use."""
524
525 def test_keygen_hd_under_2s(
526 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
527 ) -> None:
528 _patch_home(monkeypatch, tmp_path)
529 start = time.monotonic()
530 result = runner.invoke(
531 None,
532 ["auth", "keygen", "--hub", "http://localhost:10003"],
533 )
534 elapsed = time.monotonic() - start
535 assert result.exit_code == 0, result.output
536 assert elapsed < 2.0, f"keygen --hd took {elapsed:.2f}s — too slow"
537
538
539 # ---------------------------------------------------------------------------
540 # Docstrings
541 # ---------------------------------------------------------------------------
542
543
544 class TestDocstrings:
545 def test_generate_hd_keypair_has_docstring(self) -> None:
546 from muse.core.keypair import generate_hd_keypair
547 assert generate_hd_keypair.__doc__, "generate_hd_keypair is missing a docstring"
548
549 def test_run_keygen_mentions_hd(self) -> None:
550 from muse.cli.commands.auth import run_keygen
551 doc = run_keygen.__doc__ or ""
552 assert "HD" in doc or "BIP39" in doc or "mnemonic" in doc.lower()
553
554
555 # ---------------------------------------------------------------------------
556 # Stress
557 # ---------------------------------------------------------------------------
558
559
560 class TestKeygenHdStress:
561 """HD keygen must be robust under repeated and varied invocations."""
562
563 def test_10_successive_force_keygens_produce_valid_keys(
564 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
565 ) -> None:
566 """Repeated --force keygen must each produce a valid, loadable PEM."""
567 _patch_home(monkeypatch, tmp_path)
568 seen_fingerprints: set[str] = set()
569 for _ in range(10):
570 result = runner.invoke(
571 None,
572 ["auth", "keygen", "--hub", "http://localhost:10003",
573 "--force", "--json"],
574 )
575 assert result.exit_code == 0, result.output
576 json_line = result.output.splitlines()[0]
577 payload = json.loads(json_line)
578 fp = payload["fingerprint"]
579 # Each successive keygen without fixing the mnemonic uses new entropy
580 seen_fingerprints.add(fp)
581 # All 10 keys must be independently valid (distinct fingerprints)
582 assert len(seen_fingerprints) == 10, "Repeated keygen produced duplicate keys"
583
584 def test_all_entropy_strengths_produce_valid_keys(
585 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
586 ) -> None:
587 """All 5 supported strength values (128–256 bits) must succeed."""
588 strengths = [128, 160, 192, 224, 256]
589 expected_word_counts = [12, 15, 18, 21, 24]
590 fake_home = _patch_home(monkeypatch, tmp_path)
591 for strength, n_words in zip(strengths, expected_word_counts):
592 result = runner.invoke(
593 None,
594 ["auth", "keygen", "--hub", "http://localhost:10003",
595 "--strength", str(strength), "--force", "--json"],
596 )
597 assert result.exit_code == 0, f"strength={strength}: {result.output}"
598 json_line = result.output.splitlines()[0]
599 payload = json.loads(json_line)
600 assert payload["mnemonic_word_count"] == n_words, \
601 f"strength={strength}: expected {n_words} words, got {payload['mnemonic_word_count']}"
602 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
603 assert pem.exists(), f"PEM not written for strength={strength}"
604
605
606 # ---------------------------------------------------------------------------
607 # Data integrity
608 # ---------------------------------------------------------------------------
609
610
611 class TestKeygenHdDataIntegrity:
612 """Derived keys and stored mnemonics must be byte-for-byte stable."""
613
614 def test_keygen_hd_key_derives_correctly(
615 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
616 ) -> None:
617 """Keygen --hd must write a PEM key consistent with the generated mnemonic."""
618 from muse.core import bip39 as bip39_mod
619 fixed_mnemonic = (
620 "abandon abandon abandon abandon abandon abandon "
621 "abandon abandon abandon abandon abandon about"
622 )
623 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
624
625 _kc: dict[str, str] = {}
626 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
627 monkeypatch.setattr("muse.core.keychain.store",
628 lambda hub, m: _kc.__setitem__(hub, m))
629 monkeypatch.setattr("muse.core.keychain.load", lambda hub: _kc.get(hub))
630
631 fake_home = _patch_home(monkeypatch, tmp_path)
632 result = runner.invoke(
633 None,
634 ["auth", "keygen", "--hub", "http://localhost:10003"],
635 )
636 assert result.exit_code == 0
637
638 # Mnemonic must be in keychain, not TOML
639 stored_mnemonic = _kc.get("http://localhost:10003")
640 assert stored_mnemonic == fixed_mnemonic, "Mnemonic not stored in keychain"
641
642 # PEM key must match re-derivation from the mnemonic
643 seed = mnemonic_to_seed(stored_mnemonic)
644 dk = derive_identity_key(seed)
645 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
646 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
647 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
648 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
649 recomputed_fp = hashlib.sha256(pub_raw).hexdigest()
650
651 pem = fake_home / ".muse" / "keys" / "localhost_10003.pem"
652 loaded_key = load_pem_private_key(pem.read_bytes(), password=None)
653 pub_raw_loaded = loaded_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
654 stored_fp = hashlib.sha256(pub_raw_loaded).hexdigest()
655
656 assert recomputed_fp == stored_fp, \
657 "Re-derived fingerprint from keychain mnemonic does not match stored PEM key"
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 # Patch generate_mnemonic to return a fixed mnemonic both times
673 from muse.core.bip39 import mnemonic_to_seed as _mts
674 fixed = (
675 "abandon abandon abandon abandon abandon abandon "
676 "abandon abandon abandon abandon abandon about"
677 )
678 import muse.core.bip39 as bip39_mod
679 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed)
680
681 result1 = runner.invoke(
682 None,
683 ["auth", "keygen", "--hub", "http://localhost:10003", "--json"],
684 )
685 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
686
687 result2 = runner.invoke(
688 None,
689 ["auth", "keygen", "--hub", "http://localhost:10003", "--force", "--json"],
690 )
691 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
692
693 assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen"
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago