Key Material Security Audit — Muse + MuseHub
Scope: muse and musehub repos only. Agentception, Stori, Maestro deferred.
Status: Phases 1–8 complete.
The Target Architecture
OS Keychain ←→ one master BIP39 mnemonic per machine (not per hub)
│
▼ mnemonic_to_seed() [in memory, never logged]
64-byte seed
│
▼ derive_identity_key(seed, ...) SLIP-0010 Ed25519
DerivedKey → Ed25519PrivateKey [in memory]
│ │
▼ ▼
dk.zero() sign(canonical_message) [in memory]
│
▼
sig bytes → base64url → Authorization header
NO PEM EVER WRITTEN TO DISK.
NO INTERMEDIATE KEY BYTES OUTSIDE MEMORY.
ONE MNEMONIC. MANY HUBS.
Current State — What Exists Today
muse/core/keychain.py
- Stores mnemonic in OS Keychain via
keyringlibrary. ✅ - Key:
service="muse",username="mnemonic"(single global entry — not per-hub). ✅ (Phase 1) - Legacy per-hub
"{hostname}/mnemonic"entries migrated transparently on firstload(). ✅ MUSE_KEYCHAIN_BACKEND=disabledfor CI is correct. ✅load()/store()/delete()API is clean. ✅
muse/core/identity.py
- Mnemonic never written to TOML — stripped before
_save_all(). ✅ - Injected at load time via
kc_load()inload_identity(). ✅ resolve_signing_identity()derives key from keychain mnemonic +hd_path— no PEM read. ✅ (Phase 2)- TOML written atomically with
fchmod(0o600)before data. ✅ - Symlink guard on write. ✅
- Advisory
fcntl.flockon read-modify-write. ✅ key_path: strstill inIdentityEntryTypedDict and_dump_identity()serialiser. ⚠️ (Post-Phase-8 finding — see audit section below)_load_all()still parseskey_pathfrom TOML — intentional forward-compat. ✅
muse/core/keypair.py
generate_hd_keypair,load_private_key,load_private_key_from_pem,_write_private_key_pem,_key_path,_hostname_key,_SAFE_AGENT_ID,_ensure_keys_dir— all deleted. ✅ (Post-Phase-8 cleanup)derive_hd_public_info()is the only key derivation path — no file writes. ✅ (Phase 4)_KEYS_DIRsentinel kept — used bycleanup-keysandsecurity-checkcommands. ✅DerivedKey.zero()wrapped intry/finallyat all call sites. ✅ (Phase 6)~/.muse/keys/contains no PEM files — all orphans destroyed by Phase 5. ✅
muse/core/hdkeys.py
- Derivation path structure is correct and well-documented. ✅
derive_agent_sub_seed()returnsSecretByteArray(auto-zeroes on GC). ✅ (Phase 6)dk.zero()called inderive_agent_sub_seed()insidetry/finally. ✅ (Phase 6)public_bytes_from_seed()callsdk.zero()insidetry/finally. ✅ (Phase 6)- All intermediate
DerivedKeyobjects passed throughchild_key()loop are zeroed. ✅
muse/core/msign.py
build_msign_header()takes aSigningIdentity(handle + private key). ✅- Signing happens in memory. ✅
- No key material logged or included in exceptions. ✅
DEFAULT_SIGN_ALGOused correctly (notKeyAlgorithmenum). ✅ (fixed in previous session)
muse/core/transport.py — SigningIdentity
- Holds
handle: strandprivate_key: Ed25519PrivateKey. ✅ private_keynow derived from keychain mnemonic viaresolve_signing_identity()— no disk read. ✅ (Phase 2)
muse/cli/commands/auth.py
run_keygen(human): generates mnemonic → keychain → derives in memory → writes pubkey only. No PEM write. ✅ (Phase 4)run_register: derives key from keychain mnemonic +hd_path, writes nokey_pathto entry. ✅ (Phase 4)run_recover: derives in memory from stdin mnemonic → stores in keychain; no PEM write. ✅ (Phase 8)run_rotate: reads mnemonic from keychain; derives in memory; no PEM write. ✅ (Phase 8)- Mnemonic passed via
--mnemonic-fd N(fd 0/1/2 reserved). ✅ - Mnemonic never echoed to stdout/stderr. ✅
- Keychain storage is now machine-global (
"mnemonic"key, not per-hub). ✅ (Phase 1)
musehub/auth/request_signing.py
- Server-side verification: reads public key bytes from DB, calls
verify_signature(). ✅ - No private key material on the server at all. ✅
- Streaming push (
application/x-muse-wire) signs withb""body. ✅ (fixed in previous session) DEFAULT_SIGN_ALGOused for canonical message default. ✅ (fixed in previous session)
Identified Issues (Ranked by Severity)
CRITICAL
| # | File | Issue | Status |
|---|---|---|---|
| C1 | keypair.py::generate_hd_keypair |
Writes derived Ed25519 private key to ~/.muse/keys/*.pem (unencrypted, on disk) |
✅ No longer called — deletion deferred to Phase 2 cleanup |
| C2 | identity.py::resolve_signing_identity |
Loads private key from PEM file on disk instead of deriving in memory | ✅ Fixed — Phase 2 |
| C3 | keychain.py::_username |
Mnemonic stored per-hub instead of once per machine | ✅ Fixed — Phase 1 |
| C4 | identity.toml schema |
key_path field encodes PEM dependency into stored format |
✅ Fixed — Phase 3 |
HIGH
| # | File | Issue | Status |
|---|---|---|---|
| H1 | keypair.py |
load_private_key() / load_private_key_from_pem() — exist only to serve the PEM architecture |
⚠️ Still present, unused by hot paths — blocked on run_recover/run_rotate migration |
| H2 | auth.py::run_keygen |
Human keygen stored mnemonic per-hub instead of globally | ✅ Fixed — Phase 1 + 4 |
| H3 | auth.py::run_recover |
Still writes PEM after re-deriving from mnemonic | ✅ Fixed — Phase 8 |
| H4 | auth.py::run_rotate |
Still writes PEM at new rotation index | ✅ Fixed — Phase 8 |
| H5 | ~/.muse/keys/ |
Existing PEM files on disk are live key material | ✅ Fixed — Phase 5 |
MEDIUM
| # | File | Issue | Status |
|---|---|---|---|
| M1 | hdkeys.py::derive_agent_sub_seed |
Returns bytearray — callers must zero it after use, no enforcement |
✅ Fixed — Phase 6 (SecretByteArray auto-zeroes on GC) |
| M2 | keypair.py::generate_hd_keypair |
Ed25519PrivateKey from cryptography holds key bytes in C heap — Python cannot zero them |
⚠️ Known limitation — inherent to the cryptography library |
| M3 | identity.py::_load_private_key_from_path |
Reads entire PEM as bytes into Python heap — immutable, cannot be zeroed |
⚠️ Still present — only path is run_recover/run_rotate (deferred) |
| M4 | slip010.py::DerivedKey |
zero() method exists, but OS may swap bytearray to disk before zero |
⚠️ Known limitation — inherent to Python's memory model; mitigated by __del__ (Phase 6) |
LOW
| # | File | Issue |
|---|---|---|
| L1 | keychain.py |
No test that MUSE_KEYCHAIN_BACKEND=disabled mode warns when mnemonic would be lost |
| L2 | identity.toml |
provisioned_by_fingerprint field not validated against actual operator key on agent provisioning |
| L3 | keypair.py::_write_private_key_pem |
PEM bytes created as bytes — immutable, stays in Python heap until GC |
Implementation Plan
Phase 1 — Fix the Mnemonic Keychain Key (C3) ✅ COMPLETE
Goal: One master mnemonic per machine, not one per hub.
- [x] Change
_username()to return"mnemonic"(constant, no hub in key) - [x] Write migration: if
"{hostname}/mnemonic"exists and"mnemonic"does not, copy it over and delete the old entry - [x] Run migration in
load()transparently (one-time, idempotent) - [x] Update
store()anddelete()—hub_urlparam removed - [x] Update all callers of
kc_store/kc_loadinauth.py - [x] Update tests in
tests/test_core_keychain.py
Phase 2 — Derive and Sign in Memory (C1, C2, H1) ✅ COMPLETE
Goal: resolve_signing_identity() derives the Ed25519 key from the mnemonic at call time. No PEM file read. No PEM file write.
Call chain (implemented):
resolve_signing_identity(hub_url)
→ load_identity(hub_url) # reads identity.toml (handle, hd_path, fingerprint)
→ kc_load() # mnemonic from OS keychain
→ mnemonic_to_seed(mnemonic) # BIP39 PBKDF2
→ derive_path(seed, hd_path) # SLIP-0010 Ed25519
→ to_ed25519_private_key(dk) # materialise key
→ dk.zero() # zero DerivedKey immediately
→ Ed25519PrivateKey # sign, then let GC handle it
- [x] Rewrite
resolve_signing_identity()to derive from keychain — no PEM read - [x] Delete
generate_hd_keypair()fromkeypair.py— done in post-Phase-8 cleanup - [x] Delete
load_private_key()/load_private_key_from_pem()/_write_private_key_pem()fromkeypair.py— done in post-Phase-8 cleanup - [x] Delete
_check_key_file_permissions()and_load_private_key_from_path()fromidentity.py— done in post-Phase-8 cleanup
Phase 3 — Remove key_path from identity.toml Schema (C4) ⚠️ PARTIAL
Goal: The key_path field is meaningless once Phase 2 lands. Remove it from the type, the serialiser, and the parser.
- [ ] Remove
key_pathfromIdentityEntryTypedDict — ⚠️ still present (post-Phase-8 audit finding) - [x]
_load_all()parser still readskey_pathfrom TOML for forward-compat — acceptable - [ ] Remove
key_pathfrom_dump_identity()serialiser — ⚠️ still writes it if present in entry - [x]
save_identity()silently dropskey_pathif present in a loaded file — ⚠️ actually NOT dropped;_dump_identityre-serialises it - [ ] Updated all tests that set or assert on
key_path— ⚠️ many tests still setkey_pathin fixture entries; 4 tests fail becausekey_setnow useshd_pathnotkey_path
Phase 4 — Update auth.py CLI Commands (H2, H4) ✅ COMPLETE
Goal: keygen and register no longer write PEM files or read them.
- [x]
run_keygen: generate mnemonic →kc_store()→ derive in memory → store pubkey/fingerprint/hd_path. No PEM write. - [x]
run_register: derive key from keychain mnemonic +hd_path— noload_private_key, nokey_pathin entry. - [x]
run_keygenreuses existing mnemonic from keychain unless--force - [x] Data-integrity invariants tested:
hd_pathpreserved, nokey_path, fingerprint matches mnemonic, round-trip works (test_auth_register_integrity.py) - [x]
run_recover: derives in memory from stdin mnemonic → stores mnemonic in keychain; no PEM write (Phase 8) - [x]
run_rotate: reads mnemonic from keychain; derives in memory; no PEM write (Phase 8)
Phase 5 — Orphan PEM Cleanup ✅ COMPLETE
Goal: Remove existing PEM files from disk. They are now vestigial and represent unprotected key material.
- [x]
muse auth cleanup-keys: overwrites each*.pemwithos.urandombytes, fsyncs, unlinks; JSON output - [x]
muse auth security-check: four invariant checks; exits 1 on any failure; JSON output - [x] 6 stale PEM files destroyed from real
~/.muse/keys/on first run - [x] Tests: C1–C5 (cleanup), S1–S5 (security-check) — all green (
test_cmd_auth_phase5.py)
Phase 6 — DerivedKey Zeroing Hardening (M1, M2) ✅ COMPLETE
Goal: Best-effort zeroing of sensitive bytes in Python's heap. We cannot zero cryptography's C-heap allocations, but we can minimize exposure window.
Gaps identified (all code paths, as of Phase 5):
| Site | Gap |
|---|---|
slip010.py::DerivedKey |
No __del__ fallback — forgotten zero() calls leave key material until GC |
identity.py::resolve_signing_identity._derive |
dk.zero() after to_ed25519_private_key(dk) — no try/finally; exception skips zeroing |
keypair.py::derive_hd_public_info |
Same — no try/finally around dk.zero() |
keypair.py::generate_hd_keypair |
Same |
hdkeys.py::public_bytes_from_seed |
Same |
auth.py::run_register inline derivation |
Same |
hdkeys.py::derive_agent_sub_seed |
Returns raw bytearray — no auto-zero if caller forgets |
Implementation targets:
- [x]
DerivedKey.__del__added — callsself.zero()as GC safety net - [x]
try/finallywrapping alldk.zero()sites:identity.py::resolve_signing_identity._derive— exception now returnsNone, dk always zeroedkeypair.py::derive_hd_public_infokeypair.py::generate_hd_keypairhdkeys.py::public_bytes_from_seedauth.py::run_registerinline derivation
- [x]
SecretByteArrayadded toslip010.py—bytearraysubclass withzero()method and__del__auto-zero - [x]
derive_agent_sub_seed()return type changed frombytearraytoSecretByteArray - [x] Tests: Z1–Z7 all green (
test_security_zeroing.py)
Phase 7 — MuseHub Server Audit ✅ COMPLETE
Goal: Confirm server never touches private key material.
- [x] Verify
musehub/auth/request_signing.pyonly reads public keys from DB — confirmed - [x] Verify no private key material in
musehub/crypto/keys.py— confirmed - [x] Verify
MusehubAuthKeyDB model stores onlypublic_key_b64andfingerprint— confirmed - [x] Fixed stale
~/.muse/keys/{{hostname}}.pemreference inmusehub/mcp/prompts.py - [x] Fixed
key_path/~/.muse/keys/references indocs_muse_identity.htmltemplate - [x] 10 tests P7-1 through P7-6 all green (
tests/test_security_server_audit.py)
Phase 8 — Migrate run_recover and run_rotate off PEM files ✅ COMPLETE
Goal: The last two CLI commands that wrote PEM files now derive keys in memory only. No PEM files written anywhere in the CLI.
- [x]
run_recover: replacedgenerate_hd_keypairwithderive_hd_public_info; removedkey_pathfrom entry and JSON; stores mnemonic in OS keychain; guards on identity entry (not PEM file) for--forcecheck; removedkey_pathfrom_RecoverJsonTypedDict - [x]
run_rotate: reads mnemonic from keychain (kc_load()) — no_read_mnemonic_securelystdin call; replacedgenerate_hd_keypairwithderive_hd_public_info; removedkey_pathfrom entry and JSON; removedkey_pathfrom_RotateJsonTypedDict - [x]
test_cmd_auth_phase8.py— 12 tests (REC-1 through REC-7, ROT-1 through ROT-4) all green; written TDD-first - [x]
test_auth_rotate.py— keychain patching added toisolatedfixture;_rotate()no longer passes stdin;test_III2_new_pem_is_valid_ed25519replaced withtest_III2_rotate_writes_no_pem; all 10 green - [x]
test_hd_keygen_unified.py::TestRunRecover—test_recover_writes_pemreplaced withtest_recover_writes_no_pem;test_recover_pem_mode_600deleted; keychain patching added to_do_recover; all green
Test Checklist
- [x]
test_core_keychain.py— stalekey_pathfields removed from test entries - [x]
test_hd_keygen_unified.py— all PEM assertion tests replaced; keychain patching added; no-PEM + fingerprint-based tests green - [x]
test_cmd_auth_keygen_hd.py— PEM write tests replaced withderive_hd_public_infotests; stale imports removed - [x]
test_agent_signing.py—TestLoadPrivateKeyFromPemdeleted; stalekey_pathremoved; Phase 2 in-memory signing tests green - [x]
test_security_key_permissions.py— deleted; replaced bytest_security_no_pem_on_disk.py(NP-1 through NP-5) - [x]
test_auth_rotate.py— PEM assertions replaced with no-PEM assertions; keychain patching added; all 10 green (Phase 8) - [x]
test_resolve_signing_identity_keychain_path.py— new; full chain KC-1 through KC-7 all green
Migration Path (Zero-Downtime)
- Phase 1 lands first — mnemonic consolidation. Existing
staging.musehub.ai/mnemonicis migrated tomnemonictransparently on firstload(). Localhost entry (missing) is created viamuse auth recoverusing the staging mnemonic. - Phase 2 + 3 land together —
resolve_signing_identityreads from keychain. PEM files still on disk but no longer read. Identity.toml dropskey_path. - Phase 4 lands — CLI commands stop writing PEMs.
- Phase 5 lands — PEM files actively overwritten and deleted.
- Phase 6 — ongoing hardening, no user-visible change.
Post-Phase-8 Completeness Audit
Findings recorded as discovered. Each item is either clean ✅, needs a doc/comment fix 📝, or is a real code issue ⚠️.
Deleted functions — still referenced
| Location | Reference | Finding |
|---|---|---|
docs/key-material-security-audit.md (this file) |
"Current State" section + Phase 2 + Phase 6 + "Files to Delete" table | 📝 Still describes generate_hd_keypair, load_private_key, load_private_key_from_pem, _write_private_key_pem, _check_key_file_permissions, _load_private_key_from_path as present/deferred. All are now deleted. |
docs/agent-provenance.md:195 |
load_private_key_from_pem in keypair.py description |
📝 Stale — function deleted. |
tests/test_cmd_auth_keygen_hd.py module docstring (lines 9–12, 54) |
Lists generate_hd_keypair tests as coverage |
📝 Stale docstring/comment — tests migrated to derive_hd_public_info. |
tests/test_cmd_auth_keygen_hd.py:194,199 |
# Unit — generate_hd_keypair comment + class docstring |
📝 Stale comment/docstring in TestGenerateHdKeypair class. |
tests/test_derived_key_zeroing.py:102 |
# III generate_hd_keypair zeroes the final DerivedKey |
📝 Stale comment — now derive_hd_public_info. |
tests/test_auth_hd_persistence.py:88 |
mnemonic_to_seed`` and ``generate_hd_keypair`` run for real |
📝 Stale docstring. |
key_path still in production code
| Location | Finding |
|---|---|
muse/core/identity.py:120 |
✅ Fixed. key_path: str removed from IdentityEntry TypedDict. |
muse/core/identity.py:222–224 |
✅ Fixed. _dump_identity() no longer serialises key_path. TDD tests P3-1 and P3-2 added. |
muse/core/identity.py:284–286 |
✅ _load_all() parses key_path from TOML — intentional forward-compat read of old files. Acceptable. |
muse/core/identity.py module docstring (lines 33, 40, 102, 111) |
📝 File-format docstring examples still show key_path = "..." lines. |
muse/cli/commands/hub/_core.py:572–579 |
✅ Fixed. PEM-load primary path deleted; get_signing_identity(remote_url=hub_url) is now the only signing path. TDD test H1 added. |
muse/cli/commands/mist.py:184–190, 435–440, 1183–1187 |
✅ Fixed. Three PEM-load sites migrated to get_signing_identity(remote_url=...) + build_msign_header / sign_bytes. Also fixed: _require_hub and _get_hub_url called load_identity() without hub_url (TypeError). TDD tests M1–M3 added. |
muse/cli/commands/sign.py:376, 453, 519 |
✅ Fixed. Stale getattr(args, "key_path", None) positional arg removed from run_request, run_curl, run_payment call sites. TDD tests CS-1 through CS-4 added. |
Test files calling deleted _key_path function
| Location | Finding |
|---|---|
tests/test_agent_id_traversal.py entire file |
✅ Deleted. Tests keypair._key_path path-traversal guard — _key_path was deleted; all 8 tests errored with AttributeError. The traversal risk was PEM filename injection, which is gone since no PEM is written. muse rm executed and committed. |
key_set logic broken — 4 test failures
_display_entry was updated to use key_set = bool(hd_path) (not bool(key_path)). Test fixtures that set key_path but not hd_path now get key_set = false, breaking tests that expected key_set = true.
| Location | Finding |
|---|---|
tests/test_cli_auth.py::TestAuthWhoami::test_whoami_key_set_is_bool |
✅ Fixed. _store_entry() fixture updated: key_path removed, hd_path added. |
tests/test_cmd_auth_hardening.py::TestDisplayEntry::test_json_key_set_true |
✅ Fixed. _make_entry() updated: key_path removed, hd_path added. |
tests/test_cmd_auth_hardening.py::TestWhoamiHardening::test_whoami_json_schema |
✅ Fixed. _store() fixture updated: key_path removed, hd_path added. |
tests/test_cmd_auth_hardening.py::TestWhoamiHardening::test_whoami_key_set_is_bool |
✅ Fixed. Same fixture fix. |
Test files using key_path in identity entries (stale fixture data)
| Location | Finding |
|---|---|
tests/test_cli_auth.py:76, 112, 133, 150–151, 179, 225, 259, 274 |
📝 save_identity calls with key_path in the entry dict. Tests still pass because the field is still in IdentityEntry TypedDict, but semantically stale. |
tests/test_cli_hub.py:188, 232, 249, 283 |
📝 IdentityEntry dicts with key_path field. |
tests/test_cli_hub.py:393, 431, 477, 610, 646, 682, 722, 896, 1157, 1268 |
⚠️ These pass key_path=str(...) to some constructor — needs closer inspection to determine if they are constructing IdentityEntry or another type (possibly SigningIdentity). |
tests/test_cmd_auth_hardening.py:254, 317, 346–347, 364–365, 400, 426–427, 477, 500, 510, 522, 591, 621, 660, 708 |
📝 Many save_identity calls and IdentityEntry dicts with key_path. Tests for whoami/show/list display — key_path used as dummy data to populate entries. |
tests/test_cmd_hub_hardening.py:162 |
📝 Entry dict with key_path. |
tests/test_auth_show_migrate.py:67, 92, 118, 140, 165, 184, 205, 293 |
✅ Intentionally tests display of old-format entries that contain key_path. Migration/compatibility tests — keep these. |
tests/test_cmd_auth_phase5.py:190, 193, 214–234 |
✅ Intentionally tests security-check detecting key_path in identity entries (no_key_path_in_identity invariant). Keep these. |
tests/test_auth_register_integrity.py:128–143 |
✅ Intentionally asserts key_path NOT in entry after register. Keep this. |
Docs and comments with stale key_path / PEM content
| Location | Finding |
|---|---|
docs/guide/getting-started.md:37 |
✅ Fixed. Updated to show hd_path in example; prose updated to describe keychain-derived key. |
docs/reference/auth.md:38, 45, 57 |
✅ Fixed. key_path replaced with hd_path in TOML examples and type table. |
docs/reference/type-contracts.md:251, 3681 |
✅ Fixed. key_path replaced with hd_path in both type tables. |
docs/agent-provenance.md:68, 78 |
✅ Fixed. Example identity entries updated to use hd_path; PEM key path removed. |
docs/agent-provenance.md:195 |
✅ Fixed. load_private_key_from_pem → derive_hd_public_info + sign_bytes. |
muse/core/provenance.py module docstring |
✅ Fixed. Signing model updated to describe keychain-derived key, no PEM file. |
EXTREME_STRESS_PLAN.md:1039 |
✅ Fixed. Updated to describe keychain-derived key instead of PEM path. |
tests/test_cmd_auth_keygen_hd.py module + class docstrings |
✅ Fixed. References to generate_hd_keypair and PEM updated to derive_hd_public_info. |
tests/test_derived_key_zeroing.py:102 |
✅ Fixed. Section comment updated: generate_hd_keypair → derive_hd_public_info. |
tests/test_auth_hd_persistence.py:88 |
✅ Fixed. Docstring updated: removed PEM reference, updated function name. |
muse/core/snapshot.py:104 |
✅ "*.pem" in secret-pattern exclusion list — correct, keep. |
EXTREME_STRESS_PLAN.md:1346 |
✅ *.pem listed as a secret pattern — correct, keep. |
Files to Delete (deferred cleanup)
All deferred deletions are now complete as of the post-Phase-8 cleanup commit.
| File / Symbol | Reason | Status |
|---|---|---|
muse/core/keypair.py::generate_hd_keypair |
PEM write | ✅ Deleted |
muse/core/keypair.py::load_private_key |
PEM read | ✅ Deleted |
muse/core/keypair.py::load_private_key_from_pem |
PEM read | ✅ Deleted |
muse/core/keypair.py::_write_private_key_pem |
PEM write helper | ✅ Deleted |
muse/core/keypair.py::_key_path |
PEM filename builder | ✅ Deleted |
muse/core/keypair.py::_hostname_key |
PEM filename sanitiser | ✅ Deleted |
muse/core/keypair.py::_SAFE_AGENT_ID |
PEM filename regex | ✅ Deleted |
muse/core/keypair.py::_ensure_keys_dir |
PEM directory creator | ✅ Deleted |
muse/core/identity.py::_check_key_file_permissions |
Gating PEM reads | ✅ Deleted |
muse/core/identity.py::_load_private_key_from_path |
PEM read path | ✅ Deleted |
~/.muse/keys/*.pem |
Live key material | ✅ Done — Phase 5 destroyed all orphans |