gabriel / muse public

test_auth_mnemonic_input.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 mnemonic input β€” Tier 1.
2
3 The mnemonic must never appear as a CLI argument (visible in ps / shell
4 history). The only permitted input channels are:
5
6 --mnemonic-fd N read from file descriptor N, close it immediately
7 stdin (non-TTY) read one line from stdin (pipe / heredoc)
8 TTY prompt via getpass (no echo, not logged)
9
10 Coverage
11 --------
12 I _read_mnemonic_securely
13 I1 fd path reads one line and closes the fd
14 I2 stdin non-TTY path reads from sys.stdin
15 I3 TTY path delegates to getpass.getpass
16 I4 invalid fd β†’ SystemExit(1)
17 I5 empty input β†’ SystemExit(1)
18
19 II muse auth recover β€” CLI interface
20 II1 --mnemonic WORDS is rejected (flag removed)
21 II2 --mnemonic-fd N is accepted (real pipe fd)
22 II3 stdin pipe is accepted (runner.invoke input=)
23
24 III Security invariants
25 III1 recovered mnemonic is never echoed to stdout
26 III2 args namespace has no mnemonic attribute after parsing
27 """
28
29 from __future__ import annotations
30
31 import io
32 import os
33 import sys
34 import pathlib
35
36 import pytest
37
38 from tests.cli_test_helper import CliRunner
39 from muse.core.paths import muse_dir
40
41 cli = None
42 runner = CliRunner()
43
44 _TEST_HUB = "https://localhost:1337"
45 _TEST_MNEMONIC = (
46 "abandon abandon abandon abandon abandon abandon abandon abandon "
47 "abandon abandon abandon about"
48 )
49
50
51 # ---------------------------------------------------------------------------
52 # Fixtures
53 # ---------------------------------------------------------------------------
54
55
56 @pytest.fixture()
57 def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
58 fake_dir = tmp_path / "muse_dir"
59 fake_dir.mkdir()
60 monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir)
61 monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml")
62 return fake_dir
63
64
65 @pytest.fixture()
66 def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
67 keys_dir = tmp_path / "keys"
68 keys_dir.mkdir()
69 monkeypatch.setattr("muse.core.keypair._KEYS_DIR", keys_dir)
70 return keys_dir
71
72
73 @pytest.fixture()
74 def repo_with_hub(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
75 dot_muse = muse_dir(tmp_path)
76 dot_muse.mkdir()
77 (dot_muse / "HEAD").write_text("ref: refs/heads/main\n")
78 (dot_muse / "refs" / "heads").mkdir(parents=True)
79 (dot_muse / "objects").mkdir()
80 (dot_muse / "commits").mkdir()
81 (dot_muse / "snapshots").mkdir()
82 (dot_muse / "config.toml").write_text(f'[hub]\nurl = "{_TEST_HUB}"\n')
83 monkeypatch.chdir(tmp_path)
84 return tmp_path
85
86
87 @pytest.fixture()
88 def keychain_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
89 monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled")
90
91
92 # ---------------------------------------------------------------------------
93 # I _read_mnemonic_securely
94 # ---------------------------------------------------------------------------
95
96
97 class TestReadMnemonicSecurelyI:
98 def test_I1_fd_reads_and_closes(self, monkeypatch: pytest.MonkeyPatch) -> None:
99 """I1: fd path reads one line from the fd and the fd is closed after."""
100 from muse.cli.commands.auth import _read_mnemonic_securely
101
102 r_fd, w_fd = os.pipe()
103 os.write(w_fd, (_TEST_MNEMONIC + "\n").encode())
104 os.close(w_fd)
105
106 result = _read_mnemonic_securely(fd=r_fd)
107 assert result == _TEST_MNEMONIC
108
109 # fd must be closed β€” reading from it should raise
110 with pytest.raises(OSError):
111 os.read(r_fd, 1)
112
113 def test_I2_stdin_non_tty_reads_line(self, monkeypatch: pytest.MonkeyPatch) -> None:
114 """I2: non-TTY stdin path reads one line."""
115 from muse.cli.commands.auth import _read_mnemonic_securely
116
117 fake_stdin = io.StringIO(_TEST_MNEMONIC + "\n")
118 fake_stdin.isatty = lambda: False # type: ignore[method-assign]
119 monkeypatch.setattr(sys, "stdin", fake_stdin)
120
121 result = _read_mnemonic_securely(fd=None)
122 assert result == _TEST_MNEMONIC
123
124 def test_I3_tty_calls_getpass(self, monkeypatch: pytest.MonkeyPatch) -> None:
125 """I3: TTY stdin delegates to getpass.getpass."""
126 from muse.cli.commands.auth import _read_mnemonic_securely
127
128 fake_stdin = io.StringIO()
129 fake_stdin.isatty = lambda: True # type: ignore[method-assign]
130 monkeypatch.setattr(sys, "stdin", fake_stdin)
131
132 import getpass
133 calls: list[str] = []
134 monkeypatch.setattr(getpass, "getpass", lambda prompt="": (calls.append(prompt), _TEST_MNEMONIC)[1])
135
136 result = _read_mnemonic_securely(fd=None)
137 assert result == _TEST_MNEMONIC
138 assert calls, "getpass.getpass was not called"
139
140 def test_I4_invalid_fd_exits(self) -> None:
141 """I4: unreadable fd β†’ SystemExit(1)."""
142 from muse.cli.commands.auth import _read_mnemonic_securely
143
144 # Use a very high fd number that is certainly not open
145 with pytest.raises(SystemExit) as exc_info:
146 _read_mnemonic_securely(fd=9999)
147 assert exc_info.value.code == 1
148
149 def test_I5_empty_input_exits(self, monkeypatch: pytest.MonkeyPatch) -> None:
150 """I5: empty string from stdin β†’ SystemExit(1)."""
151 from muse.cli.commands.auth import _read_mnemonic_securely
152
153 fake_stdin = io.StringIO("\n")
154 fake_stdin.isatty = lambda: False # type: ignore[method-assign]
155 monkeypatch.setattr(sys, "stdin", fake_stdin)
156
157 with pytest.raises(SystemExit) as exc_info:
158 _read_mnemonic_securely(fd=None)
159 assert exc_info.value.code == 1
160
161
162 # ---------------------------------------------------------------------------
163 # II CLI interface
164 # ---------------------------------------------------------------------------
165
166
167 class TestCliInterfaceII:
168 def test_II1_mnemonic_flag_rejected(
169 self,
170 isolated_identity: pathlib.Path,
171 isolated_keys: pathlib.Path,
172 repo_with_hub: pathlib.Path,
173 keychain_disabled: None,
174 ) -> None:
175 """II1: --mnemonic WORDS is no longer a valid flag (argparse rejects it)."""
176 result = runner.invoke(
177 cli,
178 ["auth", "recover", "--hub", _TEST_HUB, "--mnemonic", _TEST_MNEMONIC],
179 )
180 # argparse exits with code 2 for unrecognised arguments
181 assert result.exit_code != 0
182
183 def test_II2_mnemonic_fd_accepted(
184 self,
185 isolated_identity: pathlib.Path,
186 isolated_keys: pathlib.Path,
187 repo_with_hub: pathlib.Path,
188 keychain_disabled: None,
189 monkeypatch: pytest.MonkeyPatch,
190 ) -> None:
191 """II2: --mnemonic-fd N reads from the fd and recover succeeds."""
192 r_fd, w_fd = os.pipe()
193 os.write(w_fd, (_TEST_MNEMONIC + "\n").encode())
194 os.close(w_fd)
195
196 result = runner.invoke(
197 cli,
198 ["auth", "recover", "--hub", _TEST_HUB, "--mnemonic-fd", str(r_fd)],
199 )
200 try:
201 os.close(r_fd)
202 except OSError:
203 pass # already closed by _read_mnemonic_securely
204
205 assert result.exit_code == 0, f"recover failed:\n{result.output}"
206
207 def test_II3_stdin_pipe_accepted(
208 self,
209 isolated_identity: pathlib.Path,
210 isolated_keys: pathlib.Path,
211 repo_with_hub: pathlib.Path,
212 keychain_disabled: None,
213 ) -> None:
214 """II3: mnemonic piped via stdin is accepted."""
215 result = runner.invoke(
216 cli,
217 ["auth", "recover", "--hub", _TEST_HUB],
218 input=_TEST_MNEMONIC + "\n",
219 )
220 assert result.exit_code == 0, f"recover via stdin failed:\n{result.output}"
221
222
223 # ---------------------------------------------------------------------------
224 # III Security invariants
225 # ---------------------------------------------------------------------------
226
227
228 class TestSecurityInvariantsIII:
229 def test_III1_mnemonic_not_in_stdout(
230 self,
231 isolated_identity: pathlib.Path,
232 isolated_keys: pathlib.Path,
233 repo_with_hub: pathlib.Path,
234 keychain_disabled: None,
235 ) -> None:
236 """III1: the mnemonic phrase must never appear in stdout."""
237 result = runner.invoke(
238 cli,
239 ["auth", "recover", "--hub", _TEST_HUB, "--json"],
240 input=_TEST_MNEMONIC + "\n",
241 )
242 assert result.exit_code == 0
243 assert _TEST_MNEMONIC not in result.stdout
244 # No individual word that's unique to the mnemonic should appear either
245 # ("abandon" appears 11 times β€” check it's not in JSON stdout)
246 import json
247 stdout_lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")]
248 for line in stdout_lines:
249 data = json.loads(line)
250 assert "mnemonic" not in data, f"'mnemonic' key leaked into JSON: {data}"
251
252 def test_III2_args_has_no_mnemonic_attribute(self) -> None:
253 """III2: the parsed args namespace has no 'mnemonic' attribute."""
254 import argparse
255 from muse.cli.app import main as _main
256
257 # We verify by checking the recover subparser directly
258 import muse.cli.commands.auth as auth_mod
259 parser = argparse.ArgumentParser()
260 subs = parser.add_subparsers(dest="command")
261 # Import and call register to build the parser
262 recover_args = parser.parse_args([]) # empty parse β€” just check the module
263
264 # The key check: the auth module's recover subparser must not register 'mnemonic'
265 from muse.cli.commands.auth import register as auth_register
266 root_parser = argparse.ArgumentParser()
267 root_subs = root_parser.add_subparsers(dest="cmd")
268 auth_sub = root_subs.add_parser("auth")
269 auth_inner = auth_sub.add_subparsers(dest="auth_cmd")
270 # Check the recover parser doesn't accept --mnemonic
271 # We just verify the flag doesn't exist by trying to parse it
272 with pytest.raises(SystemExit):
273 root_parser.parse_args(["auth", "recover", "--mnemonic", "words"])