"""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, and updates identity.toml. No PEM is written. The operator then re-registers with the hub using the new fingerprint. 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 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 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 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 """ from __future__ import annotations import json import os import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core import keypair as kp_module from muse.core import identity as id_module 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: fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".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)) 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): return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or [])) def _rotate(extra: list[str] | None = None): return runner.invoke( None, ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), ) def _fp(result) -> str: return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr] def _hd_path(result) -> 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 = isolated / ".muse" / "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 = isolated / ".muse" / "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}" )