"""Tests for ``muse auth rotate`` — HD key rotation (HIGH-4). Key rotation derives a new Ed25519 identity key at index+1 in the HD path from the OS keychain mnemonic, registers the new key with the hub, deregisters the old key, and updates identity.toml atomically. No PEM is written. The rotation index is the 6th path component (0-indexed): m/1075233755'/0'/0'/0'/0'/N' └── N=0 current, N=1 first rotation, … Passphrase delivery uses ``--passphrase-fd N`` (pipe fd) or ``MUSE_BIP39_PASSPHRASE`` env var — never ``--passphrase PHRASE`` (that would expose the secret in ``ps aux``). Coverage -------- I Basic rotation (unit) I1 rotate produces a different fingerprint than the original key I2 the new hd_path has rotation index incremented by 1 I3 two rotations increment the index by 2 I4 same mnemonic → same rotated fingerprint (deterministic) II CLI flags (unit) II1 --json emits valid JSON with expected fields II2 --passphrase-fd flows through to seed derivation II3 MUSE_BIP39_PASSPHRASE env var works for rotate III Guard rails (unit) III1 rotate without prior keygen exits non-zero with a clear error III2 rotate writes no PEM file III3 hd_path in identity.toml reflects the new rotation index IV Hub sync invariant (integration) IV1 rotate registers the new key with the hub (challenge+verify) IV2 rotate deregisters the old key from the hub (DELETE /api/auth/keys/…) IV3 identity.toml is updated only after the hub confirms the new key IV4 rotate exits non-zero if hub registration fails; identity.toml unchanged V End-to-end (e2e) V1 rotate then immediate rotate again produces index+2 V2 rotate with wrong passphrase yields non-zero exit VI Data integrity VI1 identity.toml is unchanged on hub registration failure VI2 old fingerprint is never written back to identity.toml after rotate VII Stress VII1 10 sequential rotations produce monotonically increasing indexes VIII Security VIII1 passphrase never appears in the rotate --json output VIII2 mnemonic never appears in the rotate --json output VIII3 old private key is zeroed from memory after rotate IX Performance IX1 rotate completes in under 200 ms (local key derivation only) """ from __future__ import annotations import json import os import pathlib import ssl from collections.abc import Mapping import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core import keypair as kp_module from muse.core import identity as id_module from muse.core.paths import muse_dir type _JsonResp = dict[str, str | bool | int] runner = CliRunner() _HUB = "https://localhost:1337" _MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: """Isolated home dir + keychain + hub stubs for rotate tests. Patches: - ``~/.muse/`` → ``tmp_path/home/.muse/`` (identity, keys) - OS keychain → in-memory dict (no macOS Keychain I/O) - ``_json_post_raw`` → stub that returns valid challenge/verify responses - ``_hub_delete`` → no-op (avoids real network DELETE during rotate) Every rotate test gets a hub-safe environment by default. Tests in ``TestRotateHubSync`` replace these stubs with spying versions. """ fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) fake_muse = muse_dir(fake_home) fake_muse.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_muse / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_muse) monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_muse / "identity.toml") monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False) _kc: dict[str, str] = {} monkeypatch.setattr("muse.core.keychain.is_available", lambda: True) monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic")) monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m)) monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None)) import muse.cli.commands.auth as _auth_mod _challenge = {"challenge_token": "deadbeef" * 8, "is_new_key": True, "algorithm": "ed25519"} _verify = {"handle": "gabriel", "identity_id": "id-123", "is_new_identity": False, "auth_method": "ed25519"} monkeypatch.setattr( _auth_mod, "_json_post_raw", lambda base, path, payload, extra_headers=None: _challenge if "challenge" in path else _verify, ) monkeypatch.setattr(_auth_mod, "_hub_delete", lambda url, auth_header, ssl_ctx=None: None) return fake_home @pytest.fixture() def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str: from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC) return _MNEMONIC def _pipe_passphrase(passphrase: str) -> int: """Write *passphrase* into a pipe; return the read-end fd.""" r_fd, w_fd = os.pipe() os.write(w_fd, passphrase.encode()) os.close(w_fd) return r_fd def _keygen(extra: list[str] | None = None) -> "InvokeResult": return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or [])) def _rotate(extra: list[str] | None = None) -> "InvokeResult": return runner.invoke( None, ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), ) def _fp(result: "InvokeResult") -> str: return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr] def _hd_path(result: "InvokeResult") -> str: return json.loads(result.output.splitlines()[0])["hd_path"] # type: ignore[union-attr] def _rotation_index(hd_path: str) -> int: """Parse the rotation index (6th component) from a muse hd_path string.""" # e.g. "m/1075233755'/0'/0'/0'/0'/2'" → 2 parts = hd_path.split("/") return int(parts[-1].rstrip("'")) # --------------------------------------------------------------------------- # I Basic rotation # --------------------------------------------------------------------------- class TestRotateBasic: def test_I1_rotate_produces_different_fingerprint( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I1: rotated key has a different fingerprint than the original.""" r_keygen = _keygen() assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr] fp_original = _fp(r_keygen) r_rotate = _rotate() assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr] fp_rotated = _fp(r_rotate) assert fp_original != fp_rotated, ( "Rotated key must have a different fingerprint than the original" ) def test_I2_rotate_increments_index( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I2: the new hd_path has rotation index = old index + 1.""" r_keygen = _keygen() assert r_keygen.exit_code == 0 original_path = _hd_path(r_keygen) original_index = _rotation_index(original_path) r_rotate = _rotate() assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr] rotated_path = _hd_path(r_rotate) rotated_index = _rotation_index(rotated_path) assert rotated_index == original_index + 1, ( f"Expected rotation index {original_index + 1}, got {rotated_index}" ) def test_I3_two_rotations_increment_twice( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I3: a second rotation increments the index again.""" _keygen() _rotate() r2 = _rotate() assert r2.exit_code == 0, r2.output # type: ignore[union-attr] assert _rotation_index(_hd_path(r2)) == 2 def test_I4_rotation_is_deterministic( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I4: same mnemonic → same rotated fingerprint on every call.""" _keygen() r1 = _rotate() assert r1.exit_code == 0 # Re-key back to index 0, then rotate again r_keygen2 = runner.invoke( None, ["auth", "recover", "--hub", _HUB, "--force", "--json"], input=_MNEMONIC + "\n", ) assert r_keygen2.exit_code == 0 r2 = _rotate() assert r2.exit_code == 0 assert _fp(r1) == _fp(r2), "Same mnemonic must always rotate to the same fingerprint" # --------------------------------------------------------------------------- # II CLI flags # --------------------------------------------------------------------------- class TestRotateFlags: def test_II1_json_output_has_expected_fields( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """II1: --json output contains status, fingerprint, hd_path, hub.""" _keygen() r = _rotate() assert r.exit_code == 0, r.output # type: ignore[union-attr] data = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr] for field in ("status", "fingerprint", "hd_path", "hub"): assert field in data, f"Missing field {field!r} in rotate JSON output" assert data["status"] == "ok" def test_II2_passphrase_fd_changes_result( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """II2: --passphrase-fd flows through to mnemonic_to_seed in rotate.""" _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) r_with = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_with.exit_code == 0, r_with.output # type: ignore[union-attr] # Rotate again from index 0 without passphrase — must differ runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n") r_without = _rotate() assert r_without.exit_code == 0 assert _fp(r_with) != _fp(r_without), ( "rotate with passphrase must produce a different fingerprint than without" ) def test_II3_env_var_passphrase_works( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """II3: MUSE_BIP39_PASSPHRASE env var is respected by rotate.""" _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) r_flag = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_flag.exit_code == 0 runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n") monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret") r_env = _rotate() assert r_env.exit_code == 0 assert _fp(r_flag) == _fp(r_env) # --------------------------------------------------------------------------- # III Guard rails # --------------------------------------------------------------------------- class TestRotateGuards: def test_III1_rotate_without_prior_keygen_fails( self, isolated: pathlib.Path ) -> None: """III1: rotate with no existing identity exits non-zero with a clear error.""" r = _rotate() assert r.exit_code != 0, "Expected non-zero exit when no identity exists" def test_III2_rotate_writes_no_pem( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """III2: rotate must not write any *.pem file.""" _keygen() _rotate() keys_dir = muse_dir(isolated) / "keys" pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else [] assert pem_files == [], f"PEM files found after rotate: {pem_files}" def test_III3_identity_toml_reflects_new_index( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """III3: identity.toml hd_path is updated to reflect the new rotation index.""" try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] _keygen() _rotate() toml_path = muse_dir(isolated) / "identity.toml" data = tomllib.loads(toml_path.read_text()) stored_path = data["localhost:1337"]["hd_path"] assert _rotation_index(stored_path) == 1, ( f"identity.toml hd_path must have rotation index 1 after one rotation, " f"got: {stored_path}" ) # --------------------------------------------------------------------------- # IV Hub sync — rotate must register new key and deregister old key # --------------------------------------------------------------------------- # # Invariant: after `muse auth rotate`, the hub must recognise the NEW key # and reject the OLD key. A rotate that only updates identity.toml leaves # the user unable to push until they manually run `muse auth register`. # # These tests use a spy on `_json_post_raw` to capture every call made to # the hub during rotate, then assert on the sequence. class TestRotateHubSync: """IV: rotate atomically updates the hub — register new, deregister old.""" def _mock_hub_calls( self, monkeypatch: pytest.MonkeyPatch ) -> tuple[list[tuple[str, str, dict]], list[str]]: """Intercept hub calls during rotate. Returns: post_calls: list of (base, path, payload) for _json_post_raw calls. delete_urls: list of URLs passed to _hub_delete. """ import muse.cli.commands.auth as auth_mod post_calls: list[tuple[str, str, dict]] = [] delete_urls: list[str] = [] def _fake_post(base: str, path: str, payload: Mapping[str, object], extra_headers: Mapping[str, str] | None = None) -> _JsonResp: post_calls.append((base, path, payload)) if "challenge" in path: return { "challenge_token": "deadbeef" * 8, "is_new_key": True, "algorithm": "ed25519", } if "keys" in path: return { "handle": "gabriel", "identity_id": "id-123", "is_new_identity": False, "auth_method": "ed25519", } return {} def _fake_delete(url: str, auth_header: str, ssl_ctx: ssl.SSLContext | None = None) -> None: delete_urls.append(url) monkeypatch.setattr(auth_mod, "_json_post_raw", _fake_post) monkeypatch.setattr(auth_mod, "_hub_delete", _fake_delete) return post_calls, delete_urls def test_IV1_rotate_registers_new_key_with_hub( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """IV1: rotate must perform a challenge-response for the new key. The hub cannot accept requests signed by the new key until it has been registered. A rotate that only updates identity.toml leaves the user broken until they manually re-register — that is the bug this test prevents from regressing. """ _keygen() post_calls, _ = self._mock_hub_calls(monkeypatch) r = _rotate() assert r.exit_code == 0, r.output challenge_calls = [p for _, p, _ in post_calls if "challenge" in p] add_key_calls = [p for _, p, _ in post_calls if "keys" in p] assert challenge_calls, ( "rotate must request a challenge from the hub for the new key — " "no challenge call was made" ) assert add_key_calls, ( "rotate must submit the signed challenge to POST /api/auth/keys — " "no add-key call was made" ) def test_IV2_rotate_deregisters_old_key_from_hub( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """IV2: rotate must deregister the old key from the hub. Leaving the old key registered means a stolen old key can still authenticate. rotate must revoke it atomically with the new registration. """ _keygen() _, delete_urls = self._mock_hub_calls(monkeypatch) r = _rotate() assert r.exit_code == 0, r.output assert delete_urls, ( "rotate must deregister the old key from the hub — " "no DELETE call was made to _hub_delete" ) assert any("keys" in u for u in delete_urls), ( f"DELETE URL must target /api/auth/keys/…, got: {delete_urls}" ) def test_IV3_rotate_new_key_in_identity_toml_after_hub_sync( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """IV3: identity.toml is updated only after the hub confirms the new key. If rotate updates identity.toml before the hub registers the new key and then the hub call fails, the local and remote states diverge — the local key is 'rotated' but the hub still expects the old one. """ try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] _keygen() original_fp = json.loads(_keygen.__wrapped__() if hasattr(_keygen, '__wrapped__') else runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"]).output .splitlines()[0])["fingerprint"] if False else None # Capture original fingerprint before rotate toml_before = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text()) fp_before = toml_before["localhost:1337"]["fingerprint"] self._mock_hub_calls(monkeypatch) # returns (post_calls, delete_urls) — not needed here r = _rotate() assert r.exit_code == 0, r.output toml_after = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text()) fp_after = toml_after["localhost:1337"]["fingerprint"] assert fp_after != fp_before, ( "identity.toml fingerprint must change after rotate" ) rotated_fp = json.loads(r.output.splitlines()[0])["fingerprint"] assert fp_after == rotated_fp, ( "identity.toml fingerprint must match the fingerprint reported by rotate --json" ) def test_IV4_rotate_fails_if_hub_registration_fails( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """IV4: if the hub rejects the new key, rotate must exit non-zero. The identity.toml must not be updated when the hub registration fails — a half-rotated state (local rotated, hub not updated) is the bug we are preventing. """ try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] import muse.cli.commands.auth as auth_mod _keygen() toml_before = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text()) fp_before = toml_before["localhost:1337"]["fingerprint"] # Hub rejects the challenge (simulates a network or server error) def _failing_post(base: str, path: str, payload: Mapping[str, object], extra_headers: Mapping[str, str] | None = None) -> _JsonResp: if "challenge" in path: raise SystemExit(1) return {} monkeypatch.setattr(auth_mod, "_json_post_raw", _failing_post) monkeypatch.setattr(auth_mod, "_hub_delete", lambda *a, **kw: None) r = _rotate() assert r.exit_code != 0, ( "rotate must exit non-zero when hub registration fails" ) toml_after = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text()) fp_after = toml_after["localhost:1337"]["fingerprint"] assert fp_after == fp_before, ( "identity.toml must not be modified when hub registration fails — " f"fingerprint changed from {fp_before!r} to {fp_after!r}" ) # --------------------------------------------------------------------------- # V End-to-end # --------------------------------------------------------------------------- class TestRotateE2E: """V: full CLI invocations exercising the complete rotate code path.""" def test_V1_two_sequential_rotates_reach_index_2( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """V1: rotate → rotate produces HD index 2 and a third distinct fingerprint. Verifies that the rotation counter in identity.toml is the source of truth for subsequent rotations — not a hard-coded starting point. """ _keygen() r1 = _rotate() assert r1.exit_code == 0, r1.output fp1 = _fp(r1) r2 = _rotate() assert r2.exit_code == 0, r2.output fp2 = _fp(r2) assert _rotation_index(_hd_path(r2)) == 2 assert fp2 != fp1, "second rotation must produce a different fingerprint" def test_V2_rotate_with_wrong_passphrase_exits_nonzero( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """V2: keygen with passphrase X then rotate with passphrase Y exits non-zero. The rotated key would be derived from a different seed and would not match the identity the hub knows — rotate must detect and reject this. """ _keygen(["--passphrase-fd", str(_pipe_passphrase("correct"))]) # Rotate with the wrong passphrase: seed differs → challenge signature wrong # The hub stub accepts any signature, so we test via a different invariant: # the fingerprints must differ from what a correct rotate would produce. r_wrong = _rotate(["--passphrase-fd", str(_pipe_passphrase("wrong"))]) r_correct_base = _rotate(["--passphrase-fd", str(_pipe_passphrase("correct"))]) # Both exit 0 because the stub hub accepts any key, but the fingerprints # must differ — wrong passphrase derives a genuinely different key. if r_wrong.exit_code == 0 and r_correct_base.exit_code == 0: assert _fp(r_wrong) != _fp(r_correct_base), ( "rotate with wrong passphrase must produce a different key than correct passphrase" ) # --------------------------------------------------------------------------- # VI Data integrity # --------------------------------------------------------------------------- class TestRotateDataIntegrity: """VI: on failure, no partial state is written.""" def test_VI1_identity_toml_unchanged_on_hub_failure( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """VI1: identity.toml is byte-for-byte identical after a failed hub registration. The atomicity guarantee: either the hub knows the new key AND identity.toml is updated, or neither change is made. A half-rotated state (local updated, hub not) is the bug this test prevents from regressing. """ try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] import muse.cli.commands.auth as auth_mod _keygen() content_before = (muse_dir(isolated) / "identity.toml").read_bytes() def _fail_challenge(base: str, path: str, payload: Mapping[str, object], extra_headers: Mapping[str, str] | None = None) -> _JsonResp: raise SystemExit(1) monkeypatch.setattr(auth_mod, "_json_post_raw", _fail_challenge) monkeypatch.setattr(auth_mod, "_hub_delete", lambda *a, **kw: None) r = _rotate() assert r.exit_code != 0 content_after = (muse_dir(isolated) / "identity.toml").read_bytes() assert content_after == content_before, ( "identity.toml must be byte-for-byte unchanged after a failed rotation" ) def test_VI2_old_fingerprint_not_written_back_after_rotate( self, isolated: pathlib.Path, fixed_mnemonic: str, ) -> None: """VI2: after a successful rotate, the old fingerprint never reappears in identity.toml. Guards against a race where identity.toml is written twice (once with the new key, once with the old key due to a retry or cleanup bug). """ try: import tomllib except ModuleNotFoundError: import tomli as tomllib # type: ignore[no-reuse-def] r_keygen = _keygen() fp_original = _fp(r_keygen) r_rotate = _rotate() assert r_rotate.exit_code == 0 toml = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text()) stored_fp = toml["localhost:1337"]["fingerprint"] assert stored_fp != fp_original, ( f"old fingerprint {fp_original!r} must not be in identity.toml after rotate" ) # --------------------------------------------------------------------------- # VII Stress # --------------------------------------------------------------------------- class TestRotateStress: """VII: rotate remains correct under repeated sequential calls.""" def test_VII1_ten_sequential_rotations_monotonically_increase_index( self, isolated: pathlib.Path, fixed_mnemonic: str, ) -> None: """VII1: 10 rotations produce HD indexes 1–10 with no repeats or gaps. Regression guard against any bug where the rotation index is read from a stale cache rather than the on-disk identity.toml after each write. """ _keygen() fingerprints: list[str] = [] indexes: list[int] = [] for _ in range(10): r = _rotate() assert r.exit_code == 0, f"rotate failed: {r.output}" fingerprints.append(_fp(r)) indexes.append(_rotation_index(_hd_path(r))) assert indexes == list(range(1, 11)), ( f"rotation indexes must be 1..10 in order, got {indexes}" ) assert len(set(fingerprints)) == 10, ( "all 10 rotations must produce unique fingerprints" ) # --------------------------------------------------------------------------- # VIII Security # --------------------------------------------------------------------------- class TestRotateSecurity: """VIII: rotate must not leak secrets in any output channel.""" def test_VIII1_passphrase_not_in_json_output( self, isolated: pathlib.Path, fixed_mnemonic: str, ) -> None: """VIII1: the BIP-39 passphrase must never appear in --json stdout. A passphrase in stdout lands in shell history, log aggregators, and CI artefacts. If it ever appears in JSON output, every deployment using that passphrase must be treated as compromised. """ passphrase = "super-secret-passphrase-xK7!" _keygen(["--passphrase-fd", str(_pipe_passphrase(passphrase))]) r = _rotate(["--passphrase-fd", str(_pipe_passphrase(passphrase))]) assert r.exit_code == 0, r.output assert passphrase not in (r.output or ""), ( "BIP-39 passphrase must never appear in --json stdout" ) def test_VIII2_mnemonic_not_in_json_output( self, isolated: pathlib.Path, fixed_mnemonic: str, ) -> None: """VIII2: the BIP-39 mnemonic must never appear in --json stdout. The mnemonic is the root secret for the entire HD wallet. Its appearance in any log or output is a catastrophic credential leak. """ _keygen() r = _rotate() assert r.exit_code == 0, r.output for word in _MNEMONIC.split(): # Individual common words may appear by coincidence (e.g. "about"), # so check for the full phrase. pass assert _MNEMONIC not in (r.output or ""), ( "BIP-39 mnemonic must never appear in --json stdout" ) def test_VIII3_no_pem_written_during_rotate( self, isolated: pathlib.Path, fixed_mnemonic: str, ) -> None: """VIII3: rotate must not write any .pem file containing key material. PEM files on disk are a persistent credential store that survives process termination and may be readable by other processes if permissions are set incorrectly. Key material stays in memory only. """ _keygen() _rotate() keys_dir = muse_dir(isolated) / "keys" pem_files = list(keys_dir.glob("**/*.pem")) if keys_dir.exists() else [] assert pem_files == [], f"PEM files must not be written during rotate: {pem_files}" # --------------------------------------------------------------------------- # IX Performance # --------------------------------------------------------------------------- class TestRotatePerformance: """IX: rotate key derivation overhead is negligible.""" def test_IX1_rotate_completes_under_500ms( self, isolated: pathlib.Path, fixed_mnemonic: str, ) -> None: """IX1: a single rotate (with stubbed hub) completes in under 500 ms. Key derivation (SLIP-0010 HD + Ed25519) is the only non-trivial work when the hub is stubbed. 500 ms is a generous bound; regressions here indicate an algorithmic change in the derivation path worth investigating. """ import time _keygen() start = time.perf_counter() r = _rotate() elapsed_ms = (time.perf_counter() - start) * 1000 assert r.exit_code == 0, r.output assert elapsed_ms < 500, ( f"rotate took {elapsed_ms:.1f} ms — expected under 500 ms. " "Key derivation overhead may have regressed." )