"""Tests for BIP-39 passphrase support in CLI commands — HIGH-3. The BIP-39 spec defines an optional passphrase ("25th word") that is mixed into the PBKDF2 seed derivation. The same mnemonic + different passphrase produces a completely different 512-bit seed and therefore completely different Ed25519 keys. The passphrase is **never stored** — it must be supplied at every derivation. Passphrase delivery uses safe channels only — ``--passphrase PHRASE`` was removed because it exposes the secret in ``ps aux`` / ``/proc/pid/cmdline``. Safe alternatives in priority order: 1. ``--passphrase-fd N`` — pipe fd (never in process table) 2. ``MUSE_BIP39_PASSPHRASE`` — env var (visible to owner in /proc/pid/environ) 3. Interactive ``getpass`` — TTY only, no echo Coverage -------- I keygen --passphrase-fd I1 --passphrase-fd changes the derived fingerprint vs no passphrase I2 same mnemonic + same passphrase → same fingerprint (deterministic) I3 same mnemonic + different passphrase → different fingerprint II keygen MUSE_BIP39_PASSPHRASE env var II1 env var used when --passphrase-fd not given II2 --passphrase-fd takes priority over env var III recover --passphrase-fd III1 recover without passphrase → different fingerprint than keygen with passphrase III2 recover with same passphrase → matches keygen fingerprint exactly III3 recover via MUSE_BIP39_PASSPHRASE env var → matches keygen fingerprint IV passphrase never stored IV1 passphrase not in identity.toml after keygen IV2 passphrase not in JSON stdout after keygen --json """ from __future__ import annotations import json import os import pathlib 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: """Redirect all key/identity I/O to a temp directory.""" 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: """Patch generate_mnemonic to return a fixed phrase so tests are deterministic.""" 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_args: list[str] | None = None) -> InvokeResult: return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra_args or [])) def _recover(extra_args: list[str] | None = None) -> InvokeResult: return runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force", "--json"] + (extra_args or []), input=_MNEMONIC + "\n") def _fingerprint(result: InvokeResult) -> str: data = json.loads(result.output.splitlines()[0]) # type: ignore[union-attr] return data["fingerprint"] # --------------------------------------------------------------------------- # I keygen --passphrase-fd # --------------------------------------------------------------------------- class TestKeygenPassphrase: def test_I1_passphrase_changes_fingerprint( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I1: --passphrase-fd produces a different fingerprint than no passphrase.""" result_plain = _keygen() assert result_plain.exit_code == 0, result_plain.output # type: ignore[union-attr] fp_plain = _fingerprint(result_plain) result_pass = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"]) assert result_pass.exit_code == 0, result_pass.output # type: ignore[union-attr] fp_pass = _fingerprint(result_pass) assert fp_plain != fp_pass, ( "Same mnemonic with and without passphrase must produce different fingerprints" ) def test_I2_same_passphrase_deterministic( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I2: same mnemonic + same passphrase always yields the same fingerprint.""" r1 = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))]) assert r1.exit_code == 0, r1.output # type: ignore[union-attr] fp1 = _fingerprint(r1) r2 = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"]) assert r2.exit_code == 0, r2.output # type: ignore[union-attr] fp2 = _fingerprint(r2) assert fp1 == fp2, "Same mnemonic + same passphrase must be deterministic" def test_I3_different_passphrases_different_fingerprints( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """I3: different passphrases → different fingerprints.""" r1 = _keygen(["--passphrase-fd", str(_pipe_passphrase("alpha"))]) r2 = _keygen(["--passphrase-fd", str(_pipe_passphrase("beta")), "--force"]) assert r1.exit_code == 0 and r2.exit_code == 0 assert _fingerprint(r1) != _fingerprint(r2) # --------------------------------------------------------------------------- # II keygen MUSE_BIP39_PASSPHRASE env var # --------------------------------------------------------------------------- class TestKeygenPassphraseEnvVar: def test_II1_env_var_used_when_fd_absent( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """II1: MUSE_BIP39_PASSPHRASE env var is used when --passphrase-fd is not given.""" # Derive reference fingerprint via fd r_fd = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_fd.exit_code == 0, r_fd.output # type: ignore[union-attr] fp_fd = _fingerprint(r_fd) # Derive via env var (should match) monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret") r_env = _keygen(["--force"]) assert r_env.exit_code == 0, r_env.output # type: ignore[union-attr] fp_env = _fingerprint(r_env) assert fp_fd == fp_env, ( "MUSE_BIP39_PASSPHRASE env var must produce the same result as --passphrase-fd" ) def test_II2_fd_takes_priority_over_env_var( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """II2: --passphrase-fd takes priority over MUSE_BIP39_PASSPHRASE env var.""" monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value") r = _keygen(["--passphrase-fd", str(_pipe_passphrase("flag-value"))]) assert r.exit_code == 0, r.output # type: ignore[union-attr] # Result must match fd value, not env var r_fd_only = _keygen(["--passphrase-fd", str(_pipe_passphrase("flag-value")), "--force"]) assert r_fd_only.exit_code == 0 assert _fingerprint(r) == _fingerprint(r_fd_only) # --------------------------------------------------------------------------- # III recover --passphrase-fd # --------------------------------------------------------------------------- class TestRecoverPassphrase: def test_III1_recover_without_passphrase_differs_from_keygen_with_passphrase( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """III1: recover without passphrase ≠ keygen with passphrase.""" r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr] fp_keygen = _fingerprint(r_keygen) r_recover = _recover() # no passphrase assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr] fp_recover = _fingerprint(r_recover) assert fp_keygen != fp_recover, ( "recover without passphrase must not match keygen with passphrase" ) def test_III2_recover_with_same_passphrase_matches( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """III2: recover with the same passphrase reproduces the exact same fingerprint.""" r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr] fp_keygen = _fingerprint(r_keygen) r_recover = _recover(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr] fp_recover = _fingerprint(r_recover) assert fp_keygen == fp_recover, ( "recover with same mnemonic+passphrase must reproduce the exact keygen fingerprint" ) def test_III3_recover_via_env_var_matches( self, isolated: pathlib.Path, fixed_mnemonic: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """III3: MUSE_BIP39_PASSPHRASE env var works for recover too.""" r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))]) assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr] fp_keygen = _fingerprint(r_keygen) monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret") r_recover = _recover() assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr] fp_recover = _fingerprint(r_recover) assert fp_keygen == fp_recover # --------------------------------------------------------------------------- # IV passphrase never stored # --------------------------------------------------------------------------- class TestPassphraseNeverStored: def test_IV1_passphrase_not_in_identity_toml( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """IV1: passphrase must not appear in identity.toml.""" r = _keygen(["--passphrase-fd", str(_pipe_passphrase("super-secret-passphrase"))]) assert r.exit_code == 0, r.output # type: ignore[union-attr] toml_path = isolated / ".muse" / "identity.toml" assert toml_path.exists(), "identity.toml must exist after keygen" content = toml_path.read_text() assert "super-secret-passphrase" not in content, ( "Passphrase must never be written to identity.toml" ) def test_IV2_passphrase_not_in_json_stdout( self, isolated: pathlib.Path, fixed_mnemonic: str ) -> None: """IV2: passphrase must not appear in JSON stdout.""" r = _keygen(["--passphrase-fd", str(_pipe_passphrase("super-secret-passphrase"))]) assert r.exit_code == 0, r.output # type: ignore[union-attr] assert "super-secret-passphrase" not in r.output, ( # type: ignore[union-attr] "Passphrase must never appear in stdout" )