gabriel / muse public
test_cmd_auth_keygen_register.py python
380 lines 15.7 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """Tests for ``muse auth keygen`` and ``muse auth register``.
2
3 Covers:
4 - keygen: key generation, file permissions, --force, duplicate key rejection
5 - keygen: public key / fingerprint format
6 - register: full challenge-response flow with a mocked hub
7 - register: token storage in identity.toml
8 - register: error paths (missing key, network errors, bad challenge token)
9 - keypair module: sign / verify round-trip, key loading
10 """
11 from __future__ import annotations
12
13 import base64
14 import hashlib
15 import json
16 import pathlib
17 import stat
18 import unittest.mock
19 import urllib.error
20 import urllib.request
21 import types
22 from typing import TypedDict
23
24 import pytest
25 from tests.cli_test_helper import CliRunner
26
27 from muse.core import keypair as kp_module
28 from muse.core.store import JsonValue
29 from muse.core._types import Manifest
30
31 type _AuthPayload = dict[str, str | None]
32 type _JsonResponse = dict[str, JsonValue]
33
34
35 class _ChallengeResp(TypedDict, total=False):
36 challengeToken: str
37 isNewKey: bool
38 algorithm: str
39
40
41 class _VerifyResp(TypedDict, total=False):
42 token: str
43 handle: str
44 identityId: str
45 isNewIdentity: bool
46 authMethod: str
47
48 cli = None
49 runner = CliRunner()
50
51
52 # ---------------------------------------------------------------------------
53 # Helpers
54 # ---------------------------------------------------------------------------
55
56
57 def _env(tmp_home: pathlib.Path) -> Manifest:
58 """Environment that redirects ~/.muse to a temp directory."""
59 fake_home = tmp_home / "home"
60 fake_home.mkdir(parents=True, exist_ok=True)
61 return {"HOME": str(fake_home)}
62
63
64 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
65 """Redirect pathlib.Path.home() to a temp dir for this test."""
66 fake_home = tmp_path / "home"
67 fake_home.mkdir(parents=True, exist_ok=True)
68 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
69 # Also redirect the module-level constants
70 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
71 from muse.core import identity as id_module
72 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
73 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
74 return fake_home
75
76
77 # ---------------------------------------------------------------------------
78 # keypair module unit tests
79 # All keys use generate_hd_keypair — no random (JBOK) mode exists any more.
80 # Tests use distinct deterministic seeds to produce consistent but unique keys.
81 # ---------------------------------------------------------------------------
82
83 # Fixed test seeds — deterministic, unique per test scenario.
84 _SEED_A = b"\x01" * 64 # example.com / general use
85 _SEED_B = b"\x02" * 64 # sign-test
86 _SEED_C = b"\x03" * 64 # fp-test
87 _SEED_D = b"\x04" * 64 # second key for overwrite test
88
89
90 class TestKeypairModule:
91 def test_generate_and_load_roundtrip(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
92 _patch_home(monkeypatch, tmp_path)
93 pub_b64, fingerprint = kp_module.generate_hd_keypair("example.com", _SEED_A)
94 # Fingerprint is 64 hex chars
95 assert len(fingerprint) == 64
96 assert all(c in "0123456789abcdef" for c in fingerprint)
97 # Public key is base64url without padding
98 assert "=" not in pub_b64
99 # Load the key back
100 private_key = kp_module.load_private_key("example.com")
101 assert private_key is not None
102 # Derived public key must match
103 reloaded_pub = kp_module.public_key_to_b64url(private_key.public_key())
104 assert reloaded_pub == pub_b64
105
106 def test_key_file_permissions_0o600(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
107 _patch_home(monkeypatch, tmp_path)
108 kp_module.generate_hd_keypair("example.com", _SEED_A)
109 key_path = kp_module.key_path_for("example.com")
110 mode = stat.S_IMODE(key_path.stat().st_mode)
111 assert mode == 0o600
112
113 def test_keys_dir_permissions_0o700(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
114 _patch_home(monkeypatch, tmp_path)
115 kp_module.generate_hd_keypair("example.com", _SEED_A)
116 keys_dir = kp_module._KEYS_DIR
117 mode = stat.S_IMODE(keys_dir.stat().st_mode)
118 assert mode == 0o700
119
120 def test_load_nonexistent_key_returns_none(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
121 _patch_home(monkeypatch, tmp_path)
122 assert kp_module.load_private_key("no.such.host") is None
123
124 def test_sign_verify_roundtrip(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
125 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
126 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
127
128 _patch_home(monkeypatch, tmp_path)
129 kp_module.generate_hd_keypair("sign-test.example", _SEED_B)
130 private_key = kp_module.load_private_key("sign-test.example")
131 assert private_key is not None
132
133 nonce = b"\x01\x02\x03" * 10
134 sig_b64 = kp_module.sign_bytes(private_key, nonce)
135 # Verify the signature independently
136 sig_bytes = base64.urlsafe_b64decode(sig_b64 + "==")
137 public_key: Ed25519PublicKey = private_key.public_key()
138 public_key.verify(sig_bytes, nonce) # does not raise
139
140 def test_fingerprint_matches_sha256(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
141 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
142
143 _patch_home(monkeypatch, tmp_path)
144 kp_module.generate_hd_keypair("fp-test.example", _SEED_C)
145 private_key = kp_module.load_private_key("fp-test.example")
146 assert private_key is not None
147 public_key = private_key.public_key()
148 raw = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
149 expected = hashlib.sha256(raw).hexdigest()
150 assert kp_module.public_key_fingerprint(public_key) == expected
151
152 def test_hostname_sanitisation_colon_port(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
153 _patch_home(monkeypatch, tmp_path)
154 kp_module.generate_hd_keypair("localhost:10003", _SEED_A)
155 path = kp_module.key_path_for("localhost:10003")
156 # No colon in filename
157 assert ":" not in path.name
158 assert path.exists()
159
160 def test_generate_overwrites_existing_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
161 _patch_home(monkeypatch, tmp_path)
162 pub1, _ = kp_module.generate_hd_keypair("overwrite.test", _SEED_A)
163 pub2, _ = kp_module.generate_hd_keypair("overwrite.test", _SEED_D)
164 # Keys must differ (different seeds → different HD derivations)
165 assert pub1 != pub2
166
167
168 # ---------------------------------------------------------------------------
169 # muse auth keygen CLI tests
170 # ---------------------------------------------------------------------------
171
172
173 class TestAuthKeygenCLI:
174 HUB = "http://localhost:10003"
175
176 def test_generates_key_successfully(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
177 _patch_home(monkeypatch, tmp_path)
178 result = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
179 assert result.exit_code == 0
180 assert "Ed25519 keypair generated" in result.output
181 assert "Private key:" in result.output
182 assert "Fingerprint" in result.output
183
184 def test_key_file_created(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
185 _patch_home(monkeypatch, tmp_path)
186 runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
187 key_path = kp_module.key_path_for("localhost:10003")
188 assert key_path.exists()
189
190 def test_force_flag_overwrites(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
191 _patch_home(monkeypatch, tmp_path)
192 runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
193 r1 = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB], catch_exceptions=False)
194 # Without --force, second keygen should fail
195 assert r1.exit_code != 0
196 r2 = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB, "--force"], catch_exceptions=False)
197 assert r2.exit_code == 0
198
199 def test_no_hub_fails(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
200 _patch_home(monkeypatch, tmp_path)
201 # chdir to a directory with no hub config so get_hub_url(None) returns None.
202 # MUSE_REPO_ROOT only affects find_repo_root(), not get_hub_url().
203 monkeypatch.chdir(tmp_path)
204 result = runner.invoke(cli, ["auth", "keygen"])
205 assert result.exit_code != 0
206
207 def test_label_shown_in_output(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None:
208 _patch_home(monkeypatch, tmp_path)
209 result = runner.invoke(cli, ["auth", "keygen", "--hub", self.HUB, "--label", "My Laptop"],
210 catch_exceptions=False)
211 assert result.exit_code == 0
212 assert "My Laptop" in result.output
213
214
215 # ---------------------------------------------------------------------------
216 # muse auth register CLI tests (hub is mocked)
217 # ---------------------------------------------------------------------------
218
219
220 class TestAuthRegisterCLI:
221 HUB = "http://localhost:10003"
222
223 def _setup_key(self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> tuple[str, str]:
224 _patch_home(monkeypatch, tmp_path)
225 pub_b64, fingerprint = kp_module.generate_hd_keypair("localhost:10003", _SEED_A)
226 return pub_b64, fingerprint
227
228 def _mock_hub(
229 self,
230 monkeypatch: pytest.MonkeyPatch,
231 nonce_hex: str,
232 challenge_resp: _ChallengeResp | None = None,
233 verify_resp: _VerifyResp | None = None,
234 ) -> None:
235 """Patch urllib.request.urlopen to simulate hub challenge-response."""
236 _challenge_resp = challenge_resp or {
237 "challengeToken": nonce_hex,
238 "isNewKey": True,
239 "algorithm": "ed25519",
240 }
241 _verify_resp = verify_resp or {
242 "handle": "alice",
243 "identityId": "id-123",
244 "isNewIdentity": True,
245 "authMethod": "ed25519",
246 }
247 call_count = 0
248
249 class FakeResponse:
250 def __init__(self, data: bytes) -> None:
251 self._data = data
252
253 def read(self, n: int = -1) -> bytes:
254 return self._data[:n] if n >= 0 else self._data
255
256 def __enter__(self) -> "FakeResponse":
257 return self
258
259 def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None) -> None:
260 pass
261
262 def fake_urlopen(req: urllib.request.Request, timeout: int = 30) -> FakeResponse:
263 nonlocal call_count
264 call_count += 1
265 if call_count == 1:
266 return FakeResponse(json.dumps(_challenge_resp).encode())
267 return FakeResponse(json.dumps(_verify_resp).encode())
268
269 import muse.cli.commands.auth as auth_mod
270 monkeypatch.setattr(auth_mod, "_json_post_raw", lambda base, path, payload: _challenge_resp if "challenge" in path else _verify_resp)
271
272 def test_full_registration_stores_identity(
273 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
274 ) -> None:
275 self._setup_key(monkeypatch, tmp_path)
276 import secrets
277 nonce_hex = secrets.token_hex(32)
278 self._mock_hub(monkeypatch, nonce_hex)
279
280 result = runner.invoke(
281 cli,
282 ["auth", "register", "--hub", self.HUB, "--handle", "alice"],
283 catch_exceptions=False,
284 )
285 assert result.exit_code == 0
286 assert "alice" in result.output
287
288 # Ed25519 identity must be persisted
289 from muse.core.identity import load_identity
290 entry = load_identity(self.HUB)
291 assert entry is not None
292 assert entry.get("handle") == "alice"
293 assert entry.get("key_path") is not None
294 assert "token" not in entry
295
296 def test_no_key_fails_gracefully(
297 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
298 ) -> None:
299 _patch_home(monkeypatch, tmp_path)
300 result = runner.invoke(
301 cli,
302 ["auth", "register", "--hub", self.HUB, "--handle", "alice"],
303 )
304 assert result.exit_code != 0
305 assert "keygen" in result.output.lower() or "no ed25519 key" in result.output.lower()
306
307 def test_new_key_without_handle_fails(
308 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
309 ) -> None:
310 self._setup_key(monkeypatch, tmp_path)
311 import muse.cli.commands.auth as auth_mod
312
313 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
314 if "challenge" in path:
315 return {
316 "challengeToken": "ab" * 32,
317 "isNewKey": True,
318 "algorithm": "ed25519",
319 }
320 return {} # should not reach here
321
322 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
323 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB])
324 assert result.exit_code != 0
325 assert "--handle" in result.output
326
327 def test_agent_flag_marks_identity_as_agent(
328 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
329 ) -> None:
330 self._setup_key(monkeypatch, tmp_path)
331 import muse.cli.commands.auth as auth_mod
332 import secrets
333
334 nonce_hex = secrets.token_hex(32)
335
336 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
337 if "challenge" in path:
338 return {"challengeToken": nonce_hex, "isNewKey": False, "algorithm": "ed25519"}
339 return {"handle": "bot", "identityId": "id-bot", "isNewIdentity": False, "authMethod": "ed25519"}
340
341 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
342 runner.invoke(cli, ["auth", "register", "--hub", self.HUB, "--handle", "bot", "--agent"], catch_exceptions=False)
343
344 from muse.core.identity import load_identity
345 entry = load_identity(self.HUB)
346 assert entry is not None
347 assert entry.get("type") == "agent"
348
349 def test_bad_challenge_token_fails(
350 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
351 ) -> None:
352 self._setup_key(monkeypatch, tmp_path)
353 import muse.cli.commands.auth as auth_mod
354
355 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
356 return {"challengeToken": "not-valid-hex!", "isNewKey": False, "algorithm": "ed25519"}
357
358 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
359 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB, "--handle", "alice"])
360 assert result.exit_code != 0
361
362 def test_empty_verify_response_fails(
363 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
364 ) -> None:
365 """Verify response with no handle and no --handle fallback fails."""
366 self._setup_key(monkeypatch, tmp_path)
367 import muse.cli.commands.auth as auth_mod
368 import secrets
369
370 nonce_hex = secrets.token_hex(32)
371
372 def fake_json_post(base: str, path: str, payload: _AuthPayload) -> _JsonResponse:
373 if "challenge" in path:
374 return {"challengeToken": nonce_hex, "isNewKey": False, "algorithm": "ed25519"}
375 return {} # No handle field
376
377 monkeypatch.setattr(auth_mod, "_json_post_raw", fake_json_post)
378 # No --handle flag, and hub returns no handle → must fail
379 result = runner.invoke(cli, ["auth", "register", "--hub", self.HUB])
380 assert result.exit_code != 0
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago