gabriel / muse public
test_auth_key_list_delete.py python
437 lines 18.1 KB
Raw
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 1 day ago
1 """Tests for ``muse auth key list`` / ``muse auth key delete`` (musehub#221 follow-up).
2
3 These commands expose the pre-existing hub key-management endpoints
4 (``GET``/``DELETE /api/auth/keys/{handle}[/{key_id}]``) as a proper CLI
5 resource, following the same ``<noun> <verb>`` convention as
6 ``muse hub label``/``muse hub webhook`` -- and give a durable way to clean
7 up leftover pre-hub-scoping keys after ``muse migrate hub-scoping``, since
8 the migration's own best-effort deregistration can fail (e.g. the old
9 key's key_id lookup gap fixed alongside this) and identity.toml no longer
10 has the old fingerprint on hand once migration has completed.
11 """
12
13 from __future__ import annotations
14
15 import json
16 import pathlib
17
18 import pytest
19
20 from tests.cli_test_helper import CliRunner
21 from muse.core import keypair as kp_module
22 from muse.core import identity as id_module
23 from muse.core.paths import muse_dir
24
25 runner = CliRunner()
26
27 _HUB = "https://localhost:1337"
28 _MNEMONIC = (
29 "abandon abandon abandon abandon abandon abandon abandon abandon "
30 "abandon abandon abandon about"
31 )
32
33
34 @pytest.fixture()
35 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
36 fake_home = tmp_path / "home"
37 fake_home.mkdir(parents=True, exist_ok=True)
38 fake_muse = muse_dir(fake_home)
39 fake_muse.mkdir(parents=True, exist_ok=True)
40
41 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
42 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_muse / "keys")
43 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_muse)
44 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_muse / "identity.toml")
45 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
46
47 _kc: dict[str, str] = {}
48 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
49 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
50 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
51 monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None))
52
53 import muse.cli.commands.auth as _auth_mod
54 _challenge = {"challenge_token": "deadbeef" * 8, "is_new_key": True, "algorithm": "ed25519"}
55 _verify = {"handle": "gabriel", "identity_id": "id-123", "is_new_identity": False, "auth_method": "ed25519"}
56 monkeypatch.setattr(
57 _auth_mod, "_json_post_raw",
58 lambda base, path, payload, extra_headers=None: _challenge if "challenge" in path else _verify,
59 )
60 monkeypatch.setattr(_auth_mod, "_hub_delete", lambda url, auth_header, ssl_ctx=None: None)
61 return fake_home
62
63
64 @pytest.fixture()
65 def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str:
66 from muse.core import bip39 as bip39_mod
67 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC)
68 return _MNEMONIC
69
70
71 def _keygen_and_register() -> None:
72 result = runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"])
73 assert result.exit_code == 0, f"keygen failed: {result.output}"
74 result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", "gabriel", "--json"])
75 assert result.exit_code == 0, f"register failed: {result.output}"
76
77
78 def _current_fingerprint() -> str:
79 from muse.core.identity import load_identity
80 entry = load_identity(_HUB)
81 assert entry is not None
82 return str(entry["fingerprint"])
83
84
85 class TestKeyList:
86 def test_list_returns_keys_from_hub(
87 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
88 ) -> None:
89 _keygen_and_register()
90 current_fp = _current_fingerprint()
91
92 seen = {}
93
94 def _fake_get(url: str, auth_header: str, ssl_ctx=None) -> dict:
95 seen["url"] = url
96 seen["auth_header"] = auth_header
97 return {"keys": [
98 {"key_id": "sha256:" + "1" * 64, "algorithm": "ed25519", "fingerprint": current_fp,
99 "label": "", "created_at": "2026-01-01T00:00:00Z", "last_used_at": None},
100 ]}
101
102 monkeypatch.setattr("muse.cli.commands.auth._hub_get", _fake_get)
103
104 result = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
105 assert result.exit_code == 0, result.output
106 data = json.loads(result.output)
107 assert len(data["keys"]) == 1
108 assert data["keys"][0]["fingerprint"] == current_fp
109 assert "gabriel" in seen["url"]
110 assert seen["auth_header"].startswith("MSign ")
111
112 def test_list_no_hub_errors(self, isolated: pathlib.Path, fixed_mnemonic: str) -> None:
113 result = runner.invoke(None, ["auth", "key", "list", "--json"])
114 assert result.exit_code != 0
115
116 def test_list_no_identity_errors(self, isolated: pathlib.Path, fixed_mnemonic: str) -> None:
117 result = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
118 assert result.exit_code != 0
119
120
121 class TestKeyDelete:
122 def test_delete_looks_up_key_id_by_fingerprint_and_revokes(
123 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
124 ) -> None:
125 _keygen_and_register()
126 current_fp = _current_fingerprint()
127 old_fp = "sha256:" + "a" * 64 # a different, "old" key -- not the active one
128 real_key_id = "sha256:" + "7" * 64
129
130 list_seen = {}
131 delete_seen = {}
132
133 def _fake_get(url: str, auth_header: str, ssl_ctx=None) -> dict:
134 list_seen["url"] = url
135 list_seen["auth_header"] = auth_header
136 return {"keys": [
137 {"key_id": real_key_id, "fingerprint": old_fp},
138 {"key_id": "sha256:" + "2" * 64, "fingerprint": current_fp},
139 ]}
140
141 def _fake_delete(url: str, auth_header: str, ssl_ctx=None) -> None:
142 delete_seen["url"] = url
143 delete_seen["auth_header"] = auth_header
144
145 monkeypatch.setattr("muse.cli.commands.auth._hub_get", _fake_get)
146 monkeypatch.setattr("muse.cli.commands.auth._hub_delete", _fake_delete)
147
148 result = runner.invoke(
149 None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", old_fp, "--json"]
150 )
151 assert result.exit_code == 0, result.output
152 data = json.loads(result.output)
153 assert data["revoked"] is True
154 assert data["key_id"] == real_key_id
155
156 import urllib.parse
157 assert urllib.parse.quote(real_key_id) in delete_seen["url"]
158 assert "gabriel" in delete_seen["url"]
159 assert delete_seen["auth_header"].startswith("MSign ")
160
161 def test_delete_refuses_current_active_key(
162 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
163 ) -> None:
164 """Deleting the fingerprint that's currently active in identity.toml would
165 strand the account with no working key from this client -- refuse outright,
166 never even contact the hub."""
167 _keygen_and_register()
168 current_fp = _current_fingerprint()
169
170 get_called = []
171 monkeypatch.setattr(
172 "muse.cli.commands.auth._hub_get",
173 lambda *a, **kw: get_called.append(1) or {"keys": []},
174 )
175
176 result = runner.invoke(
177 None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", current_fp, "--json"]
178 )
179 assert result.exit_code != 0
180 assert get_called == []
181
182 def test_delete_fingerprint_not_found_on_hub_errors(
183 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
184 ) -> None:
185 _keygen_and_register()
186 current_fp = _current_fingerprint()
187 delete_called = []
188
189 monkeypatch.setattr(
190 "muse.cli.commands.auth._hub_get",
191 lambda *a, **kw: {"keys": [{"key_id": "sha256:" + "3" * 64, "fingerprint": current_fp}]},
192 )
193 monkeypatch.setattr(
194 "muse.cli.commands.auth._hub_delete",
195 lambda *a, **kw: delete_called.append(1),
196 )
197
198 result = runner.invoke(
199 None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", "sha256:" + "f" * 64, "--json"]
200 )
201 assert result.exit_code != 0
202 assert delete_called == []
203
204 def test_delete_no_identity_errors(self, isolated: pathlib.Path, fixed_mnemonic: str) -> None:
205 result = runner.invoke(
206 None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", "sha256:" + "a" * 64, "--json"]
207 )
208 assert result.exit_code != 0
209
210
211 # ---------------------------------------------------------------------------
212 # Security
213 # ---------------------------------------------------------------------------
214
215
216 class TestKeySecurity:
217 """Mirrors test_auth_rotate.py's TestRotateSecurity for the key resource:
218 no secret leakage, no terminal-injection via hub-controlled strings, and
219 a bounded response size against a malicious/misbehaving hub."""
220
221 def test_mnemonic_not_in_list_json_output(
222 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
223 ) -> None:
224 _keygen_and_register()
225 monkeypatch.setattr("muse.cli.commands.auth._hub_get", lambda *a, **kw: {"keys": []})
226 result = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
227 assert result.exit_code == 0, result.output
228 assert _MNEMONIC not in (result.output or "")
229
230 def test_mnemonic_not_in_delete_json_output(
231 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
232 ) -> None:
233 _keygen_and_register()
234 old_fp = "sha256:" + "a" * 64
235 monkeypatch.setattr(
236 "muse.cli.commands.auth._hub_get",
237 lambda *a, **kw: {"keys": [{"key_id": "sha256:" + "7" * 64, "fingerprint": old_fp}]},
238 )
239 monkeypatch.setattr("muse.cli.commands.auth._hub_delete", lambda *a, **kw: None)
240 result = runner.invoke(
241 None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", old_fp, "--json"]
242 )
243 assert result.exit_code == 0, result.output
244 assert _MNEMONIC not in (result.output or "")
245
246 def test_no_pem_written_during_list_or_delete(
247 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
248 ) -> None:
249 """Keys are derived from the mnemonic at sign time -- neither command
250 should ever write PEM material to disk."""
251 _keygen_and_register()
252 old_fp = "sha256:" + "a" * 64
253 monkeypatch.setattr(
254 "muse.cli.commands.auth._hub_get",
255 lambda *a, **kw: {"keys": [{"key_id": "sha256:" + "7" * 64, "fingerprint": old_fp}]},
256 )
257 monkeypatch.setattr("muse.cli.commands.auth._hub_delete", lambda *a, **kw: None)
258
259 runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
260 runner.invoke(None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", old_fp, "--json"])
261
262 keys_dir = muse_dir(isolated) / "keys"
263 pem_files = list(keys_dir.glob("**/*.pem")) if keys_dir.exists() else []
264 assert pem_files == [], f"PEM files must not be written by key list/delete: {pem_files}"
265
266 def test_malicious_hub_response_size_is_bounded(
267 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
268 ) -> None:
269 """A hub (compromised, buggy, or MITM'd past TLS) that returns an
270 oversized response body must not be processed -- _hub_get enforces
271 the same 1 MiB bound as the rest of the HTTP layer (_json_post_raw)."""
272 _keygen_and_register()
273
274 import urllib.error
275
276 def _oversized_get(url, auth_header, ssl_ctx=None):
277 # Exercise the real _hub_get body-size check via urlopen, not a
278 # hand-rolled bypass -- monkeypatch urlopen's read() to return an
279 # oversized body and confirm _hub_get itself raises.
280 import io
281 import muse.cli.commands.auth as _auth_mod
282
283 class _FakeResp:
284 def read(self, n):
285 return b"x" * (_auth_mod._MAX_RESPONSE_BYTES + 1)
286 def __enter__(self):
287 return self
288 def __exit__(self, *a):
289 return False
290
291 monkeypatch.setattr(
292 "muse.cli.commands.auth.urllib.request.urlopen",
293 lambda req, timeout=10, context=None: _FakeResp(),
294 )
295 from muse.cli.commands.auth import _hub_get as _real_hub_get
296 return _real_hub_get(url, auth_header, ssl_ctx)
297
298 monkeypatch.setattr("muse.cli.commands.auth._hub_get", _oversized_get)
299
300 result = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
301 assert result.exit_code != 0, "oversized hub response must not be processed as valid JSON"
302
303 def test_ansi_escape_in_hub_label_is_stripped_from_terminal_output(
304 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
305 ) -> None:
306 """A hub-controlled field (label) containing an ANSI/OSC escape sequence
307 must never reach the terminal unsanitized in non-JSON output -- a
308 compromised or malicious hub could otherwise inject terminal escapes
309 into the operator's shell via a key label."""
310 _keygen_and_register()
311 current_fp = _current_fingerprint()
312 malicious_label = "\x1b]0;pwned\x07evil-label"
313
314 monkeypatch.setattr(
315 "muse.cli.commands.auth._hub_get",
316 lambda *a, **kw: {"keys": [
317 {"key_id": "sha256:" + "9" * 64, "fingerprint": current_fp, "label": malicious_label},
318 ]},
319 )
320
321 result = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB])
322 assert result.exit_code == 0, result.output
323 assert "\x1b" not in (result.output or "")
324 assert "\x07" not in (result.output or "")
325
326
327 # ---------------------------------------------------------------------------
328 # Performance
329 # ---------------------------------------------------------------------------
330
331
332 class TestKeyPerformance:
333 """Mirrors test_auth_rotate.py's TestRotatePerformance: derivation +
334 request-building overhead is negligible with the hub stubbed."""
335
336 def test_list_completes_under_500ms(
337 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
338 ) -> None:
339 import time
340
341 _keygen_and_register()
342 monkeypatch.setattr("muse.cli.commands.auth._hub_get", lambda *a, **kw: {"keys": []})
343
344 start = time.perf_counter()
345 result = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
346 elapsed_ms = (time.perf_counter() - start) * 1000
347
348 assert result.exit_code == 0, result.output
349 assert elapsed_ms < 500, f"key list took {elapsed_ms:.1f} ms — expected under 500 ms."
350
351 def test_delete_completes_under_500ms(
352 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
353 ) -> None:
354 import time
355
356 _keygen_and_register()
357 old_fp = "sha256:" + "a" * 64
358 monkeypatch.setattr(
359 "muse.cli.commands.auth._hub_get",
360 lambda *a, **kw: {"keys": [{"key_id": "sha256:" + "7" * 64, "fingerprint": old_fp}]},
361 )
362 monkeypatch.setattr("muse.cli.commands.auth._hub_delete", lambda *a, **kw: None)
363
364 start = time.perf_counter()
365 result = runner.invoke(
366 None, ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", old_fp, "--json"]
367 )
368 elapsed_ms = (time.perf_counter() - start) * 1000
369
370 assert result.exit_code == 0, result.output
371 assert elapsed_ms < 500, f"key delete took {elapsed_ms:.1f} ms — expected under 500 ms."
372
373
374 # ---------------------------------------------------------------------------
375 # Stress
376 # ---------------------------------------------------------------------------
377
378
379 class TestKeyStress:
380 """Mirrors test_auth_rotate.py's TestRotateStress: repeated calls remain
381 correct -- no state leakage or drift across repeated list/delete cycles."""
382
383 def test_repeated_list_calls_are_stable(
384 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
385 ) -> None:
386 _keygen_and_register()
387 fp = _current_fingerprint()
388 monkeypatch.setattr(
389 "muse.cli.commands.auth._hub_get",
390 lambda *a, **kw: {"keys": [{"key_id": "sha256:" + "1" * 64, "fingerprint": fp}]},
391 )
392
393 results = []
394 for _ in range(20):
395 r = runner.invoke(None, ["auth", "key", "list", "--hub", _HUB, "--json"])
396 assert r.exit_code == 0, r.output
397 results.append(json.loads(r.output))
398
399 # Compare only "keys" -- the envelope's timestamp/duration_ms legitimately
400 # differ per call.
401 assert all(r["keys"] == results[0]["keys"] for r in results), (
402 "repeated list calls must return the same key set"
403 )
404
405 def test_ten_sequential_deletes_of_distinct_fingerprints(
406 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
407 ) -> None:
408 """10 distinct orphaned keys, deleted one at a time, each correctly
409 matched to its own key_id -- guards against any accidental caching or
410 off-by-one in the fingerprint-to-key_id lookup across repeated calls."""
411 _keygen_and_register()
412 current_fp = _current_fingerprint()
413
414 orphans = [
415 {"key_id": f"sha256:{i:064d}", "fingerprint": f"sha256:{'a' * 63}{i}"}
416 for i in range(10)
417 ]
418 all_keys = orphans + [{"key_id": "sha256:" + "9" * 64, "fingerprint": current_fp}]
419
420 monkeypatch.setattr("muse.cli.commands.auth._hub_get", lambda *a, **kw: {"keys": all_keys})
421 deleted_key_ids = []
422 monkeypatch.setattr(
423 "muse.cli.commands.auth._hub_delete",
424 lambda url, auth_header, ssl_ctx=None: deleted_key_ids.append(url),
425 )
426
427 for orphan in orphans:
428 result = runner.invoke(
429 None,
430 ["auth", "key", "delete", "--hub", _HUB, "--fingerprint", orphan["fingerprint"], "--json"],
431 )
432 assert result.exit_code == 0, result.output
433 data = json.loads(result.output)
434 assert data["key_id"] == orphan["key_id"]
435
436 assert len(deleted_key_ids) == 10
437 assert len(set(deleted_key_ids)) == 10, "each delete must target a distinct key_id"
File History 1 commit
sha256:9a3bb190de5a18742792038aa709072a582ada8b223d5dfa4f72c70831ec3ba3 docs: revert migrate hub-scoping/domain-integers rows from … Sonnet 5 1 day ago