test_auth_keygen_mnemonic_guard.py
python
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