"""Phase 7 — MuseHub server security audit. Invariants ---------- The MuseHub server must NEVER touch Ed25519 private key material. All authentication is challenge-response: the server holds only public keys and verifies signatures produced by the client. P7-1 crypto/keys.py exports no private key loading or generation functions. P7-2 musehub_auth_models.MusehubAuthKey stores only public_key_b64 and fingerprint — no private_key, key_path, or mnemonic columns. P7-3 request_signing._verify_msign reads public keys from DB; it never calls any private-key function. P7-4 No source file under musehub/ (excluding deploy/, docs/, tests/) contains the string "private_key" in a live code path — only in comments/docstrings. P7-5 musehub/mcp/prompts.py contains no stale reference to ~/.muse/keys/*.pem. P7-6 musehub/auth/request_signing.py does not import any PEM-reading symbol. """ from __future__ import annotations import ast import pathlib MUSEHUB_ROOT = pathlib.Path(__file__).parent.parent / "musehub" # --------------------------------------------------------------------------- # P7-1 — crypto/keys.py exports no private-key functions # --------------------------------------------------------------------------- class TestCryptoKeysPublicOnly: def test_P7_1_no_private_key_generation(self) -> None: """crypto/keys.py must not export any private-key generation or loading.""" keys_src = (MUSEHUB_ROOT / "crypto" / "keys.py").read_text() forbidden = [ "generate_private_key", "Ed25519PrivateKey", "from_private_bytes", "load_pem_private_key", "load_der_private_key", ] for symbol in forbidden: assert symbol not in keys_src, ( f"crypto/keys.py must not reference '{symbol}' — " "server never handles private key material" ) def test_P7_1b_verify_signature_uses_public_key_only(self) -> None: """verify_signature must accept public_key_bytes, not private_key_bytes.""" from musehub.crypto.keys import verify_signature import inspect sig = inspect.signature(verify_signature) param_names = set(sig.parameters) assert "public_key_bytes" in param_names assert "private_key_bytes" not in param_names assert "private_key" not in param_names # --------------------------------------------------------------------------- # P7-2 — DB model stores only public key data # --------------------------------------------------------------------------- class TestAuthKeyModelPublicOnly: def test_P7_2_no_private_key_columns(self) -> None: """MusehubAuthKey must have no private-key, key_path, or mnemonic columns.""" from musehub.db.musehub_auth_models import MusehubAuthKey from sqlalchemy import inspect as sa_inspect mapper = sa_inspect(MusehubAuthKey) column_names = {col.key for col in mapper.mapper.columns} forbidden_columns = {"private_key", "key_path", "mnemonic", "secret", "pem"} overlap = forbidden_columns & column_names assert not overlap, ( f"MusehubAuthKey must not have private-key columns; found: {overlap}" ) def test_P7_2b_public_key_b64_column_exists(self) -> None: """MusehubAuthKey must have public_key_b64 and fingerprint columns.""" from musehub.db.musehub_auth_models import MusehubAuthKey from sqlalchemy import inspect as sa_inspect mapper = sa_inspect(MusehubAuthKey) column_names = {col.key for col in mapper.mapper.columns} assert "public_key_b64" in column_names assert "fingerprint" in column_names # --------------------------------------------------------------------------- # P7-3 — request_signing imports no private-key symbols # --------------------------------------------------------------------------- class TestRequestSigningImports: def test_P7_3_no_private_key_imports(self) -> None: """request_signing.py must not import any private-key symbol.""" src = (MUSEHUB_ROOT / "auth" / "request_signing.py").read_text() forbidden_imports = [ "Ed25519PrivateKey", "load_pem_private_key", "load_der_private_key", "generate_private_key", "load_private_key", ] for symbol in forbidden_imports: assert symbol not in src, ( f"request_signing.py must not import '{symbol}' — " "server only verifies signatures, never signs" ) def test_P7_6_no_pem_reader_imports(self) -> None: """request_signing.py must not import any PEM-reading function.""" src = (MUSEHUB_ROOT / "auth" / "request_signing.py").read_text() assert ".pem" not in src assert "load_pem" not in src # --------------------------------------------------------------------------- # P7-4 — No live private-key code paths in musehub/ application source # --------------------------------------------------------------------------- class TestNoPrivateKeyInAppCode: _SKIP_DIRS = {"deploy", "tests", "templates"} _SKIP_EXTS = {".md", ".html", ".sh", ".conf", ".txt", ".toml", ".json"} def _app_py_files(self) -> list[pathlib.Path]: files = [] for p in MUSEHUB_ROOT.rglob("*.py"): if any(part in self._SKIP_DIRS for part in p.parts): continue files.append(p) return files def test_P7_4_no_ed25519_private_key_in_app_code(self) -> None: """No application .py file may instantiate or import Ed25519PrivateKey.""" violations: list[str] = [] for path in self._app_py_files(): src = path.read_text() tree = ast.parse(src, filename=str(path)) for node in ast.walk(tree): # Check string literals and names — not comments or docstrings if isinstance(node, ast.Name) and node.id == "Ed25519PrivateKey": violations.append(f"{path.relative_to(MUSEHUB_ROOT.parent)}:{node.lineno}") elif isinstance(node, ast.Attribute) and node.attr == "Ed25519PrivateKey": violations.append(f"{path.relative_to(MUSEHUB_ROOT.parent)}:{node.lineno}") assert not violations, ( f"Ed25519PrivateKey referenced in musehub application code:\n{'\n'.join(violations)}" ) def test_P7_4b_no_load_private_key_calls(self) -> None: """No application .py file may call load_private_key or load_pem_private_key.""" violations: list[str] = [] for path in self._app_py_files(): src = path.read_text() for symbol in ("load_private_key", "load_pem_private_key", "load_der_private_key"): if symbol in src: violations.append(f"{path.relative_to(MUSEHUB_ROOT.parent)}: {symbol}") assert not violations, ( f"Private key loading found in musehub application code:\n{'\n'.join(violations)}" ) # --------------------------------------------------------------------------- # P7-5 — No stale ~/.muse/keys/*.pem reference in MCP prompts # --------------------------------------------------------------------------- class TestNoStalePemReferences: def test_P7_5_mcp_prompts_no_pem_path(self) -> None: """musehub/mcp/prompts.py must not reference ~/.muse/keys/*.pem (stale architecture).""" src = (MUSEHUB_ROOT / "mcp" / "prompts.py").read_text() assert "~/.muse/keys/" not in src, ( "musehub/mcp/prompts.py contains a stale reference to ~/.muse/keys/ — " "keys are now derived from the OS-keychain mnemonic, no PEM files" ) def test_P7_5b_identity_template_no_key_path_column(self) -> None: """The identity docs template must not describe key_path as a valid field.""" template = ( MUSEHUB_ROOT.parent / "musehub" / "templates" / "musehub" / "pages" / "docs_muse_identity.html" ) if not template.exists(): return # template may not exist in all environments src = template.read_text() assert "key_path" not in src, ( "docs_muse_identity.html still describes key_path — " "this field was removed in Phase 3 of the key-material security migration" ) assert "/.muse/keys/" not in src, ( "docs_muse_identity.html still references ~/.muse/keys/ PEM paths" )