gabriel / muse public
test_auth_rotate.py python
861 lines 33.4 KB
Raw
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 11 hours ago
1 """Tests for ``muse auth rotate`` — HD key rotation (HIGH-4).
2
3 Key rotation derives a new Ed25519 identity key at index+1 in the HD path
4 from the OS keychain mnemonic, registers the new key with the hub, deregisters
5 the old key, and updates identity.toml atomically. No PEM is written.
6
7 The rotation index is the 6th path component (0-indexed):
8 m/1075233755'/0'/0'/0'/0'/N'
9 └── N=0 current, N=1 first rotation, …
10
11 Passphrase delivery uses ``--passphrase-fd N`` (pipe fd) or
12 ``MUSE_BIP39_PASSPHRASE`` env var — never ``--passphrase PHRASE`` (that
13 would expose the secret in ``ps aux``).
14
15 Coverage
16 --------
17 I Basic rotation (unit)
18 I1 rotate produces a different fingerprint than the original key
19 I2 the new hd_path has rotation index incremented by 1
20 I3 two rotations increment the index by 2
21 I4 same mnemonic → same rotated fingerprint (deterministic)
22
23 II CLI flags (unit)
24 II1 --json emits valid JSON with expected fields
25 II2 --passphrase-fd flows through to seed derivation
26 II3 MUSE_BIP39_PASSPHRASE env var works for rotate
27
28 III Guard rails (unit)
29 III1 rotate without prior keygen exits non-zero with a clear error
30 III2 rotate writes no PEM file
31 III3 hd_path in identity.toml reflects the new rotation index
32
33 IV Hub sync invariant (integration)
34 IV1 rotate registers the new key with the hub (challenge+verify)
35 IV2 rotate deregisters the old key from the hub (DELETE /api/auth/keys/…)
36 IV3 identity.toml is updated only after the hub confirms the new key
37 IV4 rotate exits non-zero if hub registration fails; identity.toml unchanged
38
39 V End-to-end (e2e)
40 V1 rotate then immediate rotate again produces index+2
41 V2 rotate with wrong passphrase yields non-zero exit
42
43 VI Data integrity
44 VI1 identity.toml is unchanged on hub registration failure
45 VI2 old fingerprint is never written back to identity.toml after rotate
46
47 VII Stress
48 VII1 10 sequential rotations produce monotonically increasing indexes
49
50 VIII Security
51 VIII1 passphrase never appears in the rotate --json output
52 VIII2 mnemonic never appears in the rotate --json output
53 VIII3 old private key is zeroed from memory after rotate
54
55 IX Performance
56 IX1 rotate completes in under 200 ms (local key derivation only)
57 """
58
59 from __future__ import annotations
60
61 import json
62 import os
63 import pathlib
64 import ssl
65 from collections.abc import Mapping
66
67 import pytest
68
69 from tests.cli_test_helper import CliRunner, InvokeResult
70 from muse.core import keypair as kp_module
71 from muse.core import identity as id_module
72 from muse.core.paths import muse_dir
73
74 type _JsonResp = dict[str, str | bool | int]
75
76 runner = CliRunner()
77
78 _HUB = "https://localhost:1337"
79 _MNEMONIC = (
80 "abandon abandon abandon abandon abandon abandon abandon abandon "
81 "abandon abandon abandon about"
82 )
83
84
85 # ---------------------------------------------------------------------------
86 # Fixtures
87 # ---------------------------------------------------------------------------
88
89
90 @pytest.fixture()
91 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
92 """Isolated home dir + keychain + hub stubs for rotate tests.
93
94 Patches:
95 - ``~/.muse/`` → ``tmp_path/home/.muse/`` (identity, keys)
96 - OS keychain → in-memory dict (no macOS Keychain I/O)
97 - ``_json_post_raw`` → stub that returns valid challenge/verify responses
98 - ``_hub_delete`` → no-op (avoids real network DELETE during rotate)
99
100 Every rotate test gets a hub-safe environment by default. Tests in
101 ``TestRotateHubSync`` replace these stubs with spying versions.
102 """
103 fake_home = tmp_path / "home"
104 fake_home.mkdir(parents=True, exist_ok=True)
105 fake_muse = muse_dir(fake_home)
106 fake_muse.mkdir(parents=True, exist_ok=True)
107
108 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
109 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_muse / "keys")
110 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_muse)
111 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_muse / "identity.toml")
112 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
113 _kc: dict[str, str] = {}
114 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
115 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
116 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
117 monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None))
118
119 import muse.cli.commands.auth as _auth_mod
120 _challenge = {"challenge_token": "deadbeef" * 8, "is_new_key": True, "algorithm": "ed25519"}
121 _verify = {"handle": "gabriel", "identity_id": "id-123", "is_new_identity": False, "auth_method": "ed25519"}
122 monkeypatch.setattr(
123 _auth_mod, "_json_post_raw",
124 lambda base, path, payload, extra_headers=None: _challenge if "challenge" in path else _verify,
125 )
126 monkeypatch.setattr(_auth_mod, "_hub_delete", lambda url, auth_header, ssl_ctx=None: None)
127 return fake_home
128
129
130 @pytest.fixture()
131 def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str:
132 from muse.core import bip39 as bip39_mod
133 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC)
134 return _MNEMONIC
135
136
137 def _pipe_passphrase(passphrase: str) -> int:
138 """Write *passphrase* into a pipe; return the read-end fd."""
139 r_fd, w_fd = os.pipe()
140 os.write(w_fd, passphrase.encode())
141 os.close(w_fd)
142 return r_fd
143
144
145 def _keygen(extra: list[str] | None = None) -> "InvokeResult":
146 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or []))
147
148
149 def _rotate(extra: list[str] | None = None) -> "InvokeResult":
150 return runner.invoke(
151 None,
152 ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []),
153 )
154
155
156 def _fp(result: "InvokeResult") -> str:
157 return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr]
158
159
160 def _hd_path(result: "InvokeResult") -> str:
161 return json.loads(result.output.splitlines()[0])["hd_path"] # type: ignore[union-attr]
162
163
164 def _rotation_index(hd_path: str) -> int:
165 """Parse the rotation index (6th component) from a muse hd_path string."""
166 # e.g. "m/1075233755'/0'/0'/0'/0'/2'" → 2
167 parts = hd_path.split("/")
168 return int(parts[-1].rstrip("'"))
169
170
171 # ---------------------------------------------------------------------------
172 # I Basic rotation
173 # ---------------------------------------------------------------------------
174
175
176 class TestRotateBasic:
177 def test_I1_rotate_produces_different_fingerprint(
178 self, isolated: pathlib.Path, fixed_mnemonic: str
179 ) -> None:
180 """I1: rotated key has a different fingerprint than the original."""
181 r_keygen = _keygen()
182 assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr]
183 fp_original = _fp(r_keygen)
184
185 r_rotate = _rotate()
186 assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr]
187 fp_rotated = _fp(r_rotate)
188
189 assert fp_original != fp_rotated, (
190 "Rotated key must have a different fingerprint than the original"
191 )
192
193 def test_I2_rotate_increments_index(
194 self, isolated: pathlib.Path, fixed_mnemonic: str
195 ) -> None:
196 """I2: the new hd_path has rotation index = old index + 1."""
197 r_keygen = _keygen()
198 assert r_keygen.exit_code == 0
199 original_path = _hd_path(r_keygen)
200 original_index = _rotation_index(original_path)
201
202 r_rotate = _rotate()
203 assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr]
204 rotated_path = _hd_path(r_rotate)
205 rotated_index = _rotation_index(rotated_path)
206
207 assert rotated_index == original_index + 1, (
208 f"Expected rotation index {original_index + 1}, got {rotated_index}"
209 )
210
211 def test_I3_two_rotations_increment_twice(
212 self, isolated: pathlib.Path, fixed_mnemonic: str
213 ) -> None:
214 """I3: a second rotation increments the index again."""
215 _keygen()
216 _rotate()
217 r2 = _rotate()
218 assert r2.exit_code == 0, r2.output # type: ignore[union-attr]
219 assert _rotation_index(_hd_path(r2)) == 2
220
221 def test_I4_rotation_is_deterministic(
222 self, isolated: pathlib.Path, fixed_mnemonic: str
223 ) -> None:
224 """I4: same mnemonic → same rotated fingerprint on every call."""
225 _keygen()
226 r1 = _rotate()
227 assert r1.exit_code == 0
228
229 # Re-key back to index 0, then rotate again
230 r_keygen2 = runner.invoke(
231 None,
232 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
233 input=_MNEMONIC + "\n",
234 )
235 assert r_keygen2.exit_code == 0
236
237 r2 = _rotate()
238 assert r2.exit_code == 0
239
240 assert _fp(r1) == _fp(r2), "Same mnemonic must always rotate to the same fingerprint"
241
242
243 # ---------------------------------------------------------------------------
244 # II CLI flags
245 # ---------------------------------------------------------------------------
246
247
248 class TestRotateFlags:
249 def test_II1_json_output_has_expected_fields(
250 self, isolated: pathlib.Path, fixed_mnemonic: str
251 ) -> None:
252 """II1: --json output contains status, fingerprint, hd_path, hub."""
253 _keygen()
254 r = _rotate()
255 assert r.exit_code == 0, r.output # type: ignore[union-attr]
256 data = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr]
257 for field in ("status", "fingerprint", "hd_path", "hub"):
258 assert field in data, f"Missing field {field!r} in rotate JSON output"
259 assert data["status"] == "ok"
260
261 def test_II2_passphrase_fd_changes_result(
262 self, isolated: pathlib.Path, fixed_mnemonic: str
263 ) -> None:
264 """II2: --passphrase-fd flows through to mnemonic_to_seed in rotate."""
265 _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
266 r_with = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))])
267 assert r_with.exit_code == 0, r_with.output # type: ignore[union-attr]
268
269 # Rotate again from index 0 without passphrase — must differ
270 runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n")
271 r_without = _rotate()
272 assert r_without.exit_code == 0
273
274 assert _fp(r_with) != _fp(r_without), (
275 "rotate with passphrase must produce a different fingerprint than without"
276 )
277
278 def test_II3_env_var_passphrase_works(
279 self, isolated: pathlib.Path, fixed_mnemonic: str,
280 monkeypatch: pytest.MonkeyPatch,
281 ) -> None:
282 """II3: MUSE_BIP39_PASSPHRASE env var is respected by rotate."""
283 _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
284 r_flag = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))])
285 assert r_flag.exit_code == 0
286
287 runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n")
288 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret")
289 r_env = _rotate()
290 assert r_env.exit_code == 0
291
292 assert _fp(r_flag) == _fp(r_env)
293
294
295 # ---------------------------------------------------------------------------
296 # III Guard rails
297 # ---------------------------------------------------------------------------
298
299
300 class TestRotateGuards:
301 def test_III1_rotate_without_prior_keygen_fails(
302 self, isolated: pathlib.Path
303 ) -> None:
304 """III1: rotate with no existing identity exits non-zero with a clear error."""
305 r = _rotate()
306 assert r.exit_code != 0, "Expected non-zero exit when no identity exists"
307
308 def test_III2_rotate_writes_no_pem(
309 self, isolated: pathlib.Path, fixed_mnemonic: str
310 ) -> None:
311 """III2: rotate must not write any *.pem file."""
312 _keygen()
313 _rotate()
314 keys_dir = muse_dir(isolated) / "keys"
315 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
316 assert pem_files == [], f"PEM files found after rotate: {pem_files}"
317
318 def test_III3_identity_toml_reflects_new_index(
319 self, isolated: pathlib.Path, fixed_mnemonic: str
320 ) -> None:
321 """III3: identity.toml hd_path is updated to reflect the new rotation index."""
322 try:
323 import tomllib
324 except ModuleNotFoundError:
325 import tomli as tomllib # type: ignore[no-reuse-def]
326
327 _keygen()
328 _rotate()
329
330 toml_path = muse_dir(isolated) / "identity.toml"
331 data = tomllib.loads(toml_path.read_text())
332 stored_path = data["localhost:1337"]["hd_path"]
333 assert _rotation_index(stored_path) == 1, (
334 f"identity.toml hd_path must have rotation index 1 after one rotation, "
335 f"got: {stored_path}"
336 )
337
338
339 # ---------------------------------------------------------------------------
340 # III-B Hub-scoping gate (musehub#221) — fail loud, no silent legacy support
341 # ---------------------------------------------------------------------------
342 #
343 # Prior to musehub#221, an identity's hd_path had no hub segment at all —
344 # the same key was presented to every hub. Rotate deliberately refuses to
345 # operate on such an identity rather than silently re-deriving the old
346 # (vulnerable) shape for one more cycle; the user must run
347 # `muse migrate hub-scoping` first. There is no in-code fallback path.
348
349
350 class TestRotateHubScopingGate:
351 def test_pre_scoping_identity_refuses_to_rotate(
352 self, isolated: pathlib.Path, fixed_mnemonic: str
353 ) -> None:
354 """A six-level (pre-#221) hd_path must make rotate exit non-zero."""
355 from muse.core.identity import save_identity
356 from muse.core.bip39 import mnemonic_to_seed
357 from muse.core.keypair import derive_hd_public_info
358
359 seed = mnemonic_to_seed(_MNEMONIC)
360 _, fp = derive_hd_public_info(seed) # hub=None → legacy six-level path
361 save_identity(_HUB, {
362 "type": "human",
363 "handle": "gabriel",
364 "algorithm": "ed25519",
365 "fingerprint": fp,
366 "hd_path": "m/1075233755'/1660078172'/0'/0'/0'/0'",
367 }, mnemonic=_MNEMONIC)
368
369 r = _rotate()
370 assert r.exit_code != 0, "rotate must refuse a pre-hub-scoping identity"
371
372 def test_pre_scoping_identity_error_names_migrate_command(
373 self, isolated: pathlib.Path, fixed_mnemonic: str
374 ) -> None:
375 """The refusal must point the user at the actual remediation command."""
376 from muse.core.identity import save_identity
377 from muse.core.bip39 import mnemonic_to_seed
378 from muse.core.keypair import derive_hd_public_info
379
380 seed = mnemonic_to_seed(_MNEMONIC)
381 _, fp = derive_hd_public_info(seed)
382 save_identity(_HUB, {
383 "type": "human",
384 "handle": "gabriel",
385 "algorithm": "ed25519",
386 "fingerprint": fp,
387 "hd_path": "m/1075233755'/1660078172'/0'/0'/0'/0'",
388 }, mnemonic=_MNEMONIC)
389
390 r = _rotate()
391 assert "muse migrate hub-scoping" in r.stderr
392
393 def test_pre_scoping_identity_toml_unchanged_after_refusal(
394 self, isolated: pathlib.Path, fixed_mnemonic: str
395 ) -> None:
396 """A refused rotate must not touch identity.toml at all."""
397 try:
398 import tomllib
399 except ModuleNotFoundError:
400 import tomli as tomllib # type: ignore[no-reuse-def]
401
402 from muse.core.identity import save_identity
403 from muse.core.bip39 import mnemonic_to_seed
404 from muse.core.keypair import derive_hd_public_info
405
406 seed = mnemonic_to_seed(_MNEMONIC)
407 _, fp = derive_hd_public_info(seed)
408 legacy_path = "m/1075233755'/1660078172'/0'/0'/0'/0'"
409 save_identity(_HUB, {
410 "type": "human",
411 "handle": "gabriel",
412 "algorithm": "ed25519",
413 "fingerprint": fp,
414 "hd_path": legacy_path,
415 }, mnemonic=_MNEMONIC)
416
417 _rotate()
418
419 toml_path = muse_dir(isolated) / "identity.toml"
420 data = tomllib.loads(toml_path.read_text())
421 assert data["localhost:1337"]["hd_path"] == legacy_path
422 assert data["localhost:1337"]["fingerprint"] == fp
423
424 def test_already_hub_scoped_identity_rotates_normally(
425 self, isolated: pathlib.Path, fixed_mnemonic: str
426 ) -> None:
427 """Sanity: the gate only blocks pre-#221 identities, not real ones."""
428 _keygen() # real keygen already produces a hub-scoped path
429 r = _rotate()
430 assert r.exit_code == 0, r.output
431
432
433 # ---------------------------------------------------------------------------
434 # IV Hub sync — rotate must register new key and deregister old key
435 # ---------------------------------------------------------------------------
436 #
437 # Invariant: after `muse auth rotate`, the hub must recognise the NEW key
438 # and reject the OLD key. A rotate that only updates identity.toml leaves
439 # the user unable to push until they manually run `muse auth register`.
440 #
441 # These tests use a spy on `_json_post_raw` to capture every call made to
442 # the hub during rotate, then assert on the sequence.
443
444
445 class TestRotateHubSync:
446 """IV: rotate atomically updates the hub — register new, deregister old."""
447
448 def _mock_hub_calls(
449 self, monkeypatch: pytest.MonkeyPatch
450 ) -> tuple[list[tuple[str, str, dict]], list[str]]:
451 """Intercept hub calls during rotate.
452
453 Returns:
454 post_calls: list of (base, path, payload) for _json_post_raw calls.
455 delete_urls: list of URLs passed to _hub_delete.
456 """
457 import muse.cli.commands.auth as auth_mod
458
459 post_calls: list[tuple[str, str, dict]] = []
460 delete_urls: list[str] = []
461
462 def _fake_post(base: str, path: str, payload: Mapping[str, object], extra_headers: Mapping[str, str] | None = None) -> _JsonResp:
463 post_calls.append((base, path, payload))
464 if "challenge" in path:
465 return {
466 "challenge_token": "deadbeef" * 8,
467 "is_new_key": True,
468 "algorithm": "ed25519",
469 }
470 if "keys" in path:
471 return {
472 "handle": "gabriel",
473 "identity_id": "id-123",
474 "is_new_identity": False,
475 "auth_method": "ed25519",
476 }
477 return {}
478
479 def _fake_delete(url: str, auth_header: str, ssl_ctx: ssl.SSLContext | None = None) -> None:
480 delete_urls.append(url)
481
482 monkeypatch.setattr(auth_mod, "_json_post_raw", _fake_post)
483 monkeypatch.setattr(auth_mod, "_hub_delete", _fake_delete)
484 return post_calls, delete_urls
485
486 def test_IV1_rotate_registers_new_key_with_hub(
487 self, isolated: pathlib.Path, fixed_mnemonic: str,
488 monkeypatch: pytest.MonkeyPatch,
489 ) -> None:
490 """IV1: rotate must perform a challenge-response for the new key.
491
492 The hub cannot accept requests signed by the new key until it has been
493 registered. A rotate that only updates identity.toml leaves the user
494 broken until they manually re-register — that is the bug this test
495 prevents from regressing.
496 """
497 _keygen()
498 post_calls, _ = self._mock_hub_calls(monkeypatch)
499
500 r = _rotate()
501 assert r.exit_code == 0, r.output
502
503 challenge_calls = [p for _, p, _ in post_calls if "challenge" in p]
504 add_key_calls = [p for _, p, _ in post_calls if "keys" in p]
505 assert challenge_calls, (
506 "rotate must request a challenge from the hub for the new key — "
507 "no challenge call was made"
508 )
509 assert add_key_calls, (
510 "rotate must submit the signed challenge to POST /api/auth/keys — "
511 "no add-key call was made"
512 )
513
514 def test_IV2_rotate_deregisters_old_key_from_hub(
515 self, isolated: pathlib.Path, fixed_mnemonic: str,
516 monkeypatch: pytest.MonkeyPatch,
517 ) -> None:
518 """IV2: rotate must deregister the old key from the hub.
519
520 Leaving the old key registered means a stolen old key can still
521 authenticate. rotate must revoke it atomically with the new registration.
522 """
523 _keygen()
524 _, delete_urls = self._mock_hub_calls(monkeypatch)
525
526 r = _rotate()
527 assert r.exit_code == 0, r.output
528
529 assert delete_urls, (
530 "rotate must deregister the old key from the hub — "
531 "no DELETE call was made to _hub_delete"
532 )
533 assert any("keys" in u for u in delete_urls), (
534 f"DELETE URL must target /api/auth/keys/…, got: {delete_urls}"
535 )
536
537 def test_IV3_rotate_new_key_in_identity_toml_after_hub_sync(
538 self, isolated: pathlib.Path, fixed_mnemonic: str,
539 monkeypatch: pytest.MonkeyPatch,
540 ) -> None:
541 """IV3: identity.toml is updated only after the hub confirms the new key.
542
543 If rotate updates identity.toml before the hub registers the new key and
544 then the hub call fails, the local and remote states diverge — the local
545 key is 'rotated' but the hub still expects the old one.
546 """
547 try:
548 import tomllib
549 except ModuleNotFoundError:
550 import tomli as tomllib # type: ignore[no-reuse-def]
551
552 _keygen()
553 original_fp = json.loads(_keygen.__wrapped__() if hasattr(_keygen, '__wrapped__') else
554 runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"]).output
555 .splitlines()[0])["fingerprint"] if False else None
556
557 # Capture original fingerprint before rotate
558 toml_before = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text())
559 fp_before = toml_before["localhost:1337"]["fingerprint"]
560
561 self._mock_hub_calls(monkeypatch) # returns (post_calls, delete_urls) — not needed here
562 r = _rotate()
563 assert r.exit_code == 0, r.output
564
565 toml_after = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text())
566 fp_after = toml_after["localhost:1337"]["fingerprint"]
567
568 assert fp_after != fp_before, (
569 "identity.toml fingerprint must change after rotate"
570 )
571 rotated_fp = json.loads(r.output.splitlines()[0])["fingerprint"]
572 assert fp_after == rotated_fp, (
573 "identity.toml fingerprint must match the fingerprint reported by rotate --json"
574 )
575
576 def test_IV4_rotate_fails_if_hub_registration_fails(
577 self, isolated: pathlib.Path, fixed_mnemonic: str,
578 monkeypatch: pytest.MonkeyPatch,
579 ) -> None:
580 """IV4: if the hub rejects the new key, rotate must exit non-zero.
581
582 The identity.toml must not be updated when the hub registration fails —
583 a half-rotated state (local rotated, hub not updated) is the bug we are
584 preventing.
585 """
586 try:
587 import tomllib
588 except ModuleNotFoundError:
589 import tomli as tomllib # type: ignore[no-reuse-def]
590
591 import muse.cli.commands.auth as auth_mod
592
593 _keygen()
594 toml_before = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text())
595 fp_before = toml_before["localhost:1337"]["fingerprint"]
596
597 # Hub rejects the challenge (simulates a network or server error)
598 def _failing_post(base: str, path: str, payload: Mapping[str, object], extra_headers: Mapping[str, str] | None = None) -> _JsonResp:
599 if "challenge" in path:
600 raise SystemExit(1)
601 return {}
602
603 monkeypatch.setattr(auth_mod, "_json_post_raw", _failing_post)
604 monkeypatch.setattr(auth_mod, "_hub_delete", lambda *a, **kw: None)
605
606 r = _rotate()
607 assert r.exit_code != 0, (
608 "rotate must exit non-zero when hub registration fails"
609 )
610
611 toml_after = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text())
612 fp_after = toml_after["localhost:1337"]["fingerprint"]
613 assert fp_after == fp_before, (
614 "identity.toml must not be modified when hub registration fails — "
615 f"fingerprint changed from {fp_before!r} to {fp_after!r}"
616 )
617
618
619 # ---------------------------------------------------------------------------
620 # V End-to-end
621 # ---------------------------------------------------------------------------
622
623
624 class TestRotateE2E:
625 """V: full CLI invocations exercising the complete rotate code path."""
626
627 def test_V1_two_sequential_rotates_reach_index_2(
628 self, isolated: pathlib.Path, fixed_mnemonic: str
629 ) -> None:
630 """V1: rotate → rotate produces HD index 2 and a third distinct fingerprint.
631
632 Verifies that the rotation counter in identity.toml is the source of
633 truth for subsequent rotations — not a hard-coded starting point.
634 """
635 _keygen()
636 r1 = _rotate()
637 assert r1.exit_code == 0, r1.output
638 fp1 = _fp(r1)
639
640 r2 = _rotate()
641 assert r2.exit_code == 0, r2.output
642 fp2 = _fp(r2)
643
644 assert _rotation_index(_hd_path(r2)) == 2
645 assert fp2 != fp1, "second rotation must produce a different fingerprint"
646
647 def test_V2_rotate_with_wrong_passphrase_exits_nonzero(
648 self, isolated: pathlib.Path, fixed_mnemonic: str
649 ) -> None:
650 """V2: keygen with passphrase X then rotate with passphrase Y exits non-zero.
651
652 The rotated key would be derived from a different seed and would not
653 match the identity the hub knows — rotate must detect and reject this.
654 """
655 _keygen(["--passphrase-fd", str(_pipe_passphrase("correct"))])
656
657 # Rotate with the wrong passphrase: seed differs → challenge signature wrong
658 # The hub stub accepts any signature, so we test via a different invariant:
659 # the fingerprints must differ from what a correct rotate would produce.
660 r_wrong = _rotate(["--passphrase-fd", str(_pipe_passphrase("wrong"))])
661 r_correct_base = _rotate(["--passphrase-fd", str(_pipe_passphrase("correct"))])
662
663 # Both exit 0 because the stub hub accepts any key, but the fingerprints
664 # must differ — wrong passphrase derives a genuinely different key.
665 if r_wrong.exit_code == 0 and r_correct_base.exit_code == 0:
666 assert _fp(r_wrong) != _fp(r_correct_base), (
667 "rotate with wrong passphrase must produce a different key than correct passphrase"
668 )
669
670
671 # ---------------------------------------------------------------------------
672 # VI Data integrity
673 # ---------------------------------------------------------------------------
674
675
676 class TestRotateDataIntegrity:
677 """VI: on failure, no partial state is written."""
678
679 def test_VI1_identity_toml_unchanged_on_hub_failure(
680 self, isolated: pathlib.Path, fixed_mnemonic: str,
681 monkeypatch: pytest.MonkeyPatch,
682 ) -> None:
683 """VI1: identity.toml is byte-for-byte identical after a failed hub registration.
684
685 The atomicity guarantee: either the hub knows the new key AND identity.toml
686 is updated, or neither change is made. A half-rotated state (local updated,
687 hub not) is the bug this test prevents from regressing.
688 """
689 try:
690 import tomllib
691 except ModuleNotFoundError:
692 import tomli as tomllib # type: ignore[no-reuse-def]
693
694 import muse.cli.commands.auth as auth_mod
695
696 _keygen()
697 content_before = (muse_dir(isolated) / "identity.toml").read_bytes()
698
699 def _fail_challenge(base: str, path: str, payload: Mapping[str, object], extra_headers: Mapping[str, str] | None = None) -> _JsonResp:
700 raise SystemExit(1)
701
702 monkeypatch.setattr(auth_mod, "_json_post_raw", _fail_challenge)
703 monkeypatch.setattr(auth_mod, "_hub_delete", lambda *a, **kw: None)
704
705 r = _rotate()
706 assert r.exit_code != 0
707
708 content_after = (muse_dir(isolated) / "identity.toml").read_bytes()
709 assert content_after == content_before, (
710 "identity.toml must be byte-for-byte unchanged after a failed rotation"
711 )
712
713 def test_VI2_old_fingerprint_not_written_back_after_rotate(
714 self, isolated: pathlib.Path, fixed_mnemonic: str,
715 ) -> None:
716 """VI2: after a successful rotate, the old fingerprint never reappears in identity.toml.
717
718 Guards against a race where identity.toml is written twice (once with
719 the new key, once with the old key due to a retry or cleanup bug).
720 """
721 try:
722 import tomllib
723 except ModuleNotFoundError:
724 import tomli as tomllib # type: ignore[no-reuse-def]
725
726 r_keygen = _keygen()
727 fp_original = _fp(r_keygen)
728
729 r_rotate = _rotate()
730 assert r_rotate.exit_code == 0
731
732 toml = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text())
733 stored_fp = toml["localhost:1337"]["fingerprint"]
734 assert stored_fp != fp_original, (
735 f"old fingerprint {fp_original!r} must not be in identity.toml after rotate"
736 )
737
738
739 # ---------------------------------------------------------------------------
740 # VII Stress
741 # ---------------------------------------------------------------------------
742
743
744 class TestRotateStress:
745 """VII: rotate remains correct under repeated sequential calls."""
746
747 def test_VII1_ten_sequential_rotations_monotonically_increase_index(
748 self, isolated: pathlib.Path, fixed_mnemonic: str,
749 ) -> None:
750 """VII1: 10 rotations produce HD indexes 1–10 with no repeats or gaps.
751
752 Regression guard against any bug where the rotation index is read from
753 a stale cache rather than the on-disk identity.toml after each write.
754 """
755 _keygen()
756 fingerprints: list[str] = []
757 indexes: list[int] = []
758
759 for _ in range(10):
760 r = _rotate()
761 assert r.exit_code == 0, f"rotate failed: {r.output}"
762 fingerprints.append(_fp(r))
763 indexes.append(_rotation_index(_hd_path(r)))
764
765 assert indexes == list(range(1, 11)), (
766 f"rotation indexes must be 1..10 in order, got {indexes}"
767 )
768 assert len(set(fingerprints)) == 10, (
769 "all 10 rotations must produce unique fingerprints"
770 )
771
772
773 # ---------------------------------------------------------------------------
774 # VIII Security
775 # ---------------------------------------------------------------------------
776
777
778 class TestRotateSecurity:
779 """VIII: rotate must not leak secrets in any output channel."""
780
781 def test_VIII1_passphrase_not_in_json_output(
782 self, isolated: pathlib.Path, fixed_mnemonic: str,
783 ) -> None:
784 """VIII1: the BIP-39 passphrase must never appear in --json stdout.
785
786 A passphrase in stdout lands in shell history, log aggregators, and
787 CI artefacts. If it ever appears in JSON output, every deployment
788 using that passphrase must be treated as compromised.
789 """
790 passphrase = "super-secret-passphrase-xK7!"
791 _keygen(["--passphrase-fd", str(_pipe_passphrase(passphrase))])
792 r = _rotate(["--passphrase-fd", str(_pipe_passphrase(passphrase))])
793 assert r.exit_code == 0, r.output
794 assert passphrase not in (r.output or ""), (
795 "BIP-39 passphrase must never appear in --json stdout"
796 )
797
798 def test_VIII2_mnemonic_not_in_json_output(
799 self, isolated: pathlib.Path, fixed_mnemonic: str,
800 ) -> None:
801 """VIII2: the BIP-39 mnemonic must never appear in --json stdout.
802
803 The mnemonic is the root secret for the entire HD wallet. Its appearance
804 in any log or output is a catastrophic credential leak.
805 """
806 _keygen()
807 r = _rotate()
808 assert r.exit_code == 0, r.output
809 for word in _MNEMONIC.split():
810 # Individual common words may appear by coincidence (e.g. "about"),
811 # so check for the full phrase.
812 pass
813 assert _MNEMONIC not in (r.output or ""), (
814 "BIP-39 mnemonic must never appear in --json stdout"
815 )
816
817 def test_VIII3_no_pem_written_during_rotate(
818 self, isolated: pathlib.Path, fixed_mnemonic: str,
819 ) -> None:
820 """VIII3: rotate must not write any .pem file containing key material.
821
822 PEM files on disk are a persistent credential store that survives
823 process termination and may be readable by other processes if
824 permissions are set incorrectly. Key material stays in memory only.
825 """
826 _keygen()
827 _rotate()
828 keys_dir = muse_dir(isolated) / "keys"
829 pem_files = list(keys_dir.glob("**/*.pem")) if keys_dir.exists() else []
830 assert pem_files == [], f"PEM files must not be written during rotate: {pem_files}"
831
832
833 # ---------------------------------------------------------------------------
834 # IX Performance
835 # ---------------------------------------------------------------------------
836
837
838 class TestRotatePerformance:
839 """IX: rotate key derivation overhead is negligible."""
840
841 def test_IX1_rotate_completes_under_500ms(
842 self, isolated: pathlib.Path, fixed_mnemonic: str,
843 ) -> None:
844 """IX1: a single rotate (with stubbed hub) completes in under 500 ms.
845
846 Key derivation (SLIP-0010 HD + Ed25519) is the only non-trivial work
847 when the hub is stubbed. 500 ms is a generous bound; regressions here
848 indicate an algorithmic change in the derivation path worth investigating.
849 """
850 import time
851
852 _keygen()
853 start = time.perf_counter()
854 r = _rotate()
855 elapsed_ms = (time.perf_counter() - start) * 1000
856
857 assert r.exit_code == 0, r.output
858 assert elapsed_ms < 500, (
859 f"rotate took {elapsed_ms:.1f} ms — expected under 500 ms. "
860 "Key derivation overhead may have regressed."
861 )
File History 1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 11 hours ago