"""Tests for agent-first signing — compound identity keys and key paths. Covers: - identity.py: compound key load/save/clear, provisioned_by field - keypair.py: agent-specific key paths and HD key generation - resolve_signing_identity: derives key from mnemonic in memory (no PEM read) Phase 2 target architecture ---------------------------- resolve_signing_identity(hub_url) → load identity.toml entry (handle, hd_path) → kc_load() (mnemonic from OS keychain) → mnemonic_to_seed() → derive_path(seed, hd_path) → Ed25519PrivateKey [in memory] → dk.zero() → return (handle, private_key) No PEM file is ever read or written. """ from __future__ import annotations import pathlib import json import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from muse.core.identity import ( IdentityEntry, _identity_key, clear_identity, hostname_from_url, list_all_identities, load_identity, resolve_signing_identity, save_identity, ) from muse.core.keypair import derive_hd_public_info # Fixed 64-byte seeds for deterministic test keys _SEED_A = b"\x00" * 64 _SEED_B = b"\x01" * 64 # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def isolated_identity(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Redirect the identity store to a temp directory for test isolation.""" import muse.core.identity as _id_mod identity_dir = tmp_path / ".muse" identity_dir.mkdir() monkeypatch.setattr(_id_mod, "_IDENTITY_DIR", identity_dir) monkeypatch.setattr(_id_mod, "_IDENTITY_FILE", identity_dir / "identity.toml") return identity_dir @pytest.fixture() def isolated_keys(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: """Redirect key storage to a temp directory.""" import muse.core.keypair as _kp_mod keys_dir = tmp_path / ".muse" / "keys" keys_dir.mkdir(parents=True) monkeypatch.setattr(_kp_mod, "_KEYS_DIR", keys_dir) return keys_dir # --------------------------------------------------------------------------- # _identity_key helper # --------------------------------------------------------------------------- class TestIdentityKey: def test_human_key_is_bare_hostname(self) -> None: assert _identity_key("localhost:1337") == "localhost:1337" def test_agent_key_uses_hash_separator(self) -> None: assert _identity_key("localhost:1337", "agent-abc") == "localhost:1337#agent-abc" def test_none_agent_id_gives_bare_hostname(self) -> None: assert _identity_key("musehub.ai", None) == "musehub.ai" def test_empty_agent_id_gives_bare_hostname(self) -> None: # empty string is falsy assert _identity_key("musehub.ai", "") == "musehub.ai" # --------------------------------------------------------------------------- # load_identity / save_identity compound keys # --------------------------------------------------------------------------- class TestCompoundIdentityKeys: def test_human_and_agent_entries_coexist( self, isolated_identity: pathlib.Path ) -> None: hub = "https://localhost:1337" human: IdentityEntry = { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "a" * 64, } agent: IdentityEntry = { "type": "agent", "handle": "agentception-abc", "algorithm": "ed25519", "fingerprint": "b" * 64, "provisioned_by": "gabriel", } save_identity(hub, human) save_identity(hub, agent, agent_id="agentception-abc") loaded_human = load_identity(hub) loaded_agent = load_identity(hub, agent_id="agentception-abc") assert loaded_human is not None assert loaded_human["handle"] == "gabriel" assert loaded_human["type"] == "human" assert loaded_agent is not None assert loaded_agent["handle"] == "agentception-abc" assert loaded_agent["type"] == "agent" assert loaded_agent.get("provisioned_by") == "gabriel" def test_agent_entry_does_not_shadow_human( self, isolated_identity: pathlib.Path ) -> None: hub = "https://localhost:1337" human: IdentityEntry = {"type": "human", "handle": "gabriel"} save_identity(hub, human) # Load without agent_id → human entry loaded = load_identity(hub) assert loaded is not None assert loaded["handle"] == "gabriel" def test_load_missing_agent_returns_none( self, isolated_identity: pathlib.Path ) -> None: hub = "https://localhost:1337" assert load_identity(hub, agent_id="nonexistent") is None def test_clear_agent_identity_leaves_human_intact( self, isolated_identity: pathlib.Path ) -> None: hub = "https://localhost:1337" human: IdentityEntry = {"type": "human", "handle": "gabriel"} agent: IdentityEntry = {"type": "agent", "handle": "agentception-abc", "provisioned_by": "gabriel"} save_identity(hub, human) save_identity(hub, agent, agent_id="agentception-abc") cleared = clear_identity(hub, agent_id="agentception-abc") assert cleared is True assert load_identity(hub) is not None # human still present assert load_identity(hub, agent_id="agentception-abc") is None # agent gone def test_provisioned_by_roundtrip( self, isolated_identity: pathlib.Path ) -> None: """provisioned_by survives a save/load cycle.""" hub = "https://localhost:1337" agent: IdentityEntry = { "type": "agent", "handle": "bot-001", "provisioned_by": "gabriel", "algorithm": "ed25519", "fingerprint": "c" * 64, } save_identity(hub, agent, agent_id="bot-001") loaded = load_identity(hub, agent_id="bot-001") assert loaded is not None assert loaded.get("provisioned_by") == "gabriel" def test_list_all_includes_compound_keys( self, isolated_identity: pathlib.Path ) -> None: hub = "https://localhost:1337" save_identity(hub, {"type": "human", "handle": "gabriel"}) save_identity(hub, {"type": "agent", "handle": "bot"}, agent_id="bot") all_ids = list_all_identities() assert "localhost:1337" in all_ids assert "localhost:1337#bot" in all_ids # --------------------------------------------------------------------------- # keypair — different seeds produce different keys # --------------------------------------------------------------------------- class TestKeypairDistinctness: def test_different_seeds_produce_different_fingerprints(self) -> None: _, fp_a = derive_hd_public_info(_SEED_A) _, fp_b = derive_hd_public_info(_SEED_B) assert fp_a != fp_b # different seeds → different HD derivations def test_same_seed_produces_same_fingerprint(self) -> None: _, fp1 = derive_hd_public_info(_SEED_A) _, fp2 = derive_hd_public_info(_SEED_A) assert fp1 == fp2 # deterministic # --------------------------------------------------------------------------- # resolve_signing_identity — agent key → fallback chain # --------------------------------------------------------------------------- _RSI_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _RSI_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'" _RSI_AGENT_HD_PATH = "m/1075233755'/0'/0'/0'/0'/1'" class TestResolveSigningIdentity: def test_agent_key_used_when_registered( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: import muse.core.keychain as kc monkeypatch.setattr(kc, "is_available", lambda: True) monkeypatch.setattr(kc, "load", lambda: _RSI_MNEMONIC) hub = "https://localhost:1337" agent_id = "agentception-abc" save_identity( hub, { "type": "agent", "handle": agent_id, "algorithm": "ed25519", "hd_path": _RSI_AGENT_HD_PATH, "fingerprint": "b" * 64, }, agent_id=agent_id, ) result = resolve_signing_identity(hub, agent_id=agent_id) assert result is not None handle, private_key = result assert handle == agent_id assert isinstance(private_key, Ed25519PrivateKey) def test_falls_back_to_human_when_no_agent_key( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: import muse.core.keychain as kc monkeypatch.setattr(kc, "is_available", lambda: True) monkeypatch.setattr(kc, "load", lambda: _RSI_MNEMONIC) hub = "https://localhost:1337" # Only human entry registered — no agent entry save_identity( hub, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "hd_path": _RSI_HD_PATH, "fingerprint": "a" * 64, }, ) # Ask for agent signing but no agent entry → falls back to human result = resolve_signing_identity(hub, agent_id="unregistered-agent") assert result is not None handle, _ = result assert handle == "gabriel" def test_no_identity_returns_none( self, isolated_identity: pathlib.Path ) -> None: assert resolve_signing_identity("https://localhost:1337") is None def test_human_resolve_without_agent_id( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: import muse.core.keychain as kc monkeypatch.setattr(kc, "is_available", lambda: True) monkeypatch.setattr(kc, "load", lambda: _RSI_MNEMONIC) hub = "https://localhost:1337" save_identity( hub, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "hd_path": _RSI_HD_PATH, "fingerprint": "a" * 64, }, ) result = resolve_signing_identity(hub) assert result is not None handle, _ = result assert handle == "gabriel" # --------------------------------------------------------------------------- # Phase 2 — resolve_signing_identity derives from mnemonic, never reads PEM # --------------------------------------------------------------------------- _TEST_MNEMONIC = ( "abandon abandon abandon abandon abandon abandon abandon abandon " "abandon abandon abandon about" ) _TEST_HUB = "https://localhost:1337" _TEST_HD_PATH = "m/1075233755'/0'/0'/0'/0'/0'" @pytest.fixture() def keychain_with_mnemonic(monkeypatch: pytest.MonkeyPatch) -> None: """Patch keychain to return _TEST_MNEMONIC from the global slot.""" import muse.core.keychain as kc monkeypatch.setattr(kc, "is_available", lambda: True) monkeypatch.setattr(kc, "load", lambda: _TEST_MNEMONIC) def _expected_private_key() -> Ed25519PrivateKey: """Derive the Ed25519 key that _TEST_MNEMONIC produces at _TEST_HD_PATH.""" from muse.core.bip39 import mnemonic_to_seed from muse.core.slip010 import derive_path, to_ed25519_private_key seed = mnemonic_to_seed(_TEST_MNEMONIC) dk = derive_path(seed, _TEST_HD_PATH) key = to_ed25519_private_key(dk) dk.zero() return key class TestResolveSigningIdentityPhase2: """Phase 2: resolve_signing_identity derives from keychain mnemonic — no PEM.""" def test_P2_1_derives_key_from_mnemonic_not_pem( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, keychain_with_mnemonic: None, monkeypatch: pytest.MonkeyPatch, ) -> None: """P2-1: returns a key even when no PEM file exists on disk.""" save_identity( _TEST_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "hd_path": _TEST_HD_PATH, "fingerprint": "a" * 64, }, ) # Deliberately ensure no PEM file exists assert not list(isolated_keys.glob("*.pem")), "PEM should not exist for this test" result = resolve_signing_identity(_TEST_HUB) assert result is not None, "Expected signing identity, got None" handle, private_key = result assert handle == "gabriel" assert isinstance(private_key, Ed25519PrivateKey) def test_P2_2_derived_key_matches_mnemonic_derivation( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, keychain_with_mnemonic: None, ) -> None: """P2-2: the returned key is the deterministic HD derivation of the mnemonic.""" save_identity( _TEST_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "hd_path": _TEST_HD_PATH, "fingerprint": "a" * 64, }, ) result = resolve_signing_identity(_TEST_HUB) assert result is not None _, private_key = result expected = _expected_private_key() # Verify both keys sign the same message and produce the same signature msg = b"phase-2-test-message" sig_actual = private_key.sign(msg) sig_expected = expected.sign(msg) assert sig_actual == sig_expected, "Derived key does not match expected HD derivation" def test_P2_3_no_mnemonic_in_keychain_returns_none( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """P2-3: returns None when keychain has no mnemonic (no disk fallback).""" import muse.core.keychain as kc monkeypatch.setattr(kc, "is_available", lambda: True) monkeypatch.setattr(kc, "load", lambda: None) save_identity( _TEST_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "hd_path": _TEST_HD_PATH, "fingerprint": "a" * 64, }, ) result = resolve_signing_identity(_TEST_HUB) assert result is None, "Expected None when keychain has no mnemonic" def test_P2_4_no_identity_entry_returns_none( self, isolated_identity: pathlib.Path, keychain_with_mnemonic: None, ) -> None: """P2-4: returns None when identity.toml has no entry for the hub.""" result = resolve_signing_identity(_TEST_HUB) assert result is None def test_P2_5_no_hd_path_in_entry_returns_none( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, keychain_with_mnemonic: None, ) -> None: """P2-5: returns None when identity entry has no hd_path (can't derive).""" save_identity( _TEST_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "fingerprint": "a" * 64, # deliberately no hd_path }, ) result = resolve_signing_identity(_TEST_HUB) assert result is None, "Expected None when hd_path is missing from identity entry" def test_P2_6_signing_produces_verifiable_signature( self, isolated_identity: pathlib.Path, isolated_keys: pathlib.Path, keychain_with_mnemonic: None, ) -> None: """P2-6: sign a canonical message and verify with the corresponding public key.""" from muse.core.bip39 import mnemonic_to_seed from muse.core.slip010 import derive_path from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey save_identity( _TEST_HUB, { "type": "human", "handle": "gabriel", "algorithm": "ed25519", "hd_path": _TEST_HD_PATH, "fingerprint": "a" * 64, }, ) result = resolve_signing_identity(_TEST_HUB) assert result is not None _, private_key = result msg = b"ed25519\nPOST\nlocalhost:1337\n/gabriel/muse/push\n1744000000\ne3b0c44..." sig = private_key.sign(msg) # Verify with the public key derived from the same mnemonic seed = mnemonic_to_seed(_TEST_MNEMONIC) dk = derive_path(seed, _TEST_HD_PATH) pub_key = Ed25519PublicKey.from_public_bytes(dk.private_bytes[32:] if len(dk.private_bytes) > 32 else private_key.public_key().public_bytes_raw()) dk.zero() pub_key = private_key.public_key() pub_key.verify(sig, msg) # raises InvalidSignature if wrong