"""Tests for secure mnemonic input — Tier 1. The mnemonic must never appear as a CLI argument (visible in ps / shell history). The only permitted input channels are: --mnemonic-fd N read from file descriptor N, close it immediately stdin (non-TTY) read one line from stdin (pipe / heredoc) TTY prompt via getpass (no echo, not logged) Coverage -------- I _read_mnemonic_securely I1 fd path reads one line and closes the fd I2 stdin non-TTY path reads from sys.stdin I3 TTY path delegates to getpass.getpass I4 invalid fd → SystemExit(1) I5 empty input → SystemExit(1) II muse auth recover — CLI interface II1 --mnemonic WORDS is rejected (flag removed) II2 --mnemonic-fd N is accepted (real pipe fd) II3 stdin pipe is accepted (runner.invoke input=) III Security invariants III1 recovered mnemonic is never echoed to stdout III2 args namespace has no mnemonic attribute after parsing """ from __future__ import annotations import io import os import sys import pathlib import pytest from tests.cli_test_helper import CliRunner from muse.core.paths import muse_dir cli = None runner = CliRunner() _TEST_HUB = "https://localhost:1337" _TEST_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: fake_dir = tmp_path / "muse_dir" fake_dir.mkdir() monkeypatch.setattr("muse.core.identity._IDENTITY_DIR", fake_dir) monkeypatch.setattr("muse.core.identity._IDENTITY_FILE", fake_dir / "identity.toml") return fake_dir @pytest.fixture() def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: keys_dir = tmp_path / "keys" keys_dir.mkdir() monkeypatch.setattr("muse.core.keypair._KEYS_DIR", keys_dir) return keys_dir @pytest.fixture() def repo_with_hub(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: dot_muse = muse_dir(tmp_path) dot_muse.mkdir() (dot_muse / "HEAD").write_text("ref: refs/heads/main\n") (dot_muse / "refs" / "heads").mkdir(parents=True) (dot_muse / "objects").mkdir() (dot_muse / "commits").mkdir() (dot_muse / "snapshots").mkdir() (dot_muse / "config.toml").write_text(f'[hub]\nurl = "{_TEST_HUB}"\n') monkeypatch.chdir(tmp_path) return tmp_path @pytest.fixture() def keychain_disabled(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("MUSE_KEYCHAIN_BACKEND", "disabled") # --------------------------------------------------------------------------- # I _read_mnemonic_securely # --------------------------------------------------------------------------- class TestReadMnemonicSecurelyI: def test_I1_fd_reads_and_closes(self, monkeypatch: pytest.MonkeyPatch) -> None: """I1: fd path reads one line from the fd and the fd is closed after.""" from muse.cli.commands.auth import _read_mnemonic_securely r_fd, w_fd = os.pipe() os.write(w_fd, (_TEST_MNEMONIC + "\n").encode()) os.close(w_fd) result = _read_mnemonic_securely(fd=r_fd) assert result == _TEST_MNEMONIC # fd must be closed — reading from it should raise with pytest.raises(OSError): os.read(r_fd, 1) def test_I2_stdin_non_tty_reads_line(self, monkeypatch: pytest.MonkeyPatch) -> None: """I2: non-TTY stdin path reads one line.""" from muse.cli.commands.auth import _read_mnemonic_securely fake_stdin = io.StringIO(_TEST_MNEMONIC + "\n") fake_stdin.isatty = lambda: False # type: ignore[method-assign] monkeypatch.setattr(sys, "stdin", fake_stdin) result = _read_mnemonic_securely(fd=None) assert result == _TEST_MNEMONIC def test_I3_tty_calls_getpass(self, monkeypatch: pytest.MonkeyPatch) -> None: """I3: TTY stdin delegates to getpass.getpass.""" from muse.cli.commands.auth import _read_mnemonic_securely fake_stdin = io.StringIO() fake_stdin.isatty = lambda: True # type: ignore[method-assign] monkeypatch.setattr(sys, "stdin", fake_stdin) import getpass calls: list[str] = [] monkeypatch.setattr(getpass, "getpass", lambda prompt="": (calls.append(prompt), _TEST_MNEMONIC)[1]) result = _read_mnemonic_securely(fd=None) assert result == _TEST_MNEMONIC assert calls, "getpass.getpass was not called" def test_I4_invalid_fd_exits(self) -> None: """I4: unreadable fd → SystemExit(1).""" from muse.cli.commands.auth import _read_mnemonic_securely # Use a very high fd number that is certainly not open with pytest.raises(SystemExit) as exc_info: _read_mnemonic_securely(fd=9999) assert exc_info.value.code == 1 def test_I5_empty_input_exits(self, monkeypatch: pytest.MonkeyPatch) -> None: """I5: empty string from stdin → SystemExit(1).""" from muse.cli.commands.auth import _read_mnemonic_securely fake_stdin = io.StringIO("\n") fake_stdin.isatty = lambda: False # type: ignore[method-assign] monkeypatch.setattr(sys, "stdin", fake_stdin) with pytest.raises(SystemExit) as exc_info: _read_mnemonic_securely(fd=None) assert exc_info.value.code == 1 # --------------------------------------------------------------------------- # II CLI interface # --------------------------------------------------------------------------- class TestCliInterfaceII: def test_II1_mnemonic_flag_rejected( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_disabled: None, ) -> None: """II1: --mnemonic WORDS is no longer a valid flag (argparse rejects it).""" result = runner.invoke( cli, ["auth", "recover", "--hub", _TEST_HUB, "--mnemonic", _TEST_MNEMONIC], ) # argparse exits with code 2 for unrecognised arguments assert result.exit_code != 0 def test_II2_mnemonic_fd_accepted( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_disabled: None, monkeypatch: pytest.MonkeyPatch, ) -> None: """II2: --mnemonic-fd N reads from the fd and recover succeeds.""" r_fd, w_fd = os.pipe() os.write(w_fd, (_TEST_MNEMONIC + "\n").encode()) os.close(w_fd) result = runner.invoke( cli, ["auth", "recover", "--hub", _TEST_HUB, "--mnemonic-fd", str(r_fd)], ) try: os.close(r_fd) except OSError: pass # already closed by _read_mnemonic_securely assert result.exit_code == 0, f"recover failed:\n{result.output}" def test_II3_stdin_pipe_accepted( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_disabled: None, ) -> None: """II3: mnemonic piped via stdin is accepted.""" result = runner.invoke( cli, ["auth", "recover", "--hub", _TEST_HUB], input=_TEST_MNEMONIC + "\n", ) assert result.exit_code == 0, f"recover via stdin failed:\n{result.output}" # --------------------------------------------------------------------------- # III Security invariants # --------------------------------------------------------------------------- class TestSecurityInvariantsIII: def test_III1_mnemonic_not_in_stdout( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, repo_with_hub: pathlib.Path, keychain_disabled: None, ) -> None: """III1: the mnemonic phrase must never appear in stdout.""" result = runner.invoke( cli, ["auth", "recover", "--hub", _TEST_HUB, "--json"], input=_TEST_MNEMONIC + "\n", ) assert result.exit_code == 0 assert _TEST_MNEMONIC not in result.stdout # No individual word that's unique to the mnemonic should appear either # ("abandon" appears 11 times — check it's not in JSON stdout) import json stdout_lines = [ln for ln in result.stdout.splitlines() if ln.strip().startswith("{")] for line in stdout_lines: data = json.loads(line) assert "mnemonic" not in data, f"'mnemonic' key leaked into JSON: {data}" def test_III2_args_has_no_mnemonic_attribute(self) -> None: """III2: the parsed args namespace has no 'mnemonic' attribute.""" import argparse from muse.cli.app import main as _main # We verify by checking the recover subparser directly import muse.cli.commands.auth as auth_mod parser = argparse.ArgumentParser() subs = parser.add_subparsers(dest="command") # Import and call register to build the parser recover_args = parser.parse_args([]) # empty parse — just check the module # The key check: the auth module's recover subparser must not register 'mnemonic' from muse.cli.commands.auth import register as auth_register root_parser = argparse.ArgumentParser() root_subs = root_parser.add_subparsers(dest="cmd") auth_sub = root_subs.add_parser("auth") auth_inner = auth_sub.add_subparsers(dest="auth_cmd") # Check the recover parser doesn't accept --mnemonic # We just verify the flag doesn't exist by trying to parse it with pytest.raises(SystemExit): root_parser.parse_args(["auth", "recover", "--mnemonic", "words"])