Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

Two cross-references that predate the renumbering which introduced section 3.3.1 are updated to name section 3.3.2, where the text they cite now lives.

- **`examples/delegation-link/` closes the canonicalization gap spec section 3.1.3 names.** That section states the corpus was entirely ASCII, so it could not discriminate an implementation that canonicalizes the parent-record digest by RFC 8785 (UTF-16 code-unit key order) from one that takes the code-point shortcut `sort_keys=True` takes in several JSON libraries, since the two agree everywhere except where an object key holds a supplementary-plane character. `24-parent-key-supplementary-plane.json` is that vector: the root's `cnf.jwk` carries an additional member keyed outside the Basic Multilingual Plane (permitted by `schema/trace-claim.json`'s `additionalProperties` on `cnf.jwk`, covered by the chain digest per section 3.1.3), and the leaf's `parent_record_hash` is reachable only under the correct ordering. A code-point canonicalizer computes a different digest for the same root and reports `parent_not_found` on a chain that is otherwise vector 01's. `docs/rfcs/a2a-delegation-profile.md` and the corpus's own README are updated to 24 vectors; the profile's ten rules and their margins are unchanged, since this vector tests the shared digest primitive rather than any one rule.

### Fixed

- **`runtime.platform`'s description now reads its two situations as examples rather than as the whole set.** #234 aligned the schema description with spec 3.1.1, whose lead clause is "no hardware root of trust" and whose enumeration names a development-mode execution and a record assembled from evidence produced outside the runtime. A production runtime emitting for its own executions, `origin.kind: self`, with no TEE anywhere in its stack, is covered by the lead clause and named by neither example, so the enumeration read as exhaustive and narrowed the rule again in a smaller way. "For example" restores the reading without adding a case or diverging from `docs/schema.md`. Both schema copies carry it. Raised by @chernistry on [#232](https://github.com/agentrust-io/trace-spec/issues/232) and on [#234](https://github.com/agentrust-io/trace-spec/pull/234).
Expand Down
19 changes: 17 additions & 2 deletions docs/rfcs/a2a-delegation-profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
**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.
**Conformance material: ** `examples/delegation-link/`: 24 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
Expand Down Expand Up @@ -183,10 +183,25 @@ 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,
`examples/delegation-link/` holds 24 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.

