gabriel / muse public

test_passphrase_secure.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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, InvokeResult
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 = "https://localhost:1337"
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) -> InvokeResult:
87 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or []))
88
89
90 def _recover(extra: list[str] | None = None) -> InvokeResult:
91 return runner.invoke(
92 None,
93 ["auth", "recover", "--hub", _HUB, "--force", "--json"] + (extra or []),
94 input=f"{_MNEMONIC}\n",
95 )
96
97
98 def _rotate(extra: list[str] | None = None) -> InvokeResult:
99 return runner.invoke(
100 None,
101 ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []),
102 input=f"{_MNEMONIC}\n",
103 )
104
105
106 def _fp(result: InvokeResult) -> 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, monkeypatch: pytest.MonkeyPatch
146 ) -> None:
147 """I3: --passphrase-fd flows through to rotate."""
148 _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
149
150 monkeypatch.setattr("muse.cli.commands.auth._post_challenge",
151 lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True})
152 monkeypatch.setattr("muse.cli.commands.auth._json_post_raw", lambda *a, **kw: {})
153 monkeypatch.setattr("muse.cli.commands.auth._hub_delete", lambda *a, **kw: None)
154
155 r_rotate = _rotate(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
156 assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr]
157
158 # A second rotate from scratch must produce the same fingerprint (deterministic).
159 runner.invoke(
160 None,
161 ["auth", "recover", "--hub", _HUB, "--force"],
162 input=f"{_MNEMONIC}\n",
163 )
164 r_rotate2 = _rotate(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
165 assert r_rotate2.exit_code == 0, r_rotate2.output # type: ignore[union-attr]
166
167 assert _fp(r_rotate) == _fp(r_rotate2), "rotate with same passphrase must be deterministic"
168
169 def test_I4_trailing_newline_stripped(
170 self, isolated: pathlib.Path, fixed_mnemonic: str
171 ) -> None:
172 """I4: a trailing newline in fd content is stripped (shell echo behaviour)."""
173 r_with_nl = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2\n"))])
174 assert r_with_nl.exit_code == 0, r_with_nl.output # type: ignore[union-attr]
175
176 r_without_nl = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"])
177 assert r_without_nl.exit_code == 0, r_without_nl.output # type: ignore[union-attr]
178
179 assert _fp(r_with_nl) == _fp(r_without_nl), (
180 "trailing newline in passphrase fd must be stripped"
181 )
182
183
184 # ---------------------------------------------------------------------------
185 # II Interactive getpass (TTY path)
186 # ---------------------------------------------------------------------------
187
188
189 class TestPassphraseGetpass:
190 def test_II1_getpass_called_when_tty(
191 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
192 ) -> None:
193 """II1: getpass.getpass is called when stdin is a TTY and no fd/env is given."""
194 import muse.cli.commands.auth as auth_mod
195
196 calls: list[str] = []
197
198 def fake_getpass(prompt: str = "") -> str:
199 calls.append(prompt)
200 return "tty-passphrase"
201
202 monkeypatch.setattr(auth_mod, "_isatty", lambda: True)
203 monkeypatch.setattr(auth_mod, "_getpass", fake_getpass)
204
205 r = _keygen()
206 assert r.exit_code == 0, r.output # type: ignore[union-attr]
207 assert len(calls) == 1, "getpass must be called exactly once"
208
209 def test_II2_empty_getpass_gives_empty_passphrase(
210 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
211 ) -> None:
212 """II2: empty getpass reply β†’ same fingerprint as no passphrase at all."""
213 import muse.cli.commands.auth as auth_mod
214
215 # Baseline: no passphrase at all
216 r_baseline = _keygen()
217 assert r_baseline.exit_code == 0
218 fp_baseline = _fp(r_baseline)
219
220 # Now with getpass returning ""
221 monkeypatch.setattr(auth_mod, "_isatty", lambda: True)
222 monkeypatch.setattr(auth_mod, "_getpass", lambda prompt="": "")
223
224 r_empty = _keygen(["--force"])
225 assert r_empty.exit_code == 0, r_empty.output # type: ignore[union-attr]
226 fp_empty = _fp(r_empty)
227
228 assert fp_baseline == fp_empty, "empty getpass reply must behave like no passphrase"
229
230
231 # ---------------------------------------------------------------------------
232 # III --passphrase plaintext flag is gone
233 # ---------------------------------------------------------------------------
234
235
236 class TestPassphraseFlagRemoved:
237 def test_III1_passphrase_flag_rejected_keygen(self, isolated: pathlib.Path) -> None:
238 """III1: --passphrase PHRASE is no longer accepted by keygen."""
239 r = _keygen(["--passphrase", "secret"])
240 assert r.exit_code != 0, ( # type: ignore[union-attr]
241 "keygen must reject --passphrase PHRASE (exit non-zero)"
242 )
243
244 def test_III2_passphrase_flag_rejected_recover(self, isolated: pathlib.Path) -> None:
245 """III2: --passphrase PHRASE is no longer accepted by recover."""
246 r = runner.invoke(
247 None,
248 ["auth", "recover", "--hub", _HUB, "--force", "--passphrase", "secret"],
249 input=f"{_MNEMONIC}\n",
250 )
251 assert r.exit_code != 0, ( # type: ignore[union-attr]
252 "recover must reject --passphrase PHRASE (exit non-zero)"
253 )
254
255 def test_III3_passphrase_flag_rejected_rotate(self, isolated: pathlib.Path) -> None:
256 """III3: --passphrase PHRASE is no longer accepted by rotate."""
257 r = runner.invoke(
258 None,
259 ["auth", "rotate", "--hub", _HUB, "--passphrase", "secret"],
260 input=f"{_MNEMONIC}\n",
261 )
262 assert r.exit_code != 0, ( # type: ignore[union-attr]
263 "rotate must reject --passphrase PHRASE (exit non-zero)"
264 )
265
266
267 # ---------------------------------------------------------------------------
268 # IV Priority order
269 # ---------------------------------------------------------------------------
270
271
272 class TestPassphrasePriority:
273 def test_IV1_fd_beats_env_var(
274 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
275 ) -> None:
276 """IV1: --passphrase-fd beats MUSE_BIP39_PASSPHRASE env var."""
277 # Reference: keygen with "fd-value"
278 r_ref = _keygen(["--passphrase-fd", str(_pipe_passphrase("fd-value"))])
279 assert r_ref.exit_code == 0
280 fp_ref = _fp(r_ref)
281
282 # Now set env var to a different value β€” fd should still win
283 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value")
284 r_test = _keygen(
285 ["--passphrase-fd", str(_pipe_passphrase("fd-value")), "--force"]
286 )
287 assert r_test.exit_code == 0, r_test.output # type: ignore[union-attr]
288 assert _fp(r_test) == fp_ref, "--passphrase-fd must take priority over env var"
289
290 def test_IV2_env_var_beats_getpass(
291 self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch
292 ) -> None:
293 """IV2: MUSE_BIP39_PASSPHRASE env var beats interactive getpass prompt."""
294 import muse.cli.commands.auth as auth_mod
295
296 # Reference: keygen with env-value
297 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value")
298 r_ref = _keygen()
299 assert r_ref.exit_code == 0
300 fp_ref = _fp(r_ref)
301
302 # Now also set getpass to return a different value β€” env var should win
303 monkeypatch.setattr(auth_mod, "_isatty", lambda: True)
304 monkeypatch.setattr(auth_mod, "_getpass", lambda prompt="": "getpass-value")
305
306 r_test = _keygen(["--force"])
307 assert r_test.exit_code == 0, r_test.output # type: ignore[union-attr]
308 assert _fp(r_test) == fp_ref, "env var must take priority over getpass prompt"