gabriel / muse public
test_signing_hd_seed.py python
452 lines 17.8 KB
Raw
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor ⚠ breaking 146 days ago
1 """Tests for MUSE_AGENT_HD_SEED injection in get_signing_identity.
2
3 Covers the new HD sub-seed resolution step (step 1 in the resolution order)
4 added to muse.cli.config.get_signing_identity during Phase 2.
5
6 All eight categories:
7 1. Unit — base64url decode, 64-byte validation, key materialisation
8 2. Integration — get_signing_identity with MUSE_AGENT_HD_SEED set
9 3. E2E — full derivation → signing round-trip
10 4. Stress — 100 identity resolutions from HD seed
11 5. Data integrity — deterministic key, handle resolution order
12 6. Performance — resolution completes within budget
13 7. Security — wrong-length seed ignored, malformed seed ignored, no fallthrough to MUSE_AGENT_KEY on bad seed
14 8. Docstrings — get_signing_identity docstring mentions MUSE_AGENT_HD_SEED
15 """
16
17 from __future__ import annotations
18
19 import base64
20 import time
21
22 import pytest
23 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
24
25 # ---------------------------------------------------------------------------
26 # Constants — fixed test mnemonic (never used in production)
27 # ---------------------------------------------------------------------------
28
29 _TEST_MNEMONIC = (
30 "abandon abandon abandon abandon abandon abandon abandon abandon "
31 "abandon abandon abandon about"
32 )
33
34
35 # ---------------------------------------------------------------------------
36 # Helpers
37 # ---------------------------------------------------------------------------
38
39
40 def _make_hd_seed(account: int = 1) -> bytes:
41 """Derive a real 64-byte IDENTITY-domain agent sub-seed."""
42 from muse.core.bip39 import mnemonic_to_seed
43 from muse.core.hdkeys import DOMAIN_IDENTITY, derive_agent_sub_seed
44 seed = mnemonic_to_seed(_TEST_MNEMONIC)
45 return derive_agent_sub_seed(seed, domain=DOMAIN_IDENTITY, agent_id=account)
46
47
48 def _encode_hd_seed(sub_seed: bytes) -> str:
49 """base64url-encode a sub-seed (no padding, as the env var format requires)."""
50 return base64.urlsafe_b64encode(sub_seed).rstrip(b"=").decode()
51
52
53 # ---------------------------------------------------------------------------
54 # 1. Unit — low-level building blocks
55 # ---------------------------------------------------------------------------
56
57
58 class TestHdSeedDecode:
59 """Unit tests for sub-seed decode/validation logic."""
60
61 def test_64_byte_seed_decodes_correctly(self) -> None:
62 raw = _make_hd_seed(1)
63 encoded = _encode_hd_seed(raw)
64 decoded = base64.urlsafe_b64decode(encoded + "==")
65 assert decoded == raw
66
67 def test_non_64_byte_seed_identified(self) -> None:
68 short = b"\x00" * 32
69 encoded = _encode_hd_seed(short)
70 decoded = base64.urlsafe_b64decode(encoded + "==")
71 assert len(decoded) == 32 # not 64 → should be ignored by get_signing_identity
72
73 def test_derive_identity_key_from_sub_seed(self) -> None:
74 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
75 sub_seed = _make_hd_seed(1)
76 dk = derive_identity_key(sub_seed)
77 priv = dk_to_ed25519(dk)
78 assert isinstance(priv, Ed25519PrivateKey)
79
80 def test_public_key_is_32_bytes(self) -> None:
81 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
82 sub_seed = _make_hd_seed(1)
83 dk = derive_identity_key(sub_seed)
84 priv = dk_to_ed25519(dk)
85 pub = priv.public_key().public_bytes_raw()
86 assert len(pub) == 32
87
88
89 # ---------------------------------------------------------------------------
90 # 2. Integration — get_signing_identity with MUSE_AGENT_HD_SEED
91 # ---------------------------------------------------------------------------
92
93
94 class TestGetSigningIdentityHdSeed:
95 """Integration tests for the MUSE_AGENT_HD_SEED resolution path."""
96
97 def test_returns_signing_identity_when_hd_seed_set(
98 self, monkeypatch: pytest.MonkeyPatch
99 ) -> None:
100 from muse.cli.config import get_signing_identity
101 sub_seed = _make_hd_seed(1)
102 encoded = _encode_hd_seed(sub_seed)
103 monkeypatch.setenv("MUSE_AGENT_HD_SEED", encoded)
104 result = get_signing_identity()
105 assert result is not None
106
107 def test_result_has_private_key(self, monkeypatch: pytest.MonkeyPatch) -> None:
108 from muse.cli.config import get_signing_identity
109 sub_seed = _make_hd_seed(2)
110 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
111 identity = get_signing_identity()
112 assert hasattr(identity, "private_key")
113 assert isinstance(identity.private_key, Ed25519PrivateKey)
114
115 def test_result_handle_defaults_to_agent(
116 self, monkeypatch: pytest.MonkeyPatch
117 ) -> None:
118 from muse.cli.config import get_signing_identity
119 sub_seed = _make_hd_seed(1)
120 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
121 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
122 identity = get_signing_identity()
123 assert identity.handle == "agent"
124
125 def test_result_handle_from_muse_agent_handle(
126 self, monkeypatch: pytest.MonkeyPatch
127 ) -> None:
128 from muse.cli.config import get_signing_identity
129 sub_seed = _make_hd_seed(1)
130 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
131 monkeypatch.setenv("MUSE_AGENT_HANDLE", "orchestra-bot")
132 identity = get_signing_identity()
133 assert identity.handle == "orchestra-bot"
134
135 def test_result_handle_falls_back_to_agent_id_param(
136 self, monkeypatch: pytest.MonkeyPatch
137 ) -> None:
138 from muse.cli.config import get_signing_identity
139 sub_seed = _make_hd_seed(1)
140 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
141 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
142 identity = get_signing_identity(agent_id="run-abc123")
143 assert identity.handle == "run-abc123"
144
145 def test_hd_seed_takes_priority_over_muse_agent_key(
146 self, monkeypatch: pytest.MonkeyPatch
147 ) -> None:
148 """MUSE_AGENT_HD_SEED must win over MUSE_AGENT_KEY (step 1 > step 2)."""
149 from muse.cli.config import get_signing_identity
150 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
151
152 sub_seed = _make_hd_seed(1)
153 encoded = _encode_hd_seed(sub_seed)
154
155 # Derive expected public key from HD seed
156 dk = derive_identity_key(sub_seed)
157 expected_priv = dk_to_ed25519(dk)
158 expected_pub = expected_priv.public_key().public_bytes_raw()
159
160 # Also set a different MUSE_AGENT_KEY (should be ignored)
161 other_key = Ed25519PrivateKey.generate()
162 from cryptography.hazmat.primitives.serialization import (
163 Encoding, NoEncryption, PrivateFormat,
164 )
165 other_pem = other_key.private_bytes(
166 Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
167 ).decode()
168
169 monkeypatch.setenv("MUSE_AGENT_HD_SEED", encoded)
170 monkeypatch.setenv("MUSE_AGENT_KEY", other_pem)
171
172 identity = get_signing_identity()
173 actual_pub = identity.private_key.public_key().public_bytes_raw()
174 assert actual_pub == expected_pub, (
175 "HD seed should win; got a different public key (MUSE_AGENT_KEY leaked through)"
176 )
177
178
179 # ---------------------------------------------------------------------------
180 # 3. E2E — full derivation → sign → verify round-trip
181 # ---------------------------------------------------------------------------
182
183
184 class TestSignVerifyRoundtrip:
185 """End-to-end: derive from mnemonic → set env var → resolve identity → sign."""
186
187 def test_sign_and_verify(self, monkeypatch: pytest.MonkeyPatch) -> None:
188 from muse.cli.config import get_signing_identity
189
190 sub_seed = _make_hd_seed(1)
191 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
192 identity = get_signing_identity()
193
194 message = b"muse phase 2 hd seed e2e test"
195 signature = identity.private_key.sign(message)
196 # verify should not raise
197 identity.private_key.public_key().verify(signature, message)
198
199 def test_different_accounts_produce_different_signatures(
200 self, monkeypatch: pytest.MonkeyPatch
201 ) -> None:
202 from muse.cli.config import get_signing_identity
203
204 message = b"same message"
205
206 sub_seed_1 = _make_hd_seed(1)
207 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed_1))
208 id1 = get_signing_identity()
209 sig1 = id1.private_key.sign(message)
210
211 sub_seed_2 = _make_hd_seed(2)
212 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed_2))
213 id2 = get_signing_identity()
214 sig2 = id2.private_key.sign(message)
215
216 assert sig1 != sig2
217
218 def test_same_account_same_signature(
219 self, monkeypatch: pytest.MonkeyPatch
220 ) -> None:
221 """Deterministic: same seed → same key → same signature for same message."""
222 from muse.cli.config import get_signing_identity
223
224 sub_seed = _make_hd_seed(5)
225 message = b"determinism check"
226
227 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
228 id1 = get_signing_identity()
229 sig1 = id1.private_key.sign(message)
230
231 id2 = get_signing_identity()
232 sig2 = id2.private_key.sign(message)
233
234 assert sig1 == sig2
235
236
237 # ---------------------------------------------------------------------------
238 # 4. Stress — repeated resolutions
239 # ---------------------------------------------------------------------------
240
241
242 class TestStress:
243 """Stress tests."""
244
245 def test_100_resolutions_all_return_identity(
246 self, monkeypatch: pytest.MonkeyPatch
247 ) -> None:
248 from muse.cli.config import get_signing_identity
249 sub_seed = _make_hd_seed(1)
250 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
251 identities = [get_signing_identity() for _ in range(100)]
252 assert all(i is not None for i in identities)
253
254 def test_100_resolutions_all_same_pubkey(
255 self, monkeypatch: pytest.MonkeyPatch
256 ) -> None:
257 from muse.cli.config import get_signing_identity
258 sub_seed = _make_hd_seed(3)
259 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
260 pubs = {
261 get_signing_identity().private_key.public_key().public_bytes_raw()
262 for _ in range(100)
263 }
264 assert len(pubs) == 1, "Repeated resolution produced different public keys"
265
266
267 # ---------------------------------------------------------------------------
268 # 5. Data integrity
269 # ---------------------------------------------------------------------------
270
271
272 class TestDataIntegrity:
273 """Data integrity tests."""
274
275 def test_public_key_matches_direct_derivation(
276 self, monkeypatch: pytest.MonkeyPatch
277 ) -> None:
278 from muse.cli.config import get_signing_identity
279 from muse.core.hdkeys import derive_identity_key, dk_to_ed25519
280
281 sub_seed = _make_hd_seed(7)
282 dk = derive_identity_key(sub_seed)
283 expected_pub = dk_to_ed25519(dk).public_key().public_bytes_raw()
284
285 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
286 identity = get_signing_identity()
287 actual_pub = identity.private_key.public_key().public_bytes_raw()
288
289 assert actual_pub == expected_pub
290
291 def test_with_padding_variants(self, monkeypatch: pytest.MonkeyPatch) -> None:
292 """get_signing_identity must handle base64url with or without padding."""
293 from muse.cli.config import get_signing_identity
294 sub_seed = _make_hd_seed(1)
295 # Encode without padding (production format)
296 no_pad = base64.urlsafe_b64encode(sub_seed).rstrip(b"=").decode()
297 monkeypatch.setenv("MUSE_AGENT_HD_SEED", no_pad)
298 identity = get_signing_identity()
299 assert identity is not None
300
301 def test_handle_preference_order(self, monkeypatch: pytest.MonkeyPatch) -> None:
302 """MUSE_AGENT_HANDLE > agent_id param > 'agent' default."""
303 from muse.cli.config import get_signing_identity
304 sub_seed = _make_hd_seed(1)
305 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
306
307 # Neither set → "agent"
308 monkeypatch.delenv("MUSE_AGENT_HANDLE", raising=False)
309 assert get_signing_identity().handle == "agent"
310
311 # agent_id set → that value
312 assert get_signing_identity(agent_id="my-agent").handle == "my-agent"
313
314 # MUSE_AGENT_HANDLE overrides agent_id
315 monkeypatch.setenv("MUSE_AGENT_HANDLE", "env-handle")
316 assert get_signing_identity(agent_id="ignored").handle == "env-handle"
317
318
319 # ---------------------------------------------------------------------------
320 # 6. Performance
321 # ---------------------------------------------------------------------------
322
323
324 class TestPerformance:
325 """Performance tests."""
326
327 def test_single_resolution_under_2_seconds(
328 self, monkeypatch: pytest.MonkeyPatch
329 ) -> None:
330 from muse.cli.config import get_signing_identity
331 sub_seed = _make_hd_seed(1)
332 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
333 start = time.monotonic()
334 get_signing_identity()
335 elapsed = time.monotonic() - start
336 assert elapsed < 2.0, f"Single resolution took {elapsed:.3f}s"
337
338 def test_10_resolutions_under_5_seconds(
339 self, monkeypatch: pytest.MonkeyPatch
340 ) -> None:
341 from muse.cli.config import get_signing_identity
342 sub_seed = _make_hd_seed(1)
343 monkeypatch.setenv("MUSE_AGENT_HD_SEED", _encode_hd_seed(sub_seed))
344 start = time.monotonic()
345 for _ in range(10):
346 get_signing_identity()
347 elapsed = time.monotonic() - start
348 assert elapsed < 5.0, f"10 resolutions took {elapsed:.3f}s"
349
350
351 # ---------------------------------------------------------------------------
352 # 7. Security
353 # ---------------------------------------------------------------------------
354
355
356 class TestSecurity:
357 """Security tests."""
358
359 def test_wrong_length_seed_ignored(
360 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
361 ) -> None:
362 """A 32-byte seed must be silently ignored — no identity returned from it."""
363 from muse.cli.config import get_signing_identity
364 monkeypatch.chdir(tmp_path) # no config.toml → no hub fallback
365 short = base64.urlsafe_b64encode(b"\x00" * 32).rstrip(b"=").decode()
366 monkeypatch.setenv("MUSE_AGENT_HD_SEED", short)
367 monkeypatch.delenv("MUSE_AGENT_KEY", raising=False)
368 # Should return None (no hub configured, wrong-length seed ignored)
369 result = get_signing_identity()
370 assert result is None
371
372 def test_malformed_base64_ignored(
373 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
374 ) -> None:
375 """Non-base64 content must be silently ignored."""
376 from muse.cli.config import get_signing_identity
377 monkeypatch.chdir(tmp_path)
378 monkeypatch.setenv("MUSE_AGENT_HD_SEED", "!!!not-base64!!!")
379 monkeypatch.delenv("MUSE_AGENT_KEY", raising=False)
380 result = get_signing_identity()
381 assert result is None
382
383 def test_empty_hd_seed_skips_to_next_step(
384 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
385 ) -> None:
386 """Empty MUSE_AGENT_HD_SEED should fall through to MUSE_AGENT_KEY."""
387 from muse.cli.config import get_signing_identity
388 monkeypatch.chdir(tmp_path)
389 monkeypatch.setenv("MUSE_AGENT_HD_SEED", "")
390
391 # Provide a valid MUSE_AGENT_KEY so we can confirm fall-through happened
392 good_key = Ed25519PrivateKey.generate()
393 from cryptography.hazmat.primitives.serialization import (
394 Encoding, NoEncryption, PrivateFormat,
395 )
396 pem = good_key.private_bytes(
397 Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()
398 ).decode()
399 monkeypatch.setenv("MUSE_AGENT_KEY", pem)
400 monkeypatch.setenv("MUSE_AGENT_HANDLE", "fallthrough-agent")
401
402 identity = get_signing_identity()
403 assert identity is not None
404 assert identity.handle == "fallthrough-agent"
405 assert (
406 identity.private_key.public_key().public_bytes_raw()
407 == good_key.public_key().public_bytes_raw()
408 )
409
410 def test_hd_seed_env_var_not_logged_at_debug(
411 self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
412 ) -> None:
413 """The raw sub-seed bytes must never appear in log output."""
414 import logging
415 from muse.cli.config import get_signing_identity
416 sub_seed = _make_hd_seed(1)
417 encoded = _encode_hd_seed(sub_seed)
418 monkeypatch.setenv("MUSE_AGENT_HD_SEED", encoded)
419 with caplog.at_level(logging.DEBUG, logger="muse.cli.config"):
420 get_signing_identity()
421 # The raw encoded seed must not appear verbatim in any log record
422 for record in caplog.records:
423 assert encoded not in record.getMessage()
424
425
426 # ---------------------------------------------------------------------------
427 # 8. Docstrings
428 # ---------------------------------------------------------------------------
429
430
431 class TestDocstrings:
432 """Verify get_signing_identity documents the MUSE_AGENT_HD_SEED step."""
433
434 def test_get_signing_identity_mentions_hd_seed(self) -> None:
435 from muse.cli.config import get_signing_identity
436 doc = get_signing_identity.__doc__ or ""
437 assert "MUSE_AGENT_HD_SEED" in doc, (
438 "get_signing_identity docstring must document the MUSE_AGENT_HD_SEED "
439 "resolution step."
440 )
441
442 def test_get_signing_identity_has_resolution_order(self) -> None:
443 from muse.cli.config import get_signing_identity
444 doc = get_signing_identity.__doc__ or ""
445 assert "Resolution order" in doc or "resolution order" in doc.lower()
446
447 def test_get_signing_identity_mentions_muse_agent_key(self) -> None:
448 from muse.cli.config import get_signing_identity
449 doc = get_signing_identity.__doc__ or ""
450 assert "MUSE_AGENT_KEY" in doc, (
451 "get_signing_identity docstring must mention MUSE_AGENT_KEY (legacy step 2)"
452 )
File History 1 commit
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 146 days ago