gabriel / muse public
test_bip39_passphrase.py python
276 lines 11.4 KB
Raw
1 """Tests for BIP-39 passphrase support in CLI commands — HIGH-3.
2
3 The BIP-39 spec defines an optional passphrase ("25th word") that is mixed
4 into the PBKDF2 seed derivation. The same mnemonic + different passphrase
5 produces a completely different 512-bit seed and therefore completely different
6 Ed25519 keys. The passphrase is **never stored** — it must be supplied at
7 every derivation.
8
9 Passphrase delivery uses safe channels only — ``--passphrase PHRASE`` was
10 removed because it exposes the secret in ``ps aux`` / ``/proc/pid/cmdline``.
11 Safe alternatives in priority order:
12
13 1. ``--passphrase-fd N`` — pipe fd (never in process table)
14 2. ``MUSE_BIP39_PASSPHRASE`` — env var (visible to owner in /proc/pid/environ)
15 3. Interactive ``getpass`` — TTY only, no echo
16
17 Coverage
18 --------
19 I keygen --passphrase-fd
20 I1 --passphrase-fd changes the derived fingerprint vs no passphrase
21 I2 same mnemonic + same passphrase → same fingerprint (deterministic)
22 I3 same mnemonic + different passphrase → different fingerprint
23
24 II keygen MUSE_BIP39_PASSPHRASE env var
25 II1 env var used when --passphrase-fd not given
26 II2 --passphrase-fd takes priority over env var
27
28 III recover --passphrase-fd
29 III1 recover without passphrase → different fingerprint than keygen with passphrase
30 III2 recover with same passphrase → matches keygen fingerprint exactly
31 III3 recover via MUSE_BIP39_PASSPHRASE env var → matches keygen fingerprint
32
33 IV passphrase never stored
34 IV1 passphrase not in identity.toml after keygen
35 IV2 passphrase not in JSON stdout after keygen --json
36 """
37
38 from __future__ import annotations
39
40 import json
41 import os
42 import pathlib
43
44 import pytest
45
46 from tests.cli_test_helper import CliRunner, InvokeResult
47 from muse.core import keypair as kp_module
48 from muse.core import identity as id_module
49
50 runner = CliRunner()
51
52 _HUB = "https://localhost:1337"
53 _MNEMONIC = (
54 "abandon abandon abandon abandon abandon abandon abandon abandon "
55 "abandon abandon abandon about"
56 )
57
58
59 # ---------------------------------------------------------------------------
60 # Fixtures
61 # ---------------------------------------------------------------------------
62
63
64 @pytest.fixture()
65 def isolated(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> pathlib.Path:
66 """Redirect all key/identity I/O to a temp directory."""
67 fake_home = tmp_path / "home"
68 fake_home.mkdir(parents=True, exist_ok=True)
69 monkeypatch.setattr(pathlib.Path, "home", staticmethod(lambda: fake_home))
70 monkeypatch.setattr(kp_module, "_KEYS_DIR", fake_home / ".muse" / "keys")
71 monkeypatch.setattr(id_module, "_IDENTITY_DIR", fake_home / ".muse")
72 monkeypatch.setattr(id_module, "_IDENTITY_FILE", fake_home / ".muse" / "identity.toml")
73 return fake_home
74
75
76 @pytest.fixture()
77 def fixed_mnemonic(monkeypatch: pytest.MonkeyPatch) -> str:
78 """Patch generate_mnemonic to return a fixed phrase so tests are deterministic."""
79 from muse.core import bip39 as bip39_mod
80 monkeypatch.setattr(bip39_mod, "generate_mnemonic", lambda **kw: _MNEMONIC)
81 return _MNEMONIC
82
83
84 def _pipe_passphrase(passphrase: str) -> int:
85 """Write *passphrase* into a pipe; return the read-end fd."""
86 r_fd, w_fd = os.pipe()
87 os.write(w_fd, passphrase.encode())
88 os.close(w_fd)
89 return r_fd
90
91
92 def _keygen(extra_args: list[str] | None = None) -> InvokeResult:
93 return runner.invoke(None, ["auth", "keygen", "--hub", _HUB, "--json"] + (extra_args or []))
94
95
96 def _recover(extra_args: list[str] | None = None) -> InvokeResult:
97 return runner.invoke(None, ["auth", "recover", "--hub", _HUB, "--force", "--json"] + (extra_args or []),
98 input=_MNEMONIC + "\n")
99
100
101 def _fingerprint(result: InvokeResult) -> str:
102 data = json.loads(result.output.splitlines()[0]) # type: ignore[union-attr]
103 return data["fingerprint"]
104
105
106 # ---------------------------------------------------------------------------
107 # I keygen --passphrase-fd
108 # ---------------------------------------------------------------------------
109
110
111 class TestKeygenPassphrase:
112 def test_I1_passphrase_changes_fingerprint(
113 self, isolated: pathlib.Path, fixed_mnemonic: str
114 ) -> None:
115 """I1: --passphrase-fd produces a different fingerprint than no passphrase."""
116 result_plain = _keygen()
117 assert result_plain.exit_code == 0, result_plain.output # type: ignore[union-attr]
118 fp_plain = _fingerprint(result_plain)
119
120 result_pass = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"])
121 assert result_pass.exit_code == 0, result_pass.output # type: ignore[union-attr]
122 fp_pass = _fingerprint(result_pass)
123
124 assert fp_plain != fp_pass, (
125 "Same mnemonic with and without passphrase must produce different fingerprints"
126 )
127
128 def test_I2_same_passphrase_deterministic(
129 self, isolated: pathlib.Path, fixed_mnemonic: str
130 ) -> None:
131 """I2: same mnemonic + same passphrase always yields the same fingerprint."""
132 r1 = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2"))])
133 assert r1.exit_code == 0, r1.output # type: ignore[union-attr]
134 fp1 = _fingerprint(r1)
135
136 r2 = _keygen(["--passphrase-fd", str(_pipe_passphrase("hunter2")), "--force"])
137 assert r2.exit_code == 0, r2.output # type: ignore[union-attr]
138 fp2 = _fingerprint(r2)
139
140 assert fp1 == fp2, "Same mnemonic + same passphrase must be deterministic"
141
142 def test_I3_different_passphrases_different_fingerprints(
143 self, isolated: pathlib.Path, fixed_mnemonic: str
144 ) -> None:
145 """I3: different passphrases → different fingerprints."""
146 r1 = _keygen(["--passphrase-fd", str(_pipe_passphrase("alpha"))])
147 r2 = _keygen(["--passphrase-fd", str(_pipe_passphrase("beta")), "--force"])
148 assert r1.exit_code == 0 and r2.exit_code == 0
149 assert _fingerprint(r1) != _fingerprint(r2)
150
151
152 # ---------------------------------------------------------------------------
153 # II keygen MUSE_BIP39_PASSPHRASE env var
154 # ---------------------------------------------------------------------------
155
156
157 class TestKeygenPassphraseEnvVar:
158 def test_II1_env_var_used_when_fd_absent(
159 self, isolated: pathlib.Path, fixed_mnemonic: str,
160 monkeypatch: pytest.MonkeyPatch,
161 ) -> None:
162 """II1: MUSE_BIP39_PASSPHRASE env var is used when --passphrase-fd is not given."""
163 # Derive reference fingerprint via fd
164 r_fd = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
165 assert r_fd.exit_code == 0, r_fd.output # type: ignore[union-attr]
166 fp_fd = _fingerprint(r_fd)
167
168 # Derive via env var (should match)
169 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret")
170 r_env = _keygen(["--force"])
171 assert r_env.exit_code == 0, r_env.output # type: ignore[union-attr]
172 fp_env = _fingerprint(r_env)
173
174 assert fp_fd == fp_env, (
175 "MUSE_BIP39_PASSPHRASE env var must produce the same result as --passphrase-fd"
176 )
177
178 def test_II2_fd_takes_priority_over_env_var(
179 self, isolated: pathlib.Path, fixed_mnemonic: str,
180 monkeypatch: pytest.MonkeyPatch,
181 ) -> None:
182 """II2: --passphrase-fd takes priority over MUSE_BIP39_PASSPHRASE env var."""
183 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "env-value")
184 r = _keygen(["--passphrase-fd", str(_pipe_passphrase("flag-value"))])
185 assert r.exit_code == 0, r.output # type: ignore[union-attr]
186
187 # Result must match fd value, not env var
188 r_fd_only = _keygen(["--passphrase-fd", str(_pipe_passphrase("flag-value")), "--force"])
189 assert r_fd_only.exit_code == 0
190 assert _fingerprint(r) == _fingerprint(r_fd_only)
191
192
193 # ---------------------------------------------------------------------------
194 # III recover --passphrase-fd
195 # ---------------------------------------------------------------------------
196
197
198 class TestRecoverPassphrase:
199 def test_III1_recover_without_passphrase_differs_from_keygen_with_passphrase(
200 self, isolated: pathlib.Path, fixed_mnemonic: str
201 ) -> None:
202 """III1: recover without passphrase ≠ keygen with passphrase."""
203 r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
204 assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr]
205 fp_keygen = _fingerprint(r_keygen)
206
207 r_recover = _recover() # no passphrase
208 assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr]
209 fp_recover = _fingerprint(r_recover)
210
211 assert fp_keygen != fp_recover, (
212 "recover without passphrase must not match keygen with passphrase"
213 )
214
215 def test_III2_recover_with_same_passphrase_matches(
216 self, isolated: pathlib.Path, fixed_mnemonic: str
217 ) -> None:
218 """III2: recover with the same passphrase reproduces the exact same fingerprint."""
219 r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
220 assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr]
221 fp_keygen = _fingerprint(r_keygen)
222
223 r_recover = _recover(["--passphrase-fd", str(_pipe_passphrase("secret"))])
224 assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr]
225 fp_recover = _fingerprint(r_recover)
226
227 assert fp_keygen == fp_recover, (
228 "recover with same mnemonic+passphrase must reproduce the exact keygen fingerprint"
229 )
230
231 def test_III3_recover_via_env_var_matches(
232 self, isolated: pathlib.Path, fixed_mnemonic: str,
233 monkeypatch: pytest.MonkeyPatch,
234 ) -> None:
235 """III3: MUSE_BIP39_PASSPHRASE env var works for recover too."""
236 r_keygen = _keygen(["--passphrase-fd", str(_pipe_passphrase("secret"))])
237 assert r_keygen.exit_code == 0, r_keygen.output # type: ignore[union-attr]
238 fp_keygen = _fingerprint(r_keygen)
239
240 monkeypatch.setenv("MUSE_BIP39_PASSPHRASE", "secret")
241 r_recover = _recover()
242 assert r_recover.exit_code == 0, r_recover.output # type: ignore[union-attr]
243 fp_recover = _fingerprint(r_recover)
244
245 assert fp_keygen == fp_recover
246
247
248 # ---------------------------------------------------------------------------
249 # IV passphrase never stored
250 # ---------------------------------------------------------------------------
251
252
253 class TestPassphraseNeverStored:
254 def test_IV1_passphrase_not_in_identity_toml(
255 self, isolated: pathlib.Path, fixed_mnemonic: str
256 ) -> None:
257 """IV1: passphrase must not appear in identity.toml."""
258 r = _keygen(["--passphrase-fd", str(_pipe_passphrase("super-secret-passphrase"))])
259 assert r.exit_code == 0, r.output # type: ignore[union-attr]
260
261 toml_path = isolated / ".muse" / "identity.toml"
262 assert toml_path.exists(), "identity.toml must exist after keygen"
263 content = toml_path.read_text()
264 assert "super-secret-passphrase" not in content, (
265 "Passphrase must never be written to identity.toml"
266 )
267
268 def test_IV2_passphrase_not_in_json_stdout(
269 self, isolated: pathlib.Path, fixed_mnemonic: str
270 ) -> None:
271 """IV2: passphrase must not appear in JSON stdout."""
272 r = _keygen(["--passphrase-fd", str(_pipe_passphrase("super-secret-passphrase"))])
273 assert r.exit_code == 0, r.output # type: ignore[union-attr]
274 assert "super-secret-passphrase" not in r.output, ( # type: ignore[union-attr]
275 "Passphrase must never appear in stdout"
276 )
File History 1 commit