gabriel / muse public
test_cmd_auth_keygen_hd.py python
798 lines 34.6 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago
1 """Tests for ``muse auth keygen`` — BIP39/SLIP-0010 HD key generation.
2
3 Coverage matrix
4 ---------------
5 Unit
6 - IdentityEntry accepts hd_path, algorithm, fingerprint fields
7 - _dump_identity serialises HD fields correctly
8 - _dump_identity round-trips through tomllib
9 - derive_hd_public_info returns correct public_key_b64 and fingerprint
10 - derive_hd_public_info derived key matches hdkeys.derive_identity_key
11
12 Integration (full CLI round-trips via CliRunner)
13 - ``muse auth keygen`` exits 0
14 - no PEM file written to disk
15 - mnemonic printed exactly once on stderr
16 - mnemonic has correct word count (12 words default, 24 with --strength 256)
17 - mnemonic passes BIP39 validation
18 - public_key_b64 and fingerprint in stderr output
19 - --hd --force overwrites existing key
20 - --hd --force rejected when key exists without --force
21 - --json output: no mnemonic in stdout, has key_source/hd_path/mnemonic_word_count
22 - --strength 256 produces 24-word mnemonic
23 - --language spanish generates a valid Spanish mnemonic
24 - JBOK key and HD key are different private keys (different derivation paths)
25 - HD key is deterministic: same mnemonic → same fingerprint
26
27 End-to-end
28 - Full flow: keygen --hd → verify PEM → derive same key from stored mnemonic
29 - identity.toml written with key_source, mnemonic, hd_path after keygen
30
31 Stress
32 - 10 successive keygen --hd --force calls all produce valid, distinct keys
33 - keygen --hd for all 5 supported entropy strengths (128–256 bits)
34
35 Data integrity
36 - mnemonic stored in identity.toml round-trips byte-for-byte
37 - derived fingerprint is stable across multiple muse_path invocations
38 - SLIP-0010 child key from the same seed is identical on repeated calls
39
40 Security
41 - mnemonic never appears in JSON stdout (stdout is machine-readable-only)
42 - mnemonic not in key_path or fingerprint
43 - --hd with unsupported --strength exits 1
44 - --hd with unsupported --language exits 1
45 - PEM mode is 0o600 (no group/world bits)
46
47 Performance
48 - keygen --hd completes in < 2 s (PBKDF2 + SLIP-0010 are fast)
49
50 Docstrings
51 - generate_hd_keypair has a docstring
52 - run_keygen docstring mentions --hd flag
53 """
54
55 from __future__ import annotations
56
57 import hashlib
58 import json
59 import os
60 import pathlib
61 import stat
62 import time
63
64 import pytest
65
66 from tests.cli_test_helper import CliRunner, InvokeResult
67 from muse.core import keypair as kp_module
68 from muse.core import identity as id_module
69 from muse.core.identity import IdentityEntry, _dump_identity
70 from muse.core.bip39 import validate_mnemonic, word_count, STRENGTH_PARANOID
71 from muse.core.hdkeys import (
72 derive_identity_key,
73 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_json_output_has_mnemonic_word_count(
356 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
357 ) -> None:
358 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
359 json_line = result.output.splitlines()[0]
360 payload = json.loads(json_line)
361 assert payload.get("mnemonic_word_count") == 24 # default strength=256
362
363 def test_json_output_standard_fields(
364 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
365 ) -> None:
366 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
367 json_line = result.output.splitlines()[0]
368 payload = json.loads(json_line)
369 for field in ("status", "hub", "hostname", "public_key_b64", "fingerprint"):
370 assert field in payload, f"Missing field: {field}"
371 assert "key_path" not in payload, "key_path must not appear in JSON output"
372
373 def test_force_overwrites_existing(
374 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
375 ) -> None:
376 fake_home = _patch_home(monkeypatch, tmp_path)
377 args_base = ["auth", "keygen", "--hub", "https://localhost:1337"]
378 runner.invoke(None, args_base)
379 result = runner.invoke(None, args_base + ["--force"])
380 assert result.exit_code == 0, result.output
381
382 def test_second_keygen_without_force_rejected(
383 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
384 ) -> None:
385 """Repeated keygen without --force must fail — identity already exists."""
386 _patch_home(monkeypatch, tmp_path)
387 args_base = ["auth", "keygen", "--hub", "https://localhost:1337"]
388 r1 = runner.invoke(None, args_base)
389 assert r1.exit_code == 0, r1.output
390 r2 = runner.invoke(None, args_base)
391 assert r2.exit_code != 0, "Second keygen without --force should fail"
392
393
394 # ---------------------------------------------------------------------------
395 # End-to-end
396 # ---------------------------------------------------------------------------
397
398
399 class TestKeygenHdEndToEnd:
400 """Full derivation round-trip: generate → verify → re-derive."""
401
402 def test_derived_key_reproducible_from_stored_mnemonic(
403 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
404 ) -> None:
405 """Fingerprint from keygen must match manual re-derivation from keychain mnemonic."""
406 # Patch keychain so we can read back what keygen stored.
407 _kc: dict[str, str] = {}
408 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
409 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
410 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
411 _patch_home(monkeypatch, tmp_path)
412
413 result = runner.invoke(
414 None,
415 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
416 )
417 assert result.exit_code == 0, result.output
418
419 payload = json.loads(result.output.splitlines()[0])
420 stored_fingerprint = payload["fingerprint"]
421
422 # Read mnemonic from the in-memory keychain (never from terminal output).
423 mnemonic = _kc.get("mnemonic")
424 assert mnemonic is not None, "mnemonic not stored in keychain"
425
426 # Re-derive the key from the mnemonic and compare fingerprints.
427 seed = mnemonic_to_seed(mnemonic)
428 dk = derive_identity_key(seed)
429 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
430 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
431 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
432 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
433 recomputed_fp = public_key_fingerprint(pub_raw)
434
435 assert recomputed_fp == stored_fingerprint, "Re-derived fingerprint does not match stored"
436
437 def test_mnemonic_derives_and_signs(
438 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
439 ) -> None:
440 """Mnemonic stored in keychain must produce a key that signs and verifies."""
441 fixed_mnemonic = "abandon " * 11 + "about"
442 import muse.core.bip39 as bip39_mod
443 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic.strip())
444 _kc: dict[str, str] = {}
445 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
446 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
447 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
448
449 _patch_home(monkeypatch, tmp_path)
450 runner.invoke(None, ["auth", "keygen", "--hub", "https://localhost:1337"])
451
452 mnemonic = _kc.get("mnemonic")
453 assert mnemonic is not None, "mnemonic not stored in keychain"
454 seed = mnemonic_to_seed(mnemonic)
455 dk = derive_identity_key(seed)
456 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
457 key = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
458 dk.zero()
459 sig = key.sign(b"muse test message")
460 key.public_key().verify(sig, b"muse test message")
461
462
463 # ---------------------------------------------------------------------------
464 # Security
465 # ---------------------------------------------------------------------------
466
467
468 class TestKeygenHdSecurity:
469 """Security properties of HD keygen."""
470
471 def test_mnemonic_not_in_json_stdout(
472 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
473 ) -> None:
474 _, result = _keygen_hd(monkeypatch, tmp_path, ["--json"])
475 json_line = result.output.splitlines()[0]
476 payload = json.loads(json_line)
477 assert "mnemonic" not in payload
478
479 def test_unsupported_strength_exits_nonzero(
480 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
481 ) -> None:
482 _patch_home(monkeypatch, tmp_path)
483 result = runner.invoke(
484 None,
485 ["auth", "keygen", "--hub", "https://localhost:1337", "--strength", "64"],
486 )
487 assert result.exit_code != 0
488
489 def test_unsupported_language_exits_nonzero(
490 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
491 ) -> None:
492 _patch_home(monkeypatch, tmp_path)
493 result = runner.invoke(
494 None,
495 ["auth", "keygen", "--hub", "https://localhost:1337", "--language", "klingon"],
496 )
497 assert result.exit_code != 0
498
499 def test_no_pem_on_disk_after_keygen(
500 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
501 ) -> None:
502 """Keygen must not write any PEM private key to disk."""
503 fake_home, result = _keygen_hd(monkeypatch, tmp_path)
504 assert result.exit_code == 0
505 keys_dir = fake_home / ".muse" / "keys"
506 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
507 assert pem_files == [], f"PEM files found on disk: {pem_files}"
508
509
510 # ---------------------------------------------------------------------------
511 # Performance
512 # ---------------------------------------------------------------------------
513
514
515 class TestKeygenHdPerformance:
516 """HD keygen must complete quickly enough for interactive use."""
517
518 def test_keygen_hd_under_2s(
519 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
520 ) -> None:
521 _patch_home(monkeypatch, tmp_path)
522 start = time.monotonic()
523 result = runner.invoke(
524 None,
525 ["auth", "keygen", "--hub", "https://localhost:1337"],
526 )
527 elapsed = time.monotonic() - start
528 assert result.exit_code == 0, result.output
529 assert elapsed < 2.0, f"keygen --hd took {elapsed:.2f}s — too slow"
530
531
532 # ---------------------------------------------------------------------------
533 # Docstrings
534 # ---------------------------------------------------------------------------
535
536
537 class TestDocstrings:
538 def test_derive_hd_public_info_has_docstring(self) -> None:
539 from muse.core.keypair import derive_hd_public_info
540 assert derive_hd_public_info.__doc__, "derive_hd_public_info is missing a docstring"
541
542 def test_run_keygen_mentions_hd(self) -> None:
543 from muse.cli.commands.auth import run_keygen
544 doc = run_keygen.__doc__ or ""
545 assert "HD" in doc or "BIP39" in doc or "mnemonic" in doc.lower()
546
547
548 # ---------------------------------------------------------------------------
549 # Stress
550 # ---------------------------------------------------------------------------
551
552
553 class TestKeygenHdStress:
554 """HD keygen must be robust under repeated and varied invocations."""
555
556 def test_10_successive_force_keygens_produce_valid_keys(
557 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
558 ) -> None:
559 """Repeated --force --destroy-mnemonic keygen must each produce a distinct key."""
560 _patch_home(monkeypatch, tmp_path)
561 seen_fingerprints: set[str] = set()
562 for _ in range(10):
563 result = runner.invoke(
564 None,
565 ["auth", "keygen", "--hub", "https://localhost:1337",
566 "--force", "--destroy-mnemonic", "--json"],
567 )
568 assert result.exit_code == 0, result.output
569 json_line = result.output.splitlines()[0]
570 payload = json.loads(json_line)
571 fp = payload["fingerprint"]
572 # Each successive keygen with --destroy-mnemonic uses new entropy
573 seen_fingerprints.add(fp)
574 # All 10 keys must be independently valid (distinct fingerprints)
575 assert len(seen_fingerprints) == 10, "Repeated keygen produced duplicate keys"
576
577 def test_all_entropy_strengths_produce_valid_keys(
578 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
579 ) -> None:
580 """All 5 supported strength values (128–256 bits) must succeed."""
581 strengths = [128, 160, 192, 224, 256]
582 expected_word_counts = [12, 15, 18, 21, 24]
583 fake_home = _patch_home(monkeypatch, tmp_path)
584 for strength, n_words in zip(strengths, expected_word_counts):
585 # --destroy-mnemonic ensures fresh entropy for each strength iteration,
586 # rather than reusing the mnemonic stored by the previous iteration.
587 result = runner.invoke(
588 None,
589 ["auth", "keygen", "--hub", "https://localhost:1337",
590 "--strength", str(strength), "--force", "--destroy-mnemonic", "--json"],
591 )
592 assert result.exit_code == 0, f"strength={strength}: {result.output}"
593 json_line = result.output.splitlines()[0]
594 payload = json.loads(json_line)
595 assert payload["mnemonic_word_count"] == n_words, \
596 f"strength={strength}: expected {n_words} words, got {payload['mnemonic_word_count']}"
597 assert "fingerprint" in payload, f"fingerprint missing for strength={strength}"
598
599
600 # ---------------------------------------------------------------------------
601 # Data integrity
602 # ---------------------------------------------------------------------------
603
604
605 class TestKeygenHdDataIntegrity:
606 """Derived keys and stored mnemonics must be byte-for-byte stable."""
607
608 def test_keygen_hd_key_derives_correctly(
609 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
610 ) -> None:
611 """Keygen --hd must write a PEM key consistent with the generated mnemonic."""
612 from muse.core import bip39 as bip39_mod
613 fixed_mnemonic = (
614 "abandon abandon abandon abandon abandon abandon "
615 "abandon abandon abandon abandon abandon about"
616 )
617 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed_mnemonic)
618
619 _kc: dict[str, str] = {}
620 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
621 monkeypatch.setattr("muse.core.keychain.store",
622 lambda m: _kc.__setitem__("mnemonic", m))
623 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
624
625 fake_home = _patch_home(monkeypatch, tmp_path)
626 result = runner.invoke(
627 None,
628 ["auth", "keygen", "--hub", "https://localhost:1337"],
629 )
630 assert result.exit_code == 0
631
632 # Mnemonic must be in keychain, not TOML
633 stored_mnemonic = _kc.get("mnemonic")
634 assert stored_mnemonic == fixed_mnemonic, "Mnemonic not stored in keychain"
635
636 # Fingerprint in JSON output must match re-derivation from the mnemonic
637 result_json = runner.invoke(
638 None,
639 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
640 )
641 assert result_json.exit_code == 0
642 payload = json.loads(result_json.output.splitlines()[0])
643 reported_fp = payload["fingerprint"]
644
645 seed = mnemonic_to_seed(stored_mnemonic)
646 dk = derive_identity_key(seed)
647 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
648 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
649 priv = Ed25519PrivateKey.from_private_bytes(dk.private_bytes)
650 dk.zero()
651 pub_raw = priv.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
652 recomputed_fp = public_key_fingerprint(pub_raw)
653
654 assert recomputed_fp == reported_fp, \
655 "Re-derived fingerprint from mnemonic does not match keygen output"
656
657 def test_slip010_child_key_identical_on_repeated_calls(self) -> None:
658 """derive_identity_key with the same seed must produce the same bytes every time."""
659 seed = b"\xab\xcd\xef" * 21 + b"\x00" # 64 bytes
660 dk1 = derive_identity_key(seed)
661 dk2 = derive_identity_key(seed)
662 assert dk1.private_bytes == dk2.private_bytes, \
663 "SLIP-0010 derivation is not deterministic"
664
665 def test_derived_fingerprint_stable_across_invocations(
666 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
667 ) -> None:
668 """Same mnemonic must yield the same fingerprint across two keygen calls."""
669 _patch_home(monkeypatch, tmp_path)
670 fixed = (
671 "abandon abandon abandon abandon abandon abandon "
672 "abandon abandon abandon abandon abandon about"
673 )
674 import muse.core.bip39 as bip39_mod
675 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: fixed)
676 # Isolate keychain so both calls go through generate_mnemonic
677 _kc: dict[str, str] = {}
678 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
679 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
680 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
681
682 result1 = runner.invoke(
683 None,
684 ["auth", "keygen", "--hub", "https://localhost:1337", "--json"],
685 )
686 fp1 = json.loads(result1.output.splitlines()[0])["fingerprint"]
687
688 result2 = runner.invoke(
689 None,
690 ["auth", "keygen", "--hub", "https://localhost:1337", "--force", "--json"],
691 )
692 fp2 = json.loads(result2.output.splitlines()[0])["fingerprint"]
693
694 assert fp1 == fp2, "Same mnemonic produced different fingerprints on repeated keygen"
695
696
697 # ---------------------------------------------------------------------------
698 # Phase 4 — keygen writes no PEM; identity entry has no key_path
699 # ---------------------------------------------------------------------------
700
701
702 _P4_MNEMONIC = (
703 "abandon abandon abandon abandon abandon abandon abandon abandon "
704 "abandon abandon abandon about"
705 )
706 _P4_HUB = "https://localhost:1337"
707
708
709 def _p4_patch(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
710 """Patch home + keychain for Phase 4 tests; returns fake_home."""
711 fake_home = _patch_home(monkeypatch, tmp_path)
712 import muse.core.bip39 as bip39_mod
713 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _P4_MNEMONIC)
714 _kc: dict[str, str] = {}
715 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
716 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
717 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
718 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
719 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
720 return fake_home
721
722
723 class TestKeygenPhase4NoPem:
724 """Phase 4: auth keygen must NOT write PEM files and must NOT store key_path."""
725
726 def test_P4_1_no_pem_written_after_keygen(
727 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
728 ) -> None:
729 """P4-1: no *.pem file must exist in ~/.muse/keys/ after keygen."""
730 fake_home = _p4_patch(monkeypatch, tmp_path)
731 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
732 assert result.exit_code == 0, result.output
733 keys_dir = fake_home / ".muse" / "keys"
734 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
735 assert pem_files == [], f"Unexpected PEM files written: {pem_files}"
736
737 def test_P4_2_identity_entry_has_no_key_path(
738 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
739 ) -> None:
740 """P4-2: identity.toml entry must NOT contain key_path after keygen."""
741 import tomllib
742 fake_home = _p4_patch(monkeypatch, tmp_path)
743 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
744 assert result.exit_code == 0, result.output
745 identity_file = fake_home / ".muse" / "identity.toml"
746 assert identity_file.exists(), "identity.toml was not written"
747 parsed = tomllib.loads(identity_file.read_text())
748 hostname = "localhost:1337"
749 assert hostname in parsed, f"No entry for {hostname}"
750 entry = parsed[hostname]
751 assert "key_path" not in entry, f"key_path must not appear in identity entry: {entry}"
752
753 def test_P4_3_identity_entry_has_hd_path(
754 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
755 ) -> None:
756 """P4-3: identity.toml entry must have hd_path after keygen."""
757 import tomllib
758 fake_home = _p4_patch(monkeypatch, tmp_path)
759 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
760 assert result.exit_code == 0, result.output
761 identity_file = fake_home / ".muse" / "identity.toml"
762 parsed = tomllib.loads(identity_file.read_text())
763 entry = parsed["localhost:1337"]
764 assert "hd_path" in entry, f"hd_path missing from identity entry: {entry}"
765 assert entry["hd_path"].startswith("m/"), f"hd_path has wrong format: {entry['hd_path']}"
766
767 def test_P4_4_resolve_signing_identity_works_after_keygen_and_register(
768 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
769 ) -> None:
770 """P4-4: resolve_signing_identity returns a key after keygen + handle set (register step)."""
771 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
772 from muse.core.identity import resolve_signing_identity, load_identity, save_identity
773
774 fake_home = _p4_patch(monkeypatch, tmp_path)
775 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB])
776 assert result.exit_code == 0, result.output
777
778 # Simulate the handle being set after registration
779 entry = load_identity(_P4_HUB)
780 assert entry is not None
781 entry["handle"] = "gabriel"
782 save_identity(_P4_HUB, entry)
783
784 result2 = resolve_signing_identity(_P4_HUB)
785 assert result2 is not None, "resolve_signing_identity returned None after keygen+register"
786 handle, private_key = result2
787 assert handle == "gabriel"
788 assert isinstance(private_key, Ed25519PrivateKey)
789
790 def test_P4_5_json_output_has_no_key_path(
791 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
792 ) -> None:
793 """P4-5: --json output must not include key_path."""
794 _p4_patch(monkeypatch, tmp_path)
795 result = runner.invoke(None, ["auth", "keygen", "--hub", _P4_HUB, "--json"])
796 assert result.exit_code == 0, result.output
797 payload = json.loads(result.output.splitlines()[0])
798 assert "key_path" not in payload, f"key_path must not appear in JSON output: {payload}"
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago