diff --git a/CHANGELOG.md b/CHANGELOG.md index d4d1d8bd..b58d68fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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). diff --git a/docs/rfcs/a2a-delegation-profile.md b/docs/rfcs/a2a-delegation-profile.md index f2f12d44..fecf7408 100644 --- a/docs/rfcs/a2a-delegation-profile.md +++ b/docs/rfcs/a2a-delegation-profile.md @@ -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 @@ -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 diff --git a/examples/delegation-link/24-parent-key-supplementary-plane.json b/examples/delegation-link/24-parent-key-supplementary-plane.json new file mode 100644 index 00000000..fe14a9b6 --- /dev/null +++ b/examples/delegation-link/24-parent-key-supplementary-plane.json @@ -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": [] + } +} diff --git a/examples/delegation-link/README.md b/examples/delegation-link/README.md index c9f72d08..680e6103 100644 --- a/examples/delegation-link/README.md +++ b/examples/delegation-link/README.md @@ -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. diff --git a/examples/delegation-link/gen_delegation_vectors.py b/examples/delegation-link/gen_delegation_vectors.py index 820201dc..ba714a36 100644 --- a/examples/delegation-link/gen_delegation_vectors.py +++ b/examples/delegation-link/gen_delegation_vectors.py @@ -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. @@ -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 @@ -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]]] = [] @@ -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" diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index 14778487..2c8dfe9e 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -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 diff --git a/tests/test_delegation_parent_hash_canonicalization.py b/tests/test_delegation_parent_hash_canonicalization.py new file mode 100644 index 00000000..99ffec0c --- /dev/null +++ b/tests/test_delegation_parent_hash_canonicalization.py @@ -0,0 +1,126 @@ +"""Vector 24 exists to catch one specific mistake; this file proves it does. + +`test_delegation_vectors.py::test_vector_reaches_its_declared_outcome` already +runs `24-parent-key-supplementary-plane.json` through the reference walk and +checks it comes back `verified`, the same as every other vector in the loop. +That is necessary but not sufficient: a vector can reach the right +classification for a reason that has nothing to do with what it was built to +test. This file checks the thing itself, the same way +`test_canonicalization_boundary.py::test_declared_divergence_is_the_measured_divergence` +does for the section 3.2.2 signature preimage: it recomputes the divergence +rather than trusting the vector's docstring to still describe it. + +Section 3.1.3's near-miss is specific to *this* preimage, the delegation chain +digest, and not to the one `test_canonicalization_boundary.py` already covers. +Both are RFC 8785 over a record, but over a different one: the record's own +signature covers itself with `signature` absent; the chain digest covers a +*parent* record with `signature` present, and is computed by whoever is +walking a delegation chain rather than by whoever is verifying a lone record's +signature. A canonicalizer that is correct for one call site and reused +carelessly for the other has no vector to catch it before this one. +""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from tests.test_delegation_vectors import _digest, verify_chain + +VECTOR_PATH = ( + Path(__file__).resolve().parents[1] + / "examples" + / "delegation-link" + / "24-parent-key-supplementary-plane.json" +) + + +def _load() -> dict[str, Any]: + return json.loads(VECTOR_PATH.read_text(encoding="utf-8")) + + +def _code_point_digest(record: dict[str, Any], alg: str = "sha256") -> str: + """The rejected canonicalizer: `json.dumps(sort_keys=True)`. + + Python's `sorted()`, and therefore `sort_keys=True`, orders strings by code + point. RFC 8785 section 3.2.3 orders by UTF-16 code unit. The two agree + everywhere except where a key holds a character outside the Basic + Multilingual Plane, which is exactly what this record's `cnf.jwk` was + built to carry. + """ + body = json.dumps( + record, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ).encode("utf-8") + return f"{alg}:" + hashlib.new(alg, body).hexdigest() + + +def test_the_root_record_has_the_key_pair_this_vector_needs() -> None: + """Sanity check on the fixture itself, before trusting anything it proves. + + Both members must be present, and one of them must sit outside the Basic + Multilingual Plane, or this vector is testing nothing. + """ + vector = _load() + root = next(r for r in vector["records"] if "delegation" not in r) + keys = root["cnf"]["jwk"].keys() + non_bmp = [k for k in keys if any(ord(c) > 0xFFFF for c in k)] + bmp_only = [ + k + for k in keys + if k not in ("kty", "crv", "x", "y", "kid") and k not in non_bmp + ] + assert non_bmp, "no cnf.jwk member key is outside the Basic Multilingual Plane" + assert bmp_only, "no cnf.jwk member key is BMP-only, so there is nothing to compare against" + + +def test_utf16_and_code_point_order_disagree_on_this_record() -> None: + """The premise. If this ever stopped holding, the vector would stop proving + anything and every other assertion here would be vacuous.""" + vector = _load() + root = next(r for r in vector["records"] if "delegation" not in r) + assert _digest(root, "sha256") != _code_point_digest(root), ( + "RFC 8785 and json.dumps(sort_keys=True) computed the same digest for " + "the root record: the supplementary-plane key no longer discriminates " + "the two canonicalizers, and this vector needs a replacement." + ) + + +def test_the_declared_link_is_the_rfc8785_digest_not_the_code_point_one() -> None: + """The leaf's `delegation.parent_record_hash` must be reachable only by the + conformant canonicalizer. If it matched the code-point digest instead (or + both), a non-conformant implementation would resolve the link by accident + and this vector would certify exactly the mistake it exists to catch.""" + vector = _load() + root = next(r for r in vector["records"] if "delegation" not in r) + leaf = next(r for r in vector["records"] if "delegation" in r) + declared = leaf["delegation"]["parent_record_hash"] + assert declared == _digest(root, "sha256") + assert declared != _code_point_digest(root) + + +def test_a_code_point_canonicalizer_cannot_resolve_the_link() -> None: + """The failure mode section 3.1.3 predicts, reproduced directly: a verifier + that indexes parent records by `json.dumps(sort_keys=True)` digests instead + of RFC 8785 ones builds an index that does not contain the key the leaf is + looking for. Vector 04 (`parent-record-absent`) is what that looks like + from the walk's side; this is why it would happen here even though every + record is present.""" + vector = _load() + root = next(r for r in vector["records"] if "delegation" not in r) + leaf = next(r for r in vector["records"] if "delegation" in r) + code_point_index = {_code_point_digest(root): root} + assert leaf["delegation"]["parent_record_hash"] not in code_point_index + + +def test_the_reference_walk_verifies_this_vector() -> None: + """The reference walk in `test_delegation_vectors.py` uses `rfc8785.dumps`, + is therefore conformant, and must reach the outcome the vector declares. + Restated here rather than left to the parametrised loop alone, so a reader + of this file sees the positive and negative cases side by side.""" + vector = _load() + result = verify_chain(vector) + assert result.classification == "verified" + assert not result.failures + assert not result.warnings diff --git a/tests/test_delegation_vectors.py b/tests/test_delegation_vectors.py index 67367b86..05a51213 100644 --- a/tests/test_delegation_vectors.py +++ b/tests/test_delegation_vectors.py @@ -414,6 +414,7 @@ def test_vector_set_is_complete() -> None: "21-depth-one-past-the-bound.json", "22-leaf-link-uses-sha384.json", "23-deep-link-uses-sha384.json", + "24-parent-key-supplementary-plane.json", ]