gabriel / muse public

test_agent_signing.py file-level

at sha256:d · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
1 """Tests for agent-first signing β€” compound identity keys and key paths.
2
3 Covers:
4 - identity.py: compound key load/save/clear, provisioned_by field
5 - keypair.py: agent-specific key paths and HD key generation
6 - resolve_signing_identity: derives key from mnemonic in memory (no PEM read)
7
8 Phase 2 target architecture
9 ----------------------------
10 resolve_signing_identity(hub_url)
11 β†’ load identity.toml entry (handle, hd_path)
12 β†’ kc_load() (mnemonic from OS keychain)
13 β†’ mnemonic_to_seed()
14 β†’ derive_path(seed, hd_path) β†’ Ed25519PrivateKey [in memory]
15 β†’ dk.zero()
16 β†’ return (handle, private_key)
17
18 No PEM file is ever read or written.
19 """
20 from __future__ import annotations
21
22 import pathlib
23 import json
24
25 import pytest
26
27 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
28
29 from muse.core.identity import (
30 IdentityEntry,
31 _identity_key,
32 clear_identity,
33 hostname_from_url,
34 list_all_identities,
35 load_identity,
36 resolve_signing_identity,
37 save_identity,
38 )
39 from muse.core.keypair import derive_hd_public_info
40
41 # Fixed 64-byte seeds for deterministic test keys
42 _SEED_A = b"\x00" * 64
43 _SEED_B = b"\x01" * 64
44
45
46 # ---------------------------------------------------------------------------
47 # Fixtures
48 # ---------------------------------------------------------------------------
49
50
51 @pytest.fixture()
52 def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
53 """Redirect the identity store to a temp directory for test isolation."""
54 import muse.core.identity as _id_mod
55 identity_dir = tmp_path / ".muse"
56 identity_dir.mkdir()
57 monkeypatch.setattr(_id_mod, "_IDENTITY_DIR", identity_dir)
58 monkeypatch.setattr(_id_mod, "_IDENTITY_FILE", identity_dir / "identity.toml")
59 return identity_dir
60
61
62 @pytest.fixture()
63 def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
64 """Redirect key storage to a temp directory."""
65 import muse.core.keypair as _kp_mod
66 keys_dir = tmp_path / ".muse" / "keys"
67 keys_dir.mkdir(parents=True)
68 monkeypatch.setattr(_kp_mod, "_KEYS_DIR", keys_dir)
69 return keys_dir
70
71
72 # ---------------------------------------------------------------------------
73 # _identity_key helper
74 # ---------------------------------------------------------------------------
75
76
77 class TestIdentityKey:
78 def test_human_key_is_bare_hostname(self) -> None:
79 assert _identity_key("localhost:1337") == "localhost:1337"
80
81 def test_agent_key_uses_hash_separator(self) -> None:
82 assert _identity_key("localhost:1337", "agent-abc") == "localhost:1337#agent-abc"
83
84 def test_none_agent_id_gives_bare_hostname(self) -> None:
85 assert _identity_key("musehub.ai", None) == "musehub.ai"
86
87 def test_empty_agent_id_gives_bare_hostname(self) -> None:
88 # empty string is falsy
89 assert _identity_key("musehub.ai", "") == "musehub.ai"
90
91
92 # ---------------------------------------------------------------------------
93 # load_identity / save_identity compound keys
94 # ---------------------------------------------------------------------------
95
96
97 class TestCompoundIdentityKeys:
98 def test_human_and_agent_entries_coexist(
99 self, isolated_identity: pathlib.Path
100 ) -> None:
101 hub = "https://localhost:1337"
102
103 human: IdentityEntry = {
104 "type": "human",
105 "handle": "gabriel",
106 "algorithm": "ed25519",
107 "fingerprint": "a" * 64,
108 }
109 agent: IdentityEntry = {
110 "type": "agent",
111 "handle": "agentception-abc",
112 "algorithm": "ed25519",
113 "fingerprint": "b" * 64,
114 "provisioned_by": "gabriel",
115 }
116
117 save_identity(hub, human)
118 save_identity(hub, agent, agent_id="agentception-abc")
119
120 loaded_human = load_identity(hub)
121 loaded_agent = load_identity(hub, agent_id="agentception-abc")
122
123 assert loaded_human is not None
124 assert loaded_human["handle"] == "gabriel"
125 assert loaded_human["type"] == "human"
126
127 assert loaded_agent is not None
128 assert loaded_agent["handle"] == "agentception-abc"
129 assert loaded_agent["type"] == "agent"
130 assert loaded_agent.get("provisioned_by") == "gabriel"
131
132 def test_agent_entry_does_not_shadow_human(
133 self, isolated_identity: pathlib.Path
134 ) -> None:
135 hub = "https://localhost:1337"
136 human: IdentityEntry = {"type": "human", "handle": "gabriel"}
137 save_identity(hub, human)
138
139 # Load without agent_id β†’ human entry
140 loaded = load_identity(hub)
141 assert loaded is not None
142 assert loaded["handle"] == "gabriel"
143
144 def test_load_missing_agent_returns_none(
145 self, isolated_identity: pathlib.Path
146 ) -> None:
147 hub = "https://localhost:1337"
148 assert load_identity(hub, agent_id="nonexistent") is None
149
150 def test_clear_agent_identity_leaves_human_intact(
151 self, isolated_identity: pathlib.Path
152 ) -> None:
153 hub = "https://localhost:1337"
154 human: IdentityEntry = {"type": "human", "handle": "gabriel"}
155 agent: IdentityEntry = {"type": "agent", "handle": "agentception-abc", "provisioned_by": "gabriel"}
156
157 save_identity(hub, human)
158 save_identity(hub, agent, agent_id="agentception-abc")
159
160 cleared = clear_identity(hub, agent_id="agentception-abc")
161 assert cleared is True
162
163 assert load_identity(hub) is not None # human still present
164 assert load_identity(hub, agent_id="agentception-abc") is None # agent gone
165
166 def test_provisioned_by_roundtrip(
167 self, isolated_identity: pathlib.Path
168 ) -> None:
169 """provisioned_by survives a save/load cycle."""
170 hub = "https://localhost:1337"
171 agent: IdentityEntry = {
172 "type": "agent",
173 "handle": "bot-001",
174 "provisioned_by": "gabriel",
175 "algorithm": "ed25519",
176 "fingerprint": "c" * 64,
177 }
178 save_identity(hub, agent, agent_id="bot-001")
179 loaded = load_identity(hub, agent_id="bot-001")
180 assert loaded is not None
181 assert loaded.get("provisioned_by") == "gabriel"
182
183 def test_list_all_includes_compound_keys(
184 self, isolated_identity: pathlib.Path
185 ) -> None:
186 hub = "https://localhost:1337"
187 save_identity(hub, {"type": "human", "handle": "gabriel"})
188 save_identity(hub, {"type": "agent", "handle": "bot"}, agent_id="bot")
189
190 all_ids = list_all_identities()
191 assert "localhost:1337" in all_ids
192 assert "localhost:1337#bot" in all_ids
193
194
195 # ---------------------------------------------------------------------------
196 # keypair β€” different seeds produce different keys
197 # ---------------------------------------------------------------------------
198
199
200 class TestKeypairDistinctness:
201 def test_different_seeds_produce_different_fingerprints(self) -> None:
202 _, fp_a = derive_hd_public_info(_SEED_A)
203 _, fp_b = derive_hd_public_info(_SEED_B)
204 assert fp_a != fp_b # different seeds β†’ different HD derivations
205
206 def test_same_seed_produces_same_fingerprint(self) -> None:
207 _, fp1 = derive_hd_public_info(_SEED_A)
208 _, fp2 = derive_hd_public_info(_SEED_A)
209 assert fp1 == fp2 # deterministic
210
211
212 # ---------------------------------------------------------------------------
213 # resolve_signing_identity β€” agent key β†’ fallback chain
214 # ---------------------------------------------------------------------------
215
216
217 _RSI_MNEMONIC = (
218 "abandon abandon abandon abandon abandon abandon abandon abandon "
219 "abandon abandon abandon about"
220 )
221 _RSI_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'"
222 _RSI_AGENT_HD_PATH = "m/1075233755'/0'/0'/0'/0'/1'"
223
224
225 class TestResolveSigningIdentity:
226 def test_agent_key_used_when_registered(
227 self,
228 isolated_identity: pathlib.Path,
229 isolated_keys: pathlib.Path,
230 monkeypatch: pytest.MonkeyPatch,
231 ) -> None:
232 import muse.core.keychain as kc
233 monkeypatch.setattr(kc, "is_available", lambda: True)
234 monkeypatch.setattr(kc, "load", lambda: _RSI_MNEMONIC)
235
236 hub = "https://localhost:1337"
237 agent_id = "agentception-abc"
238
239 save_identity(
240 hub,
241 {
242 "type": "agent",
243 "handle": agent_id,
244 "algorithm": "ed25519",
245 "hd_path": _RSI_AGENT_HD_PATH,
246 "fingerprint": "b" * 64,
247 },
248 agent_id=agent_id,
249 )
250
251 result = resolve_signing_identity(hub, agent_id=agent_id)
252 assert result is not None
253 handle, private_key = result
254 assert handle == agent_id
255 assert isinstance(private_key, Ed25519PrivateKey)
256
257 def test_falls_back_to_human_when_no_agent_key(
258 self,
259 isolated_identity: pathlib.Path,
260 isolated_keys: pathlib.Path,
261 monkeypatch: pytest.MonkeyPatch,
262 ) -> None:
263 import muse.core.keychain as kc
264 monkeypatch.setattr(kc, "is_available", lambda: True)
265 monkeypatch.setattr(kc, "load", lambda: _RSI_MNEMONIC)
266
267 hub = "https://localhost:1337"
268
269 # Only human entry registered β€” no agent entry
270 save_identity(
271 hub,
272 {
273 "type": "human",
274 "handle": "gabriel",
275 "algorithm": "ed25519",
276 "hd_path": _RSI_HD_PATH,
277 "fingerprint": "a" * 64,
278 },
279 )
280
281 # Ask for agent signing but no agent entry β†’ falls back to human
282 result = resolve_signing_identity(hub, agent_id="unregistered-agent")
283 assert result is not None
284 handle, _ = result
285 assert handle == "gabriel"
286
287 def test_no_identity_returns_none(
288 self, isolated_identity: pathlib.Path
289 ) -> None:
290 assert resolve_signing_identity("https://localhost:1337") is None
291
292 def test_human_resolve_without_agent_id(
293 self,
294 isolated_identity: pathlib.Path,
295 isolated_keys: pathlib.Path,
296 monkeypatch: pytest.MonkeyPatch,
297 ) -> None:
298 import muse.core.keychain as kc
299 monkeypatch.setattr(kc, "is_available", lambda: True)
300 monkeypatch.setattr(kc, "load", lambda: _RSI_MNEMONIC)
301
302 hub = "https://localhost:1337"
303 save_identity(
304 hub,
305 {
306 "type": "human",
307 "handle": "gabriel",
308 "algorithm": "ed25519",
309 "hd_path": _RSI_HD_PATH,
310 "fingerprint": "a" * 64,
311 },
312 )
313 result = resolve_signing_identity(hub)
314 assert result is not None
315 handle, _ = result
316 assert handle == "gabriel"
317
318
319 # ---------------------------------------------------------------------------
320 # Phase 2 β€” resolve_signing_identity derives from mnemonic, never reads PEM
321 # ---------------------------------------------------------------------------
322
323 _TEST_MNEMONIC = (
324 "abandon abandon abandon abandon abandon abandon abandon abandon "
325 "abandon abandon abandon about"
326 )
327 _TEST_HUB = "https://localhost:1337"
328 _TEST_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'"
329
330
331 @pytest.fixture()
332 def keychain_with_mnemonic(monkeypatch: pytest.MonkeyPatch) -> None:
333 """Patch keychain to return _TEST_MNEMONIC from the global slot."""
334 import muse.core.keychain as kc
335 monkeypatch.setattr(kc, "is_available", lambda: True)
336 monkeypatch.setattr(kc, "load", lambda: _TEST_MNEMONIC)
337
338
339 def _expected_private_key() -> Ed25519PrivateKey:
340 """Derive the Ed25519 key that _TEST_MNEMONIC produces at _TEST_HD_PATH."""
341 from muse.core.bip39 import mnemonic_to_seed
342 from muse.core.slip010 import derive_path, to_ed25519_private_key
343 seed = mnemonic_to_seed(_TEST_MNEMONIC)
344 dk = derive_path(seed, _TEST_HD_PATH)
345 key = to_ed25519_private_key(dk)
346 dk.zero()
347 return key
348
349
350 class TestResolveSigningIdentityPhase2:
351 """Phase 2: resolve_signing_identity derives from keychain mnemonic β€” no PEM."""
352
353 def test_P2_1_derives_key_from_mnemonic_not_pem(
354 self,
355 isolated_identity: pathlib.Path,
356 isolated_keys: pathlib.Path,
357 keychain_with_mnemonic: None,
358 monkeypatch: pytest.MonkeyPatch,
359 ) -> None:
360 """P2-1: returns a key even when no PEM file exists on disk."""
361 save_identity(
362 _TEST_HUB,
363 {
364 "type": "human",
365 "handle": "gabriel",
366 "algorithm": "ed25519",
367 "hd_path": _TEST_HD_PATH,
368 "fingerprint": "a" * 64,
369 },
370 )
371 # Deliberately ensure no PEM file exists
372 assert not list(isolated_keys.glob("*.pem")), "PEM should not exist for this test"
373
374 result = resolve_signing_identity(_TEST_HUB)
375
376 assert result is not None, "Expected signing identity, got None"
377 handle, private_key = result
378 assert handle == "gabriel"
379 assert isinstance(private_key, Ed25519PrivateKey)
380
381 def test_P2_2_derived_key_matches_mnemonic_derivation(
382 self,
383 isolated_identity: pathlib.Path,
384 isolated_keys: pathlib.Path,
385 keychain_with_mnemonic: None,
386 ) -> None:
387 """P2-2: the returned key is the deterministic HD derivation of the mnemonic."""
388 save_identity(
389 _TEST_HUB,
390 {
391 "type": "human",
392 "handle": "gabriel",
393 "algorithm": "ed25519",
394 "hd_path": _TEST_HD_PATH,
395 "fingerprint": "a" * 64,
396 },
397 )
398
399 result = resolve_signing_identity(_TEST_HUB)
400 assert result is not None
401 _, private_key = result
402
403 expected = _expected_private_key()
404 # Verify both keys sign the same message and produce the same signature
405 msg = b"phase-2-test-message"
406 sig_actual = private_key.sign(msg)
407 sig_expected = expected.sign(msg)
408 assert sig_actual == sig_expected, "Derived key does not match expected HD derivation"
409
410 def test_P2_3_no_mnemonic_in_keychain_returns_none(
411 self,
412 isolated_identity: pathlib.Path,
413 isolated_keys: pathlib.Path,
414 monkeypatch: pytest.MonkeyPatch,
415 ) -> None:
416 """P2-3: returns None when keychain has no mnemonic (no disk fallback)."""
417 import muse.core.keychain as kc
418 monkeypatch.setattr(kc, "is_available", lambda: True)
419 monkeypatch.setattr(kc, "load", lambda: None)
420
421 save_identity(
422 _TEST_HUB,
423 {
424 "type": "human",
425 "handle": "gabriel",
426 "algorithm": "ed25519",
427 "hd_path": _TEST_HD_PATH,
428 "fingerprint": "a" * 64,
429 },
430 )
431
432 result = resolve_signing_identity(_TEST_HUB)
433 assert result is None, "Expected None when keychain has no mnemonic"
434
435 def test_P2_4_no_identity_entry_returns_none(
436 self,
437 isolated_identity: pathlib.Path,
438 keychain_with_mnemonic: None,
439 ) -> None:
440 """P2-4: returns None when identity.toml has no entry for the hub."""
441 result = resolve_signing_identity(_TEST_HUB)
442 assert result is None
443
444 def test_P2_5_no_hd_path_in_entry_returns_none(
445 self,
446 isolated_identity: pathlib.Path,
447 isolated_keys: pathlib.Path,
448 keychain_with_mnemonic: None,
449 ) -> None:
450 """P2-5: returns None when identity entry has no hd_path (can't derive)."""
451 save_identity(
452 _TEST_HUB,
453 {
454 "type": "human",
455 "handle": "gabriel",
456 "algorithm": "ed25519",
457 "fingerprint": "a" * 64,
458 # deliberately no hd_path
459 },
460 )
461
462 result = resolve_signing_identity(_TEST_HUB)
463 assert result is None, "Expected None when hd_path is missing from identity entry"
464
465 def test_P2_6_signing_produces_verifiable_signature(
466 self,
467 isolated_identity: pathlib.Path,
468 isolated_keys: pathlib.Path,
469 keychain_with_mnemonic: None,
470 ) -> None:
471 """P2-6: sign a canonical message and verify with the corresponding public key."""
472 from muse.core.bip39 import mnemonic_to_seed
473 from muse.core.slip010 import derive_path
474 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
475
476 save_identity(
477 _TEST_HUB,
478 {
479 "type": "human",
480 "handle": "gabriel",
481 "algorithm": "ed25519",
482 "hd_path": _TEST_HD_PATH,
483 "fingerprint": "a" * 64,
484 },
485 )
486
487 result = resolve_signing_identity(_TEST_HUB)
488 assert result is not None
489 _, private_key = result
490
491 msg = b"ed25519\nPOST\nlocalhost:1337\n/gabriel/muse/push\n1744000000\ne3b0c44..."
492 sig = private_key.sign(msg)
493
494 # Verify with the public key derived from the same mnemonic
495 seed = mnemonic_to_seed(_TEST_MNEMONIC)
496 dk = derive_path(seed, _TEST_HD_PATH)
497 pub_key = Ed25519PublicKey.from_public_bytes(dk.private_bytes[32:] if len(dk.private_bytes) > 32 else private_key.public_key().public_bytes_raw())
498 dk.zero()
499 pub_key = private_key.public_key()
500 pub_key.verify(sig, msg) # raises InvalidSignature if wrong