From cb28176e2c5a47d2ddd0cac3a8e88cca48a9732e Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Thu, 16 Jul 2026 09:59:42 -0700 Subject: [PATCH] feat(claude-code): persistent signing key so the manifest is verifiable E2E testing showed the signed Agent Manifest could not be verified by anyone: sign_all minted a fresh keypair per run with generate_ed25519() and discarded it, and the manifest carried only key_id (a hash of the public key), never the key itself. The signature was real but unverifiable after the process exited, contradicting the README's "shareable proof a third party can verify". (TRACE was unaffected: trace.json embeds cnf.jwk and verifies standalone.) - Persist one Ed25519 key at ~/.claude/agentrust/signing_key.json, generated once and reused every run (reconstructed via cryptography from the stored seed); best-effort owner-only permissions. Records now carry a stable signing identity instead of a throwaway key. - Publish the public half beside each record as verification_key.json ({key_id, public_key_b64url}); the private key never leaves the machine. - README + /trace doc: correct the claim and show the exact manifest verification recipe (load verification_key.json into trusted_keys). - gitignore verification_key.json and signing_key.json. Verified: key is stable across runs; a third party with only verification_key.json gets VALID / signature_verified True; any tamper (agent_id, artifact hash) or a wrong key -> MISMATCH. Added 2 crypto-guarded regression tests (importorskip); 15 pass with and without the crypto packages. ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- claude-code/.gitignore | 2 + claude-code/README.md | 35 ++++++++++++---- claude-code/commands/trace.md | 6 +++ claude-code/engine/capture.py | 69 +++++++++++++++++++++++++++++-- claude-code/tests/test_capture.py | 47 +++++++++++++++++++++ 5 files changed, 149 insertions(+), 10 deletions(-) diff --git a/claude-code/.gitignore b/claude-code/.gitignore index a3df02c..04c3f88 100644 --- a/claude-code/.gitignore +++ b/claude-code/.gitignore @@ -4,4 +4,6 @@ __pycache__/ # local capture artifacts manifest.json trace.json +verification_key.json +signing_key.json live.json diff --git a/claude-code/README.md b/claude-code/README.md index 3bfddcf..e14eb29 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -124,18 +124,26 @@ which is what you need to go look. ## Signed records: Agent Manifest and TRACE -When you run `/trace` (or `/manifest approve --sign`), the plugin writes two -signed JSON records: +When you run `/trace` (or `/manifest approve --sign`), the plugin writes three +files: -- an **Agent Manifest**: what your agent *is*, the full composition above, each - part fingerprinted and Ed25519-signed +- **`manifest.json`**, an **Agent Manifest**: what your agent *is*, the full + composition above, each part fingerprinted and Ed25519-signed ([agent-manifest](https://github.com/agentrust-io/agent-manifest)). -- a **TRACE Trust Record**: what your agent *did* this run, signed and checkable - against the public conformance suite +- **`trace.json`**, a **TRACE Trust Record**: what your agent *did* this run, + signed and checkable against the public conformance suite ([trace-spec](https://github.com/agentrust-io/trace-spec)). +- **`verification_key.json`**, the public key for the manifest. These are shareable proof a third party can verify without trusting your machine. -Confirm a record with: + +The manifest is signed with a **persistent** key kept at +`~/.claude/agentrust/signing_key.json`. It is generated once, reused every run, +and its private half never leaves your machine, so every record you produce +carries the same signing identity. `verification_key.json` publishes only the +public half (`{key_id, public_key_b64url}`). + +Confirm the TRACE record against the conformance suite: ```bash trace-tests verify --record trace.json --level 0 @@ -143,6 +151,19 @@ trace-tests verify --record trace.json --level 0 python -m trace_tests.cli verify --record trace.json --level 0 ``` +Verify the Agent Manifest on any machine, using only the published public key: + +```python +import json +from agent_manifest import VerificationContext, verify_manifest, RevocationStore + +manifest = json.load(open("manifest.json")) +vk = json.load(open("verification_key.json")) +ctx = VerificationContext(trusted_keys={vk["key_id"]: vk["public_key_b64url"]}) +result = verify_manifest(manifest, ctx, RevocationStore()) +print(result.result, result.signature_verified) # VALID True (any tampering -> MISMATCH False) +``` + ## Known limits (read before relying on it) This plugin is honest about what it is. On a normal developer machine: diff --git a/claude-code/commands/trace.md b/claude-code/commands/trace.md index bdd1879..aecf28f 100644 --- a/claude-code/commands/trace.md +++ b/claude-code/commands/trace.md @@ -17,6 +17,10 @@ Steps: actually connected now). 3. Run `python "${CLAUDE_PLUGIN_ROOT}/engine/capture.py" report --live-context live.json --out .` + This writes `manifest.json`, `trace.json`, and `verification_key.json` (the + public key a third party uses to verify the manifest). The manifest is signed + with the persistent key at `~/.claude/agentrust/signing_key.json`; the private + half never leaves the machine. 4. Optionally confirm the TRACE record passes the suite: `trace-tests verify --record trace.json --level 0` (or `python -m trace_tests.cli verify --record trace.json --level 0` if the @@ -27,6 +31,8 @@ Then explain the report the user actually cares about: - what the agent IS: skills, tools, MCP, model, permissions, each fingerprinted. - what it DID this run: the TRACE record, software-only and Level 0 on a dev box. - whether anything changed since their approved baseline. +- that the records are shareable: `manifest.json` verifies on any machine with + `verification_key.json`, and `trace.json` passes the public conformance suite. Be honest about scope. No TEE on a normal laptop means Level 0, not hardware attestation. The instruction-layer fingerprint covers `CLAUDE.md` and memory, not diff --git a/claude-code/engine/capture.py b/claude-code/engine/capture.py index 962562d..44372e1 100644 --- a/claude-code/engine/capture.py +++ b/claude-code/engine/capture.py @@ -30,9 +30,11 @@ from __future__ import annotations import argparse +import base64 import hashlib import json import os +import stat import sys import time import uuid @@ -43,6 +45,7 @@ STATE_DIR = CLAUDE_HOME / "agentrust" BASELINE = STATE_DIR / "baseline.json" LATEST = STATE_DIR / "session-latest.json" +SIGNING_KEY = STATE_DIR / "signing_key.json" # --------------------------------------------------------------------------- # @@ -349,7 +352,7 @@ def build_trace(cur: dict) -> dict: def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]: try: - from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest, generate_ed25519 + from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest from agentrust_trace import generate_key, sign_record except ImportError as e: raise SystemExit( @@ -359,9 +362,10 @@ def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]: "approve without --sign) does not need them." ) + kp = _load_or_create_manifest_keypair() + manifest = build_manifest(cur) Manifest.model_validate(manifest) - kp = generate_ed25519() manifest["signature"] = Ed25519Signer(kp).sign(manifest) Ed25519Verifier(kp.public_bytes).verify(manifest, manifest["signature"]["signature_value"]) @@ -370,9 +374,66 @@ def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]: outdir.mkdir(parents=True, exist_ok=True) (outdir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") (outdir / "trace.json").write_text(json.dumps(trace, indent=2), encoding="utf-8") + # Publish the public key so a third party can verify manifest.json without + # trusting this machine: load it into the verifier's trusted_keys as + # {key_id: public_key_b64url}. The private key never leaves ~/.claude. + verification_key = { + "algorithm": "Ed25519", + "key_id": kp.key_id, + "public_key_b64url": kp.public_b64url(), + "note": "load as {key_id: public_key_b64url} into the verifier's trusted_keys", + } + (outdir / "verification_key.json").write_text( + json.dumps(verification_key, indent=2), encoding="utf-8" + ) return manifest, trace +def _load_or_create_manifest_keypair(): + """Return a stable Ed25519 keypair, persisted at ~/.claude/agentrust. + + Signing the Agent Manifest with a fresh key every run would make the + signature unverifiable (nobody has the public half) and give the agent a + different identity each session. Instead we persist one key and publish its + public half beside each record, so manifest.json is genuinely third-party + verifiable and records chain to a single identity. The private key stays + local, readable only by the owner where the OS supports it. + """ + from agent_manifest import Ed25519KeyPair, generate_ed25519 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + existing = _load(SIGNING_KEY) + if existing and isinstance(existing.get("private_b64url"), str): + try: + pad = "=" * (-len(existing["private_b64url"]) % 4) + raw = base64.urlsafe_b64decode(existing["private_b64url"] + pad) + priv = Ed25519PrivateKey.from_private_bytes(raw) + return Ed25519KeyPair(priv, priv.public_key()) + except (ValueError, TypeError): + pass # corrupt key file: fall through and mint a fresh one + + kp = generate_ed25519() + SIGNING_KEY.parent.mkdir(parents=True, exist_ok=True) + SIGNING_KEY.write_text( + json.dumps( + { + "algorithm": "Ed25519", + "key_id": kp.key_id, + "public_key_b64url": kp.public_b64url(), + "private_b64url": kp.private_b64url(), + "created_at": _now_iso(), + }, + indent=2, + ), + encoding="utf-8", + ) + try: # best-effort owner-only permissions (no-op on filesystems without it) + os.chmod(SIGNING_KEY, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + return kp + + # --------------------------------------------------------------------------- # # report rendering # --------------------------------------------------------------------------- # @@ -409,7 +470,9 @@ def render_report(cur: dict, changes: list[dict] | None, signed: bool) -> str: L.append("") if signed: L += [" Signed records written: manifest.json (agent-manifest, verified),", - " trace.json (TRACE Level 0, software-only).", ""] + " trace.json (TRACE Level 0, software-only),", + " verification_key.json (public key to verify", + " manifest.json on another machine).", ""] L.append("=" * 66) return "\n".join(L) diff --git a/claude-code/tests/test_capture.py b/claude-code/tests/test_capture.py index 07c1587..9986eea 100644 --- a/claude-code/tests/test_capture.py +++ b/claude-code/tests/test_capture.py @@ -9,6 +9,8 @@ import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "engine")) import capture # noqa: E402 @@ -188,3 +190,48 @@ def boom(*a, **k): payload = json.loads(out) assert payload["hookSpecificOutput"]["hookEventName"] == "SessionStart" assert "integrity check skipped" in payload["hookSpecificOutput"]["additionalContext"] + + +# --------------------------------------------------------------------------- +# Signing: persistent key + externally-verifiable, tamper-evident manifest. +# These need the crypto packages; skipped cleanly where they are absent. +# --------------------------------------------------------------------------- +def _point_signing_key(tmp_path, monkeypatch): + monkeypatch.setattr(capture, "STATE_DIR", tmp_path / "agentrust") + monkeypatch.setattr(capture, "SIGNING_KEY", tmp_path / "agentrust" / "signing_key.json") + + +def test_signing_key_is_persisted_and_stable(tmp_path, monkeypatch): + pytest.importorskip("agent_manifest") + pytest.importorskip("cryptography") + _point_signing_key(tmp_path, monkeypatch) + + kp1 = capture._load_or_create_manifest_keypair() + assert capture.SIGNING_KEY.is_file() + kp2 = capture._load_or_create_manifest_keypair() # second run must reuse it + assert kp1.key_id == kp2.key_id + + +def test_manifest_is_externally_verifiable_and_tamper_evident(tmp_path, monkeypatch): + pytest.importorskip("agent_manifest") + pytest.importorskip("cryptography") + from agent_manifest import RevocationStore, VerificationContext, verify_manifest + + _point_signing_key(tmp_path, monkeypatch) + out = tmp_path / "records" + cur = capture.snapshot({"model_id": "claude-x", "builtin_tools": ["Bash"], "mcp_servers": ["github"]}) + manifest, _trace = capture.sign_all(cur, out) + + vk = json.loads((out / "verification_key.json").read_text(encoding="utf-8")) + assert vk["key_id"] == manifest["signature"]["key_id"] + ctx = VerificationContext(trusted_keys={vk["key_id"]: vk["public_key_b64url"]}) + + # a third party with only the published public key verifies the manifest + good = verify_manifest(manifest, ctx, RevocationStore()) + assert good.signature_verified is True + + # any post-signing change breaks verification + tampered = json.loads(json.dumps(manifest)) + tampered["artifacts"]["policy_bundle"]["hash"] = "sha256:" + "0" * 64 + bad = verify_manifest(tampered, ctx, RevocationStore()) + assert bad.signature_verified is False