Twenty of the twenty-four score the ten rules above, two per rule with a
declared implementation defect that one vector catches and the other misses.
Three more (`01` to `03`) are clean chains, expected to verify with no rule
firing. The remaining vector, `24-parent-key-supplementary-plane.json`, is
not a rule vector: it targets the digest preimage itself, over which every
rule is evaluated. Spec section 3.1.3 names the gap this vector closes: the
corpus was entirely ASCII, under which RFC 8785's UTF-16 key ordering and
a code-point ordering (`sort_keys=True` in several JSON libraries) agree, so
nothing in the set could tell an implementation that canonicalizes correctly
apart from one that takes the code-point shortcut. This vector's parent
record carries a `cnf.jwk` member keyed outside the Basic Multilingual
Plane, where the two orderings disagree; a canonicalizer that takes the
shortcut computes the wrong digest for it and reports `parent_not_found` on
a chain every other vector here shows verifying.

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
Expand Down
134 changes: 134 additions & 0 deletions examples/delegation-link/24-parent-key-supplementary-plane.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
{
"id": "TRACE-DELEG-024",
"name": "parent-key-supplementary-plane",
"description": "The root's `cnf.jwk` carries two additional RFC 7517 members, keyed by a BMP private-use character and by a character outside the Basic Multilingual Plane. Section 3.1.3 states that RFC 8785's UTF-16 code-unit key order and code-point order agree everywhere except here, and that this corpus was entirely ASCII and so could not separate an implementation that canonicalizes correctly from one that sorts by code point instead. This is that vector: the leaf's `parent_record_hash` is the root's digest under UTF-16 order. A verifier that sorts by code point computes a different digest for the same root, fails to resolve the link, and reports `parent_not_found` on a chain that is otherwise exactly vector 01's.",
"spec": "docs/rfcs/a2a-delegation-profile.md",
"profile": "trace.a2a.delegation-link.v0",
"context": {
"leaf": "sha256:96b9e835bb6c8d54db70276fa115b28cdd3790ed6657636fa0fcaa68a683f540",
"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:04e312eb2af4b55b8b7b138e8f1c9e077c6066b401ec9bc33702374454375ccd",
"credential_id": "cred:orchestrator-to-planner"
},
"cnf": {
"jwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "W2BsRtHZW8BVdiCsCxrNxvv4LOfgBhuS_2v7TU7TyHU"
}
},
"signature": "mw9DO_IIbFDAq2tXsZAwOsLyoEfhVdPBdfTS-2D-8hmj8dXBLCp3gIkys4JBaUPKyrvPZHRqSmZ8Zm29HXl8DA"
},
{
"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",
"": "bmp-private-use",
"😀": "supplementary-plane"
}
},
"signature": "ozc4srGxEI1VCqR5VmlMr82GwvZUrp6dbKoB8P5BiMuUf829rZF31rvtJ3K5kxg-Shats2qMxJgPDWDL9sI1DQ"
}
],
"expected": {
"classification": "verified",
"codes": []
}
}
8 changes: 8 additions & 0 deletions examples/delegation-link/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,11 @@ 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`.

Vector 24 is a different kind of vector: not a rule pair, but the corpus's answer to a gap
named directly in `spec/trace-v0.2.md` §3.1.3. Every other vector here is ASCII, so nothing
in the set could tell an implementation that canonicalizes a parent record by RFC 8785
(UTF-16 code-unit key order) apart from one that canonicalizes it by code point, the way
`json.dumps(sort_keys=True)` does — the two agree on every prior vector and disagree on this
one. `tests/test_delegation_parent_hash_canonicalization.py` recomputes that divergence
directly rather than trusting this description of it.
63 changes: 63 additions & 0 deletions examples/delegation-link/gen_delegation_vectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@
tampered with afterwards — which is why `sign_key` is a build-time parameter here
and no vector is produced by mutating a finished chain.

**The digest is over UTF-16 code-unit key order, not code-point order.** Section
3.1.3 states that the two orderings agree across the Basic Multilingual Plane and
diverge only once an object key contains a supplementary-plane character, and that
every vector in this corpus was ASCII and so could not tell an implementation that
takes the code-point shortcut from one that computes RFC 8785 correctly. Vector 24
is the vector that spec paragraph names as missing: its root's `cnf.jwk` carries
extra members keyed by a BMP private-use character and by a character outside the
Basic Multilingual Plane, which sort in opposite relative order under the two
schemes. The chain is otherwise exactly vector 01's.

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.
Expand Down Expand Up @@ -206,6 +216,24 @@ def build_chain(hops: list[dict[str, Any]]) -> list[dict[str, Any]]:
}
if "claimed_key" in hop:
body["cnf"] = {"jwk": jwk_for(hop["claimed_key"])}
if "jwk_extra" in hop:
# RFC 7517 members `cnf.jwk` carries beyond `kty`/`crv`/`x`/`y`.
# `additionalProperties` on `cnf.jwk` in schema/trace-claim.json
# allows them, section 3.2.2 puts them under the signature, and
# section 3.1.3 puts them under the chain digest. `_jwk_key` reads
# only the four named fields, so they never change which key this
# is for `root_key_untrusted` or for `claimed_key`'s comparison
# provided they do not shadow those four names, which would make
# this hop claim different key material rather than the same
# material plus extra members, so that is asserted rather than
# left to be noticed in a diff of the emitted JWK.
assert not set(hop["jwk_extra"]) & {"kty", "crv", "x", "y"}, (
"jwk_extra must not shadow key material"
)
base = body.get("cnf", {}).get("jwk") or jwk_for(
hop.get("claimed_key", hop.get("sign_key", hop["role"]))
)
body["cnf"] = {"jwk": {**base, **hop["jwk_extra"]}}
records.append(signed(body, sign_key=hop.get("sign_key", hop["role"])))
return records

Expand Down Expand Up @@ -275,6 +303,22 @@ def vector(
{"role": "planner", "data_class": "confidential", "credential_id": CRED_PLANNER[0]},
]

#: The near-miss section 3.1.3 describes: a BMP private-use character (single
#: UTF-16 code unit U+E000) and a character outside the Basic Multilingual Plane
#: (U+1F600, a surrogate pair whose leading unit is U+D83D). RFC 8785 orders by
#: UTF-16 code unit, so the surrogate-pair key sorts first (0xD83D < 0xE000);
#: Python's `sorted()` and `json.dumps(sort_keys=True)` order by code point, so
#: the private-use key sorts first instead (0xE000 < 0x1F600). Same two members,
#: opposite relative order, one of them wrong.
SUPPLEMENTARY_PLANE_EXTRA = {
"\ue000": "bmp-private-use",
"\U0001f600": "supplementary-plane",
}

SUPPLEMENTARY_PLANE_HOPS = [
{**SINGLE_HOP[0], "jwk_extra": SUPPLEMENTARY_PLANE_EXTRA},
SINGLE_HOP[1],
]

def main() -> None:
out: list[tuple[str, dict[str, Any]]] = []
Expand Down Expand Up @@ -626,6 +670,25 @@ def add(filename: str, doc: dict[str, Any]) -> None:
credentials=WELL_FORMED,
))

# -- canonicalization: UTF-16 code-unit key order, not code-point order -----

add("24-parent-key-supplementary-plane.json", vector(
"TRACE-DELEG-024", "parent-key-supplementary-plane",
"The root's `cnf.jwk` carries two additional RFC 7517 members, keyed by "
"a BMP private-use character and by a character outside the Basic "
"Multilingual Plane. Section 3.1.3 states that RFC 8785's UTF-16 "
"code-unit key order and code-point order agree everywhere except here, "
"and that this corpus was entirely ASCII and so could not separate an "
"implementation that canonicalizes correctly from one that sorts by "
"code point instead. This is that vector: the leaf's "
"`parent_record_hash` is the root's digest under UTF-16 order. A "
"verifier that sorts by code point computes a different digest for the "
"same root, fails to resolve the link, and reports `parent_not_found` "
"on a chain that is otherwise exactly vector 01's.",
build_chain(SUPPLEMENTARY_PLANE_HOPS),
classification="verified", codes=[], 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"
Expand Down
3 changes: 2 additions & 1 deletion spec/trace-v0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,8 @@ with `sha384:` and SHA-384 as the permitted alternative, per the schema pattern.

The other two readings fail for concrete reasons. Digesting the file's raw bytes makes the value sensitive to whitespace and key order, so it breaks the first time a record passes through a system that re-serialises it. Digesting with `signature` removed adds a second canonicalisation rule for implementers to get wrong and buys nothing.

**A near-miss here passes every existing vector.** The RFC 8785 key ordering that §3.2.2 describes, and the code-point ordering that `sort_keys=True` produces in several JSON libraries, agree across the Basic Multilingual Plane and diverge only once a key contains a supplementary-plane character. An implementation that takes the shortcut therefore computes correct chain digests for ASCII records indefinitely and produces an unverifiable chain the first time such a key appears. Every vector in `examples/delegation-link/` is currently ASCII, so the corpus does not discriminate the two; a vector whose parent record carries a supplementary-plane key is what closes that.
**A near-miss here passes every vector but one.** The RFC 8785 key ordering that §3.2.2 describes, and the code-point ordering that `sort_keys=True` produces in several JSON libraries, agree across the Basic Multilingual Plane and diverge only once a key contains a supplementary-plane character. An implementation that takes the shortcut therefore computes correct chain digests for ASCII records indefinitely and produces an unverifiable chain the first time such a key appears.
`examples/delegation-link/24-parent-key-supplementary-plane.json` is the vector that catches it: its parent record carries a key outside the Basic Multilingual Plane, where the two orderings disagree. Every other record's keys in that corpus are ASCII.

### 3.2 Wire format

Expand Down
Loading