diff --git a/CHANGELOG.md b/CHANGELOG.md index d09c93d..6aaf1f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,264 +1,265 @@ -# Changelog - -All notable changes to the TRACE specification will be documented here. - -Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.MINOR.PATCH`: -- **MAJOR**: breaking changes to wire format or required Trust Record fields -- **MINOR**: new optional fields, new platform profiles, new conformance levels -- **PATCH**: editorial fixes, clarifications, non-normative additions - ---- - -## [Unreleased] - -### Added - -- **Record-signing key revocation is anchored to transparency-log entry ordering, not to `iat`.** New spec section 3.2.3 defines the `TraceRevocation/1.0` claim type: a record from a revoked key is valid if and only if its SCITT inclusion entry ID is at or below `last_valid_entry_id` on the log the statement names. The intuitive time-based rule cannot work, because a compromised record-signing key also signs the `iat` it would be judged against, so an attacker backdates the record and the rule passes. Entry IDs are monotonic and bound to the Merkle structure, so ordering survives the compromise a timestamp does not. - - Distribution keeps section 3.3's no-callback property: statements are anchored in the same log as the records they govern, and verifiers cache a signed bundle carrying `valid_until`. An expired bundle is not a pass, and a verifier with none reports that it performed no revocation check rather than reporting an affirming appraisal. A revocation statement MUST be signed by a key above the revoked one in the section 3.2.1 hierarchy, or by a recovery key with an independent compromise domain, because a statement the compromised key could sign for itself is a tool for whoever stole it. Records with no usable inclusion entry ID fall back to binary revocation, which is the existing behaviour. Schemas: `schema/trace-revocation.json`, `schema/trace-revocation-bundle.json`. Resolves [#67](https://github.com/agentrust-io/trace-spec/issues/67). - - Section 3.2.1 previously required verifiers to "consult current revocation status at verification time", which contradicted the offline-verification property in the same document. It now points at 3.2.3. - -- **`build_provenance` now declares verification depth.** A new optional `provenance_depth` (`surface`, `builder`, `transitive`) says how far down the supply chain the issuer claims to have walked, and a new optional `appraisal.provenance_depth_verified` records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting `provenance_depth` is read as `surface`, so existing records keep their meaning. Evidence that does not resolve and evidence that resolves and contradicts the record are separate outcomes: the first downgrades the recorded depth and names what was missing, the second fails the appraisal and cannot be downgraded away. Resolves [#50](https://github.com/agentrust-io/trace-spec/issues/50). - -### Security - -- **Release gates now fail closed.** CodeQL analysis failures block instead of being ignored. Before trusted PyPI publication, clean virtual environments install and verify both the built wheel and source distribution outside the checkout, checking tag/version identity, packaged schema resources, signing and verification, and rejection of unknown security fields. - -### Fixed - -- **Local test runs now always exercise the checkout.** Pytest prepends `src` to its import path, and a regression test asserts that `agentrust_trace` resolves to the repository source. A stale installed wheel can no longer shadow current security fixes and produce misleading failures or passes. - -### Fixed - -- **The confirmation key must now match the trusted signing key.** `verify_record()` compares the RFC 7638 thumbprints of `cnf.jwk` and the caller-supplied trusted key before accepting the signature. A trusted signer can no longer produce a record that verifies under one key while naming another key for downstream proof-of-possession checks. - -- **`verify_record()` now enforces the canonical v0.2 JSON Schema.** A cryptographically valid signature no longer causes an object with unknown fields, missing required claims, or invalid nested values to be accepted as a verified TRACE record. Schema failures are surfaced as `ValueError` with the failing field path. - -### Fixed - -- **Future-dated records no longer create an unbounded freshness window.** `verify_record()` now rejects an `iat` later than the verifier's clock plus `max_future_skew_seconds` (default 5 minutes), independently of the maximum-age check. The v0.2 freshness requirements and verification tutorial document both bounds. - -## [0.9.0] — 2026-08-09 - -### Documentation - -- **The security policy now describes the software that is actually released.** It puts the Python signing and verification APIs, schemas, adapters, packaging, and release automation in scope; lists TRACE v0.2 and `agentrust-trace` 0.x as supported; and marks the superseded v0.1 profile unsupported. - -## [0.9.0] — 2026-08-09 - -### Added - -- **`spec/content-marking-v1.md` and `agentrust_trace.content_marking`: bind a marked asset to the execution that produced it.** EU AI Act Article 50(2) and 50(4) have been in force since 2 August 2026 and are the only AI Act obligations that bite this year. One C2PA assertion, `com.agentrust-io.trace`, carries a hashed reference to the Trust Record for the execution that produced the asset. - - **The document opens with what this does not do**, because the temptation to overclaim here is strong. It does not stop anyone stripping the mark: removing a C2PA manifest from a file is trivial and nothing here changes that. It is not watermarking. It does not make a deployment compliant. What it buys is narrower and real: a mark that *is* present becomes checkable against a hardware-rooted claim instead of being an unverifiable label, and in a channel that requires marks an absent one is detectable. - - Three separate things must be checked and the assertion merges none of them: the C2PA signature says the assertion was in the manifest when the asset was signed, the TRACE signature says the execution happened as described, and the hash says the record being pointed at is the one the signer meant. A verifier that checks one of the three has checked a third. - - `build_assertion()` takes the **exact bytes** that will be served rather than a record object, because a hash over a re-serialized dict is a hash of bytes nobody will fetch; a test pins that `indent=2` alone breaks the binding. `verify_assertion()` has no signature-only path: `record_bytes` is a required parameter, since an assertion whose hash was never checked is a URL in a file. - - 17 tests. - -### Added - -- **`enforcement_mode: "declared"`.** The three existing modes all assert that *something evaluated the policy*: `enforce` acted on the result, `advisory` did not, `silent` acted with the log lines suppressed. `declared` asserts less — the policy is named and bound into the signed record, and nothing evaluated it. - - That is not a corner case, it is the common one. An agent framework has no policy engine, so a record built by observing a LangChain or LlamaIndex run has a policy the operator declares and no evaluation of it anywhere. With three values, such a record had to claim an evaluation that never happened; both framework adapters refused to default the field and documented the overstatement instead, which is honest and still leaves every framework record marginally untrue. - - `declared` is the weakest value and is never a default. A producer that evaluates policy MUST NOT use it, and a consumer MUST NOT read it as evidence that any rule was checked. A verifier appraising for enforcement SHOULD treat it as it treats an absent enforcement claim. - - Additive to a closed enum in both the model and the JSON schema, so unknown values are still rejected. Same one-directional consequence as `origin`: a verifier older than this release rejects a record carrying `declared`. - -## [0.8.0] — 2026-08-09 - -### Added - -- **`agentrust_trace.provenance`: build, sign and verify MCP Server Provenance Records.** Step 2 of the sequence, implementing `spec/server-provenance-v1.md`. In the SDK rather than in cMCP, so a publisher can produce a record without adopting a runtime, which is the only way the format reaches an ecosystem that will not adopt one. - - **`check_tool_catalog()` is a separate call on purpose.** Verifying the signature proves a document is internally consistent and signed by a key you trust, which is exactly what an attacker holding a stolen publisher key can produce. What they cannot do is make the server in front of you offer the tools their record describes. That comparison needs something `verify_record()` does not have — what the server said to *you* — so it is its own obvious call rather than a flag, and it raises its own exception type so a consumer can tell "bad document" from "wrong server". - - The builder refuses records that cannot mean anything: an identity with neither artifact nor endpoint, a `tee-attested` record with no evidence, evidence attached to a kind that does not claim it (a reader would take it as an attestation that was made), a publisher that is a display name rather than a resolvable DID or SPIFFE URI, and an endpoint URL with no key digest, since a URL alone is not an identity. - - `verify_record()` requires a trusted key and never takes one from the record. Verifying a document against a key it supplies proves only that it is internally consistent, which is what a forgery is. - - 24 tests, including the one the format exists for: a perfectly valid signature over a description of a different server still fails the catalog check. - -### Added - -- **`spec/server-provenance-v1.md`: a signed statement about an MCP server.** cMCP enforces policy at the call boundary and can say nothing about whether the server on the other end is what it claims. Its catalog answers that locally — approved definitions, a measured catalog hash, a pinned TLS fingerprint — but every part of that is operator-asserted, so nothing one operator learns is usable by the next. - - The format carries the same shape of honesty as the `origin` block: a closed `kind` (`publisher-asserted`, `observer-attested`, `tee-attested`) because the interesting fact is never that provenance exists but who is asserting it, and an explicit rule that a verifier MUST NOT treat absence as any of them. - - Identity is `artifact`, `endpoint`, or both, with the record saying which. A URL is the obvious handle and the worst candidate: it moves, it is per-deployment, and two operators running the same server produce different ones. `artifact.digest` covers the entrypoint rather than the interpreter, for the reason the stdio work found: every interpreted server on a host shares one interpreter digest, so a pin over it matches a completely different server. - - The tool-catalog hash covers name, description and input schema. Description is in deliberately — a tool whose description changes from "search the docs" to "search the docs and email results to the address in the query" is exactly the rug-pull the hash exists to catch, and a hash over names alone misses it. - - Three things are stated as out of scope rather than hand-waved: key distribution for `publisher` (a PKI question this format would only pretend to solve), whether a server is any good (the moment a provenance format scores servers, its publisher becomes the party everyone must trust), and what the code does at runtime (provenance narrows what code you are talking to, nothing more). - -### Fixed - -- **The packaged schema had drifted from the normative one, and a DID subject was the casualty.** `validate_json()` loads `src/agentrust_trace/schema/trace-v0.2.json`, while the spec, README and CONTRIBUTING all point a reader at `schema/trace-claim.json`. The two disagreed in three places, so the schema someone reads was not the schema their record was checked against. The visible consequence: the root file and `models.py` both accept a `did:` subject, the packaged copy still required `^spiffe://`, so `agentrust_trace.validate_json()` rejected a subject form the specification permits. Also resynced: the `slsa_level` description, and the root file's `$id`, which still said `trace-v0.1.json` on a schema whose `eat_profile` const is v0.2. - - `tests/test_validate.py` now compares the two as parsed JSON on every run, so the next drift fails instead of shipping. Compared parsed rather than byte for byte because the two files differ in line endings by long-standing accident, which changes nothing about how either validates a record. - -- **A 0.6.0 changelog entry was filed under 0.5.1.** The `verify_record()` profile-cutover enforcement shipped in 0.6.0; its entry landed in the 0.5.1 section, next to the cutover declaration it implements, which left 0.5.1 with two `### Fixed` blocks and 0.6.0 with no entry for a behaviour change. Moved, with its two internal cross-references corrected to match where it now sits. - -## [0.7.0] — 2026-08-08 - -### Added - -- **`origin` (optional object): who assembled this record, when it was not the runtime that ran.** `{kind, producer, source_event_id, ingested_at}`. Additive and backward compatible; existing records without it stay valid, and absence means `self`. Same shape of change as `delegation` in 0.4.0, under the same v0.2 profile URI. - - It exists because `runtime.platform: "software-only"` is ambiguous, and the ambiguity is about to matter. It is the honest value for a dev-mode record, where nothing attested the execution, and for a record transcribed from a third-party control plane, where the party asserting the evidence also wrote the log. Those are different claims, and a consumer weighing a record could not tell them apart from `platform` alone. `kind` is a closed set (`self`, `third-party-control-plane`, `log-import`) rather than free text, because the value of the field is that a verifier can key on it. - - **A record whose `kind` is not `self` MUST carry `runtime.platform: "software-only"`,** enforced in the model and in `schema/trace-claim.json` via `if`/`then`, so a validator that never loads the Python still rejects it. An importer holding someone else's log has no quote to present, so a hardware platform there is untrue rather than stronger. It is also exactly what an adapter produces by starting from a hardware example and editing the fields it understood, which is why it is a MUST and why it is tested from both directions. - - The block launders assurance in neither direction. Naming your producer does not make unattested evidence attested, and a record with a verified hardware quote is what it is whether or not it says `origin: self`. - - Spec §3.1.1, `docs/schema.md`, both schemas, 11 tests. - -- **`spec/registry-anchor-v1.md`: the registry anchor and inclusion-proof format is now public (#111).** The format was already normative and already written so a conforming verifier could be built from it alone, but it lived in `trace-registry`, which is private. The effect was that the one document an external verifier needs was the one they could not read, and an inclusion proof nobody outside can check is not transparency. It is published here, in the public spec home, as @l33tdawg proposed in the discussion that raised this. - - §0 leads with the trap, because it is the one that costs an implementer a day and gives no useful error: TRACE canonicalizes with **RFC 8785 (JCS)** for *signing* and with **sorted-key JSON** for the *anchor leaf*. The two agree on ASCII-only records with integer numbers, which is most records, which is exactly what makes assuming JCS at the leaf dangerous. - - §8 states conformance, including the requirement that the append-only property be externally checkable rather than asserted. An operator that issues verifiable proofs but publishes nothing an outsider can audit is running a log, not a transparency log. - -### Fixed - -- `docs/schema.md` described `transparency` as required with an empty string for unanchored records. It has been optional below Level 2 since 0.5.1, and `""` is rejected. - -- **Four documents told readers to send signed records to a domain this project does not own.** `docs/integration/agt.md`, `docs/integration/cmcp.md`, `docs/trust-levels.md` and `docs/verification.md` still named `registry.agentrust.io`, which resolves to third-party parked addresses. This is the same defect the v0.2 profile cutover fixed in the identifier, missed in the prose. Moved to `registry.agentrust-io.com`, which is what the SDK's adapters already emit. The v0.1 spec keeps its original values; it is a superseded document and a record of what was published. - -- **The anchoring tutorial documented an API that does not exist.** It instructed readers to POST signed Trust Records to a SCITT HTTP endpoint at the parked domain above and to read a `receipt_uri` from the response. There is no such endpoint. Rewritten against the actual mechanism: submit to staging, retrieve an inclusion proof, and verify that proof yourself against the published entry. It also still described `transparency` as a required string, which 0.5.1 changed. The page carries a dated note saying what it used to say, because anyone who built against it deserves to know nothing they sent was received. - -### Changed - -- **The spec, the README and the roadmap named three different standards homes between them.** §6.1 proposed splitting TRACE between CoSAI and the Linux Foundation entity hosting MCP; the README said "Targeting AAIF"; neither is where this is going. TRACE is being formed at the Linux Foundation as its own series, "TRACE Specification, a Series of LF Projects, LLC" (see #127). §6.1 is rewritten, the README line is corrected, and §7 Q1 is marked resolved rather than deleted so a reader tracking it can see how it landed. - -- **§4.1 described the MCP and A2A profiles as "targeted for v0.2" in the v0.2 document.** Neither shipped in v0.2. Both are now stated as targeted for v0.3, and the A2A entry says what did land: the `delegation` link block, as the foundation the binding rules will attach to. §7 Q6 (A2A timing) is marked resolved, since A2A stabilizing at v1.x was the blocker it asked about. - -- **§7 was headed "These need input before v0.2".** Now v1.0. No normative text, schema, or record field changed. - -## [0.6.0] — 2026-08-07 - -### Fixed - -- **`__version__` reported the wrong number for four releases.** It was a literal that drifted from `pyproject.toml` at #36 and was never corrected, so v0.3.0, v0.4.0, v0.5.0 and v0.5.1 each shipped a wheel reporting `0.2.0` at runtime. Anyone pinning or logging on `agentrust_trace.__version__` got the wrong answer, and nothing failed. It now derives from installed package metadata, which makes the two unable to disagree, and `tests/test_version.py` pins the source tree against `pyproject.toml` and requires the changelog to carry a section for the declared version before a tag is cut. - -- **The package description advertised TRACE v0.1.** The PyPI summary still named the superseded profile. - -- **`verify_record()` now enforces the profile cutover this changelog already declares.** The 0.5.1 cutover entry states that a v0.2 verifier "requires the new URI and rejects the old one; it does not accept both" — but `verify_record()` never read `eat_profile`, so a record carrying the v0.1 identifier, a future version, a foreign tag, or no profile at all verified exactly as a v0.2 record, provided its signature checked out. A valid signature over semantics this build does not implement is not evidence, so the profile is now checked first, before any cryptographic work: anything other than `TRACE_PROFILE_V0_2` (newly exported) raises `ValueError`, with a message that says why when the profile is the superseded v0.1 identifier. Same shape as the revocation enforcement in this release: an already-merged spec requirement (`spec/trace-v0.2.md` section 2) that the reference implementation did not carry out. `docs/verification.md` step 4 notes the check is now built in. No normative text, schema, or record field changed. - -### Added - -- **`TraceSandboxAdapter`: Trust Records from a sandboxed agent runtime.** A kernel sandbox confines one agent on one machine. It does not answer, on its own, which agent on which of two hundred machines took an action, what actually ran rather than what the policy said, or how to say either on a host with no secure hardware. The adapter builds a record from what such a runtime already has at session close: sandbox identity, image digest, the effective policy bundle bytes, and the decision log. No change to the runtime is required. - - Unlike `TraceAGTAdapter`, one code path spans Level 0 and Level 1. Passing a `SandboxAttestation` moves the record from `software-only` to the attested platform and nothing else about the call changes, because a sandbox runs wherever the customer runs it and the deployments that most need evidence often have the least hardware. - - A caller cannot claim hardware it does not have: `platform` is only ever set from a supplied attestation, an attestation may not name `software-only`, the platform is validated against the enum on `RuntimeInfo` rather than a copy of it, and the measurement must be a `sha256:`/`sha384:` digest. Sandbox identity and image ride the existing `subject` and `build_provenance.digest`, so no schema change was needed. - - Two defaults differ from the AGT adapter, deliberately. `appraisal.status` is `"none"`, because building a record does not appraise it and `affirming` would put a verdict in the field a consumer reads to find out whether anybody checked. `transparency` is `None` and omitted, which is what an unanchored record should say. - - `tool_transcript.hash` is taken over the RFC 8785 canonical form of the decision log rather than `json.dumps(sort_keys=True)`. The two agree on ASCII and diverge on non-ASCII strings and number formatting; a decision log carries paths and hostnames, and the signature pre-image already uses JCS. See `docs/integration/sandbox-runtime.md` and `examples/sandbox-runtime.json`. - -- **`verify_record(..., revocation=...)` enforces key revocation at verification time (#76).** §3.2.1 has always required that "Verifiers MUST consult current revocation status at verification time", but `verify_record()` checked only signature and freshness, so a record signed by a revoked or compromised key kept verifying. The new `revocation` parameter accepts either a container of revoked key identifiers or a callable performing a live CRL, status-endpoint, or SCITT lookup. A listed key is rejected, and a store that cannot answer is also rejected: an unavailable revocation source is not evidence that a key is unrevoked. - - Keys are identified by RFC 7638 JWK Thumbprint or `kid`. The check reads the *trusted* key rather than `record["cnf"]["jwk"]`, which is attacker-controlled until the signature verifies. - - Additive and backward compatible: `revocation` defaults to `None`, which leaves verification purely offline and unchanged. That mode cannot prove non-revocation, now stated in `LIMITATIONS.md` and `docs/verification.md`. No normative text, schema, or record field changed. - -- **`jwk_thumbprint(jwk)`**: RFC 7638 JWK Thumbprint (RFC 8037 §2 for OKP), exported so callers can key a revocation list on the same identifier the verifier derives. - -## [0.5.1] — 2026-07-28 - -### Fixed - -- **`transparency` is optional below Level 2.** The model required a non-empty URI on every record, which was stricter than both `schema/trace-claim.json` (required, no `minLength`) and the conformance suite, which runs `TR-ANC` at Level 2 only. A Level 0 or Level 1 record is not anchored, so it has no receipt to name, and that state was unrepresentable. `None` now means unanchored; an empty string stays rejected, since `""` is not a URI and a field that looks populated but resolves to nothing is worse in a trust record than an absent one. - -### Changed - -- **BREAKING: TRACE v0.2 changes the EAT profile URI to `tag:agentrust-io.com,2026:trace-v0.2`** (was `tag:agentrust.io,2026:trace-v0.1`). `agentrust.io` was never a domain this project controlled; it resolves to third-party parked addresses. RFC 4151 permits a tag URI only where the minting authority controlled the named domain on the stated date, so the v0.1 identifier was invalid rather than merely misspelled: it asserted authority over a name someone else could stand up a conflicting definition at. - - **Cutover, not coexistence.** A v0.2 verifier requires the new URI and rejects the old one; it does not accept both. Dual acceptance would keep the invalid identifier live indefinitely, which is the thing being fixed. Records already issued under v0.1 stay verifiable against `spec/trace-v0.1.md` and the published `agentrust-trace` 0.4.x releases, which remain on PyPI. They are v0.1 records and are read as such. - - Nothing else in the record format changed. No field was added, removed, or re-typed, so migration for a producer is the profile string and a dependency bump. - - Moved together: `spec/trace-v0.2.md` (new, with a "Changes from v0.1" section), `spec/trace-v0.1.md` (retained, marked superseded), the root `schema/trace-claim.json` const, the packaged `agentrust_trace/schema/trace-v0.2.json`, the `eat_profile` `Literal` in `models.py`, the AGT adapter, `validate.py`'s schema resource, the four platform example records, and the docs. - -- Other `agentrust.io` URLs moved to `agentrust-io.com`: the registry and verifier hosts in the AGT adapter and the schema `$id`. - -## [0.4.0] - -### Added - -- **`azure-cvm-sev-snp` platform** — Azure confidential VMs run AMD SEV-SNP behind a Hyper-V paravisor: the SNP report is read from the vTPM (the guest does not control `REPORT_DATA`), so the runtime binding rides a vTPM AK-signed quote rather than the SNP `report_data`. Given its own `runtime.platform` value (distinct from `amd-sev-snp`) so a consumer keying on `runtime.platform` knows the root of trust is vTPM-rooted, not direct-silicon. Added to the `RuntimeInfo` model and the JSON schema enum. Hardware-validated on a live Azure SEV-SNP VM via cMCP. - -- `delegation` (optional object): the A2A profile delegation-link block, carrying `parent_record_hash` (digest of the parent hop's Trust Record) and `credential_id` (the delegation credential this hop acted under). A chain of records linked this way forms an offline-verifiable delegation DAG. Backward-compatible: existing records without `delegation` remain valid. This is a MINOR (additive) change and the foundation of the forthcoming A2A profile; A2A is now stable at v1.x, clearing the prior blocker. - ---- - -## [0.3.0] — 2026-06-30 - -### Security - -- `verify_record` now requires an explicit trusted key. Self-verification from the embedded `cnf.jwk` is no longer the default; use `allow_embedded_key=True` to opt in. -- Verification enforces freshness (`iat` / `max_age_seconds`, default 24h) and an optional `expected_nonce`. JWK `kty` / `crv` are validated. - -### Breaking - -- **BREAKING:** Canonicalization is now RFC 8785 (JCS). Trust records are NOT cross-verifiable with 0.2.0 (the prior `json.dumps` canonicalization was non-conformant). - ---- - -## [0.1.0] — 2026-06-23 - -Initial public draft. Announced at Confidential Computing Summit, San Francisco. - -### Specification - -- Trust Record logical schema (§3.1): `subject`, `model`, `runtime`, `policy`, `data_class`, `tool_transcript`, `build_provenance`, `appraisal`, `transparency`, `cnf` -- Wire format (§3.2): EAT/JWT and CBOR-COSE envelopes; profile URI `tag:agentrust.io,2026:trace-v0.1` -- Signing and key management (§3.2.1): ES256/ES384/EdDSA; four-layer key hierarchy; hash agility; revocation -- Verification protocol (§3.3): five-step offline verification, no issuer callback -- Standards composition (§4): RATS/EAT, SLSA, SPIFFE, SCITT, EAR, MCP, A2A, AIBOM, C2PA -- Hardware roots (§4.2): NVIDIA H100/Blackwell, Intel TDX, AMD SEV-SNP, Azure MAA, GCP Confidential Space, AWS Nitro -- Reference implementation (§5): cMCP Phase 1–3 roadmap - -### Schema - -- `schema/trace-claim.json`: JSON Schema (draft/2020-12) for Trust Record validation - -### Examples - -- `examples/amd-sev-snp.json`: AMD SEV-SNP Trust Record -- `examples/intel-tdx.json`: Intel TDX Trust Record -- `examples/nvidia-h100.json`: NVIDIA H100 Confidential Computing Trust Record - -### Open questions - -Seven open questions requiring community input before v0.2 are documented in §7 of the spec. - ---- - -## [0.2.0] — TBD - -### Specification - -- Extend `subject` field to accept DID URIs (any `did:` method) in addition to SPIFFE SVIDs. - Previously `^spiffe://` only; now `^(spiffe://|did:)`. Additive, backward-compatible. - DID-native runtimes (e.g. AGT `did:mesh:` identities) no longer require a parallel SPIFFE identity. - Closes: microsoft/agent-governance-toolkit ADR-0032, agentrust-io/trace-spec#35. - -### Schema - -- `schema/trace-claim.json`: `subject` pattern updated to `^(spiffe://|did:)`, description updated. - -### Reference Implementation - -- `TrustRecord.subject` pattern updated to `r"^(spiffe://|did:)"`. - ---- - -## Upcoming - -See [ROADMAP.md](ROADMAP.md) for planned changes in v0.2 and v1.0. +# Changelog + +All notable changes to the TRACE specification will be documented here. + +Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.MINOR.PATCH`: +- **MAJOR**: breaking changes to wire format or required Trust Record fields +- **MINOR**: new optional fields, new platform profiles, new conformance levels +- **PATCH**: editorial fixes, clarifications, non-normative additions + +--- + +## [Unreleased] + +### Added + +- **Record-signing key revocation is anchored to transparency-log entry ordering, not to `iat`.** New spec section 3.2.3 defines the `TraceRevocation/1.0` claim type: a record from a revoked key is valid if and only if its SCITT inclusion entry ID is at or below `last_valid_entry_id` on the log the statement names. The intuitive time-based rule cannot work, because a compromised record-signing key also signs the `iat` it would be judged against, so an attacker backdates the record and the rule passes. Entry IDs are monotonic and bound to the Merkle structure, so ordering survives the compromise a timestamp does not. + + Distribution keeps section 3.3's no-callback property: statements are anchored in the same log as the records they govern, and verifiers cache a signed bundle carrying `valid_until`. An expired bundle is not a pass, and a verifier with none reports that it performed no revocation check rather than reporting an affirming appraisal. A revocation statement MUST be signed by a key above the revoked one in the section 3.2.1 hierarchy, or by a recovery key with an independent compromise domain, because a statement the compromised key could sign for itself is a tool for whoever stole it. Records with no usable inclusion entry ID fall back to binary revocation, which is the existing behaviour. Schemas: `schema/trace-revocation.json`, `schema/trace-revocation-bundle.json`. Resolves [#67](https://github.com/agentrust-io/trace-spec/issues/67). + + Section 3.2.1 previously required verifiers to "consult current revocation status at verification time", which contradicted the offline-verification property in the same document. It now points at 3.2.3. +- **A verification profile is proposed for the `delegation` block, with the conformance material to argue it against.** The block is normative in v0.2 and nothing says what a verifier does with a chain of them: `spec/trace-v0.2.md` never mentions `parent_record_hash` or `credential_id`, and the one descriptive sentence in `docs/schema.md` leaves every operative term open, so two conforming implementations can agree on nothing. [`docs/rfcs/a2a-delegation-profile.md`](docs/rfcs/a2a-delegation-profile.md) proposes ten rules over the fields that already exist — no schema change — and `examples/delegation-link/` carries 23 vectors that score an implementation against them. Three forks in the current text had to be settled before any vector could be written, and each is stated with the reason rather than assumed: the digest covers the complete parent record including its signature, because a digest of the signed body alone does not bind the parent's signer; there is no cycle rule, because a delegation cycle is a hash collision and the reachable analogue is an unbounded chain, which is what the depth bound is for; and a link naming a digest algorithm the verifier cannot compute makes the chain unverifiable rather than invalid, which is the delegation-surface instance of the semantics already merged in `docs/verification.md`. Nothing here binds an implementation until the profile is adopted. Targets the v0.3 A2A profile named in [`ROADMAP.md`](ROADMAP.md). + +- **`build_provenance` now declares verification depth.** A new optional `provenance_depth` (`surface`, `builder`, `transitive`) says how far down the supply chain the issuer claims to have walked, and a new optional `appraisal.provenance_depth_verified` records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting `provenance_depth` is read as `surface`, so existing records keep their meaning. Evidence that does not resolve and evidence that resolves and contradicts the record are separate outcomes: the first downgrades the recorded depth and names what was missing, the second fails the appraisal and cannot be downgraded away. Resolves [#50](https://github.com/agentrust-io/trace-spec/issues/50). + +### Security + +- **Release gates now fail closed.** CodeQL analysis failures block instead of being ignored. Before trusted PyPI publication, clean virtual environments install and verify both the built wheel and source distribution outside the checkout, checking tag/version identity, packaged schema resources, signing and verification, and rejection of unknown security fields. + +### Fixed + +- **Local test runs now always exercise the checkout.** Pytest prepends `src` to its import path, and a regression test asserts that `agentrust_trace` resolves to the repository source. A stale installed wheel can no longer shadow current security fixes and produce misleading failures or passes. + +### Fixed + +- **The confirmation key must now match the trusted signing key.** `verify_record()` compares the RFC 7638 thumbprints of `cnf.jwk` and the caller-supplied trusted key before accepting the signature. A trusted signer can no longer produce a record that verifies under one key while naming another key for downstream proof-of-possession checks. + +- **`verify_record()` now enforces the canonical v0.2 JSON Schema.** A cryptographically valid signature no longer causes an object with unknown fields, missing required claims, or invalid nested values to be accepted as a verified TRACE record. Schema failures are surfaced as `ValueError` with the failing field path. + +### Fixed + +- **Future-dated records no longer create an unbounded freshness window.** `verify_record()` now rejects an `iat` later than the verifier's clock plus `max_future_skew_seconds` (default 5 minutes), independently of the maximum-age check. The v0.2 freshness requirements and verification tutorial document both bounds. + +## [0.9.0] — 2026-08-09 + +### Documentation + +- **The security policy now describes the software that is actually released.** It puts the Python signing and verification APIs, schemas, adapters, packaging, and release automation in scope; lists TRACE v0.2 and `agentrust-trace` 0.x as supported; and marks the superseded v0.1 profile unsupported. + +## [0.9.0] — 2026-08-09 + +### Added + +- **`spec/content-marking-v1.md` and `agentrust_trace.content_marking`: bind a marked asset to the execution that produced it.** EU AI Act Article 50(2) and 50(4) have been in force since 2 August 2026 and are the only AI Act obligations that bite this year. One C2PA assertion, `com.agentrust-io.trace`, carries a hashed reference to the Trust Record for the execution that produced the asset. + + **The document opens with what this does not do**, because the temptation to overclaim here is strong. It does not stop anyone stripping the mark: removing a C2PA manifest from a file is trivial and nothing here changes that. It is not watermarking. It does not make a deployment compliant. What it buys is narrower and real: a mark that *is* present becomes checkable against a hardware-rooted claim instead of being an unverifiable label, and in a channel that requires marks an absent one is detectable. + + Three separate things must be checked and the assertion merges none of them: the C2PA signature says the assertion was in the manifest when the asset was signed, the TRACE signature says the execution happened as described, and the hash says the record being pointed at is the one the signer meant. A verifier that checks one of the three has checked a third. + + `build_assertion()` takes the **exact bytes** that will be served rather than a record object, because a hash over a re-serialized dict is a hash of bytes nobody will fetch; a test pins that `indent=2` alone breaks the binding. `verify_assertion()` has no signature-only path: `record_bytes` is a required parameter, since an assertion whose hash was never checked is a URL in a file. + + 17 tests. + +### Added + +- **`enforcement_mode: "declared"`.** The three existing modes all assert that *something evaluated the policy*: `enforce` acted on the result, `advisory` did not, `silent` acted with the log lines suppressed. `declared` asserts less — the policy is named and bound into the signed record, and nothing evaluated it. + + That is not a corner case, it is the common one. An agent framework has no policy engine, so a record built by observing a LangChain or LlamaIndex run has a policy the operator declares and no evaluation of it anywhere. With three values, such a record had to claim an evaluation that never happened; both framework adapters refused to default the field and documented the overstatement instead, which is honest and still leaves every framework record marginally untrue. + + `declared` is the weakest value and is never a default. A producer that evaluates policy MUST NOT use it, and a consumer MUST NOT read it as evidence that any rule was checked. A verifier appraising for enforcement SHOULD treat it as it treats an absent enforcement claim. + + Additive to a closed enum in both the model and the JSON schema, so unknown values are still rejected. Same one-directional consequence as `origin`: a verifier older than this release rejects a record carrying `declared`. + +## [0.8.0] — 2026-08-09 + +### Added + +- **`agentrust_trace.provenance`: build, sign and verify MCP Server Provenance Records.** Step 2 of the sequence, implementing `spec/server-provenance-v1.md`. In the SDK rather than in cMCP, so a publisher can produce a record without adopting a runtime, which is the only way the format reaches an ecosystem that will not adopt one. + + **`check_tool_catalog()` is a separate call on purpose.** Verifying the signature proves a document is internally consistent and signed by a key you trust, which is exactly what an attacker holding a stolen publisher key can produce. What they cannot do is make the server in front of you offer the tools their record describes. That comparison needs something `verify_record()` does not have — what the server said to *you* — so it is its own obvious call rather than a flag, and it raises its own exception type so a consumer can tell "bad document" from "wrong server". + + The builder refuses records that cannot mean anything: an identity with neither artifact nor endpoint, a `tee-attested` record with no evidence, evidence attached to a kind that does not claim it (a reader would take it as an attestation that was made), a publisher that is a display name rather than a resolvable DID or SPIFFE URI, and an endpoint URL with no key digest, since a URL alone is not an identity. + + `verify_record()` requires a trusted key and never takes one from the record. Verifying a document against a key it supplies proves only that it is internally consistent, which is what a forgery is. + + 24 tests, including the one the format exists for: a perfectly valid signature over a description of a different server still fails the catalog check. + +### Added + +- **`spec/server-provenance-v1.md`: a signed statement about an MCP server.** cMCP enforces policy at the call boundary and can say nothing about whether the server on the other end is what it claims. Its catalog answers that locally — approved definitions, a measured catalog hash, a pinned TLS fingerprint — but every part of that is operator-asserted, so nothing one operator learns is usable by the next. + + The format carries the same shape of honesty as the `origin` block: a closed `kind` (`publisher-asserted`, `observer-attested`, `tee-attested`) because the interesting fact is never that provenance exists but who is asserting it, and an explicit rule that a verifier MUST NOT treat absence as any of them. + + Identity is `artifact`, `endpoint`, or both, with the record saying which. A URL is the obvious handle and the worst candidate: it moves, it is per-deployment, and two operators running the same server produce different ones. `artifact.digest` covers the entrypoint rather than the interpreter, for the reason the stdio work found: every interpreted server on a host shares one interpreter digest, so a pin over it matches a completely different server. + + The tool-catalog hash covers name, description and input schema. Description is in deliberately — a tool whose description changes from "search the docs" to "search the docs and email results to the address in the query" is exactly the rug-pull the hash exists to catch, and a hash over names alone misses it. + + Three things are stated as out of scope rather than hand-waved: key distribution for `publisher` (a PKI question this format would only pretend to solve), whether a server is any good (the moment a provenance format scores servers, its publisher becomes the party everyone must trust), and what the code does at runtime (provenance narrows what code you are talking to, nothing more). + +### Fixed + +- **The packaged schema had drifted from the normative one, and a DID subject was the casualty.** `validate_json()` loads `src/agentrust_trace/schema/trace-v0.2.json`, while the spec, README and CONTRIBUTING all point a reader at `schema/trace-claim.json`. The two disagreed in three places, so the schema someone reads was not the schema their record was checked against. The visible consequence: the root file and `models.py` both accept a `did:` subject, the packaged copy still required `^spiffe://`, so `agentrust_trace.validate_json()` rejected a subject form the specification permits. Also resynced: the `slsa_level` description, and the root file's `$id`, which still said `trace-v0.1.json` on a schema whose `eat_profile` const is v0.2. + + `tests/test_validate.py` now compares the two as parsed JSON on every run, so the next drift fails instead of shipping. Compared parsed rather than byte for byte because the two files differ in line endings by long-standing accident, which changes nothing about how either validates a record. + +- **A 0.6.0 changelog entry was filed under 0.5.1.** The `verify_record()` profile-cutover enforcement shipped in 0.6.0; its entry landed in the 0.5.1 section, next to the cutover declaration it implements, which left 0.5.1 with two `### Fixed` blocks and 0.6.0 with no entry for a behaviour change. Moved, with its two internal cross-references corrected to match where it now sits. + +## [0.7.0] — 2026-08-08 + +### Added + +- **`origin` (optional object): who assembled this record, when it was not the runtime that ran.** `{kind, producer, source_event_id, ingested_at}`. Additive and backward compatible; existing records without it stay valid, and absence means `self`. Same shape of change as `delegation` in 0.4.0, under the same v0.2 profile URI. + + It exists because `runtime.platform: "software-only"` is ambiguous, and the ambiguity is about to matter. It is the honest value for a dev-mode record, where nothing attested the execution, and for a record transcribed from a third-party control plane, where the party asserting the evidence also wrote the log. Those are different claims, and a consumer weighing a record could not tell them apart from `platform` alone. `kind` is a closed set (`self`, `third-party-control-plane`, `log-import`) rather than free text, because the value of the field is that a verifier can key on it. + + **A record whose `kind` is not `self` MUST carry `runtime.platform: "software-only"`,** enforced in the model and in `schema/trace-claim.json` via `if`/`then`, so a validator that never loads the Python still rejects it. An importer holding someone else's log has no quote to present, so a hardware platform there is untrue rather than stronger. It is also exactly what an adapter produces by starting from a hardware example and editing the fields it understood, which is why it is a MUST and why it is tested from both directions. + + The block launders assurance in neither direction. Naming your producer does not make unattested evidence attested, and a record with a verified hardware quote is what it is whether or not it says `origin: self`. + + Spec §3.1.1, `docs/schema.md`, both schemas, 11 tests. + +- **`spec/registry-anchor-v1.md`: the registry anchor and inclusion-proof format is now public (#111).** The format was already normative and already written so a conforming verifier could be built from it alone, but it lived in `trace-registry`, which is private. The effect was that the one document an external verifier needs was the one they could not read, and an inclusion proof nobody outside can check is not transparency. It is published here, in the public spec home, as @l33tdawg proposed in the discussion that raised this. + + §0 leads with the trap, because it is the one that costs an implementer a day and gives no useful error: TRACE canonicalizes with **RFC 8785 (JCS)** for *signing* and with **sorted-key JSON** for the *anchor leaf*. The two agree on ASCII-only records with integer numbers, which is most records, which is exactly what makes assuming JCS at the leaf dangerous. + + §8 states conformance, including the requirement that the append-only property be externally checkable rather than asserted. An operator that issues verifiable proofs but publishes nothing an outsider can audit is running a log, not a transparency log. + +### Fixed + +- `docs/schema.md` described `transparency` as required with an empty string for unanchored records. It has been optional below Level 2 since 0.5.1, and `""` is rejected. + +- **Four documents told readers to send signed records to a domain this project does not own.** `docs/integration/agt.md`, `docs/integration/cmcp.md`, `docs/trust-levels.md` and `docs/verification.md` still named `registry.agentrust.io`, which resolves to third-party parked addresses. This is the same defect the v0.2 profile cutover fixed in the identifier, missed in the prose. Moved to `registry.agentrust-io.com`, which is what the SDK's adapters already emit. The v0.1 spec keeps its original values; it is a superseded document and a record of what was published. + +- **The anchoring tutorial documented an API that does not exist.** It instructed readers to POST signed Trust Records to a SCITT HTTP endpoint at the parked domain above and to read a `receipt_uri` from the response. There is no such endpoint. Rewritten against the actual mechanism: submit to staging, retrieve an inclusion proof, and verify that proof yourself against the published entry. It also still described `transparency` as a required string, which 0.5.1 changed. The page carries a dated note saying what it used to say, because anyone who built against it deserves to know nothing they sent was received. + +### Changed + +- **The spec, the README and the roadmap named three different standards homes between them.** §6.1 proposed splitting TRACE between CoSAI and the Linux Foundation entity hosting MCP; the README said "Targeting AAIF"; neither is where this is going. TRACE is being formed at the Linux Foundation as its own series, "TRACE Specification, a Series of LF Projects, LLC" (see #127). §6.1 is rewritten, the README line is corrected, and §7 Q1 is marked resolved rather than deleted so a reader tracking it can see how it landed. + +- **§4.1 described the MCP and A2A profiles as "targeted for v0.2" in the v0.2 document.** Neither shipped in v0.2. Both are now stated as targeted for v0.3, and the A2A entry says what did land: the `delegation` link block, as the foundation the binding rules will attach to. §7 Q6 (A2A timing) is marked resolved, since A2A stabilizing at v1.x was the blocker it asked about. + +- **§7 was headed "These need input before v0.2".** Now v1.0. No normative text, schema, or record field changed. + +## [0.6.0] — 2026-08-07 + +### Fixed + +- **`__version__` reported the wrong number for four releases.** It was a literal that drifted from `pyproject.toml` at #36 and was never corrected, so v0.3.0, v0.4.0, v0.5.0 and v0.5.1 each shipped a wheel reporting `0.2.0` at runtime. Anyone pinning or logging on `agentrust_trace.__version__` got the wrong answer, and nothing failed. It now derives from installed package metadata, which makes the two unable to disagree, and `tests/test_version.py` pins the source tree against `pyproject.toml` and requires the changelog to carry a section for the declared version before a tag is cut. + +- **The package description advertised TRACE v0.1.** The PyPI summary still named the superseded profile. + +- **`verify_record()` now enforces the profile cutover this changelog already declares.** The 0.5.1 cutover entry states that a v0.2 verifier "requires the new URI and rejects the old one; it does not accept both" — but `verify_record()` never read `eat_profile`, so a record carrying the v0.1 identifier, a future version, a foreign tag, or no profile at all verified exactly as a v0.2 record, provided its signature checked out. A valid signature over semantics this build does not implement is not evidence, so the profile is now checked first, before any cryptographic work: anything other than `TRACE_PROFILE_V0_2` (newly exported) raises `ValueError`, with a message that says why when the profile is the superseded v0.1 identifier. Same shape as the revocation enforcement in this release: an already-merged spec requirement (`spec/trace-v0.2.md` section 2) that the reference implementation did not carry out. `docs/verification.md` step 4 notes the check is now built in. No normative text, schema, or record field changed. + +### Added + +- **`TraceSandboxAdapter`: Trust Records from a sandboxed agent runtime.** A kernel sandbox confines one agent on one machine. It does not answer, on its own, which agent on which of two hundred machines took an action, what actually ran rather than what the policy said, or how to say either on a host with no secure hardware. The adapter builds a record from what such a runtime already has at session close: sandbox identity, image digest, the effective policy bundle bytes, and the decision log. No change to the runtime is required. + + Unlike `TraceAGTAdapter`, one code path spans Level 0 and Level 1. Passing a `SandboxAttestation` moves the record from `software-only` to the attested platform and nothing else about the call changes, because a sandbox runs wherever the customer runs it and the deployments that most need evidence often have the least hardware. + + A caller cannot claim hardware it does not have: `platform` is only ever set from a supplied attestation, an attestation may not name `software-only`, the platform is validated against the enum on `RuntimeInfo` rather than a copy of it, and the measurement must be a `sha256:`/`sha384:` digest. Sandbox identity and image ride the existing `subject` and `build_provenance.digest`, so no schema change was needed. + + Two defaults differ from the AGT adapter, deliberately. `appraisal.status` is `"none"`, because building a record does not appraise it and `affirming` would put a verdict in the field a consumer reads to find out whether anybody checked. `transparency` is `None` and omitted, which is what an unanchored record should say. + + `tool_transcript.hash` is taken over the RFC 8785 canonical form of the decision log rather than `json.dumps(sort_keys=True)`. The two agree on ASCII and diverge on non-ASCII strings and number formatting; a decision log carries paths and hostnames, and the signature pre-image already uses JCS. See `docs/integration/sandbox-runtime.md` and `examples/sandbox-runtime.json`. + +- **`verify_record(..., revocation=...)` enforces key revocation at verification time (#76).** §3.2.1 has always required that "Verifiers MUST consult current revocation status at verification time", but `verify_record()` checked only signature and freshness, so a record signed by a revoked or compromised key kept verifying. The new `revocation` parameter accepts either a container of revoked key identifiers or a callable performing a live CRL, status-endpoint, or SCITT lookup. A listed key is rejected, and a store that cannot answer is also rejected: an unavailable revocation source is not evidence that a key is unrevoked. + + Keys are identified by RFC 7638 JWK Thumbprint or `kid`. The check reads the *trusted* key rather than `record["cnf"]["jwk"]`, which is attacker-controlled until the signature verifies. + + Additive and backward compatible: `revocation` defaults to `None`, which leaves verification purely offline and unchanged. That mode cannot prove non-revocation, now stated in `LIMITATIONS.md` and `docs/verification.md`. No normative text, schema, or record field changed. + +- **`jwk_thumbprint(jwk)`**: RFC 7638 JWK Thumbprint (RFC 8037 §2 for OKP), exported so callers can key a revocation list on the same identifier the verifier derives. + +## [0.5.1] — 2026-07-28 + +### Fixed + +- **`transparency` is optional below Level 2.** The model required a non-empty URI on every record, which was stricter than both `schema/trace-claim.json` (required, no `minLength`) and the conformance suite, which runs `TR-ANC` at Level 2 only. A Level 0 or Level 1 record is not anchored, so it has no receipt to name, and that state was unrepresentable. `None` now means unanchored; an empty string stays rejected, since `""` is not a URI and a field that looks populated but resolves to nothing is worse in a trust record than an absent one. + +### Changed + +- **BREAKING: TRACE v0.2 changes the EAT profile URI to `tag:agentrust-io.com,2026:trace-v0.2`** (was `tag:agentrust.io,2026:trace-v0.1`). `agentrust.io` was never a domain this project controlled; it resolves to third-party parked addresses. RFC 4151 permits a tag URI only where the minting authority controlled the named domain on the stated date, so the v0.1 identifier was invalid rather than merely misspelled: it asserted authority over a name someone else could stand up a conflicting definition at. + + **Cutover, not coexistence.** A v0.2 verifier requires the new URI and rejects the old one; it does not accept both. Dual acceptance would keep the invalid identifier live indefinitely, which is the thing being fixed. Records already issued under v0.1 stay verifiable against `spec/trace-v0.1.md` and the published `agentrust-trace` 0.4.x releases, which remain on PyPI. They are v0.1 records and are read as such. + + Nothing else in the record format changed. No field was added, removed, or re-typed, so migration for a producer is the profile string and a dependency bump. + + Moved together: `spec/trace-v0.2.md` (new, with a "Changes from v0.1" section), `spec/trace-v0.1.md` (retained, marked superseded), the root `schema/trace-claim.json` const, the packaged `agentrust_trace/schema/trace-v0.2.json`, the `eat_profile` `Literal` in `models.py`, the AGT adapter, `validate.py`'s schema resource, the four platform example records, and the docs. + +- Other `agentrust.io` URLs moved to `agentrust-io.com`: the registry and verifier hosts in the AGT adapter and the schema `$id`. + +## [0.4.0] + +### Added + +- **`azure-cvm-sev-snp` platform** — Azure confidential VMs run AMD SEV-SNP behind a Hyper-V paravisor: the SNP report is read from the vTPM (the guest does not control `REPORT_DATA`), so the runtime binding rides a vTPM AK-signed quote rather than the SNP `report_data`. Given its own `runtime.platform` value (distinct from `amd-sev-snp`) so a consumer keying on `runtime.platform` knows the root of trust is vTPM-rooted, not direct-silicon. Added to the `RuntimeInfo` model and the JSON schema enum. Hardware-validated on a live Azure SEV-SNP VM via cMCP. + +- `delegation` (optional object): the A2A profile delegation-link block, carrying `parent_record_hash` (digest of the parent hop's Trust Record) and `credential_id` (the delegation credential this hop acted under). A chain of records linked this way forms an offline-verifiable delegation DAG. Backward-compatible: existing records without `delegation` remain valid. This is a MINOR (additive) change and the foundation of the forthcoming A2A profile; A2A is now stable at v1.x, clearing the prior blocker. + +--- + +## [0.3.0] — 2026-06-30 + +### Security + +- `verify_record` now requires an explicit trusted key. Self-verification from the embedded `cnf.jwk` is no longer the default; use `allow_embedded_key=True` to opt in. +- Verification enforces freshness (`iat` / `max_age_seconds`, default 24h) and an optional `expected_nonce`. JWK `kty` / `crv` are validated. + +### Breaking + +- **BREAKING:** Canonicalization is now RFC 8785 (JCS). Trust records are NOT cross-verifiable with 0.2.0 (the prior `json.dumps` canonicalization was non-conformant). + +--- + +## [0.1.0] — 2026-06-23 + +Initial public draft. Announced at Confidential Computing Summit, San Francisco. + +### Specification + +- Trust Record logical schema (§3.1): `subject`, `model`, `runtime`, `policy`, `data_class`, `tool_transcript`, `build_provenance`, `appraisal`, `transparency`, `cnf` +- Wire format (§3.2): EAT/JWT and CBOR-COSE envelopes; profile URI `tag:agentrust.io,2026:trace-v0.1` +- Signing and key management (§3.2.1): ES256/ES384/EdDSA; four-layer key hierarchy; hash agility; revocation +- Verification protocol (§3.3): five-step offline verification, no issuer callback +- Standards composition (§4): RATS/EAT, SLSA, SPIFFE, SCITT, EAR, MCP, A2A, AIBOM, C2PA +- Hardware roots (§4.2): NVIDIA H100/Blackwell, Intel TDX, AMD SEV-SNP, Azure MAA, GCP Confidential Space, AWS Nitro +- Reference implementation (§5): cMCP Phase 1–3 roadmap + +### Schema + +- `schema/trace-claim.json`: JSON Schema (draft/2020-12) for Trust Record validation + +### Examples + +- `examples/amd-sev-snp.json`: AMD SEV-SNP Trust Record +- `examples/intel-tdx.json`: Intel TDX Trust Record +- `examples/nvidia-h100.json`: NVIDIA H100 Confidential Computing Trust Record + +### Open questions + +Seven open questions requiring community input before v0.2 are documented in §7 of the spec. + +--- + +## [0.2.0] — TBD + +### Specification + +- Extend `subject` field to accept DID URIs (any `did:` method) in addition to SPIFFE SVIDs. + Previously `^spiffe://` only; now `^(spiffe://|did:)`. Additive, backward-compatible. + DID-native runtimes (e.g. AGT `did:mesh:` identities) no longer require a parallel SPIFFE identity. + Closes: microsoft/agent-governance-toolkit ADR-0032, agentrust-io/trace-spec#35. + +### Schema + +- `schema/trace-claim.json`: `subject` pattern updated to `^(spiffe://|did:)`, description updated. + +### Reference Implementation + +- `TrustRecord.subject` pattern updated to `r"^(spiffe://|did:)"`. + +--- + +## Upcoming + +See [ROADMAP.md](ROADMAP.md) for planned changes in v0.2 and v1.0. diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md new file mode 100644 index 0000000..c5797df --- /dev/null +++ b/docs/rfcs/a2a-delegation-profile.md @@ -0,0 +1,409 @@ +# RFC Proposal: A2A delegation-link verification profile + +**Status:** Draft proposal. Binds nothing. +**Scope:** Verification rules for the existing `delegation` block. No schema change. +**Target:** `spec/trace-v0.2.md` §3.1 surface, for the v0.3 A2A profile named in `ROADMAP.md`. +**Conformance material:** `examples/delegation-link/` — 23 vectors, generator, published key. + +Requirement keywords are lowercase throughout this document, deliberately. `CONTRIBUTING.md` +draws the line that normative text lives in the specifications and informative text binds no +implementation; this file is informative until its rules are adopted, at which point they +become uppercase in `spec/` and this file becomes a pointer to where they went. A proposal +that writes itself in the imperative is a specification nobody agreed to. + +**Why the corpus was built before the rules.** The three decisions in §4 were not read out of +the existing text. They were hit, because no vector could be written without settling them, and +in each case the text supports both branches. A reader passes over all three without noticing; +someone building a fixture cannot get to the end of one. That order — corpus first, and let it +interrogate the text — is the part of this document worth keeping if the rules themselves are +replaced, because it produces a measurement the repository does not otherwise have. + +Not *do the tests cover the rules*, which measures an implementation. **Do two independent +readings of the same normative text produce the same rules**, which measures the +specification. Where two readings agree, the text is doing its job; where they diverge, the +text is under-specified, and the divergence names the sentence that is missing. §7.1 is that +measurement run once, on two implementations that were written without reference to this +document: on the digest preimage they agree with §4.1 and with each other, and on the +unresolvable-algorithm question they split — one treats it as unreadable and one as tampering. +The first is the text working. The second is a missing sentence, located, in one line, and it +took no argument to find because two implementations were asked the same question. + +A single reading measures nothing. That is the case for a second profile design rather than +against one. + +--- + +## 1. What exists today + +The `delegation` block is normative in v0.2. `schema/trace-claim.json` requires +`parent_record_hash` and `credential_id`, pins the first to `sha256:`/`sha384:` and the +second to a non-empty string, and forbids additional members. + +What a verifier does with a chain of them is not normative anywhere. +`spec/trace-v0.2.md` does not mention either field. The only prose is `docs/schema.md`: + +> A chain of records linked this way forms an offline-verifiable delegation DAG: a verifier +> walks `parent_record_hash` from a leaf record back to the root and confirms each hop acted +> under a credential in the delegation chain. + +One sentence, and every operative word in it is undefined. What are the bytes the digest +covers. What is "the delegation chain" that a credential is in, given that no credential +object exists in the schema. What makes a hop's use of one legitimate. What a verifier does +when it cannot compute the digest algorithm the link names. + +Two conforming implementations can satisfy every constraint the repository states today and +agree on nothing. This proposal is the smallest set of rules that closes that, written +against the block as it stands so that adopting it requires no schema change. + +## 2. The verification model + +A verifier is given a **record set**, a **leaf**, and **context**. The record set is a set: +it arrives in no defined order, and the leaf is named by digest rather than by position. +An implementation that reads the first element as the root, or the next element as the +parent, is reading a property of its input channel rather than of the evidence. + +The verifier walks from the leaf towards the root, resolving each `parent_record_hash` +against the digests of the records it holds. The walk ends at a record with no `delegation` +block — the root — or at a link it could not follow. + +Context is what a verifier knows that no record can tell it, and it is enumerated rather +than assumed: + +| Context | Why it cannot come from the records | +|---|---| +| `trusted_root_keys` | A record naming its own key as trusted is not evidence. | +| `credentials` | `credential_id` is an identifier; the thing it identifies is held out of band. | +| `data_class_lattice` | `data_class` is an open string in the schema, so no ordering can be inferred from a record. | +| `max_depth` | A bound is a deployment decision, not a property of a chain. | +| `supported_digest_algorithms` | What the verifier can compute, which is not what the chain may name. | +| `now` | Present for completeness; §4.3 explains why no rule reads it. | + +Each entry is a place where an implementation that guesses instead of being told produces +an answer that looks like verification and is not. Naming them is half the profile. + +## 3. The rules + +Ten rules, each with a stable code, a classification, and two independent vectors. +Classification is the three-way distinction the conformance corpus in `agentrust-io/ca2a` +already uses for its action cases: a structural failure, an authority failure, and a chain +that could not be read are three different findings, and a verifier that collapses any two +of them is reporting something other than what it found. + +| # | Code | Class | Rule | Vectors | +|---|---|---|---|---| +| D-1 | `record_signature_invalid` | provenance | Every record on the walked chain verifies under the key it advertises in `cnf.jwk`. | 06, 07 | +| D-2 | `root_key_untrusted` | provenance | The record with no `delegation` block carries a key in `trusted_root_keys`. | 08, 09 | +| D-3 | `parent_not_found` | provenance | Each `parent_record_hash` resolves to a record in the set, under §4.1's preimage. | 04, 05 | +| D-4 | `depth_exceeded` | authorization | The walk follows no more than `max_depth` links. | 20, 21 | +| D-5 | `credential_unknown` | authorization | `credential_id` matches a registry entry as an exact octet string. | 10, 11 | +| D-6 | `credential_issuer_mismatch` | authorization | The credential's issuer is the parent record's `subject`. | 12, 13 | +| D-7 | `credential_holder_mismatch` | authorization | The credential's holder is this record's `subject`. | 14, 15 | +| D-8 | `credential_window` | authorization | This record's `iat` falls inside the credential's validity window. | 16, 17 | +| D-9 | `data_class_widened` | authorization | A hop's `data_class` is no more sensitive than its parent's, per the supplied lattice. | 18, 19 | +| D-10 | `digest_algorithm_unsupported` | unverifiable | A link naming an algorithm the verifier cannot compute yields an unverifiable chain. | 22, 23 | + +Provenance outranks authorization in the reported classification. A chain whose structure is +broken has no established parent for a credential to be judged against, so reporting an +authority failure over it would describe a relationship that was never demonstrated. + +D-6 and D-7 together are the whole of what "acted under a credential in the delegation +chain" can mean offline with the fields that exist: the credential came *from* the hop above +and was issued *to* this one. Vectors 13 and 15 are the two ways an implementation gets that +backwards while every comparison it makes still returns agreement. + +## 4. Three decisions a vector could not be written without + +These are not refinements. Each is a fork where the existing text supports both branches, +and no conformance material can exist until one is chosen. + +### 4.1 The digest covers the complete record, signature included + +"Digest of the parent hop's Trust Record" does not say which bytes. Over the RFC 8785 +encoding of the complete record, or over the signed body with `signature` removed? Both are +natural readings and they are not interoperable: a chain built under one is a chain of +dangling links under the other, and neither side has a diagnostic that says so. + +This profile takes the complete record, because the alternative does not bind the parent's +signer. Under the body reading, a child's `parent_record_hash` commits to bytes that any +holder of any key can re-sign. Two records with different signatures — one genuine, one +issued by an attacker — satisfy the same commitment, and a verifier walking the chain +resolves to whichever it was handed. The child would have committed to *what its parent +said* and not to *who said it*, which is the entire content of a provenance link. + +The complete-record reading has a consequence worth stating, because it changes what an +attacker can reach: every ancestor's bytes are committed to by its child, so no record on a +chain can be altered in place except the leaf. An ancestor with an invalid signature is +still reachable — it has to be built that way before its child signs — which is why D-1 +applies to every record on the walk and not only to the leaf. Vector 07 is that case, and +vector 06 is the leaf case; an implementation that verifies the leaf and takes the rest on +the strength of the hashes passes one and fails the other. + +Vector 05 is this decision and nothing else: a complete, correctly signed two-record chain +whose only defect is that its link was computed over the parent's signed body. + +### 4.2 No cycle rule, and why the depth bound is not a substitute for one + +A delegation cycle would need record A's block to carry a digest of B while B's block +carries a digest of A. Each digest covers the block holding the other, so constructing the +pair is a hash collision. Cycles are not forbidden by this profile; they are unreachable, +and a rule against them would be untestable by construction — there is no vector that could +demonstrate an implementation lacking it. + +The reachable analogue is an *unbounded* chain, which is trivially constructible, and a walk +without a limit is a denial of service on the verifier. That is the only reason D-4 exists, +and it is stated here so that a reader can tell which of the two was decided and which was +forgotten. + +Vectors 20 and 21 pin the comparison from both sides: 21 sits one past the bound, and vector +02 sits exactly on it and verifies. A bound tested only from above accepts an off-by-one; a +bound tested only from below rejects legitimate chains at the limit. + +### 4.3 A link that cannot be read is unverifiable, not invalid + +The schema permits `sha384:` links. A verifier that implements only `sha256:` cannot resolve +such a link — and reporting `parent_not_found` for it would be a finding nobody made. The +verifier did not fail to find the parent; it did not look. + +This follows the semantics already merged in `docs/verification.md`: evidence that resolves +and contradicts fails the appraisal, and a verifier does not downgrade to escape that; +evidence that does not resolve downgrades honestly, and the verifier records the depth it +actually achieved. D-10 is the delegation-surface instance of the second half. In the +reference walk, D-3 is explicitly guarded on algorithm support so that the two findings +cannot be produced together for the same link. + +Vector 23 is why this needs a rule rather than a note: a chain whose leaf link is `sha256:` +and whose third link is `sha384:` reports "verified" from any implementation that reads the +algorithm once and assumes the chain is uniform, having never resolved half of it. + +The related decision, made for the same reason: D-8 judges the credential window against the +hop's own `iat`, not against the verifier's `now`. A chain does not become invalid because +it is being read late, and a verifier using its own clock returns a different answer every +day for the same evidence. `now` stays in the context table because a deployment may impose +freshness policy on top of this profile; no rule here reads it. + +## 5. The conformance corpus + +`examples/delegation-link/` holds 23 vectors. Each is one scenario: a complete record set, +the verifier context to judge it under, and the classification and codes it expects, so a +third party can score an implementation without running anything from this repository. + +Every record in every vector — including the ones built to fail — validates against +`schema/trace-claim.json`. A defect the schema already rejects is not a profile defect, and a +rule that appears covered only because its vector is malformed in some louder way is not +covered. + +Reproducibility is a property of the corpus, not a courtesy. Keys derive from one published +seed by role label; the generator regenerates every byte; `tests/test_generators_reproduce_fixtures.py` +(#171) discovered it with no new guard code and holds it to that with no entry in the +`NOT_GENERATED` ledger. This is the bar issue #178 proposes for the repository's corpora, +adopted here from the first vector rather than retrofitted. + +Coverage is held to the discipline argued in #124 and executed in +`tests/test_vector_completeness.py`: two load-bearing vectors per rule, and — the part that +is not satisfied by writing the same vector twice — at least one declared implementation +defect that one vector catches and the other misses. `tests/test_delegation_completeness.py` +declares those defects, all ten of them modelling a real shortcut: verifying the leaf only, +anchoring on any trusted key found, an off-by-one bound, case-insensitive identifier lookup, +issuer and holder compared to the wrong ends, half a validity window, narrowing checked at +one hop. + +Vector identifiers are `TRACE-DELEG-NNN` and are never reused. They are deliberately not +`ACTION-*`: that namespace belongs to ca2a's conformance set, whose own rule is that its +identifiers are never reused, and borrowing it from another repository would collide the +first time either side adds a case. §7 proposes a cross-reference table instead. + +## 6. What this proposal does not do + +- **No schema change.** Every rule reads fields that exist in v0.2. +- **No credential format.** The registry is verifier context, like a trusted key set. What a + credential *is*, how it is issued, signed, or revoked, is out of scope, and the vectors + express one only as the fields the rules read. +- **No revocation.** A revoked credential is indistinguishable here from a valid one, which + is a real gap and belongs in the same discussion as the credential format. +- **No withdrawal before execution.** An authorization revoked between issue and use is one + of the two gaps issue #66 has already named on the approval-shaped surface; it needs a + field this schema does not have. +- **No authority-epoch staleness**, the other #66 gap, for the same reason. +- **No mutual case — and it is not clear the delegation block is where it belongs.** + `ROADMAP.md:21` scopes the v0.3 A2A profile as "binding rules over the `delegation` block now + that A2A is stable at v1.x, **including the mutual case**". In the reference implementation + the mutual case is mutual *attestation*, not mutual delegation: `ca2a/docs/spec/mutual-attestation.md` + describes a callee-issued challenge and a caller offer bound to it, so each side establishes + what the other is running before a payload opens. That document separates the two concerns in + as many words — it "establishes what each side is running", while "the delegation chain + remains the thing that says what it is allowed to ask for" — and it does not mention a Trust + Record anywhere. Neither does cA2A's own `docs/spec/trace-a2a-profile.md`, whose A2A profile + is the delegation-link block and nothing else. + + So the roadmap asks the A2A profile to cover something that today has no record + representation at all, in either repository, and which sits at the transport layer rather + than on this surface. That is a scoping question rather than a hole in these rules: either + mutual attestation gains a binding into the record — which is a schema question, not a + verification one — or the v0.3 profile is two profiles. Raised here because a reader + comparing this document against that roadmap line will otherwise conclude the gap is a + coverage failure, and it is not the same thing. + + Bidirectional *delegation*, if that reading were ever intended, is separately unreachable: + A's block would have to carry a digest covering B's block and B's a digest covering A's, + which is the same hash collision §4.2 rules out for cycles. Recorded so the two readings do + not get merged later. +- **No cross-verifier agreement.** This is the load-bearing omission and §7 is about it. + +## 7. The part that makes this worth doing + +One implementation passing its own conformance corpus is self-agreement. What would make +this a profile rather than a local convention is the same vectors run through two +independently written verifiers. + +Both exist. `agentrust-io/ca2a` verifies delegation DAGs offline in `ca2a_verify`; +`agentrust-io/agent-manifest` exports `verify_delegation_chain`, `DelegationHopSigner` and +`delegation_depth_exceeded` from its public API. This corpus has been run against the first +of them; what came back is in §7.1, and it is the reason this section is the load-bearing one. + +The proposal for the next step, which is not this document's to decide: + +1. A cross-reference table between `TRACE-DELEG-*` here and ca2a's `ACTION-*` group, so an + implementation scored under one can be read against the other. +2. A harness running these vectors through both verifiers and asserting agreement on the + shared surface — signature validity, hop continuity, scope narrowing, depth. +3. Divergences are the output. Each is either an ambiguity in this text, which comes back + here as a vector and a paragraph, or a defect in one implementation, which is filed + where it lives. + +The same corpus would also close a gap on the agent-manifest side: of its 21 vectors, two +touch delegation and both are single-hop, so its own narrowing and depth logic has no vector +coverage at all. + +### 7.1 What running it against cA2A actually returned + +Measured against `ca2a_verify.verify_trace_dag` at ca2a `5dd77b2`. Thirteen of the 23 +vectors exercise the record-linkage surface that function covers; the other ten are +credential defects, which its own docstring assigns to `ca2a_runtime.delegation.verify_chain`. + +**The digest decision in §4.1 is confirmed independently.** `ca2a_runtime.trace_binding.trace_record_hash` +computes `"sha256:" + sha256(rfc8785.dumps(signed_record))` — the complete record, signature +included, byte-identical to what this profile specifies. That was arrived at separately, in +another repository, and it is the strongest evidence available that §4.1 chose the reading +the ecosystem is already built on rather than the one that was convenient here. Vector 05, +the body-digest link, is rejected by cA2A as a broken parent link. + +**Vectors 01–07 agree.** Valid chains are accepted, the absent parent and the body-digest +link are rejected as broken links, and both signature vectors are rejected as bad +signatures. Same verdict, same reason, two implementations. + +**Vectors 22 and 23 disagreed, exactly as §4.3 predicts, and the disagreement is closed.** +cA2A's block validator accepted a `sha384:` link, compared it against a hash it only ever +computes as `sha256:`, and reported `ProvenanceLinkBroken` with the detail *"a tampered or +reparented record was detected"*: a chain that is intact, and simply addressed under the +other permitted algorithm, reported as tampering. + +[agentrust-io/ca2a#119](https://github.com/agentrust-io/ca2a/pull/119) carried the fix. At +ca2a `52141e8`, `src/ca2a_verify/dag.py` raises `TraceDigestUnsupported` with the detail +*"the chain is unverifiable here, not invalid"* before the link comparison is reached, so +the two vectors now describe fixed behaviour and agree with the implementation as well as +with the ruling. The case is kept here because of what it establishes rather than as a +defect report: a corpus written to argue a rule found the case, the other implementation +changed, and the shape it changed to is the one §4.3 asks for. + +**The trust contract differs and cannot be normalised away.** `verify_trace_dag` requires +every record's `cnf.jwk` to be in the trusted set; this profile anchors on the root's key and +lets the chain carry the rest. Run under this profile's contract — only the declared root +trusted — cA2A rejects every chain longer than one record, valid ones included. Neither is +wrong: cA2A's model fits a workflow whose orchestrator knows every participant, and this +one fits the cross-organisation case where that knowledge is exactly what is missing. It is +worth noting that cA2A itself uses the root-anchored model on its other surface, where +`verify_chain` takes `trusted_root_issuers`. + +**The credential surfaces cannot be compared at all, and that is the finding.** Three +repositories, three delegation models, no conversion between them: + +| | this profile | cA2A `DelegationCredential` | agent-manifest hop | +|---|---|---|---| +| issuer / subject | record `subject` (SPIFFE) | raw Ed25519 public key hex | `principal_id` | +| scope | `data_class` + supplied lattice | `scope: frozenset[str]` | tools, `data_classifications`, constraints, `ttl_seconds` | +| validity window | `not_before` / `not_after` vs hop `iat` | **absent** | `ttl_seconds`, narrowing | +| depth bound | verifier context | on each credential, default 8 | `max_delegation_depth` on the root hop | +| trust anchor | root record's key | `trusted_root_issuers` | `public_keys` map of every principal | +| replay binding | — | `parent_id` chaining, unique ids | `manifest_id` in the signature pre-image | + +Two things follow. agent-manifest already narrows on `data_classifications`, which is +independent support for D-9 belonging on this surface at all — open question 2 above. And +cA2A credentials carry no validity window, so D-8 has no counterpart there; an authority +that cannot expire is the same family of gap as the two that issue #66 has already named. + +**The reverse direction passes.** Everything above pushes this corpus outward, which only +shows that cA2A rejects what the profile rejects. The harder question is whether a chain the +*ecosystem produces* verifies here. `ca2a/examples/trace-dag/demo.py` emits a signed +three-hop TRACE DAG through cA2A's own `trace_binding`; run against it, all three records are +schema-valid, both links match the §4.1 preimage exactly, all three signatures verify under +D-1, and the chain returns `verified` with no codes and no adjustment to the walk. The +profile describes what is already being emitted rather than something invented alongside it. + +Worth recording while it was found: `examples/trace-dag/` commits a README and a demo but no +vectors — the DAG is produced at runtime and not kept. That is the gap this corpus fills from +the trace-spec side, and it is why §7's step 1 is a cross-reference table rather than a +request that cA2A publish one. + +Also worth recording, as a hazard for anyone writing a parser: cA2A uses the field name +`parent_record_hash` in two different formats. In a TRACE record it is the schema's +`sha256:`/`sha384:`-prefixed digest; in cA2A's own provenance DAG — `ca2a verify-dag`, and the +committed `examples/*/dag.json` — it is a bare hex digest with no prefix, on a record with +no TRACE fields at all. Both are deliberate and neither is wrong; the collision is in the +name. + +**agent-manifest already treats unverifiable as a first-class outcome, on this exact +surface.** Its corpus declares results as data in the vector files, over a vocabulary of +`VALID`, `MISMATCH`, `UNVERIFIABLE`, `EXPIRED`, `REVOKED`, `SIGNATURE_MISSING`, `INCOMPLETE`, +`INCOMPATIBLE_VERSION`, `ATTESTATION_UNAVAILABLE` — and `AM-VEC-012` reads: + +```json +{"result": "UNVERIFIABLE", "fields_verified": {"delegation_chain": "UNVERIFIABLE"}} +``` + +The case is a delegation chain with no public keys: evidence the verifier lacks what it needs +to check, recorded as unreadable rather than as a finding against the chain. That is §4.3, +on the delegation surface, in a second implementation, arrived at independently. + +Which puts the sha384 case in a different light than a matter of taste. All three +implementations now distinguish "could not be read" from "contradicts"; cA2A's TRACE DAG +verifier collapsed the two until #119, and what it changed to is the distinction §4.3 asks +for. The per-field shape of `fields_verified` is +also prior art this proposal does not have and probably should consider: a verdict per field +says more than one verdict per chain. + +**One claim, checked and upheld.** `ca2a_runtime/canonical.py` hand-implements RFC 8785 +rather than taking a library, and states that this makes cA2A signatures cross-verifiable +with agent-manifest. Run against `examples/canonicalization-boundary/`, which exists to +separate a conforming serializer from a carefully configured `json.dumps`, that +implementation is byte-identical to the reference on all four vectors — including both +UTF-16 key-order cases, which is where near-misses fail. The claim is narrower than +"the two verifiers agree on a chain": it is about the signed byte string, and on that axis +it holds. + +## 8. Open questions + +The first one is not about the design. + +0. **Is this document the v0.3 profile, or an input to it?** `ROADMAP.md:21` places the + normative A2A profile at v0.3 and names cA2A as the reference implementation. It does not + say who writes the binding rules, and this was written without asking. Both answers are + fine and they lead to different documents: the profile itself would need the mutual case, + a credential model and a ratification path; an input would stay what it is, a set of rules + that already have executable material behind them and can be lifted, argued with, or + discarded a rule at a time. The corpus is useful either way, which is the reason it was + built first. +1. **Is the credential registry the right shape?** It is modelled here as verifier context + because nothing in the schema describes a credential. The alternative — a credential + object in the record — is a schema change and a much larger proposal. +2. **Should `data_class` narrowing be in this profile at all?** It is the only scope-like + comparison the current fields support, and it may belong with a general scope model + rather than with delegation. +3. **Is `max_depth` a profile constant or deployment context?** Context here, on the grounds + that a bound is a deployment decision, but a fixed floor would make chains portable + between verifiers in a way this does not. +4. **Where should the cross-verifier harness live** — ca2a's tests, agent-manifest's, or a + neutral repository? Each choice makes one implementation the host of the corpus that + scores it. +5. **Does the leaf need to be named at all?** It is context here. A record set with two + unreferenced records has two candidate leaves, and picking wrong verifies a different + thing than the caller asked about. diff --git a/examples/delegation-link/01-valid-single-hop.json b/examples/delegation-link/01-valid-single-hop.json new file mode 100644 index 0000000..fdf9b10 --- /dev/null +++ b/examples/delegation-link/01-valid-single-hop.json @@ -0,0 +1,132 @@ +{ + "id": "TRACE-DELEG-001", + "name": "valid-single-hop", + "description": "One delegation from a trusted root. The shortest chain the profile has anything to say about, and the case every rule below is a departure from.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "verified", + "codes": [] + } +} diff --git a/examples/delegation-link/02-valid-full-depth-out-of-order.json b/examples/delegation-link/02-valid-full-depth-out-of-order.json new file mode 100644 index 0000000..9e52ec4 --- /dev/null +++ b/examples/delegation-link/02-valid-full-depth-out-of-order.json @@ -0,0 +1,246 @@ +{ + "id": "TRACE-DELEG-002", + "name": "valid-full-depth-out-of-order", + "description": "Four delegations, exactly on `max_depth`, narrowing at every hop, with the record set emitted leaf-first. Verifies. A verifier whose bound is off by one rejects this, which is the other half of vector 21.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:94e67431b15e62ca8d333bee66f5c509dbe24275bd4858c0ae693ef32366641b", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "lGkHlTh-HxvT-Subg8v5AxA8__3U4e1X-WNUL5yYMg9ShD5v7qvsAOKWuDC46c-6IiRqfGqToRWqN5rQ4PpdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "verified", + "codes": [] + } +} diff --git a/examples/delegation-link/03-valid-root-only.json b/examples/delegation-link/03-valid-root-only.json new file mode 100644 index 0000000..59059f8 --- /dev/null +++ b/examples/delegation-link/03-valid-root-only.json @@ -0,0 +1,94 @@ +{ + "id": "TRACE-DELEG-003", + "name": "valid-root-only", + "description": "A record with no delegation block at all. Not a degenerate chain: a root execution is the ordinary case, and a verifier that requires the block to be present has broken every non-delegated record.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "verified", + "codes": [] + } +} diff --git a/examples/delegation-link/04-parent-record-absent.json b/examples/delegation-link/04-parent-record-absent.json new file mode 100644 index 0000000..8c91bad --- /dev/null +++ b/examples/delegation-link/04-parent-record-absent.json @@ -0,0 +1,210 @@ +{ + "id": "TRACE-DELEG-004", + "name": "parent-record-absent", + "description": "The chain is sound, and the record the third hop names is simply not in the set. Presenting a leaf without the hops that authorise it is the cheapest attack on a chain nobody walks to the end.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "parent_not_found" + ] + } +} diff --git a/examples/delegation-link/05-link-over-signed-body.json b/examples/delegation-link/05-link-over-signed-body.json new file mode 100644 index 0000000..5292db0 --- /dev/null +++ b/examples/delegation-link/05-link-over-signed-body.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-005", + "name": "link-over-signed-body", + "description": "Every record is present and correctly signed; the leaf's `parent_record_hash` is a digest of its parent's signed body with `signature` removed, rather than of the complete record. This is the one vector that separates the two readings of \"digest of the parent hop's Trust Record\": under the profile's reading the link resolves to nothing, under the rejected reading the whole chain verifies.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:169845502881464187359f61e4857aa1b663f8614c6764d1a3c890ee018d180f", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:90bfc2c5fb7a2e082252400055f450d252e6d2a8deb6129868bc0820b415069c", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "hPdHyrSkbMdYwothvxKKg9-PbyH0f0IErsHQx060sf3jeWicXwd99j4N-YZUbEIxgbizQ5TTaEtEYN80myKJCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "parent_not_found" + ] + } +} diff --git a/examples/delegation-link/06-leaf-signed-by-other-key.json b/examples/delegation-link/06-leaf-signed-by-other-key.json new file mode 100644 index 0000000..181addb --- /dev/null +++ b/examples/delegation-link/06-leaf-signed-by-other-key.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-006", + "name": "leaf-signed-by-other-key", + "description": "The leaf advertises the executor's key in `cnf.jwk` and is signed by the stranger's. The leaf is the only record in a chain whose bytes nothing else commits to, so it is the only one an attacker can reach in place.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:5542cbcb9318aa362a8e3ae717865d1ec023bb36b3cfbcf62284c006915900e4", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "EQzzIBgZleeYvHd2xljslD1GihKKw3A56dIHmdko6kzjPCTxS-qL0DfBPWq7Jkn40_ssnLICR-UfowTFkjs4Ag" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "record_signature_invalid" + ] + } +} diff --git a/examples/delegation-link/07-intermediate-signed-by-other-key.json b/examples/delegation-link/07-intermediate-signed-by-other-key.json new file mode 100644 index 0000000..90e2df5 --- /dev/null +++ b/examples/delegation-link/07-intermediate-signed-by-other-key.json @@ -0,0 +1,172 @@ +{ + "id": "TRACE-DELEG-007", + "name": "intermediate-signed-by-other-key", + "description": "The same defect one hop up, built in before the child committed to it, so the link still resolves and only the signature is wrong. A verifier that checks the leaf and trusts the rest of the chain because 'the hashes match' passes this and fails vector 06.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:bb857be837d7c7043a047ed627508ee0d604e7234aff8c6dce3e7c65df546fff", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:8536dcabc3df38275f477505d997303eda99b5bd2415ab7d7c692808b005921d", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "zr4_ISnv0Lhz0cP2vx07e_jr7wLd_BmU5MQtOLcIlnnnppJhL6Z4Lsrn9er9P0JHKU9VUTKg7vYEuU-GQKYNBA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "boX6sOSimP9il6rQX-jXE3pB0dms_GIPYKAOaPNOCZEcfEBnmiUo1eiRqtZcX-rThGGqWJ_GBXi9-jGnovr4Aw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "record_signature_invalid" + ] + } +} diff --git a/examples/delegation-link/08-root-key-untrusted.json b/examples/delegation-link/08-root-key-untrusted.json new file mode 100644 index 0000000..22f57b2 --- /dev/null +++ b/examples/delegation-link/08-root-key-untrusted.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-008", + "name": "root-key-untrusted", + "description": "A correctly signed, internally consistent single-hop chain whose root is held by a key the verifier was never given. Every hash matches and every signature verifies; the chain is anchored to nothing.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:c73150297c588dc01ef579b527182c932e7f86499831d162c331b34495f19b58", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:stranger-to-planner": { + "issuer": "spiffe://acme.example/agent/stranger", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:ceda95720fbfdf66be21cc2f866dd8d4004b1d15873ec461ad343845769da570", + "credential_id": "cred:stranger-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "RSdSUVplb1ShCA_RwqXys2OyNsOf-c4kmXwOFe_JDBzFBPZt0XyNoKhceX1a8FxcOsgQZtf-c9vQWqy9da_WAQ" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "_TJMhmc0FvvJlaI_nDsLUmSOJrjBiKqbNj16qh___YMAoWtCsx1LZE6kbfd61qoDcoZuIAAaBHWJfOOJ5wZRAQ" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "root_key_untrusted" + ] + } +} diff --git a/examples/delegation-link/09-trusted-key-below-the-root.json b/examples/delegation-link/09-trusted-key-below-the-root.json new file mode 100644 index 0000000..571fb3e --- /dev/null +++ b/examples/delegation-link/09-trusted-key-below-the-root.json @@ -0,0 +1,160 @@ +{ + "id": "TRACE-DELEG-009", + "name": "trusted-key-below-the-root", + "description": "The root is the stranger again, but this time the *second* record on the chain is held by the trusted orchestrator key. Every hop is sound and the chain is still anchored to nobody: authority does not begin partway up. A verifier that anchors on the highest trusted key it finds, rather than on the record with no delegation block, reports this chain verified — and still fails vector 08, where no record carries a trusted key at all.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:f4ebc66afd1a0980c197ffe1d72ca4b9e7cae20755f0e7f14d05d4dde79cfdb7", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:stranger-to-orchestrator": { + "issuer": "spiffe://acme.example/agent/stranger", + "holder": "spiffe://acme.example/agent/orchestrator", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:0e1eff74077463368dff6cf940d9f8c0e37aa5af83f16abd193900d04a95cb3c", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "xOaYSbDnzUMGzZUCsAuRf1MlY6CHCR1FUXK5V7SW8eF0SJ1xwjFiAWzRLB7nGrA23cAXiZ7igGOcAWsNlF-lCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:ceda95720fbfdf66be21cc2f866dd8d4004b1d15873ec461ad343845769da570", + "credential_id": "cred:stranger-to-orchestrator" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "NG3IIR9d-DqxZofLCGb8OFYfsrvLoYGO8vVk7AIUrjTgSRVH4WCrklU5OCMlQtGK8CAHxh6TjtexksO3KmMiDQ" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "_TJMhmc0FvvJlaI_nDsLUmSOJrjBiKqbNj16qh___YMAoWtCsx1LZE6kbfd61qoDcoZuIAAaBHWJfOOJ5wZRAQ" + } + ], + "expected": { + "classification": "provenance-invalid", + "codes": [ + "root_key_untrusted" + ] + } +} diff --git a/examples/delegation-link/10-credential-not-registered.json b/examples/delegation-link/10-credential-not-registered.json new file mode 100644 index 0000000..8effae9 --- /dev/null +++ b/examples/delegation-link/10-credential-not-registered.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-010", + "name": "credential-not-registered", + "description": "The hop names a credential the verifier holds nothing for. The chain is structurally sound; the authority it claims cannot be looked up.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:6107269d774276ab0f19d62ba285a6c91c890fe2cea715472537a925496cbc00", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:never-issued" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "no3gXo5r5OUWIL-Wuqj5lenTvU0CXN4fVsCBOEmu3tQwCU9K9wg0NdI6vYEg6Zy0XzwrtIw2SynhceZ6cPXLAw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_unknown" + ] + } +} diff --git a/examples/delegation-link/11-credential-id-case-differs.json b/examples/delegation-link/11-credential-id-case-differs.json new file mode 100644 index 0000000..8ad61a9 --- /dev/null +++ b/examples/delegation-link/11-credential-id-case-differs.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-011", + "name": "credential-id-case-differs", + "description": "The hop names `CRED:Orchestrator-To-Planner`, differing from the registered id only in case. `credential_id` is an opaque octet string with no case-folding rule, so this is an unknown credential — and a verifier that lowercases before lookup accepts an identifier nobody issued.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:642a127409daae86933967acfb5b398a66e924af57aa193f742789434b1f2a74", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "CRED:Orchestrator-To-Planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "y3xyLFI-1mYb7M0zfMDJBrknk-kGwsUMy_09pvYm7ihNZVa3FBY1cOjgNPvMU4l6w0i7ZlVxcmCX9EDjnOHdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_unknown" + ] + } +} diff --git a/examples/delegation-link/12-credential-issued-by-third-party.json b/examples/delegation-link/12-credential-issued-by-third-party.json new file mode 100644 index 0000000..dbc9036 --- /dev/null +++ b/examples/delegation-link/12-credential-issued-by-third-party.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-012", + "name": "credential-issued-by-third-party", + "description": "A registered, in-window credential naming the courier as issuer, used on a hop whose parent is the orchestrator. Authority that did not come from the delegating hop is not delegation.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_issuer_mismatch" + ] + } +} diff --git a/examples/delegation-link/13-credential-self-issued.json b/examples/delegation-link/13-credential-self-issued.json new file mode 100644 index 0000000..8703b0c --- /dev/null +++ b/examples/delegation-link/13-credential-self-issued.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-013", + "name": "credential-self-issued", + "description": "The credential names the *holder* as its own issuer. A verifier comparing the issuer against the record under appraisal rather than against its parent reads this as consistent, and grants an agent whatever it wrote down for itself.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_issuer_mismatch" + ] + } +} diff --git a/examples/delegation-link/14-credential-held-by-third-party.json b/examples/delegation-link/14-credential-held-by-third-party.json new file mode 100644 index 0000000..fb71b04 --- /dev/null +++ b/examples/delegation-link/14-credential-held-by-third-party.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-014", + "name": "credential-held-by-third-party", + "description": "The credential was issued by the right party to somebody else. Replaying a valid credential issued to a different agent is the attack a holder check exists for.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_holder_mismatch" + ] + } +} diff --git a/examples/delegation-link/15-credential-holder-is-the-parent.json b/examples/delegation-link/15-credential-holder-is-the-parent.json new file mode 100644 index 0000000..7c2756b --- /dev/null +++ b/examples/delegation-link/15-credential-holder-is-the-parent.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-015", + "name": "credential-holder-is-the-parent", + "description": "Issuer and holder both name the parent. A verifier that has the two comparisons the wrong way round finds the holder where it expects the issuer, reports agreement, and passes this while failing 14.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/orchestrator", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_holder_mismatch" + ] + } +} diff --git a/examples/delegation-link/16-credential-expired-at-hop.json b/examples/delegation-link/16-credential-expired-at-hop.json new file mode 100644 index 0000000..b655541 --- /dev/null +++ b/examples/delegation-link/16-credential-expired-at-hop.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-016", + "name": "credential-expired-at-hop", + "description": "The hop executed after its credential's `not_after`. Judged against the hop's own `iat`, not against the verifier's clock: a chain does not become invalid because it is being read late.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784992800, + "not_after": 1784996400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_window" + ] + } +} diff --git a/examples/delegation-link/17-credential-not-yet-valid-at-hop.json b/examples/delegation-link/17-credential-not-yet-valid-at-hop.json new file mode 100644 index 0000000..68c3287 --- /dev/null +++ b/examples/delegation-link/17-credential-not-yet-valid-at-hop.json @@ -0,0 +1,116 @@ +{ + "id": "TRACE-DELEG-017", + "name": "credential-not-yet-valid-at-hop", + "description": "The hop executed before its credential's `not_before`. The other side of the window, and the side an implementation reaches for expiry alone leaves open — a credential dated into the future authorises everything done before it existed.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:939ccd49a652239792388be929ed99ca2c771acbc72aa84eefd05913384b516c", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1785003600, + "not_after": 1785007200 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "w1-yxCO_nNRY2pj2rGO065DN25Myk9xLJ13DzL0d9tTTwb3Wq0tnwe2H5U5oEXctynLDJN8xP0CLhLjVcBUmDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "credential_window" + ] + } +} diff --git a/examples/delegation-link/18-data-class-widened-at-leaf.json b/examples/delegation-link/18-data-class-widened-at-leaf.json new file mode 100644 index 0000000..b64dc56 --- /dev/null +++ b/examples/delegation-link/18-data-class-widened-at-leaf.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-018", + "name": "data-class-widened-at-leaf", + "description": "A hop delegated from an `internal` parent declares `restricted`. Delegation cannot manufacture reach the delegator did not have.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:df9cf83aa91975271e107340efbf56c62f728d768acefaa9a0b4c4760f25fbad", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:577823d463ee91ace643bb1e3cde34094c3705dc1a8b349b77003b05a4eaf194", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "C1IhTRYCK34UCumyk13hA_41Xypdae3csiOkfT31G3-C9anB22bO_pEx3MlKYfhLKJeUldB3QMJfMTR0ykIRAA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "6p-6Zx3azYR7CZ7fNKWSPoUO4KnX5LTP0y5sE3P2jPMHDLcb96UDnnMSuN4XI7g8GEsLOXcgidZi6MOSZbVTDw" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "data_class_widened" + ] + } +} diff --git a/examples/delegation-link/19-data-class-widened-mid-chain.json b/examples/delegation-link/19-data-class-widened-mid-chain.json new file mode 100644 index 0000000..5251e62 --- /dev/null +++ b/examples/delegation-link/19-data-class-widened-mid-chain.json @@ -0,0 +1,210 @@ +{ + "id": "TRACE-DELEG-019", + "name": "data-class-widened-mid-chain", + "description": "Widening at the first hop, inside a chain whose leaf is narrower than its root. Comparing the leaf against the root shows narrowing and reports nothing; the rule is per hop, and this is the vector that says so.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:0ac3e0a7eac8cc5a7f5c504b2259110094fd220a7042774fe99e058f74b74c74", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:cde925006e1ac25ca66c3b615c51ebc97c953ca10efae9c6e116baea1ebfcac4", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "85dbWZVZkFrGFqMtCBIEIzrFwCTL-yVLA2HxwcK_mM9MNaCxEbJUEhvqyMuhIkDI3LG3dS91ppsAB07uX1KTCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:5d7653249cc8e3ffcd994aa02c7068cd06198eace3689f265f663eccb705a826", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "zepjJsVAEZ5YC1yvwIlf6fGH-bSMeC2y1v4GjSv_0WlRpKQtD_xnbCURbRTAnNLCxFPVKpTHNNhJ3qs7EwhYAg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:20d2a1b91f90bedc4513b0d7518ea18d380d6ebbfd010dbcad496d536c73acf3", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "ifBTLAif-3ndswuVAFfiaoHE3Lj73fm3MWeleYMhHfzYNgwpG_F1jc3fxW5nbwpsQmBEPtxeq6-w3qhGKd_3Bg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "Xpe0aEdxpjj_mvm-TUUaai9JIvEViB_-BcXq3a-ox2EoTxdId3lswf79CVi681BX8siMO4pM6hF8RZp22wviAw" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "data_class_widened" + ] + } +} diff --git a/examples/delegation-link/20-depth-far-past-the-bound.json b/examples/delegation-link/20-depth-far-past-the-bound.json new file mode 100644 index 0000000..2313c13 --- /dev/null +++ b/examples/delegation-link/20-depth-far-past-the-bound.json @@ -0,0 +1,336 @@ +{ + "id": "TRACE-DELEG-020", + "name": "depth-far-past-the-bound", + "description": "Six delegations against a bound of four. An implementation whose bound is off by one — or absent, walking until the records run out — is separated from a correct one by 21, not by this; this one is what an unbounded walk looks like when nobody is counting at all.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:41e60139aea7d50dd42f0efc8cbdaea2978d915df9383ff8c49cdc33138c869b", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:auditor-to-stranger": { + "issuer": "spiffe://acme.example/agent/auditor", + "holder": "spiffe://acme.example/agent/stranger", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:stranger-to-planner-deep": { + "issuer": "spiffe://acme.example/agent/stranger", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:059125d665345b413d60b30401d1453d6258e6779699e1e165f2d2217c20a0ed", + "credential_id": "cred:stranger-to-planner-deep" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "lJZPFInY6IWhd8RIm9v7kswTAnUFBCNf59hYLjj8-6-B7DbpmqeNqDD76IozSU27sDTB-zdizbhLe8NszayoDw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "credential_id": "cred:auditor-to-stranger" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "QNyWokvMGYuvzxhWQEXdwwSt_eGmKQ425NTIlHfVrYtODw0YV5IpGh0qEn9zXLQU_mMHaXn7v46z0JS11RWbDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:94e67431b15e62ca8d333bee66f5c509dbe24275bd4858c0ae693ef32366641b", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "lGkHlTh-HxvT-Subg8v5AxA8__3U4e1X-WNUL5yYMg9ShD5v7qvsAOKWuDC46c-6IiRqfGqToRWqN5rQ4PpdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "depth_exceeded" + ] + } +} diff --git a/examples/delegation-link/21-depth-one-past-the-bound.json b/examples/delegation-link/21-depth-one-past-the-bound.json new file mode 100644 index 0000000..782542b --- /dev/null +++ b/examples/delegation-link/21-depth-one-past-the-bound.json @@ -0,0 +1,292 @@ +{ + "id": "TRACE-DELEG-021", + "name": "depth-one-past-the-bound", + "description": "Five delegations against a bound of four. One past, so a verifier comparing with the wrong operator accepts it — and vector 02 sits exactly on the bound and must not be rejected, which pins the comparison from both sides.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:059125d665345b413d60b30401d1453d6258e6779699e1e165f2d2217c20a0ed", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:auditor-to-stranger": { + "issuer": "spiffe://acme.example/agent/auditor", + "holder": "spiffe://acme.example/agent/stranger", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/stranger", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:1cfd7c177f3d961fb827aabf0316d9d7f6694a95d99579f26947fbdb4f648861", + "credential_id": "cred:auditor-to-stranger" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "7xv-WpnA5ZV4IycK5tBiwQfzJKGgBELktm9NnHh-xdA" + } + }, + "signature": "QNyWokvMGYuvzxhWQEXdwwSt_eGmKQ425NTIlHfVrYtODw0YV5IpGh0qEn9zXLQU_mMHaXn7v46z0JS11RWbDg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/auditor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "public", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:575f7a6461062a7b9c960b54fe5661faaec207472c5e5dc97547ecf5f678df61", + "credential_id": "cred:courier-to-auditor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "V3hU2B1dYeqXQeRP2jJmUpez71enNy7RAp-Ef4rK734" + } + }, + "signature": "QfFxAtWzE1lJi3xdTrCkjfU84p9T9pmXvNRwlyZhdXZujQS1EOHVNjn9U6TqBAXgDqNuAMnlxxaw3Hr0HsufCw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:d05ba16a8608d3061ab9178424ce0b5c5f1d0d40472c057f4fe8dca16a713dcd", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "g_Zo3wVxYi_czGNi4tg_GyEpzc7i5MPitG4l_u79xKV9iH996juPHXXq6LyuwQWaKzLs3kF222xgLOnU6c9dDA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:94e67431b15e62ca8d333bee66f5c509dbe24275bd4858c0ae693ef32366641b", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "lGkHlTh-HxvT-Subg8v5AxA8__3U4e1X-WNUL5yYMg9ShD5v7qvsAOKWuDC46c-6IiRqfGqToRWqN5rQ4PpdCA" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:b1d65c65d7191e1c6f57f8902548475671edad51035f1542f76a4e45337052b6", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "YU4x2PlIkNR3mOOhy9cL1ld8cedfUox2b8dPUHZ3n3m_GFREEpWGfgbrM8I0l5PtIb9rsCEw1vHJDhnuBw2iCg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "authorization-invalid", + "codes": [ + "depth_exceeded" + ] + } +} diff --git a/examples/delegation-link/22-leaf-link-uses-sha384.json b/examples/delegation-link/22-leaf-link-uses-sha384.json new file mode 100644 index 0000000..4b70904 --- /dev/null +++ b/examples/delegation-link/22-leaf-link-uses-sha384.json @@ -0,0 +1,134 @@ +{ + "id": "TRACE-DELEG-022", + "name": "leaf-link-uses-sha384", + "description": "The leaf links to its parent by a sha384 digest, which the schema permits and this verifier does not implement. The digest is correct: a verifier that supports sha384 resolves the link and verifies the chain. The outcome is unverifiable, not invalid — nothing here contradicts, it just cannot be read.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:9480306c822d8db40120621b12b03a6e6bbbcede73c6e4bd99e8cef6c880a6c0", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha384:f1a0e81fba0858571d2f85a593040d83ff1ddfdfd5c19c8318783f46f25170402c695c07dbef9507883edf76d7c8f893", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "85M197lOYQ8oWZkkK3aUupPPq9xNwFgQX6WgzzC2EoxCfveD_uZ3R9tvO6_0XF0pY_bAuqQZ1TFyCYrQEOk6Bg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "unverifiable", + "codes": [ + "digest_algorithm_unsupported" + ] + } +} diff --git a/examples/delegation-link/23-deep-link-uses-sha384.json b/examples/delegation-link/23-deep-link-uses-sha384.json new file mode 100644 index 0000000..1d7a28d --- /dev/null +++ b/examples/delegation-link/23-deep-link-uses-sha384.json @@ -0,0 +1,210 @@ +{ + "id": "TRACE-DELEG-023", + "name": "deep-link-uses-sha384", + "description": "The same unreadable link two hops up, with a sha256 link at the leaf. A verifier that inspects the algorithm of the first link and assumes the rest of the chain matches reports this chain verified, having never resolved half of it.", + "spec": "docs/rfcs/a2a-delegation-profile.md", + "profile": "trace.a2a.delegation-link.v0", + "context": { + "leaf": "sha256:1560b386744ac9f59b1ccab93a3875ec34fc4727c690f7737e77793fbd015615", + "now": 1785000000, + "max_depth": 4, + "supported_digest_algorithms": [ + "sha256" + ], + "data_class_lattice": [ + "public", + "internal", + "confidential", + "restricted" + ], + "trusted_root_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + ], + "credentials": { + "cred:orchestrator-to-planner": { + "issuer": "spiffe://acme.example/agent/orchestrator", + "holder": "spiffe://acme.example/agent/planner", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:planner-to-executor": { + "issuer": "spiffe://acme.example/agent/planner", + "holder": "spiffe://acme.example/agent/executor", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:executor-to-courier": { + "issuer": "spiffe://acme.example/agent/executor", + "holder": "spiffe://acme.example/agent/courier", + "not_before": 1784913600, + "not_after": 1785086400 + }, + "cred:courier-to-auditor": { + "issuer": "spiffe://acme.example/agent/courier", + "holder": "spiffe://acme.example/agent/auditor", + "not_before": 1784913600, + "not_after": 1785086400 + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/courier", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:981b8eabe4b44f980f473b75ab879066de27e0219d246eed25cd9683e13b9da8", + "credential_id": "cred:executor-to-courier" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "KSw3ZhDAmsf7DDzqMDLU-sVSw2Ns525oP-SMpU-FzQY" + } + }, + "signature": "v1KCZwrQJJxdEZL38sh8kLDf-xNHtbDa1R6KhQgZ0UcAlGVKzIlFwmvhmhVp2jjU9l0hMQrjY63nf_jfLnJcAw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/executor", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "confidential", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha256:f108528ee46015ea711f2af8d13e1d43acb7b5589f1386d6fd3f3397cb40d944", + "credential_id": "cred:planner-to-executor" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "9m_aNMvOSk_seLmEY-fD0lZZgQMjASQ-ILQUnJwtJ3A" + } + }, + "signature": "cn-Al-dw5JPf4b3q0IExv9nG1ZuoXJBjuZ--2d7zwzD0S2Jjza4OApCJEIwtug1KGPBg-Ty1LQJSMWfE8uMxBg" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/planner", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "delegation": { + "parent_record_hash": "sha384:f1a0e81fba0858571d2f85a593040d83ff1ddfdfd5c19c8318783f46f25170402c695c07dbef9507883edf76d7c8f893", + "credential_id": "cred:orchestrator-to-planner" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU" + } + }, + "signature": "dDyHQ26TDFbEwRo_7tbCWTHr3MWB-PJRZaiaKeRDu0Ju-LCGvG5fFNW1RWKBF7WmTEcYiRfiyLLHYIOvyMdTAw" + }, + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1785000000, + "subject": "spiffe://acme.example/agent/orchestrator", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "restricted", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pNTZUXlAITEWbtVbHk6zGRwVD73s0BEakqkKKaFQyZ4" + } + }, + "signature": "m3TAE_Z_chc0gs8HMh1KAN4NECFLP09kiqyQPBDz9w9G0_iYoQ5-I94gpptC1a4xiRloSFLyTdprEmr9CFD_DA" + } + ], + "expected": { + "classification": "unverifiable", + "codes": [ + "digest_algorithm_unsupported" + ] + } +} diff --git a/examples/delegation-link/README.md b/examples/delegation-link/README.md new file mode 100644 index 0000000..c9f72d0 --- /dev/null +++ b/examples/delegation-link/README.md @@ -0,0 +1,77 @@ +# Delegation-link conformance vectors + +Conformance material for the profile proposed in +[`docs/rfcs/a2a-delegation-profile.md`](../../docs/rfcs/a2a-delegation-profile.md). +The `delegation` block is normative in v0.2; the rules for verifying a chain of them are +not, and these vectors exist so the proposal can be argued against executable material. + +Nothing here binds an implementation. It scores one. + +## Running them + +``` +python -m pytest tests/test_delegation_vectors.py tests/test_delegation_completeness.py +``` + +To score an implementation that is not this one, read the vectors directly — each is +self-contained and needs nothing from this repository. + +## What a vector is + +One JSON file, one scenario: + +| Field | | +|---|---| +| `id` | `TRACE-DELEG-NNN`, stable, never reused | +| `context.leaf` | digest of the record under appraisal | +| `context.trusted_root_keys` | JWKs the verifier anchors on | +| `context.credentials` | the delegation credential registry, held out of band | +| `context.data_class_lattice` | least sensitive first; `data_class` is an open string in the schema, so the ordering has to be supplied | +| `context.max_depth` | links the verifier will follow | +| `context.supported_digest_algorithms` | what this verifier can compute | +| `records` | the record set, **emitted leaf-first** | +| `expected` | classification and codes | + +`records` is a set, not a sequence. It is emitted leaf-first precisely so that an +implementation reading `records[0]` as the root fails immediately rather than passing until +its first shuffled input. `tests/test_delegation_vectors.py` re-runs every vector under two +further permutations. + +Every record validates against `schema/trace-claim.json`, including the ones built to fail. + +## Classifications + +Three outcomes, and collapsing any two of them is the nonconformance the split exists to +name: + +- `provenance-invalid` — the chain's structure or signatures are broken +- `authorization-invalid` — the chain is sound and the authority it claims is not +- `unverifiable` — a link this verifier could not read; not a finding against the chain +- `verified` + +## Reproducing them + +``` +python examples/delegation-link/gen_delegation_vectors.py +``` + +Keys derive from one published seed by role label — no secret, fully reissuable by anyone. +`tests/test_generators_reproduce_fixtures.py` regenerates the set into an emptied directory +and compares bytes on every run, with no entry in its `NOT_GENERATED` ledger. + +## Coverage + +Ten rules, two load-bearing vectors each, and for every rule at least one declared +implementation defect that one of its vectors catches and the other misses. +`tests/test_delegation_completeness.py` enforces all three, and records the margins so they +cannot silently thin. + +The pairs are not two views of the same mistake. They are built around a specific shortcut a +real implementation takes — verifying the leaf only, anchoring on any trusted key it finds, +an off-by-one bound, case-insensitive lookup of an opaque identifier, issuer and holder +compared to the wrong ends of the hop, half a validity window, narrowing checked at one hop, +the link algorithm read once and assumed uniform. + +Vector 05 is the one to read first. It is a complete, correctly signed chain whose only +defect is which bytes its link was computed over, and it is the whole of the difference +between two readings of one sentence in `docs/schema.md`. diff --git a/examples/delegation-link/gen_delegation_vectors.py b/examples/delegation-link/gen_delegation_vectors.py new file mode 100644 index 0000000..820201d --- /dev/null +++ b/examples/delegation-link/gen_delegation_vectors.py @@ -0,0 +1,637 @@ +"""Generate delegation-link conformance vectors for the proposed A2A profile. + +The `delegation` block is normative in v0.2: `schema/trace-claim.json` requires +`parent_record_hash` and `credential_id`, and pins the first to a sha256/sha384 +digest string. What a verifier is supposed to *do* with a chain of them is not +normative anywhere. `spec/trace-v0.2.md` never mentions either field; the only +prose is `docs/schema.md`, which says a verifier "walks `parent_record_hash` from +a leaf record back to the root and confirms each hop acted under a credential in +the delegation chain" — one sentence, no rules. `ROADMAP.md` targets the normative +A2A profile at v0.3. + +These vectors encode the rules proposed in `docs/rfcs/a2a-delegation-profile.md`, +so that the proposal can be argued against executable material rather than in the +abstract. Each vector is a complete record set plus the verifier context it must +be judged under, and carries its own expected classification and codes. Nothing +here is normative until the proposal is. + +Three things were settled while building this set, each because a vector could not +be written without settling it. They are the reason the corpus exists at this +stage rather than after the profile is written: + +**The digest preimage.** "Digest of the parent hop's Trust Record" does not say +which bytes. Over the RFC 8785 encoding of the complete record, signature +included, or over the signed body only? The two readings are both natural and +they are not interoperable — a chain built under one is a chain of dangling links +under the other. The profile takes the complete record, because under the other +reading a child's commitment does not bind the parent's *signer*: anyone may +re-sign identical body bytes under a different key and produce a record the child +still points at. Vector 05 is that difference and nothing else. + +**Cycles cannot be built.** A cycle would need A's block to carry a digest of B +while B's carries a digest of A, and each digest covers the block holding the +other, so constructing one is a hash collision. The profile therefore has no +cycle rule and says why; what it does need is a depth bound, because an +*unbounded* chain is constructible and a walk without a limit is a denial of +service. That is `depth_exceeded`, and it is the only reason the bound exists. + +**Only the leaf's signature is independently attackable.** Every ancestor's bytes +are already committed to by its child, so a record cannot be altered in place +without breaking the link that names it. An ancestor with an invalid signature is +still reachable — it just has to be built that way from the start rather than +tampered with afterwards — which is why `sign_key` is a build-time parameter here +and no vector is produced by mutating a finished chain. + +Keys derive from one published seed, per role, so the whole set regenerates +byte-for-byte and a third party can reissue any of it. Public test material with +no secret in it. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Any + +import rfc8785 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +OUT = Path("examples/delegation-link") +V0_2 = "tag:agentrust-io.com,2026:trace-v0.2" +PROFILE = "trace.a2a.delegation-link.v0" +SPEC = "docs/rfcs/a2a-delegation-profile.md" + +#: One published seed; every role key is a labelled derivation from it, so the +#: corpus needs a single secret-free constant to be fully reissuable. The label is +#: part of the preimage rather than a counter, so adding a role later cannot shift +#: an existing role's key. +SEED = hashlib.sha256(b"trace-spec delegation-link fixture key").digest() + +ROLES = ("orchestrator", "planner", "executor", "courier", "auditor", "stranger") + +#: Least sensitive first. `data_class` is an open string in the schema, so an +#: ordering cannot be inferred from a record; it is verifier context, supplied +#: per vector exactly like the trusted key set. +LATTICE = ("public", "internal", "confidential", "restricted") + +#: Delegation hops beyond the root that a verifier will follow. Vector 02 sits +#: exactly on it and must verify; 21 sits one past it. +MAX_DEPTH = 4 + +#: Fixed, so every fixture regenerates identically. Vectors that turn on time +#: move the credential window, never the clock. +NOW = 1785000000 + + +def key_for(role: str) -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(SEED + b"|" + role.encode()).digest() + ) + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def jwk_for(role: str) -> dict[str, str]: + raw = key_for(role).public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + return {"kty": "OKP", "crv": "Ed25519", "x": b64u(raw)} + + +def subject_for(role: str) -> str: + return f"spiffe://acme.example/agent/{role}" + + +def base_record(role: str, *, iat: int, data_class: str) -> dict[str, Any]: + """A schema-valid v0.2 record for `role`, with no delegation block yet. + + Deliberately minimal: every field here is required by the schema, and nothing + optional is present except what a vector adds. A record that carries more than + the rules under test can read is a record whose failures are harder to place. + """ + return { + "eat_profile": V0_2, + "iat": iat, + "subject": subject_for(role), + "model": {"provider": "anthropic", "model_id": "claude-sonnet-4-6"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "00" * 32}, + "policy": { + "bundle_hash": "sha256:" + "aa" * 32, + "enforcement_mode": "enforce", + }, + "data_class": data_class, + "build_provenance": {"slsa_level": 0, "digest": "sha256:" + "bb" * 32}, + "appraisal": {"status": "affirming", "verifier": "https://verifier.example/v1"}, + } + + +def signed(body: dict[str, Any], *, sign_key: str) -> dict[str, Any]: + """Seal `body` under `sign_key`'s private half, declaring `cnf.jwk` separately. + + `cnf.jwk` is set by the caller before this runs when a vector needs the record + to *claim* one key and be signed by another; when it is absent the claimed key + is the signing key, which is the honest case. + """ + record = dict(body) + record.setdefault("cnf", {"jwk": jwk_for(sign_key)}) + record["signature"] = b64u(key_for(sign_key).sign(rfc8785.dumps(record))) + return record + + +def digest(record: dict[str, Any], alg: str = "sha256") -> str: + """The profile's preimage: RFC 8785 bytes of the complete record.""" + return f"{alg}:" + hashlib.new(alg, rfc8785.dumps(record)).hexdigest() + + +def body_digest(record: dict[str, Any], alg: str = "sha256") -> str: + """The rejected reading: the same record with `signature` removed. + + Present only so vector 05 can be built. Nothing in the profile computes this. + """ + body = {k: v for k, v in record.items() if k != "signature"} + return f"{alg}:" + hashlib.new(alg, rfc8785.dumps(body)).hexdigest() + + +def credential( + cid: str, + *, + issuer: str, + holder: str, + not_before: int = NOW - 86400, + not_after: int = NOW + 86400, +) -> tuple[str, dict[str, Any]]: + return cid, { + "issuer": subject_for(issuer), + "holder": subject_for(holder), + "not_before": not_before, + "not_after": not_after, + } + + +def build_chain(hops: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Build a chain root-first, returning the records in root-to-leaf order. + + `hops[0]` is the root and takes no delegation block. Each later hop reads: + + role, data_class, iat — the record's own content + credential_id — what its delegation block names + parent_alg — digest algorithm for the link (default sha256) + sign_key — signer, when it must differ from `role` + claimed_key — what `cnf.jwk` advertises, when it must differ + from the signer + link_over_body — link to the parent's signed body rather than + the complete record (vector 05 only) + + Built rather than mutated: a finished chain cannot be edited anywhere but the + leaf without breaking the link that commits to the edited record, so every + defect involving an ancestor has to exist before its child is signed. + """ + records: list[dict[str, Any]] = [] + for index, hop in enumerate(hops): + body = base_record( + hop["role"], iat=hop.get("iat", NOW), data_class=hop["data_class"] + ) + if index: + parent = records[-1] + alg = hop.get("parent_alg", "sha256") + link = body_digest if hop.get("link_over_body") else digest + body["delegation"] = { + "parent_record_hash": link(parent, alg), + "credential_id": hop["credential_id"], + } + if "claimed_key" in hop: + body["cnf"] = {"jwk": jwk_for(hop["claimed_key"])} + records.append(signed(body, sign_key=hop.get("sign_key", hop["role"]))) + return records + + +def vector( + vid: str, + name: str, + description: str, + records: list[dict[str, Any]], + *, + classification: str, + codes: list[str], + credentials: dict[str, Any], + trusted_roots: tuple[str, ...] = ("orchestrator",), + supported_algorithms: tuple[str, ...] = ("sha256",), + leaf: dict[str, Any] | None = None, + emit: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """One scenario, with everything a third party needs to judge it offline. + + `records` is emitted last-to-first. A verifier that assumes the set arrives in + chain order, or that treats `records[0]` as the root, fails every vector here + rather than passing until the first shuffled input reaches production. + """ + under_appraisal = leaf if leaf is not None else records[-1] + emitted = emit if emit is not None else records + return { + "id": vid, + "name": name, + "description": description, + "spec": SPEC, + "profile": PROFILE, + "context": { + "leaf": digest(under_appraisal), + "now": NOW, + "max_depth": MAX_DEPTH, + "supported_digest_algorithms": supported_algorithms, + "data_class_lattice": list(LATTICE), + "trusted_root_keys": [jwk_for(role) for role in trusted_roots], + "credentials": credentials, + }, + "records": list(reversed(emitted)), + "expected": {"classification": classification, "codes": codes}, + } + + +# --- the credential registry the well-formed chains draw on -------------------- + +CRED_PLANNER = credential("cred:orchestrator-to-planner", issuer="orchestrator", holder="planner") +CRED_EXECUTOR = credential("cred:planner-to-executor", issuer="planner", holder="executor") +CRED_COURIER = credential("cred:executor-to-courier", issuer="executor", holder="courier") +CRED_AUDITOR = credential("cred:courier-to-auditor", issuer="courier", holder="auditor") + +WELL_FORMED = dict([CRED_PLANNER, CRED_EXECUTOR, CRED_COURIER, CRED_AUDITOR]) + +#: Root to leaf, narrowing at every hop, exactly `MAX_DEPTH` delegations deep. +FULL_DEPTH_HOPS = [ + {"role": "orchestrator", "data_class": "restricted"}, + {"role": "planner", "data_class": "restricted", "credential_id": CRED_PLANNER[0]}, + {"role": "executor", "data_class": "confidential", "credential_id": CRED_EXECUTOR[0]}, + {"role": "courier", "data_class": "internal", "credential_id": CRED_COURIER[0]}, + {"role": "auditor", "data_class": "public", "credential_id": CRED_AUDITOR[0]}, +] + +SINGLE_HOP = [ + {"role": "orchestrator", "data_class": "restricted"}, + {"role": "planner", "data_class": "confidential", "credential_id": CRED_PLANNER[0]}, +] + + +def main() -> None: + out: list[tuple[str, dict[str, Any]]] = [] + + def add(filename: str, doc: dict[str, Any]) -> None: + out.append((filename, doc)) + + # -- verified --------------------------------------------------------------- + + add("01-valid-single-hop.json", vector( + "TRACE-DELEG-001", "valid-single-hop", + "One delegation from a trusted root. The shortest chain the profile has " + "anything to say about, and the case every rule below is a departure from.", + build_chain(SINGLE_HOP), + classification="verified", codes=[], credentials=WELL_FORMED, + )) + + add("02-valid-full-depth-out-of-order.json", vector( + "TRACE-DELEG-002", "valid-full-depth-out-of-order", + "Four delegations, exactly on `max_depth`, narrowing at every hop, with " + "the record set emitted leaf-first. Verifies. A verifier whose bound is " + "off by one rejects this, which is the other half of vector 21.", + build_chain(FULL_DEPTH_HOPS), + classification="verified", codes=[], credentials=WELL_FORMED, + )) + + add("03-valid-root-only.json", vector( + "TRACE-DELEG-003", "valid-root-only", + "A record with no delegation block at all. Not a degenerate chain: a root " + "execution is the ordinary case, and a verifier that requires the block to " + "be present has broken every non-delegated record.", + build_chain([FULL_DEPTH_HOPS[0]]), + classification="verified", codes=[], credentials=WELL_FORMED, + )) + + # -- parent_not_found ------------------------------------------------------- + + full = build_chain(FULL_DEPTH_HOPS) + add("04-parent-record-absent.json", vector( + "TRACE-DELEG-004", "parent-record-absent", + "The chain is sound, and the record the third hop names is simply not in " + "the set. Presenting a leaf without the hops that authorise it is the " + "cheapest attack on a chain nobody walks to the end.", + full, + classification="provenance-invalid", codes=["parent_not_found"], + credentials=WELL_FORMED, + emit=[r for i, r in enumerate(full) if i != 2], + )) + + add("05-link-over-signed-body.json", vector( + "TRACE-DELEG-005", "link-over-signed-body", + "Every record is present and correctly signed; the leaf's " + "`parent_record_hash` is a digest of its parent's signed body with " + "`signature` removed, rather than of the complete record. This is the one " + "vector that separates the two readings of \"digest of the parent hop's " + "Trust Record\": under the profile's reading the link resolves to nothing, " + "under the rejected reading the whole chain verifies.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "link_over_body": True}, + ]), + classification="provenance-invalid", codes=["parent_not_found"], + credentials=WELL_FORMED, + )) + + # -- record_signature_invalid ----------------------------------------------- + + add("06-leaf-signed-by-other-key.json", vector( + "TRACE-DELEG-006", "leaf-signed-by-other-key", + "The leaf advertises the executor's key in `cnf.jwk` and is signed by the " + "stranger's. The leaf is the only record in a chain whose bytes nothing " + "else commits to, so it is the only one an attacker can reach in place.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "sign_key": "stranger", "claimed_key": "planner"}, + ]), + classification="provenance-invalid", codes=["record_signature_invalid"], + credentials=WELL_FORMED, + )) + + add("07-intermediate-signed-by-other-key.json", vector( + "TRACE-DELEG-007", "intermediate-signed-by-other-key", + "The same defect one hop up, built in before the child committed to it, so " + "the link still resolves and only the signature is wrong. A verifier that " + "checks the leaf and trusts the rest of the chain because 'the hashes " + "match' passes this and fails vector 06.", + build_chain([ + FULL_DEPTH_HOPS[0], + {**FULL_DEPTH_HOPS[1], "sign_key": "stranger", "claimed_key": "planner"}, + FULL_DEPTH_HOPS[2], + ]), + classification="provenance-invalid", codes=["record_signature_invalid"], + credentials=WELL_FORMED, + )) + + # -- root_key_untrusted ----------------------------------------------------- + + add("08-root-key-untrusted.json", vector( + "TRACE-DELEG-008", "root-key-untrusted", + "A correctly signed, internally consistent single-hop chain whose root is " + "held by a key the verifier was never given. Every hash matches and every " + "signature verifies; the chain is anchored to nothing.", + build_chain([ + {"role": "stranger", "data_class": "restricted"}, + {"role": "planner", "data_class": "confidential", + "credential_id": "cred:stranger-to-planner"}, + ]), + classification="provenance-invalid", codes=["root_key_untrusted"], + credentials=dict([ + credential("cred:stranger-to-planner", issuer="stranger", holder="planner"), + ]), + )) + + add("09-trusted-key-below-the-root.json", vector( + "TRACE-DELEG-009", "trusted-key-below-the-root", + "The root is the stranger again, but this time the *second* record on the " + "chain is held by the trusted orchestrator key. Every hop is sound and the " + "chain is still anchored to nobody: authority does not begin partway up. A " + "verifier that anchors on the highest trusted key it finds, rather than on " + "the record with no delegation block, reports this chain verified — and " + "still fails vector 08, where no record carries a trusted key at all.", + build_chain([ + {"role": "stranger", "data_class": "restricted"}, + {"role": "orchestrator", "data_class": "restricted", + "credential_id": "cred:stranger-to-orchestrator"}, + {"role": "planner", "data_class": "confidential", + "credential_id": CRED_PLANNER[0]}, + ]), + classification="provenance-invalid", codes=["root_key_untrusted"], + credentials=dict([ + credential("cred:stranger-to-orchestrator", issuer="stranger", + holder="orchestrator"), + CRED_PLANNER, + ]), + )) + + # -- credential_unknown ----------------------------------------------------- + + add("10-credential-not-registered.json", vector( + "TRACE-DELEG-010", "credential-not-registered", + "The hop names a credential the verifier holds nothing for. The chain is " + "structurally sound; the authority it claims cannot be looked up.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "credential_id": "cred:never-issued"}, + ]), + classification="authorization-invalid", codes=["credential_unknown"], + credentials=WELL_FORMED, + )) + + add("11-credential-id-case-differs.json", vector( + "TRACE-DELEG-011", "credential-id-case-differs", + "The hop names `CRED:Orchestrator-To-Planner`, differing from the " + "registered id only in case. `credential_id` is an opaque octet string " + "with no case-folding rule, so this is an unknown credential — and a " + "verifier that lowercases before lookup accepts an identifier nobody " + "issued.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "credential_id": "CRED:Orchestrator-To-Planner"}, + ]), + classification="authorization-invalid", codes=["credential_unknown"], + credentials=WELL_FORMED, + )) + + # -- credential_issuer_mismatch --------------------------------------------- + + add("12-credential-issued-by-third-party.json", vector( + "TRACE-DELEG-012", "credential-issued-by-third-party", + "A registered, in-window credential naming the courier as issuer, used on " + "a hop whose parent is the orchestrator. Authority that did not come from " + "the delegating hop is not delegation.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_issuer_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="courier", holder="planner"), + ]), + )) + + add("13-credential-self-issued.json", vector( + "TRACE-DELEG-013", "credential-self-issued", + "The credential names the *holder* as its own issuer. A verifier " + "comparing the issuer against the record under appraisal rather than " + "against its parent reads this as consistent, and grants an agent whatever " + "it wrote down for itself.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_issuer_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="planner", holder="planner"), + ]), + )) + + # -- credential_holder_mismatch --------------------------------------------- + + add("14-credential-held-by-third-party.json", vector( + "TRACE-DELEG-014", "credential-held-by-third-party", + "The credential was issued by the right party to somebody else. Replaying " + "a valid credential issued to a different agent is the attack a holder " + "check exists for.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_holder_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", holder="courier"), + ]), + )) + + add("15-credential-holder-is-the-parent.json", vector( + "TRACE-DELEG-015", "credential-holder-is-the-parent", + "Issuer and holder both name the parent. A verifier that has the two " + "comparisons the wrong way round finds the holder where it expects the " + "issuer, reports agreement, and passes this while failing 14.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_holder_mismatch"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", + holder="orchestrator"), + ]), + )) + + # -- credential_window ------------------------------------------------------ + + add("16-credential-expired-at-hop.json", vector( + "TRACE-DELEG-016", "credential-expired-at-hop", + "The hop executed after its credential's `not_after`. Judged against the " + "hop's own `iat`, not against the verifier's clock: a chain does not " + "become invalid because it is being read late.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_window"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", + holder="planner", not_before=NOW - 7200, not_after=NOW - 3600), + ]), + )) + + add("17-credential-not-yet-valid-at-hop.json", vector( + "TRACE-DELEG-017", "credential-not-yet-valid-at-hop", + "The hop executed before its credential's `not_before`. The other side of " + "the window, and the side an implementation reaches for expiry alone " + "leaves open — a credential dated into the future authorises everything " + "done before it existed.", + build_chain(SINGLE_HOP), + classification="authorization-invalid", codes=["credential_window"], + credentials=dict([ + credential("cred:orchestrator-to-planner", issuer="orchestrator", + holder="planner", not_before=NOW + 3600, not_after=NOW + 7200), + ]), + )) + + # -- data_class_widened ----------------------------------------------------- + + add("18-data-class-widened-at-leaf.json", vector( + "TRACE-DELEG-018", "data-class-widened-at-leaf", + "A hop delegated from an `internal` parent declares `restricted`. " + "Delegation cannot manufacture reach the delegator did not have.", + build_chain([ + {"role": "orchestrator", "data_class": "internal"}, + {"role": "planner", "data_class": "restricted", + "credential_id": CRED_PLANNER[0]}, + ]), + classification="authorization-invalid", codes=["data_class_widened"], + credentials=WELL_FORMED, + )) + + add("19-data-class-widened-mid-chain.json", vector( + "TRACE-DELEG-019", "data-class-widened-mid-chain", + "Widening at the first hop, inside a chain whose leaf is narrower than its " + "root. Comparing the leaf against the root shows narrowing and reports " + "nothing; the rule is per hop, and this is the vector that says so.", + build_chain([ + {"role": "orchestrator", "data_class": "confidential"}, + {"role": "planner", "data_class": "restricted", + "credential_id": CRED_PLANNER[0]}, + {"role": "executor", "data_class": "internal", + "credential_id": CRED_EXECUTOR[0]}, + {"role": "courier", "data_class": "public", + "credential_id": CRED_COURIER[0]}, + ]), + classification="authorization-invalid", codes=["data_class_widened"], + credentials=WELL_FORMED, + )) + + # -- depth_exceeded --------------------------------------------------------- + + deep_hops = FULL_DEPTH_HOPS + [ + {"role": "stranger", "data_class": "public", + "credential_id": "cred:auditor-to-stranger"}, + ] + add("21-depth-one-past-the-bound.json", vector( + "TRACE-DELEG-021", "depth-one-past-the-bound", + "Five delegations against a bound of four. One past, so a verifier " + "comparing with the wrong operator accepts it — and vector 02 sits exactly " + "on the bound and must not be rejected, which pins the comparison from " + "both sides.", + build_chain(deep_hops), + classification="authorization-invalid", codes=["depth_exceeded"], + credentials=dict(list(WELL_FORMED.items()) + [ + credential("cred:auditor-to-stranger", issuer="auditor", holder="stranger"), + ]), + )) + + deeper_hops = deep_hops + [ + {"role": "planner", "data_class": "public", + "credential_id": "cred:stranger-to-planner-deep"}, + ] + add("20-depth-far-past-the-bound.json", vector( + "TRACE-DELEG-020", "depth-far-past-the-bound", + "Six delegations against a bound of four. An implementation whose bound is " + "off by one — or absent, walking until the records run out — is separated " + "from a correct one by 21, not by this; this one is what an unbounded walk " + "looks like when nobody is counting at all.", + build_chain(deeper_hops), + classification="authorization-invalid", codes=["depth_exceeded"], + credentials=dict(list(WELL_FORMED.items()) + [ + credential("cred:auditor-to-stranger", issuer="auditor", holder="stranger"), + credential("cred:stranger-to-planner-deep", issuer="stranger", holder="planner"), + ]), + )) + + # -- digest_algorithm_unsupported ------------------------------------------- + + add("22-leaf-link-uses-sha384.json", vector( + "TRACE-DELEG-022", "leaf-link-uses-sha384", + "The leaf links to its parent by a sha384 digest, which the schema permits " + "and this verifier does not implement. The digest is correct: a verifier " + "that supports sha384 resolves the link and verifies the chain. The " + "outcome is unverifiable, not invalid — nothing here contradicts, it just " + "cannot be read.", + build_chain([ + SINGLE_HOP[0], + {**SINGLE_HOP[1], "parent_alg": "sha384"}, + ]), + classification="unverifiable", codes=["digest_algorithm_unsupported"], + credentials=WELL_FORMED, + )) + + add("23-deep-link-uses-sha384.json", vector( + "TRACE-DELEG-023", "deep-link-uses-sha384", + "The same unreadable link two hops up, with a sha256 link at the leaf. A " + "verifier that inspects the algorithm of the first link and assumes the " + "rest of the chain matches reports this chain verified, having never " + "resolved half of it.", + build_chain([ + FULL_DEPTH_HOPS[0], + {**FULL_DEPTH_HOPS[1], "parent_alg": "sha384"}, + FULL_DEPTH_HOPS[2], + FULL_DEPTH_HOPS[3], + ]), + classification="unverifiable", codes=["digest_algorithm_unsupported"], + credentials=WELL_FORMED, + )) + + for name, doc in sorted(out): + (OUT / name).write_text( + json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/tests/delegation_margins.json b/tests/delegation_margins.json new file mode 100644 index 0000000..f1ed3fb --- /dev/null +++ b/tests/delegation_margins.json @@ -0,0 +1,12 @@ +{ + "credential_holder_mismatch": 2, + "credential_issuer_mismatch": 2, + "credential_unknown": 2, + "credential_window": 2, + "data_class_widened": 2, + "depth_exceeded": 2, + "digest_algorithm_unsupported": 2, + "parent_not_found": 2, + "record_signature_invalid": 2, + "root_key_untrusted": 2 +} diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index 234ca7b..bec5453 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -101,9 +101,23 @@ def _depth_boundary(v: Vector) -> tuple[str, ...]: return (v.boundary,) if v.boundary else () + +def delegation_link() -> list[Vector]: + """The delegation-link set, graded by the same criteria as every other set here. + + Its boundaries are its failure codes: `tests/delegation_margins.json` records the + two-vector margin per code, and `tests/test_delegation_completeness.py` holds each + rule to being load-bearing for both of them, so the default mapping is the set's + own unit rather than an assumption made here. + """ + return _load("delegation-link", + lambda e: e["classification"], lambda e: list(e.get("codes") or [])) + + SETS = { "build-provenance-depth": (build_provenance_depth, _depth_boundary), "canonicalization-boundary": (canonicalization_boundary, None), + "delegation-link": (delegation_link, None), } # Every set must be able to fail both unconditional implementations. A set that diff --git a/tests/test_delegation_completeness.py b/tests/test_delegation_completeness.py new file mode 100644 index 0000000..159b123 --- /dev/null +++ b/tests/test_delegation_completeness.py @@ -0,0 +1,403 @@ +"""Completeness checks over the delegation-link vectors. + +`test_delegation_vectors.py` asks whether the vectors are *correct*. This module +asks whether they are *complete*, by the same five questions +`test_vector_completeness.py` asks of the action-receipt corpus, against the same +floor: two load-bearing vectors per rule, and at least one declared implementation +defect that tells the two apart. + +The method is unchanged and the reason is unchanged. Mutation targets named rule +hooks — a rule is deleted by rebuilding the registry without its entry, or weakened +by substituting its check — never by pattern-matching source text, so the mutation +cannot drift away from the code under test. `DEFECTS` is fail-closed: registering a +rule without declaring what its second vector adds fails this suite, so the +question "what bug would your second vector catch that your first would not?" is +answered when the rule is written rather than after a regression demonstrates it. + +Two of the defects declared below found real faults in the walk while this file was +being written, which is the argument for declaring them at all rather than +asserting margin and stopping: + + `anchor_on_any_trusted_key` is why vector 09 exists in its present form. The + first version put an untrusted root three hops down and no declared defect could + separate it from vector 08 — both were simply "the root is not trusted", twice. + The vector that separates them had to place a *trusted* key partway up the + chain, which is the shortcut an implementation actually takes. + + `off_by_one` on the depth bound could not deviate anything while the walk + repeated the depth comparison in its own break condition. A weakened bound never + got to walk further than a correct one, so both depth vectors moved together + under every mutation. The walk now breaks for one reason — an unresolvable + parent — and terminates on a visited set instead. +""" + +from __future__ import annotations + +import ast +import hashlib +import json +from collections.abc import Callable +from dataclasses import replace +from pathlib import Path + +import pytest +import rfc8785 + +from tests.test_delegation_vectors import ( + CLASSIFICATIONS, + VECTOR_PATHS, + Hop, + Rule, + RULES, + _jwk_key, + _signature_invalid, + verify_chain, +) + +TESTS_DIR = Path(__file__).parent +WALK_MODULE = TESTS_DIR / "test_delegation_vectors.py" +MARGINS_FILE = TESTS_DIR / "delegation_margins.json" + +RULE_CODES = tuple(rule.code for rule in RULES) +VECTORS = [json.loads(path.read_text(encoding="utf-8")) for path in VECTOR_PATHS] +NAMES = [path.stem for path in VECTOR_PATHS] + + +# --------------------------------------------------------------------------- +# Declared defects: one weakened check per rule, minimum +# --------------------------------------------------------------------------- + +Check = Callable[[Hop], bool] + + +def _leaf_only_signature(hop: Hop) -> bool: + """Verify the leaf and take the rest of the chain on the strength of the hashes. + + Tempting because it is nearly right: a parent's bytes really are committed to + by its child, so nothing upstream can be altered in place. What the hashes do + not establish is that an ancestor was ever validly signed at all. + """ + return hop.depth == 0 and _signature_invalid(hop.record) + + +def _anchor_on_any_trusted_key(hop: Hop) -> bool: + """Accept the chain if any record on it carries a trusted key. + + The shape of "we found our own key in there somewhere, so this is ours". + """ + trusted = {_jwk_key(jwk) for jwk in hop.context["trusted_root_keys"]} + present = {_jwk_key(r.get("cnf", {}).get("jwk", {})) for r in hop.index.values()} + return not (trusted & present) + + +def _off_by_one_depth(hop: Hop) -> bool: + """`>` where the bound wanted `>=`, or a counter started at one.""" + return hop.depth > hop.context["max_depth"] + 1 + + +def _first_link_algorithm_only(hop: Hop) -> bool: + """Read the algorithm off the leaf's link and assume the chain is uniform.""" + return hop.depth == 1 and hop.link_algorithm not in hop.context[ + "supported_digest_algorithms" + ] + + +def _resolves_over_signed_body(hop: Hop) -> bool: + """Index parents by the digest of their signed body as well as the record. + + The other reading of "digest of the parent hop's Trust Record" — and an + implementation being liberal about which one it accepts ends up accepting both. + """ + if hop.link_algorithm not in hop.context["supported_digest_algorithms"]: + return False + wanted = hop.delegation["parent_record_hash"] + if wanted in hop.index: + return False + algorithm = hop.link_algorithm + for record in hop.index.values(): + body = {k: v for k, v in record.items() if k != "signature"} + if f"{algorithm}:" + hashlib.new(algorithm, rfc8785.dumps(body)).hexdigest() == wanted: + return False + return True + + +def _case_insensitive_credential_lookup(hop: Hop) -> bool: + """The classic 'be liberal in what you accept', applied to an opaque identifier.""" + wanted = hop.delegation["credential_id"].lower() + return not any(cid.lower() == wanted for cid in hop.context["credentials"]) + + +def _issuer_compared_to_the_record_itself(hop: Hop) -> bool: + """The right comparison against the wrong end of the hop.""" + credential = hop.credential + if credential is None: + return False + return credential["issuer"] != hop.record["subject"] + + +def _holder_compared_to_the_parent(hop: Hop) -> bool: + """Issuer and holder read in the wrong order.""" + credential = hop.credential + if credential is None: + return False + assert hop.parent is not None + return credential["holder"] != hop.parent["subject"] + + +def _expiry_only(hop: Hop) -> bool: + """Half a validity window. The half everyone remembers.""" + credential = hop.credential + if credential is None: + return False + return hop.record["iat"] > credential["not_after"] + + +def _narrowing_checked_at_the_first_hop_only(hop: Hop) -> bool: + """Compare the leaf against its parent and call the chain narrowed.""" + if hop.depth != 1: + return False + assert hop.parent is not None + lattice: list[str] = hop.context["data_class_lattice"] + if hop.record["data_class"] not in lattice or hop.parent["data_class"] not in lattice: + return False + return lattice.index(hop.record["data_class"]) > lattice.index(hop.parent["data_class"]) + + +DEFECTS: dict[str, dict[str, Check]] = { + "record_signature_invalid": {"verifies_the_leaf_only": _leaf_only_signature}, + "root_key_untrusted": {"anchor_on_any_trusted_key": _anchor_on_any_trusted_key}, + "depth_exceeded": {"off_by_one": _off_by_one_depth}, + "digest_algorithm_unsupported": {"first_link_only": _first_link_algorithm_only}, + "parent_not_found": {"resolves_over_signed_body": _resolves_over_signed_body}, + "credential_unknown": {"case_insensitive_lookup": _case_insensitive_credential_lookup}, + "credential_issuer_mismatch": { + "issuer_compared_to_the_record_itself": _issuer_compared_to_the_record_itself + }, + "credential_holder_mismatch": { + "holder_compared_to_the_parent": _holder_compared_to_the_parent + }, + "credential_window": {"expiry_only": _expiry_only}, + "data_class_widened": {"first_hop_only": _narrowing_checked_at_the_first_hop_only}, +} + + +# --------------------------------------------------------------------------- +# Mutation machinery +# --------------------------------------------------------------------------- + + +def _outcomes(rules: tuple[Rule, ...]) -> list[tuple[str, str, tuple[str, ...]]]: + out = [] + for name, vector in zip(NAMES, VECTORS, strict=True): + result = verify_chain(vector, rules) + out.append((name, result.classification, tuple(result.codes))) + return out + + +DECLARED = [ + (name, vector["expected"]["classification"], tuple(sorted(vector["expected"]["codes"]))) + for name, vector in zip(NAMES, VECTORS, strict=True) +] + + +def _without(code: str) -> tuple[Rule, ...]: + return tuple(rule for rule in RULES if rule.code != code) + + +def _weakened(code: str, check: Check) -> tuple[Rule, ...]: + return tuple( + replace(rule, check=check) if rule.code == code else rule for rule in RULES + ) + + +def _deviating(rules: tuple[Rule, ...]) -> set[str]: + """Vector names whose outcome under `rules` departs from their declared block.""" + return { + was[0] for was, now in zip(DECLARED, _outcomes(rules), strict=True) if was != now + } + + +def _margin(code: str) -> set[str]: + return _deviating(_without(code)) + + +# --------------------------------------------------------------------------- +# 0. Guards on the instrument itself +# --------------------------------------------------------------------------- + + +def test_the_unmutated_registry_agrees_with_every_declaration() -> None: + """Without this, every mutation test below measures deviation from a baseline + that already deviates, and a registry that agrees with nothing passes them all.""" + assert _deviating(RULES) == set() + + +def test_registry_is_well_formed() -> None: + assert VECTORS, "no vectors found" + assert len(RULES) >= 10, "the registry lost entries" + assert len(set(RULE_CODES)) == len(RULE_CODES), "duplicate rule codes" + assert all(rule.severity in {"failure", "warning"} for rule in RULES) + assert all(rule.klass in {"provenance", "authorization", "unverifiable"} for rule in RULES) + assert all(rule.path in {"record", "root", "resolve", "link"} for rule in RULES) + + +def test_no_emission_outside_the_registry() -> None: + """Nothing in the walk may emit a code around the registry. + + The registry is the inventory *because* `_evaluate` is the only place a code is + emitted. This walks the module's source and fails on an append to a failure or + warning collection anywhere outside that function, or a literal code passed + into a result's `failures=` / `warnings=` argument. + """ + tree = ast.parse(WALK_MODULE.read_text(encoding="utf-8")) + + enclosing: dict[ast.AST, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + for child in ast.walk(node): + enclosing.setdefault(child, node.name) + + offenders: list[str] = [] + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in {"failures", "warnings"} + and enclosing.get(node) != "_evaluate" + ): + offenders.append(f"{enclosing.get(node, '')}:{node.lineno} append") + if isinstance(node, ast.keyword) and node.arg in {"failures", "warnings"}: + if isinstance(node.value, ast.List): + for element in node.value.elts: + if isinstance(element, ast.Constant) and isinstance(element.value, str): + offenders.append( + f"{enclosing.get(node, '')}: literal " + f"{element.value!r} in {node.arg}=" + ) + + assert not offenders, ( + "codes emitted outside the registry, which would make the rule inventory " + f"incomplete without failing anything: {offenders}" + ) + + +def test_registry_codes_are_literals() -> None: + """A code built from a variable or an f-string is invisible to every reader and + to the cross-reference table in the RFC.""" + tree = ast.parse(WALK_MODULE.read_text(encoding="utf-8")) + constructions = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "Rule" + ] + assert len(constructions) == len(RULES), ( + "the registry is not built from literal Rule(...) constructions in this module" + ) + assert all( + node.args and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + for node in constructions + ), "a rule code is not a string literal" + + +# --------------------------------------------------------------------------- +# 1-2. Dead expectations, unexercised rules, unreached outcomes +# --------------------------------------------------------------------------- + + +def test_no_vector_expects_a_code_the_registry_cannot_emit() -> None: + declared = {code for vector in VECTORS for code in vector["expected"]["codes"]} + unknown = sorted(declared - set(RULE_CODES)) + assert not unknown, f"vectors expect codes no rule emits: {unknown}" + + +def test_every_registered_rule_is_exercised_by_some_vector() -> None: + declared = {code for vector in VECTORS for code in vector["expected"]["codes"]} + idle = sorted(set(RULE_CODES) - declared) + assert not idle, f"registered rules no vector exercises: {idle}" + + +def test_every_declared_outcome_is_reached_by_some_vector() -> None: + reached = {vector["expected"]["classification"] for vector in VECTORS} + assert reached == CLASSIFICATIONS, ( + f"classifications the walk can return that no vector produces: " + f"{sorted(CLASSIFICATIONS - reached)}" + ) + + +# --------------------------------------------------------------------------- +# 3-4. Margin and independence +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("code", RULE_CODES) +def test_each_rule_is_load_bearing_for_two_vectors(code: str) -> None: + """Deleting an obligation must change at least two vectors' outcomes. + + One is existence; two is margin. With a single load-bearing vector, any change + that weakens or retires that vector silently removes the rule's coverage. + """ + margin = _margin(code) + assert len(margin) >= 2, ( + f"removing rule {code!r} changed {len(margin)} vector outcome(s) " + f"({sorted(margin)}). Two independent vectors are required per rule." + ) + + +def test_every_rule_declares_a_defect() -> None: + missing = sorted(set(RULE_CODES) - set(DEFECTS)) + assert not missing, f"registered rules with no declared defect variant: {missing}" + stale = sorted(set(DEFECTS) - set(RULE_CODES)) + assert not stale, f"defects declared for rules that no longer exist: {stale}" + + +@pytest.mark.parametrize("code", RULE_CODES) +def test_vectors_for_each_rule_are_independent(code: str) -> None: + """#124's criterion, executed: some single defect separates the rule's vectors.""" + bearing = _margin(code) + if len(bearing) < 2: + pytest.fail(f"rule {code!r} lacks two load-bearing vectors; the margin test covers this") + + separations = {} + for name, weakened_check in DEFECTS[code].items(): + caught = _deviating(_weakened(code, weakened_check)) & bearing + if 0 < len(caught) < len(bearing): + separations[name] = sorted(caught) + + assert separations, ( + f"no declared defect separates the vectors for {code!r}: every weakening " + f"either fools all of {sorted(bearing)} or none of them. The vectors are " + "mutually redundant — author one that catches a defect the others miss, or " + "declare a defect that tells them apart." + ) + + +# --------------------------------------------------------------------------- +# 5. The ratchet +# --------------------------------------------------------------------------- + + +def test_margins_have_not_thinned() -> None: + """A ratchet above the floor. The floor is two.""" + current = {code: len(_margin(code)) for code in RULE_CODES} + + if not MARGINS_FILE.exists(): + MARGINS_FILE.write_text(json.dumps(current, indent=2, sort_keys=True) + "\n") + pytest.skip(f"recorded initial margins to {MARGINS_FILE.name}; re-run to enforce") + + recorded: dict[str, int] = json.loads(MARGINS_FILE.read_text(encoding="utf-8")) + thinned = { + key: (recorded[key], current[key]) + for key in recorded + if key in current and current[key] < recorded[key] + } + assert not thinned, ( + "coverage thinned for: " + + ", ".join(f"{k} {was}->{now}" for k, (was, now) in sorted(thinned.items())) + + ". Lowering a recorded margin is a decision to make on purpose, in this " + "commit, with a reason." + ) diff --git a/tests/test_delegation_vectors.py b/tests/test_delegation_vectors.py new file mode 100644 index 0000000..502b3b6 --- /dev/null +++ b/tests/test_delegation_vectors.py @@ -0,0 +1,559 @@ +"""Run the delegation-link corpus through a reference walk of the proposed A2A profile. + +The `delegation` block is normative in v0.2; what a verifier does with a chain of +them is not, and `docs/rfcs/a2a-delegation-profile.md` is the proposal that would +make it so. This module is that proposal executed: every rule below corresponds to +one numbered requirement in the RFC, and every vector under +`examples/delegation-link/` declares the outcome it expects before this code runs. + +The registry is the same shape as `test_action_receipt_fixtures.RULES`, for the +same reason: a check that is not registered never runs, so it cannot exist quietly +outside the inventory that `test_delegation_completeness.py` mutates. Nothing here +is exported from `agentrust_trace` — the package gains no public API for rules +that are not yet normative. + +Two orderings in the walk carry weight and are not incidental: + + `parent_not_found` cannot fire while the link's digest algorithm is one this + verifier does not implement. Reporting a chain invalid because of a link nobody + looked at is the downgrade-to-escape that `docs/verification.md` forbids in the + other direction: evidence that does not resolve is unverifiable, and only + evidence that resolves and contradicts is a failure. + + Provenance outranks authorization in the classification. A chain whose structure + is broken has no established parent to judge a credential against, so reporting + an authorization failure over it would be describing a relationship that was + never demonstrated. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from agentrust_trace.validate import iter_errors + +VECTOR_DIR = Path(__file__).resolve().parents[1] / "examples" / "delegation-link" + +CLASSIFICATIONS = frozenset( + {"verified", "provenance-invalid", "authorization-invalid", "unverifiable"} +) +"""Every outcome the walk can return. ca2a's Group 7 three-way classification with +`unverifiable` kept distinct from both invalid kinds; collapsing any two of these +is the nonconformance the classification exists to name.""" + + +@dataclass(frozen=True) +class ChainResult: + classification: str + failures: list[str] + warnings: list[str] + depth: int + + def __post_init__(self) -> None: + assert self.classification in CLASSIFICATIONS, ( + f"undeclared outcome {self.classification!r}" + ) + + @property + def codes(self) -> list[str]: + return sorted(set(self.failures) | set(self.warnings)) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _canonical(value: dict[str, Any]) -> bytes: + return rfc8785.dumps(value) + + +def _digest(record: dict[str, Any], alg: str) -> str: + """The profile's preimage: the complete record, signature included. + + The alternative reading — the signed body alone — is what vector 05 is built + on, and the RFC states why it is rejected: a body digest does not bind the + parent's signer, so anyone may re-sign identical bytes and satisfy the child's + commitment. + """ + return f"{alg}:" + hashlib.new(alg, _canonical(record)).hexdigest() + + +def _decode_b64u(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _signature_invalid(record: dict[str, Any]) -> bool: + """Does `record` fail verification under the key it advertises in `cnf.jwk`? + + The claimed key, not a trusted one: whether the key is trusted is a separate + question with its own rule, and merging them produces a verifier that cannot + tell "forged" from "signed by someone you have not heard of".""" + jwk = record.get("cnf", {}).get("jwk", {}) + if jwk.get("kty") != "OKP" or jwk.get("crv") != "Ed25519" or "x" not in jwk: + return True + body = {k: v for k, v in record.items() if k != "signature"} + try: + Ed25519PublicKey.from_public_bytes(_decode_b64u(jwk["x"])).verify( + _decode_b64u(record["signature"]), _canonical(body) + ) + except (InvalidSignature, ValueError, KeyError): + return True + return False + + +def _jwk_key(jwk: dict[str, Any]) -> tuple[Any, ...]: + """A hashable identity for a JWK, over key material only. + + `kid` and other members are advisory; two records naming the same curve point + hold the same key whatever else they say about it.""" + return (jwk.get("kty"), jwk.get("crv"), jwk.get("x"), jwk.get("y")) + + +# --------------------------------------------------------------------------- +# The rule registry +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Hop: + """One position in the walk, with everything a rule may read. + + `parent` is populated only on the `link` path, after the parent record has + actually been resolved. A rule that needs the parent and runs before it exists + would be reasoning about a hop that was never demonstrated.""" + + record: dict[str, Any] + context: dict[str, Any] + index: dict[str, dict[str, Any]] + depth: int + parent: dict[str, Any] | None = None + + @property + def delegation(self) -> dict[str, Any]: + return self.record["delegation"] + + @property + def link_algorithm(self) -> str: + return self.delegation["parent_record_hash"].split(":", 1)[0] + + @property + def credential(self) -> dict[str, Any] | None: + return self.context["credentials"].get(self.delegation["credential_id"]) + + +@dataclass(frozen=True) +class Rule: + """One named obligation. ``check`` returns True when the defect it guards + against is observed at this hop — True means the code is emitted.""" + + code: str + severity: str # "failure" | "warning" + klass: str # "provenance" | "authorization" | "unverifiable" + path: str # "record" | "root" | "resolve" | "link" + check: Callable[[Hop], bool] = field(compare=False) + + +def _record_signature_invalid(hop: Hop) -> bool: + return _signature_invalid(hop.record) + + +def _root_key_untrusted(hop: Hop) -> bool: + trusted = {_jwk_key(j) for j in hop.context["trusted_root_keys"]} + return _jwk_key(hop.record.get("cnf", {}).get("jwk", {})) not in trusted + + +def _depth_exceeded(hop: Hop) -> bool: + return hop.depth > hop.context["max_depth"] + + +def _digest_algorithm_unsupported(hop: Hop) -> bool: + return hop.link_algorithm not in hop.context["supported_digest_algorithms"] + + +def _parent_not_found(hop: Hop) -> bool: + # Guarded on support: a link this verifier cannot compute is unread, not + # broken, and saying "not found" about it would be a finding nobody made. + if _digest_algorithm_unsupported(hop): + return False + return hop.delegation["parent_record_hash"] not in hop.index + + +def _credential_unknown(hop: Hop) -> bool: + return hop.credential is None + + +def _credential_issuer_mismatch(hop: Hop) -> bool: + cred = hop.credential + if cred is None: + return False + assert hop.parent is not None + return cred["issuer"] != hop.parent["subject"] + + +def _credential_holder_mismatch(hop: Hop) -> bool: + cred = hop.credential + if cred is None: + return False + return cred["holder"] != hop.record["subject"] + + +def _credential_window(hop: Hop) -> bool: + cred = hop.credential + if cred is None: + return False + # Judged at the hop's own `iat`. A chain does not expire because it is being + # read late, and a verifier using its own clock reports a different answer + # every day for the same evidence. + return not (cred["not_before"] <= hop.record["iat"] <= cred["not_after"]) + + +def _data_class_widened(hop: Hop) -> bool: + assert hop.parent is not None + lattice: list[str] = hop.context["data_class_lattice"] + if hop.record["data_class"] not in lattice or hop.parent["data_class"] not in lattice: + # A class outside the supplied ordering is not comparable. It is not this + # rule's business to invent a position for it. + return False + return lattice.index(hop.record["data_class"]) > lattice.index(hop.parent["data_class"]) + + +RULES: tuple[Rule, ...] = ( + # -- every record on the chain ---------------------------------------------- + Rule("record_signature_invalid", "failure", "provenance", "record", + _record_signature_invalid), + # -- the record with no delegation block ------------------------------------- + Rule("root_key_untrusted", "failure", "provenance", "root", _root_key_untrusted), + # -- following a link, before the parent is known ---------------------------- + Rule("depth_exceeded", "failure", "authorization", "resolve", _depth_exceeded), + Rule("digest_algorithm_unsupported", "warning", "unverifiable", "resolve", + _digest_algorithm_unsupported), + Rule("parent_not_found", "failure", "provenance", "resolve", _parent_not_found), + # -- the hop, once its parent has been resolved ------------------------------ + Rule("credential_unknown", "failure", "authorization", "link", _credential_unknown), + Rule("credential_issuer_mismatch", "failure", "authorization", "link", + _credential_issuer_mismatch), + Rule("credential_holder_mismatch", "failure", "authorization", "link", + _credential_holder_mismatch), + Rule("credential_window", "failure", "authorization", "link", _credential_window), + Rule("data_class_widened", "failure", "authorization", "link", _data_class_widened), +) + + +def _evaluate( + hop: Hop, rules: Sequence[Rule], path: str +) -> tuple[list[str], list[str]]: + """The single point where rule codes are emitted. + + Everything the walk reports flows through this loop, which is what makes the + registry authoritative. `test_delegation_completeness` asserts by AST that no + other function in this module appends to a failure or warning list. + """ + failures: list[str] = [] + warnings: list[str] = [] + for rule in rules: + if rule.path == path and rule.check(hop): + (failures if rule.severity == "failure" else warnings).append(rule.code) + return failures, warnings + + +# --------------------------------------------------------------------------- +# The walk +# --------------------------------------------------------------------------- + + +def verify_chain(vector: dict[str, Any], rules: Sequence[Rule] = RULES) -> ChainResult: + """Walk `vector` from its designated leaf towards the root. + + The record set is a set: it arrives in no useful order, the leaf is named by + digest rather than by position, and a parent is found by looking its digest up + rather than by taking the next element. An implementation that reads + `records[0]` as the root agrees with this one on nothing. + + No cycle check, deliberately. A cycle needs each record's delegation block to + carry a digest covering the block that names it back, which is a hash + collision; the reachable analogue is an unbounded chain, and that is what + `depth_exceeded` is for. The RFC states this rather than leaving a reader to + wonder which of the two was forgotten. + """ + context = vector["context"] + records = vector["records"] + + index: dict[str, dict[str, Any]] = {} + for algorithm in context["supported_digest_algorithms"]: + for record in records: + index[_digest(record, algorithm)] = record + + current = index.get(context["leaf"]) + assert current is not None, ( + f"{vector['id']}: the record under appraisal is not in the vector's own " + "record set, or is not addressable under a supported digest algorithm" + ) + + failures: list[str] = [] + warnings: list[str] = [] + depth = 0 + visited: set[int] = set() + + while True: + hop = Hop(record=current, context=context, index=index, depth=depth) + + found, warned = _evaluate(hop, rules, "record") + failures += found + warnings += warned + + if "delegation" not in current: + found, warned = _evaluate(hop, rules, "root") + failures += found + warnings += warned + break + + depth += 1 + hop = replace(hop, depth=depth) + found, warned = _evaluate(hop, rules, "resolve") + failures += found + warnings += warned + + # Whether the walk can continue is a structural fact about the records, + # asked here directly. The registry says *why* a link could not be + # followed; it does not decide whether one was. Deriving the control flow + # from "did any rule fire" instead couples the walk to the registry's + # contents, and the completeness suite's whole method is to run this + # function with rules removed — under which that walk stepped off the end + # of the index and raised, rather than reporting a changed outcome. + # One reason only: the parent could not be resolved. The depth bound is + # deliberately *not* repeated here. Repeating it stops the walk at the same + # record whether or not `depth_exceeded` is registered, so a weakened bound + # never gets to walk further than a correct one, and the two depth vectors + # move together under every mutation — margin without independence, which + # is exactly what #124 says a second vector must not be. + parent = None + if hop.link_algorithm in context["supported_digest_algorithms"]: + parent = index.get(current["delegation"]["parent_record_hash"]) + if parent is None: + break + + hop = replace(hop, parent=parent) + found, warned = _evaluate(hop, rules, "link") + failures += found + warnings += warned + + # Not a conformance rule, and it cannot fire on a chain anyone can build: + # a cycle would need a record's delegation block to hold a digest of the + # block that names it back. It is here so that this function terminates on + # any input at all, including one hand-edited into a shape the hashes + # forbid, rather than terminating because the corpus happens to be honest. + if id(parent) in visited: + break + visited.add(id(parent)) + + current = parent + + if any(rule.klass == "provenance" for rule in rules if rule.code in failures): + classification = "provenance-invalid" + elif failures: + classification = "authorization-invalid" + elif warnings: + classification = "unverifiable" + else: + classification = "verified" + + return ChainResult( + classification=classification, + failures=failures, + warnings=warnings, + depth=depth, + ) + + +# --------------------------------------------------------------------------- +# The conformance run +# --------------------------------------------------------------------------- + +VECTOR_PATHS = sorted(VECTOR_DIR.glob("*.json")) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def test_vector_set_is_complete() -> None: + """A glob that silently loses a file passes every test parametrised on it.""" + assert [path.name for path in VECTOR_PATHS] == [ + "01-valid-single-hop.json", + "02-valid-full-depth-out-of-order.json", + "03-valid-root-only.json", + "04-parent-record-absent.json", + "05-link-over-signed-body.json", + "06-leaf-signed-by-other-key.json", + "07-intermediate-signed-by-other-key.json", + "08-root-key-untrusted.json", + "09-trusted-key-below-the-root.json", + "10-credential-not-registered.json", + "11-credential-id-case-differs.json", + "12-credential-issued-by-third-party.json", + "13-credential-self-issued.json", + "14-credential-held-by-third-party.json", + "15-credential-holder-is-the-parent.json", + "16-credential-expired-at-hop.json", + "17-credential-not-yet-valid-at-hop.json", + "18-data-class-widened-at-leaf.json", + "19-data-class-widened-mid-chain.json", + "20-depth-far-past-the-bound.json", + "21-depth-one-past-the-bound.json", + "22-leaf-link-uses-sha384.json", + "23-deep-link-uses-sha384.json", + ] + + +def test_vector_ids_match_their_filenames() -> None: + """The id is the cross-reference surface and the filename is how anyone finds + the file; a vector renumbered in one and not the other is citable under a name + that leads somewhere else.""" + for path in VECTOR_PATHS: + number = int(path.stem.split("-", 1)[0]) + assert _load(path)["id"] == f"TRACE-DELEG-{number:03d}", path.name + + +def test_every_vector_is_emitted_leaf_first() -> None: + """Not decoration. The set is emitted in the order least likely to be right for + an implementation that reads position, so that such an implementation fails on + the first vector rather than on the first shuffled input in production.""" + for path in VECTOR_PATHS: + vector = _load(path) + assert _digest(vector["records"][0], "sha256") == vector["context"]["leaf"], ( + f"{path.name}: the first emitted record is not the leaf" + ) + + +def test_each_vector_holds_exactly_one_root() -> None: + """Two roots is two chains, and a walk that reaches either would be judging a + record set nobody meant to present. Vector 04 drops a record from the middle, + which leaves the root intact and the chain broken — the defect it is for.""" + for path in VECTOR_PATHS: + vector = _load(path) + roots = [r for r in vector["records"] if "delegation" not in r] + assert len(roots) == 1, f"{path.name}: {len(roots)} records without a delegation block" + + +def test_the_trusted_key_placement_that_separates_08_from_09() -> None: + """Both vectors are 'the root is not trusted', and only one of them catches an + implementation that anchors on any trusted key it can find. That difference is + a property of where the keys sit, not of the walk, so it is pinned here: 09 has + a trusted key below its root and 08 has none anywhere. Lose either half and the + pair collapses into two copies of one test.""" + for name, trusted_key_present in ( + ("08-root-key-untrusted.json", False), + ("09-trusted-key-below-the-root.json", True), + ): + vector = _load(VECTOR_DIR / name) + trusted = {_jwk_key(jwk) for jwk in vector["context"]["trusted_root_keys"]} + held = {_jwk_key(r.get("cnf", {}).get("jwk", {})) for r in vector["records"]} + root = next(r for r in vector["records"] if "delegation" not in r) + assert _jwk_key(root["cnf"]["jwk"]) not in trusted, f"{name}: the root is trusted" + assert bool(trusted & held) is trusted_key_present, ( + f"{name}: expected trusted key present={trusted_key_present} on the chain" + ) + + +def test_vector_ids_are_unique_and_ordered() -> None: + """IDs are the cross-reference surface with ca2a's ACTION-* set and are never + reused. A duplicate would silently retire whichever case is read second.""" + ids = [_load(path)["id"] for path in VECTOR_PATHS] + assert len(set(ids)) == len(ids), "duplicate vector id" + assert ids == sorted(ids), "vector ids do not follow file order" + + +@pytest.mark.parametrize("path", VECTOR_PATHS, ids=lambda p: p.stem) +def test_every_record_is_schema_valid(path: Path) -> None: + """A defect the schema already rejects is not a profile defect. + + Every record in every vector — including the ones built to fail — validates + against `trace-claim.json`. Otherwise a rule here could be "passing" only + because its vector is malformed in some louder, unrelated way. + """ + vector = _load(path) + for position, record in enumerate(vector["records"]): + errors = list(iter_errors(record)) + assert not errors, ( + f"{vector['id']} record {position} is not schema-valid: " + f"{[e.message for e in errors][:3]}" + ) + + +@pytest.mark.parametrize("path", VECTOR_PATHS, ids=lambda p: p.stem) +def test_vector_reaches_its_declared_outcome(path: Path) -> None: + vector = _load(path) + result = verify_chain(vector) + expected = vector["expected"] + assert result.classification == expected["classification"], ( + f"{vector['id']} ({vector['name']}): expected " + f"{expected['classification']}, got {result.classification} " + f"with {result.codes}" + ) + assert result.codes == sorted(expected["codes"]), ( + f"{vector['id']} ({vector['name']}): expected codes " + f"{sorted(expected['codes'])}, got {result.codes}" + ) + + +@pytest.mark.parametrize("path", VECTOR_PATHS, ids=lambda p: p.stem) +def test_outcome_is_independent_of_record_order(path: Path) -> None: + """The record set is a set. + + Every vector is emitted leaf-first already, so this reverses it back to + root-first and rotates it, and asserts the walk is unmoved. A verifier that + reads position — `records[0]` as the root, or the next element as the parent — + passes the corpus in one ordering and fails in another, and which one it met + first is not a property of the implementation. + """ + vector = _load(path) + baseline = verify_chain(vector) + records = vector["records"] + for permutation in (list(reversed(records)), records[1:] + records[:1]): + shuffled = {**vector, "records": permutation} + assert verify_chain(shuffled).codes == baseline.codes + assert verify_chain(shuffled).classification == baseline.classification + + +def test_the_corpus_exercises_every_classification() -> None: + """Three-way classification is the contract; a corpus that never produces one + of the outcomes cannot tell a verifier that collapses it from one that does.""" + produced = {verify_chain(_load(path)).classification for path in VECTOR_PATHS} + assert produced == CLASSIFICATIONS, f"never produced: {sorted(CLASSIFICATIONS - produced)}" + + +def test_the_rejected_digest_reading_is_what_vector_05_isolates() -> None: + """Vector 05's whole value is that it separates two readings of one sentence. + + If the leaf's link happened to resolve under the profile's reading too, the + vector would be testing nothing and no one would notice, because it would + still report `parent_not_found` for some other reason. This pins the + counterfactual: recomputing the link over the parent's signed body finds the + parent exactly, which is what makes the vector a boundary rather than a + coincidence. + """ + vector = _load(VECTOR_DIR / "05-link-over-signed-body.json") + leaf = next(r for r in vector["records"] if "delegation" in r) + parent = next(r for r in vector["records"] if "delegation" not in r) + claimed = leaf["delegation"]["parent_record_hash"] + + body = {k: v for k, v in parent.items() if k != "signature"} + body_digest = "sha256:" + hashlib.sha256(_canonical(body)).hexdigest() + + assert claimed == body_digest, "vector 05 no longer links over the signed body" + assert claimed != _digest(parent, "sha256"), ( + "the two readings agree on this vector, which makes it a boundary that " + "does not separate anything" + )