gabriel / muse public
test_passphrase_secure.py python
303 lines 11.9 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago
1 """Tests for secure BIP-39 passphrase delivery — no plaintext CLI flag.
2
3 The ``--passphrase PHRASE`` flag was removed because it exposes the passphrase
4 in ``ps aux`` / ``/proc/pid/cmdline`` to any local user. Safe alternatives:
5
6 1. ``--passphrase-fd N`` — read from a pipe fd (never in process table)
7 2. ``MUSE_BIP39_PASSPHRASE`` env var — fallback (visible to process owner in
8 /proc/pid/environ, but not world-readable)
9 3. Interactive ``getpass`` prompt — TTY only; no echo
10
11 Coverage
12 --------
13 I --passphrase-fd
14 I1 --passphrase-fd delivers passphrase to keygen (different fingerprint)
15 I2 --passphrase-fd delivers passphrase to recover (matches keygen)
16 I3 --passphrase-fd delivers passphrase to rotate (matches keygen-with-passphrase rotation)
17 I4 trailing newline in fd content is stripped
18
19 II Interactive getpass (TTY path)
20 II1 getpass.getpass is called when stdin.isatty() is True and no fd/env
21 II2 empty getpass reply → empty passphrase (standard BIP-39 behaviour)
22
23 III --passphrase plaintext flag is gone
24 III1 --passphrase PHRASE is rejected by keygen (argparse error, exit 2)
25 III2 --passphrase PHRASE is rejected by recover
26 III3 --passphrase PHRASE is rejected by rotate
27
28 IV Priority order
29 IV1 --passphrase-fd beats MUSE_BIP39_PASSPHRASE env var
30 IV2 MUSE_BIP39_PASSPHRASE beats getpass prompt
31 """
32
33 from __future__ import annotations
34
35 import json
36 import os
37 import pathlib
38 from unittest.mock import patch
39
40 import pytest
41
42 from tests.cli_test_helper import CliRunner
43 from muse.core import keypair as kp_module
44 from muse.core import identity as id_module
45
46 runner = CliRunner()
47
48 _HUB = "http://localhost:10003"
49 _MNEMONIC = (
50 "abandon abandon abandon abandon abandon abandon abandon abandon "
51 "abandon abandon abandon about"
52 )
53
54
55 # ---------------------------------------------------------------------------
56 # Fixtures
57 # ---------------------------------------------------------------------------
58
59
60 @pytest.fixture()
61 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
62 fake_home = tmp_path / "home"
63 fake_home.mkdir(parents=True, exist_ok=True)
64 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
65 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
66 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
67 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
68 return fake_home
69
70
71 @pytest.fixture()
72 def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str:
73 from muse.core import bip39 as bip39_mod
74 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC)
75 return _MNEMONIC
76
77
78 def _pipe_passphrase(passphrase: str) -> int:
79 """Write *passphrase* into a pipe; return the read-end fd."""
80 r_fd, w_fd = os.pipe()
81 os.write(w_fd, passphrase.encode())
82 os.close(w_fd)
83 return r_fd
84
85
86 def _keygen(extra: list[str] | None = None) -> object:
87 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or []))
88
89
90 def _recover(extra: list[str] | None = None) -> object:
91 return runner.invoke(
92 None,
93 ["auth", "recover", "--hub", _HUB, "--force", "--json"] + (extra or []),
94 input=_MNEMONIC + "\n",
95 )
96
97
98 def _rotate(extra: list[str] | None = None) -> object:
99 return runner.invoke(
100 None,
101 ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []),
102 input=_MNEMONIC + "\n",
103 )
104
105
106 def _fp(result: object) -> str:
107 return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr]
108
109
110 # ---------------------------------------------------------------------------
111 # I --passphrase-fd
112 # ---------------------------------------------------------------------------
113
114
115 class TestPassphraseFd:
116 def test_I1_passphrase_fd_keygen_changes_fingerprint(
117 self, isolated: pathlib.Path, fixed_mnemonic: str
118 ) -> None:
119 """I1: --passphrase-fd delivers passphrase to keygen (different fp than no-passphrase)."""
120 r_plain = _keygen()
121 assert r_plain.exit_code == 0, r_plain.output # type: ignore[union-attr]
122 fp_plain = _fp(r_plain)
123
124 r_fd = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"])
125 assert r_fd.exit_code == 0, r_fd.output # type: ignore[union-attr]
126 fp_fd = _fp(r_fd)
127
128 assert fp_plain != fp_fd, "passphrase via fd must produce a different fingerprint"
129
130 def test_I2_passphrase_fd_recover_matches_keygen(
131 self, isolated: pathlib.Path, fixed_mnemonic: str
132 ) -> None:
133 """I2: recover with --passphrase-fd reproduces the keygen fingerprint exactly."""
134 r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
135 assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr]
136 fp_keygen = _fp(r_keygen)
137
138 r_recover = _recover(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
139 assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr]
140 fp_recover = _fp(r_recover)
141
142 assert fp_keygen == fp_recover, "recover must reproduce keygen fingerprint"
143
144 def test_I3_passphrase_fd_rotate(
145 self, isolated: pathlib.Path, fixed_mnemonic: str
146 ) -> None:
147 """I3: --passphrase-fd flows through to rotate."""
148 _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
149
150 r_rotate = _rotate(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
151 assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr]
152
153 # A second rotate from scratch must produce the same fingerprint (deterministic).
154 runner.invoke(
155 None,
156 ["auth", "recover", "--hub", _HUB, "--force"],
157 input=_MNEMONIC + "\n",
158 )
159 r_rotate2 = _rotate(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
160 assert r_rotate2.exit_code == 0, r_rotate2.output # type: ignore[union-attr]
161
162 assert _fp(r_rotate) == _fp(r_rotate2), "rotate with same passphrase must be deterministic"
163
164 def test_I4_trailing_newline_stripped(
165 self, isolated: pathlib.Path, fixed_mnemonic: str
166 ) -> None:
167 """I4: a trailing newline in fd content is stripped (shell echo behaviour)."""
168 r_with_nl = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2\n"))])
169 assert r_with_nl.exit_code == 0, r_with_nl.output # type: ignore[union-attr]
170
171 r_without_nl = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"])
172 assert r_without_nl.exit_code == 0, r_without_nl.output # type: ignore[union-attr]
173
174 assert _fp(r_with_nl) == _fp(r_without_nl), (
175 "trailing newline in passphrase fd must be stripped"
176 )
177
178
179 # ---------------------------------------------------------------------------
180 # II Interactive getpass (TTY path)
181 # ---------------------------------------------------------------------------
182
183
184 class TestPassphraseGetpass:
185 def test_II1_getpass_called_when_tty(
186 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
187 ) -> None:
188 """II1: getpass.getpass is called when stdin is a TTY and no fd/env is given."""
189 import muse.cli.commands.auth as auth_mod
190
191 calls: list[str] = []
192
193 def fake_getpass(prompt: str = "") -> str:
194 calls.append(prompt)
195 return "tty-passphrase"
196
197 monkeypatch.setattr(auth_mod, "_isatty", lambda: True)
198 monkeypatch.setattr(auth_mod, "_getpass", fake_getpass)
199
200 r = _keygen()
201 assert r.exit_code == 0, r.output # type: ignore[union-attr]
202 assert len(calls) == 1, "getpass must be called exactly once"
203
204 def test_II2_empty_getpass_gives_empty_passphrase(
205 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
206 ) -> None:
207 """II2: empty getpass reply → same fingerprint as no passphrase at all."""
208 import muse.cli.commands.auth as auth_mod
209
210 # Baseline: no passphrase at all
211 r_baseline = _keygen()
212 assert r_baseline.exit_code == 0
213 fp_baseline = _fp(r_baseline)
214
215 # Now with getpass returning ""
216 monkeypatch.setattr(auth_mod, "_isatty", lambda: True)
217 monkeypatch.setattr(auth_mod, "_getpass", lambda prompt="": "")
218
219 r_empty = _keygen(["--force"])
220 assert r_empty.exit_code == 0, r_empty.output # type: ignore[union-attr]
221 fp_empty = _fp(r_empty)
222
223 assert fp_baseline == fp_empty, "empty getpass reply must behave like no passphrase"
224
225
226 # ---------------------------------------------------------------------------
227 # III --passphrase plaintext flag is gone
228 # ---------------------------------------------------------------------------
229
230
231 class TestPassphraseFlagRemoved:
232 def test_III1_passphrase_flag_rejected_keygen(self, isolated: pathlib.Path) -> None:
233 """III1: --passphrase PHRASE is no longer accepted by keygen."""
234 r = _keygen(["--passphrase", "secret"])
235 assert r.exit_code != 0, ( # type: ignore[union-attr]
236 "keygen must reject --passphrase PHRASE (exit non-zero)"
237 )
238
239 def test_III2_passphrase_flag_rejected_recover(self, isolated: pathlib.Path) -> None:
240 """III2: --passphrase PHRASE is no longer accepted by recover."""
241 r = runner.invoke(
242 None,
243 ["auth", "recover", "--hub", _HUB, "--force", "--passphrase", "secret"],
244 input=_MNEMONIC + "\n",
245 )
246 assert r.exit_code != 0, ( # type: ignore[union-attr]
247 "recover must reject --passphrase PHRASE (exit non-zero)"
248 )
249
250 def test_III3_passphrase_flag_rejected_rotate(self, isolated: pathlib.Path) -> None:
251 """III3: --passphrase PHRASE is no longer accepted by rotate."""
252 r = runner.invoke(
253 None,
254 ["auth", "rotate", "--hub", _HUB, "--passphrase", "secret"],
255 input=_MNEMONIC + "\n",
256 )
257 assert r.exit_code != 0, ( # type: ignore[union-attr]
258 "rotate must reject --passphrase PHRASE (exit non-zero)"
259 )
260
261
262 # ---------------------------------------------------------------------------
263 # IV Priority order
264 # ---------------------------------------------------------------------------
265
266
267 class TestPassphrasePriority:
268 def test_IV1_fd_beats_env_var(
269 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
270 ) -> None:
271 """IV1: --passphrase-fd beats MUSE_BIP39_PASSPHRASE env var."""
272 # Reference: keygen with "fd-value"
273 r_ref = _keygen(["--passphrase-fd", str(_pipe_passphrase("fd-value"))])
274 assert r_ref.exit_code == 0
275 fp_ref = _fp(r_ref)
276
277 # Now set env var to a different value — fd should still win
278 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value")
279 r_test = _keygen(
280 ["--passphrase-fd", str(_pipe_passphrase("fd-value")), "--force"]
281 )
282 assert r_test.exit_code == 0, r_test.output # type: ignore[union-attr]
283 assert _fp(r_test) == fp_ref, "--passphrase-fd must take priority over env var"
284
285 def test_IV2_env_var_beats_getpass(
286 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
287 ) -> None:
288 """IV2: MUSE_BIP39_PASSPHRASE env var beats interactive getpass prompt."""
289 import muse.cli.commands.auth as auth_mod
290
291 # Reference: keygen with env-value
292 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value")
293 r_ref = _keygen()
294 assert r_ref.exit_code == 0
295 fp_ref = _fp(r_ref)
296
297 # Now also set getpass to return a different value — env var should win
298 monkeypatch.setattr(auth_mod, "_isatty", lambda: True)
299 monkeypatch.setattr(auth_mod, "_getpass", lambda prompt="": "getpass-value")
300
301 r_test = _keygen(["--force"])
302 assert r_test.exit_code == 0, r_test.output # type: ignore[union-attr]
303 assert _fp(r_test) == fp_ref, "env var must take priority over getpass prompt"
File History 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago