From a6aeb133fcd608d5b0832c61bccdaa8c83b0f573 Mon Sep 17 00:00:00 2001 From: Sankalp Gilda Date: Fri, 18 Sep 2026 15:14:07 -0400 Subject: [PATCH 1/2] docs: add jcs_depth_v1 nesting-bound vectors to the integrity profile RFC 8785 specifies byte production and no limit on input nesting, so two canonicalizers that agree on every byte of the jcs_edge_v1 corpus can still disagree on whether a deeply nested artifact canonicalizes at all. One returns bytes, one returns an error, and one exhausts its call stack. The profile has to state the bound itself. This corpus pins the boundary at 128 open containers, counted so that an empty container is charged its own level. Four accept vectors sit at the bound in objects, arrays, alternating containers and with an empty-container leaf; three reject vectors sit one level past each of those; and one holds ten million levels, which separates an implementation that refuses from one that dies. Four pair invariants state the relations no single vector can. Expected canonical bytes come from canonicalizing each materialized preimage with rfc8785 0.1.4, the reference implementation jcs_edge_v1 also names, and were reproduced with a second serializer before being pinned. Preimages are given as nesting rules rather than literal JSON so the corpus file stays shallow enough to be read by a parser enforcing the bound it describes. --- .github/actions/spelling/excludes.txt | 1 + .../vectors/jcs_depth_v1/PROVENANCE.json | 54 ++++ .../vectors/jcs_depth_v1/README.md | 50 ++++ .../vectors/jcs_depth_v1/jcs_depth_v1.json | 240 ++++++++++++++++++ 4 files changed, 345 insertions(+) create mode 100644 proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json create mode 100644 proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md create mode 100644 proposals/content-integrity-profile/vectors/jcs_depth_v1/jcs_depth_v1.json diff --git a/.github/actions/spelling/excludes.txt b/.github/actions/spelling/excludes.txt index c9974fec4..10d4bbca8 100644 --- a/.github/actions/spelling/excludes.txt +++ b/.github/actions/spelling/excludes.txt @@ -91,3 +91,4 @@ CHANGELOG.md \.gitignore ^\Qdocs/robots.txt\E$ CODE_OF_CONDUCT.md +(?:^|/)proposals/content-integrity-profile/vectors/ diff --git a/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json b/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json new file mode 100644 index 000000000..f72307fc4 --- /dev/null +++ b/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json @@ -0,0 +1,54 @@ +{ + "_note": "Provenance for the jcs_depth_v1 corpus. Unlike the sibling jcs_edge_v1 directory, nothing here is a byte-verbatim retention of a third-party artifact: the vectors were authored for this repository, and every expected byte string was produced by canonicalising the materialised preimage with the reference implementation named below rather than written by hand. The record exists for the same reason the sibling's does. A corpus that is silently regenerated or hand-edited still cross-checks cleanly against itself, so the cross-check reports agreement while proving nothing. Confirm the digests below before running anything against these vectors.", + "corpus": { + "name": "jcs_depth_v1", + "license": "Apache-2.0", + "author": "Sankalp Gilda", + "vectors": 8, + "pair_invariants": 4, + "max_depth": 128, + "depth_counting_rule": "open containers; the outermost brace or bracket is depth 1, and an empty container is its own level" + }, + "artifacts": [ + { + "file": "jcs_depth_v1.json", + "sha256": "7e262412c3dea78713a950219f0413ee0382a48d43cf4698bd6d67214c78cdce" + }, + { + "file": "README.md", + "sha256": "6efefe6c7f317e6d4b07fefa121fc1fa1a89b4d8a58838b511ea6aedc0063ba0" + } + ], + "generation": { + "reference_impl": "Python rfc8785 0.1.4 (Trail of Bits)", + "_note": "The same reference implementation the sibling jcs_edge_v1 corpus names. Each accept vector's expected_jcs_bytes_b64 is the output of canonicalising the materialised preimage with it, reproduced independently by a second serialiser before being pinned; expected_sha256 is SHA-256 over those same bytes. Reject vectors publish no canonical bytes, because an implementation that produces bytes for them has not applied the bound.", + "cross_check": "Every vector's preimage was rebuilt from its published preimage_rule, its depth measured by counting open containers over the materialised text rather than read from the rule, and its preimage_sha256 confirmed before any canonicalisation ran." + }, + "source_corpus": { + "_note": "The two boundary cases carry a counterpart in the corpus below, recorded per vector in trace.sibling_vector_id. The remaining six are authored here to separate container kinds and to distinguish a refusal from a crash, which that corpus does not cover at the JCS layer.", + "name": "agent-evidence-vectors", + "repository": "https://github.com/astrogilda/agent-evidence-vectors", + "commit": "23949cc90b136359b54123b4cffb0800ea311980", + "suite": "adversarial-execution-evidence-conformance", + "suite_revision": 28, + "corpus_digest": "8b035678def9e5ac00ba761b8c640e4412c57163134afb9d2c1a90f49d573a52", + "vectors_in_suite": 272, + "run": "uvx agent-evidence-vectors", + "counterparts": [ + { + "vector_id": "jcs-depth-101-object-one-past-bound", + "source_vector_id": "v08251931ec038e91" + }, + { + "vector_id": "jcs-depth-103-empty-object-leaf-one-past-bound", + "source_vector_id": "v83f4b7fe6068ef86" + } + ] + }, + "bound_reference": { + "_note": "The bound and its counting rule are the ones stated in the predicate specification proposed in the pull request below. That pull request is open, not merged, so this is a citation of proposed text and not of a published standard.", + "pull_request": "https://github.com/in-toto/attestation/pull/570", + "state_read_at": "2026-09-18", + "state": "open" + } +} diff --git a/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md b/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md new file mode 100644 index 000000000..854d02d6d --- /dev/null +++ b/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md @@ -0,0 +1,50 @@ +# Content Integrity Profile: jcs_depth_v1 conformance vectors + +## What this corpus pins + +`jcs_edge_v1` pins the bytes an RFC 8785 canonicalizer must produce. This corpus pins the input it must refuse. + +RFC 8785 specifies escaping, number form and property ordering, and it specifies no limit on how deeply an input may nest. That omission is deliberate and it is not a defect in the RFC, but it leaves a profile built on JCS with a gap the byte-level vectors cannot reach. Two implementations that agree on all ten `jcs_edge_v1` vectors can still disagree about whether a deeply nested artifact canonicalizes at all: one returns bytes, one returns an error, and one walks off the end of its call stack. All three are conformant to RFC 8785, so the profile has to state the bound itself or inherit whichever bound each implementation happened to pick. + +The gap has teeth because canonicalization runs before signature verification. A verifier has to canonicalize a received artifact to know what bytes the signature covers, so the recursive walk is reachable by anyone who can hand the verifier an artifact, with no key and no prior trust. A canonicalizer that recurses once per nesting level and carries no depth counter exhausts its stack on such input. In a compiled runtime that is not an exception a caller can catch: Go reports `fatal error: stack overflow` and unwinds the process, and a deferred `recover` does not intercept it. The widely used Go implementation `gowebpki/jcs` is built this way, with a recursive descent through `parseElement`, `parseObject` and `parseArray` and no depth counter on any path; measured on one machine, about 10^5 levels canonicalize normally and about 10^7 levels, roughly 20 MB of brackets, kill the process. A byte cap does not close this, because pure nesting stays small per level and reaches any plausible size limit long after it has passed any plausible stack. The bound has to be checked before the walk begins. + +## The bound + +Depth counts **open containers**. The outermost brace or bracket is depth 1, and every object or array opened inside it adds one, an empty container included. + +The counting rule matters as much as the number. A counter that charges a level when it recurses into a child, rather than when a container opens, never charges an empty container and lands one level off. Two implementations can hold the identical constant and still disagree across the boundary, which is the failure `jcs-depth-004` and `jcs-depth-103` exist to separate. + +The bound in this corpus is **128**. It is the same figure and the same counting rule stated in the predicate specification proposed in [in-toto/attestation#570](https://github.com/in-toto/attestation/pull/570), where it was chosen on two grounds: it is the recursion limit `serde_json` already enforces, and it sits far above real content. The deepest artifact in the corpus that prompted it nests seven levels. + +## The vectors + +| vector | outcome | depth | what it separates | +| --- | --- | --- | --- | +| `jcs-depth-001-object-at-bound` | accept | 128 | the deepest input the bound admits, in objects | +| `jcs-depth-002-array-at-bound` | accept | 128 | the same boundary in the array branch | +| `jcs-depth-003-alternating-at-bound` | accept | 128 | a counter that tracks one container kind and resets on the other | +| `jcs-depth-004-empty-object-leaf-at-bound` | accept | 128 | an empty container counted as its own level | +| `jcs-depth-101-object-one-past-bound` | reject | 129 | one wrapping object past `jcs-depth-001` | +| `jcs-depth-102-array-one-past-bound` | reject | 129 | one wrapping array past `jcs-depth-002` | +| `jcs-depth-103-empty-object-leaf-one-past-bound` | reject | 129 | a per-child counter, which accepts this and rejects `jcs-depth-101` | +| `jcs-depth-104-unbounded-recursion` | reject | 10000000 | a refusal from a crash | + +Four `pair_invariants` in the corpus state the relations the individual vectors cannot: each accept and its one-level-deeper reject locate the boundary exactly, since an implementation that accepts both has no bound and one that rejects both has the bound off by one. `jcs-depth-104` is passed only when the implementation returns a rejection to its caller. A process that dies on that input has not rejected it. + +## How to run + +Preimages are given as nesting rules rather than literal JSON, so this file stays four levels deep and can be read by a parser that enforces the bound it describes. Materialize a rule, then check the corpus against your canonicalizer: + +1. For `i` from 0 to `count - 1`, emit the opener for `containers[i % len(containers)]`: `{"a":` for `object`, `[` for `array`. +2. Emit `leaf`, then the matching closers innermost first. +3. Compute SHA-256 over the materialized UTF-8 bytes and compare against `preimage_sha256`. Do this before canonicalizing anything. A vector built wrong cross-checks cleanly against itself and proves nothing. +4. For an `accept` vector, canonicalize the preimage, compare the bytes exactly against the base64-decoded `expected_jcs_bytes_b64`, and compare SHA-256 of those bytes against `expected_sha256`. +5. For a `reject` vector, confirm your canonicalizer returns a rejection to the caller. No canonical bytes are published for these: an implementation that produces bytes here has not applied the bound. + +Every `expected_jcs_bytes_b64` in this corpus was produced by canonicalizing the materialized preimage with `rfc8785` 0.1.4, the Trail of Bits reference implementation that `jcs_edge_v1` also names, and independently reproduced with a second serializer before being pinned. + +## Source and attribution + +The two boundary cases are drawn from [`agent-evidence-vectors`](https://github.com/astrogilda/agent-evidence-vectors), which carries them as statement-level vectors in a suite you can run with `uvx agent-evidence-vectors`; each vector here records the corpus in its `trace` field, and the two with a direct counterpart carry that vector's identifier. `PROVENANCE.json` pins the source suite revision and corpus digest alongside a SHA-256 for each file in this directory. + +Authored for this repository under Apache-2.0, the license the surrounding repository and the sibling `jcs_edge_v1` corpus both use. `jcs_edge_v1` is retained byte-verbatim from its author; this corpus is not a retention of anything, so it carries no upstream `LICENSE` or `NOTICE` to travel with it. diff --git a/proposals/content-integrity-profile/vectors/jcs_depth_v1/jcs_depth_v1.json b/proposals/content-integrity-profile/vectors/jcs_depth_v1/jcs_depth_v1.json new file mode 100644 index 000000000..c55c565e1 --- /dev/null +++ b/proposals/content-integrity-profile/vectors/jcs_depth_v1/jcs_depth_v1.json @@ -0,0 +1,240 @@ +{ + "name": "jcs_depth_v1", + "license": "Apache-2.0", + "copyright": "Copyright 2026 Sankalp Gilda", + "spec": "RFC 8785 (JCS) nesting depth: the resource bound the scheme leaves open", + "spec_authorship": "Conformance set for the nesting bound a canonicalisation profile has to state for itself. RFC 8785 specifies byte production and no input limit, so two implementations that agree on every byte of jcs_edge_v1 can still disagree on whether a deep artifact canonicalises at all: one returns bytes, one refuses, one exhausts its stack. Pins the boundary at 128 open containers, the empty-container case a scalar leaf cannot discriminate, and the unbounded-recursion input that separates a refusal from a crash.", + "canon_version": "jcs-rfc8785-v1", + "reference_impl": "Python rfc8785 0.1.4 (Trail of Bits)", + "max_depth": 128, + "depth_counting_rule": "Depth counts OPEN CONTAINERS: the outermost brace or bracket is depth 1, and every object or array opened inside it adds one, an empty container included. A counter that charges a level per parsed child instead never charges an empty container and lands one level off.", + "preimage_rule_form": { + "_note": "Preimages are given as nesting rules rather than literal JSON so that this file stays four levels deep and can be parsed by a reader that enforces the very bound it describes. Materialise a rule as text: for i from 0 to count-1 emit the opener for containers[i % len(containers)] -- '{\"a\":' for object, '[' for array -- then emit leaf, then emit the matching closers innermost-first. Confirm preimage_sha256 over the resulting UTF-8 bytes before canonicalising: a vector built wrong cross-checks cleanly against itself and proves nothing.", + "openers": { + "object": "{\"a\":", + "array": "[" + }, + "closers": { + "object": "}", + "array": "]" + } + }, + "source_corpus": { + "name": "agent-evidence-vectors", + "suite": "adversarial-execution-evidence-conformance", + "suite_revision": 28, + "corpus_digest": "8b035678def9e5ac00ba761b8c640e4412c57163134afb9d2c1a90f49d573a52", + "repository": "https://github.com/astrogilda/agent-evidence-vectors", + "run": "uvx agent-evidence-vectors" + }, + "vectors": [ + { + "vector_id": "jcs-depth-001-object-at-bound", + "description": "128 nested objects, the deepest input the bound admits. A canonicaliser that applies the bound one level early refuses this and is off by one.", + "rfc8785_section": "none", + "outcome": "accept", + "expectation": "canonicalise-at-bound", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 128, + "leaf": "1" + }, + "preimage_bytes": 769, + "preimage_sha256": "a4908c65856c2fb1e94d6b2b55620177bd082f54d43252b9e0648f9ccd53e3fe", + "expected_jcs_bytes_b64": "eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOjF9fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fQ==", + "expected_sha256": "a4908c65856c2fb1e94d6b2b55620177bd082f54d43252b9e0648f9ccd53e3fe", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-002-array-at-bound", + "description": "128 nested arrays at the bound. Separated from the object case because a depth counter placed only in the object branch never charges an array.", + "rfc8785_section": "none", + "outcome": "accept", + "expectation": "canonicalise-at-bound", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 128, + "leaf": "1" + }, + "preimage_bytes": 257, + "preimage_sha256": "68da6c21da4d39e99f241a56379e98c2053372e77cd74f72400af6d3a37261c3", + "expected_jcs_bytes_b64": "W1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1tbW1sxXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV0=", + "expected_sha256": "68da6c21da4d39e99f241a56379e98c2053372e77cd74f72400af6d3a37261c3", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-003-alternating-at-bound", + "description": "128 containers alternating object, array, object, array at the bound. Catches a counter that tracks one container kind and resets on the other.", + "rfc8785_section": "none", + "outcome": "accept", + "expectation": "canonicalise-at-bound", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "object", + "array" + ], + "count": 128, + "leaf": "1" + }, + "preimage_bytes": 513, + "preimage_sha256": "aa4e4f042129f600f2352f2ecd58559409175fd83cf4918c382afc3d33942268", + "expected_jcs_bytes_b64": "eyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbeyJhIjpbMV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19XX1dfV19", + "expected_sha256": "aa4e4f042129f600f2352f2ecd58559409175fd83cf4918c382afc3d33942268", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-004-empty-object-leaf-at-bound", + "description": "127 wrapping objects around an empty object. The empty container is itself the 128th open container, so this sits exactly at the bound and is accepted.", + "rfc8785_section": "none", + "outcome": "accept", + "expectation": "canonicalise-at-bound-empty-leaf", + "depth": 128, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 127, + "leaf": "{}" + }, + "preimage_bytes": 764, + "preimage_sha256": "95abadd19f4a27dd41c2e3b46baca7320d039f4e4686fcdaebb0a7d7146bb16e", + "expected_jcs_bytes_b64": "eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7ImEiOnsiYSI6eyJhIjp7fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX19fX0=", + "expected_sha256": "95abadd19f4a27dd41c2e3b46baca7320d039f4e4686fcdaebb0a7d7146bb16e", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-101-object-one-past-bound", + "description": "129 nested objects, one level past the bound. Differs from jcs-depth-001-object-at-bound by exactly one wrapping object.", + "rfc8785_section": "none", + "outcome": "reject", + "expectation": "refuse-exceeds-max-depth", + "depth": 129, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 129, + "leaf": "1" + }, + "preimage_bytes": 775, + "preimage_sha256": "eeb23e8c9c090d0303063348ae7ca4a5914992972fc20e295e8e386802e2a95a", + "trace": { + "corpus": "agent-evidence-vectors", + "sibling_vector_id": "v08251931ec038e91" + } + }, + { + "vector_id": "jcs-depth-102-array-one-past-bound", + "description": "129 nested arrays, one level past the bound.", + "rfc8785_section": "none", + "outcome": "reject", + "expectation": "refuse-exceeds-max-depth", + "depth": 129, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 129, + "leaf": "1" + }, + "preimage_bytes": 259, + "preimage_sha256": "84aaf90be3265f8e148bdcc72153a59156d358f74e3dedd2d6a5dd566f5944f5", + "trace": { + "corpus": "agent-evidence-vectors" + } + }, + { + "vector_id": "jcs-depth-103-empty-object-leaf-one-past-bound", + "description": "128 wrapping objects around an empty object: open-container depth 129, one past the bound. This is the case a scalar leaf cannot discriminate. A canonicaliser that charges a level per parsed child rather than per opened container never charges the empty object, reads this as depth 128 and accepts it while refusing jcs-depth-101-object-one-past-bound.", + "rfc8785_section": "none", + "outcome": "reject", + "expectation": "refuse-exceeds-max-depth-empty-leaf", + "depth": 129, + "preimage_rule": { + "form": "nest", + "containers": [ + "object" + ], + "count": 128, + "leaf": "{}" + }, + "preimage_bytes": 770, + "preimage_sha256": "47ec83edd0d185f58e09a843867e31af9088c4da554f55c730c52f547161a8b1", + "trace": { + "corpus": "agent-evidence-vectors", + "sibling_vector_id": "v83f4b7fe6068ef86" + } + }, + { + "vector_id": "jcs-depth-104-unbounded-recursion", + "description": "Ten million nested arrays, about 20 MB of brackets. A canonicaliser that recurses once per level exhausts the call stack here rather than returning a rejection, and in a compiled runtime that unwind is not catchable. A byte cap alone does not close it: pure nesting stays small per level, so the input reaches any plausible size limit only long after it has passed any plausible stack. Refusal must be reached from the depth bound, before the recursive walk is entered.", + "rfc8785_section": "none", + "outcome": "reject", + "expectation": "refuse-without-exhausting-the-stack", + "depth": 10000000, + "preimage_rule": { + "form": "nest", + "containers": [ + "array" + ], + "count": 10000000, + "leaf": "1" + }, + "preimage_bytes": 20000001, + "preimage_sha256": "0cc26ae2e8031215e24f98189a86d1af2a3f47c4335b3b631b8cc4ff41dd811c", + "trace": { + "corpus": "agent-evidence-vectors" + } + } + ], + "pair_invariants": [ + { + "name": "object_bound_is_exact", + "a": "jcs-depth-001-object-at-bound", + "b": "jcs-depth-101-object-one-past-bound", + "relation": "accept_then_reject", + "why": "The two inputs differ by one wrapping object. An implementation that accepts both has no bound; one that refuses both has the bound off by one. Only the pair locates it." + }, + { + "name": "array_bound_is_exact", + "a": "jcs-depth-002-array-at-bound", + "b": "jcs-depth-102-array-one-past-bound", + "relation": "accept_then_reject", + "why": "The same boundary in the array branch, which a counter placed only in the object branch never reaches." + }, + { + "name": "empty_container_charges_a_level", + "a": "jcs-depth-004-empty-object-leaf-at-bound", + "b": "jcs-depth-103-empty-object-leaf-one-past-bound", + "relation": "accept_then_reject", + "why": "A per-child counter never charges an empty container, so it reads the second input as depth 128 and accepts it. Both scalar-leaf vectors pass under that counter, which is why this pair exists." + }, + { + "name": "refusal_is_not_a_crash", + "vector": "jcs-depth-104-unbounded-recursion", + "relation": "reject_without_stack_exhaustion", + "why": "The vector is passed only if the implementation returns a rejection to its caller. A process that dies on this input has not rejected it, and a crash on an unauthenticated artifact is the outcome the bound exists to prevent." + } + ] +} From 057a0a6ae94d9a20c73b332805654d17e7fe5ea7 Mon Sep 17 00:00:00 2001 From: Sankalp Gilda Date: Wed, 23 Sep 2026 09:12:02 -0400 Subject: [PATCH 2/2] docs: label the jcs_depth_v1 bound as a proposed profile choice The README now says 128 is a proposed profile choice, required by neither RFC 8785 nor an adopted A2A rule, and states the grounds the in-toto proposal gives for it. The unsourced second-serializer claim is withdrawn from both the README and PROVENANCE.json, and replaced by the third-party run against the a2a-go canonicalizer reported on #2219, including its finding that the jcs-depth-104 refusal there came from the encoding/json decoder rather than from the canonicalizer. The gowebpki/jcs measurement now names its version, and notes that v1.0.2 adds a limit of 10,000. PROVENANCE.json pins the new README digest and names the source corpus by its current repository path. --- .../vectors/jcs_depth_v1/PROVENANCE.json | 16 ++++-- .../vectors/jcs_depth_v1/README.md | 52 ++++++++++++++----- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json b/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json index f72307fc4..3d428349b 100644 --- a/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json +++ b/proposals/content-integrity-profile/vectors/jcs_depth_v1/PROVENANCE.json @@ -16,18 +16,24 @@ }, { "file": "README.md", - "sha256": "6efefe6c7f317e6d4b07fefa121fc1fa1a89b4d8a58838b511ea6aedc0063ba0" + "sha256": "61c02c9377b738fa52a6696be0fb6552d92d01d21664337dba978e47c14d1d36" } ], "generation": { "reference_impl": "Python rfc8785 0.1.4 (Trail of Bits)", - "_note": "The same reference implementation the sibling jcs_edge_v1 corpus names. Each accept vector's expected_jcs_bytes_b64 is the output of canonicalising the materialised preimage with it, reproduced independently by a second serialiser before being pinned; expected_sha256 is SHA-256 over those same bytes. Reject vectors publish no canonical bytes, because an implementation that produces bytes for them has not applied the bound.", - "cross_check": "Every vector's preimage was rebuilt from its published preimage_rule, its depth measured by counting open containers over the materialised text rather than read from the rule, and its preimage_sha256 confirmed before any canonicalisation ran." + "_note": "The same reference implementation the sibling jcs_edge_v1 corpus names. Each accept vector's expected_jcs_bytes_b64 is the output of canonicalising the materialised preimage with it; expected_sha256 is SHA-256 over those same bytes. Reject vectors publish no canonical bytes, because an implementation that produces bytes for them has not applied the bound.", + "cross_check": "Every vector's preimage was rebuilt from its published preimage_rule, its depth measured by counting open containers over the materialised text rather than read from the rule, and its preimage_sha256 confirmed before any canonicalisation ran.", + "independent_check": { + "_note": "A third party pinned this corpus's digests and ran it against the Go canonicalizer before the README was revised. The four accept vectors reproduced their expected bytes and SHA-256; the three one-past-bound vectors were accepted, and jcs-depth-104's refusal came from the encoding/json decoder's nesting cap of 10000 rather than from the canonicalizer.", + "implementation": "https://github.com/a2aproject/a2a-go/pull/368", + "implementation_commit": "eddcf62", + "report": "https://github.com/a2aproject/A2A/pull/2219#issuecomment-5749126086" + } }, "source_corpus": { "_note": "The two boundary cases carry a counterpart in the corpus below, recorded per vector in trace.sibling_vector_id. The remaining six are authored here to separate container kinds and to distinguish a refusal from a crash, which that corpus does not cover at the JCS layer.", "name": "agent-evidence-vectors", - "repository": "https://github.com/astrogilda/agent-evidence-vectors", + "repository": "https://github.com/probityai/agent-evidence-vectors", "commit": "23949cc90b136359b54123b4cffb0800ea311980", "suite": "adversarial-execution-evidence-conformance", "suite_revision": 28, @@ -48,7 +54,7 @@ "bound_reference": { "_note": "The bound and its counting rule are the ones stated in the predicate specification proposed in the pull request below. That pull request is open, not merged, so this is a citation of proposed text and not of a published standard.", "pull_request": "https://github.com/in-toto/attestation/pull/570", - "state_read_at": "2026-09-18", + "state_read_at": "2026-09-23", "state": "open" } } diff --git a/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md b/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md index 854d02d6d..29ed51b7b 100644 --- a/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md +++ b/proposals/content-integrity-profile/vectors/jcs_depth_v1/README.md @@ -4,17 +4,29 @@ `jcs_edge_v1` pins the bytes an RFC 8785 canonicalizer must produce. This corpus pins the input it must refuse. -RFC 8785 specifies escaping, number form and property ordering, and it specifies no limit on how deeply an input may nest. That omission is deliberate and it is not a defect in the RFC, but it leaves a profile built on JCS with a gap the byte-level vectors cannot reach. Two implementations that agree on all ten `jcs_edge_v1` vectors can still disagree about whether a deeply nested artifact canonicalizes at all: one returns bytes, one returns an error, and one walks off the end of its call stack. All three are conformant to RFC 8785, so the profile has to state the bound itself or inherit whichever bound each implementation happened to pick. +The RFC specifies escaping, number form and property ordering. It specifies no limit on how deeply an input may nest, and that omission is deliberate. It still leaves a profile built on JCS with a gap the byte-level vectors cannot reach. -The gap has teeth because canonicalization runs before signature verification. A verifier has to canonicalize a received artifact to know what bytes the signature covers, so the recursive walk is reachable by anyone who can hand the verifier an artifact, with no key and no prior trust. A canonicalizer that recurses once per nesting level and carries no depth counter exhausts its stack on such input. In a compiled runtime that is not an exception a caller can catch: Go reports `fatal error: stack overflow` and unwinds the process, and a deferred `recover` does not intercept it. The widely used Go implementation `gowebpki/jcs` is built this way, with a recursive descent through `parseElement`, `parseObject` and `parseArray` and no depth counter on any path; measured on one machine, about 10^5 levels canonicalize normally and about 10^7 levels, roughly 20 MB of brackets, kill the process. A byte cap does not close this, because pure nesting stays small per level and reaches any plausible size limit long after it has passed any plausible stack. The bound has to be checked before the walk begins. +Two implementations that agree on every `jcs_edge_v1` vector can still disagree about whether a deeply nested artifact canonicalizes at all. One returns bytes, one returns an error, and one walks off the end of its call stack. All three conform to the RFC, so the profile has to state the bound itself or inherit whichever bound each implementation happened to pick. + +The gap matters because canonicalization runs before signature verification: a verifier has to canonicalize a received artifact to learn what bytes the signature covers, so anyone who can hand the verifier an artifact reaches the recursive walk with no key, no prior trust and no earlier step at which the verifier could have refused the input. + +A canonicalizer that recurses once per nesting level and carries no depth counter exhausts its stack on such input. In Go, stack exhaustion is not an exception a caller can catch. The Go runtime reports `fatal error: stack overflow` and terminates the process, and a deferred `recover` does not intercept it. + +The widely used Go implementation `gowebpki/jcs` was built this way through v1.0.1, descending recursively through `parseElement`, `parseObject` and `parseArray` with no depth counter on any path, so the depth of the input set the depth of its call stack. In a measurement with v1.0.1 on `go1.25.5`, an input nested 10^7 levels deep, 20 MB of brackets, ended the process with that error, and one nested 10^5 levels, 200 KB of brackets, took about two minutes on one core. Its v1.0.2 release of 21 September adds a limit of 10,000 levels, the figure `encoding/json` uses, while `serde_json` stops at 128, so each library still picks its own bound. + +A byte cap does not close this gap. Pure nesting costs a few bytes per level, so it passes any plausible stack long before it reaches any plausible size limit. The bound has to be checked before the walk begins. ## The bound Depth counts **open containers**. The outermost brace or bracket is depth 1, and every object or array opened inside it adds one, an empty container included. -The counting rule matters as much as the number. A counter that charges a level when it recurses into a child, rather than when a container opens, never charges an empty container and lands one level off. Two implementations can hold the identical constant and still disagree across the boundary, which is the failure `jcs-depth-004` and `jcs-depth-103` exist to separate. +The counting rule matters as much as the number. Some counters charge a level on recursion into a child. An empty container has no child, so such a counter never charges it and lands one level off. Two implementations can hold the identical constant and still disagree across the boundary, which is the failure `jcs-depth-004` and `jcs-depth-103` exist to separate. -The bound in this corpus is **128**. It is the same figure and the same counting rule stated in the predicate specification proposed in [in-toto/attestation#570](https://github.com/in-toto/attestation/pull/570), where it was chosen on two grounds: it is the recursion limit `serde_json` already enforces, and it sits far above real content. The deepest artifact in the corpus that prompted it nests seven levels. +The bound in this corpus is **128**, and 128 is a proposed profile choice. The RFC states no depth limit, no adopted A2A rule states one, and [in-toto/attestation#570](https://github.com/in-toto/attestation/pull/570) has not merged, so the number is a requirement of none of the three. + +It is the figure and the counting rule that pull request proposes, where it was chosen on two grounds: it is the recursion limit `serde_json` already enforces, and it sits far above real content. The deepest artifact in the corpus that prompted it nests seven levels. + +Each vector states its depth as a `count` in a preimage rule, so a different bound regenerates the set by changing that integer alone. Implementations still have to agree on how they count, and the pair of vectors named above is where that shows. ## The vectors @@ -29,22 +41,38 @@ The bound in this corpus is **128**. It is the same figure and the same counting | `jcs-depth-103-empty-object-leaf-one-past-bound` | reject | 129 | a per-child counter, which accepts this and rejects `jcs-depth-101` | | `jcs-depth-104-unbounded-recursion` | reject | 10000000 | a refusal from a crash | -Four `pair_invariants` in the corpus state the relations the individual vectors cannot: each accept and its one-level-deeper reject locate the boundary exactly, since an implementation that accepts both has no bound and one that rejects both has the bound off by one. `jcs-depth-104` is passed only when the implementation returns a rejection to its caller. A process that dies on that input has not rejected it. +Four `pair_invariants` in the corpus state the relations the individual vectors cannot. Each accept and its one-level-deeper reject locate the boundary exactly: an implementation that accepts both has no bound, and one that rejects both has the bound off by one. + +`jcs-depth-104` is passed only when the implementation returns a rejection to its caller. A process that dies on that input has not rejected it. ## How to run -Preimages are given as nesting rules rather than literal JSON, so this file stays four levels deep and can be read by a parser that enforces the bound it describes. Materialize a rule, then check the corpus against your canonicalizer: +Preimages are given as nesting rules, so `jcs_depth_v1.json` stays four levels deep and a parser that enforces the bound it describes can still read it. To check the corpus, materialize each rule and run it against your canonicalizer in five steps. + +1. For `i` from 0 to `count - 1`, emit an opener for `containers[i % len(containers)]`: `{"a":` for `object`, `[` for `array`. -1. For `i` from 0 to `count - 1`, emit the opener for `containers[i % len(containers)]`: `{"a":` for `object`, `[` for `array`. 2. Emit `leaf`, then the matching closers innermost first. -3. Compute SHA-256 over the materialized UTF-8 bytes and compare against `preimage_sha256`. Do this before canonicalizing anything. A vector built wrong cross-checks cleanly against itself and proves nothing. + +3. Compute SHA-256 over the materialized UTF-8 bytes and compare against `preimage_sha256`. Do this before canonicalizing anything, because a vector built wrong cross-checks cleanly against itself and proves nothing. + 4. For an `accept` vector, canonicalize the preimage, compare the bytes exactly against the base64-decoded `expected_jcs_bytes_b64`, and compare SHA-256 of those bytes against `expected_sha256`. -5. For a `reject` vector, confirm your canonicalizer returns a rejection to the caller. No canonical bytes are published for these: an implementation that produces bytes here has not applied the bound. -Every `expected_jcs_bytes_b64` in this corpus was produced by canonicalizing the materialized preimage with `rfc8785` 0.1.4, the Trail of Bits reference implementation that `jcs_edge_v1` also names, and independently reproduced with a second serializer before being pinned. +5. For a `reject` vector, confirm your canonicalizer returns a rejection to the caller. No canonical bytes are published for these, since an implementation that produces bytes here has not applied the bound. + +Every `expected_jcs_bytes_b64` in this corpus was produced by canonicalizing the materialized preimage with `rfc8785` 0.1.4, the Trail of Bits reference implementation that `jcs_edge_v1` also names. + +The independent check is a third party's run against the Go canonicalizer in [a2aproject/a2a-go#368](https://github.com/a2aproject/a2a-go/pull/368) at `eddcf62`, reported on [#2219](https://github.com/a2aproject/A2A/pull/2219#issuecomment-5749126086), which pinned this corpus's digests before running anything and then reproduced the expected bytes and SHA-256 for all four accept vectors. + +The same run shows what the `jcs-depth-104` row cannot tell you on its own. The Go implementation returned `exceeded max depth` to its caller, which the row scores as a pass. + +That refusal came from a nesting cap of 10,000 in the `encoding/json` decoder, and not from the canonicalizer, whose walk is recursive and carries no counter. + +An implementation can therefore pass `jcs-depth-104` on a property of its toolchain and still have no bound of its own. The reject vectors one level past the bound catch that case, and the same run accepted every one of them. ## Source and attribution -The two boundary cases are drawn from [`agent-evidence-vectors`](https://github.com/astrogilda/agent-evidence-vectors), which carries them as statement-level vectors in a suite you can run with `uvx agent-evidence-vectors`; each vector here records the corpus in its `trace` field, and the two with a direct counterpart carry that vector's identifier. `PROVENANCE.json` pins the source suite revision and corpus digest alongside a SHA-256 for each file in this directory. +The two boundary cases are drawn from [`agent-evidence-vectors`](https://github.com/probityai/agent-evidence-vectors), which carries them as statement-level vectors in a suite you can run with `uvx agent-evidence-vectors`. + +Each vector here records that corpus in its `trace` field, and the two with a direct counterpart carry that vector's identifier. `PROVENANCE.json` pins the source suite revision and corpus digest alongside a digest for each file in this directory. -Authored for this repository under Apache-2.0, the license the surrounding repository and the sibling `jcs_edge_v1` corpus both use. `jcs_edge_v1` is retained byte-verbatim from its author; this corpus is not a retention of anything, so it carries no upstream `LICENSE` or `NOTICE` to travel with it. +The corpus is authored for this repository under Apache-2.0, the license the surrounding repository and the sibling `jcs_edge_v1` corpus both use. `jcs_edge_v1` is retained byte-verbatim from its author. This corpus retains nothing from elsewhere, so it carries no upstream `LICENSE` or `NOTICE`.