"""Tests for secure BIP-39 passphrase delivery — no plaintext CLI flag. The ``--passphrase PHRASE`` flag was removed because it exposes the passphrase in ``ps aux`` / ``/proc/pid/cmdline`` to any local user. Safe alternatives: 1. ``--passphrase-fd N`` — read from a pipe fd (never in process table) 2. ``MUSE_BIP39_PASSPHRASE`` env var — fallback (visible to process owner in /proc/pid/environ, but not world-readable) 3. Interactive ``getpass`` prompt — TTY only; no echo Coverage -------- I --passphrase-fd I1 --passphrase-fd delivers passphrase to keygen (different fingerprint) I2 --passphrase-fd delivers passphrase to recover (matches keygen) I3 --passphrase-fd delivers passphrase to rotate (matches keygen-with-passphrase rotation) I4 trailing newline in fd content is stripped II Interactive getpass (TTY path) II1 getpass.getpass is called when stdin.isatty() is True and no fd/env II2 empty getpass reply → empty passphrase (standard BIP-39 behaviour) III --passphrase plaintext flag is gone III1 --passphrase PHRASE is rejected by keygen (argparse error, exit 2) III2 --passphrase PHRASE is rejected by recover III3 --passphrase PHRASE is rejected by rotate IV Priority order IV1 --passphrase-fd beats MUSE_BIP39_PASSPHRASE env var IV2 MUSE_BIP39_PASSPHRASE beats getpass prompt """ from __future__ import annotations import json import os import pathlib from unittest.mock import patch import pytest from tests.cli_test_helper import CliRunner, InvokeResult from muse.core import keypair as kp_module from muse.core import identity as id_module runner = CliRunner() _HUB = "https://localhost:1337" _MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path: fake_home = tmp_path / "home" fake_home.mkdir(parents=True, exist_ok=True) monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home)) monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys") monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse") monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml") return fake_home @pytest.fixture() def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str: from muse.core import bip39 as bip39_mod monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC) return _MNEMONIC def _pipe_passphrase(passphrase: str) -> int: """Write *passphrase* into a pipe; return the read-end fd.""" r_fd, w_fd = os.pipe() os.write(w_fd, passphrase.encode()) os.close(w_fd) return r_fd def _keygen(extra: list[str] | None = None) -> InvokeResult: return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra or [])) def _recover(extra: list[str] | None = None) -> InvokeResult: return runner.invoke( None, ["auth", "recover", "--hub", _HUB, "--force", "--json"] + (extra or []), input=f"{_MNEMONIC}\n", ) def _rotate(extra: list[str] | None = None) -> InvokeResult: return runner.invoke( None, ["auth", "rotate", "--hub", _HUB, "--json"] + (extra or []), input=f"{_MNEMONIC}\n", ) def _fp(result: InvokeResult) -> str: return json.loads(result.output.splitlines()[0])["fingerprint"] # type: ignore[union-attr] # --------------------------------------------------------------------------- # I --passphrase-fd # --------------------------------------------------------------------------- class TestPassphraseFd: def test_I1_passphrase_fd_keygen_changes_fingerprint( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I1: --passphrase-fd delivers passphrase to keygen (different fp than no-passphrase).""" r_plain = _keygen() assert r_plain.exit_code == 0, r_plain.output # type: ignore[union-attr] fp_plain = _fp(r_plain) r_fd = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"]) assert r_fd.exit_code == 0, r_fd.output # type: ignore[union-attr] fp_fd = _fp(r_fd) assert fp_plain != fp_fd, "passphrase via fd must produce a different fingerprint" def test_I2_passphrase_fd_recover_matches_keygen( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I2: recover with --passphrase-fd reproduces the keygen fingerprint exactly.""" r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))]) assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr] fp_keygen = _fp(r_keygen) r_recover = _recover(["--passphrase-fd", str(_pipe_passphrase("hunter2"))]) assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr] fp_recover = _fp(r_recover) assert fp_keygen == fp_recover, "recover must reproduce keygen fingerprint" def test_I3_passphrase_fd_rotate( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch ) -> None: """I3: --passphrase-fd flows through to rotate.""" _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))]) monkeypatch.setattr("muse.cli.commands.auth._post_challenge", lambda *a, **kw: {"challenge_token": "ab" * 32, "is_new_key": True}) monkeypatch.setattr("muse.cli.commands.auth._json_post_raw", lambda *a, **kw: {}) monkeypatch.setattr("muse.cli.commands.auth._hub_delete", lambda *a, **kw: None) r_rotate = _rotate(["--passphrase-fd", str(_pipe_passphrase("hunter2"))]) assert r_rotate.exit_code == 0, r_rotate.output # type: ignore[union-attr] # A second rotate from scratch must produce the same fingerprint (deterministic). runner.invoke( None, ["auth", "recover", "--hub", _HUB, "--force"], input=f"{_MNEMONIC}\n", ) r_rotate2 = _rotate(["--passphrase-fd", str(_pipe_passphrase("hunter2"))]) assert r_rotate2.exit_code == 0, r_rotate2.output # type: ignore[union-attr] assert _fp(r_rotate) == _fp(r_rotate2), "rotate with same passphrase must be deterministic" def test_I4_trailing_newline_stripped( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I4: a trailing newline in fd content is stripped (shell echo behaviour).""" r_with_nl = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2\n"))]) assert r_with_nl.exit_code == 0, r_with_nl.output # type: ignore[union-attr] r_without_nl = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"]) assert r_without_nl.exit_code == 0, r_without_nl.output # type: ignore[union-attr] assert _fp(r_with_nl) == _fp(r_without_nl), ( "trailing newline in passphrase fd must be stripped" ) # --------------------------------------------------------------------------- # II Interactive getpass (TTY path) # --------------------------------------------------------------------------- class TestPassphraseGetpass: def test_II1_getpass_called_when_tty( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch ) -> None: """II1: getpass.getpass is called when stdin is a TTY and no fd/env is given.""" import muse.cli.commands.auth as auth_mod calls: list[str] = [] def fake_getpass(prompt: str = "") -> str: calls.append(prompt) return "tty-passphrase" monkeypatch.setattr(auth_mod, "_isatty", lambda: True) monkeypatch.setattr(auth_mod, "_getpass", fake_getpass) r = _keygen() assert r.exit_code == 0, r.output # type: ignore[union-attr] assert len(calls) == 1, "getpass must be called exactly once" def test_II2_empty_getpass_gives_empty_passphrase( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch ) -> None: """II2: empty getpass reply → same fingerprint as no passphrase at all.""" import muse.cli.commands.auth as auth_mod # Baseline: no passphrase at all r_baseline = _keygen() assert r_baseline.exit_code == 0 fp_baseline = _fp(r_baseline) # Now with getpass returning "" monkeypatch.setattr(auth_mod, "_isatty", lambda: True) monkeypatch.setattr(auth_mod, "_getpass", lambda prompt="": "") r_empty = _keygen(["--force"]) assert r_empty.exit_code == 0, r_empty.output # type: ignore[union-attr] fp_empty = _fp(r_empty) assert fp_baseline == fp_empty, "empty getpass reply must behave like no passphrase" # --------------------------------------------------------------------------- # III --passphrase plaintext flag is gone # --------------------------------------------------------------------------- class TestPassphraseFlagRemoved: def test_III1_passphrase_flag_rejected_keygen(self, isolated: pathlib.Path) -> None: """III1: --passphrase PHRASE is no longer accepted by keygen.""" r = _keygen(["--passphrase", "secret"]) assert r.exit_code != 0, ( # type: ignore[union-attr] "keygen must reject --passphrase PHRASE (exit non-zero)" ) def test_III2_passphrase_flag_rejected_recover(self, isolated: pathlib.Path) -> None: """III2: --passphrase PHRASE is no longer accepted by recover.""" r = runner.invoke( None, ["auth", "recover", "--hub", _HUB, "--force", "--passphrase", "secret"], input=f"{_MNEMONIC}\n", ) assert r.exit_code != 0, ( # type: ignore[union-attr] "recover must reject --passphrase PHRASE (exit non-zero)" ) def test_III3_passphrase_flag_rejected_rotate(self, isolated: pathlib.Path) -> None: """III3: --passphrase PHRASE is no longer accepted by rotate.""" r = runner.invoke( None, ["auth", "rotate", "--hub", _HUB, "--passphrase", "secret"], input=f"{_MNEMONIC}\n", ) assert r.exit_code != 0, ( # type: ignore[union-attr] "rotate must reject --passphrase PHRASE (exit non-zero)" ) # --------------------------------------------------------------------------- # IV Priority order # --------------------------------------------------------------------------- class TestPassphrasePriority: def test_IV1_fd_beats_env_var( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch ) -> None: """IV1: --passphrase-fd beats MUSE_BIP39_PASSPHRASE env var.""" # Reference: keygen with "fd-value" r_ref = _keygen(["--passphrase-fd", str(_pipe_passphrase("fd-value"))]) assert r_ref.exit_code == 0 fp_ref = _fp(r_ref) # Now set env var to a different value — fd should still win monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value") r_test = _keygen( ["--passphrase-fd", str(_pipe_passphrase("fd-value")), "--force"] ) assert r_test.exit_code == 0, r_test.output # type: ignore[union-attr] assert _fp(r_test) == fp_ref, "--passphrase-fd must take priority over env var" def test_IV2_env_var_beats_getpass( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch ) -> None: """IV2: MUSE_BIP39_PASSPHRASE env var beats interactive getpass prompt.""" import muse.cli.commands.auth as auth_mod # Reference: keygen with env-value monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value") r_ref = _keygen() assert r_ref.exit_code == 0 fp_ref = _fp(r_ref) # Now also set getpass to return a different value — env var should win monkeypatch.setattr(auth_mod, "_isatty", lambda: True) monkeypatch.setattr(auth_mod, "_getpass", lambda prompt="": "getpass-value") r_test = _keygen(["--force"]) assert r_test.exit_code == 0, r_test.output # type: ignore[union-attr] assert _fp(r_test) == fp_ref, "env var must take priority over getpass prompt"