gabriel / muse public
test_auth_logout_keygen_integrity.py python
251 lines 10.3 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago
1 """Data-integrity tests for the auth logout → keygen → push cycle.
2
3 Bug
4 ---
5 ``muse auth logout --hub X`` removes the entire identity entry.
6 ``muse auth keygen --hub X`` then writes a new entry with ``handle = ""``.
7 Subsequent pushes fail 401 because the MSign Authorization header carries
8 an empty handle that the server cannot match.
9
10 Expected invariants
11 -------------------
12 I1. ``keygen --force`` on an existing entry preserves the registered handle.
13 I2. After ``logout`` + ``keygen``, ``register --handle H`` restores the handle
14 (idempotent: server returns "already registered" but client still writes it).
15 I3. After full restore (logout → keygen → register), ``resolve_signing_identity``
16 returns the original handle so push can sign successfully.
17 """
18
19 from __future__ import annotations
20
21 import pathlib
22 from collections.abc import Mapping
23
24 import pytest
25 import tomllib
26
27 from tests.cli_test_helper import CliRunner
28 import muse.core.keypair as kp_module
29 import muse.core.identity as id_module
30 from muse.core.types import long_id
31
32 type _KcDict = dict[str, str]
33 type _ChallengeResp = dict[str, bool | str]
34 type _VerifyResp = dict[str, str | bool]
35
36 runner = CliRunner()
37
38 _FIXED_MNEMONIC = (
39 "abandon abandon abandon abandon abandon abandon abandon abandon "
40 "abandon abandon abandon about"
41 )
42 _HUB = "https://localhost:1337"
43 _HOSTNAME = "localhost:1337"
44 _HANDLE = "gabriel"
45
46
47 # ---------------------------------------------------------------------------
48 # Shared helpers
49 # ---------------------------------------------------------------------------
50
51 def _patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
52 fake_home = tmp_path / "home"
53 fake_home.mkdir(parents=True, exist_ok=True)
54 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
55 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
56 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
57 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
58 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
59 return fake_home
60
61
62 def _patch_keychain(monkeypatch: pytest.MonkeyPatch) -> _KcDict:
63 kc: dict[str, str] = {"mnemonic": _FIXED_MNEMONIC}
64 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
65 monkeypatch.setattr("muse.core.keychain.store", lambda m: kc.__setitem__("mnemonic", m))
66 monkeypatch.setattr("muse.core.keychain.load", lambda: kc.get("mnemonic"))
67 return kc
68
69
70 def _run_keygen(monkeypatch: pytest.MonkeyPatch, force: bool = False) -> None:
71 import muse.core.bip39 as bip39_mod
72 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _FIXED_MNEMONIC)
73 cmd = ["auth", "keygen", "--hub", _HUB]
74 if force:
75 cmd.append("--force")
76 result = runner.invoke(None, cmd)
77 assert result.exit_code == 0, f"keygen failed: {result.output}\n{result.stderr}"
78
79
80 def _run_logout(monkeypatch: pytest.MonkeyPatch) -> None:
81 result = runner.invoke(None, ["auth", "logout", "--hub", _HUB])
82 assert result.exit_code == 0, f"logout failed: {result.output}"
83
84
85 def _seed_identity_with_handle(handle: str = _HANDLE) -> None:
86 """Write a complete identity entry (including handle) into the toml file."""
87 from muse.core.bip39 import mnemonic_to_seed
88 from muse.core.keypair import derive_hd_public_info
89 from muse.core.hdkeys import muse_path, DOMAIN_IDENTITY, ENTITY_HUMAN
90
91 seed = mnemonic_to_seed(_FIXED_MNEMONIC)
92 pub_b64, fingerprint = derive_hd_public_info(seed)
93 hd_path_str = muse_path(DOMAIN_IDENTITY, ENTITY_HUMAN)
94 entry = {
95 "type": "human",
96 "handle": handle,
97 "algorithm": "ed25519",
98 "fingerprint": fingerprint,
99 "hd_path": hd_path_str,
100 }
101 id_module.save_identity(_HUB, entry, mnemonic=_FIXED_MNEMONIC)
102
103
104 # ---------------------------------------------------------------------------
105 # I1 — keygen --force preserves the registered handle
106 # ---------------------------------------------------------------------------
107
108 class TestKeygenForcePreservesHandle:
109 """I1: When --force is used on an existing entry, the handle must survive."""
110
111 def test_force_keygen_preserves_handle(
112 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
113 ) -> None:
114 _patch_home(monkeypatch, tmp_path)
115 _patch_keychain(monkeypatch)
116
117 # Pre-condition: a complete identity with handle exists.
118 _seed_identity_with_handle(_HANDLE)
119 pre = tomllib.loads(id_module._IDENTITY_FILE.read_text())
120 assert pre[_HOSTNAME].get("handle") == _HANDLE
121
122 # Act: re-key with --force (same mnemonic, same key material).
123 _run_keygen(monkeypatch, force=True)
124
125 # Assert: handle must be preserved in the new entry.
126 post = tomllib.loads(id_module._IDENTITY_FILE.read_text())
127 entry = post[_HOSTNAME]
128 assert entry.get("handle") == _HANDLE, (
129 f"keygen --force erased the registered handle. Entry: {entry}"
130 )
131
132 def test_force_keygen_still_updates_fingerprint(
133 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
134 ) -> None:
135 """Sanity check: --force updates key material, just not the handle."""
136 _patch_home(monkeypatch, tmp_path)
137 _patch_keychain(monkeypatch)
138
139 _seed_identity_with_handle(_HANDLE)
140 _run_keygen(monkeypatch, force=True)
141
142 post = tomllib.loads(id_module._IDENTITY_FILE.read_text())
143 entry = post[_HOSTNAME]
144 assert "fingerprint" in entry
145 assert "hd_path" in entry
146 assert entry.get("handle") == _HANDLE
147
148
149 # ---------------------------------------------------------------------------
150 # I2 — logout → keygen → register restores handle
151 # ---------------------------------------------------------------------------
152
153 class TestLogoutKeygenRegisterRestoresHandle:
154 """I2: The logout → keygen → register cycle must end with a valid handle."""
155
156 def _fake_challenge_resp(self) -> _ChallengeResp:
157 return {"challenge_token": "ab" * 32, "is_new_key": False}
158
159 def _fake_verify_resp(self, handle: str = _HANDLE) -> _VerifyResp:
160 return {
161 "handle": handle,
162 "identity_id": long_id("a" * 64),
163 "is_new_identity": False,
164 }
165
166 def test_register_after_logout_keygen_writes_handle(
167 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
168 ) -> None:
169 _patch_home(monkeypatch, tmp_path)
170 _patch_keychain(monkeypatch)
171
172 # Start from a complete identity.
173 _seed_identity_with_handle(_HANDLE)
174
175 # Logout wipes the entry.
176 _run_logout(monkeypatch)
177
178 # keygen creates an entry without a handle.
179 _run_keygen(monkeypatch)
180
181 mid = tomllib.loads(id_module._IDENTITY_FILE.read_text())
182 assert mid[_HOSTNAME].get("handle", "") == "", "precondition: handle is empty after logout+keygen"
183
184 # Register (mocked server returns the handle).
185 monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: self._fake_challenge_resp())
186 monkeypatch.setattr("muse.cli.commands.auth._post_verify", lambda *a, **kw: self._fake_verify_resp())
187 result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", _HANDLE])
188 assert result.exit_code == 0, f"register failed: {result.output}\n{result.stderr}"
189
190 post = tomllib.loads(id_module._IDENTITY_FILE.read_text())
191 entry = post[_HOSTNAME]
192 assert entry.get("handle") == _HANDLE, (
193 f"handle not restored after logout+keygen+register. Entry: {entry}"
194 )
195
196 def test_register_idempotent_when_key_already_on_server(
197 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
198 ) -> None:
199 """Server says key is known (isNewKey=False) — register must succeed and write handle."""
200 _patch_home(monkeypatch, tmp_path)
201 _patch_keychain(monkeypatch)
202
203 _seed_identity_with_handle(_HANDLE)
204 _run_logout(monkeypatch)
205 _run_keygen(monkeypatch)
206
207 # Server returns isNewKey=False — the key is already registered.
208 monkeypatch.setattr("muse.cli.commands.auth._post_challenge",
209 lambda *a, **kw: {"challenge_token": "cd" * 32, "is_new_key": False})
210 monkeypatch.setattr("muse.cli.commands.auth._post_verify",
211 lambda *a, **kw: {"handle": _HANDLE, "identity_id": long_id("b" * 64), "is_new_identity": False})
212
213 result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", _HANDLE])
214 assert result.exit_code == 0, f"idempotent register failed: {result.output}"
215
216 post = tomllib.loads(id_module._IDENTITY_FILE.read_text())
217 assert post[_HOSTNAME].get("handle") == _HANDLE
218
219
220 # ---------------------------------------------------------------------------
221 # I3 — full restore: resolve_signing_identity returns handle after cycle
222 # ---------------------------------------------------------------------------
223
224 class TestFullRestoreRoundTrip:
225 """I3: resolve_signing_identity must return the correct handle after the full cycle."""
226
227 def test_resolve_signing_identity_after_logout_keygen_register(
228 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
229 ) -> None:
230 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
231 from muse.core.identity import resolve_signing_identity
232
233 _patch_home(monkeypatch, tmp_path)
234 _patch_keychain(monkeypatch)
235
236 _seed_identity_with_handle(_HANDLE)
237 _run_logout(monkeypatch)
238 _run_keygen(monkeypatch)
239
240 monkeypatch.setattr("muse.cli.commands.auth._post_challenge",
241 lambda *a, **kw: {"challenge_token": "ef" * 32, "is_new_key": False})
242 monkeypatch.setattr("muse.cli.commands.auth._post_verify",
243 lambda *a, **kw: {"handle": _HANDLE, "identity_id": long_id("c" * 64), "is_new_identity": False})
244 result = runner.invoke(None, ["auth", "register", "--hub", _HUB, "--handle", _HANDLE])
245 assert result.exit_code == 0
246
247 signing = resolve_signing_identity(_HUB)
248 assert signing is not None, "resolve_signing_identity returned None after restore"
249 handle, private_key = signing
250 assert handle == _HANDLE, f"expected handle '{_HANDLE}', got '{handle}'"
251 assert isinstance(private_key, Ed25519PrivateKey)
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 7 days ago