gabriel / muse public
test_agent_key_fd.py python
423 lines 15.6 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
1 """Tests for fd-based agent key injection — Tier 3.
2
3 MUSE_AGENT_KEY_FD is the only supported env-var mechanism for injecting
4 a sub-seed into an agent subprocess. The old MUSE_AGENT_HD_SEED and
5 MUSE_AGENT_KEY env vars are removed.
6
7 Protocol:
8 1. Parent creates an anonymous pipe (r_fd, w_fd).
9 2. Parent writes exactly 64 bytes of sub-seed to w_fd, closes w_fd.
10 3. Parent sets MUSE_AGENT_KEY_FD=str(r_fd) and spawns child with pass_fds=(r_fd,).
11 4. Child (get_signing_identity) reads exactly 64 bytes from r_fd, closes r_fd.
12 5. Child derives Ed25519 private key from sub_seed via derive_identity_key.
13 6. Secret never appears in /proc/<pid>/environ.
14
15 Coverage
16 --------
17 I get_signing_identity — fd injection
18 I1 MUSE_AGENT_KEY_FD reads 64 bytes, returns valid SigningIdentity
19 I2 derived key is deterministic for the same sub-seed
20 I3 fd is closed after read (cannot be read a second time)
21 I4 MUSE_AGENT_HANDLE sets the identity handle
22 I5 handle defaults to "agent" when MUSE_AGENT_HANDLE is unset
23
24 II Priority and fallback
25 II1 MUSE_AGENT_KEY_FD takes priority over identity store
26 II2 falls through to identity store when MUSE_AGENT_KEY_FD is unset
27
28 III Error handling
29 III1 invalid fd number → falls through (does not crash)
30 III2 fd with wrong byte count → falls through
31 III3 MUSE_AGENT_HD_SEED is no longer recognised
32 III4 MUSE_AGENT_KEY is no longer recognised
33
34 IV Security
35 IV1 sub-seed does not appear in any log output
36 IV2 two different sub-seeds produce two different signing keys
37 """
38
39 from __future__ import annotations
40
41 import os
42 import pathlib
43
44 import pytest
45 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
46
47 _TEST_MNEMONIC = (
48 "abandon abandon abandon abandon abandon abandon abandon abandon "
49 "abandon abandon abandon about"
50 )
51
52
53 def _make_sub_seed(account: int = 1) -> bytes:
54 """Derive a real 64-byte IDENTITY-domain agent sub-seed."""
55 from muse.core.bip39 import mnemonic_to_seed
56 from muse.core.hdkeys import DOMAIN_IDENTITY, derive_agent_sub_seed
57 seed = mnemonic_to_seed(_TEST_MNEMONIC)
58 return derive_agent_sub_seed(seed, domain=DOMAIN_IDENTITY, agent_id=account)
59
60
61 def _pipe_with_seed(sub_seed: bytes) -> int:
62 """Create a pipe, write sub_seed, close write end, return read fd."""
63 r_fd, w_fd = os.pipe()
64 os.write(w_fd, sub_seed)
65 os.close(w_fd)
66 return r_fd
67
68
69 # ---------------------------------------------------------------------------
70 # I get_signing_identity — fd injection
71 # ---------------------------------------------------------------------------
72
73
74 class TestFdInjectionI:
75 def test_I1_fd_returns_signing_identity(
76 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
77 ) -> None:
78 """I1: MUSE_AGENT_KEY_FD yields a valid SigningIdentity."""
79 from muse.cli.config import get_signing_identity
80
81 sub_seed = _make_sub_seed(account=1)
82 r_fd = _pipe_with_seed(sub_seed)
83
84 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
85 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
86
87 result = get_signing_identity(repo_root=tmp_path)
88 try:
89 os.close(r_fd)
90 except OSError:
91 pass
92
93 assert result is not None
94 assert isinstance(result.private_key, Ed25519PrivateKey)
95
96 def test_I2_deterministic_key_from_same_seed(
97 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
98 ) -> None:
99 """I2: same sub-seed always produces the same signing key."""
100 from muse.cli.config import get_signing_identity
101
102 sub_seed = _make_sub_seed(account=2)
103
104 r_fd1 = _pipe_with_seed(sub_seed)
105 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd1))
106 result1 = get_signing_identity(repo_root=tmp_path)
107 try:
108 os.close(r_fd1)
109 except OSError:
110 pass
111
112 r_fd2 = _pipe_with_seed(sub_seed)
113 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd2))
114 result2 = get_signing_identity(repo_root=tmp_path)
115 try:
116 os.close(r_fd2)
117 except OSError:
118 pass
119
120 assert result1 is not None and result2 is not None
121 # Same key material → same public key bytes
122 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
123 pub1 = result1.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
124 pub2 = result2.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
125 assert pub1 == pub2
126
127 def test_I3_fd_closed_after_read(
128 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
129 ) -> None:
130 """I3: the fd is closed by get_signing_identity — cannot be read again."""
131 from muse.cli.config import get_signing_identity
132
133 sub_seed = _make_sub_seed(account=3)
134 r_fd = _pipe_with_seed(sub_seed)
135
136 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
137 get_signing_identity(repo_root=tmp_path)
138
139 # fd must be closed
140 with pytest.raises(OSError):
141 os.read(r_fd, 1)
142
143 def test_I4_muse_agent_handle_sets_handle(
144 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
145 ) -> None:
146 """I4: MUSE_AGENT_HANDLE sets the identity handle."""
147 from muse.cli.config import get_signing_identity
148
149 sub_seed = _make_sub_seed(account=4)
150 r_fd = _pipe_with_seed(sub_seed)
151
152 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
153 monkeypatch.setenv("MUSE_AGENT_HANDLE", "my-agent-001")
154
155 result = get_signing_identity(repo_root=tmp_path)
156 try:
157 os.close(r_fd)
158 except OSError:
159 pass
160
161 assert result is not None
162 assert result.handle == "my-agent-001"
163
164 def test_I5_default_handle_is_agent(
165 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
166 ) -> None:
167 """I5: handle defaults to 'agent' when MUSE_AGENT_HANDLE is not set."""
168 from muse.cli.config import get_signing_identity
169
170 sub_seed = _make_sub_seed(account=5)
171 r_fd = _pipe_with_seed(sub_seed)
172
173 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
174 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
175
176 result = get_signing_identity(repo_root=tmp_path)
177 try:
178 os.close(r_fd)
179 except OSError:
180 pass
181
182 assert result is not None
183 assert result.handle == "agent"
184
185
186 # ---------------------------------------------------------------------------
187 # II Priority and fallback
188 # ---------------------------------------------------------------------------
189
190
191 class TestPriorityII:
192 def test_II1_fd_takes_priority_over_identity_store(
193 self,
194 monkeypatch: pytest.MonkeyPatch,
195 tmp_path: pathlib.Path,
196 ) -> None:
197 """II1: MUSE_AGENT_KEY_FD takes priority over file-based identity."""
198 import muse.core.identity as id_mod
199 # Patch resolve_signing_identity so we know if it was called
200 called = []
201 orig = id_mod.resolve_signing_identity
202 monkeypatch.setattr(id_mod, "resolve_signing_identity",
203 lambda *a, **kw: (called.append(True), orig(*a, **kw))[1])
204
205 from muse.cli.config import get_signing_identity
206 sub_seed = _make_sub_seed(account=6)
207 r_fd = _pipe_with_seed(sub_seed)
208 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
209
210 result = get_signing_identity(repo_root=tmp_path)
211 try:
212 os.close(r_fd)
213 except OSError:
214 pass
215
216 assert result is not None
217 assert not called, "identity store was consulted despite MUSE_AGENT_KEY_FD being set"
218
219 def test_II2_falls_through_to_identity_store_when_unset(
220 self,
221 monkeypatch: pytest.MonkeyPatch,
222 tmp_path: pathlib.Path,
223 ) -> None:
224 """II2: without MUSE_AGENT_KEY_FD, falls through to identity store (returns None)."""
225 from muse.cli.config import get_signing_identity
226 monkeypatch.delenv("MUSE_AGENT_KEY_FD", raising=False)
227 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
228
229 # No identity configured → returns None
230 result = get_signing_identity(repo_root=tmp_path)
231 assert result is None
232
233
234 # ---------------------------------------------------------------------------
235 # III Error handling
236 # ---------------------------------------------------------------------------
237
238
239 class TestErrorHandlingIII:
240 def test_III1_invalid_fd_falls_through(
241 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
242 ) -> None:
243 """III1: an invalid fd number falls through gracefully (no crash)."""
244 from muse.cli.config import get_signing_identity
245 monkeypatch.setenv("MUSE_AGENT_KEY_FD", "9999") # certainly not open
246
247 # Should not raise — just falls through to identity store (returns None)
248 result = get_signing_identity(repo_root=tmp_path)
249 assert result is None
250
251 def test_III2_wrong_byte_count_falls_through(
252 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
253 ) -> None:
254 """III2: fewer than 64 bytes in the pipe → falls through."""
255 from muse.cli.config import get_signing_identity
256
257 r_fd, w_fd = os.pipe()
258 os.write(w_fd, b"\x00" * 32) # 32 bytes, not 64
259 os.close(w_fd)
260
261 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
262
263 result = get_signing_identity(repo_root=tmp_path)
264 try:
265 os.close(r_fd)
266 except OSError:
267 pass
268
269 assert result is None
270
271 def test_III3_muse_agent_hd_seed_not_recognised(
272 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
273 ) -> None:
274 """III3: MUSE_AGENT_HD_SEED is no longer supported — setting it has no effect."""
275 from muse.cli.config import get_signing_identity
276 from muse.core.bip39 import mnemonic_to_seed
277 from muse.core.hdkeys import DOMAIN_IDENTITY, derive_agent_sub_seed
278 import base64
279
280 sub_seed = _make_sub_seed(account=7)
281 seed_b64 = base64.urlsafe_b64encode(sub_seed).rstrip(b"=").decode()
282
283 monkeypatch.delenv("MUSE_AGENT_KEY_FD", raising=False)
284 monkeypatch.setenv("MUSE_AGENT_HD_SEED", seed_b64)
285
286 # Should NOT return a signing identity (env var is removed)
287 result = get_signing_identity(repo_root=tmp_path)
288 assert result is None, (
289 "MUSE_AGENT_HD_SEED should be ignored but returned a signing identity"
290 )
291
292 def test_III4_muse_agent_key_not_recognised(
293 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
294 ) -> None:
295 """III4: MUSE_AGENT_KEY (PEM env var) is no longer supported."""
296 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
297 from cryptography.hazmat.primitives.serialization import (
298 Encoding, PrivateFormat, NoEncryption,
299 )
300 from muse.cli.config import get_signing_identity
301
302 key = Ed25519PrivateKey.generate()
303 pem = key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()).decode()
304
305 monkeypatch.delenv("MUSE_AGENT_KEY_FD", raising=False)
306 monkeypatch.setenv("MUSE_AGENT_KEY", pem)
307
308 result = get_signing_identity(repo_root=tmp_path)
309 assert result is None, (
310 "MUSE_AGENT_KEY should be ignored but returned a signing identity"
311 )
312
313
314 # ---------------------------------------------------------------------------
315 # IV Security
316 # ---------------------------------------------------------------------------
317
318
319 class TestSecurityIV:
320 def test_IV1_sub_seed_not_in_environ(
321 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
322 ) -> None:
323 """IV1: the sub-seed bytes never appear in os.environ."""
324 from muse.cli.config import get_signing_identity
325
326 sub_seed = _make_sub_seed(account=8)
327 r_fd = _pipe_with_seed(sub_seed)
328 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
329
330 get_signing_identity(repo_root=tmp_path)
331 try:
332 os.close(r_fd)
333 except OSError:
334 pass
335
336 # The raw bytes and any base64 encoding of them must not be in environ
337 import base64
338 seed_b64 = base64.urlsafe_b64encode(sub_seed).decode()
339 for val in os.environ.values():
340 assert seed_b64 not in val, "sub-seed base64 found in os.environ"
341
342 def test_IV2_different_seeds_different_keys(
343 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
344 ) -> None:
345 """IV2: two different sub-seeds produce two different signing keys."""
346 from muse.cli.config import get_signing_identity
347 from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
348
349 sub_seed_a = _make_sub_seed(account=9)
350 sub_seed_b = _make_sub_seed(account=10)
351
352 r_fd_a = _pipe_with_seed(sub_seed_a)
353 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd_a))
354 result_a = get_signing_identity(repo_root=tmp_path)
355 try:
356 os.close(r_fd_a)
357 except OSError:
358 pass
359
360 r_fd_b = _pipe_with_seed(sub_seed_b)
361 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd_b))
362 result_b = get_signing_identity(repo_root=tmp_path)
363 try:
364 os.close(r_fd_b)
365 except OSError:
366 pass
367
368 assert result_a is not None and result_b is not None
369 pub_a = result_a.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
370 pub_b = result_b.private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
371 assert pub_a != pub_b, "Different sub-seeds produced identical keys"
372
373 def test_IV3_sub_seed_zeroed_after_use(
374 self, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
375 ) -> None:
376 """IV3: the sub-seed buffer must be zeroed in memory after key derivation.
377
378 CRITICAL-2: a lingering plaintext sub-seed in RAM can be recovered from
379 a core dump or via /proc/<pid>/mem. The buffer must be all-zero before
380 get_signing_identity returns.
381
382 Strategy: patch muse.core.hdkeys.derive_identity_key to capture a
383 reference to the buffer passed by the caller. After get_signing_identity
384 returns we verify:
385 1. The buffer is a bytearray (mutable, so it *can* be zeroed).
386 2. Every byte is 0x00 (was zeroed before returning).
387 """
388 import os
389 from unittest.mock import patch
390 from muse.cli.config import get_signing_identity
391 from muse.core import hdkeys as _hdkeys
392
393 sub_seed = _make_sub_seed(account=99)
394 r_fd = _pipe_with_seed(sub_seed)
395 monkeypatch.setenv("MUSE_AGENT_KEY_FD", str(r_fd))
396 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
397
398 captured: list[object] = []
399 original_derive = _hdkeys.derive_identity_key
400
401 def capturing_derive(seed): # type: ignore[no-untyped-def]
402 captured.append(seed) # keep a reference — will survive zeroing
403 return original_derive(seed)
404
405 with patch.object(_hdkeys, "derive_identity_key", side_effect=capturing_derive):
406 result = get_signing_identity(repo_root=tmp_path)
407
408 try:
409 os.close(r_fd)
410 except OSError:
411 pass
412
413 assert result is not None, "get_signing_identity must still succeed"
414 assert len(captured) == 1, "derive_identity_key must be called exactly once"
415
416 buf = captured[0]
417 assert isinstance(buf, bytearray), (
418 f"sub-seed must be passed as bytearray (got {type(buf).__name__}) "
419 "so it can be zeroed after use"
420 )
421 assert buf == bytearray(64), (
422 "sub-seed buffer must be all-zero after get_signing_identity returns"
423 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 140 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 143 days ago