Skip to content

Commit 647f140

Browse files
fix(claude-code): sign the TRACE record with the persisted key, not a fresh one (#180)
sign_all called agentrust_trace.generate_key() per record, so the private half was discarded immediately and the only copy of the public half was the cnf.jwk inside the record itself. agentrust_trace.verify_record refuses that by default: ValueError: verify_record requires a trusted key. Pass an Ed25519PublicKey or JWK dict, or set allow_embedded_key=True to (insecurely) trust the key embedded in record.cnf.jwk. and with the flag on it warns that this "proves the record is internally consistent, NOT that it came from a trusted issuer". So the signature on a per-session signed Trust Record attested to nothing about its origin, which is the property the record exists to carry. Anyone can generate a key, sign a record and embed the public half. It also gave the agent a new TRACE identity every session. Three records captured on this machine in August carry three different cnf.jwk keys (EQRU0ZFB..., tjOZOANU..., FJscFYnI...) while their manifests all share key 92d6daa1.... An identity that changes per session cannot be pinned out of band and cannot be registered as a trace-registry producer, because the registry looks a producer key up from producers/ rather than trusting the key a claim names for itself. The record now uses the keypair the manifest already uses, persisted at ~/.claude/agentrust/signing_key.json, whose public half is published beside every record as verification_key.json. Third parties verify both files with that one key, and the note in it says so. Records emitted before this change cannot be retrofitted; their signing keys are gone. The test suite asserted the manifest was externally verifiable and never asked the same of the record, which is why this survived three sessions. Two tests now mirror that assertion: the record verifies under the published key with no allow_embedded_key, refuses a different key, and the manifest and record share one key across two runs. Both fail against the previous behaviour, checked by reverting. 61 pass. Claude-Session: https://claude.ai/code/session_01X2GDChXjA7BAdDNzCAmBJv Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 30fef35 commit 647f140

3 files changed

Lines changed: 105 additions & 6 deletions

File tree

claude-code/CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,32 @@ All notable changes to the AgenTrust for Claude Code plugin.
44

55
## Unreleased
66

7+
### Fixed
8+
- **The TRACE Trust Record is signed with the plugin's persisted key, not a
9+
throwaway one.** `sign_all` called `agentrust_trace.generate_key()` for each
10+
record, so the private half was discarded at once and the only copy of the
11+
public half was the `cnf.jwk` inside the record itself.
12+
`agentrust_trace.verify_record` refuses that by default and warns when forced
13+
with `allow_embedded_key=True` that it "proves the record is internally
14+
consistent, NOT that it came from a trusted issuer" - so the signature
15+
attested to nothing about origin, which is the property the record exists to
16+
carry. It also gave the agent a new TRACE identity every session: three
17+
records captured locally in August carry three different keys, where their
18+
manifests all share one.
19+
20+
The record now shares the key that signs the manifest, whose public half is
21+
already published beside it as `verification_key.json`. That makes the record
22+
verifiable by a third party holding only that file, and makes the identity
23+
stable enough to pin out of band or register as a trace-registry producer,
24+
which a per-session key can never be.
25+
26+
Records emitted before this change cannot be retrofitted; their signing keys
27+
no longer exist. Re-run `/trace` to emit a verifiable one.
28+
29+
Found because the test suite asserted the manifest was externally verifiable
30+
and never asked the same of the record. It does now, and both new tests fail
31+
against the previous behaviour.
32+
733
### Breaking
834
- Drift detection now requires the separately published
935
`agentrust-capture-core` package; the previous vendored fallback was removed.

claude-code/engine/capture.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ def build_trace(cur: dict) -> dict:
451451
def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]:
452452
try:
453453
from agent_manifest import Ed25519Signer, Ed25519Verifier, Manifest
454-
from agentrust_trace import generate_key, sign_record
454+
from agentrust_trace import sign_record
455455
except ImportError as e:
456456
raise SystemExit(
457457
"Signing needs the crypto packages, which are not installed. Run:\n"
@@ -467,19 +467,28 @@ def sign_all(cur: dict, outdir: Path) -> tuple[dict, dict]:
467467
manifest["signature"] = Ed25519Signer(kp).sign(manifest)
468468
Ed25519Verifier(kp.public_bytes).verify(manifest, manifest["signature"]["signature_value"])
469469

470-
trace = sign_record(build_trace(cur), generate_key())
470+
# The same persisted key that signs the manifest, not a fresh one. A record
471+
# signed by a throwaway key keeps its only public half inside its own
472+
# cnf.jwk, and agentrust_trace.verify_record refuses that by default:
473+
# trusting the key a record names proves the record is internally
474+
# consistent, not that it came from anyone. It also gave the agent a new
475+
# TRACE identity every session, so the key could never be pinned out of
476+
# band or registered as a producer.
477+
trace = sign_record(build_trace(cur), kp.private_key)
471478

472479
outdir.mkdir(parents=True, exist_ok=True)
473480
(outdir / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
474481
(outdir / "trace.json").write_text(json.dumps(trace, indent=2), encoding="utf-8")
475-
# Publish the public key so a third party can verify manifest.json without
476-
# trusting this machine: load it into the verifier's trusted_keys as
477-
# {key_id: public_key_b64url}. The private key never leaves ~/.claude.
482+
# Publish the public key so a third party can verify manifest.json and
483+
# trace.json without trusting this machine: load it into the verifier's
484+
# trusted_keys as {key_id: public_key_b64url}. The private key never leaves
485+
# ~/.claude.
478486
verification_key = {
479487
"algorithm": "Ed25519",
480488
"key_id": kp.key_id,
481489
"public_key_b64url": kp.public_b64url(),
482-
"note": "load as {key_id: public_key_b64url} into the verifier's trusted_keys",
490+
"note": "load as {key_id: public_key_b64url} into the verifier's trusted_keys; "
491+
"verifies both manifest.json and trace.json, which share this key",
483492
}
484493
(outdir / "verification_key.json").write_text(
485494
json.dumps(verification_key, indent=2), encoding="utf-8"

claude-code/tests/test_claude_code_capture.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
from __future__ import annotations
77

8+
import base64
89
import importlib.util
910
import json
1011
from pathlib import Path
@@ -543,6 +544,69 @@ def test_manifest_is_externally_verifiable_and_tamper_evident(tmp_path, monkeypa
543544
assert bad.signature_verified is False
544545

545546

547+
def test_trace_record_is_externally_verifiable_with_the_published_key(tmp_path, monkeypatch):
548+
"""The mirror of the manifest test above, which is why this was missed.
549+
550+
The manifest had this test and the trace record did not, so the trace
551+
record was signed with agentrust_trace.generate_key() for three sessions
552+
and nobody noticed. A record signed by a throwaway key keeps its only
553+
public half inside its own cnf.jwk, and verify_record refuses that by
554+
default: it proves the record is internally consistent, not that it came
555+
from anyone.
556+
"""
557+
pytest.importorskip("agentrust_trace")
558+
pytest.importorskip("cryptography")
559+
from agentrust_trace import verify_record
560+
561+
_point_signing_key(tmp_path, monkeypatch)
562+
out = tmp_path / "records"
563+
cur = capture.snapshot({"model_id": "claude-x", "builtin_tools": ["Bash"], "mcp_servers": []})
564+
_manifest, trace = capture.sign_all(cur, out)
565+
566+
vk = json.loads((out / "verification_key.json").read_text(encoding="utf-8"))
567+
jwk = {"kty": "OKP", "crv": "Ed25519", "x": vk["public_key_b64url"]}
568+
569+
# A third party holding only the published public key verifies the record.
570+
# No allow_embedded_key: that flag is the insecure path this test exists to
571+
# keep us off, and verify_record raises without either.
572+
verify_record(trace, jwk, max_age_seconds=None)
573+
574+
# And the record must not be verifiable under some other key.
575+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
576+
other = Ed25519PrivateKey.generate().public_key().public_bytes_raw()
577+
other_jwk = {"kty": "OKP", "crv": "Ed25519",
578+
"x": base64.urlsafe_b64encode(other).decode().rstrip("=")}
579+
with pytest.raises(Exception):
580+
verify_record(trace, other_jwk, max_age_seconds=None)
581+
582+
583+
def test_manifest_and_trace_share_one_stable_identity(tmp_path, monkeypatch):
584+
"""Two sessions, one key, and the same key across both record types.
585+
586+
A TRACE identity that changes every session cannot be pinned out of band,
587+
so it can never be registered as a trace-registry producer. Registering a
588+
producer is the whole point of a stable signing identity.
589+
"""
590+
pytest.importorskip("agentrust_trace")
591+
pytest.importorskip("cryptography")
592+
593+
_point_signing_key(tmp_path, monkeypatch)
594+
cur = capture.snapshot({"model_id": "claude-x", "builtin_tools": [], "mcp_servers": []})
595+
596+
first_manifest, first_trace = capture.sign_all(cur, tmp_path / "run1")
597+
second_manifest, second_trace = capture.sign_all(cur, tmp_path / "run2")
598+
599+
published = json.loads(
600+
(tmp_path / "run1" / "verification_key.json").read_text(encoding="utf-8")
601+
)["public_key_b64url"]
602+
603+
# The trace record names its confirmation key, and it has to be the
604+
# published one rather than a per-session key.
605+
assert first_trace["cnf"]["jwk"]["x"] == published
606+
assert second_trace["cnf"]["jwk"]["x"] == first_trace["cnf"]["jwk"]["x"]
607+
assert first_manifest["signature"]["key_id"] == second_manifest["signature"]["key_id"]
608+
609+
546610
# ---------------------------------------------------------------------------
547611
# Baseline integrity: the baseline is what every comparison is made against, so
548612
# a baseline that can be rewritten unnoticed makes the drift check pass forever.

0 commit comments

Comments
 (0)