gabriel / muse public
test_auth_keygen_mnemonic_guard.py python
404 lines 15.3 KB
Raw
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago
1 """Tests for the mnemonic-destruction guard in ``muse auth keygen``.
2
3 Background
4 ----------
5 ``muse auth keygen --force`` used to silently generate fresh entropy and
6 overwrite the existing OS keychain mnemonic. Losing the mnemonic means
7 permanent, irrecoverable loss of every key ever derived from it — all
8 registered hub identities become unrecoverable.
9
10 Fix
11 ---
12 ``--force`` now only overwrites the identity *entry* in identity.toml.
13 Destroying the mnemonic requires the explicit ``--destroy-mnemonic`` flag
14 *in addition to* ``--force``. Neither flag alone is sufficient.
15
16 Coverage
17 --------
18 I Unit — mnemonic reuse / guard
19 I1 keygen with no existing mnemonic generates fresh entropy (baseline)
20 I2 keygen --force with existing mnemonic reuses it (no destruction)
21 I3 keygen --destroy-mnemonic without --force exits non-zero (blocked by
22 the "existing identity" guard before reaching the mnemonic guard)
23 I4 keygen --destroy-mnemonic --force generates fresh entropy (escape hatch)
24 I5 keygen --force fingerprint is stable (same mnemonic → same key)
25 I6 keygen --destroy-mnemonic --force fingerprint changes (new entropy)
26
27 II CLI output / flags
28 II1 --force alone emits "reused from keychain" in output
29 II2 --destroy-mnemonic --force emits "generated and saved to keychain"
30
31 III Guard message quality
32 III1 --destroy-mnemonic without --force mentions --force in the error
33 III2 --force alone never prints "destroy" or "overwrite" in mnemonic context
34
35 IV Data integrity
36 IV1 identity.toml fingerprint unchanged after --force (mnemonic reused)
37 IV2 identity.toml fingerprint changes after --destroy-mnemonic --force
38 IV3 keychain mnemonic unchanged after --force
39 IV4 keychain mnemonic changes after --destroy-mnemonic --force
40 """
41
42 from __future__ import annotations
43
44 import pathlib
45 from typing import Generator
46 from unittest.mock import patch
47
48 import pytest
49
50 from collections.abc import Mapping
51
52 from tests.cli_test_helper import CliRunner, InvokeResult
53 from muse.core import keypair as kp_module
54 from muse.core import identity as id_module
55 from muse.core.paths import muse_dir
56
57 runner = CliRunner()
58
59 _HUB = "https://localhost:1337"
60 _HOSTNAME = "localhost:1337"
61
62 _MNEMONIC_A = (
63 "abandon abandon abandon abandon abandon abandon abandon abandon "
64 "abandon abandon abandon about"
65 )
66 _MNEMONIC_B = (
67 "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
68 )
69
70
71 # ---------------------------------------------------------------------------
72 # Fixtures
73 # ---------------------------------------------------------------------------
74
75
76 @pytest.fixture()
77 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
78 """Isolated home dir + keychain + hub stubs for keygen tests.
79
80 Patches:
81 - ``~/.muse/`` → ``tmp_path/home/.muse/``
82 - OS keychain → in-memory dict (no macOS Keychain I/O)
83 - ``_json_post_raw`` → stub returning valid challenge/verify responses
84 - ``_hub_delete`` → no-op
85 - ``muse.core.bip39.generate_mnemonic`` → returns _MNEMONIC_B by default
86 (tests that need the real generator can override this)
87 """
88 fake_home = tmp_path / "home"
89 fake_home.mkdir(parents=True, exist_ok=True)
90 fake_muse = muse_dir(fake_home)
91 fake_muse.mkdir(parents=True, exist_ok=True)
92
93 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
94 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_muse / "keys")
95 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_muse)
96 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_muse / "identity.toml")
97 monkeypatch.setattr("muse.cli.commands.auth._stderr_isatty", lambda: False)
98
99 _kc: dict[str, str] = {}
100 monkeypatch.setattr("muse.core.keychain.is_available", lambda: True)
101 monkeypatch.setattr("muse.core.keychain.load", lambda: _kc.get("mnemonic"))
102 monkeypatch.setattr("muse.core.keychain.store", lambda m: _kc.__setitem__("mnemonic", m))
103 monkeypatch.setattr("muse.core.keychain.delete", lambda: _kc.pop("mnemonic", None))
104
105 # generate_mnemonic → deterministic fresh entropy (distinct from _MNEMONIC_A)
106 import muse.core.bip39 as _bip39
107 monkeypatch.setattr(_bip39, "generate_mnemonic", lambda **kw: _MNEMONIC_B)
108
109 import muse.cli.commands.auth as _auth_mod
110 _challenge = {"challenge_token": "deadbeef" * 16, "is_new_key": True, "algorithm": "ed25519"}
111 _verify = {"handle": "gabriel", "identity_id": "id-123", "is_new_identity": True, "auth_method": "ed25519"}
112 monkeypatch.setattr(
113 _auth_mod, "_json_post_raw",
114 lambda base, path, payload, extra_headers=None: _challenge if "challenge" in path else _verify,
115 )
116 monkeypatch.setattr(_auth_mod, "_hub_delete", lambda url, auth_header, ssl_ctx=None: None)
117
118 return fake_home
119
120
121 def _keygen(*extra_args: str) -> InvokeResult:
122 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json", *extra_args])
123
124
125 def _get_kc(monkeypatch_kc_dict: Mapping[str, str]) -> str | None: # pragma: no cover
126 return monkeypatch_kc_dict.get("mnemonic")
127
128
129 # ---------------------------------------------------------------------------
130 # I Unit — mnemonic reuse / guard
131 # ---------------------------------------------------------------------------
132
133
134 class TestMnemonicGuardUnit:
135 def test_I1_no_existing_mnemonic_generates_fresh(
136 self, isolated: pathlib.Path,
137 ) -> None:
138 """I1: first keygen with no keychain mnemonic always generates fresh entropy."""
139 r = _keygen()
140 assert r.exit_code == 0, r.output
141 try:
142 import tomllib
143 except ModuleNotFoundError:
144 import tomli as tomllib # type: ignore[no-reuse-def]
145 toml = tomllib.loads((muse_dir(isolated) / "identity.toml").read_text())
146 assert _HOSTNAME in toml
147 # The stub generate_mnemonic returns _MNEMONIC_B so the key should be
148 # derived from that, not _MNEMONIC_A.
149 assert toml[_HOSTNAME]["fingerprint"] # non-empty
150
151 def test_I2_force_with_existing_mnemonic_reuses_it(
152 self, isolated: pathlib.Path,
153 ) -> None:
154 """I2: --force with an existing keychain mnemonic must NOT generate new entropy."""
155 # Seed keychain with mnemonic A.
156 import muse.core.keychain as _kc_mod
157 _kc_mod.store(_MNEMONIC_A)
158
159 r_first = _keygen()
160 assert r_first.exit_code == 0, r_first.output
161 fp_first = __import__("json").loads(r_first.output)["fingerprint"]
162
163 r_force = _keygen("--force")
164 assert r_force.exit_code == 0, r_force.output
165 fp_force = __import__("json").loads(r_force.output)["fingerprint"]
166
167 assert fp_first == fp_force, (
168 "--force must reuse the existing mnemonic — fingerprint must not change"
169 )
170 assert _kc_mod.load() == _MNEMONIC_A, (
171 "--force must not overwrite the keychain mnemonic"
172 )
173
174 def test_I3_destroy_mnemonic_without_force_is_blocked(
175 self, isolated: pathlib.Path,
176 ) -> None:
177 """I3: --destroy-mnemonic without --force is rejected at the identity guard.
178
179 The identity guard fires first (before the mnemonic guard) when an
180 existing identity is present. Either way, the exit code must be non-zero
181 and the mnemonic must be untouched.
182 """
183 import muse.core.keychain as _kc_mod
184 _kc_mod.store(_MNEMONIC_A)
185 _keygen() # establish an identity entry
186
187 r = _keygen("--destroy-mnemonic")
188 assert r.exit_code != 0, (
189 "--destroy-mnemonic without --force must exit non-zero"
190 )
191 assert _kc_mod.load() == _MNEMONIC_A, (
192 "keychain mnemonic must be untouched when blocked"
193 )
194
195 def test_I4_destroy_mnemonic_and_force_generates_fresh(
196 self, isolated: pathlib.Path,
197 ) -> None:
198 """I4: --destroy-mnemonic --force together must generate fresh entropy."""
199 import muse.core.keychain as _kc_mod
200 _kc_mod.store(_MNEMONIC_A)
201 _keygen() # establish identity with mnemonic A
202
203 r = _keygen("--force", "--destroy-mnemonic")
204 assert r.exit_code == 0, r.output
205
206 new_mnemonic = _kc_mod.load()
207 assert new_mnemonic != _MNEMONIC_A, (
208 "--destroy-mnemonic --force must generate fresh entropy, "
209 "not reuse the existing mnemonic"
210 )
211 assert new_mnemonic == _MNEMONIC_B, (
212 "fresh mnemonic must be what generate_mnemonic() returned"
213 )
214
215 def test_I5_force_fingerprint_stable(
216 self, isolated: pathlib.Path,
217 ) -> None:
218 """I5: --force produces the same fingerprint on every call (mnemonic reused)."""
219 import muse.core.keychain as _kc_mod
220 _kc_mod.store(_MNEMONIC_A)
221
222 r1 = _keygen()
223 r2 = _keygen("--force")
224 r3 = _keygen("--force")
225
226 fp1 = __import__("json").loads(r1.output)["fingerprint"]
227 fp2 = __import__("json").loads(r2.output)["fingerprint"]
228 fp3 = __import__("json").loads(r3.output)["fingerprint"]
229 assert fp1 == fp2 == fp3, "--force must produce the same key every time"
230
231 def test_I6_destroy_mnemonic_force_fingerprint_changes(
232 self, isolated: pathlib.Path,
233 ) -> None:
234 """I6: --destroy-mnemonic --force must produce a different fingerprint."""
235 import muse.core.keychain as _kc_mod
236 _kc_mod.store(_MNEMONIC_A)
237
238 r_before = _keygen()
239 fp_before = __import__("json").loads(r_before.output)["fingerprint"]
240
241 r_destroy = _keygen("--force", "--destroy-mnemonic")
242 assert r_destroy.exit_code == 0, r_destroy.output
243 fp_after = __import__("json").loads(r_destroy.output)["fingerprint"]
244
245 assert fp_before != fp_after, (
246 "--destroy-mnemonic --force must derive a new key from fresh entropy"
247 )
248
249
250 # ---------------------------------------------------------------------------
251 # II CLI output / flags
252 # ---------------------------------------------------------------------------
253
254
255 class TestMnemonicGuardOutput:
256 def test_II1_force_reports_reused(
257 self, isolated: pathlib.Path,
258 ) -> None:
259 """II1: --force with existing mnemonic reports 'reused from keychain' on stderr."""
260 import muse.core.keychain as _kc_mod
261 _kc_mod.store(_MNEMONIC_A)
262 _keygen()
263
264 r = _keygen("--force")
265 assert r.exit_code == 0, r.output
266 assert "already stored in your OS keychain" in r.stderr, (
267 "--force must report on stderr that the existing mnemonic was reused"
268 )
269
270 def test_II2_destroy_mnemonic_force_reports_generated(
271 self, isolated: pathlib.Path,
272 ) -> None:
273 """II2: --destroy-mnemonic --force reports 'generated and saved to keychain' on stderr."""
274 import muse.core.keychain as _kc_mod
275 _kc_mod.store(_MNEMONIC_A)
276 _keygen()
277
278 r = _keygen("--force", "--destroy-mnemonic")
279 assert r.exit_code == 0, r.output
280 assert "generated and stored in your OS keychain" in r.stderr, (
281 "--destroy-mnemonic --force must report on stderr that new entropy was generated"
282 )
283
284
285 # ---------------------------------------------------------------------------
286 # III Guard message quality
287 # ---------------------------------------------------------------------------
288
289
290 class TestMnemonicGuardMessages:
291 def test_III1_destroy_mnemonic_no_force_mentions_force(
292 self, isolated: pathlib.Path,
293 ) -> None:
294 """III1: the error when --destroy-mnemonic is missing --force must mention --force."""
295 import muse.core.keychain as _kc_mod
296 _kc_mod.store(_MNEMONIC_A)
297 _keygen()
298
299 r = _keygen("--destroy-mnemonic")
300 assert r.exit_code != 0
301 combined = r.output + r.stderr
302 assert "--force" in combined, (
303 "error message must mention --force so the user knows the escape hatch"
304 )
305
306 def test_III2_force_output_never_says_destroyed(
307 self, isolated: pathlib.Path,
308 ) -> None:
309 """III2: --force alone must not print anything implying the mnemonic was changed."""
310 import muse.core.keychain as _kc_mod
311 _kc_mod.store(_MNEMONIC_A)
312 _keygen()
313
314 r = _keygen("--force")
315 assert r.exit_code == 0, r.output
316 for forbidden in ("destroy", "overwrit", "generat"):
317 assert forbidden not in r.output.lower() or "reused" in r.output.lower(), (
318 f"--force output must not imply mnemonic was destroyed (found '{forbidden}')"
319 )
320
321
322 # ---------------------------------------------------------------------------
323 # IV Data integrity
324 # ---------------------------------------------------------------------------
325
326
327 class TestMnemonicGuardDataIntegrity:
328 def test_IV1_identity_toml_fingerprint_unchanged_after_force(
329 self, isolated: pathlib.Path,
330 ) -> None:
331 """IV1: identity.toml fingerprint must not change when --force reuses mnemonic."""
332 import muse.core.keychain as _kc_mod
333 _kc_mod.store(_MNEMONIC_A)
334 _keygen()
335 try:
336 import tomllib
337 except ModuleNotFoundError:
338 import tomli as tomllib # type: ignore[no-reuse-def]
339 fp_before = tomllib.loads(
340 (muse_dir(isolated) / "identity.toml").read_text()
341 )[_HOSTNAME]["fingerprint"]
342
343 _keygen("--force")
344
345 fp_after = tomllib.loads(
346 (muse_dir(isolated) / "identity.toml").read_text()
347 )[_HOSTNAME]["fingerprint"]
348 assert fp_after == fp_before, (
349 "--force must not change identity.toml fingerprint when mnemonic is reused"
350 )
351
352 def test_IV2_identity_toml_fingerprint_changes_after_destroy(
353 self, isolated: pathlib.Path,
354 ) -> None:
355 """IV2: identity.toml fingerprint must change after --destroy-mnemonic --force."""
356 import muse.core.keychain as _kc_mod
357 _kc_mod.store(_MNEMONIC_A)
358 _keygen()
359 try:
360 import tomllib
361 except ModuleNotFoundError:
362 import tomli as tomllib # type: ignore[no-reuse-def]
363 fp_before = tomllib.loads(
364 (muse_dir(isolated) / "identity.toml").read_text()
365 )[_HOSTNAME]["fingerprint"]
366
367 _keygen("--force", "--destroy-mnemonic")
368
369 fp_after = tomllib.loads(
370 (muse_dir(isolated) / "identity.toml").read_text()
371 )[_HOSTNAME]["fingerprint"]
372 assert fp_after != fp_before, (
373 "--destroy-mnemonic --force must write a new fingerprint to identity.toml"
374 )
375
376 def test_IV3_keychain_mnemonic_unchanged_after_force(
377 self, isolated: pathlib.Path,
378 ) -> None:
379 """IV3: keychain mnemonic must be byte-for-byte identical after --force."""
380 import muse.core.keychain as _kc_mod
381 _kc_mod.store(_MNEMONIC_A)
382 _keygen()
383
384 _keygen("--force")
385
386 assert _kc_mod.load() == _MNEMONIC_A, (
387 "--force must not modify the keychain mnemonic"
388 )
389
390 def test_IV4_keychain_mnemonic_changes_after_destroy_force(
391 self, isolated: pathlib.Path,
392 ) -> None:
393 """IV4: keychain mnemonic must be replaced after --destroy-mnemonic --force."""
394 import muse.core.keychain as _kc_mod
395 _kc_mod.store(_MNEMONIC_A)
396 _keygen()
397
398 _keygen("--force", "--destroy-mnemonic")
399
400 new_mnemonic = _kc_mod.load()
401 assert new_mnemonic is not None, "mnemonic must be written to keychain"
402 assert new_mnemonic != _MNEMONIC_A, (
403 "--destroy-mnemonic --force must replace the mnemonic in the keychain"
404 )
File History 1 commit
sha256:2eaa5d95f9d9383498e76947410a26e5a3ba23d182f339910c424cf88fad412b fix: try fetch/presign before fetch/mpack to avoid Cloudfla… Sonnet 4.6 patch 6 days ago