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