gabriel / muse public
test_auth_hd_persistence.py python
518 lines 21.9 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 150 days ago
1 """Phase 2A — HD mnemonic persistence tests.
2
3 Verifies that:
4 1. ``_load_all`` round-trips ``key_source``, ``mnemonic``, and ``hd_path``
5 through identity.toml.
6 2. ``muse auth keygen --hd`` writes HD provenance to identity.toml immediately
7 (mnemonic is NOT lost when the process exits).
8 3. ``muse auth register`` preserves HD fields from an existing identity entry
9 rather than silently overwriting them with a plain ``IdentityEntry``.
10 4. Mnemonic is never emitted in the JSON stdout object (only on stderr).
11
12 Test categories covered
13 -----------------------
14 - unit : _load_all / _dump_identity round-trip
15 - integration : CLI round-trip via CliRunner
16 - e2e : keygen → register field preservation
17 - stress : 10 re-registrations, 20 successive save_identity writes
18 - data integrity: mnemonic survives repeated register calls + TOML escaping
19 - performance : save+load under 100 ms; full keygen --hd under 3 s
20 - security : mnemonic absent from JSON stdout object
21 - docstrings : public API has docstrings (smoke)
22 """
23
24 from __future__ import annotations
25
26 import json
27 import os
28 import pathlib
29 import tempfile
30 import time
31 import tomllib
32 from unittest.mock import patch
33
34 import pytest
35 from tests.cli_test_helper import CliRunner
36
37 from muse.core import keypair as kp_module
38
39 cli = None
40 runner = CliRunner()
41
42 HUB = "http://localhost:10003"
43 HOSTNAME = "localhost:10003"
44 FAKE_MNEMONIC = (
45 "abandon abandon abandon abandon abandon abandon "
46 "abandon abandon abandon abandon abandon about"
47 )
48 FAKE_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'"
49 FAKE_FINGERPRINT = "a" * 64 # used only in unit-test TOML fixtures (not real derivation)
50 FAKE_HANDLE = "gabriel"
51
52
53 # ---------------------------------------------------------------------------
54 # Shared fixtures
55 # ---------------------------------------------------------------------------
56
57
58 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
59 """Redirect pathlib.Path.home() and module-level constants to a temp dir."""
60 fake_home = tmp_path / "home"
61 fake_home.mkdir(parents=True, exist_ok=True)
62 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
63 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
64 from muse.core import identity as id_module
65 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
66 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
67 return fake_home
68
69
70 def _mock_hub(monkeypatch: pytest.MonkeyPatch, handle: str = FAKE_HANDLE) -> None:
71 """Patch _json_post_raw to simulate a successful hub challenge-response."""
72 import muse.cli.commands.auth as auth_mod
73 challenge = {"challengeToken": "deadbeef" * 8, "isNewKey": True, "algorithm": "ed25519"}
74 verify = {"handle": handle, "identityId": "id-123", "isNewIdentity": False, "authMethod": "ed25519"}
75 monkeypatch.setattr(
76 auth_mod,
77 "_json_post_raw",
78 lambda base, path, payload: challenge if "challenge" in path else verify,
79 )
80
81
82 def _mock_bip39(monkeypatch: pytest.MonkeyPatch) -> None:
83 """Patch only mnemonic *generation* to avoid OS entropy.
84
85 ``mnemonic_to_seed`` and ``generate_hd_keypair`` run for real so that:
86 - A valid PEM key is written to disk (run_register can sign with it).
87 - The fingerprint stored in identity.toml is the genuine derived value.
88 - SLIP-0010 derivation is exercised, not bypassed.
89 """
90 import muse.core.bip39 as bip39_mod
91 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: FAKE_MNEMONIC)
92
93
94 def _identity_file(fake_home: pathlib.Path) -> pathlib.Path:
95 return fake_home / ".muse" / "identity.toml"
96
97
98 def _read_identity_toml(fake_home: pathlib.Path) -> dict:
99 ifile = _identity_file(fake_home)
100 with ifile.open("rb") as fh:
101 return tomllib.load(fh)
102
103
104 # ---------------------------------------------------------------------------
105 # 1. Unit — _load_all round-trips HD fields
106 # ---------------------------------------------------------------------------
107
108
109 class TestLoadAllHdFields:
110 """_load_all must parse key_source, mnemonic, hd_path from TOML."""
111
112 def _write(self, path: pathlib.Path, text: str) -> None:
113 path.write_text(text, encoding="utf-8")
114
115 def test_loads_key_source(self, tmp_path):
116 p = tmp_path / "identity.toml"
117 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
118 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
119 f'fingerprint="{FAKE_FINGERPRINT}"\nkey_source="hd"\n')
120 from muse.core.identity import _load_all
121 assert _load_all(p)[HOSTNAME]["key_source"] == "hd"
122
123 def test_loads_mnemonic(self, tmp_path):
124 p = tmp_path / "identity.toml"
125 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
126 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
127 f'fingerprint="{FAKE_FINGERPRINT}"\nmnemonic="{FAKE_MNEMONIC}"\n')
128 from muse.core.identity import _load_all
129 assert _load_all(p)[HOSTNAME]["mnemonic"] == FAKE_MNEMONIC
130
131 def test_loads_hd_path(self, tmp_path):
132 p = tmp_path / "identity.toml"
133 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
134 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
135 f'fingerprint="{FAKE_FINGERPRINT}"\nhd_path="{FAKE_HD_PATH}"\n')
136 from muse.core.identity import _load_all
137 assert _load_all(p)[HOSTNAME]["hd_path"] == FAKE_HD_PATH
138
139 def test_loads_all_hd_fields_together(self, tmp_path):
140 p = tmp_path / "identity.toml"
141 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
142 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
143 f'fingerprint="{FAKE_FINGERPRINT}"\n'
144 f'key_source="hd"\nmnemonic="{FAKE_MNEMONIC}"\nhd_path="{FAKE_HD_PATH}"\n')
145 from muse.core.identity import _load_all
146 entry = _load_all(p)[HOSTNAME]
147 assert entry["key_source"] == "hd"
148 assert entry["mnemonic"] == FAKE_MNEMONIC
149 assert entry["hd_path"] == FAKE_HD_PATH
150
151 def test_jbok_entry_has_no_hd_fields(self, tmp_path):
152 p = tmp_path / "identity.toml"
153 self._write(p, f'["{HOSTNAME}"]\ntype="human"\nhandle="{FAKE_HANDLE}"\n'
154 f'key_path="/tmp/k.pem"\nalgorithm="ed25519"\n'
155 f'fingerprint="{FAKE_FINGERPRINT}"\n')
156 from muse.core.identity import _load_all
157 entry = _load_all(p)[HOSTNAME]
158 assert "key_source" not in entry
159 assert "mnemonic" not in entry
160 assert "hd_path" not in entry
161
162 def test_round_trip_hd_fields(self, tmp_path):
163 """_dump_identity → write → _load_all preserves HD fields exactly."""
164 from muse.core.identity import _dump_identity, _load_all
165 identities = {HOSTNAME: {
166 "type": "human", "handle": FAKE_HANDLE,
167 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
168 "fingerprint": FAKE_FINGERPRINT,
169 "key_source": "hd", "mnemonic": FAKE_MNEMONIC, "hd_path": FAKE_HD_PATH,
170 }}
171 p = tmp_path / "identity.toml"
172 p.write_text(_dump_identity(identities), encoding="utf-8")
173 entry = _load_all(p)[HOSTNAME]
174 assert entry["key_source"] == "hd"
175 assert entry["mnemonic"] == FAKE_MNEMONIC
176 assert entry["hd_path"] == FAKE_HD_PATH
177
178
179 # ---------------------------------------------------------------------------
180 # 2. Integration — run_keygen --hd writes identity.toml
181 # ---------------------------------------------------------------------------
182
183
184 class TestKeygenHdWritesIdentity:
185 """run_keygen --hd must persist HD fields to identity.toml."""
186
187 def _run(self, monkeypatch, tmp_path, extra_args=None):
188 fake_home = _patch_home(monkeypatch, tmp_path)
189 _mock_bip39(monkeypatch)
190 args = ["auth", "keygen", "--hub", HUB, "--hd"] + (extra_args or [])
191 result = runner.invoke(cli, args, catch_exceptions=False)
192 return result, fake_home
193
194 def test_identity_toml_created(self, monkeypatch, tmp_path):
195 result, fake_home = self._run(monkeypatch, tmp_path)
196 assert result.exit_code == 0, result.output
197 assert _identity_file(fake_home).exists()
198
199 def test_key_source_hd_written(self, monkeypatch, tmp_path):
200 _, fake_home = self._run(monkeypatch, tmp_path)
201 data = _read_identity_toml(fake_home)
202 assert data[HOSTNAME]["key_source"] == "hd"
203
204 def test_mnemonic_written(self, monkeypatch, tmp_path):
205 _, fake_home = self._run(monkeypatch, tmp_path)
206 data = _read_identity_toml(fake_home)
207 assert data[HOSTNAME]["mnemonic"] == FAKE_MNEMONIC
208
209 def test_hd_path_written(self, monkeypatch, tmp_path):
210 _, fake_home = self._run(monkeypatch, tmp_path)
211 data = _read_identity_toml(fake_home)
212 assert data[HOSTNAME]["hd_path"].startswith("m/")
213
214 def test_json_output_has_no_mnemonic_key(self, monkeypatch, tmp_path):
215 """JSON stdout object must not contain a 'mnemonic' key."""
216 result, _ = self._run(monkeypatch, tmp_path, extra_args=["--json"])
217 assert result.exit_code == 0
218 # Find the JSON line in combined output (stdout is first, before stderr)
219 json_line = next(
220 (line for line in result.output.splitlines() if line.startswith("{")), None
221 )
222 assert json_line is not None, "No JSON in output"
223 out = json.loads(json_line)
224 assert "mnemonic" not in out
225
226 def test_mnemonic_absent_from_json_object(self, monkeypatch, tmp_path):
227 """Even when mnemonic appears in stderr, JSON dict must not carry it."""
228 result, _ = self._run(monkeypatch, tmp_path, extra_args=["--json"])
229 json_line = next(
230 (line for line in result.output.splitlines() if line.startswith("{")), None
231 )
232 out = json.loads(json_line)
233 assert FAKE_MNEMONIC not in json.dumps(out)
234
235 def test_keygen_hd_force_overwrites_identity(self, monkeypatch, tmp_path):
236 """--force on an existing HD key overwrites identity.toml entry."""
237 self._run(monkeypatch, tmp_path)
238 result, fake_home = self._run(monkeypatch, tmp_path, extra_args=["--force"])
239 assert result.exit_code == 0, result.output
240 data = _read_identity_toml(fake_home)
241 assert data[HOSTNAME]["key_source"] == "hd"
242
243
244 # ---------------------------------------------------------------------------
245 # 3. E2E — run_register preserves HD fields
246 # ---------------------------------------------------------------------------
247
248
249 class TestRegisterPreservesHdFields:
250 """run_register must carry forward key_source, mnemonic, hd_path."""
251
252 def _setup_hd_keygen(self, monkeypatch, tmp_path) -> pathlib.Path:
253 """Run keygen --hd so identity.toml gets HD fields, return fake_home.
254
255 Only ``generate_mnemonic`` is mocked (to avoid OS entropy and
256 non-determinism); ``mnemonic_to_seed`` and ``generate_hd_keypair``
257 run for real so that a valid PEM is written to disk and
258 run_register can actually sign the challenge.
259 """
260 fake_home = _patch_home(monkeypatch, tmp_path)
261 _mock_bip39(monkeypatch)
262 args = ["auth", "keygen", "--hub", HUB, "--hd"]
263 result = runner.invoke(cli, args, catch_exceptions=False)
264 assert result.exit_code == 0, result.output
265 return fake_home
266
267 def _run_register(self, monkeypatch, fake_home) -> "object":
268 _mock_hub(monkeypatch)
269 args = ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE]
270 result = runner.invoke(cli, args, catch_exceptions=False)
271 return result
272
273 def test_hd_fields_preserved_after_register(self, monkeypatch, tmp_path):
274 fake_home = self._setup_hd_keygen(monkeypatch, tmp_path)
275 result = self._run_register(monkeypatch, fake_home)
276 assert result.exit_code == 0, result.output
277
278 data = _read_identity_toml(fake_home)
279 entry = data[HOSTNAME]
280 assert entry.get("key_source") == "hd", "key_source lost after register"
281 assert entry.get("mnemonic") == FAKE_MNEMONIC, "mnemonic lost after register"
282 assert entry.get("hd_path", "").startswith("m/"), "hd_path lost after register"
283
284 def test_handle_updated_after_register(self, monkeypatch, tmp_path):
285 """Provisional empty handle must be replaced by server-assigned handle."""
286 fake_home = self._setup_hd_keygen(monkeypatch, tmp_path)
287 # Confirm provisional entry has empty handle before register
288 pre_data = _read_identity_toml(fake_home)
289 assert pre_data[HOSTNAME].get("handle", "") == ""
290
291 self._run_register(monkeypatch, fake_home)
292 post_data = _read_identity_toml(fake_home)
293 assert post_data[HOSTNAME].get("handle") == FAKE_HANDLE
294
295 def test_register_second_time_still_preserves_mnemonic(self, monkeypatch, tmp_path):
296 """Re-authentication preserves the mnemonic through multiple registrations."""
297 fake_home = self._setup_hd_keygen(monkeypatch, tmp_path)
298 self._run_register(monkeypatch, fake_home)
299 # Second registration (e.g. key rotation to same hub)
300 result2 = self._run_register(monkeypatch, fake_home)
301 assert result2.exit_code == 0, result2.output
302
303 data = _read_identity_toml(fake_home)
304 assert data[HOSTNAME].get("mnemonic") == FAKE_MNEMONIC
305
306 def test_jbok_register_no_hd_fields_added(self, monkeypatch, tmp_path):
307 """Registering with a JBOK key must not add HD fields."""
308 fake_home = _patch_home(monkeypatch, tmp_path)
309 # Generate a JBOK key (not HD)
310 kp_module.generate_keypair(HOSTNAME)
311 _mock_hub(monkeypatch)
312 result = runner.invoke(
313 cli,
314 ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE],
315 catch_exceptions=False,
316 )
317 assert result.exit_code == 0, result.output
318
319 if _identity_file(fake_home).exists():
320 data = _read_identity_toml(fake_home)
321 entry = data.get(HOSTNAME, {})
322 assert "key_source" not in entry
323 assert "mnemonic" not in entry
324
325
326 # ---------------------------------------------------------------------------
327 # 4. Data integrity — mnemonic survives TOML escaping edge cases
328 # ---------------------------------------------------------------------------
329
330
331 class TestMnemonicTomlEscaping:
332 def test_mnemonic_with_quotes_round_trips(self, tmp_path):
333 """A mnemonic containing TOML-special chars survives _dump → _load."""
334 from muse.core.identity import _dump_identity, _load_all
335 weird = 'word1 word2 "quoted" word3 back\\slash word4'
336 entry = {
337 "type": "human", "handle": FAKE_HANDLE,
338 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
339 "fingerprint": FAKE_FINGERPRINT,
340 "key_source": "hd", "mnemonic": weird, "hd_path": FAKE_HD_PATH,
341 }
342 p = tmp_path / "identity.toml"
343 p.write_text(_dump_identity({HOSTNAME: entry}), encoding="utf-8")
344 assert _load_all(p)[HOSTNAME]["mnemonic"] == weird
345
346 def test_hd_path_prime_and_slash_preserved(self, tmp_path):
347 """HD path with primes and slashes round-trips without corruption."""
348 from muse.core.identity import _dump_identity, _load_all
349 entry = {
350 "type": "human", "handle": FAKE_HANDLE,
351 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
352 "fingerprint": FAKE_FINGERPRINT,
353 "key_source": "hd", "mnemonic": FAKE_MNEMONIC, "hd_path": FAKE_HD_PATH,
354 }
355 p = tmp_path / "identity.toml"
356 p.write_text(_dump_identity({HOSTNAME: entry}), encoding="utf-8")
357 assert _load_all(p)[HOSTNAME]["hd_path"] == FAKE_HD_PATH
358
359
360 # ---------------------------------------------------------------------------
361 # 5. Security — mnemonic never in JSON stdout object
362 # ---------------------------------------------------------------------------
363
364
365 class TestMnemonicNeverInJsonObject:
366 """Mnemonic must not appear in any JSON stdout object."""
367
368 def test_keygen_hd_json_no_mnemonic_key(self, monkeypatch, tmp_path):
369 _patch_home(monkeypatch, tmp_path)
370 _mock_bip39(monkeypatch)
371 result = runner.invoke(
372 cli,
373 ["auth", "keygen", "--hub", HUB, "--hd", "--json"],
374 catch_exceptions=False,
375 )
376 assert result.exit_code == 0
377 json_line = next(
378 (l for l in result.output.splitlines() if l.startswith("{")), None
379 )
380 assert json_line is not None
381 obj = json.loads(json_line)
382 assert "mnemonic" not in obj
383
384 def test_keygen_hd_json_mnemonic_word_count_present(self, monkeypatch, tmp_path):
385 """JSON should have mnemonic_word_count (count, not content)."""
386 _patch_home(monkeypatch, tmp_path)
387 _mock_bip39(monkeypatch)
388 result = runner.invoke(
389 cli,
390 ["auth", "keygen", "--hub", HUB, "--hd", "--json"],
391 catch_exceptions=False,
392 )
393 json_line = next(
394 (l for l in result.output.splitlines() if l.startswith("{")), None
395 )
396 obj = json.loads(json_line)
397 assert "mnemonic_word_count" in obj
398 assert obj["mnemonic_word_count"] == 12 # 128-bit → 12 words
399
400
401 # ---------------------------------------------------------------------------
402 # 6. Docstring smoke tests
403 # ---------------------------------------------------------------------------
404
405
406 class TestDocstrings:
407 def test_load_all_has_docstring(self):
408 from muse.core.identity import _load_all
409 assert _load_all.__doc__
410
411 def test_save_identity_has_docstring(self):
412 from muse.core.identity import save_identity
413 assert save_identity.__doc__
414
415 def test_load_identity_has_docstring(self):
416 from muse.core.identity import load_identity
417 assert load_identity.__doc__
418
419 def test_generate_hd_keypair_has_docstring(self):
420 from muse.core.keypair import generate_hd_keypair
421 assert generate_hd_keypair.__doc__
422
423
424 # ---------------------------------------------------------------------------
425 # Performance
426 # ---------------------------------------------------------------------------
427
428
429 class TestPersistencePerformance:
430 """Identity TOML read/write must add negligible latency."""
431
432 def test_save_and_load_identity_under_100ms(self, tmp_path):
433 """save_identity + load_identity must complete in under 100 ms."""
434 from muse.core import identity as id_module
435 import pytest
436 identity_file = tmp_path / "identity.toml"
437 monkeypatch = pytest.MonkeyPatch()
438 monkeypatch.setattr(id_module, "_IDENTITY_DIR", tmp_path)
439 monkeypatch.setattr(id_module, "_IDENTITY_FILE", identity_file)
440 entry = {
441 "type": "human", "handle": FAKE_HANDLE,
442 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
443 "fingerprint": FAKE_FINGERPRINT,
444 "key_source": "hd", "mnemonic": FAKE_MNEMONIC, "hd_path": FAKE_HD_PATH,
445 }
446 start = time.monotonic()
447 id_module.save_identity(HUB, entry)
448 loaded = id_module.load_identity(HUB)
449 elapsed = time.monotonic() - start
450 monkeypatch.undo()
451 assert loaded is not None
452 assert elapsed < 0.1, f"save+load took {elapsed*1000:.1f}ms"
453
454 def test_keygen_hd_then_load_identity_under_3s(self, monkeypatch, tmp_path):
455 """The full keygen --hd path including SLIP-0010 must complete in under 3 s."""
456 _patch_home(monkeypatch, tmp_path)
457 _mock_bip39(monkeypatch)
458 start = time.monotonic()
459 result = runner.invoke(
460 cli,
461 ["auth", "keygen", "--hub", HUB, "--hd"],
462 catch_exceptions=False,
463 )
464 elapsed = time.monotonic() - start
465 assert result.exit_code == 0, result.output
466 assert elapsed < 3.0, f"keygen --hd took {elapsed:.2f}s"
467
468
469 # ---------------------------------------------------------------------------
470 # Stress
471 # ---------------------------------------------------------------------------
472
473
474 class TestPersistenceStress:
475 """HD field persistence must hold under repeated writes and re-loads."""
476
477 def test_10_successive_registers_preserve_mnemonic(self, monkeypatch, tmp_path):
478 """Mnemonic must survive 10 consecutive register calls without corruption."""
479 fake_home = _patch_home(monkeypatch, tmp_path)
480 _mock_bip39(monkeypatch)
481 # Generate HD key once
482 runner.invoke(cli, ["auth", "keygen", "--hub", HUB, "--hd"],
483 catch_exceptions=False)
484 # Simulate 10 re-registrations
485 _mock_hub(monkeypatch)
486 for i in range(10):
487 result = runner.invoke(
488 cli,
489 ["auth", "register", "--hub", HUB, "--handle", FAKE_HANDLE],
490 catch_exceptions=False,
491 )
492 assert result.exit_code == 0, f"iteration {i}: {result.output}"
493 data = _read_identity_toml(fake_home)
494 mnemonic_stored = data[HOSTNAME].get("mnemonic", "")
495 assert mnemonic_stored == FAKE_MNEMONIC, \
496 f"Mnemonic corrupted after {i+1} register calls"
497
498 def test_concurrent_identity_writes_do_not_corrupt(self, monkeypatch, tmp_path):
499 """Multiple save_identity calls in succession must not corrupt the file."""
500 from muse.core import identity as id_module
501 identity_file = tmp_path / "identity.toml"
502 monkeypatch.setattr(id_module, "_IDENTITY_DIR", tmp_path)
503 monkeypatch.setattr(id_module, "_IDENTITY_FILE", identity_file)
504
505 base_entry = {
506 "type": "human", "handle": FAKE_HANDLE,
507 "key_path": "/tmp/k.pem", "algorithm": "ed25519",
508 "fingerprint": FAKE_FINGERPRINT,
509 "key_source": "hd", "mnemonic": FAKE_MNEMONIC, "hd_path": FAKE_HD_PATH,
510 }
511 for i in range(20):
512 entry = {**base_entry, "handle": f"user_{i}"}
513 id_module.save_identity(HUB, entry)
514
515 loaded = id_module.load_identity(HUB)
516 assert loaded is not None
517 assert loaded.get("key_source") == "hd"
518 assert loaded.get("mnemonic") == FAKE_MNEMONIC
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 150 days ago