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