diff --git a/docs/tutorials/anchoring-to-the-registry.md b/docs/tutorials/anchoring-to-the-registry.md new file mode 100644 index 0000000..ead0f9e --- /dev/null +++ b/docs/tutorials/anchoring-to-the-registry.md @@ -0,0 +1,172 @@ +# Anchoring a Trust Record to the TRACE registry + +After signing a Trust Record, you should anchor it to the TRACE transparency registry. The anchor receipt proves that the record existed at a specific time and has not been altered since — tamper evidence that holds even if the operator who produced the record is later compromised. + +**What you need:** A signed Trust Record (from [Signing your first trust record](signing-your-first-trust-record.md)). + +**What you'll do:** Submit the record to the registry, receive a SCITT receipt, and set the `transparency` field to the canonical receipt URI. + +--- + +## Why transparency anchoring matters + +A Trust Record carries a signature from the issuer's key. A verifier holding that key can confirm the record has not been modified — but only if the key is trustworthy. If the issuer is later compromised, an attacker could forge records backdated to before the compromise. + +The `transparency` field solves this with a different trust root: an append-only log operated by an independent party. Once a record is registered, its content is fixed in the log at that timestamp. A verifier checks that the record's digest matches the log entry — no trust in the operator required. + +TRACE uses SCITT ([draft-ietf-scitt-architecture](https://datatracker.ietf.org/doc/draft-ietf-scitt-architecture/)) as its transparency log substrate. + +--- + +## The `transparency` field + +In the `TrustRecord` schema, `transparency` is a required string: + +```python +transparency: Annotated[str, Field(min_length=1)] +``` + +It holds the canonical URI of the registry entry — the URL at which any verifier can independently retrieve the SCITT receipt and confirm the record's inclusion. + +Example value from the spec: + +``` +https://registry.agentrust.io/claim/trace-2026-06-23T09:15:42Z-f2a8d1 +``` + +--- + +## Step 1 — Build and sign the record with a placeholder + +During development, use a placeholder for `transparency` so you can construct and sign a valid record before anchoring: + +```python +import time +from agentrust_trace.sign import generate_key, sign_record + +key = generate_key() + +record = { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": int(time.time()), + "subject": "spiffe://example.org/agent/my-agent", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6", + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:" + "0" * 64, + }, + "policy": { + "bundle_hash": "sha256:" + "a" * 64, + "enforcement_mode": "enforce", + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:" + "b" * 64, + }, + "appraisal": { + "status": "none", + "verifier": "self", + }, + # Placeholder — replace after anchoring + "transparency": "pending", +} + +signed = sign_record(record, key) +``` + +!!! note + `transparency: "pending"` is valid for local development. Production records MUST carry a real receipt URI before being handed to a verifier that enforces §3.3 step 6. + +--- + +## Step 2 — Submit to the registry + +!!! info "No SDK client yet" + The `agentrust_trace` SDK does not yet include a registry client. Submit directly via HTTP using the SCITT Reference API ([draft-ietf-scitt-scrapi](https://datatracker.ietf.org/doc/draft-ietf-scitt-scrapi/)). A Python client will be added to the SDK in a future release. + +Submit the signed record as a SCITT Signed Statement: + +```python +import json +import requests # pip install requests + +REGISTRY_URL = "https://registry.agentrust.io" + +response = requests.post( + f"{REGISTRY_URL}/entries", + headers={"Content-Type": "application/json"}, + data=json.dumps(signed), + timeout=30, +) +response.raise_for_status() + +entry = response.json() +# entry contains the registry-assigned receipt URI +receipt_uri = entry["receipt_uri"] +print(f"Anchored: {receipt_uri}") +``` + +The registry returns a JSON object with at minimum: + +| Field | Description | +|---|---| +| `receipt_uri` | Canonical URL of the SCITT receipt — use this as `transparency` | +| `entry_id` | Registry-internal identifier for the log entry | +| `registered_at` | ISO 8601 timestamp of registration | + +--- + +## Step 3 — Set `transparency` and re-sign + +Replace the placeholder and re-sign the record with the real receipt URI: + +```python +record["transparency"] = receipt_uri +signed_final = sign_record(record, key) +``` + +The signature now covers the real `transparency` value. A verifier who later retrieves the receipt from `receipt_uri` can confirm the digest matches — without contacting the original operator. + +--- + +## Step 4 — Verify the receipt (optional, recommended) + +To confirm the registry accepted the record correctly, retrieve and inspect the receipt: + +```python +receipt_resp = requests.get(receipt_uri, timeout=30) +receipt_resp.raise_for_status() +receipt = receipt_resp.json() + +# The receipt contains the log entry digest and a Merkle inclusion proof. +# Check that it references your record's content. +assert receipt["subject"] == signed_final["subject"] +assert receipt["iat"] == signed_final["iat"] +``` + +A full cryptographic Merkle proof verification is not yet in the SDK. The registry exposes the raw proof fields for implementers who want to verify inclusion independently. + +--- + +## Verification step 6 + +When a verifier calls `verify_record()`, it checks the signature. Step 6 of the TRACE verification procedure (spec §3.3) additionally requires the transparency receipt to resolve: + +> SCITT receipt resolves on the named transparency log. + +A verifier configured with `require_transparency: true` will retrieve `transparency` and check the digest against the log. Verifiers that skip this step accept a weaker guarantee — signature-only, not log-anchored. + +--- + +## Summary + +| Step | What happens | +|---|---| +| Build record with `"pending"` | Valid locally, not for production hand-off | +| POST to registry | SCITT log records the digest at this timestamp | +| Replace `transparency`, re-sign | Signature now covers the real receipt URI | +| Verifier retrieves receipt URI | Tamper evidence independent of operator trust | diff --git a/docs/tutorials/verifying-the-audit-chain.md b/docs/tutorials/verifying-the-audit-chain.md new file mode 100644 index 0000000..e18ab04 --- /dev/null +++ b/docs/tutorials/verifying-the-audit-chain.md @@ -0,0 +1,251 @@ +# Verifying the tool call transcript + +A TRACE Trust Record commits the evidence of every tool call by hash. This tutorial explains what the `tool_transcript` field contains, how to verify that a received record's transcript hash is consistent with the actual tool calls, and how external execution receipts extend the chain. + +**What you need:** A Trust Record with a `tool_transcript` field, the matching transcript file, and the issuer's public key. + +--- + +## What `tool_transcript` captures + +The `tool_transcript` field in `TrustRecord` has three fields: + +```python +class ToolTranscript(BaseModel): + hash: DigestStr # sha256 or sha384 digest of all tool call content + call_count: int | None # number of calls in this session (optional) + transcript_uri: str | None # where the full transcript can be retrieved +``` + +`hash` is the binding between the Trust Record (which is signed) and the full transcript (which is stored externally). When the Trust Record signature verifies, the `hash` inside it is signed. When the hash matches the transcript you retrieve from `transcript_uri`, you know the transcript has not been altered since the record was signed. + +The full transcript is NOT embedded in the Trust Record — it lives at `transcript_uri`. This keeps records small enough to sign and transmit while still committing all call-level evidence. + +--- + +## Step 1 — Retrieve and verify the record signature + +Start by checking the Trust Record signature with the issuer's public key: + +```python +from agentrust_trace.sign import verify_record, load_key + +with open("issuer_pub.pem", "rb") as f: + public_key = load_key(f.read()) + +with open("trust_record.json") as f: + import json + record = json.load(f) + +result = verify_record(record, public_key_or_jwk=public_key) +# raises agentrust_trace.exceptions.VerificationError on failure +# returns True on success +``` + +`verify_record` confirms that the signed content of the record has not been altered. This includes the `tool_transcript.hash` field — if the signature is valid, you have a trusted copy of the hash. + +--- + +## Step 2 — Retrieve the transcript + +The full transcript lives at `tool_transcript.transcript_uri`. Retrieve it and hold the raw bytes for hashing: + +```python +import requests + +transcript_uri = record["tool_transcript"]["transcript_uri"] +response = requests.get(transcript_uri, timeout=30) +response.raise_for_status() + +# Hold raw bytes — hash must be computed over the exact bytes served +transcript_bytes = response.content +``` + +!!! warning "Hash bytes, not parsed content" + The `tool_transcript.hash` is computed over the raw bytes of the transcript as stored. Do not decode, re-encode, or reformat before hashing — JSON parsing and re-serialization changes whitespace and key order, which changes the hash. + +--- + +## Step 3 — Verify the transcript hash + +Parse the `hash` field to determine the algorithm, then compute and compare: + +```python +import hashlib + +expected = record["tool_transcript"]["hash"] +# expected is a DigestStr: "sha256:" or "sha384:" + +algorithm, expected_hex = expected.split(":", 1) + +if algorithm == "sha256": + computed = hashlib.sha256(transcript_bytes).hexdigest() +elif algorithm == "sha384": + computed = hashlib.sha384(transcript_bytes).hexdigest() +else: + raise ValueError(f"Unsupported digest algorithm: {algorithm}") + +if computed != expected_hex: + raise RuntimeError( + f"Transcript hash mismatch.\n" + f" Expected: {expected}\n" + f" Computed: {algorithm}:{computed}" + ) + +print(f"Transcript verified: {len(transcript_bytes)} bytes, {algorithm}:{computed[:16]}...") +``` + +If this check passes, the transcript at `transcript_uri` is byte-for-byte what was hashed when the Trust Record was signed. Combined with the signature check from Step 1, this gives you end-to-end integrity: record → hash → transcript. + +--- + +## Step 4 — Inspect individual call records + +The transcript is a JSON array of tool call records. Each entry captures one call: + +```json +[ + { + "call_index": 0, + "tool_name": "read_file", + "input_hash": "sha256:...", + "output_hash": "sha256:...", + "started_at": "2026-06-23T09:14:58Z", + "duration_ms": 142 + } +] +``` + +The inputs and outputs are themselves hashed — the raw argument and response values are not in the transcript by default. This protects sensitive tool arguments while still committing the content: + +```python +import json + +calls = json.loads(transcript_bytes) + +print(f"Total calls: {len(calls)}") +for call in calls: + print(f" [{call['call_index']}] {call['tool_name']}") + print(f" input: {call.get('input_hash', 'not committed')}") + print(f" output: {call.get('output_hash', 'not committed')}") +``` + +Cross-check against `call_count` if it was set in the Trust Record: + +```python +call_count = record["tool_transcript"].get("call_count") +if call_count is not None and len(calls) != call_count: + print(f"Warning: record says {call_count} calls but transcript has {len(calls)}") +``` + +--- + +## External execution receipts + +For high-assurance scenarios, individual calls may carry external execution receipts — signed by a third-party (the caller, an orchestrator, or a notary) rather than the agent that produced the Trust Record. + +The spec (§3.3.1) defines the receipt structure: + +| Field | Description | +|---|---| +| `issuer` | URI identifying the signing party | +| `issuer_key_id` | Key identifier within that party's key set | +| `signature` | Signature over `evidence_hash` | +| `evidence_hash` | Digest of the specific call being attested | +| `evidence_type` | Content type of the evidence (e.g., `application/json`) | +| `linked_call_id` | The call index this receipt binds to | + +To verify a receipt against a specific call: + +```python +def verify_external_receipt(call, receipt, issuer_public_key): + expected_hash = call["input_hash"] # or output_hash depending on what was attested + algorithm, expected_hex = expected_hash.split(":", 1) + + receipt_evidence_hash = receipt["evidence_hash"] + receipt_alg, receipt_hex = receipt_evidence_hash.split(":", 1) + + # The receipt's evidence_hash must match the call's committed hash + if receipt_hex != expected_hex or receipt_alg != algorithm: + raise RuntimeError( + f"Receipt evidence_hash does not match call {call['call_index']}" + ) + + # The signature covers the evidence_hash bytes (algorithm-specific) + # Verify using the issuer's public key from their published key set + # (Key retrieval from issuer URI is application-specific) + # ... + return True +``` + +!!! info "No SDK helper for receipt verification" + The `agentrust_trace` SDK does not include an issuer key resolver or receipt chain verifier. Resolution of `issuer` URIs to public keys is application-specific — typically a DID document or a published JWK Set at a well-known endpoint. + +--- + +## Putting it together + +A complete audit verification run: + +```python +from agentrust_trace.sign import verify_record, load_key +import hashlib +import json +import requests + +def verify_audit_chain(record_path, public_key_path): + with open(public_key_path, "rb") as f: + public_key = load_key(f.read()) + + with open(record_path) as f: + record = json.load(f) + + # Step 1: Verify record signature + verify_record(record, public_key_or_jwk=public_key) + print("Signature: OK") + + tt = record.get("tool_transcript") + if not tt: + print("No tool_transcript — nothing further to verify") + return + + # Step 2: Retrieve transcript + uri = tt.get("transcript_uri") + if not uri: + print("No transcript_uri — cannot retrieve transcript") + return + + transcript_bytes = requests.get(uri, timeout=30).content + + # Step 3: Hash check + algorithm, expected_hex = tt["hash"].split(":", 1) + hashfn = hashlib.sha256 if algorithm == "sha256" else hashlib.sha384 + computed = hashfn(transcript_bytes).hexdigest() + + if computed != expected_hex: + raise RuntimeError(f"Transcript hash mismatch: got {algorithm}:{computed}") + + print(f"Transcript hash: OK ({algorithm}:{computed[:16]}...)") + + # Step 4: Call count + calls = json.loads(transcript_bytes) + call_count = tt.get("call_count") + if call_count is not None: + match = "OK" if len(calls) == call_count else "MISMATCH" + print(f"Call count: {len(calls)}/{call_count} [{match}]") + else: + print(f"Calls in transcript: {len(calls)}") +``` + +--- + +## Summary + +| Step | What it proves | +|---|---| +| `verify_record()` | Record was not altered after signing; `tool_transcript.hash` is trusted | +| Transcript hash check | Transcript bytes are exactly what was hashed at signing time | +| Call count check | Transcript was not truncated | +| External receipt check | Third-party confirms specific call inputs/outputs (optional) | + +The chain of custody runs: hardware/software measurement → signed Trust Record → committed transcript hash → per-call hashes → optional external receipts. Each link is independently verifiable without contacting the operator who produced the record. diff --git a/mkdocs.yml b/mkdocs.yml index 3f6a1ac..907aa9b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -157,6 +157,8 @@ nav: - Verify a trust record: docs/tutorials/verifying-a-trust-record.md - Hardware attestation platforms: docs/tutorials/hardware-attestation-platforms.md - Integration with cMCP: docs/tutorials/integrating-with-cmcp.md + - Anchor to the registry: docs/tutorials/anchoring-to-the-registry.md + - Verify the tool transcript: docs/tutorials/verifying-the-audit-chain.md - Specification: spec/trace-v0.1.md - Integration: - AGT: docs/integration/agt.md