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