gabriel / muse public
test_auth_rotate.py python
283 lines 10.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days 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, and updates identity.toml. No PEM is written.
5 The operator then re-registers with the hub using the new fingerprint.
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
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
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
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
34 from __future__ import annotations
35
36 import json
37 import os
38 import pathlib
39
40 import pytest
41
42 from tests.cli_test_helper import CliRunner
43 from muse.core import keypair as kp_module
44 from muse.core import identity as id_module
45
46 runner = CliRunner()
47
48 _HUB = "https://localhost:1337"
49 _MNEMONIC = (
50 "abandon abandon abandon abandon abandon abandon abandon abandon "
51 "abandon abandon abandon about"
52 )
53
54
55 # ---------------------------------------------------------------------------
56 # Fixtures
57 # ---------------------------------------------------------------------------
58
59
60 @pytest.fixture()
61 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
62 fake_home = tmp_path / "home"
63 fake_home.mkdir(parents=True, exist_ok=True)
64 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
65 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
66 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
67 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
68 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
69 _kc: dict[str, str] = {}
70 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
71 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
72 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
73 monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None))
74 return fake_home
75
76
77 @pytest.fixture()
78 def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str:
79 from muse.core import bip39 as bip39_mod
80 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC)
81 return _MNEMONIC
82
83
84 def _pipe_passphrase(passphrase: str) -> int:
85 """Write *passphrase* into a pipe; return the read-end fd."""
86 r_fd, w_fd = os.pipe()
87 os.write(w_fd, passphrase.encode())
88 os.close(w_fd)
89 return r_fd
90
91
92 def _keygen(extra: list[str] | None = None):
93 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or []))
94
95
96 def _rotate(extra: list[str] | None = None):
97 return runner.invoke(
98 None,
99 ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []),
100 )
101
102
103 def _fp(result) -> str:
104 return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr]
105
106
107 def _hd_path(result) -> str:
108 return json.loads(result.output.splitlines()[0])["hd_path"] # type: ignore[union-attr]
109
110
111 def _rotation_index(hd_path: str) -> int:
112 """Parse the rotation index (6th component) from a muse hd_path string."""
113 # e.g. "m/1075233755'/0'/0'/0'/0'/2'" → 2
114 parts = hd_path.split("/")
115 return int(parts[-1].rstrip("'"))
116
117
118 # ---------------------------------------------------------------------------
119 # I Basic rotation
120 # ---------------------------------------------------------------------------
121
122
123 class TestRotateBasic:
124 def test_I1_rotate_produces_different_fingerprint(
125 self, isolated: pathlib.Path, fixed_mnemonic: str
126 ) -> None:
127 """I1: rotated key has a different fingerprint than the original."""
128 r_keygen = _keygen()
129 assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr]
130 fp_original = _fp(r_keygen)
131
132 r_rotate = _rotate()
133 assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr]
134 fp_rotated = _fp(r_rotate)
135
136 assert fp_original != fp_rotated, (
137 "Rotated key must have a different fingerprint than the original"
138 )
139
140 def test_I2_rotate_increments_index(
141 self, isolated: pathlib.Path, fixed_mnemonic: str
142 ) -> None:
143 """I2: the new hd_path has rotation index = old index + 1."""
144 r_keygen = _keygen()
145 assert r_keygen.exit_code == 0
146 original_path = _hd_path(r_keygen)
147 original_index = _rotation_index(original_path)
148
149 r_rotate = _rotate()
150 assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr]
151 rotated_path = _hd_path(r_rotate)
152 rotated_index = _rotation_index(rotated_path)
153
154 assert rotated_index == original_index + 1, (
155 f"Expected rotation index {original_index + 1}, got {rotated_index}"
156 )
157
158 def test_I3_two_rotations_increment_twice(
159 self, isolated: pathlib.Path, fixed_mnemonic: str
160 ) -> None:
161 """I3: a second rotation increments the index again."""
162 _keygen()
163 _rotate()
164 r2 = _rotate()
165 assert r2.exit_code == 0, r2.output # type: ignore[union-attr]
166 assert _rotation_index(_hd_path(r2)) == 2
167
168 def test_I4_rotation_is_deterministic(
169 self, isolated: pathlib.Path, fixed_mnemonic: str
170 ) -> None:
171 """I4: same mnemonic → same rotated fingerprint on every call."""
172 _keygen()
173 r1 = _rotate()
174 assert r1.exit_code == 0
175
176 # Re-key back to index 0, then rotate again
177 r_keygen2 = runner.invoke(
178 None,
179 ["auth", "recover", "--hub", _HUB, "--force", "--json"],
180 input=_MNEMONIC + "\n",
181 )
182 assert r_keygen2.exit_code == 0
183
184 r2 = _rotate()
185 assert r2.exit_code == 0
186
187 assert _fp(r1) == _fp(r2), "Same mnemonic must always rotate to the same fingerprint"
188
189
190 # ---------------------------------------------------------------------------
191 # II CLI flags
192 # ---------------------------------------------------------------------------
193
194
195 class TestRotateFlags:
196 def test_II1_json_output_has_expected_fields(
197 self, isolated: pathlib.Path, fixed_mnemonic: str
198 ) -> None:
199 """II1: --json output contains status, fingerprint, hd_path, hub."""
200 _keygen()
201 r = _rotate()
202 assert r.exit_code == 0, r.output # type: ignore[union-attr]
203 data = json.loads(r.output.splitlines()[0]) # type: ignore[union-attr]
204 for field in ("status", "fingerprint", "hd_path", "hub"):
205 assert field in data, f"Missing field {field!r} in rotate JSON output"
206 assert data["status"] == "ok"
207
208 def test_II2_passphrase_fd_changes_result(
209 self, isolated: pathlib.Path, fixed_mnemonic: str
210 ) -> None:
211 """II2: --passphrase-fd flows through to mnemonic_to_seed in rotate."""
212 _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
213 r_with = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))])
214 assert r_with.exit_code == 0, r_with.output # type: ignore[union-attr]
215
216 # Rotate again from index 0 without passphrase — must differ
217 runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n")
218 r_without = _rotate()
219 assert r_without.exit_code == 0
220
221 assert _fp(r_with) != _fp(r_without), (
222 "rotate with passphrase must produce a different fingerprint than without"
223 )
224
225 def test_II3_env_var_passphrase_works(
226 self, isolated: pathlib.Path, fixed_mnemonic: str,
227 monkeypatch: pytest.MonkeyPatch,
228 ) -> None:
229 """II3: MUSE_BIP39_PASSPHRASE env var is respected by rotate."""
230 _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
231 r_flag = _rotate(["--passphrase-fd", str(_pipe_passphrase("secret"))])
232 assert r_flag.exit_code == 0
233
234 runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force"], input=_MNEMONIC + "\n")
235 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret")
236 r_env = _rotate()
237 assert r_env.exit_code == 0
238
239 assert _fp(r_flag) == _fp(r_env)
240
241
242 # ---------------------------------------------------------------------------
243 # III Guard rails
244 # ---------------------------------------------------------------------------
245
246
247 class TestRotateGuards:
248 def test_III1_rotate_without_prior_keygen_fails(
249 self, isolated: pathlib.Path
250 ) -> None:
251 """III1: rotate with no existing identity exits non-zero with a clear error."""
252 r = _rotate()
253 assert r.exit_code != 0, "Expected non-zero exit when no identity exists"
254
255 def test_III2_rotate_writes_no_pem(
256 self, isolated: pathlib.Path, fixed_mnemonic: str
257 ) -> None:
258 """III2: rotate must not write any *.pem file."""
259 _keygen()
260 _rotate()
261 keys_dir = isolated / ".muse" / "keys"
262 pem_files = list(keys_dir.glob("*.pem")) if keys_dir.exists() else []
263 assert pem_files == [], f"PEM files found after rotate: {pem_files}"
264
265 def test_III3_identity_toml_reflects_new_index(
266 self, isolated: pathlib.Path, fixed_mnemonic: str
267 ) -> None:
268 """III3: identity.toml hd_path is updated to reflect the new rotation index."""
269 try:
270 import tomllib
271 except ModuleNotFoundError:
272 import tomli as tomllib # type: ignore[no-reuse-def]
273
274 _keygen()
275 _rotate()
276
277 toml_path = isolated / ".muse" / "identity.toml"
278 data = tomllib.loads(toml_path.read_text())
279 stored_path = data["localhost:1337"]["hd_path"]
280 assert _rotation_index(stored_path) == 1, (
281 f"identity.toml hd_path must have rotation index 1 after one rotation, "
282 f"got: {stored_path}"
283 )
File History 2 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago