From 03a9a32978b1d81b92b8771679964774faa0e03a Mon Sep 17 00:00:00 2001 From: chernistry Date: Sat, 19 Sep 2026 19:46:44 +0300 Subject: [PATCH 01/22] feat(trace-verify-ts): add a TypeScript verifier for TRACE v0.2 records A specification with one implementation says whatever that implementation does. This is a second one, written from spec/trace-v0.2.md and schema/, and it runs on WebCrypto alone: no network, no filesystem, no platform module and no runtime dependency, so a record can be checked where there is no Python runtime. What it implements, with the rule each check comes from: - the profile cutover of section 2, with the superseded v0.1 identifier rejected under its own code rather than as an unknown profile; - RFC 8785 canonicalization, including section 3.2.2's requirement to reject an integer outside the JCS safe range and the I-JSON rule that a lone surrogate has no canonical form; - Ed25519 over the pre-image of section 3.2.2, the record with its signature member absent; - canonical unpadded base64url for the signature and for JWK members (RFC 4648 sections 5 and 3.5), so one byte string has one spelling; - RFC 7638 thumbprints, and the confirmation-key binding of section 3.2.2; - freshness and nonce, with the bounds section 3.2.2 states; - the three revocation outcomes of section 3.2.3 against a bundle the caller passes in, with a bundle defect reported as an outcome rather than thrown; - the chain digest of section 3.1.3, over the complete parent record with its signature present. The schemas are compiled ahead of time with ajv, so nothing is evaluated or fetched at run time, `pattern` is read as ECMA-262 as JSON Schema 2020-12 requires, and `format: uri` is asserted rather than annotated. The generator records the SHA-256 of every schema file it read. The order of the checks is the Python implementation's, so both report the same first failure for the same record. Signed-off-by: chernistry --- trace-verify-ts/.gitignore | 4 + trace-verify-ts/README.md | 109 ++ trace-verify-ts/package-lock.json | 934 ++++++++++++++++++ trace-verify-ts/package.json | 36 + .../scripts/generate-validators.mjs | 78 ++ trace-verify-ts/src/base64url.ts | 92 ++ trace-verify-ts/src/crypto.ts | 122 +++ trace-verify-ts/src/delegation.ts | 143 +++ trace-verify-ts/src/errors.ts | 75 ++ trace-verify-ts/src/index.ts | 45 + trace-verify-ts/src/jcs.ts | 166 ++++ trace-verify-ts/src/jwk.ts | 75 ++ trace-verify-ts/src/revocation.ts | 246 +++++ trace-verify-ts/src/schema.ts | 105 ++ trace-verify-ts/src/text.ts | 99 ++ trace-verify-ts/src/uri.ts | 57 ++ trace-verify-ts/src/verify.ts | 360 +++++++ trace-verify-ts/tsconfig.json | 24 + 18 files changed, 2770 insertions(+) create mode 100644 trace-verify-ts/.gitignore create mode 100644 trace-verify-ts/README.md create mode 100644 trace-verify-ts/package-lock.json create mode 100644 trace-verify-ts/package.json create mode 100644 trace-verify-ts/scripts/generate-validators.mjs create mode 100644 trace-verify-ts/src/base64url.ts create mode 100644 trace-verify-ts/src/crypto.ts create mode 100644 trace-verify-ts/src/delegation.ts create mode 100644 trace-verify-ts/src/errors.ts create mode 100644 trace-verify-ts/src/index.ts create mode 100644 trace-verify-ts/src/jcs.ts create mode 100644 trace-verify-ts/src/jwk.ts create mode 100644 trace-verify-ts/src/revocation.ts create mode 100644 trace-verify-ts/src/schema.ts create mode 100644 trace-verify-ts/src/text.ts create mode 100644 trace-verify-ts/src/uri.ts create mode 100644 trace-verify-ts/src/verify.ts create mode 100644 trace-verify-ts/tsconfig.json diff --git a/trace-verify-ts/.gitignore b/trace-verify-ts/.gitignore new file mode 100644 index 00000000..629021ef --- /dev/null +++ b/trace-verify-ts/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src/generated/ +differential/build/ diff --git a/trace-verify-ts/README.md b/trace-verify-ts/README.md new file mode 100644 index 00000000..9f1edc53 --- /dev/null +++ b/trace-verify-ts/README.md @@ -0,0 +1,109 @@ +# trace-verify-ts + +A TypeScript verifier for TRACE v0.2 Trust Records. + +It is a second implementation, written from [`spec/trace-v0.2.md`](../spec/trace-v0.2.md) +and the schemas in [`schema/`](../schema), and held against the Python implementation in +this repository by a differential harness. Two implementations that agree are evidence the +specification says what it means; where they disagree, the disagreement is written down +rather than smoothed over. + +It runs on WebCrypto alone: no network, no filesystem, no platform module, no runtime +dependency. That is what a browser, an edge worker or a service mesh sidecar needs to check +a record without a Python runtime. + +## Using it + +```ts +import { verifyRecord } from "trace-verify-ts"; + +const result = await verifyRecord(record, { + trustedKey: issuerJwk, // an Ed25519 public JWK the caller already trusts + maxAgeSeconds: 86400, // section 3.2.2 default + revocationBundle: bundle, // optional, section 3.2.3 + trustedBundleKeys: [logJwk], +}); + +result.trustedKeyThumbprint; // RFC 7638 thumbprint of the verifying key +result.revocation.outcome; // "verified" | "unverified_for_revocation" | "no_check_performed" +``` + +A rejection is a `TraceVerificationError` carrying a stable `code`. A resolved result still +has to be read: `revocation.outcome` is the verifier's appraisal input, and section 3.2.3 +states that `unverified_for_revocation` and `no_check_performed` MUST NOT be reported as an +affirming appraisal. + +## What it checks, and where each rule comes from + +| Check | Source | +|---|---| +| `eat_profile` is `tag:agentrust-io.com,2026:trace-v0.2`, and the v0.1 identifier is rejected rather than accepted | spec section 2, "the cutover is cutover, not coexistence" | +| The record conforms to `schema/trace-claim.json`, Draft 2020-12, with `pattern` read as ECMA-262 and `format: uri` asserted | schema, and JSON Schema 2020-12 section 7 | +| The signature is Ed25519 over the RFC 8785 form of the record with `signature` absent | spec section 3.2.2 | +| The signature is canonical unpadded base64url: no padding, unused trailing bits zero | RFC 4648 sections 5 and 3.5 | +| An integer outside the JCS safe range MUST be rejected rather than canonicalised | spec section 3.2.2, RFC 8785 Appendix B | +| `cnf.jwk` is an Ed25519 key whose RFC 7638 thumbprint is the verifying key's | spec section 3.2.2 | +| Freshness: `iat` is an integer, the record is not past `maxAgeSeconds` and not further ahead than `maxFutureSkewSeconds` | spec section 3.2.2 | +| Revocation is keyed on the trusted key and never on the record's own `cnf.jwk` | spec section 3.2.3 | +| A revocation bundle a verifier cannot ground an answer on is reported as `unverified_for_revocation`, which MUST NOT be read as an affirming appraisal | spec section 3.2.3 | +| A statement naming the key rejects the record whatever the bundle's age | spec section 3.2.3 | +| `delegation.parent_record_hash` is taken over the complete parent record, signature included | spec section 3.1.3 | +| A chain digest whose prefix names an unsupported algorithm MUST be rejected rather than computed with another | spec section 3.1.3 | + +The check order inside `verifyRecord` is the Python implementation's, so both report the +same first failure for the same record. It is documented beside the function. + +## What it does not do + +- No chain walk. The `delegation` block is normative in v0.2 and what a verifier does with a + chain of them is not, so this package ships one link (`verifyDelegationLink`) and leaves + the policy to its caller. See [`docs/rfcs/a2a-delegation-profile.md`](../docs/rfcs/a2a-delegation-profile.md). +- No signing. A verifier that can sign is a verifier that can be made to sign. +- No key or bundle fetching. Both are passed in, which is what keeps the package offline. +- No ES256 or ES384 bundle signatures. The bundle schema admits them; a signature nobody + checked grounds nothing, so they are reported as `bundle_signature_unsupported`. + +## Building + +``` +npm ci +npm run build # generates the validators from ../schema, then compiles +npm test +``` + +`npm run build` compiles the schemas ahead of time with ajv into +`src/generated/validators.js`, so nothing is evaluated or fetched at run time, and records +the SHA-256 of each schema file it read. A test compares those digests against `../schema`, +so a schema edit that is not rebuilt fails rather than passing quietly. + +## The differential harness + +``` +python differential/generate_cases.py [--external /tests/vectors] +python differential/oracle.py # the Python implementation's verdicts +node differential/run.mjs # this implementation's verdicts +python differential/compare.py +``` + +The corpus is one JSON file holding each record as *text*, because several cases exist to +probe a difference that only survives in text. Both runners parse the same bytes with their +own JSON parser. A case agrees when both sides reach the same verdict for the same stated +reason, and every disagreement is argued in +[`differential/known-divergences.json`](differential/known-divergences.json), matched on +both the case and the pair of reported reasons, so a new disagreement in a family already +listed is reported rather than absorbed. + +Latest run, against agentrust-trace 0.10.0: + +| Group | Cases | Identical | +|---|---:|---:| +| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1053 | +| RFC 8785 canonicalization corpus | 34 | 33 | +| RFC 7638 thumbprints | 14 | 14 | +| Chain digests over the delegation corpus | 134 | 134 | +| This repository's conformance vectors | 34 | 34 | +| Published conformance vectors | 12 | 12 | +| **Total** | **1644** | **1280** | + +Every published vector agrees. The 364 remaining cases are 13 documented divergence classes +in the adversarial matrix, each one appearing once per verifier configuration. diff --git a/trace-verify-ts/package-lock.json b/trace-verify-ts/package-lock.json new file mode 100644 index 00000000..0b6ba791 --- /dev/null +++ b/trace-verify-ts/package-lock.json @@ -0,0 +1,934 @@ +{ + "name": "trace-verify-ts", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trace-verify-ts", + "version": "0.1.0", + "devDependencies": { + "ajv": "8.20.0", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + } + } +} diff --git a/trace-verify-ts/package.json b/trace-verify-ts/package.json new file mode 100644 index 00000000..29bcd3b5 --- /dev/null +++ b/trace-verify-ts/package.json @@ -0,0 +1,36 @@ +{ + "name": "trace-verify-ts", + "version": "0.1.0", + "private": true, + "description": "TypeScript verifier for TRACE v0.2 Trust Records: zero network, WebCrypto only.", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "scripts", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "sideEffects": false, + "scripts": { + "generate": "node scripts/generate-validators.mjs", + "build": "npm run generate && tsc -p tsconfig.json", + "test": "npm run build && node --test tests/*.test.mjs", + "differential": "node differential/run.mjs" + }, + "devDependencies": { + "ajv": "8.20.0", + "typescript": "7.0.2" + } +} diff --git a/trace-verify-ts/scripts/generate-validators.mjs b/trace-verify-ts/scripts/generate-validators.mjs new file mode 100644 index 00000000..c9d70a41 --- /dev/null +++ b/trace-verify-ts/scripts/generate-validators.mjs @@ -0,0 +1,78 @@ +// Compile the repository's JSON Schemas into a standalone ES module. +// +// Run by `npm run build`. The output, src/generated/validators.js, is not +// committed: it is derived from the schema files on every build, so it cannot +// drift from them. TRACE_SCHEMA_DIR selects another schema directory, which is +// how the package is built against a released schema set for comparison. + +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import Ajv2020 from "ajv/dist/2020.js"; +import standaloneCode from "ajv/dist/standalone/index.js"; +import { _ } from "ajv"; + +const here = dirname(fileURLToPath(import.meta.url)); +const schemaDir = resolve(process.env.TRACE_SCHEMA_DIR ?? join(here, "..", "..", "schema")); +const files = ["trace-claim.json", "trace-revocation-bundle.json", "trace-revocation.json"]; + +const texts = Object.fromEntries(files.map((name) => [name, readFileSync(join(schemaDir, name), "utf8")])); +const schemas = Object.fromEntries(files.map((name) => [name, JSON.parse(texts[name])])); +const digests = Object.fromEntries( + files.map((name) => [name, createHash("sha256").update(readFileSync(join(schemaDir, name))).digest("hex")]), +); + +const ajv = new Ajv2020.default({ + schemas: files.map((name) => schemas[name]), + allErrors: true, + // The schemas use if/then without a sibling "type" and union types in + // canonicalizableValue; both are ordinary Draft 2020-12 and not errors. + strict: true, + strictTypes: false, + strictRequired: false, + allowUnionTypes: true, + validateFormats: true, + // A placeholder at compile time; the generated code calls the real checker + // through the __formats binding imported below. + formats: { uri: () => true }, + code: { source: true, esm: true, formats: _`__formats` }, +}); + +let code = standaloneCode.default(ajv, { + validateRecord: schemas["trace-claim.json"].$id, + validateBundle: schemas["trace-revocation-bundle.json"].$id, +}); + +const UCS2 = 'require("ajv/dist/runtime/ucs2length").default'; +code = code.split(UCS2).join("__codePointLength"); +code = code.replace(/^"use strict";/, ""); +if (/\brequire\(/.test(code)) { + const found = code.match(/require\([^)]*\)/g); + throw new Error(`generated validators still require a runtime module: ${found.join(", ")}`); +} + +const header = [ + "// Generated by scripts/generate-validators.mjs from the schema files named in", + "// schemaDigests. Do not edit; run `npm run build`.", + 'import { formats as __formats } from "../uri.js";', + 'import { codePointLength as __codePointLength } from "../text.js";', + `export const schemaDigests = ${JSON.stringify(digests)};`, + "", +].join("\n"); + +const outDir = join(here, "..", "src", "generated"); +mkdirSync(outDir, { recursive: true }); +writeFileSync(join(outDir, "validators.js"), `${header}${code}\n`); +writeFileSync( + join(outDir, "validators.d.ts"), + [ + "// Generated by scripts/generate-validators.mjs. Do not edit.", + "export declare const schemaDigests: Readonly>;", + "export declare function validateRecord(data: unknown): boolean;", + "export declare function validateBundle(data: unknown): boolean;", + "", + ].join("\n"), +); +console.log(`validators generated from ${schemaDir}`); diff --git a/trace-verify-ts/src/base64url.ts b/trace-verify-ts/src/base64url.ts new file mode 100644 index 00000000..5d600cc9 --- /dev/null +++ b/trace-verify-ts/src/base64url.ts @@ -0,0 +1,92 @@ +/** + * base64url without padding (RFC 4648 section 5), decoded strictly. + * + * Section 3.2.2 carries an embedded signature as "base64url, no padding", and + * RFC 7515 section 2 defines the same form for JWK members. A value is accepted + * only in that form: characters from the URL-safe alphabet, no `=` padding, no + * whitespace, a length that is not 1 more than a multiple of 4, and unused + * trailing bits set to zero (RFC 4648 section 3.5). The last rule makes the + * encoding canonical, so one byte string has exactly one accepted spelling. + */ + +import { fail } from "./errors.js"; + +const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +const DECODE = (() => { + const table = new Int16Array(128).fill(-1); + for (let i = 0; i < ALPHABET.length; i++) { + table[ALPHABET.charCodeAt(i)] = i; + } + return table; +})(); + +/** Why a value was refused, or `null` for a well-formed value. */ +export function base64urlProblem(value: unknown): string | null { + if (typeof value !== "string") { + return `expected a string, got ${typeof value}`; + } + if (value.length % 4 === 1) { + return `length ${value.length} is not a valid unpadded base64url length`; + } + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 128 || DECODE[unit] === -1) { + return `character at offset ${i} is outside the base64url alphabet`; + } + } + const rest = value.length % 4; + if (rest !== 0) { + const last = DECODE[value.charCodeAt(value.length - 1)] as number; + const unusedBits = rest === 2 ? 0b1111 : 0b11; + if ((last & unusedBits) !== 0) { + return "unused trailing bits are not zero, so the encoding is not canonical"; + } + } + return null; +} + +/** Decode *value*, or return `null` when it is not canonical unpadded base64url. */ +export function decodeBase64url(value: unknown): Uint8Array | null { + if (typeof value !== "string") { + return null; + } + if (base64urlProblem(value) !== null) { + return null; + } + const out = new Uint8Array(Math.floor((value.length * 3) / 4)); + let buffer = 0; + let bits = 0; + let index = 0; + for (let i = 0; i < value.length; i++) { + buffer = ((buffer << 6) | (DECODE[value.charCodeAt(i)] as number)) & 0xffff; + bits += 6; + if (bits >= 8) { + bits -= 8; + out[index++] = (buffer >> bits) & 0xff; + } + } + return out; +} + +/** Encode *bytes* as unpadded base64url. */ +export function encodeBase64url(bytes: Uint8Array): string { + if (!ArrayBuffer.isView(bytes)) { + fail("invalid_argument", "encodeBase64url takes a Uint8Array"); + } + let out = ""; + let buffer = 0; + let bits = 0; + for (const byte of bytes) { + buffer = ((buffer << 8) | byte) & 0xffff; + bits += 8; + while (bits >= 6) { + bits -= 6; + out += ALPHABET[(buffer >> bits) & 0x3f]; + } + } + if (bits > 0) { + out += ALPHABET[(buffer << (6 - bits)) & 0x3f]; + } + return out; +} diff --git a/trace-verify-ts/src/crypto.ts b/trace-verify-ts/src/crypto.ts new file mode 100644 index 00000000..d7026017 --- /dev/null +++ b/trace-verify-ts/src/crypto.ts @@ -0,0 +1,122 @@ +/** + * WebCrypto access. Nothing here reaches the network or any module outside the + * platform: the verifier runs wherever `globalThis.crypto.subtle` exists, which + * covers current browsers, Node.js 20 and later, Deno, Bun and edge workers. + */ + +import { fail, type FailureCode } from "./errors.js"; +import { decodeBase64url } from "./base64url.js"; +import { isPlainObject, own } from "./text.js"; + +function subtle(): SubtleCrypto { + const value = (globalThis as { crypto?: { subtle?: SubtleCrypto } }).crypto?.subtle; + if (value === undefined) { + fail("crypto_unavailable", "this runtime exposes no WebCrypto (globalThis.crypto.subtle)"); + } + return value; +} + +function isNotSupported(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + (error as { name?: unknown }).name === "NotSupportedError" + ); +} + +/** A short, never-throwing description of an untrusted value for an error message. */ +export function show(value: unknown): string { + return typeof value === "string" ? JSON.stringify(value.slice(0, 64)) : typeof value; +} + +export async function digest(algorithm: "SHA-256" | "SHA-384", data: Uint8Array): Promise { + return new Uint8Array(await subtle().digest(algorithm, data as Uint8Array)); +} + +export function hex(bytes: Uint8Array): string { + let out = ""; + for (const byte of bytes) { + out += byte.toString(16).padStart(2, "0"); + } + return out; +} + +/** + * Import an Ed25519 public key from a JWK, refusing every other key type. + * + * `role` selects which failure codes a refusal carries, so the caller learns + * whether the trusted key or the record's own `cnf.jwk` was at fault. The + * checks and their order follow the reference: `kty`, then `crv`, then the + * presence of `x`, then its encoding, then its length. + */ +export async function importEd25519( + jwk: unknown, + role: "trusted_key" | "cnf_key" | "bundle_key", +): Promise { + const unsupported: FailureCode = + role === "trusted_key" ? "trusted_key_unsupported" : role === "cnf_key" ? "cnf_key_unsupported" : "invalid_argument"; + const malformed: FailureCode = + role === "trusted_key" ? "trusted_key_malformed" : role === "cnf_key" ? "cnf_key_malformed" : "invalid_argument"; + const label = role === "trusted_key" ? "trusted key" : role === "cnf_key" ? "record cnf.jwk" : "bundle key"; + + if (!isPlainObject(jwk)) { + fail(unsupported, `${label} must be a JWK object`); + } + const kty = own(jwk, "kty"); + const crv = own(jwk, "crv"); + const x = own(jwk, "x"); + if (kty !== "OKP") { + fail(unsupported, `${label} has kty ${show(kty)}; expected "OKP" for Ed25519`); + } + if (crv !== "Ed25519") { + fail(unsupported, `${label} has crv ${show(crv)}; expected "Ed25519"`); + } + if (x === undefined || x === null || x === "") { + fail(malformed, `${label} has no "x" member`); + } + if (typeof x !== "string") { + fail(malformed, `${label} "x" must be a base64url string`); + } + const raw = decodeBase64url(x); + if (raw === null) { + fail(malformed, `${label} "x" is not canonical unpadded base64url`); + } + if (raw.length !== 32) { + fail(malformed, `${label} "x" decodes to ${raw.length} bytes; an Ed25519 public key is 32 bytes`); + } + try { + return await subtle().importKey("raw", raw as Uint8Array, { name: "Ed25519" }, false, ["verify"]); + } catch (error) { + if (isNotSupported(error)) { + fail("crypto_unavailable", "this runtime's WebCrypto does not implement Ed25519", { + cause: error, + }); + } + fail(malformed, `${label} "x" is not an Ed25519 public key this runtime accepts`, { + cause: error, + }); + } +} + +/** Ed25519 verification. Any refusal by the platform counts as a signature that does not verify. */ +export async function verifyEd25519( + key: CryptoKey, + signature: Uint8Array, + message: Uint8Array, +): Promise { + try { + return await subtle().verify( + { name: "Ed25519" }, + key, + signature as Uint8Array, + message as Uint8Array, + ); + } catch (error) { + if (isNotSupported(error)) { + fail("crypto_unavailable", "this runtime's WebCrypto does not implement Ed25519", { + cause: error, + }); + } + return false; + } +} diff --git a/trace-verify-ts/src/delegation.ts b/trace-verify-ts/src/delegation.ts new file mode 100644 index 00000000..afeac330 --- /dev/null +++ b/trace-verify-ts/src/delegation.ts @@ -0,0 +1,143 @@ +/** + * The delegation link of section 3.1.3: the digest a child record commits its + * parent hop to. + * + * The pre-image is the complete parent record as published, with its `signature` + * member present, canonicalised with RFC 8785 (section 3.1.3): + * + * delegation.parent_record_hash = "sha256:" + hex(SHA-256(JCS(parent_record))) + * + * That is deliberately not the signature pre-image of section 3.2.2, which has + * `signature` absent because a signature cannot cover itself. Digesting the record + * as published means a verifier digests exactly what it received, with no + * member-removal step to get wrong. + * + * What a verifier does with a *chain* of these links is not normative in v0.2, so + * this module stops at one link. It gives a caller walking a chain the two + * operations the specification does fix: recomputing the digest, and comparing a + * child's commitment against a candidate parent. + */ + +import { digest, hex } from "./crypto.js"; +import { fail } from "./errors.js"; +import { canonicalize } from "./jcs.js"; +import { isPlainObject, own, timingSafeEqual } from "./text.js"; + +/** The digest algorithms the schema's pattern permits for a chain digest (section 3.1.3). */ +export const DELEGATION_DIGEST_ALGORITHMS = Object.freeze(["sha256", "sha384"] as const); + +export type DelegationDigestAlgorithm = (typeof DELEGATION_DIGEST_ALGORITHMS)[number]; + +const WEBCRYPTO_NAME: Readonly> = { + sha256: "SHA-256", + sha384: "SHA-384", +}; + +/** + * The chain digest of *record*, in the form `":"` (section 3.1.3). + * + * The record is digested whole, signature included, over its RFC 8785 form. The + * RFC 8785 key order is by UTF-16 code unit, which agrees with code-point order + * across the Basic Multilingual Plane and diverges above it, so an implementation + * that sorts by code point computes correct digests for ASCII records + * indefinitely and a wrong one the first time a key carries a supplementary-plane + * character. + */ +export async function parentRecordHash( + record: unknown, + algorithm: DelegationDigestAlgorithm = "sha256", +): Promise { + if (typeof algorithm !== "string" || !Object.hasOwn(WEBCRYPTO_NAME, algorithm)) { + fail( + "delegation_algorithm_unsupported", + `unsupported chain digest algorithm ${typeof algorithm === "string" ? JSON.stringify(algorithm) : typeof algorithm}`, + ); + } + return `${algorithm}:${hex(await digest(WEBCRYPTO_NAME[algorithm], canonicalize(record)))}`; +} + +export interface DelegationLinkOptions { + /** + * The algorithms this verifier implements. A link naming any other algorithm is + * reported as unsupported and MUST NOT fall back to another one (section 3.1.3). + */ + readonly supportedDigestAlgorithms?: readonly string[]; +} + +export interface DelegationLink { + /** The digest as the child states it. */ + readonly parentRecordHash: string; + readonly algorithm: DelegationDigestAlgorithm; + /** The delegation credential the child names as its authority for this hop. */ + readonly credentialId: string; +} + +/** + * Check that *parent* is the record *child* commits to in its `delegation` block. + * + * Returns the link when the recomputed digest of *parent* equals the child's + * `parent_record_hash`. Refuses with `delegation_absent` when the child has no + * `delegation` block (a root hop, which this function's caller has to handle as + * such rather than treat as a broken link), `delegation_algorithm_unsupported` + * when the prefix names an algorithm this verifier does not implement, and + * `delegation_parent_mismatch` when the digests differ. + * + * This checks the link and nothing else: whether either record's own signature + * verifies is `verifyRecord`'s question, and whether the credential authorises + * what the child claims is not fixed by v0.2. + */ +export async function verifyDelegationLink( + child: unknown, + parent: unknown, + options: DelegationLinkOptions = {}, +): Promise { + if (options === null || typeof options !== "object") { + fail("invalid_argument", "verifyDelegationLink takes an options object"); + } + const configured = (options as DelegationLinkOptions).supportedDigestAlgorithms; + if (configured !== undefined && (!Array.isArray(configured) || configured.some((a) => typeof a !== "string"))) { + fail("invalid_argument", "supportedDigestAlgorithms must be an array of algorithm names"); + } + const supported: readonly string[] = configured ?? DELEGATION_DIGEST_ALGORITHMS; + if (!isPlainObject(child)) { + fail("record_not_object", "a Trust Record is a JSON object"); + } + const delegation = own(child, "delegation"); + if (delegation === undefined || delegation === null) { + fail("delegation_absent", "the record has no delegation block, so it claims no parent hop"); + } + if (!isPlainObject(delegation)) { + fail("schema_invalid", "the record's delegation member is not an object", { path: "delegation" }); + } + const stated = own(delegation, "parent_record_hash"); + const credentialId = own(delegation, "credential_id"); + if (typeof stated !== "string" || typeof credentialId !== "string" || credentialId === "") { + fail("schema_invalid", "the delegation block needs a parent_record_hash and a credential_id", { + path: "delegation", + }); + } + const separator = stated.indexOf(":"); + const algorithm = separator === -1 ? "" : stated.slice(0, separator); + if (!supported.includes(algorithm)) { + fail( + "delegation_algorithm_unsupported", + `the link names digest algorithm ${JSON.stringify(algorithm)}, which this verifier does not ` + + "implement; section 3.1.3 forbids falling back to another one", + ); + } + if (!Object.hasOwn(WEBCRYPTO_NAME, algorithm)) { + fail( + "delegation_algorithm_unsupported", + `digest algorithm ${JSON.stringify(algorithm)} is not one this build can compute`, + ); + } + const alg = algorithm as DelegationDigestAlgorithm; + const computed = await parentRecordHash(parent, alg); + if (!timingSafeEqual(computed, stated)) { + fail( + "delegation_parent_mismatch", + "the candidate parent record does not digest to the value the child commits to", + ); + } + return { parentRecordHash: stated, algorithm: alg, credentialId }; +} diff --git a/trace-verify-ts/src/errors.ts b/trace-verify-ts/src/errors.ts new file mode 100644 index 00000000..2a4ad08c --- /dev/null +++ b/trace-verify-ts/src/errors.ts @@ -0,0 +1,75 @@ +/** + * Failure codes and the single error type every rejection is reported with. + * + * Each code names one check. The order in which `verifyRecord` runs its checks + * is documented beside the function and is the order the Python reference uses, + * so two implementations report the same first failure for the same input. + */ + +export const FAILURE_CODES = [ + // Caller-supplied configuration, not record data. + "invalid_argument", + "crypto_unavailable", + // Envelope, in check order. + "record_not_object", + "profile_missing", + "profile_superseded", + "profile_unsupported", + "signature_missing", + "signature_malformed", + "schema_invalid", + // Trust anchoring. + "trusted_key_missing", + "trusted_key_unsupported", + "trusted_key_malformed", + // Revocation, section 3.2.3. + "revocation_unavailable", + "key_revoked", + // Confirmation-key binding, section 3.2.2. + "cnf_key_unsupported", + "cnf_key_malformed", + "cnf_key_mismatch", + // Freshness, section 3.2.2. + "iat_invalid", + "record_in_future", + "record_stale", + "nonce_mismatch", + // Signature binding, section 3.2.2. + "canonicalization_failed", + "signature_invalid", + // Helpers outside verifyRecord. + "jwk_invalid", + "delegation_absent", + "delegation_algorithm_unsupported", + "delegation_parent_mismatch", +] as const; + +export type FailureCode = (typeof FAILURE_CODES)[number]; + +/** + * The only error type this package throws. + * + * `code` is stable and meant for programs; `message` is for people and may + * change. `path` is set for `schema_invalid` and names the location of the + * reported violation in the form the Python reference prints it: property names + * and array indexes joined with dots, or `` for the record itself. + */ +export class TraceVerificationError extends Error { + readonly code: FailureCode; + readonly path: string | undefined; + + constructor(code: FailureCode, message: string, options?: { path?: string; cause?: unknown }) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = "TraceVerificationError"; + this.code = code; + this.path = options?.path; + } +} + +export function fail( + code: FailureCode, + message: string, + options?: { path?: string; cause?: unknown }, +): never { + throw new TraceVerificationError(code, message, options); +} diff --git a/trace-verify-ts/src/index.ts b/trace-verify-ts/src/index.ts new file mode 100644 index 00000000..b824a346 --- /dev/null +++ b/trace-verify-ts/src/index.ts @@ -0,0 +1,45 @@ +/** + * A TypeScript verifier for TRACE v0.2 Trust Records. + * + * Nothing here reaches the network, reads a file or imports a Node built-in: the + * verifier runs in a browser, an edge worker, Deno, Bun and Node.js 20 and later + * on WebCrypto alone. Every input a caller cannot vouch for, including the record + * and a revocation bundle, is treated as untrusted data. + * + * Start at `verifyRecord`. + */ + +export { decodeBase64url, encodeBase64url } from "./base64url.js"; +export { + DELEGATION_DIGEST_ALGORITHMS, + parentRecordHash, + verifyDelegationLink, + type DelegationDigestAlgorithm, + type DelegationLink, + type DelegationLinkOptions, +} from "./delegation.js"; +export { FAILURE_CODES, TraceVerificationError, type FailureCode } from "./errors.js"; +export { canonicalize, canonicalJson, JCS_SAFE_INTEGER } from "./jcs.js"; +export { jwkThumbprint, keyIdentifiers } from "./jwk.js"; +export { + bundleDigest, + checkRevocationBundle, + NO_CHECK, + type BundleCheckOptions, + type RevocationCause, + type RevocationCheck, + type RevocationOutcome, +} from "./revocation.js"; +export { + bundleSchemaViolations, + recordSchemaViolations, + SCHEMA_DIGESTS, + type SchemaViolation, +} from "./schema.js"; +export { + TRACE_PROFILE_V0_2, + verifyRecord, + type RevocationStore, + type VerificationResult, + type VerifyOptions, +} from "./verify.js"; diff --git a/trace-verify-ts/src/jcs.ts b/trace-verify-ts/src/jcs.ts new file mode 100644 index 00000000..8723cb6d --- /dev/null +++ b/trace-verify-ts/src/jcs.ts @@ -0,0 +1,166 @@ +/** + * RFC 8785 JSON Canonicalization Scheme, with the integer bound of section 3.2.2. + * + * Section 3.2.2 fixes the signature pre-image as the RFC 8785 form of the record. + * The rules implemented here: + * + * - Object members are sorted by the UTF-16 code units of their names + * (RFC 8785 section 3.2.3). JavaScript string comparison is exactly that order. + * - No whitespace between tokens. + * - Strings are emitted as UTF-8 with only U+0022, U+005C and U+0000 to U+001F + * escaped, using the short forms for backspace, tab, line feed, form feed and + * carriage return and lowercase `\u00xx` otherwise (RFC 8785 section 3.2.2.2). + * - Numbers use the ECMAScript Number-to-String form (RFC 8785 section + * 3.2.2.3), which is what `String(number)` produces; negative zero becomes `0`. + * + * Refused, each as `canonicalization_failed`: + * + * - an integer outside -9007199254740991 to 9007199254740991, which section + * 3.2.2 raises from RFC 8785's SHOULD to a MUST; + * - NaN and the infinities, which have no JSON form; + * - a string or member name holding an unpaired surrogate, which has no UTF-8 + * form (RFC 8785 section 3.1 requires I-JSON input, RFC 7493 section 2.1); + * - anything that is not JSON data: `undefined`, functions, symbols, bigints, + * class instances, array holes, and cyclic structures. + */ + +import { fail } from "./errors.js"; +import { isPlainObject, unpairedSurrogateAt, utf8 } from "./text.js"; + +/** The widest integer range section 3.2.2 lets a canonicalized object carry. */ +export const JCS_SAFE_INTEGER = 9007199254740991; + +const SHORT_ESCAPES: Record = { + 0x08: "\\b", + 0x09: "\\t", + 0x0a: "\\n", + 0x0c: "\\f", + 0x0d: "\\r", + 0x22: '\\"', + 0x5c: "\\\\", +}; + +function serializeString(value: string, where: string): string { + const bad = unpairedSurrogateAt(value); + if (bad !== -1) { + fail( + "canonicalization_failed", + `${where} holds an unpaired surrogate at offset ${bad}; it has no UTF-8 form`, + ); + } + let out = '"'; + let start = 0; + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit < 0x20 || unit === 0x22 || unit === 0x5c) { + out += value.slice(start, i); + out += SHORT_ESCAPES[unit] ?? `\\u00${unit.toString(16).padStart(2, "0")}`; + start = i + 1; + } + } + return `${out}${value.slice(start)}"`; +} + +function serializeNumber(value: number, where: string): string { + if (!Number.isFinite(value)) { + fail("canonicalization_failed", `${where} is ${String(value)}, which has no JSON form`); + } + if (Number.isInteger(value) && Math.abs(value) > JCS_SAFE_INTEGER) { + fail( + "canonicalization_failed", + `${where} is an integer outside -${JCS_SAFE_INTEGER} to ${JCS_SAFE_INTEGER}; ` + + "section 3.2.2 requires such a value to be rejected, because distinct integers " + + "past that bound share one canonical form", + ); + } + return Object.is(value, -0) ? "0" : String(value); +} + +function describe(path: readonly (string | number)[]): string { + return path.length === 0 ? "the value" : `the value at ${path.map(String).join(".")}`; +} + +function serialize( + value: unknown, + path: (string | number)[], + ancestors: Set, + parts: string[], +): void { + if (value === null) { + parts.push("null"); + return; + } + switch (typeof value) { + case "boolean": + parts.push(value ? "true" : "false"); + return; + case "string": + parts.push(serializeString(value, describe(path))); + return; + case "number": + parts.push(serializeNumber(value, describe(path))); + return; + case "object": + break; + default: + fail("canonicalization_failed", `${describe(path)} is a ${typeof value}, which is not JSON`); + } + const node = value as object; + if (ancestors.has(node)) { + fail("canonicalization_failed", `${describe(path)} contains itself`); + } + ancestors.add(node); + if (Array.isArray(node)) { + parts.push("["); + for (let i = 0; i < node.length; i++) { + if (!Object.hasOwn(node, i)) { + fail("canonicalization_failed", `${describe([...path, i])} is an array hole`); + } + if (i > 0) { + parts.push(","); + } + path.push(i); + serialize(node[i], path, ancestors, parts); + path.pop(); + } + parts.push("]"); + } else if (isPlainObject(node)) { + const names = Object.keys(node).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + parts.push("{"); + names.forEach((name, index) => { + if (index > 0) { + parts.push(","); + } + parts.push(serializeString(name, `a member name in ${describe(path)}`)); + parts.push(":"); + path.push(name); + serialize(node[name], path, ancestors, parts); + path.pop(); + }); + parts.push("}"); + } else { + fail("canonicalization_failed", `${describe(path)} is not a plain JSON object or array`); + } + ancestors.delete(node); +} + +/** The RFC 8785 text of *value*. */ +export function canonicalJson(value: unknown): string { + const parts: string[] = []; + try { + serialize(value, [], new Set(), parts); + } catch (error) { + if (error instanceof RangeError) { + fail("canonicalization_failed", "the value is nested too deeply to canonicalize", { + cause: error, + }); + } + throw error; + } + return parts.join(""); +} + +/** The RFC 8785 bytes of *value*: the UTF-8 encoding of `canonicalJson(value)`. */ +export function canonicalize(value: unknown): Uint8Array { + return utf8(canonicalJson(value)); +} diff --git a/trace-verify-ts/src/jwk.ts b/trace-verify-ts/src/jwk.ts new file mode 100644 index 00000000..3d084e3b --- /dev/null +++ b/trace-verify-ts/src/jwk.ts @@ -0,0 +1,75 @@ +/** + * RFC 7638 JWK Thumbprints, and the identifiers a revocation source may list a key under. + */ + +import { encodeBase64url } from "./base64url.js"; +import { digest } from "./crypto.js"; +import { fail, TraceVerificationError } from "./errors.js"; +import { canonicalize } from "./jcs.js"; +import { isPlainObject, own } from "./text.js"; + +/** + * The required members of each key type (RFC 7638 section 3.2, and RFC 8037 + * section 2 for OKP). Only these are hashed. `oct` is left out on purpose: it + * carries a symmetric secret, and every key this package handles is public. + */ +const THUMBPRINT_MEMBERS: Readonly> = { + EC: ["crv", "kty", "x", "y"], + OKP: ["crv", "kty", "x"], + RSA: ["e", "kty", "n"], +}; + +/** + * The RFC 7638 thumbprint of *jwk*: base64url SHA-256 of the required members, + * serialized with RFC 8785 (their names are ASCII, so that is the member order + * RFC 7638 section 3.3 specifies). + * + * Refuses, as `jwk_invalid`, a value that is not an object, an unknown `kty`, + * and a required member that is absent, empty or not a string. + */ +export async function jwkThumbprint(jwk: unknown): Promise { + if (!isPlainObject(jwk)) { + fail("jwk_invalid", "a JWK must be a JSON object"); + } + const kty = own(jwk, "kty"); + const members = typeof kty === "string" && Object.hasOwn(THUMBPRINT_MEMBERS, kty) + ? (THUMBPRINT_MEMBERS[kty] as readonly string[]) + : undefined; + if (members === undefined) { + fail("jwk_invalid", "cannot compute a JWK thumbprint for this kty; expected EC, OKP or RSA"); + } + const required: Record = {}; + for (const name of members) { + const value = own(jwk, name); + if (typeof value !== "string" || value === "") { + fail("jwk_invalid", `the JWK is missing required thumbprint member "${name}"`); + } + required[name] = value; + } + let bytes: Uint8Array; + try { + bytes = canonicalize(required); + } catch (error) { + if (error instanceof TraceVerificationError) { + fail("jwk_invalid", `the JWK's thumbprint members have no RFC 8785 form: ${error.message}`, { + cause: error, + }); + } + throw error; + } + return encodeBase64url(await digest("SHA-256", bytes)); +} + +/** + * Every identifier a revocation store or statement may list *jwk* under: its + * thumbprint first, then its `kid` when that is a non-empty string different + * from the thumbprint. Matching either one revokes the key (section 3.2.3). + */ +export async function keyIdentifiers(jwk: unknown): Promise { + const identifiers = [await jwkThumbprint(jwk)]; + const kid = own(jwk as Record, "kid"); + if (typeof kid === "string" && kid !== "" && !identifiers.includes(kid)) { + identifiers.push(kid); + } + return identifiers; +} diff --git a/trace-verify-ts/src/revocation.ts b/trace-verify-ts/src/revocation.ts new file mode 100644 index 00000000..ef61f44e --- /dev/null +++ b/trace-verify-ts/src/revocation.ts @@ -0,0 +1,246 @@ +/** + * Revocation-bundle consumer for section 3.2.3. + * + * Section 3.2.3 separates three states a verifier reports: verified against a + * bundle valid at T; unverified for revocation, because the bundle cannot ground + * that answer; and no revocation check performed, because there was no bundle. + * It forbids reporting either of the last two as an affirming appraisal. Those + * three states are the three values of `RevocationCheck.outcome`, and nothing + * here sets or names an appraisal status. + * + * The checks run in a fixed order, and the order is part of the result: a bundle + * that fails a step is not read further, so `cause` names the first thing wrong. + * + * 1. Shape, against the bundle schema and the statement schema it refers to. + * 2. One log per bundle: every statement names the bundle's `log_id`. + * 3. Who signed the set: an `ed25519` signature by a key the caller trusts, + * over the RFC 8785 form of the bundle with `sig` absent. + * 4. What the set says about this key. A statement naming it rejects the + * record, whatever the bundle's age: the statement was authenticated with + * the bundle, and section 3.2.3 gives a statement no expiry. + * 5. Whether the set was issued in the future, beyond the allowed clock skew. + * 6. Whether the set is still evidence for what it does not say: both the + * issuer's `valid_until` and the caller's maximum age hold, the tighter one + * governing, inclusive on the valid side. + * + * No inclusion entry ID reaches this function, so a statement naming the key + * applies section 3.2.3's fallback for records without a usable receipt: every + * record the key signed is rejected. Statement-level signatures are not checked; + * the bundle signature authenticates the set. + */ + +import { decodeBase64url } from "./base64url.js"; +import { digest, hex, importEd25519, verifyEd25519 } from "./crypto.js"; +import { fail, TraceVerificationError } from "./errors.js"; +import { canonicalize } from "./jcs.js"; +import { keyIdentifiers } from "./jwk.js"; +import { bundleSchemaViolations } from "./schema.js"; +import { isPlainObject } from "./text.js"; + +export type RevocationOutcome = "verified" | "unverified_for_revocation" | "no_check_performed"; + +export type RevocationCause = + | "bundle_expired" + | "bundle_malformed" + | "bundle_key_untrusted" + | "bundle_signature_invalid" + | "bundle_signature_unsupported" + | "bundle_issued_in_future"; + +/** + * What the revocation check reported, with the facts a second verifier needs to + * reach the same outcome from retained data alone. `evidence` is plain JSON. + */ +export interface RevocationCheck { + readonly outcome: RevocationOutcome; + readonly cause: RevocationCause | null; + readonly evidence: Readonly>; +} + +export const NO_CHECK: RevocationCheck = Object.freeze({ + outcome: "no_check_performed", + cause: null, + evidence: Object.freeze({}), +}); + +export interface BundleCheckOptions { + /** Identifiers of the key the record is verified against: thumbprint first, then `kid`. */ + readonly trustedKeyIdentifiers: readonly string[]; + /** JWKs whose signature the caller accepts on a bundle. */ + readonly trustedBundleKeys: readonly unknown[]; + /** Verification moment, Unix seconds. */ + readonly now: number; + readonly maxBundleAgeSeconds: number; + readonly maxFutureSkewSeconds: number; +} + +function unverified(cause: RevocationCause, evidence: Record): RevocationCheck { + return { outcome: "unverified_for_revocation", cause, evidence }; +} + +/** The bundle's identity: `sha256:` and the hex SHA-256 of its RFC 8785 form, signature included. */ +export async function bundleDigest(bundle: unknown): Promise { + return `sha256:${hex(await digest("SHA-256", canonicalize(bundle)))}`; +} + +async function trustedBundleKey( + bundleKeyId: string, + trustedBundleKeys: readonly unknown[], +): Promise { + for (const jwk of trustedBundleKeys) { + let identifiers: string[]; + try { + identifiers = await keyIdentifiers(jwk); + } catch (error) { + if (error instanceof TraceVerificationError && error.code === "jwk_invalid") { + fail("invalid_argument", `a trusted bundle key is not a usable JWK: ${error.message}`, { + cause: error, + }); + } + throw error; + } + if (identifiers.includes(bundleKeyId)) { + return jwk; + } + } + return undefined; +} + +/** + * Decide what *bundle* lets a verifier report about the trusted record key. + * + * Returns a `RevocationCheck`. Throws `key_revoked` when a statement on the + * bundle's log names the key, and `invalid_argument` when a trusted bundle key + * the search reaches has no thumbprint. Every other defect in the bundle is an + * outcome, not an exception: inability to check is not evidence of a defect. + */ +export async function checkRevocationBundle( + bundle: unknown, + options: BundleCheckOptions, +): Promise { + if (!isPlainObject(options)) { + fail("invalid_argument", "checkRevocationBundle needs an options object"); + } + const { trustedKeyIdentifiers, trustedBundleKeys, now, maxBundleAgeSeconds, maxFutureSkewSeconds } = + options as BundleCheckOptions; + if (!Array.isArray(trustedKeyIdentifiers) || !trustedKeyIdentifiers.every((id) => typeof id === "string")) { + fail("invalid_argument", "trustedKeyIdentifiers must be an array of key identifier strings"); + } + if (!Array.isArray(trustedBundleKeys)) { + fail("invalid_argument", "trustedBundleKeys must be an array of JWK objects"); + } + for (const [name, value] of [ + ["now", now], + ["maxBundleAgeSeconds", maxBundleAgeSeconds], + ["maxFutureSkewSeconds", maxFutureSkewSeconds], + ] as const) { + if (typeof value !== "number" || !Number.isSafeInteger(value) || (name !== "now" && value < 0)) { + fail("invalid_argument", `${name} must be an integer number of seconds`); + } + } + + // 1. Shape. The first violation by location, so the evidence names one place. + const violations = bundleSchemaViolations(bundle); + if (violations.length > 0) { + const first = violations[0] as { path: readonly string[]; message: string }; + return unverified("bundle_malformed", { + path: first.path.length === 0 ? "/" : first.path.join("/"), + error: first.message, + }); + } + const shaped = bundle as { + log_id: string; + issued_at: number; + valid_until: number; + statements: { log_id: string; compromised_key_id: string }[]; + bundle_key_id: string; + sig: { alg: string; value: string }; + }; + const logId = shaped.log_id; + + // 2. One log per bundle. + for (const [index, statement] of shaped.statements.entries()) { + if (statement.log_id !== logId) { + return unverified("bundle_malformed", { + path: `statements/${index}/log_id`, + error: "statement names a different log than the bundle", + }); + } + } + + let bundleId: string; + try { + bundleId = await bundleDigest(bundle); + } catch (error) { + if (error instanceof TraceVerificationError && error.code === "canonicalization_failed") { + return unverified("bundle_malformed", { + path: "/", + error: `the bundle has no RFC 8785 form: ${error.message}`, + }); + } + throw error; + } + const base = { + bundle_digest: bundleId, + log_id: logId, + issued_at: shaped.issued_at, + valid_until: shaped.valid_until, + now, + max_bundle_age_seconds: maxBundleAgeSeconds, + }; + + // 3. Who signed the set. Only Ed25519 is verified here. The schema also admits + // ES256 and ES384; a signature nobody checked grounds nothing, so those are + // reported as unsupported rather than skipped. + const alg = shaped.sig.alg; + if (alg !== "ed25519") { + return unverified("bundle_signature_unsupported", { ...base, alg }); + } + const signerJwk = await trustedBundleKey(shaped.bundle_key_id, trustedBundleKeys); + if (signerJwk === undefined) { + return unverified("bundle_key_untrusted", { ...base, bundle_key_id: shaped.bundle_key_id }); + } + let signer: CryptoKey; + try { + signer = await importEd25519(signerJwk, "bundle_key"); + } catch (error) { + if (error instanceof TraceVerificationError && error.code !== "crypto_unavailable") { + return unverified("bundle_signature_unsupported", { ...base, alg, key: error.message }); + } + throw error; + } + const signature = decodeBase64url(shaped.sig.value); + const unsigned = Object.fromEntries(Object.entries(shaped).filter(([name]) => name !== "sig")); + const signed = signature !== null && (await verifyEd25519(signer, signature, canonicalize(unsigned))); + if (!signed) { + return unverified("bundle_signature_invalid", { ...base, bundle_key_id: shaped.bundle_key_id }); + } + + // 4. What the set says about this key, read before either time check. + for (const statement of shaped.statements) { + if (trustedKeyIdentifiers.includes(statement.compromised_key_id)) { + fail( + "key_revoked", + `the signing key is revoked: a statement on log ${JSON.stringify(logId)} names it as ` + + `${JSON.stringify(statement.compromised_key_id)} (bundle ${bundleId}). No inclusion ` + + "entry ID is available to place this record before the revocation, so section " + + "3.2.3's fallback applies and the record is rejected.", + ); + } + } + + // 5. Issued in the future: its silence about other keys is evidence of nothing. + if (shaped.issued_at > now + maxFutureSkewSeconds) { + return unverified("bundle_issued_in_future", { ...base, max_future_skew_seconds: maxFutureSkewSeconds }); + } + + // 6. Age. Tighter governs; inclusive on the valid side. + const issuerTripped = now > shaped.valid_until; + const deploymentTripped = now - shaped.issued_at > maxBundleAgeSeconds; + if (issuerTripped || deploymentTripped) { + const boundTripped = issuerTripped && deploymentTripped ? "both" : issuerTripped ? "issuer" : "deployment"; + return unverified("bundle_expired", { ...base, bound_tripped: boundTripped }); + } + + return { outcome: "verified", cause: null, evidence: { ...base, statements_count: shaped.statements.length } }; +} diff --git a/trace-verify-ts/src/schema.ts b/trace-verify-ts/src/schema.ts new file mode 100644 index 00000000..313b6f70 --- /dev/null +++ b/trace-verify-ts/src/schema.ts @@ -0,0 +1,105 @@ +/** + * JSON Schema (Draft 2020-12) validation against the repository's own schemas. + * + * The validators are generated at build time by `scripts/generate-validators.mjs` + * from `schema/trace-claim.json`, `schema/trace-revocation-bundle.json` and + * `schema/trace-revocation.json`, compiled ahead of time so nothing is evaluated + * at run time and nothing is fetched: the statement schema the bundle schema + * refers to by URL is compiled in beside it. Patterns follow ECMA-262, the + * dialect JSON Schema 2020-12 specifies for `pattern`, and `format: uri` is + * asserted, not treated as an annotation. + */ + +import { schemaDigests, validateBundle, validateRecord } from "./generated/validators.js"; +import { compareCodePoints } from "./text.js"; + +export interface SchemaViolation { + /** Location of the offending value, one entry per property name or array index. */ + readonly path: readonly string[]; + /** The failing keyword, for example `required`, `pattern` or `additionalProperties`. */ + readonly keyword: string; + readonly message: string; +} + +interface GeneratedError { + instancePath: string; + keyword: string; + message?: string; + params?: Record; +} + +type GeneratedValidator = ((data: unknown) => boolean) & { errors?: GeneratedError[] | null }; + +function pointerSegments(pointer: string): string[] { + if (pointer === "") { + return []; + } + return pointer + .slice(1) + .split("/") + .map((segment) => segment.replace(/~1/g, "/").replace(/~0/g, "~")); +} + +function describeError(error: GeneratedError): string { + const base = error.message ?? `fails "${error.keyword}"`; + const extra = error.params?.["additionalProperty"]; + return typeof extra === "string" ? `${base} (${JSON.stringify(extra)})` : base; +} + +function run(validator: GeneratedValidator, value: unknown): SchemaViolation[] { + let ok: boolean; + try { + ok = validator(value); + } catch (error) { + if (error instanceof RangeError) { + return [{ path: [], keyword: "depth", message: "the value is nested too deeply to validate" }]; + } + throw error; + } + if (ok) { + return []; + } + return (validator.errors ?? []).map((error) => ({ + path: pointerSegments(error.instancePath), + keyword: error.keyword, + message: describeError(error), + })); +} + +/** + * Every violation of the Trust Record schema, in evaluation order. The first + * entry is the one `verifyRecord` reports. Empty when the record is valid. + */ +export function recordSchemaViolations(record: unknown): SchemaViolation[] { + return run(validateRecord as GeneratedValidator, record); +} + +/** + * Every violation of the revocation bundle schema, sorted by location. + * + * The sort compares locations as sequences of strings, element by element, in + * code-point order, with a location sorting before any location it is a prefix + * of. That is the order the Python reference sorts its errors in before naming + * the first one, so both report the same location for the same bundle. + */ +export function bundleSchemaViolations(bundle: unknown): SchemaViolation[] { + const violations = run(validateBundle as GeneratedValidator, bundle); + return violations + .map((violation, index) => ({ violation, index })) + .sort((a, b) => { + const left = a.violation.path; + const right = b.violation.path; + const shared = Math.min(left.length, right.length); + for (let i = 0; i < shared; i++) { + const order = compareCodePoints(left[i] as string, right[i] as string); + if (order !== 0) { + return order; + } + } + return left.length - right.length || a.index - b.index; + }) + .map(({ violation }) => violation); +} + +/** SHA-256 of each schema file the validators were generated from, keyed by file name. */ +export const SCHEMA_DIGESTS: Readonly> = schemaDigests; diff --git a/trace-verify-ts/src/text.ts b/trace-verify-ts/src/text.ts new file mode 100644 index 00000000..8112c83d --- /dev/null +++ b/trace-verify-ts/src/text.ts @@ -0,0 +1,99 @@ +/** String helpers shared by the canonicalizer, the schema validator and the verifier. */ + +const encoder = new TextEncoder(); + +/** UTF-8 bytes of a string already known to be well formed. */ +export function utf8(value: string): Uint8Array { + return encoder.encode(value); +} + +/** + * Offset of the first unpaired surrogate in *value*, or -1. + * + * A JavaScript string is a sequence of UTF-16 code units and may hold a lone + * surrogate, which has no UTF-8 encoding. `TextEncoder` would silently replace + * it with U+FFFD, so two different strings would produce the same bytes. + */ +export function unpairedSurrogateAt(value: string): number { + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0; + if (next >= 0xdc00 && next <= 0xdfff) { + i++; + continue; + } + return i; + } + if (unit >= 0xdc00 && unit <= 0xdfff) { + return i; + } + } + return -1; +} + +/** + * Number of Unicode code points in *value*, counting a lone surrogate as one. + * + * JSON Schema measures string length in code points, not UTF-16 code units, so + * `minLength` and `maxLength` need this rather than `.length`. + */ +export function codePointLength(value: string): number { + let length = 0; + for (let i = 0; i < value.length; i++) { + const unit = value.charCodeAt(i); + if (unit >= 0xd800 && unit <= 0xdbff && i + 1 < value.length) { + const next = value.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + i++; + } + } + length++; + } + return length; +} + +/** Compare two strings by Unicode code point, the order Python uses for `str`. */ +export function compareCodePoints(a: string, b: string): number { + const left = Array.from(a, (ch) => ch.codePointAt(0) as number); + const right = Array.from(b, (ch) => ch.codePointAt(0) as number); + const shared = Math.min(left.length, right.length); + for (let i = 0; i < shared; i++) { + const diff = (left[i] as number) - (right[i] as number); + if (diff !== 0) { + return diff < 0 ? -1 : 1; + } + } + return left.length === right.length ? 0 : left.length < right.length ? -1 : 1; +} + +/** + * Compare two strings without an early exit on the first differing code unit. + * + * Code units rather than UTF-8 bytes: encoding first would map every lone + * surrogate to U+FFFD and make distinct strings compare equal. The length is + * not hidden, as with any constant-time comparison of variable-length input. + */ +export function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return diff === 0; +} + +export function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const proto = Object.getPrototypeOf(value) as unknown; + return proto === Object.prototype || proto === null; +} + +/** Own-property read, so an inherited member never stands in for an absent one. */ +export function own(object: Record, key: string): unknown { + return Object.hasOwn(object, key) ? object[key] : undefined; +} diff --git a/trace-verify-ts/src/uri.ts b/trace-verify-ts/src/uri.ts new file mode 100644 index 00000000..9dadcb9c --- /dev/null +++ b/trace-verify-ts/src/uri.ts @@ -0,0 +1,57 @@ +/** + * The JSON Schema `uri` format: the `URI` rule of RFC 3986 Appendix A. + * + * Built from the ABNF rule by rule, so each constant below is one production. + * An IRI is not a URI, so a character outside ASCII is refused, and the whole + * string must match: a trailing line feed is not part of any URI. + */ + +const HEXDIG = "[0-9A-Fa-f]"; +const PCT_ENCODED = `%${HEXDIG}${HEXDIG}`; +const UNRESERVED = "[A-Za-z0-9\\-._~]"; +const SUB_DELIMS = "[!$&'()*+,;=]"; +const PCHAR = `(?:${UNRESERVED}|${PCT_ENCODED}|${SUB_DELIMS}|[:@])`; + +const SCHEME = "[A-Za-z][A-Za-z0-9+\\-.]*"; +const USERINFO = `(?:${UNRESERVED}|${PCT_ENCODED}|${SUB_DELIMS}|:)*`; + +// dec-octet = DIGIT / %x31-39 DIGIT / "1" 2DIGIT / "2" %x30-34 DIGIT / "25" %x30-35 +const DEC_OCTET = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[0-9])"; +const IPV4ADDRESS = `(?:${DEC_OCTET}\\.${DEC_OCTET}\\.${DEC_OCTET}\\.${DEC_OCTET})`; +const H16 = `${HEXDIG}{1,4}`; +const LS32 = `(?:${H16}:${H16}|${IPV4ADDRESS})`; +const IPV6ADDRESS = `(?:${[ + `(?:${H16}:){6}${LS32}`, + `::(?:${H16}:){5}${LS32}`, + `(?:${H16})?::(?:${H16}:){4}${LS32}`, + `(?:(?:${H16}:){0,1}${H16})?::(?:${H16}:){3}${LS32}`, + `(?:(?:${H16}:){0,2}${H16})?::(?:${H16}:){2}${LS32}`, + `(?:(?:${H16}:){0,3}${H16})?::${H16}:${LS32}`, + `(?:(?:${H16}:){0,4}${H16})?::${LS32}`, + `(?:(?:${H16}:){0,5}${H16})?::${H16}`, + `(?:(?:${H16}:){0,6}${H16})?::`, +].join("|")})`; +const IPVFUTURE = `v${HEXDIG}+\\.(?:${UNRESERVED}|${SUB_DELIMS}|:)+`; +const IP_LITERAL = `\\[(?:${IPV6ADDRESS}|${IPVFUTURE})\\]`; +const REG_NAME = `(?:${UNRESERVED}|${PCT_ENCODED}|${SUB_DELIMS})*`; +const HOST = `(?:${IP_LITERAL}|${IPV4ADDRESS}|${REG_NAME})`; +const AUTHORITY = `(?:${USERINFO}@)?${HOST}(?::[0-9]*)?`; + +const SEGMENT = `${PCHAR}*`; +const SEGMENT_NZ = `${PCHAR}+`; +const PATH_ABEMPTY = `(?:/${SEGMENT})*`; +const PATH_ABSOLUTE = `/(?:${SEGMENT_NZ}(?:/${SEGMENT})*)?`; +const PATH_ROOTLESS = `${SEGMENT_NZ}(?:/${SEGMENT})*`; +const HIER_PART = `(?://${AUTHORITY}${PATH_ABEMPTY}|${PATH_ABSOLUTE}|${PATH_ROOTLESS}|)`; +const QUERY = `(?:${PCHAR}|[/?])*`; +const FRAGMENT = QUERY; + +const URI = new RegExp(`^${SCHEME}:${HIER_PART}(?:\\?${QUERY})?(?:#${FRAGMENT})?$`); + +/** True when *value* is a URI under RFC 3986. */ +export function isUri(value: string): boolean { + return URI.test(value); +} + +/** The format checkers the generated schema validators call, keyed by format name. */ +export const formats = { uri: isUri } as const; diff --git a/trace-verify-ts/src/verify.ts b/trace-verify-ts/src/verify.ts new file mode 100644 index 00000000..cd2302a5 --- /dev/null +++ b/trace-verify-ts/src/verify.ts @@ -0,0 +1,360 @@ +/** + * `verifyRecord`: verification of a TRACE v0.2 Trust Record with an embedded signature. + * + * The checks run in this order, and the first one that fails is the one reported. + * The order is the Python reference's (`agentrust_trace.verify_record`), so two + * implementations report the same failure for the same input; where the + * specification requires a check, the section is named. + * + * 1. Options: `invalid_argument` for a malformed option (caller data, not record data). + * 2. The record is a JSON object: `record_not_object`. + * 3. Profile, before any cryptographic work. A v0.2 verifier MUST require + * `tag:agentrust-io.com,2026:trace-v0.2` and MUST reject the v0.1 identifier + * (spec/trace-v0.2.md, "Changes from v0.1"): `profile_missing`, + * `profile_superseded`, `profile_unsupported`. + * 4. The embedded signature is present and is a string: `signature_missing`, + * `signature_malformed`. + * 5. The record conforms to the schema: `schema_invalid`, with the location. + * Then the signature is decoded as canonical unpadded base64url + * (section 3.2.2), after the schema's own pattern for the member has run. + * 6. A trusted key: the caller's, or `cnf.jwk` only when `allowEmbeddedKey` is + * set, and it is an Ed25519 key: `trusted_key_missing`, + * `trusted_key_unsupported`, `trusted_key_malformed`. + * 7. Revocation (section 3.2.3), keyed on the trusted key and never on the + * record's own `cnf.jwk`: a caller-supplied store, then a bundle. + * `key_revoked`, `revocation_unavailable`; any other bundle defect is + * reported in the result, not thrown. + * 8. The confirmation key: `cnf.jwk` is an Ed25519 key with the same RFC 7638 + * thumbprint as the trusted key, because the binding is a signature made + * by the key in `cnf` (section 3.2.2): `cnf_key_unsupported`, + * `cnf_key_malformed`, `cnf_key_mismatch`. + * 9. Freshness (section 3.2.2): `iat_invalid`, `record_in_future` past the + * allowed skew, `record_stale` past the maximum age, `nonce_mismatch` when a + * challenge nonce was issued. + * 10. The signature verifies over the RFC 8785 form of the record with + * `signature` absent (section 3.2.2): `canonicalization_failed`, + * `signature_invalid`. + */ + +import { decodeBase64url } from "./base64url.js"; +import { importEd25519, verifyEd25519 } from "./crypto.js"; +import { fail, TraceVerificationError } from "./errors.js"; +import { canonicalize } from "./jcs.js"; +import { keyIdentifiers } from "./jwk.js"; +import { checkRevocationBundle, NO_CHECK, type RevocationCheck } from "./revocation.js"; +import { recordSchemaViolations } from "./schema.js"; +import { isPlainObject, own, timingSafeEqual } from "./text.js"; + +/** The profile URI this build implements, and the only one `verifyRecord` accepts. */ +export const TRACE_PROFILE_V0_2 = "tag:agentrust-io.com,2026:trace-v0.2"; + +/** The superseded v0.1 identifier, named only so its rejection can say why. */ +const TRACE_PROFILE_V0_1 = "tag:agentrust.io,2026:trace-v0.1"; + +/** + * A caller-supplied revocation source: the identifiers of revoked keys, or a + * lookup returning `true` for a revoked key. An identifier is a key's RFC 7638 + * thumbprint or its `kid`. A lookup that throws, or answers with anything but a + * boolean, has not answered, and the record is rejected rather than passed. + */ +export type RevocationStore = + | ReadonlySet + | readonly string[] + | ((identifier: string) => boolean | Promise); + +export interface VerifyOptions { + /** The Ed25519 public key, as a JWK, that the caller trusts to have signed the record. */ + readonly trustedKey?: unknown; + /** + * Verify against the record's own `cnf.jwk` when no trusted key is given. That + * proves the record is internally consistent, not who produced it, and the + * result reports it as `trustedKeySource: "record"`. + */ + readonly allowEmbeddedKey?: boolean; + /** Verification moment in Unix seconds. Defaults to the clock. */ + readonly now?: number; + /** Maximum record age in seconds. Default 86400 (section 3.2.2); `null` disables the bound. */ + readonly maxAgeSeconds?: number | null; + /** Tolerated clock skew for `iat` and for a bundle's `issued_at`. Default 300 (section 3.2.2). */ + readonly maxFutureSkewSeconds?: number; + /** A challenge nonce the record's `runtime.nonce` must echo. */ + readonly expectedNonce?: string; + readonly revocation?: RevocationStore; + /** A `TraceRevocationBundle/1.0`, validated here against its schema. */ + readonly revocationBundle?: unknown; + /** JWKs whose signature the caller accepts on a bundle. */ + readonly trustedBundleKeys?: readonly unknown[]; + /** Maximum bundle age in seconds, measured from `issued_at`. Default 86400. */ + readonly maxBundleAgeSeconds?: number; +} + +export interface VerificationResult { + /** What the revocation check reported. Only `verified` is a revocation check that passed. */ + readonly revocation: RevocationCheck; + /** RFC 7638 thumbprint of the key the signature was verified against. */ + readonly trustedKeyThumbprint: string; + /** `caller` for a pinned key, `record` when `allowEmbeddedKey` trusted `cnf.jwk`. */ + readonly trustedKeySource: "caller" | "record"; +} + +const DEFAULT_MAX_AGE_SECONDS = 86400; +const DEFAULT_MAX_FUTURE_SKEW_SECONDS = 300; +const DEFAULT_MAX_BUNDLE_AGE_SECONDS = 86400; + +function seconds(name: string, value: unknown, fallback: number): number { + if (value === undefined) { + return fallback; + } + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + fail("invalid_argument", `${name} must be a non-negative integer number of seconds`); + } + return value; +} + +interface Settings { + trustedKey: unknown; + allowEmbeddedKey: boolean; + now: number; + maxAgeSeconds: number | null; + maxFutureSkewSeconds: number; + expectedNonce: string | undefined; + revocation: RevocationStore | undefined; + revocationBundle: unknown; + trustedBundleKeys: readonly unknown[]; + maxBundleAgeSeconds: number; +} + +function settings(options: unknown): Settings { + if (options === undefined) { + options = {}; + } + if (!isPlainObject(options)) { + fail("invalid_argument", "options must be an object"); + } + const o = options as VerifyOptions; + const allowEmbeddedKey = own(o as Record, "allowEmbeddedKey"); + if (allowEmbeddedKey !== undefined && typeof allowEmbeddedKey !== "boolean") { + fail("invalid_argument", "allowEmbeddedKey must be a boolean"); + } + const now = own(o as Record, "now"); + if (now !== undefined && (typeof now !== "number" || !Number.isSafeInteger(now))) { + fail("invalid_argument", "now must be an integer Unix timestamp in seconds"); + } + const maxAge = own(o as Record, "maxAgeSeconds"); + const expectedNonce = own(o as Record, "expectedNonce"); + if (expectedNonce !== undefined && typeof expectedNonce !== "string") { + fail("invalid_argument", "expectedNonce must be a string"); + } + const revocation = own(o as Record, "revocation"); + if ( + revocation !== undefined && + typeof revocation !== "function" && + !(revocation instanceof Set) && + !(Array.isArray(revocation) && revocation.every((item) => typeof item === "string")) + ) { + fail("invalid_argument", "revocation must be a Set or array of key identifiers, or a lookup function"); + } + const bundleKeys = own(o as Record, "trustedBundleKeys"); + if (bundleKeys !== undefined && bundleKeys !== null && !Array.isArray(bundleKeys)) { + fail("invalid_argument", "trustedBundleKeys must be an array of JWK objects"); + } + const bundle = own(o as Record, "revocationBundle"); + return { + trustedKey: own(o as Record, "trustedKey"), + allowEmbeddedKey: allowEmbeddedKey === true, + now: now === undefined ? Math.floor(Date.now() / 1000) : (now as number), + maxAgeSeconds: maxAge === null ? null : seconds("maxAgeSeconds", maxAge, DEFAULT_MAX_AGE_SECONDS), + maxFutureSkewSeconds: seconds( + "maxFutureSkewSeconds", + own(o as Record, "maxFutureSkewSeconds"), + DEFAULT_MAX_FUTURE_SKEW_SECONDS, + ), + expectedNonce: expectedNonce as string | undefined, + revocation: revocation as RevocationStore | undefined, + revocationBundle: bundle === null ? undefined : bundle, + trustedBundleKeys: (bundleKeys ?? []) as readonly unknown[], + maxBundleAgeSeconds: seconds( + "maxBundleAgeSeconds", + own(o as Record, "maxBundleAgeSeconds"), + DEFAULT_MAX_BUNDLE_AGE_SECONDS, + ), + }; +} + +async function checkStore(identifiers: readonly string[], store: RevocationStore): Promise { + for (const identifier of identifiers) { + let revoked: unknown; + if (typeof store === "function") { + try { + revoked = await store(identifier); + } catch (error) { + fail( + "revocation_unavailable", + `revocation status for key ${JSON.stringify(identifier)} could not be determined; ` + + "an unavailable revocation source is not evidence that the key is unrevoked", + { cause: error }, + ); + } + if (typeof revoked !== "boolean") { + fail( + "revocation_unavailable", + `revocation lookup for key ${JSON.stringify(identifier)} answered with a ${typeof revoked}, ` + + "not a boolean, so it has not answered", + ); + } + } else if (store instanceof Set) { + revoked = store.has(identifier); + } else { + revoked = (store as readonly string[]).includes(identifier); + } + if (revoked === true) { + fail("key_revoked", `the signing key is revoked (listed as ${JSON.stringify(identifier)})`); + } + } +} + +/** + * Verify a signed TRACE v0.2 Trust Record. + * + * Resolves with a `VerificationResult` when the record verifies, and rejects + * with a `TraceVerificationError` naming the first failed check otherwise. A + * resolved result still has to be read: its `revocation.outcome` is + * `no_check_performed` or `unverified_for_revocation` whenever no bundle or store + * established that the key is unrevoked, and section 3.2.3 forbids treating + * either as an affirming appraisal. + */ +export async function verifyRecord(record: unknown, options?: VerifyOptions): Promise { + const s = settings(options); + + if (!isPlainObject(record)) { + fail("record_not_object", "a Trust Record is a JSON object"); + } + + const profile = own(record, "eat_profile"); + if (typeof profile !== "string" || profile === "") { + fail("profile_missing", "the record has no eat_profile, and a verifier cannot supply it by assumption"); + } + if (profile !== TRACE_PROFILE_V0_2) { + if (profile === TRACE_PROFILE_V0_1) { + fail( + "profile_superseded", + "the record carries the superseded v0.1 profile; a v0.2 verifier rejects it " + + "(spec/trace-v0.2.md, Changes from v0.1)", + ); + } + fail("profile_unsupported", `the record profile is not ${TRACE_PROFILE_V0_2}`); + } + + const encodedSignature = own(record, "signature"); + if (encodedSignature === undefined || encodedSignature === null || encodedSignature === "") { + fail("signature_missing", "the record has no embedded signature"); + } + if (typeof encodedSignature !== "string") { + fail("signature_malformed", "the signature must be a base64url string"); + } + + const violations = recordSchemaViolations(record); + if (violations.length > 0) { + const first = violations[0] as { path: readonly string[]; message: string }; + const path = first.path.length === 0 ? "" : first.path.join("."); + fail("schema_invalid", `the record does not conform to the TRACE v0.2 schema at ${path}: ${first.message}`, { + path, + }); + } + // The schema's own pattern for `signature` has run by now, so what this adds is + // the canonical-encoding rule the pattern cannot state: no padding, and unused + // trailing bits zero (RFC 4648 section 3.5). Without it one signature has + // several spellings, and section 3.1.3 digests the record with the signature + // member present, so the spellings are different records that both verify. + const signature = decodeBase64url(encodedSignature); + if (signature === null) { + fail("signature_malformed", "the signature is not canonical unpadded base64url"); + } + + // From here the schema holds: cnf.jwk is an object and iat an integer in range. + const cnf = record["cnf"] as Record; + const embeddedJwk = cnf["jwk"]; + + let trustedJwk: unknown; + let trustedKeySource: "caller" | "record"; + if (s.trustedKey === undefined || s.trustedKey === null) { + if (!s.allowEmbeddedKey) { + fail( + "trusted_key_missing", + "verifyRecord requires a trusted key; pass trustedKey, or set allowEmbeddedKey to " + + "trust the record's own cnf.jwk, which proves consistency and not origin", + ); + } + trustedJwk = embeddedJwk; + trustedKeySource = "record"; + } else { + trustedJwk = s.trustedKey; + trustedKeySource = "caller"; + } + const trustedKey = await importEd25519(trustedJwk, "trusted_key"); + const trustedIds = await keyIdentifiers(trustedJwk); + + if (s.revocation !== undefined) { + await checkStore(trustedIds, s.revocation); + } + let revocation: RevocationCheck; + if (s.revocationBundle !== undefined) { + revocation = await checkRevocationBundle(s.revocationBundle, { + trustedKeyIdentifiers: trustedIds, + trustedBundleKeys: s.trustedBundleKeys, + now: s.now, + maxBundleAgeSeconds: s.maxBundleAgeSeconds, + maxFutureSkewSeconds: s.maxFutureSkewSeconds, + }); + if (s.revocation !== undefined) { + revocation = { ...revocation, evidence: { ...revocation.evidence, store: "consulted" } }; + } + } else if (s.revocation !== undefined) { + revocation = { outcome: "verified", cause: null, evidence: { source: "store" } }; + } else { + revocation = NO_CHECK; + } + + await importEd25519(embeddedJwk, "cnf_key"); + const embeddedIds = await keyIdentifiers(embeddedJwk); + if (!timingSafeEqual(embeddedIds[0] as string, trustedIds[0] as string)) { + fail("cnf_key_mismatch", "the record's cnf.jwk does not identify the trusted key that verifies its signature"); + } + + const iat = record["iat"]; + if (typeof iat !== "number" || !Number.isSafeInteger(iat)) { + fail("iat_invalid", "the record has no valid integer iat for the freshness check"); + } + const age = s.now - iat; + if (age < -s.maxFutureSkewSeconds) { + fail( + "record_in_future", + `the record is dated ${-age}s in the future, past the allowed skew of ${s.maxFutureSkewSeconds}s`, + ); + } + if (s.maxAgeSeconds !== null && age > s.maxAgeSeconds) { + fail("record_stale", `the record is ${age}s old, past the maximum age of ${s.maxAgeSeconds}s`); + } + if (s.expectedNonce !== undefined) { + const nonce = own(record["runtime"] as Record, "nonce"); + if (typeof nonce !== "string" || !timingSafeEqual(nonce, s.expectedNonce)) { + fail("nonce_mismatch", "the record's runtime.nonce does not match the expected nonce"); + } + } + + const unsigned = Object.fromEntries(Object.entries(record).filter(([name]) => name !== "signature")); + let message: Uint8Array; + try { + message = canonicalize(unsigned); + } catch (error) { + if (error instanceof TraceVerificationError) { + fail("canonicalization_failed", `the record has no RFC 8785 form: ${error.message}`, { cause: error }); + } + throw error; + } + if (!(await verifyEd25519(trustedKey, signature, message))) { + fail("signature_invalid", "the signature does not verify over the record's RFC 8785 form"); + } + + return { revocation, trustedKeyThumbprint: trustedIds[0] as string, trustedKeySource }; +} diff --git a/trace-verify-ts/tsconfig.json b/trace-verify-ts/tsconfig.json new file mode 100644 index 00000000..b57c6144 --- /dev/null +++ b/trace-verify-ts/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["es2022", "dom"], + "strict": true, + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + "checkJs": false, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "removeComments": false, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} From 74f0b9c2381dfc90d877914988c57fbf2b57a896 Mon Sep 17 00:00:00 2001 From: chernistry Date: Sat, 19 Sep 2026 19:46:58 +0300 Subject: [PATCH 02/22] test(trace-verify-ts): cover the units, this repository's vectors and the build Three files. The unit tests pin the canonical forms and the refusals that have a published answer: RFC 8785 member ordering by UTF-16 code unit, the number and escape forms, the values that have no canonical form, the base64url alphabet and pad-bit rules, and the RFC 7638 section 3.1 example thumbprint. The vector tests run the canonicalization-boundary and revocation-bundle corpora through the verifier and check the outcome, the cause and every evidence member each vector states, plus the chain digest of every link in the delegation corpus. The third file tests the build rather than the logic: the compiled output imports nothing, so a platform module or a network call cannot arrive unnoticed; the schema digests the validators were generated from still match schema/; and every exported function refuses junk with the package's own error type rather than a TypeError, which is what a caller written against the documented contract catches. Signed-off-by: chernistry --- trace-verify-ts/tests/hygiene.test.mjs | 91 ++++++++++++++++++ trace-verify-ts/tests/units.test.mjs | 111 ++++++++++++++++++++++ trace-verify-ts/tests/vectors.test.mjs | 123 +++++++++++++++++++++++++ 3 files changed, 325 insertions(+) create mode 100644 trace-verify-ts/tests/hygiene.test.mjs create mode 100644 trace-verify-ts/tests/units.test.mjs create mode 100644 trace-verify-ts/tests/vectors.test.mjs diff --git a/trace-verify-ts/tests/hygiene.test.mjs b/trace-verify-ts/tests/hygiene.test.mjs new file mode 100644 index 00000000..43a3d344 --- /dev/null +++ b/trace-verify-ts/tests/hygiene.test.mjs @@ -0,0 +1,91 @@ +// What the build must not contain, and what every exported function must survive. + +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import * as trace from "../dist/index.js"; +import { SCHEMA_DIGESTS, TraceVerificationError } from "../dist/index.js"; + +const PACKAGE = dirname(dirname(fileURLToPath(import.meta.url))); +const REPOSITORY = dirname(PACKAGE); + +function sources(directory) { + const out = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + out.push(...sources(path)); + } else if (entry.name.endsWith(".js") || entry.name.endsWith(".d.ts")) { + out.push(path); + } + } + return out; +} + +test("the build imports nothing: no platform module, no dependency, no network", () => { + const forbidden = [/from\s+"node:/, /require\s*\(/, /\bfetch\s*\(/, /XMLHttpRequest/, /import\s*\(/]; + for (const path of sources(join(PACKAGE, "dist"))) { + const text = readFileSync(path, "utf8"); + for (const pattern of forbidden) { + assert.equal(pattern.test(text), false, `${path} matches ${pattern}`); + } + for (const match of text.matchAll(/from\s+"([^"]+)"/g)) { + assert.ok(match[1].startsWith("."), `${path} imports ${match[1]}, which is not a relative path`); + } + } +}); + +test("the compiled validators are the repository's schemas", () => { + for (const [name, expected] of Object.entries(SCHEMA_DIGESTS)) { + const bytes = readFileSync(join(REPOSITORY, "schema", name)); + assert.equal(createHash("sha256").update(bytes).digest("hex"), expected, name); + } + for (const name of ["trace-claim.json", "trace-revocation-bundle.json", "trace-revocation.json"]) { + assert.ok(Object.hasOwn(SCHEMA_DIGESTS, name), `${name} is not among the compiled schemas`); + } +}); + +test("every exported function refuses junk with a TraceVerificationError, never a TypeError", async () => { + const junk = [ + undefined, + null, + true, + 0, + -1, + Number.NaN, + "", + "junk", + [], + {}, + { __proto__: { eat_profile: "tag:agentrust-io.com,2026:trace-v0.2" } }, + Object.create(null), + new Map(), + Symbol.iterator, + ]; + const skip = new Set(["FAILURE_CODES", "NO_CHECK", "TRACE_PROFILE_V0_2", "JCS_SAFE_INTEGER", + "DELEGATION_DIGEST_ALGORITHMS", "TraceVerificationError", "SCHEMA_DIGESTS"]); + let exercised = 0; + for (const [name, exported] of Object.entries(trace)) { + if (skip.has(name) || typeof exported !== "function") { + continue; + } + for (const [i, first] of junk.entries()) { + for (const [j, second] of junk.entries()) { + exercised++; + try { + await exported(first, second); + } catch (error) { + assert.ok( + error instanceof TraceVerificationError, + `${name}(junk[${i}], junk[${j}]) threw ${error?.name}: ${error?.message}`, + ); + } + } + } + } + assert.ok(exercised > 0); +}); diff --git a/trace-verify-ts/tests/units.test.mjs b/trace-verify-ts/tests/units.test.mjs new file mode 100644 index 00000000..6b3d77ce --- /dev/null +++ b/trace-verify-ts/tests/units.test.mjs @@ -0,0 +1,111 @@ +// Unit tests for the pieces the verifier is assembled from: RFC 8785 +// canonicalization, base64url, RFC 7638 thumbprints and the profile cutover. + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canonicalJson, + decodeBase64url, + encodeBase64url, + jwkThumbprint, + TRACE_PROFILE_V0_2, + TraceVerificationError, + verifyRecord, +} from "../dist/index.js"; + +async function rejects(promise, code) { + await assert.rejects(promise, (error) => { + assert.ok(error instanceof TraceVerificationError, `expected a TraceVerificationError, got ${error}`); + assert.equal(error.code, code); + return true; + }); +} + +test("canonicalJson orders members by UTF-16 code unit", () => { + assert.equal(canonicalJson({ b: 1, a: 2 }), '{"a":2,"b":1}'); + // The RFC 8785 sort is over UTF-16 code units. A supplementary-plane key + // therefore sorts before U+FB33, which code-point order puts first. + assert.equal(canonicalJson({ "\u{10480}": 1, "דּ": 2 }), '{"\u{10480}":1,"דּ":2}'); +}); + +test("canonicalJson emits non-ASCII literally and escapes only what RFC 8785 escapes", () => { + assert.equal(canonicalJson("modèle"), '"modèle"'); + assert.equal(canonicalJson("\b\f\n\r\t\"\\"), '"\\u0001\\b\\f\\n\\r\\t\\"\\\\"'); + assert.equal(canonicalJson(""), '""'); +}); + +test("canonicalJson serializes numbers in the ES6 form", () => { + assert.equal(canonicalJson(1), "1"); + assert.equal(canonicalJson(-0), "0"); + assert.equal(canonicalJson(1e-7), "1e-7"); + assert.equal(canonicalJson(0.1), "0.1"); + assert.equal(canonicalJson(9007199254740991), "9007199254740991"); +}); + +test("canonicalJson refuses what has no canonical form", () => { + assert.throws(() => canonicalJson(Number.NaN), TraceVerificationError); + assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), TraceVerificationError); + // Above 2^53-1 an integer literal is no longer the number it names. + assert.throws(() => canonicalJson(9007199254740992), TraceVerificationError); + // A lone surrogate has no UTF-8 encoding; encoding it would map two strings to one. + assert.throws(() => canonicalJson("\ud800"), TraceVerificationError); + assert.throws(() => canonicalJson({ "\ud800": 1 }), TraceVerificationError); + assert.throws(() => canonicalJson(undefined), TraceVerificationError); + assert.throws(() => canonicalJson(new Date(0)), TraceVerificationError); + const cycle = {}; + cycle.self = cycle; + assert.throws(() => canonicalJson(cycle), TraceVerificationError); +}); + +test("base64url decodes canonical input and refuses everything else", () => { + assert.deepEqual([...decodeBase64url("")], []); + assert.deepEqual([...decodeBase64url("AQID")], [1, 2, 3]); + assert.deepEqual([...decodeBase64url("_w")], [255]); + assert.equal(encodeBase64url(new Uint8Array([1, 2, 3])), "AQID"); + assert.equal(encodeBase64url(new Uint8Array([255])), "_w"); + for (const bad of ["AQI=", "A", "+w", "a/b", "AQ ID", "é", "_x"]) { + assert.equal(decodeBase64url(bad), null, `accepted ${JSON.stringify(bad)}`); + } +}); + +test("jwkThumbprint reproduces the RFC 7638 section 3.1 example", async () => { + const rsa = { + kty: "RSA", + n: + "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMs" + + "tn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajr" + + "n1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + e: "AQAB", + alg: "RS256", + kid: "2011-04-29", + }; + assert.equal(await jwkThumbprint(rsa), "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"); +}); + +test("jwkThumbprint refuses a key it cannot hash", async () => { + await rejects(jwkThumbprint({ kty: "oct", k: "AQID" }), "jwk_invalid"); + await rejects(jwkThumbprint({ kty: "OKP", crv: "Ed25519" }), "jwk_invalid"); + await rejects(jwkThumbprint("not a key"), "jwk_invalid"); +}); + +test("the profile is checked before anything else, and v0.1 is named as superseded", async () => { + const key = { kty: "OKP", crv: "Ed25519", x: "Be97jkxfFpVXzj9B-gwpMzv5t8PH30Edd-J7AIlrdoA" }; + await rejects(verifyRecord(null, { trustedKey: key }), "record_not_object"); + await rejects(verifyRecord({}, { trustedKey: key }), "profile_missing"); + await rejects(verifyRecord({ eat_profile: 7 }, { trustedKey: key }), "profile_missing"); + await rejects( + verifyRecord({ eat_profile: "tag:agentrust.io,2026:trace-v0.1" }, { trustedKey: key }), + "profile_superseded", + ); + await rejects(verifyRecord({ eat_profile: "urn:other" }, { trustedKey: key }), "profile_unsupported"); + // A record carrying the supported profile gets past the check and fails later. + await rejects(verifyRecord({ eat_profile: TRACE_PROFILE_V0_2 }, { trustedKey: key }), "signature_missing"); +}); + +test("verifyRecord refuses to guess a trust anchor", async () => { + await rejects( + verifyRecord({ eat_profile: TRACE_PROFILE_V0_2, signature: "AQID" }), + "schema_invalid", + ); +}); diff --git a/trace-verify-ts/tests/vectors.test.mjs b/trace-verify-ts/tests/vectors.test.mjs new file mode 100644 index 00000000..64c2f5c4 --- /dev/null +++ b/trace-verify-ts/tests/vectors.test.mjs @@ -0,0 +1,123 @@ +// The repository's own conformance vectors, run through this verifier. +// +// Each vector states the outcome it expects before this code runs, so these are +// the same assertions the Python test suite makes about the same bytes. The +// revocation vectors additionally state an evidence block, and this checks it +// member by member: an outcome that is right for the wrong reason is not right. + +import assert from "node:assert/strict"; +import { readdirSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { parentRecordHash, TraceVerificationError, verifyRecord } from "../dist/index.js"; + +const EXAMPLES = join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), "examples"); + +function vectors(directory) { + const path = join(EXAMPLES, directory); + return readdirSync(path) + .filter((name) => name.endsWith(".json")) + .sort() + .map((name) => ({ name, vector: JSON.parse(readFileSync(join(path, name), "utf8")) })); +} + +async function outcome(promise) { + try { + return { rejected: false, result: await promise }; + } catch (error) { + assert.ok(error instanceof TraceVerificationError, `unexpected error type: ${error?.stack ?? error}`); + return { rejected: true, code: error.code }; + } +} + +test("canonicalization boundary vectors", async (t) => { + for (const { name, vector } of vectors("canonicalization-boundary")) { + await t.test(name, async () => { + // These vectors fix the canonical form, not the clock: the freshness bound + // is the caller's and is disabled so the vector tests what it is about. + const got = await outcome( + verifyRecord(vector.record, { + trustedKey: vector.trusted_key, + now: vector.record.iat, + maxAgeSeconds: null, + }), + ); + if (vector.expected.outcome === "verified") { + assert.equal(got.rejected, false, `expected verified, got ${got.code}`); + assert.equal(got.result.revocation.outcome, "no_check_performed"); + } else { + assert.equal(got.rejected, true, "expected a rejection"); + assert.equal(got.code, vector.expected.failure); + } + }); + } +}); + +test("revocation bundle vectors", async (t) => { + for (const { name, vector } of vectors("revocation-bundle")) { + await t.test(name, async () => { + const context = vector.context; + for (const record of vector.records) { + const got = await outcome( + verifyRecord(record, { + trustedKey: context.trusted_key, + now: context.now, + revocationBundle: context.bundle, + trustedBundleKeys: context.trusted_bundle_keys ?? [], + maxBundleAgeSeconds: context.max_bundle_age_seconds, + maxFutureSkewSeconds: context.max_future_skew_seconds, + }), + ); + assert.equal(got.rejected, vector.expected.rejected, `codes: ${got.code ?? "none"}`); + if (vector.expected.rejected) { + // A vector may list more codes than a verifier reports: the extra ones + // name the property the vector demonstrates, not a second failure. + assert.ok( + vector.expected.codes.includes(got.code), + `${got.code} is not among ${vector.expected.codes.join(", ")}`, + ); + continue; + } + const revocation = got.result.revocation; + assert.equal(revocation.outcome, vector.expected.outcome); + assert.equal(revocation.cause, vector.expected.cause); + for (const [member, value] of Object.entries(vector.expected.evidence ?? {})) { + assert.deepEqual(revocation.evidence[member], value, `evidence.${member}`); + } + } + }); + } +}); + +test("delegation-link vectors agree on the chain digest of section 3.1.3", async (t) => { + // The chain walk is not normative in v0.2 and this package does not implement + // it. The pre-image is normative, so every link a vector states is recomputed + // here against the record the vector holds. + let checked = 0; + for (const { name, vector } of vectors("delegation-link")) { + if (!Array.isArray(vector.records)) { + continue; + } + await t.test(name, async () => { + const byDigest = new Map(); + for (const record of vector.records) { + for (const algorithm of ["sha256", "sha384"]) { + byDigest.set(await parentRecordHash(record, algorithm), record); + } + } + for (const record of vector.records) { + const stated = record.delegation?.parent_record_hash; + if (stated === undefined || !byDigest.has(stated)) { + continue; + } + const parent = byDigest.get(stated); + const algorithm = stated.slice(0, stated.indexOf(":")); + assert.equal(await parentRecordHash(parent, algorithm), stated); + checked++; + } + }); + } + assert.ok(checked > 0, "no delegation link resolved, so nothing was checked"); +}); From cd8ef28c7e837d50ac71c77fcafa8dd96603fb8a Mon Sep 17 00:00:00 2001 From: chernistry Date: Sat, 19 Sep 2026 19:46:58 +0300 Subject: [PATCH 03/22] test(trace-verify-ts): score the two implementations against each other A second implementation is only evidence if something compares them. The harness generates a corpus, runs both sides over it and reports every case where they disagree. The corpus holds each record as text, not as a parsed object, because several cases exist to probe a difference that only survives in text: an iat written 1785000000.0, a lone surrogate in a string, a signature spelled with padding. Both runners parse the same bytes with their own JSON parser, which is part of what is compared. It covers a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, the chain digest of every record in the delegation corpus, this repository's own vectors, and the published conformance vectors from a pinned checkout whose files are recorded by SHA-256 rather than vendored. The Python side reports a rejection as an exception, and two distinct checks raise the same message from the same helper called twice, so the classifier reads the traceback to ask which call site it came from, and looks the line numbers up in the installed source rather than writing them down. An exception it does not recognise is reported, not guessed. 1644 cases: 1280 agree exactly, including every published vector, every thumbprint and every chain digest. The other 364 are 13 classes argued in differential/known-divergences.json, which matches on both the case and the pair of reported reasons, so a new disagreement inside a listed family is reported rather than absorbed. Nothing was adjusted to match the other side. Signed-off-by: chernistry --- trace-verify-ts/differential/compare.py | 165 +++++++ .../differential/generate_cases.py | 465 ++++++++++++++++++ .../differential/known-divergences.json | 438 +++++++++++++++++ trace-verify-ts/differential/oracle.py | 243 +++++++++ trace-verify-ts/differential/run.mjs | 152 ++++++ 5 files changed, 1463 insertions(+) create mode 100644 trace-verify-ts/differential/compare.py create mode 100644 trace-verify-ts/differential/generate_cases.py create mode 100644 trace-verify-ts/differential/known-divergences.json create mode 100644 trace-verify-ts/differential/oracle.py create mode 100644 trace-verify-ts/differential/run.mjs diff --git a/trace-verify-ts/differential/compare.py b/trace-verify-ts/differential/compare.py new file mode 100644 index 00000000..034348f3 --- /dev/null +++ b/trace-verify-ts/differential/compare.py @@ -0,0 +1,165 @@ +"""Compare the two verdict files and report every case where they differ. + +A case agrees when both sides reach the same verdict for the same stated reason: +the same failure code for a rejection, the same canonical bytes or digest for the +other kinds, and for a record that verifies, the same key thumbprint and the same +revocation outcome, cause and evidence. Evidence is compared with the two members +that carry a human-readable message removed, since those are prose. + +A disagreement listed in `known-divergences.json` is reported as known and does +not fail the run; every other one does. The ledger is the place where a difference +is argued, so no difference can be absorbed by quietly matching the other side. + + python differential/compare.py +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import pathlib +from typing import Any + + +def load(path: pathlib.Path) -> dict[str, dict[str, Any]]: + out = {} + # split("\n"), not splitlines(): a canonical form may contain U+2028, which + # splitlines treats as a line boundary and JSON does not. + for line in path.read_text(encoding="utf-8").split("\n"): + if line.strip(): + verdict = json.loads(line) + out[verdict["id"]] = verdict + return out + + +def summarise(verdict: dict[str, Any]) -> tuple: + """What has to match. Anything outside this tuple is commentary.""" + kind = verdict["verdict"] + if kind == "rejected": + return ("rejected", verdict.get("code")) + if kind == "verified": + revocation = verdict.get("revocation") or {} + return ( + "verified", + verdict.get("thumbprint"), + revocation.get("outcome"), + revocation.get("cause"), + json.dumps(revocation.get("evidence"), sort_keys=True), + ) + if kind in {"canonical", "thumbprint", "digest"}: + return (kind, verdict.get("bytes") or verdict.get("value")) + return (kind,) + + +def observed_pair(left: dict[str, Any], right: dict[str, Any]) -> list[str]: + return [left.get("code") or left["verdict"], right.get("code") or right["verdict"]] + + +def ledger_entry( + ledger: list[dict[str, Any]], case_id: str, pair: list[str] +) -> dict[str, Any] | None: + """The ledger entry that covers this divergence, or None. + + An entry matches on both the case and the pair of reported reasons. Matching on + the case alone would let a broad pattern absorb a *different* divergence + appearing later in the same family, which is the failure mode the ledger exists + to prevent. + """ + for entry in ledger: + if not any(fnmatch.fnmatch(case_id, pattern) for pattern in entry["ids"]): + continue + if pair in [list(known) for known in entry["pairs"]]: + return entry + return None + + +def main() -> int: + here = pathlib.Path(__file__).resolve().parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--python", type=pathlib.Path, default=here / "build" / "python.jsonl") + parser.add_argument( + "--typescript", type=pathlib.Path, default=here / "build" / "typescript.jsonl" + ) + parser.add_argument("--ledger", type=pathlib.Path, default=here / "known-divergences.json") + parser.add_argument("--report", type=pathlib.Path, default=None) + arguments = parser.parse_args() + + python = load(arguments.python) + typescript = load(arguments.typescript) + ledger = json.loads(arguments.ledger.read_text(encoding="utf-8"))["divergences"] \ + if arguments.ledger.exists() else [] + + ids = sorted(set(python) | set(typescript)) + agreed: list[str] = [] + known: list[dict[str, Any]] = [] + unexpected: list[dict[str, Any]] = [] + unclassified: list[str] = [] + path_only: list[dict[str, Any]] = [] + + for case_id in ids: + left = python.get(case_id) + right = typescript.get(case_id) + if left is None or right is None: + unexpected.append({"id": case_id, "python": left, "typescript": right}) + continue + if left.get("code") == "unclassified": + unclassified.append(f"{case_id}: {left.get('detail')}") + if summarise(left) == summarise(right): + agreed.append(case_id) + if left.get("code") == "schema_invalid" and left.get("path") != right.get("path"): + path_only.append({ + "id": case_id, + "python": left.get("path"), + "typescript": right.get("path"), + }) + continue + record = { + "id": case_id, + "group": left.get("group"), + "python": {k: v for k, v in left.items() if k not in {"id", "group"}}, + "typescript": {k: v for k, v in right.items() if k not in {"id", "group"}}, + } + entry = ledger_entry(ledger, case_id, observed_pair(left, right)) + if entry is not None: + known.append({ + **record, + "reason": entry["reason"], + "classification": entry["classification"], + }) + else: + unexpected.append(record) + + total = len(ids) + print(f"cases: {total}") + print(f"identical verdicts: {len(agreed)}") + print(f"known divergences: {len(known)}") + print(f"unexpected: {len(unexpected)}") + if unclassified: + print(f"unclassified Python exceptions: {len(unclassified)}") + for line in unclassified[:20]: + print(f" {line}") + if path_only: + print(f"schema locations that differ while the code agrees: {len(path_only)}") + for record in unexpected: + print(json.dumps(record, sort_keys=True)) + + if arguments.report is not None: + arguments.report.write_text(json.dumps({ + "totals": { + "cases": total, + "identical": len(agreed), + "known_divergences": len(known), + "unexpected": len(unexpected), + "unclassified_python_exceptions": len(unclassified), + }, + "known": known, + "unexpected": unexpected, + "schema_location_differences": path_only, + }, indent=1, sort_keys=True) + "\n", encoding="utf-8") + + return 1 if unexpected or unclassified else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/trace-verify-ts/differential/generate_cases.py b/trace-verify-ts/differential/generate_cases.py new file mode 100644 index 00000000..ed6accb1 --- /dev/null +++ b/trace-verify-ts/differential/generate_cases.py @@ -0,0 +1,465 @@ +"""Build the differential corpus: one JSON file both implementations read. + +Every case carries the record as *text*, not as a parsed object, because several +of them exist to probe a difference that only survives in text: an `iat` written +`1785000000.0`, a lone surrogate in a string, a signature spelled with padding. +Both runners parse the same bytes with their own JSON parser, which is part of +what is being compared. + +Keys derive from a published seed by role label, so the corpus regenerates +byte-identically for anyone. Nothing here is secret and nothing is fetched. + + python differential/generate_cases.py [--out differential/build/cases.json] +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import pathlib +from typing import Any + +from agentrust_trace.sign import jwk_thumbprint, key_to_jwk, sign_record +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +HERE = pathlib.Path(__file__).resolve().parent +REPO = HERE.parent.parent +EXAMPLES = REPO / "examples" +BUILD = HERE / "build" +"""Generated artifacts live here: nothing under it is committed.""" + +NOW = 1785000000 +"""The moment the repository's own vectors are written against.""" + + +def seeded_key(label: str) -> Ed25519PrivateKey: + seed = hashlib.sha256(f"trace-verify-ts/differential/{label}".encode()).digest() + return Ed25519PrivateKey.from_private_bytes(seed) + + +SIGNER = seeded_key("signer") +OTHER = seeded_key("other") + +BASE_RECORD: dict[str, Any] = { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": NOW, + "subject": "spiffe://factory.example/agent/payments/prod", + "model": {"provider": "anthropic", "model_id": "claude-sonnet-4-6"}, + "runtime": { + "platform": "software-only", + "measurement": "sha256:" + "0" * 64, + "nonce": "a-nonce-value", + }, + "policy": {"bundle_hash": "sha256:" + "a" * 64, "enforcement_mode": "enforce"}, + "data_class": "internal", + "build_provenance": {"slsa_level": 0, "digest": "sha256:" + "b" * 64}, + "appraisal": {"status": "affirming", "verifier": "https://verifier.example/v1"}, + "cnf": {"jwk": {}}, +} + + +def signed(record: dict[str, Any], key: Ed25519PrivateKey = SIGNER) -> dict[str, Any]: + body = {k: v for k, v in record.items() if k not in {"signature", "cnf"}} + return sign_record(body, key) + + +RECORD = signed(BASE_RECORD) +SIGNER_JWK = key_to_jwk(SIGNER) +OTHER_JWK = key_to_jwk(OTHER) +EC_JWK = { + "kty": "EC", + "crv": "P-256", + "x": "f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU", + "y": "x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0", +} + + +def compact(value: Any) -> str: + """Compact JSON text: the mutations are textual, so the spelling has to be fixed.""" + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def load(path: pathlib.Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +VALID_BUNDLE = load(EXAMPLES / "revocation-bundle" / "01-fresh-well-inside-both-bounds.json") + + +def contexts() -> dict[str, dict[str, Any]]: + """Verifier configurations, each naming one decision the record is judged under.""" + bundle_context = VALID_BUNDLE["context"] + bundle_text = json.dumps(bundle_context["bundle"]) + signer_thumbprint = jwk_thumbprint(SIGNER_JWK) + base = {"trusted_key": SIGNER_JWK, "now": NOW} + return { + "c00-pinned-key": {**base}, + "c01-no-key": {"now": NOW}, + "c02-embedded-key": {"now": NOW, "allow_embedded_key": True}, + "c03-age-at-bound": {**base, "now": NOW + 86400}, + "c04-age-one-past-bound": {**base, "now": NOW + 86401}, + "c05-future-at-skew": {**base, "now": NOW - 300}, + "c06-future-one-past-skew": {**base, "now": NOW - 301}, + "c07-no-age-bound": {**base, "now": NOW + 10**6, "max_age_seconds": None}, + "c08-zero-age-bound": {**base, "max_age_seconds": 0}, + "c09-nonce-matches": {**base, "expected_nonce": "a-nonce-value"}, + "c10-nonce-differs": {**base, "expected_nonce": "another-nonce"}, + "c11-store-lists-nothing": {**base, "revocation": {"kind": "list", "ids": []}}, + "c12-store-lists-thumbprint": { + **base, + "revocation": {"kind": "list", "ids": [signer_thumbprint]}, + }, + "c13-store-raises": {**base, "revocation": {"kind": "raises"}}, + "c14-store-answers-non-bool": {**base, "revocation": {"kind": "non_bool"}}, + "c15-wrong-key": {**base, "trusted_key": OTHER_JWK}, + "c16-ec-key": {**base, "trusted_key": EC_JWK}, + "c17-key-x-truncated": {**base, "trusted_key": {**SIGNER_JWK, "x": SIGNER_JWK["x"][:-4]}}, + "c18-key-x-non-canonical": { + **base, + "trusted_key": {**SIGNER_JWK, "x": SIGNER_JWK["x"][:-1] + "_"}, + }, + "c19-bundle": { + **base, + "revocation_bundle_json": bundle_text, + "trusted_bundle_keys": bundle_context["trusted_bundle_keys"], + "max_bundle_age_seconds": bundle_context["max_bundle_age_seconds"], + "max_future_skew_seconds": bundle_context["max_future_skew_seconds"], + }, + "c20-bundle-and-store": { + **base, + "revocation": {"kind": "list", "ids": []}, + "revocation_bundle_json": bundle_text, + "trusted_bundle_keys": bundle_context["trusted_bundle_keys"], + }, + "c21-bundle-untrusted-key": { + **base, + "revocation_bundle_json": bundle_text, + "trusted_bundle_keys": [], + }, + "c22-bundle-malformed": { + **base, + "revocation_bundle_json": json.dumps({"type": "TraceRevocationBundle/1.0"}), + "trusted_bundle_keys": bundle_context["trusted_bundle_keys"], + }, + "c23-bundle-lone-surrogate": { + **base, + "revocation_bundle_json": bundle_text.replace( + "log.example/trace", "log.example/trace\\ud800" + ), + "trusted_bundle_keys": bundle_context["trusted_bundle_keys"], + }, + } + + +def mutations() -> list[tuple[str, str]]: + """(name, record text) pairs, each one thing done to the signed base record.""" + text = compact(RECORD) + out: list[tuple[str, str]] = [("m00-unmodified", text)] + + def structural(name: str, change) -> None: + record = copy.deepcopy(RECORD) + change(record) + out.append((name, compact(record))) + + def resigned(name: str, change) -> None: + record = copy.deepcopy(RECORD) + change(record) + out.append((name, compact(signed(record)))) + + sig = RECORD["signature"] + for name, value in [ + ("m01-signature-padded", sig + "="), + ("m02-signature-standard-alphabet", sig.replace("-", "+").replace("_", "/")), + ("m03-signature-non-canonical-pad-bits", sig[:-1] + "_"), + ("m04-signature-truncated", sig[:-4]), + ("m05-signature-trailing-newline", sig + "\n"), + ("m06-signature-empty", ""), + ("m07-signature-flipped", ("B" if sig[0] != "B" else "C") + sig[1:]), + ]: + structural(name, lambda record, v=value: record.__setitem__("signature", v)) + for name, literal in [ + ("m08-signature-zero", "0"), + ("m09-signature-false", "false"), + ("m10-signature-null", "null"), + ("m11-signature-empty-array", "[]"), + ("m12-signature-empty-object", "{}"), + ("m13-signature-number", "1785000000"), + ]: + out.append(( + name, + text.replace(f'"signature":{compact(sig)}', f'"signature":{literal}'), + )) + out.append(("m14-signature-absent", + compact({k: v for k, v in RECORD.items() if k != "signature"}))) + + structural( + "m15-profile-v0-1", + lambda r: r.__setitem__("eat_profile", "tag:agentrust.io,2026:trace-v0.1"), + ) + structural("m16-profile-unknown", lambda r: r.__setitem__("eat_profile", "urn:example:other")) + structural("m17-profile-empty", lambda r: r.__setitem__("eat_profile", "")) + structural("m18-profile-number", lambda r: r.__setitem__("eat_profile", 2)) + structural("m19-profile-absent", lambda r: r.pop("eat_profile")) + + iat_literal = f'"iat":{NOW}' + for name, literal in [ + ("m20-iat-float-form", f'"iat":{NOW}.0'), + ("m21-iat-exponent-form", '"iat":1.785e9'), + ("m22-iat-string", f'"iat":"{NOW}"'), + ("m23-iat-true", '"iat":true'), + ("m24-iat-negative", '"iat":-1'), + ("m25-iat-past-safe-integer", '"iat":9007199254740993'), + ]: + out.append((name, text.replace(iat_literal, literal))) + structural("m26-iat-absent", lambda r: r.pop("iat")) + + resigned( + "m27-subject-trailing-newline", + lambda r: r.__setitem__("subject", r["subject"] + "\n"), + ) + resigned( + "m28-subject-carriage-return", + lambda r: r.__setitem__("subject", r["subject"] + "\r"), + ) + resigned("m29-subject-line-separator", lambda r: r.__setitem__("subject", r["subject"] + "
")) + resigned("m30-subject-empty", lambda r: r.__setitem__("subject", "")) + resigned( + "m31-verifier-uri-trailing-newline", + lambda r: r["appraisal"].__setitem__("verifier", r["appraisal"]["verifier"] + "\n"), + ) + resigned( + "m32-verifier-uri-ipv6-leading-zero", + lambda r: r["appraisal"].__setitem__("verifier", "https://[::1.2.3.04]/"), + ) + resigned( + "m33-verifier-uri-not-a-uri", + lambda r: r["appraisal"].__setitem__("verifier", "not a uri"), + ) + resigned("m34-unknown-top-level-member", lambda r: r.__setitem__("unknown_member", 1)) + resigned("m35-unknown-nested-member", lambda r: r["appraisal"].__setitem__("unknown", 1)) + resigned( + "m36-measurement-not-a-digest", + lambda r: r["runtime"].__setitem__("measurement", "sha256:zz"), + ) + resigned( + "m37-slsa-level-out-of-range", + lambda r: r["build_provenance"].__setitem__("slsa_level", 5), + ) + resigned( + "m38-enforcement-mode-unknown", + lambda r: r["policy"].__setitem__("enforcement_mode", "audit-ish"), + ) + resigned("m39-runtime-absent", lambda r: r.pop("runtime")) + resigned( + "m40-appraisal-status-unknown", + lambda r: r["appraisal"].__setitem__("status", "excellent"), + ) + resigned("m41-nonce-non-ascii", lambda r: r["runtime"].__setitem__("nonce", "nønce")) + resigned("m42-nonce-absent", lambda r: r["runtime"].pop("nonce")) + + structural("m43-cnf-jwk-kty-ec", lambda r: r["cnf"].__setitem__("jwk", EC_JWK)) + structural( + "m44-cnf-jwk-crv-x25519", + lambda r: r["cnf"]["jwk"].__setitem__("crv", "X25519"), + ) + structural("m45-cnf-jwk-x-absent", lambda r: r["cnf"]["jwk"].pop("x")) + structural( + "m46-cnf-jwk-x-non-canonical", + lambda r: r["cnf"]["jwk"].__setitem__("x", SIGNER_JWK["x"][:-1] + "_"), + ) + structural( + "m47-cnf-jwk-x-truncated", + lambda r: r["cnf"]["jwk"].__setitem__("x", SIGNER_JWK["x"][:-4]), + ) + structural("m48-cnf-jwk-other-key", lambda r: r["cnf"].__setitem__("jwk", OTHER_JWK)) + structural("m49-cnf-jwk-with-kid", lambda r: r["cnf"]["jwk"].__setitem__("kid", "a-kid")) + structural("m50-cnf-absent", lambda r: r.pop("cnf")) + structural("m51-cnf-jwk-empty", lambda r: r["cnf"].__setitem__("jwk", {})) + + out.append(("m52-lone-surrogate-in-subject", text.replace( + compact(RECORD["subject"]), compact(RECORD["subject"])[:-1] + '\\ud800"'))) + out.append(("m53-duplicate-member", text.replace( + '"eat_profile":', '"eat_profile":"urn:example:shadowed","eat_profile":', 1))) + out.append(("m54-not-an-object-array", "[]")) + out.append(("m55-not-an-object-string", '"a record"')) + out.append(("m56-not-an-object-null", "null")) + out.append(("m57-not-an-object-number", "1")) + out.append(("m58-empty-object", "{}")) + return out + + +def jcs_cases() -> list[dict[str, Any]]: + """Values whose canonical form, or refusal to have one, is compared directly.""" + literals = [ + "{}", "[]", "0", "-0", "1", "-1", "1.0", "0.1", "1e2", "1e-7", "1.0e+21", + "9007199254740991", "9007199254740992", "-9007199254740991", "9007199254740993", + '"\\u00e9"', '"\\ud83d\\ude00"', '"\\u0000"', '"\\u001f"', '"\\u007f"', + '"\\u2028"', '"\\ud800"', '"\\udfff"', '"\\ud800\\udc00"', '"\\ufffe"', + '{"b":1,"a":2}', '{"\\u00e9":1,"z":2}', '{"\\ud83d\\ude00":1,"\\ufb33":2}', + '{"a":{"d":1,"c":[3,2,{"f":1,"e":2}]}}', '{"":1}', '{"a":null,"b":true,"c":false}', + '[1,"two",{"three":3},[4]]', '{"a":1e400}', + '{"k":"line\\nbreak\\tand\\\\slash\\"quote"}', + ] + return [{"id": f"jcs-{i:02d}", "kind": "jcs", "group": "D2-jcs", "value_json": literal} + for i, literal in enumerate(literals)] + + +def thumbprint_cases() -> list[dict[str, Any]]: + keys: list[Any] = [ + SIGNER_JWK, + {**SIGNER_JWK, "kid": "a-kid", "use": "sig", "alg": "EdDSA"}, + EC_JWK, + {**EC_JWK, "y": None}, + {"kty": "OKP", "crv": "Ed25519"}, + {"kty": "OKP", "crv": "Ed25519", "x": ""}, + {"kty": "oct", "k": "AQID"}, + {"kty": "RSA", "n": "0vx7ag", "e": "AQAB"}, + {"kty": None}, + {}, + [], + "jwk", + None, + {"kty": "OKP", "crv": "Ed25519", "x": SIGNER_JWK["x"], "extra": {"nested": True}}, + ] + return [{"id": f"thumb-{i:02d}", "kind": "thumbprint", "group": "D3-thumbprint", + "value_json": json.dumps(key)} for i, key in enumerate(keys)] + + +def chain_digest_cases() -> list[dict[str, Any]]: + """Every record in the delegation corpus, digested under both algorithms.""" + cases = [] + for path in sorted((EXAMPLES / "delegation-link").glob("*.json")): + vector = load(path) + for index, record in enumerate(vector.get("records", [])): + for algorithm in ("sha256", "sha384"): + cases.append({ + "id": f"chain-{path.stem}-{index}-{algorithm}", + "kind": "chain_digest", + "group": "D4-chain-digest", + "algorithm": algorithm, + "value_json": json.dumps(record, ensure_ascii=False), + }) + return cases + + +def repository_vector_cases() -> list[dict[str, Any]]: + """The repository's own signed records, under the context each vector states.""" + cases = [] + for path in sorted((EXAMPLES / "canonicalization-boundary").glob("*.json")): + vector = load(path) + cases.append({ + "id": f"repo-canon-{path.stem}", + "kind": "verify", + "group": "D5-repository-vectors", + "record_json": json.dumps(vector["record"], ensure_ascii=False), + "options": {"trusted_key": vector["trusted_key"], "now": vector["record"]["iat"]}, + }) + for path in sorted((EXAMPLES / "revocation-bundle").glob("*.json")): + vector = load(path) + context = vector["context"] + for index, record in enumerate(vector["records"]): + cases.append({ + "id": f"repo-bundle-{path.stem}-{index}", + "kind": "verify", + "group": "D5-repository-vectors", + "record_json": json.dumps(record, ensure_ascii=False), + "options": { + "trusted_key": context["trusted_key"], + "now": context["now"], + "revocation_bundle_json": json.dumps(context["bundle"]), + "trusted_bundle_keys": context["trusted_bundle_keys"], + "max_bundle_age_seconds": context["max_bundle_age_seconds"], + "max_future_skew_seconds": context["max_future_skew_seconds"], + }, + }) + return cases + + +def external_vector_cases(root: pathlib.Path | None) -> list[dict[str, Any]]: + """The published conformance vectors, read from a pinned checkout. + + Nothing is vendored: the directory is passed in, and `manifest.json` beside + this file records the SHA-256 of every file the corpus was built from, so a + later run against a different checkout is detected rather than absorbed. + """ + if root is None or not root.is_dir(): + return [] + cases = [] + manifest = {} + for path in sorted(root.rglob("*.json")): + raw = path.read_bytes() + name = path.relative_to(root).as_posix() + manifest[name] = hashlib.sha256(raw).hexdigest() + document = json.loads(raw.decode("utf-8")) + record = None + if isinstance(document, dict): + for member in ("record", "trace"): + candidate = document.get(member) + if isinstance(candidate, dict) and "eat_profile" in candidate: + record = candidate + break + if record is None: + record = document + if not isinstance(record, dict) or "eat_profile" not in record: + continue + trusted_key = document.get("trusted_key") if isinstance(document, dict) else None + issued = record.get("iat") + options: dict[str, Any] = {"now": issued if isinstance(issued, int) else NOW} + if isinstance(trusted_key, dict): + options["trusted_key"] = trusted_key + else: + options["allow_embedded_key"] = True + cases.append({ + "id": f"external-{name.removesuffix('.json').replace('/', '-')}", + "kind": "verify", + "group": "D6-published-vectors", + "record_json": json.dumps(record, ensure_ascii=False), + "options": options, + }) + (BUILD / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return cases + + +def build(external: pathlib.Path | None) -> dict[str, Any]: + cases: list[dict[str, Any]] = [] + for context_name, options in contexts().items(): + for mutation_name, record_json in mutations(): + cases.append({ + "id": f"{context_name}/{mutation_name}", + "kind": "verify", + "group": "D1-record-under-context", + "record_json": record_json, + "options": options, + }) + cases += repository_vector_cases() + cases += external_vector_cases(external) + cases += jcs_cases() + cases += thumbprint_cases() + cases += chain_digest_cases() + return { + "signer_jwk": SIGNER_JWK, + "other_jwk": OTHER_JWK, + "cases": cases, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=pathlib.Path, default=BUILD / "cases.json") + parser.add_argument("--external", type=pathlib.Path, default=None, + help="a checkout of the published conformance vectors") + arguments = parser.parse_args() + BUILD.mkdir(exist_ok=True) + corpus = build(arguments.external) + arguments.out.write_text( + json.dumps(corpus, indent=1, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f"{len(corpus['cases'])} cases written to {arguments.out}") + + +if __name__ == "__main__": + main() diff --git a/trace-verify-ts/differential/known-divergences.json b/trace-verify-ts/differential/known-divergences.json new file mode 100644 index 00000000..9c656d76 --- /dev/null +++ b/trace-verify-ts/differential/known-divergences.json @@ -0,0 +1,438 @@ +{ + "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", + "reference": "agentrust-trace 0.10.0", + "divergences": [ + { + "ids": [ + "c23-bundle-lone-surrogate/*" + ], + "classification": "reference-defect", + "reason": "The revocation bundle carries a lone surrogate in log_id. Section 3.2.3 says a bundle a verifier cannot ground an answer on is reported as unverified for revocation, and the record's own verification continues. This implementation reports outcome unverified_for_revocation with cause bundle_malformed. The reference canonicalises the bundle to compute its digest before that point, and the canonicalisation error leaves verify_record as an exception, so the record is rejected for a defect in a third party's bundle. Every pair in this entry is that one difference showing through whichever record mutation the case also carries.", + "pairs": [ + [ + "canonicalization_failed", + "cnf_key_malformed" + ], + [ + "canonicalization_failed", + "cnf_key_mismatch" + ], + [ + "canonicalization_failed", + "cnf_key_unsupported" + ], + [ + "canonicalization_failed", + "schema_invalid" + ], + [ + "canonicalization_failed", + "signature_invalid" + ], + [ + "canonicalization_failed", + "signature_malformed" + ], + [ + "canonicalization_failed", + "verified" + ], + [ + "signature_malformed", + "schema_invalid" + ], + [ + "signature_missing", + "signature_malformed" + ] + ] + }, + { + "ids": [ + "c18-key-x-non-canonical/*" + ], + "classification": "stricter-here", + "reason": "The caller's trusted key has an x whose unused trailing bits are not zero, so it is not canonical base64url (RFC 4648 section 3.5). This implementation refuses the key as trusted_key_malformed. The reference decodes it leniently, obtaining a different key, and rejects later for a different stated reason. Both reject; the reason differs, and the pairs record which reason the reference reached under each record mutation.", + "pairs": [ + [ + "cnf_key_malformed", + "trusted_key_malformed" + ], + [ + "cnf_key_mismatch", + "schema_invalid" + ], + [ + "cnf_key_mismatch", + "signature_malformed" + ], + [ + "cnf_key_mismatch", + "trusted_key_malformed" + ], + [ + "cnf_key_unsupported", + "trusted_key_malformed" + ], + [ + "signature_invalid", + "trusted_key_malformed" + ], + [ + "signature_malformed", + "schema_invalid" + ], + [ + "signature_missing", + "signature_malformed" + ] + ] + }, + { + "ids": [ + "*/m03-signature-non-canonical-pad-bits" + ], + "classification": "reference-defect", + "reason": "The signature is re-spelled with non-zero unused trailing bits: a different string that decodes to the same 64 bytes. The reference pads and decodes leniently, so it verifies the record. This implementation refuses the spelling as signature_malformed. The difference matters beyond tidiness: section 3.1.3 digests the parent record with its signature member present, so two records that differ only in this spelling verify identically and have different chain digests.", + "pairs": [ + [ + "cnf_key_mismatch", + "signature_malformed" + ], + [ + "key_revoked", + "signature_malformed" + ], + [ + "nonce_mismatch", + "signature_malformed" + ], + [ + "record_in_future", + "signature_malformed" + ], + [ + "record_stale", + "signature_malformed" + ], + [ + "revocation_unavailable", + "signature_malformed" + ], + [ + "trusted_key_malformed", + "signature_malformed" + ], + [ + "trusted_key_missing", + "signature_malformed" + ], + [ + "trusted_key_unsupported", + "signature_malformed" + ], + [ + "verified", + "signature_malformed" + ] + ] + }, + { + "ids": [ + "*/m04-signature-truncated" + ], + "classification": "stricter-here", + "reason": "The signature is truncated to a length that is still a valid base64url length but whose trailing bits are not zero. Both implementations reject. The reference decodes it and reports signature_invalid; this one reports signature_malformed before verifying.", + "pairs": [ + [ + "cnf_key_mismatch", + "signature_malformed" + ], + [ + "key_revoked", + "signature_malformed" + ], + [ + "nonce_mismatch", + "signature_malformed" + ], + [ + "record_in_future", + "signature_malformed" + ], + [ + "record_stale", + "signature_malformed" + ], + [ + "revocation_unavailable", + "signature_malformed" + ], + [ + "signature_invalid", + "signature_malformed" + ], + [ + "trusted_key_malformed", + "signature_malformed" + ], + [ + "trusted_key_missing", + "signature_malformed" + ], + [ + "trusted_key_unsupported", + "signature_malformed" + ] + ] + }, + { + "ids": [ + "*/m05-signature-trailing-newline" + ], + "classification": "reference-defect", + "reason": "The signature carries a trailing newline. Both implementations reject. The schema pattern is ^[A-Za-z0-9_-]+$, and in JSON Schema 2020-12 a pattern is an ECMA-262 regular expression, where $ does not match before a final newline. This implementation therefore reports schema_invalid. Python's re module matches $ before a trailing newline, so the reference's schema check passes and its base64 decoder reports the failure instead.", + "pairs": [ + [ + "signature_malformed", + "schema_invalid" + ] + ] + }, + { + "ids": [ + "*/m08-signature-zero", + "*/m09-signature-false", + "*/m11-signature-empty-array", + "*/m12-signature-empty-object" + ], + "classification": "stricter-here", + "reason": "The signature member is present but is 0, false, [] or {}. Both implementations reject. The reference tests the member for truthiness, so all four read as a missing signature; this one distinguishes an absent member from a member of the wrong type and reports signature_malformed.", + "pairs": [ + [ + "signature_missing", + "signature_malformed" + ] + ] + }, + { + "ids": [ + "*/m20-iat-float-form", + "*/m21-iat-exponent-form" + ], + "classification": "language-level", + "reason": "iat is written 1785000000.0 or 1.785e9. JSON has one number type and JavaScript has one number type, so after JSON.parse the value is indistinguishable from the integer, its RFC 8785 form is the same bytes, and the record verifies here. Python's json module preserves the distinction as float, and the reference requires an int. A verifier that holds the record text can reject the spelling before parsing; one that is handed a parsed value cannot. Worth stating in the specification rather than leaving to each language.", + "pairs": [ + [ + "iat_invalid", + "nonce_mismatch" + ], + [ + "iat_invalid", + "record_in_future" + ], + [ + "iat_invalid", + "record_stale" + ], + [ + "iat_invalid", + "verified" + ] + ] + }, + { + "ids": [ + "*/m27-subject-trailing-newline", + "*/m28-subject-carriage-return", + "*/m29-subject-line-separator" + ], + "classification": "reference-defect", + "reason": "subject carries a trailing U+000A, U+000D or U+2028 inside a value that must match ^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$. Under ECMA-262, the dialect JSON Schema 2020-12 specifies for pattern, . excludes all three and $ does not match before a final newline, so this implementation reports schema_invalid. Python's re matches . against U+000D and U+2028 and $ before a trailing newline, so the reference accepts the record and verifies it.", + "pairs": [ + [ + "cnf_key_mismatch", + "schema_invalid" + ], + [ + "key_revoked", + "schema_invalid" + ], + [ + "nonce_mismatch", + "schema_invalid" + ], + [ + "record_in_future", + "schema_invalid" + ], + [ + "record_stale", + "schema_invalid" + ], + [ + "revocation_unavailable", + "schema_invalid" + ], + [ + "trusted_key_malformed", + "schema_invalid" + ], + [ + "trusted_key_missing", + "schema_invalid" + ], + [ + "trusted_key_unsupported", + "schema_invalid" + ], + [ + "verified", + "schema_invalid" + ] + ] + }, + { + "ids": [ + "*/m31-verifier-uri-trailing-newline" + ], + "classification": "reference-defect", + "reason": "appraisal.verifier is a format: uri value with a trailing newline. Same cause as the subject entry: the reference's format checker anchors with a regex whose $ matches before a final newline, so it accepts and verifies; this implementation applies RFC 3986 with an ECMA-262 anchor and reports schema_invalid.", + "pairs": [ + [ + "cnf_key_mismatch", + "schema_invalid" + ], + [ + "key_revoked", + "schema_invalid" + ], + [ + "nonce_mismatch", + "schema_invalid" + ], + [ + "record_in_future", + "schema_invalid" + ], + [ + "record_stale", + "schema_invalid" + ], + [ + "revocation_unavailable", + "schema_invalid" + ], + [ + "trusted_key_malformed", + "schema_invalid" + ], + [ + "trusted_key_missing", + "schema_invalid" + ], + [ + "trusted_key_unsupported", + "schema_invalid" + ], + [ + "verified", + "schema_invalid" + ] + ] + }, + { + "ids": [ + "*/m32-verifier-uri-ipv6-leading-zero" + ], + "classification": "reference-defect", + "reason": "appraisal.verifier is https://[::1.2.3.04]/. RFC 3986's dec-octet excludes a leading zero, so the IPv4-mapped literal is not a URI and this implementation reports schema_invalid. The reference's format checker accepts it and verifies the record.", + "pairs": [ + [ + "cnf_key_mismatch", + "schema_invalid" + ], + [ + "key_revoked", + "schema_invalid" + ], + [ + "nonce_mismatch", + "schema_invalid" + ], + [ + "record_in_future", + "schema_invalid" + ], + [ + "record_stale", + "schema_invalid" + ], + [ + "revocation_unavailable", + "schema_invalid" + ], + [ + "trusted_key_malformed", + "schema_invalid" + ], + [ + "trusted_key_missing", + "schema_invalid" + ], + [ + "trusted_key_unsupported", + "schema_invalid" + ], + [ + "verified", + "schema_invalid" + ] + ] + }, + { + "ids": [ + "*/m41-nonce-non-ascii" + ], + "classification": "reference-defect", + "reason": "runtime.nonce is a string outside ASCII. Both implementations reject, but the reference leaves verify_record as a TypeError from hmac.compare_digest, which refuses a str outside ASCII, rather than as one of its documented outcomes. This implementation compares in constant time over UTF-16 code units and reports nonce_mismatch.", + "pairs": [ + [ + "nonce_compare_unsupported", + "nonce_mismatch" + ] + ] + }, + { + "ids": [ + "*/m46-cnf-jwk-x-non-canonical", + "*/m47-cnf-jwk-x-truncated" + ], + "classification": "stricter-here", + "reason": "The record's cnf.jwk has an x that is not canonical base64url. Both implementations reject. This one refuses the encoding (cnf_key_malformed, or trusted_key_malformed when allowEmbeddedKey made that same key the trusted one); the reference decodes it leniently and reports the mismatch or the failed signature that follows.", + "pairs": [ + [ + "cnf_key_mismatch", + "cnf_key_malformed" + ], + [ + "signature_invalid", + "trusted_key_malformed" + ] + ] + }, + { + "ids": [ + "jcs-10" + ], + "classification": "language-level", + "reason": "The value is 1.0e+21. In Python that is a float, and RFC 8785 gives it the form 1e+21. In JavaScript it is an integer beyond 2^53-1, where section 3.2.2 requires rejection because two distinct integers share one canonical form. A number written with an exponent cannot be told from a float in a language with one number type, so the two rules meet here. The same value inside a record would be rejected by this implementation and canonicalised by the reference.", + "pairs": [ + [ + "canonical", + "canonicalization_failed" + ] + ] + } + ] +} \ No newline at end of file diff --git a/trace-verify-ts/differential/oracle.py b/trace-verify-ts/differential/oracle.py new file mode 100644 index 00000000..00554264 --- /dev/null +++ b/trace-verify-ts/differential/oracle.py @@ -0,0 +1,243 @@ +"""Run the differential corpus through the Python implementation and record verdicts. + +The reference reports a rejection as an exception, and several distinct checks +raise the same exception type with a message that is not machine readable. Two of +them raise the *same message* from the same helper, called twice: once for the +trusted key and once for the record's `cnf.jwk`. Classifying on the message alone +would merge those two checks, so the classifier reads the traceback and asks which +call site inside `verify_record` the exception came from. The line numbers are +looked up in the installed source rather than written down here, so a release that +moves them is a lookup failure rather than a silent misclassification. + +An exception this classifier does not recognise is recorded as `unclassified` with +its text, and the comparison reports it. Nothing is guessed. + + python differential/oracle.py [--cases build/cases.json] [--out build/python.jsonl] +""" + +from __future__ import annotations + +import argparse +import hashlib +import inspect +import json +import pathlib +import re +import traceback +import warnings +from typing import Any + +import agentrust_trace +import rfc8785 +from agentrust_trace.sign import jwk_thumbprint, verify_record +from cryptography.exceptions import InvalidSignature + + +def _call_sites() -> dict[str, int]: + """Line numbers of the two `_pubkey_from_jwk` calls inside `verify_record`.""" + lines, start = inspect.getsourcelines(verify_record) + sites: dict[str, int] = {} + for offset, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("pub = _pubkey_from_jwk(public_key_or_jwk)"): + sites["trusted_key"] = start + offset + elif stripped.startswith("_pubkey_from_jwk(embedded_jwk)"): + sites["cnf_key"] = start + offset + missing = {"trusted_key", "cnf_key"} - set(sites) + if missing: + raise SystemExit( + f"cannot locate the {sorted(missing)} call site in this release of " + "agentrust_trace; the classifier would merge two checks, so it refuses to run" + ) + return sites + + +CALL_SITES = _call_sites() + +MESSAGE_CODES: list[tuple[str, str]] = [ + ("now must be an integer", "invalid_argument"), + ("must be non-negative", "invalid_argument"), + ("record must be a JSON object", "record_not_object"), + ("record has no 'eat_profile'", "profile_missing"), + ("carries the superseded v0.1 profile", "profile_superseded"), + ("is not 'tag:agentrust-io.com,2026:trace-v0.2'", "profile_unsupported"), + ("record has no 'signature' field", "signature_missing"), + ("signature must be a base64url string", "signature_malformed"), + ("signature is not valid base64url", "signature_malformed"), + ("record does not conform to the TRACE v0.2 schema", "schema_invalid"), + ("verify_record requires a trusted key", "trusted_key_missing"), + ("record has no cnf.jwk and no public key was supplied", "trusted_key_missing"), + ("revocation status for key", "revocation_unavailable"), + ("signing key is revoked", "key_revoked"), + ("record has no valid cnf.jwk confirmation key", "cnf_key_malformed"), + ("record cnf.jwk does not identify the trusted key", "cnf_key_mismatch"), + ("record has no valid integer 'iat'", "iat_invalid"), + ("is dated", "record_in_future"), + ("record is stale", "record_stale"), + ("record runtime.nonce does not match", "nonce_mismatch"), +] + +KEY_MESSAGES: list[tuple[str, str]] = [ + ("unsupported JWK kty", "unsupported"), + ("unsupported JWK crv", "unsupported"), + ("JWK missing 'x' field", "malformed"), + ("JWK 'x' must be a base64url string", "malformed"), + ("JWK 'x' is not valid base64url", "malformed"), + ("An Ed25519 public key is 32 bytes", "malformed"), + ("public key is 32 bytes", "malformed"), +] + +SCHEMA_LOCATION = re.compile(r"schema at (?P[^:]*):") + + +def classify(exc: BaseException) -> dict[str, Any]: + if isinstance(exc, InvalidSignature): + return {"code": "signature_invalid"} + text = str(exc) + if isinstance(exc, TypeError) and "comparing strings with non-ASCII" in text: + # `hmac.compare_digest` refuses a str outside ASCII, so a record whose + # nonce is not ASCII leaves `verify_record` as a TypeError, which is not a + # documented outcome. Named rather than left unclassified: the classifier + # has understood it, and the comparison reports it as a divergence. + return {"code": "nonce_compare_unsupported"} + if isinstance(exc, rfc8785.CanonicalizationError) or type(exc).__name__ in { + "CanonicalizationError", + "IntegerDomainError", + "NaNError", + "InfError", + }: + return {"code": "canonicalization_failed", "detail": text} + if isinstance(exc, ValueError): + for fragment, code in MESSAGE_CODES: + if fragment in text: + result: dict[str, Any] = {"code": code} + if code == "schema_invalid": + match = SCHEMA_LOCATION.search(text) + result["path"] = match.group("location").strip() if match else None + return result + for fragment, kind in KEY_MESSAGES: + if fragment in text: + frames = [f for f in traceback.extract_tb(exc.__traceback__) + if f.name == "verify_record"] + if not frames: + return {"code": f"jwk_{kind}", "detail": text} + line = frames[-1].lineno + role = "trusted_key" if line <= CALL_SITES["cnf_key"] else "cnf_key" + if line == CALL_SITES["cnf_key"]: + role = "cnf_key" + elif line == CALL_SITES["trusted_key"]: + role = "trusted_key" + return {"code": f"{role}_{kind}"} + if "thumbprint" in text or "jwk must be a JSON object" in text: + return {"code": "jwk_invalid", "detail": text} + return {"code": "unclassified", "detail": f"{type(exc).__name__}: {text}"} + + +def store_from(spec: dict[str, Any] | None): + if spec is None: + return None + kind = spec["kind"] + if kind == "list": + return list(spec["ids"]) + if kind == "raises": + def raising(identifier: str) -> bool: + raise RuntimeError("the revocation source is unreachable") + return raising + if kind == "non_bool": + return lambda identifier: None + raise SystemExit(f"unknown revocation store kind {kind!r}") + + +def scrub(evidence: dict[str, Any]) -> dict[str, Any]: + """Evidence without the two members that carry a human-readable message.""" + return {k: v for k, v in evidence.items() if k not in {"error", "key"}} + + +def run_verify(case: dict[str, Any]) -> dict[str, Any]: + options = case["options"] + record = json.loads(case["record_json"]) + bundle_json = options.get("revocation_bundle_json") + keywords: dict[str, Any] = { + "allow_embedded_key": options.get("allow_embedded_key", False), + "now": options.get("now"), + "max_future_skew_seconds": options.get("max_future_skew_seconds", 300), + "max_bundle_age_seconds": options.get("max_bundle_age_seconds", 86400), + "expected_nonce": options.get("expected_nonce"), + "revocation": store_from(options.get("revocation")), + "revocation_bundle": json.loads(bundle_json) if bundle_json is not None else None, + "trusted_bundle_keys": options.get("trusted_bundle_keys"), + } + if "max_age_seconds" in options: + keywords["max_age_seconds"] = options["max_age_seconds"] + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + try: + result = verify_record(record, options.get("trusted_key"), **keywords) + except BaseException as exc: # noqa: BLE001 - every rejection is classified + return {"verdict": "rejected", **classify(exc)} + return { + "verdict": "verified", + "thumbprint": result.trusted_key_thumbprint, + "revocation": { + "outcome": result.revocation.outcome, + "cause": result.revocation.cause, + "evidence": scrub(dict(result.revocation.evidence)), + }, + } + + +def run_jcs(case: dict[str, Any]) -> dict[str, Any]: + try: + value = json.loads(case["value_json"]) + except ValueError as exc: + return {"verdict": "parse_error", "detail": str(exc)} + try: + return {"verdict": "canonical", "bytes": rfc8785.dumps(value).decode("utf-8")} + except BaseException as exc: # noqa: BLE001 + return {"verdict": "rejected", **classify(exc)} + + +def run_thumbprint(case: dict[str, Any]) -> dict[str, Any]: + try: + return {"verdict": "thumbprint", "value": jwk_thumbprint(json.loads(case["value_json"]))} + except BaseException as exc: # noqa: BLE001 + return {"verdict": "rejected", **classify(exc)} + + +def run_chain_digest(case: dict[str, Any]) -> dict[str, Any]: + record = json.loads(case["value_json"]) + algorithm = case["algorithm"] + try: + canonical = rfc8785.dumps(record) + except BaseException as exc: # noqa: BLE001 + return {"verdict": "rejected", **classify(exc)} + return {"verdict": "digest", + "value": f"{algorithm}:{hashlib.new(algorithm, canonical).hexdigest()}"} + + +RUNNERS = { + "verify": run_verify, + "jcs": run_jcs, + "thumbprint": run_thumbprint, + "chain_digest": run_chain_digest, +} + + +def main() -> None: + here = pathlib.Path(__file__).resolve().parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cases", type=pathlib.Path, default=here / "build" / "cases.json") + parser.add_argument("--out", type=pathlib.Path, default=here / "build" / "python.jsonl") + arguments = parser.parse_args() + corpus = json.loads(arguments.cases.read_text(encoding="utf-8")) + with arguments.out.open("w", encoding="utf-8") as handle: + for case in corpus["cases"]: + verdict = RUNNERS[case["kind"]](case) + handle.write(json.dumps({"id": case["id"], "group": case["group"], **verdict}, + sort_keys=True) + "\n") + print(f"{len(corpus['cases'])} verdicts from agentrust-trace {agentrust_trace.__version__} " + f"written to {arguments.out}") + + +if __name__ == "__main__": + main() diff --git a/trace-verify-ts/differential/run.mjs b/trace-verify-ts/differential/run.mjs new file mode 100644 index 00000000..330f54e7 --- /dev/null +++ b/trace-verify-ts/differential/run.mjs @@ -0,0 +1,152 @@ +// Run the differential corpus through this implementation and record verdicts. +// +// The verdict shape is the one `oracle.py` writes, so `compare.py` reads two +// files of the same kind. Each case is parsed here with this runtime's own JSON +// parser, from the same text the Python side parses with its own: where the two +// parsers differ, that is a difference the corpus is meant to surface. +// +// node differential/run.mjs [--cases build/cases.json] [--out build/typescript.jsonl] + +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + canonicalJson, + jwkThumbprint, + parentRecordHash, + TraceVerificationError, + verifyRecord, +} from "../dist/index.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +function argument(name, fallback) { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? fallback : process.argv[index + 1]; +} + +function scrub(evidence) { + return Object.fromEntries(Object.entries(evidence).filter(([name]) => name !== "error" && name !== "key")); +} + +function rejection(error) { + if (error instanceof TraceVerificationError) { + const out = { verdict: "rejected", code: error.code }; + if (error.code === "schema_invalid") { + out.path = error.path ?? null; + } + return out; + } + return { verdict: "rejected", code: "unclassified", detail: `${error?.name}: ${error?.message}` }; +} + +function store(spec) { + if (spec === undefined || spec === null) { + return undefined; + } + if (spec.kind === "list") { + return spec.ids; + } + if (spec.kind === "raises") { + return () => { + throw new Error("the revocation source is unreachable"); + }; + } + if (spec.kind === "non_bool") { + return () => undefined; + } + throw new Error(`unknown revocation store kind ${spec.kind}`); +} + +async function runVerify(item) { + const options = item.options; + let record; + try { + record = JSON.parse(item.record_json); + } catch (error) { + return { verdict: "parse_error", detail: String(error?.message) }; + } + const settings = { + trustedKey: options.trusted_key, + allowEmbeddedKey: options.allow_embedded_key ?? false, + now: options.now, + maxFutureSkewSeconds: options.max_future_skew_seconds ?? 300, + maxBundleAgeSeconds: options.max_bundle_age_seconds ?? 86400, + expectedNonce: options.expected_nonce ?? undefined, + revocation: store(options.revocation), + trustedBundleKeys: options.trusted_bundle_keys ?? [], + }; + if (Object.hasOwn(options, "max_age_seconds")) { + settings.maxAgeSeconds = options.max_age_seconds; + } + if (options.revocation_bundle_json !== undefined && options.revocation_bundle_json !== null) { + try { + settings.revocationBundle = JSON.parse(options.revocation_bundle_json); + } catch (error) { + return { verdict: "parse_error", detail: `bundle: ${String(error?.message)}` }; + } + } + try { + const result = await verifyRecord(record, settings); + return { + verdict: "verified", + thumbprint: result.trustedKeyThumbprint, + revocation: { + outcome: result.revocation.outcome, + cause: result.revocation.cause, + evidence: scrub(result.revocation.evidence), + }, + }; + } catch (error) { + return rejection(error); + } +} + +async function runJcs(item) { + let value; + try { + value = JSON.parse(item.value_json); + } catch (error) { + return { verdict: "parse_error", detail: String(error?.message) }; + } + try { + return { verdict: "canonical", bytes: canonicalJson(value) }; + } catch (error) { + return rejection(error); + } +} + +async function runThumbprint(item) { + try { + return { verdict: "thumbprint", value: await jwkThumbprint(JSON.parse(item.value_json)) }; + } catch (error) { + return rejection(error); + } +} + +async function runChainDigest(item) { + try { + return { verdict: "digest", value: await parentRecordHash(JSON.parse(item.value_json), item.algorithm) }; + } catch (error) { + return rejection(error); + } +} + +const RUNNERS = { + verify: runVerify, + jcs: runJcs, + thumbprint: runThumbprint, + chain_digest: runChainDigest, +}; + +const casesPath = argument("cases", join(HERE, "build", "cases.json")); +const outPath = argument("out", join(HERE, "build", "typescript.jsonl")); +const corpus = JSON.parse(readFileSync(casesPath, "utf8")); +const lines = []; +for (const item of corpus.cases) { + const verdict = await RUNNERS[item.kind](item); + lines.push(JSON.stringify({ id: item.id, group: item.group, ...verdict })); +} +writeFileSync(outPath, `${lines.join("\n")}\n`, "utf8"); +console.log(`${corpus.cases.length} verdicts written to ${outPath}`); From 0d5e84482439e5f939ba29fe48d0f33cbf3b5e37 Mon Sep 17 00:00:00 2001 From: chernistry Date: Sat, 19 Sep 2026 19:47:09 +0300 Subject: [PATCH 04/22] ci(trace-verify-ts): run the unit tests and the differential harness Two jobs. The unit job builds and tests on Node 20, 22 and 24: the floor is what the package declares and the ceiling is current, so a runtime leaving that matrix is a supported-versions decision rather than a CI adjustment. The differential job installs the Python implementation from this tree, checks out the published conformance vectors at a pinned commit, runs both sides over the generated corpus and fails on any disagreement that is not in the ledger. The vector checkout is allowed to fail: the corpus without it is this repository's own vectors plus the generated matrix, which is the larger part, and a verifier's CI should not go red because another repository is unreachable. Actions are pinned by commit, as the other workflows here are. Signed-off-by: chernistry --- .github/workflows/trace-verify-ts.yml | 112 ++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 .github/workflows/trace-verify-ts.yml diff --git a/.github/workflows/trace-verify-ts.yml b/.github/workflows/trace-verify-ts.yml new file mode 100644 index 00000000..36112e2c --- /dev/null +++ b/.github/workflows/trace-verify-ts.yml @@ -0,0 +1,112 @@ +name: trace-verify-ts + +on: + push: + branches: ["main", "feat/**", "fix/**"] + paths: + - "trace-verify-ts/**" + - "schema/**" + - "examples/**" + - "src/agentrust_trace/**" + - ".github/workflows/trace-verify-ts.yml" + pull_request: + paths: + - "trace-verify-ts/**" + - "schema/**" + - "examples/**" + - "src/agentrust_trace/**" + - ".github/workflows/trace-verify-ts.yml" + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +permissions: + contents: read + +jobs: + unit: + name: unit tests + runs-on: ubuntu-latest + strategy: + matrix: + # The floor is what the package declares (WebCrypto with Ed25519), and + # the ceiling is current. A runtime that drops out of this matrix is a + # supported-versions change, not a CI adjustment. + node-version: ["20", "22", "24"] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Install + run: npm ci + working-directory: trace-verify-ts + + - name: Build and test + run: npm test + working-directory: trace-verify-ts + + differential: + name: differential against the Python implementation + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Check out the published conformance vectors + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: agentrust-io/trace-tests + # Pinned: the corpus is scored input, so it changes when this workflow + # says it changes. generate_cases.py writes the SHA-256 of every file + # it read into build/manifest.json. + ref: 3af2b53eb2e041918b8a6706a3e5a83f143a7568 + path: .vectors + continue-on-error: true + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install + run: | + pip install --require-hashes -r requirements/dev.txt + pip install --no-deps -e . + npm ci --prefix trace-verify-ts + + - name: Lint the harness + run: ruff check trace-verify-ts/differential + + - name: Build the verifier + run: npm run build --prefix trace-verify-ts + + - name: Generate the corpus + run: | + if [ -d .vectors/tests/vectors ]; then + python differential/generate_cases.py --external "$GITHUB_WORKSPACE/.vectors/tests/vectors" + else + python differential/generate_cases.py + fi + working-directory: trace-verify-ts + + - name: Run both implementations and compare + run: | + python differential/oracle.py + node differential/run.mjs + python differential/compare.py --report differential/build/report.json + working-directory: trace-verify-ts + + - name: Upload the report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: differential-report + path: trace-verify-ts/differential/build/report.json + if-no-files-found: ignore From 5df7fd5405b4626092f32c45bb5246906acec09c Mon Sep 17 00:00:00 2001 From: chernistry Date: Sat, 19 Sep 2026 19:47:09 +0300 Subject: [PATCH 05/22] docs(changelog): record the TypeScript verifier and what the differential run found Signed-off-by: chernistry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19bb7397..2af492f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ### Added +- **A second, independent verifier: `trace-verify-ts`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trace-verify-ts/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1280 agree exactly, every published vector among them, and the remaining 364 are 13 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed. Five of the 13 are cases where the reference accepts a record this specification's own rules exclude: a signature re-spelled with non-zero unused base64 bits verifies (and section 3.1.3 digests the signature member, so two records that differ only in that spelling both verify and have different chain digests); a trailing newline or a carriage return in a patterned field passes because Python's `re` anchors `$` before a final newline and matches `.` against characters ECMA-262 excludes; `https://[::1.2.3.04]/` passes `format: uri` although RFC 3986's `dec-octet` has no leading zero; a non-ASCII `runtime.nonce` leaves `verify_record` as a `TypeError` from `hmac.compare_digest`; and a revocation bundle carrying a lone surrogate makes the reference reject the record rather than report `unverified_for_revocation` as section 3.2.3 requires. Two more are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. + - **Reproducibility claim: deterministic re-execution as evidence (section 3.1.4).** A record may now carry `reproducibility`, the claim that re-executing a named deterministic function of the run, the producer's coordination logic rather than the workload's side effects, over a pinned input closure yields a transcript whose RFC 8785 digest equals the claimed value. The claim has three explicit parts: the code identity (`code_identity`, with the `function` it exposes and an optional `code_resolver` where the digest alone does not locate the artifact), `input_closure` as `{id, digest, resolver}` entries covering everything the function reads, model calls included, and `transcript_digest`. A closure that omits anything which can change the transcript makes the claim malformed rather than weak. A verifier must hold the implementation, obtainable as a public artifact without the producer; the closure blobs, each matching the digest the producer signed, which is an integrity bar and not a provenance one, since a run's closure is run-private; and the claim itself from the signed record. If re-execution needs state only the producer's environment can supply, it is not reproduction, and a read beyond the closure fixes the outcome at `not-attempted` with the read named, whatever the re-run would otherwise have produced. The re-execution result is an appraisal under `appraisal.method: "re-execution"` with a tri-state `outcome`: `reproduced`, `diverged` (recording the verifier's observed digest) or `not-attempted` (carrying the reason), and `not-attempted` MUST NOT be reported as either of the other two. `appraisal.status` stays the closed EAR set of #190, and the section says why `not-attempted` is not `status: none`. Non-breaking: a new optional member, an optional `appraisal.method`, and `appraisal.re_execution` present exactly when the method is `re-execution`; no change to required fields or wire format. The schema, the reference model and `docs/schema.md` track the text in a follow-up, and vectors from a producer that makes the claim follow with them. Proposed and written by @chernistry in #249; carried by @imran-siddique as sponsoring maintainer. The vendor annex in #231 is sequenced after it. - **The PIC/TRACE bridge cites the specification it consumes.** `docs/integration/pic-trace-bridge-v1.md` gains a References section naming the PIC Standard repository, PIC Canonical JSON v1 (`PIC-CJSON/1.0`), whose section 8 gives the byte rules for the `intent_digest` and `args_digest` values this bridge preserves, and the PIC vocabulary that PIC asks downstream specifications to cite rather than recoin. It also gains a subsection on the representation boundary those values cross. PIC gives each digest as 64 lowercase hexadecimal characters and computes the two differently: section 8.1 over the canonical bytes of `action.args`, section 8.3 over the UTF-8 bytes of the `intent` string with no JSON wrapping or canonicalization. `schema/pic-trace-bridge-v1.json` constrains both fields through its `$defs/digest`, `^sha256:[0-9a-f]{64}$`, so an adapter serializes the PIC verifier's output as `sha256:` at the boundary. A bare hexadecimal value is refused with `authorization.pic.intent_digest must be a sha256 digest` on the artifact, or `intent_digest must be a sha256 digest` on the argument to the reference implementation. The prefix is a TRACE serialization of the same digest value: it does not redefine the PIC digests, and the bridge still does not recompute them. `tests/test_pic_bridge_doc_matches_the_code.py` pins the quoted pattern and both quoted messages to what they quote, since nothing else in the suite reads that document. Reported by @madeinplutofabio in #361. Informative only: no schema, wire-format or normative change. From da8b9f0eac0699a58aa8a09817fb99451d5e30ee Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Sun, 20 Sep 2026 21:44:27 -0700 Subject: [PATCH 06/22] fix(trace-verify-ts): accept uppercase IPvFuture version flag Signed-off-by: Imran Siddique --- trace-verify-ts/src/uri.ts | 2 +- trace-verify-ts/tests/uri.test.mjs | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 trace-verify-ts/tests/uri.test.mjs diff --git a/trace-verify-ts/src/uri.ts b/trace-verify-ts/src/uri.ts index 9dadcb9c..2ec35a63 100644 --- a/trace-verify-ts/src/uri.ts +++ b/trace-verify-ts/src/uri.ts @@ -31,7 +31,7 @@ const IPV6ADDRESS = `(?:${[ `(?:(?:${H16}:){0,5}${H16})?::${H16}`, `(?:(?:${H16}:){0,6}${H16})?::`, ].join("|")})`; -const IPVFUTURE = `v${HEXDIG}+\\.(?:${UNRESERVED}|${SUB_DELIMS}|:)+`; +const IPVFUTURE = `[vV]${HEXDIG}+\\.(?:${UNRESERVED}|${SUB_DELIMS}|:)+`; const IP_LITERAL = `\\[(?:${IPV6ADDRESS}|${IPVFUTURE})\\]`; const REG_NAME = `(?:${UNRESERVED}|${PCT_ENCODED}|${SUB_DELIMS})*`; const HOST = `(?:${IP_LITERAL}|${IPV4ADDRESS}|${REG_NAME})`; diff --git a/trace-verify-ts/tests/uri.test.mjs b/trace-verify-ts/tests/uri.test.mjs new file mode 100644 index 00000000..a8dc7f6e --- /dev/null +++ b/trace-verify-ts/tests/uri.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { isUri } from '../dist/uri.js'; + +test('IPvFuture version flag is case-insensitive under RFC 3986 section 3.2.2', () => { + for (const uri of ['https://[v1.a]/', 'https://[V1.a]/', 'https://[vF.a:b]/', 'https://[VF.a:b]/']) { + assert.equal(isUri(uri), true, uri); + } + for (const uri of ['https://[V.a]/', 'https://[VG.a]/', 'https://[V1.]/', 'https://[V1.a]/\n']) { + assert.equal(isUri(uri), false, uri); + } +}); \ No newline at end of file From 8314ff857adb0462bd3f4acf99c8762d872d791d Mon Sep 17 00:00:00 2001 From: chernistry Date: Mon, 21 Sep 2026 16:04:48 +0300 Subject: [PATCH 07/22] differential: assert the published-vector count and hold the ledger in both directions The workflow tested for the trace-tests checkout at a path relative to trace-verify-ts, so the published-vector group never ran in CI: the one run logged 1632 cases where 1644 were expected, and nothing failed. The checkout is now required, generate_cases.py refuses a root it cannot read or that yields no record, and compare.py --expect-external N fails unless exactly N cases came from it. The manifest of vector digests is always written, printed with the count, and uploaded with the report. The ledger failed in one direction only: a declared divergence that stopped being reached lowered the number the run leads with and exited 0. Each entry now declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails the run. Six pairs that attributed a signature or schema divergence to the bundle or trusted-key context move to the entries whose fix alone resolves them; the 364 is unchanged, its split is now 20/14/23/23/24/96/30/69/23/23/2/16/1. Setting the RFC 4648 unused-bits mask to zero, which one unit test already caught, now also fails the differential: entries 2, 3, 4 and 12 unreached, one pair of entry 1 never observed. Signed-off-by: chernistry --- .github/workflows/trace-verify-ts.yml | 23 ++--- trace-verify-ts/README.md | 17 +++- trace-verify-ts/differential/compare.py | 93 +++++++++++++++++-- .../differential/generate_cases.py | 29 ++++-- .../differential/known-divergences.json | 45 ++++----- 5 files changed, 149 insertions(+), 58 deletions(-) diff --git a/.github/workflows/trace-verify-ts.yml b/.github/workflows/trace-verify-ts.yml index 36112e2c..c34ff96f 100644 --- a/.github/workflows/trace-verify-ts.yml +++ b/.github/workflows/trace-verify-ts.yml @@ -61,11 +61,15 @@ jobs: with: repository: agentrust-io/trace-tests # Pinned: the corpus is scored input, so it changes when this workflow - # says it changes. generate_cases.py writes the SHA-256 of every file - # it read into build/manifest.json. + # says it changes. The case count below is pinned with it: compare.py + # fails unless exactly that many cases came from this checkout, and + # generate_cases.py fails if the checkout is missing, so a corpus that + # did not run cannot print as a smaller corpus with `unexpected: 0`. + # generate_cases.py writes the SHA-256 of every file it read into + # build/manifest.json; compare.py prints the count and the manifest + # digest, and both are uploaded with the report. ref: 3af2b53eb2e041918b8a6706a3e5a83f143a7568 path: .vectors - continue-on-error: true - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -88,19 +92,14 @@ jobs: run: npm run build --prefix trace-verify-ts - name: Generate the corpus - run: | - if [ -d .vectors/tests/vectors ]; then - python differential/generate_cases.py --external "$GITHUB_WORKSPACE/.vectors/tests/vectors" - else - python differential/generate_cases.py - fi + run: python differential/generate_cases.py --external "$GITHUB_WORKSPACE/.vectors/tests/vectors" working-directory: trace-verify-ts - name: Run both implementations and compare run: | python differential/oracle.py node differential/run.mjs - python differential/compare.py --report differential/build/report.json + python differential/compare.py --expect-external 12 --report differential/build/report.json working-directory: trace-verify-ts - name: Upload the report @@ -108,5 +107,7 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: differential-report - path: trace-verify-ts/differential/build/report.json + path: | + trace-verify-ts/differential/build/report.json + trace-verify-ts/differential/build/manifest.json if-no-files-found: ignore diff --git a/trace-verify-ts/README.md b/trace-verify-ts/README.md index 9f1edc53..94b217e9 100644 --- a/trace-verify-ts/README.md +++ b/trace-verify-ts/README.md @@ -79,19 +79,29 @@ so a schema edit that is not rebuilt fails rather than passing quietly. ## The differential harness ``` -python differential/generate_cases.py [--external /tests/vectors] +python differential/generate_cases.py --external /tests/vectors python differential/oracle.py # the Python implementation's verdicts node differential/run.mjs # this implementation's verdicts -python differential/compare.py +python differential/compare.py --expect-external 12 ``` +`--external` is a checkout of the published conformance vectors; CI pins one and passes +`--expect-external` with the number of cases it must yield, so a checkout that did not +happen fails the run rather than shrinking the corpus. Without it the corpus is 1632 cases +and the published-vector row below is absent. `generate_cases.py` writes the SHA-256 of +every vector file it read to `build/manifest.json`, and `compare.py` prints the count and +the manifest digest. + The corpus is one JSON file holding each record as *text*, because several cases exist to probe a difference that only survives in text. Both runners parse the same bytes with their own JSON parser. A case agrees when both sides reach the same verdict for the same stated reason, and every disagreement is argued in [`differential/known-divergences.json`](differential/known-divergences.json), matched on both the case and the pair of reported reasons, so a new disagreement in a family already -listed is reported rather than absorbed. +listed is reported rather than absorbed. The ledger holds in the other direction too: each +entry declares how many cases land on it, and an entry, a pair or a count the run does not +bear out fails it, so a divergence that stops happening on either side has to leave the +ledger rather than stay as a claim nothing checks. Latest run, against agentrust-trace 0.10.0: @@ -107,3 +117,4 @@ Latest run, against agentrust-trace 0.10.0: Every published vector agrees. The 364 remaining cases are 13 documented divergence classes in the adversarial matrix, each one appearing once per verifier configuration. + diff --git a/trace-verify-ts/differential/compare.py b/trace-verify-ts/differential/compare.py index 034348f3..a31fde78 100644 --- a/trace-verify-ts/differential/compare.py +++ b/trace-verify-ts/differential/compare.py @@ -10,13 +10,21 @@ not fail the run; every other one does. The ledger is the place where a difference is argued, so no difference can be absorbed by quietly matching the other side. - python differential/compare.py +The ledger holds in the other direction too. An entry that no case reaches, or a +declared pair of reasons that no case produces, fails the run: a divergence that +stops happening, on either side, has to leave the ledger rather than stay as a +claim nothing checks. `--expect-external N` fails the run unless exactly N cases +came from the published conformance vectors, so a checkout that did not happen +prints as a failure rather than as a smaller corpus with `unexpected: 0`. + + python differential/compare.py [--expect-external N] """ from __future__ import annotations import argparse import fnmatch +import hashlib import json import pathlib from typing import Any @@ -58,22 +66,44 @@ def observed_pair(left: dict[str, Any], right: dict[str, Any]) -> list[str]: def ledger_entry( ledger: list[dict[str, Any]], case_id: str, pair: list[str] -) -> dict[str, Any] | None: - """The ledger entry that covers this divergence, or None. +) -> tuple[int, dict[str, Any]] | None: + """The ledger entry that covers this divergence, with its position, or None. An entry matches on both the case and the pair of reported reasons. Matching on the case alone would let a broad pattern absorb a *different* divergence appearing later in the same family, which is the failure mode the ledger exists to prevent. """ - for entry in ledger: + for index, entry in enumerate(ledger): if not any(fnmatch.fnmatch(case_id, pattern) for pattern in entry["ids"]): continue if pair in [list(known) for known in entry["pairs"]]: - return entry + return index, entry return None +def unreached(ledger: list[dict[str, Any]], hits: dict[tuple[int, str], int]) -> list[str]: + """Every ledger claim the run did not bear out: an entry no case reached, a + declared pair no case produced, or a `count` the entry did not land on. Each is + a divergence that was resolved, renamed, moved to another entry or never + existed, and the ledger did not follow.""" + out = [] + for index, entry in enumerate(ledger): + pairs = [list(known) for known in entry["pairs"]] + observed = {json.dumps(pair): hits.get((index, json.dumps(pair)), 0) for pair in pairs} + if not any(observed.values()): + out.append(f"entry {index + 1} {entry['ids']}: no case reached it") + continue + for pair in pairs: + if not observed[json.dumps(pair)]: + out.append(f"entry {index + 1} {entry['ids']}: pair {pair} never observed") + landed = sum(observed.values()) + if "count" in entry and landed != entry["count"]: + where = f"entry {index + 1} {entry['ids']}" + out.append(f"{where}: declares {entry['count']} cases, {landed} landed") + return out + + def main() -> int: here = pathlib.Path(__file__).resolve().parent parser = argparse.ArgumentParser(description=__doc__) @@ -83,6 +113,14 @@ def main() -> int: ) parser.add_argument("--ledger", type=pathlib.Path, default=here / "known-divergences.json") parser.add_argument("--report", type=pathlib.Path, default=None) + parser.add_argument( + "--manifest", type=pathlib.Path, default=here / "build" / "manifest.json", + help="the SHA-256 manifest generate_cases.py wrote for the published vectors", + ) + parser.add_argument( + "--expect-external", type=int, default=None, metavar="N", + help="fail unless exactly N cases came from the published conformance vectors", + ) arguments = parser.parse_args() python = load(arguments.python) @@ -96,6 +134,7 @@ def main() -> int: unexpected: list[dict[str, Any]] = [] unclassified: list[str] = [] path_only: list[dict[str, Any]] = [] + hits: dict[tuple[int, str], int] = {} for case_id in ids: left = python.get(case_id) @@ -120,8 +159,11 @@ def main() -> int: "python": {k: v for k, v in left.items() if k not in {"id", "group"}}, "typescript": {k: v for k, v in right.items() if k not in {"id", "group"}}, } - entry = ledger_entry(ledger, case_id, observed_pair(left, right)) - if entry is not None: + pair = observed_pair(left, right) + found = ledger_entry(ledger, case_id, pair) + if found is not None: + index, entry = found + hits[(index, json.dumps(pair))] = hits.get((index, json.dumps(pair)), 0) + 1 known.append({ **record, "reason": entry["reason"], @@ -130,11 +172,38 @@ def main() -> int: else: unexpected.append(record) + stale = unreached(ledger, hits) + external = [ + case_id for case_id in ids + if (python.get(case_id) or typescript.get(case_id) or {}).get("group") + == "D6-published-vectors" + ] + manifest = ( + json.loads(arguments.manifest.read_text(encoding="utf-8")) + if arguments.manifest.exists() else {} + ) + manifest_digest = hashlib.sha256( + json.dumps(manifest, sort_keys=True).encode("utf-8") + ).hexdigest() + external_short = ( + None if arguments.expect_external is None + else arguments.expect_external - len(external) + ) + total = len(ids) print(f"cases: {total}") print(f"identical verdicts: {len(agreed)}") print(f"known divergences: {len(known)}") print(f"unexpected: {len(unexpected)}") + print(f"ledger: {len(ledger)} entries, " + f"{sum(len(e['pairs']) for e in ledger)} pairs, {len(stale)} unreached") + print(f"published vectors: {len(external)} cases from {len(manifest)} files, " + f"manifest sha256:{manifest_digest[:16]}") + if external_short: + print(f" expected {arguments.expect_external} published-vector cases, " + f"got {len(external)}") + for line in stale: + print(f" {line}") if unclassified: print(f"unclassified Python exceptions: {len(unclassified)}") for line in unclassified[:20]: @@ -152,13 +221,21 @@ def main() -> int: "known_divergences": len(known), "unexpected": len(unexpected), "unclassified_python_exceptions": len(unclassified), + "published_vector_cases": len(external), + "ledger_unreached": len(stale), + }, + "published_vectors": { + "files": manifest, + "manifest_sha256": manifest_digest, + "expected_cases": arguments.expect_external, }, + "ledger_unreached": stale, "known": known, "unexpected": unexpected, "schema_location_differences": path_only, }, indent=1, sort_keys=True) + "\n", encoding="utf-8") - return 1 if unexpected or unclassified else 0 + return 1 if unexpected or unclassified or stale or external_short else 0 if __name__ == "__main__": diff --git a/trace-verify-ts/differential/generate_cases.py b/trace-verify-ts/differential/generate_cases.py index ed6accb1..41f353bf 100644 --- a/trace-verify-ts/differential/generate_cases.py +++ b/trace-verify-ts/differential/generate_cases.py @@ -380,14 +380,19 @@ def repository_vector_cases() -> list[dict[str, Any]]: def external_vector_cases(root: pathlib.Path | None) -> list[dict[str, Any]]: """The published conformance vectors, read from a pinned checkout. - Nothing is vendored: the directory is passed in, and `manifest.json` beside - this file records the SHA-256 of every file the corpus was built from, so a - later run against a different checkout is detected rather than absorbed. + Nothing is vendored: the directory is passed in, and `build/manifest.json` + records the SHA-256 of every file the corpus was built from. A root that is + passed and cannot be read is an error, not an empty group: the workflow pins + the checkout and asserts the case count, and a silent empty group would print + the same `unexpected: 0` as a full one. """ - if root is None or not root.is_dir(): - return [] - cases = [] - manifest = {} + cases: list[dict[str, Any]] = [] + manifest: dict[str, str] = {} + if root is None: + write_manifest(manifest) + return cases + if not root.is_dir(): + raise SystemExit(f"--external {root}: not a directory") for path in sorted(root.rglob("*.json")): raw = path.read_bytes() name = path.relative_to(root).as_posix() @@ -418,10 +423,18 @@ def external_vector_cases(root: pathlib.Path | None) -> list[dict[str, Any]]: "record_json": json.dumps(record, ensure_ascii=False), "options": options, }) + write_manifest(manifest) + if not cases: + raise SystemExit(f"--external {root}: {len(manifest)} JSON files, no Trust Record in them") + return cases + + +def write_manifest(manifest: dict[str, str]) -> None: + """Always written, empty when no vectors were read, so compare.py never reads a + manifest left behind by an earlier run against a different checkout.""" (BUILD / "manifest.json").write_text( json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) - return cases def build(external: pathlib.Path | None) -> dict[str, Any]: diff --git a/trace-verify-ts/differential/known-divergences.json b/trace-verify-ts/differential/known-divergences.json index 9c656d76..a53b262d 100644 --- a/trace-verify-ts/differential/known-divergences.json +++ b/trace-verify-ts/differential/known-divergences.json @@ -1,13 +1,14 @@ { - "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", + "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. Entries are tried in order and a case lands on the first that matches; count is how many cases land on the entry in the committed corpus, and compare.py fails when an entry, a pair or a count is not borne out by the run, so a divergence that stops happening has to leave the ledger. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", "reference": "agentrust-trace 0.10.0", "divergences": [ { "ids": [ "c23-bundle-lone-surrogate/*" ], + "count": 20, "classification": "reference-defect", - "reason": "The revocation bundle carries a lone surrogate in log_id. Section 3.2.3 says a bundle a verifier cannot ground an answer on is reported as unverified for revocation, and the record's own verification continues. This implementation reports outcome unverified_for_revocation with cause bundle_malformed. The reference canonicalises the bundle to compute its digest before that point, and the canonicalisation error leaves verify_record as an exception, so the record is rejected for a defect in a third party's bundle. Every pair in this entry is that one difference showing through whichever record mutation the case also carries.", + "reason": "The revocation bundle carries a lone surrogate in log_id. Section 3.2.3 says a bundle a verifier cannot ground an answer on is reported as unverified for revocation, and the record's own verification continues. This implementation reports outcome unverified_for_revocation with cause bundle_malformed. The reference canonicalises the bundle to compute its digest before that point, and the canonicalisation error leaves verify_record as an exception, so the record is rejected for a defect in a third party's bundle. Every pair in this entry is that one difference showing through whichever record mutation the case also carries. A mutation that fails before the bundle is read, a malformed or missing signature, is that mutation's entry, not this one.", "pairs": [ [ "canonicalization_failed", @@ -36,14 +37,6 @@ [ "canonicalization_failed", "verified" - ], - [ - "signature_malformed", - "schema_invalid" - ], - [ - "signature_missing", - "signature_malformed" ] ] }, @@ -51,21 +44,14 @@ "ids": [ "c18-key-x-non-canonical/*" ], + "count": 14, "classification": "stricter-here", - "reason": "The caller's trusted key has an x whose unused trailing bits are not zero, so it is not canonical base64url (RFC 4648 section 3.5). This implementation refuses the key as trusted_key_malformed. The reference decodes it leniently, obtaining a different key, and rejects later for a different stated reason. Both reject; the reason differs, and the pairs record which reason the reference reached under each record mutation.", + "reason": "The caller's trusted key has an x whose unused trailing bits are not zero, so it is not canonical base64url (RFC 4648 section 3.5). This implementation refuses the key as trusted_key_malformed. The reference decodes it leniently, obtaining a different key, and rejects later for a different stated reason. Both reject; the reason differs, and the pairs record which reason the reference reached under each record mutation. A mutation that this implementation refuses before it reads the trusted key, a schema or signature defect, is that mutation's entry, not this one.", "pairs": [ [ "cnf_key_malformed", "trusted_key_malformed" ], - [ - "cnf_key_mismatch", - "schema_invalid" - ], - [ - "cnf_key_mismatch", - "signature_malformed" - ], [ "cnf_key_mismatch", "trusted_key_malformed" @@ -77,14 +63,6 @@ [ "signature_invalid", "trusted_key_malformed" - ], - [ - "signature_malformed", - "schema_invalid" - ], - [ - "signature_missing", - "signature_malformed" ] ] }, @@ -92,6 +70,7 @@ "ids": [ "*/m03-signature-non-canonical-pad-bits" ], + "count": 23, "classification": "reference-defect", "reason": "The signature is re-spelled with non-zero unused trailing bits: a different string that decodes to the same 64 bytes. The reference pads and decodes leniently, so it verifies the record. This implementation refuses the spelling as signature_malformed. The difference matters beyond tidiness: section 3.1.3 digests the parent record with its signature member present, so two records that differ only in this spelling verify identically and have different chain digests.", "pairs": [ @@ -141,6 +120,7 @@ "ids": [ "*/m04-signature-truncated" ], + "count": 23, "classification": "stricter-here", "reason": "The signature is truncated to a length that is still a valid base64url length but whose trailing bits are not zero. Both implementations reject. The reference decodes it and reports signature_invalid; this one reports signature_malformed before verifying.", "pairs": [ @@ -190,6 +170,7 @@ "ids": [ "*/m05-signature-trailing-newline" ], + "count": 24, "classification": "reference-defect", "reason": "The signature carries a trailing newline. Both implementations reject. The schema pattern is ^[A-Za-z0-9_-]+$, and in JSON Schema 2020-12 a pattern is an ECMA-262 regular expression, where $ does not match before a final newline. This implementation therefore reports schema_invalid. Python's re module matches $ before a trailing newline, so the reference's schema check passes and its base64 decoder reports the failure instead.", "pairs": [ @@ -206,6 +187,7 @@ "*/m11-signature-empty-array", "*/m12-signature-empty-object" ], + "count": 96, "classification": "stricter-here", "reason": "The signature member is present but is 0, false, [] or {}. Both implementations reject. The reference tests the member for truthiness, so all four read as a missing signature; this one distinguishes an absent member from a member of the wrong type and reports signature_malformed.", "pairs": [ @@ -220,6 +202,7 @@ "*/m20-iat-float-form", "*/m21-iat-exponent-form" ], + "count": 30, "classification": "language-level", "reason": "iat is written 1785000000.0 or 1.785e9. JSON has one number type and JavaScript has one number type, so after JSON.parse the value is indistinguishable from the integer, its RFC 8785 form is the same bytes, and the record verifies here. Python's json module preserves the distinction as float, and the reference requires an int. A verifier that holds the record text can reject the spelling before parsing; one that is handed a parsed value cannot. Worth stating in the specification rather than leaving to each language.", "pairs": [ @@ -247,6 +230,7 @@ "*/m28-subject-carriage-return", "*/m29-subject-line-separator" ], + "count": 69, "classification": "reference-defect", "reason": "subject carries a trailing U+000A, U+000D or U+2028 inside a value that must match ^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$. Under ECMA-262, the dialect JSON Schema 2020-12 specifies for pattern, . excludes all three and $ does not match before a final newline, so this implementation reports schema_invalid. Python's re matches . against U+000D and U+2028 and $ before a trailing newline, so the reference accepts the record and verifies it.", "pairs": [ @@ -296,6 +280,7 @@ "ids": [ "*/m31-verifier-uri-trailing-newline" ], + "count": 23, "classification": "reference-defect", "reason": "appraisal.verifier is a format: uri value with a trailing newline. Same cause as the subject entry: the reference's format checker anchors with a regex whose $ matches before a final newline, so it accepts and verifies; this implementation applies RFC 3986 with an ECMA-262 anchor and reports schema_invalid.", "pairs": [ @@ -345,6 +330,7 @@ "ids": [ "*/m32-verifier-uri-ipv6-leading-zero" ], + "count": 23, "classification": "reference-defect", "reason": "appraisal.verifier is https://[::1.2.3.04]/. RFC 3986's dec-octet excludes a leading zero, so the IPv4-mapped literal is not a URI and this implementation reports schema_invalid. The reference's format checker accepts it and verifies the record.", "pairs": [ @@ -394,6 +380,7 @@ "ids": [ "*/m41-nonce-non-ascii" ], + "count": 2, "classification": "reference-defect", "reason": "runtime.nonce is a string outside ASCII. Both implementations reject, but the reference leaves verify_record as a TypeError from hmac.compare_digest, which refuses a str outside ASCII, rather than as one of its documented outcomes. This implementation compares in constant time over UTF-16 code units and reports nonce_mismatch.", "pairs": [ @@ -408,6 +395,7 @@ "*/m46-cnf-jwk-x-non-canonical", "*/m47-cnf-jwk-x-truncated" ], + "count": 16, "classification": "stricter-here", "reason": "The record's cnf.jwk has an x that is not canonical base64url. Both implementations reject. This one refuses the encoding (cnf_key_malformed, or trusted_key_malformed when allowEmbeddedKey made that same key the trusted one); the reference decodes it leniently and reports the mismatch or the failed signature that follows.", "pairs": [ @@ -425,6 +413,7 @@ "ids": [ "jcs-10" ], + "count": 1, "classification": "language-level", "reason": "The value is 1.0e+21. In Python that is a float, and RFC 8785 gives it the form 1e+21. In JavaScript it is an integer beyond 2^53-1, where section 3.2.2 requires rejection because two distinct integers share one canonical form. A number written with an exponent cannot be told from a float in a language with one number type, so the two rules meet here. The same value inside a record would be rejected by this implementation and canonicalised by the reference.", "pairs": [ @@ -435,4 +424,4 @@ ] } ] -} \ No newline at end of file +} From 78244478d4faf58b2abea4157fcafe0ee1def8e6 Mon Sep 17 00:00:00 2001 From: chernistry Date: Mon, 21 Sep 2026 16:04:48 +0300 Subject: [PATCH 08/22] differential: judge a published vector at a valid now generate_cases.py set now from the vector's own iat whenever that was an int, so a vector whose point is an iat outside the safe-integer range made the TypeScript verifier refuse the caller's argument before it read the record. now is taken from iat only inside [0, 2^53-1]. Against trace-tests cf30e9b the corpus goes from 1660/1295/364/1 to 1660/1296/364/0; the pinned corpus is unchanged. Signed-off-by: chernistry --- trace-verify-ts/differential/generate_cases.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/trace-verify-ts/differential/generate_cases.py b/trace-verify-ts/differential/generate_cases.py index 41f353bf..7fc536f2 100644 --- a/trace-verify-ts/differential/generate_cases.py +++ b/trace-verify-ts/differential/generate_cases.py @@ -411,7 +411,14 @@ def external_vector_cases(root: pathlib.Path | None) -> list[dict[str, Any]]: continue trusted_key = document.get("trusted_key") if isinstance(document, dict) else None issued = record.get("iat") - options: dict[str, Any] = {"now": issued if isinstance(issued, int) else NOW} + # `now` is the verifier's own argument and is refused outside the safe + # range before the record is read; a vector whose point is an `iat` + # outside that range must be judged at a valid `now`. + valid_now = ( + isinstance(issued, int) and not isinstance(issued, bool) + and 0 <= issued <= 2**53 - 1 + ) + options: dict[str, Any] = {"now": issued if valid_now else NOW} if isinstance(trusted_key, dict): options["trusted_key"] = trusted_key else: From 99fce5ebf2ed456225d83d4937538798839f99b8 Mon Sep 17 00:00:00 2001 From: chernistry Date: Mon, 21 Sep 2026 16:04:48 +0300 Subject: [PATCH 09/22] differential: a text either runner cannot parse is a verdict, not a crash oracle.py parsed record_json, revocation_bundle_json and value_json outside any try, so a text json.loads refuses, or one nested past the interpreter's stack, took the process down with the output file however far it got, where run.mjs reports parse_error and exits 0. Every runner on the Python side now reports parse_error for such a text, a RecursionError inside rfc8785 classifies as canonicalization_failed, which is what the other side reports for the same input, and run.mjs gains the same parse arm on its thumbprint and chain-digest runners. Latent in the committed corpus, since all 1644 texts parse; live for the external one, which is arbitrary JSON from another repository. Signed-off-by: chernistry --- trace-verify-ts/differential/oracle.py | 49 ++++++++++++++++++++++---- trace-verify-ts/differential/run.mjs | 16 +++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/trace-verify-ts/differential/oracle.py b/trace-verify-ts/differential/oracle.py index 00554264..3a6b61ad 100644 --- a/trace-verify-ts/differential/oracle.py +++ b/trace-verify-ts/differential/oracle.py @@ -107,6 +107,11 @@ def classify(exc: BaseException) -> dict[str, Any]: "InfError", }: return {"code": "canonicalization_failed", "detail": text} + if isinstance(exc, RecursionError): + # rfc8785 recurses once per nesting level; a value nested past the + # interpreter's stack has no canonical form this runner can produce, + # which is what the other side reports for the same text. + return {"code": "canonicalization_failed", "detail": f"RecursionError: {text}"} if isinstance(exc, ValueError): for fragment, code in MESSAGE_CODES: if fragment in text: @@ -153,10 +158,35 @@ def scrub(evidence: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in evidence.items() if k not in {"error", "key"}} +def parse(text: str) -> Any: + """json.loads with the two failures a corpus can provoke turned into one exception. + + A case's text is arbitrary bytes from another repository; a text this parser + refuses, or nests deeper than the interpreter's stack, is a verdict on the + case, not a crash of the runner. `run.mjs` reports the same case as + `parse_error`, so the comparison sees a verdict on both sides. + """ + try: + return json.loads(text) + except (ValueError, RecursionError) as exc: + raise ParseError(f"{type(exc).__name__}: {exc}") from exc + + +class ParseError(Exception): + pass + + def run_verify(case: dict[str, Any]) -> dict[str, Any]: options = case["options"] - record = json.loads(case["record_json"]) + try: + record = parse(case["record_json"]) + except ParseError as exc: + return {"verdict": "parse_error", "detail": str(exc)} bundle_json = options.get("revocation_bundle_json") + try: + bundle = parse(bundle_json) if bundle_json is not None else None + except ParseError as exc: + return {"verdict": "parse_error", "detail": f"bundle: {exc}"} keywords: dict[str, Any] = { "allow_embedded_key": options.get("allow_embedded_key", False), "now": options.get("now"), @@ -164,7 +194,7 @@ def run_verify(case: dict[str, Any]) -> dict[str, Any]: "max_bundle_age_seconds": options.get("max_bundle_age_seconds", 86400), "expected_nonce": options.get("expected_nonce"), "revocation": store_from(options.get("revocation")), - "revocation_bundle": json.loads(bundle_json) if bundle_json is not None else None, + "revocation_bundle": bundle, "trusted_bundle_keys": options.get("trusted_bundle_keys"), } if "max_age_seconds" in options: @@ -188,8 +218,8 @@ def run_verify(case: dict[str, Any]) -> dict[str, Any]: def run_jcs(case: dict[str, Any]) -> dict[str, Any]: try: - value = json.loads(case["value_json"]) - except ValueError as exc: + value = parse(case["value_json"]) + except ParseError as exc: return {"verdict": "parse_error", "detail": str(exc)} try: return {"verdict": "canonical", "bytes": rfc8785.dumps(value).decode("utf-8")} @@ -199,13 +229,20 @@ def run_jcs(case: dict[str, Any]) -> dict[str, Any]: def run_thumbprint(case: dict[str, Any]) -> dict[str, Any]: try: - return {"verdict": "thumbprint", "value": jwk_thumbprint(json.loads(case["value_json"]))} + value = parse(case["value_json"]) + except ParseError as exc: + return {"verdict": "parse_error", "detail": str(exc)} + try: + return {"verdict": "thumbprint", "value": jwk_thumbprint(value)} except BaseException as exc: # noqa: BLE001 return {"verdict": "rejected", **classify(exc)} def run_chain_digest(case: dict[str, Any]) -> dict[str, Any]: - record = json.loads(case["value_json"]) + try: + record = parse(case["value_json"]) + except ParseError as exc: + return {"verdict": "parse_error", "detail": str(exc)} algorithm = case["algorithm"] try: canonical = rfc8785.dumps(record) diff --git a/trace-verify-ts/differential/run.mjs b/trace-verify-ts/differential/run.mjs index 330f54e7..24edeab5 100644 --- a/trace-verify-ts/differential/run.mjs +++ b/trace-verify-ts/differential/run.mjs @@ -118,16 +118,28 @@ async function runJcs(item) { } async function runThumbprint(item) { + let value; + try { + value = JSON.parse(item.value_json); + } catch (error) { + return { verdict: "parse_error", detail: String(error?.message) }; + } try { - return { verdict: "thumbprint", value: await jwkThumbprint(JSON.parse(item.value_json)) }; + return { verdict: "thumbprint", value: await jwkThumbprint(value) }; } catch (error) { return rejection(error); } } async function runChainDigest(item) { + let value; + try { + value = JSON.parse(item.value_json); + } catch (error) { + return { verdict: "parse_error", detail: String(error?.message) }; + } try { - return { verdict: "digest", value: await parentRecordHash(JSON.parse(item.value_json), item.algorithm) }; + return { verdict: "digest", value: await parentRecordHash(value, item.algorithm) }; } catch (error) { return rejection(error); } From 6f0596299a1995c0ce35c86195dbf0d52586c6f3 Mon Sep 17 00:00:00 2001 From: chernistry Date: Mon, 21 Sep 2026 16:04:48 +0300 Subject: [PATCH 10/22] differential: classify the accepted-profiles refusal main now raises Since #116 the reference refuses a record whose eat_profile is outside the verifier's accepted set with a new message. The oracle mapped only the previous wording, so after merging main the 25 m16-profile-unknown cases reported unclassified against the TypeScript profile_unsupported. Signed-off-by: chernistry --- trace-verify-ts/differential/oracle.py | 1 + 1 file changed, 1 insertion(+) diff --git a/trace-verify-ts/differential/oracle.py b/trace-verify-ts/differential/oracle.py index 3a6b61ad..ef527096 100644 --- a/trace-verify-ts/differential/oracle.py +++ b/trace-verify-ts/differential/oracle.py @@ -61,6 +61,7 @@ def _call_sites() -> dict[str, int]: ("record has no 'eat_profile'", "profile_missing"), ("carries the superseded v0.1 profile", "profile_superseded"), ("is not 'tag:agentrust-io.com,2026:trace-v0.2'", "profile_unsupported"), + ("is not in this verifier's accepted set", "profile_unsupported"), ("record has no 'signature' field", "signature_missing"), ("signature must be a base64url string", "signature_malformed"), ("signature is not valid base64url", "signature_malformed"), From ba0226fdc265e09295f4c7b8bb9dcdf6fa566b9b Mon Sep 17 00:00:00 2001 From: chernistry Date: Mon, 21 Sep 2026 16:04:48 +0300 Subject: [PATCH 11/22] docs(trace-verify-ts): four acceptances rather than five, and what two sentences hold The changelog entry and the description said five of the 13 divergence classes were records the reference accepts. Four are (entries 3, 8, 9, 10); the other two are rejections of a different shape; and the "patterned field" clause covered signature, where the reference rejects. The clause now names subject (U+000A, U+000D, U+2028) and appraisal.verifier (format: uri, U+000A). The schema-digest test is described by what it holds, that a build came from exactly the schema bytes it names, rather than by a rebuild case no invocation in the repository reaches. The canonicalization divergence is named as its class, magnitude 2^53 and above, of which 1.0e+21 is the corpus's instance. A README paragraph says what the differential cannot observe: a constant-time property, and a length function the schema does not exercise. Signed-off-by: chernistry --- CHANGELOG.md | 2 +- trace-verify-ts/README.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7cd61cb..c93d3515 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. - **Vendor annex: Bernstein (software-only).** `docs/platforms/bernstein.md` maps a deterministic-scheduler producer with no hardware root onto the Trust Record: the journal each claim is derived from, the delegation link and aggregate composition under the section 3.1.3 preimage, where the two recomputable commitments of section 3.1.4 sit on such a record today, the mapping of the reproducibility claim onto the producer's re-derivation check, what a verifier can recompute without the producer, and the Level 0 ceiling a committed software-only corpus has by construction. Seven emitter-minted vectors, the supplementary-plane pair among them, are pinned by commit. Informative, per GOVERNANCE; the annex track is #231. -- **A second, independent verifier: `trace-verify-ts`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trace-verify-ts/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1280 agree exactly, every published vector among them, and the remaining 364 are 13 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed. Five of the 13 are cases where the reference accepts a record this specification's own rules exclude: a signature re-spelled with non-zero unused base64 bits verifies (and section 3.1.3 digests the signature member, so two records that differ only in that spelling both verify and have different chain digests); a trailing newline or a carriage return in a patterned field passes because Python's `re` anchors `$` before a final newline and matches `.` against characters ECMA-262 excludes; `https://[::1.2.3.04]/` passes `format: uri` although RFC 3986's `dec-octet` has no leading zero; a non-ASCII `runtime.nonce` leaves `verify_record` as a `TypeError` from `hmac.compare_digest`; and a revocation bundle carrying a lone surrogate makes the reference reject the record rather than report `unverified_for_revocation` as section 3.2.3 requires. Two more are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. +- **A second, independent verifier: `trace-verify-ts`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trace-verify-ts/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1280 agree exactly, every published vector among them, and the remaining 364 are 13 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. Four of the 13 are cases where the reference accepts a record this specification's own rules exclude: a signature re-spelled with non-zero unused base64 bits verifies (and section 3.1.3 digests the signature member, so two records that differ only in that spelling both verify and have different chain digests); a `subject` ending in U+000A, U+000D or U+2028 passes its `pattern`, and an `appraisal.verifier` ending in U+000A passes `format: uri`, because Python's `re` anchors `$` before a final newline and matches `.` against characters ECMA-262 excludes; and `https://[::1.2.3.04]/` passes `format: uri` although RFC 3986's `dec-octet` has no leading zero. Two are rejections of a different shape: a non-ASCII `runtime.nonce` leaves `verify_record` as a `TypeError` from `hmac.compare_digest`, and a revocation bundle carrying a lone surrogate makes the reference reject the record rather than report `unverified_for_revocation` as section 3.2.3 requires. Two more are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and a number of magnitude 2^53 or above has an RFC 8785 form on one side and none on the other. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. - **Reproducibility claim: deterministic re-execution as evidence (section 3.1.4).** A record may now carry `reproducibility`, the claim that re-executing a named deterministic function of the run, the producer's coordination logic rather than the workload's side effects, over a pinned input closure yields a transcript whose RFC 8785 digest equals the claimed value. The claim has three explicit parts: the code identity (`code_identity`, with the `function` it exposes and an optional `code_resolver` where the digest alone does not locate the artifact), `input_closure` as `{id, digest, resolver}` entries covering everything the function reads, model calls included, and `transcript_digest`. A closure that omits anything which can change the transcript makes the claim malformed rather than weak. A verifier must hold the implementation, obtainable as a public artifact without the producer; the closure blobs, each matching the digest the producer signed, which is an integrity bar and not a provenance one, since a run's closure is run-private; and the claim itself from the signed record. If re-execution needs state only the producer's environment can supply, it is not reproduction, and a read beyond the closure fixes the outcome at `not-attempted` with the read named, whatever the re-run would otherwise have produced. The re-execution result is an appraisal under `appraisal.method: "re-execution"` with a tri-state `outcome`: `reproduced`, `diverged` (recording the verifier's observed digest) or `not-attempted` (carrying the reason), and `not-attempted` MUST NOT be reported as either of the other two. `appraisal.status` stays the closed EAR set of #190, and the section says why `not-attempted` is not `status: none`. Non-breaking: a new optional member, an optional `appraisal.method`, and `appraisal.re_execution` present exactly when the method is `re-execution`; no change to required fields or wire format. The schema, the reference model and `docs/schema.md` track the text in a follow-up, and vectors from a producer that makes the claim follow with them. Proposed and written by @chernistry in #249; carried by @imran-siddique as sponsoring maintainer. The vendor annex in #231 is sequenced after it. diff --git a/trace-verify-ts/README.md b/trace-verify-ts/README.md index 94b217e9..809b6120 100644 --- a/trace-verify-ts/README.md +++ b/trace-verify-ts/README.md @@ -74,7 +74,8 @@ npm test `npm run build` compiles the schemas ahead of time with ajv into `src/generated/validators.js`, so nothing is evaluated or fetched at run time, and records the SHA-256 of each schema file it read. A test compares those digests against `../schema`, -so a schema edit that is not rebuilt fails rather than passing quietly. +so the validators in a build are known to come from exactly those bytes: a build made +against another schema directory (`TRACE_SCHEMA_DIR`) fails that test. ## The differential harness @@ -118,3 +119,8 @@ Latest run, against agentrust-trace 0.10.0: Every published vector agrees. The 364 remaining cases are 13 documented divergence classes in the adversarial matrix, each one appearing once per verifier configuration. +The differential compares verdicts, so it is silent on anything that does not change a +verdict. Whether `timingSafeEqual` is constant-time is not observable in a verdict at all, +and `codePointLength` cannot disagree with `value.length` on anything the current schema +constrains (no `maxLength`; every `minLength` is 1). Those rest on the unit tests and on +reading the code, not on the 1644. From 23b4e588d6bd326eb82791c5df9d73aab630baac Mon Sep 17 00:00:00 2001 From: chernistry Date: Tue, 22 Sep 2026 09:14:19 +0300 Subject: [PATCH 12/22] refactor(trust-record-verify): rename the package to @agentrust-io/trust-record-verify The package, its directory, the workflow and its path filters, the README and the changelog entry take the new name. The corpus seed in differential/generate_cases.py keeps its original spelling, as the named constant CORPUS_SEED_DOMAIN: every key in the 1644-case corpus derives from it, so changing it reissues the corpus and moves outcomes that known-divergences.json pins by case and by count. Differential against agentrust-trace 0.10.0 after the rename: 1644 cases, 1280 identical, 364 known, 0 unexpected; ledger 13 entries, 71 pairs, 0 unreached; 12 published vectors, manifest sha256:869550bf03073ba3. Unit tests 74/74 on Node 25. Signed-off-by: chernistry --- ...-verify-ts.yml => trust-record-verify.yml} | 28 +++++++++---------- CHANGELOG.md | 2 +- .../.gitignore | 0 .../README.md | 4 +-- .../differential/compare.py | 0 .../differential/generate_cases.py | 11 +++++++- .../differential/known-divergences.json | 0 .../differential/oracle.py | 0 .../differential/run.mjs | 0 .../package-lock.json | 4 +-- .../package.json | 2 +- .../scripts/generate-validators.mjs | 0 .../src/base64url.ts | 0 .../src/crypto.ts | 0 .../src/delegation.ts | 0 .../src/errors.ts | 0 .../src/index.ts | 0 .../src/jcs.ts | 0 .../src/jwk.ts | 0 .../src/revocation.ts | 0 .../src/schema.ts | 0 .../src/text.ts | 0 .../src/uri.ts | 0 .../src/verify.ts | 0 .../tests/hygiene.test.mjs | 0 .../tests/units.test.mjs | 0 .../tests/uri.test.mjs | 0 .../tests/vectors.test.mjs | 0 .../tsconfig.json | 0 29 files changed, 30 insertions(+), 21 deletions(-) rename .github/workflows/{trace-verify-ts.yml => trust-record-verify.yml} (82%) rename {trace-verify-ts => trust-record-verify}/.gitignore (100%) rename {trace-verify-ts => trust-record-verify}/README.md (98%) rename {trace-verify-ts => trust-record-verify}/differential/compare.py (100%) rename {trace-verify-ts => trust-record-verify}/differential/generate_cases.py (97%) rename {trace-verify-ts => trust-record-verify}/differential/known-divergences.json (100%) rename {trace-verify-ts => trust-record-verify}/differential/oracle.py (100%) rename {trace-verify-ts => trust-record-verify}/differential/run.mjs (100%) rename {trace-verify-ts => trust-record-verify}/package-lock.json (99%) rename {trace-verify-ts => trust-record-verify}/package.json (94%) rename {trace-verify-ts => trust-record-verify}/scripts/generate-validators.mjs (100%) rename {trace-verify-ts => trust-record-verify}/src/base64url.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/crypto.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/delegation.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/errors.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/index.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/jcs.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/jwk.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/revocation.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/schema.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/text.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/uri.ts (100%) rename {trace-verify-ts => trust-record-verify}/src/verify.ts (100%) rename {trace-verify-ts => trust-record-verify}/tests/hygiene.test.mjs (100%) rename {trace-verify-ts => trust-record-verify}/tests/units.test.mjs (100%) rename {trace-verify-ts => trust-record-verify}/tests/uri.test.mjs (100%) rename {trace-verify-ts => trust-record-verify}/tests/vectors.test.mjs (100%) rename {trace-verify-ts => trust-record-verify}/tsconfig.json (100%) diff --git a/.github/workflows/trace-verify-ts.yml b/.github/workflows/trust-record-verify.yml similarity index 82% rename from .github/workflows/trace-verify-ts.yml rename to .github/workflows/trust-record-verify.yml index c34ff96f..c800173e 100644 --- a/.github/workflows/trace-verify-ts.yml +++ b/.github/workflows/trust-record-verify.yml @@ -1,21 +1,21 @@ -name: trace-verify-ts +name: trust-record-verify on: push: branches: ["main", "feat/**", "fix/**"] paths: - - "trace-verify-ts/**" + - "trust-record-verify/**" - "schema/**" - "examples/**" - "src/agentrust_trace/**" - - ".github/workflows/trace-verify-ts.yml" + - ".github/workflows/trust-record-verify.yml" pull_request: paths: - - "trace-verify-ts/**" + - "trust-record-verify/**" - "schema/**" - "examples/**" - "src/agentrust_trace/**" - - ".github/workflows/trace-verify-ts.yml" + - ".github/workflows/trust-record-verify.yml" env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -43,11 +43,11 @@ jobs: - name: Install run: npm ci - working-directory: trace-verify-ts + working-directory: trust-record-verify - name: Build and test run: npm test - working-directory: trace-verify-ts + working-directory: trust-record-verify differential: name: differential against the Python implementation @@ -83,24 +83,24 @@ jobs: run: | pip install --require-hashes -r requirements/dev.txt pip install --no-deps -e . - npm ci --prefix trace-verify-ts + npm ci --prefix trust-record-verify - name: Lint the harness - run: ruff check trace-verify-ts/differential + run: ruff check trust-record-verify/differential - name: Build the verifier - run: npm run build --prefix trace-verify-ts + run: npm run build --prefix trust-record-verify - name: Generate the corpus run: python differential/generate_cases.py --external "$GITHUB_WORKSPACE/.vectors/tests/vectors" - working-directory: trace-verify-ts + working-directory: trust-record-verify - name: Run both implementations and compare run: | python differential/oracle.py node differential/run.mjs python differential/compare.py --expect-external 12 --report differential/build/report.json - working-directory: trace-verify-ts + working-directory: trust-record-verify - name: Upload the report if: always() @@ -108,6 +108,6 @@ jobs: with: name: differential-report path: | - trace-verify-ts/differential/build/report.json - trace-verify-ts/differential/build/manifest.json + trust-record-verify/differential/build/report.json + trust-record-verify/differential/build/manifest.json if-no-files-found: ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index c93d3515..78a1af38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. - **Vendor annex: Bernstein (software-only).** `docs/platforms/bernstein.md` maps a deterministic-scheduler producer with no hardware root onto the Trust Record: the journal each claim is derived from, the delegation link and aggregate composition under the section 3.1.3 preimage, where the two recomputable commitments of section 3.1.4 sit on such a record today, the mapping of the reproducibility claim onto the producer's re-derivation check, what a verifier can recompute without the producer, and the Level 0 ceiling a committed software-only corpus has by construction. Seven emitter-minted vectors, the supplementary-plane pair among them, are pinned by commit. Informative, per GOVERNANCE; the annex track is #231. -- **A second, independent verifier: `trace-verify-ts`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trace-verify-ts/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1280 agree exactly, every published vector among them, and the remaining 364 are 13 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. Four of the 13 are cases where the reference accepts a record this specification's own rules exclude: a signature re-spelled with non-zero unused base64 bits verifies (and section 3.1.3 digests the signature member, so two records that differ only in that spelling both verify and have different chain digests); a `subject` ending in U+000A, U+000D or U+2028 passes its `pattern`, and an `appraisal.verifier` ending in U+000A passes `format: uri`, because Python's `re` anchors `$` before a final newline and matches `.` against characters ECMA-262 excludes; and `https://[::1.2.3.04]/` passes `format: uri` although RFC 3986's `dec-octet` has no leading zero. Two are rejections of a different shape: a non-ASCII `runtime.nonce` leaves `verify_record` as a `TypeError` from `hmac.compare_digest`, and a revocation bundle carrying a lone surrogate makes the reference reject the record rather than report `unverified_for_revocation` as section 3.2.3 requires. Two more are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and a number of magnitude 2^53 or above has an RFC 8785 form on one side and none on the other. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. +- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1280 agree exactly, every published vector among them, and the remaining 364 are 13 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. Four of the 13 are cases where the reference accepts a record this specification's own rules exclude: a signature re-spelled with non-zero unused base64 bits verifies (and section 3.1.3 digests the signature member, so two records that differ only in that spelling both verify and have different chain digests); a `subject` ending in U+000A, U+000D or U+2028 passes its `pattern`, and an `appraisal.verifier` ending in U+000A passes `format: uri`, because Python's `re` anchors `$` before a final newline and matches `.` against characters ECMA-262 excludes; and `https://[::1.2.3.04]/` passes `format: uri` although RFC 3986's `dec-octet` has no leading zero. Two are rejections of a different shape: a non-ASCII `runtime.nonce` leaves `verify_record` as a `TypeError` from `hmac.compare_digest`, and a revocation bundle carrying a lone surrogate makes the reference reject the record rather than report `unverified_for_revocation` as section 3.2.3 requires. Two more are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and a number of magnitude 2^53 or above has an RFC 8785 form on one side and none on the other. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. - **Reproducibility claim: deterministic re-execution as evidence (section 3.1.4).** A record may now carry `reproducibility`, the claim that re-executing a named deterministic function of the run, the producer's coordination logic rather than the workload's side effects, over a pinned input closure yields a transcript whose RFC 8785 digest equals the claimed value. The claim has three explicit parts: the code identity (`code_identity`, with the `function` it exposes and an optional `code_resolver` where the digest alone does not locate the artifact), `input_closure` as `{id, digest, resolver}` entries covering everything the function reads, model calls included, and `transcript_digest`. A closure that omits anything which can change the transcript makes the claim malformed rather than weak. A verifier must hold the implementation, obtainable as a public artifact without the producer; the closure blobs, each matching the digest the producer signed, which is an integrity bar and not a provenance one, since a run's closure is run-private; and the claim itself from the signed record. If re-execution needs state only the producer's environment can supply, it is not reproduction, and a read beyond the closure fixes the outcome at `not-attempted` with the read named, whatever the re-run would otherwise have produced. The re-execution result is an appraisal under `appraisal.method: "re-execution"` with a tri-state `outcome`: `reproduced`, `diverged` (recording the verifier's observed digest) or `not-attempted` (carrying the reason), and `not-attempted` MUST NOT be reported as either of the other two. `appraisal.status` stays the closed EAR set of #190, and the section says why `not-attempted` is not `status: none`. Non-breaking: a new optional member, an optional `appraisal.method`, and `appraisal.re_execution` present exactly when the method is `re-execution`; no change to required fields or wire format. The schema, the reference model and `docs/schema.md` track the text in a follow-up, and vectors from a producer that makes the claim follow with them. Proposed and written by @chernistry in #249; carried by @imran-siddique as sponsoring maintainer. The vendor annex in #231 is sequenced after it. diff --git a/trace-verify-ts/.gitignore b/trust-record-verify/.gitignore similarity index 100% rename from trace-verify-ts/.gitignore rename to trust-record-verify/.gitignore diff --git a/trace-verify-ts/README.md b/trust-record-verify/README.md similarity index 98% rename from trace-verify-ts/README.md rename to trust-record-verify/README.md index 809b6120..96d2d0ae 100644 --- a/trace-verify-ts/README.md +++ b/trust-record-verify/README.md @@ -1,4 +1,4 @@ -# trace-verify-ts +# @agentrust-io/trust-record-verify A TypeScript verifier for TRACE v0.2 Trust Records. @@ -15,7 +15,7 @@ a record without a Python runtime. ## Using it ```ts -import { verifyRecord } from "trace-verify-ts"; +import { verifyRecord } from "@agentrust-io/trust-record-verify"; const result = await verifyRecord(record, { trustedKey: issuerJwk, // an Ed25519 public JWK the caller already trusts diff --git a/trace-verify-ts/differential/compare.py b/trust-record-verify/differential/compare.py similarity index 100% rename from trace-verify-ts/differential/compare.py rename to trust-record-verify/differential/compare.py diff --git a/trace-verify-ts/differential/generate_cases.py b/trust-record-verify/differential/generate_cases.py similarity index 97% rename from trace-verify-ts/differential/generate_cases.py rename to trust-record-verify/differential/generate_cases.py index 7fc536f2..c8bd6203 100644 --- a/trace-verify-ts/differential/generate_cases.py +++ b/trust-record-verify/differential/generate_cases.py @@ -34,8 +34,17 @@ """The moment the repository's own vectors are written against.""" +CORPUS_SEED_DOMAIN = "trace-verify-ts/differential" +"""Frozen: the label the corpus keys are derived from, not the package name. + +It keeps its original spelling deliberately. Changing it reissues every key in +the corpus, which moves the outcomes the ledger in known-divergences.json pins +by case and count, so the rename of the package leaves it alone. +""" + + def seeded_key(label: str) -> Ed25519PrivateKey: - seed = hashlib.sha256(f"trace-verify-ts/differential/{label}".encode()).digest() + seed = hashlib.sha256(f"{CORPUS_SEED_DOMAIN}/{label}".encode()).digest() return Ed25519PrivateKey.from_private_bytes(seed) diff --git a/trace-verify-ts/differential/known-divergences.json b/trust-record-verify/differential/known-divergences.json similarity index 100% rename from trace-verify-ts/differential/known-divergences.json rename to trust-record-verify/differential/known-divergences.json diff --git a/trace-verify-ts/differential/oracle.py b/trust-record-verify/differential/oracle.py similarity index 100% rename from trace-verify-ts/differential/oracle.py rename to trust-record-verify/differential/oracle.py diff --git a/trace-verify-ts/differential/run.mjs b/trust-record-verify/differential/run.mjs similarity index 100% rename from trace-verify-ts/differential/run.mjs rename to trust-record-verify/differential/run.mjs diff --git a/trace-verify-ts/package-lock.json b/trust-record-verify/package-lock.json similarity index 99% rename from trace-verify-ts/package-lock.json rename to trust-record-verify/package-lock.json index 0b6ba791..5c195ce6 100644 --- a/trace-verify-ts/package-lock.json +++ b/trust-record-verify/package-lock.json @@ -1,11 +1,11 @@ { - "name": "trace-verify-ts", + "name": "@agentrust-io/trust-record-verify", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "trace-verify-ts", + "name": "@agentrust-io/trust-record-verify", "version": "0.1.0", "devDependencies": { "ajv": "8.20.0", diff --git a/trace-verify-ts/package.json b/trust-record-verify/package.json similarity index 94% rename from trace-verify-ts/package.json rename to trust-record-verify/package.json index 29bcd3b5..dde34b5d 100644 --- a/trace-verify-ts/package.json +++ b/trust-record-verify/package.json @@ -1,5 +1,5 @@ { - "name": "trace-verify-ts", + "name": "@agentrust-io/trust-record-verify", "version": "0.1.0", "private": true, "description": "TypeScript verifier for TRACE v0.2 Trust Records: zero network, WebCrypto only.", diff --git a/trace-verify-ts/scripts/generate-validators.mjs b/trust-record-verify/scripts/generate-validators.mjs similarity index 100% rename from trace-verify-ts/scripts/generate-validators.mjs rename to trust-record-verify/scripts/generate-validators.mjs diff --git a/trace-verify-ts/src/base64url.ts b/trust-record-verify/src/base64url.ts similarity index 100% rename from trace-verify-ts/src/base64url.ts rename to trust-record-verify/src/base64url.ts diff --git a/trace-verify-ts/src/crypto.ts b/trust-record-verify/src/crypto.ts similarity index 100% rename from trace-verify-ts/src/crypto.ts rename to trust-record-verify/src/crypto.ts diff --git a/trace-verify-ts/src/delegation.ts b/trust-record-verify/src/delegation.ts similarity index 100% rename from trace-verify-ts/src/delegation.ts rename to trust-record-verify/src/delegation.ts diff --git a/trace-verify-ts/src/errors.ts b/trust-record-verify/src/errors.ts similarity index 100% rename from trace-verify-ts/src/errors.ts rename to trust-record-verify/src/errors.ts diff --git a/trace-verify-ts/src/index.ts b/trust-record-verify/src/index.ts similarity index 100% rename from trace-verify-ts/src/index.ts rename to trust-record-verify/src/index.ts diff --git a/trace-verify-ts/src/jcs.ts b/trust-record-verify/src/jcs.ts similarity index 100% rename from trace-verify-ts/src/jcs.ts rename to trust-record-verify/src/jcs.ts diff --git a/trace-verify-ts/src/jwk.ts b/trust-record-verify/src/jwk.ts similarity index 100% rename from trace-verify-ts/src/jwk.ts rename to trust-record-verify/src/jwk.ts diff --git a/trace-verify-ts/src/revocation.ts b/trust-record-verify/src/revocation.ts similarity index 100% rename from trace-verify-ts/src/revocation.ts rename to trust-record-verify/src/revocation.ts diff --git a/trace-verify-ts/src/schema.ts b/trust-record-verify/src/schema.ts similarity index 100% rename from trace-verify-ts/src/schema.ts rename to trust-record-verify/src/schema.ts diff --git a/trace-verify-ts/src/text.ts b/trust-record-verify/src/text.ts similarity index 100% rename from trace-verify-ts/src/text.ts rename to trust-record-verify/src/text.ts diff --git a/trace-verify-ts/src/uri.ts b/trust-record-verify/src/uri.ts similarity index 100% rename from trace-verify-ts/src/uri.ts rename to trust-record-verify/src/uri.ts diff --git a/trace-verify-ts/src/verify.ts b/trust-record-verify/src/verify.ts similarity index 100% rename from trace-verify-ts/src/verify.ts rename to trust-record-verify/src/verify.ts diff --git a/trace-verify-ts/tests/hygiene.test.mjs b/trust-record-verify/tests/hygiene.test.mjs similarity index 100% rename from trace-verify-ts/tests/hygiene.test.mjs rename to trust-record-verify/tests/hygiene.test.mjs diff --git a/trace-verify-ts/tests/units.test.mjs b/trust-record-verify/tests/units.test.mjs similarity index 100% rename from trace-verify-ts/tests/units.test.mjs rename to trust-record-verify/tests/units.test.mjs diff --git a/trace-verify-ts/tests/uri.test.mjs b/trust-record-verify/tests/uri.test.mjs similarity index 100% rename from trace-verify-ts/tests/uri.test.mjs rename to trust-record-verify/tests/uri.test.mjs diff --git a/trace-verify-ts/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs similarity index 100% rename from trace-verify-ts/tests/vectors.test.mjs rename to trust-record-verify/tests/vectors.test.mjs diff --git a/trace-verify-ts/tsconfig.json b/trust-record-verify/tsconfig.json similarity index 100% rename from trace-verify-ts/tsconfig.json rename to trust-record-verify/tsconfig.json From 7e94708195d67cafbb49de00a4196c2207869497 Mon Sep 17 00:00:00 2001 From: chernistry Date: Tue, 22 Sep 2026 09:20:02 +0300 Subject: [PATCH 13/22] test(differential): retire the five divergences the reference no longer has #383, #386, #387 and #388 fixed the four defects this harness reported, so the cases that recorded them stopped diverging and the ledger failed closed on five entries no case reached: the lone-surrogate revocation bundle (#382), `subject` and `appraisal.verifier` under ECMA-262 pattern semantics (#379), the IPv4-in-IPv6 literal with a leading zero (#380), and the non-ASCII nonce (#381). They leave the ledger rather than stay as claims nothing checks. Four counts move with them. The `c23-bundle-lone-surrogate` context used to diverge on every mutation and matched the first entry; now that it verifies like any other context, its cases fall through to the mutation families: m03 23 to 24, m04 23 to 24, m20/m21 30 to 32, m46/m47 16 to 17. The ledger's `reference` and the README's table no longer say 0.10.0 without qualification, because the behaviour under test is the tree's and the four fixes are not in that release. 1644 cases, 1412 identical, 232 known, 0 unexpected; ledger 8 entries, 33 pairs, 0 unreached; 12 published vectors, manifest sha256:869550bf03073ba3. Signed-off-by: chernistry --- trust-record-verify/README.md | 17 +- .../differential/known-divergences.json | 214 +----------------- 2 files changed, 16 insertions(+), 215 deletions(-) diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md index 96d2d0ae..2ffe12cd 100644 --- a/trust-record-verify/README.md +++ b/trust-record-verify/README.md @@ -104,20 +104,25 @@ entry declares how many cases land on it, and an entry, a pair or a count the ru bear out fails it, so a divergence that stops happening on either side has to leave the ledger rather than stay as a claim nothing checks. -Latest run, against agentrust-trace 0.10.0: +Latest run, against this repository's implementation at the commit under test (its version string reads 0.10.0; #383, #386, #387 and #388 landed after that release): | Group | Cases | Identical | |---|---:|---:| -| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1053 | +| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1185 | | RFC 8785 canonicalization corpus | 34 | 33 | | RFC 7638 thumbprints | 14 | 14 | | Chain digests over the delegation corpus | 134 | 134 | | This repository's conformance vectors | 34 | 34 | | Published conformance vectors | 12 | 12 | -| **Total** | **1644** | **1280** | - -Every published vector agrees. The 364 remaining cases are 13 documented divergence classes -in the adversarial matrix, each one appearing once per verifier configuration. +| **Total** | **1644** | **1412** | + +Every published vector agrees. The 232 remaining cases are 8 documented divergence classes +in the adversarial matrix, each one appearing once per verifier configuration. Five classes +left the ledger when the reference stopped diverging: the lone-surrogate revocation bundle +(#382, fixed by #386), `subject` and `appraisal.verifier` under ECMA-262 pattern semantics +(#379, fixed by #388), the IPv4-in-IPv6 literal with a leading zero (#380, fixed by #387), +and the non-ASCII nonce (#381, fixed by #383). The harness reported each of them as an +entry no case reached, which is how a fixed divergence is meant to leave. The differential compares verdicts, so it is silent on anything that does not change a verdict. Whether `timingSafeEqual` is constant-time is not observable in a verdict at all, diff --git a/trust-record-verify/differential/known-divergences.json b/trust-record-verify/differential/known-divergences.json index a53b262d..8974b4d7 100644 --- a/trust-record-verify/differential/known-divergences.json +++ b/trust-record-verify/differential/known-divergences.json @@ -1,45 +1,7 @@ { "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. Entries are tried in order and a case lands on the first that matches; count is how many cases land on the entry in the committed corpus, and compare.py fails when an entry, a pair or a count is not borne out by the run, so a divergence that stops happening has to leave the ledger. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", - "reference": "agentrust-trace 0.10.0", + "reference": "agentrust-trace, this repository's implementation at the commit under test. The package version string reads 0.10.0, which is also the last release, but the behaviour recorded here is the tree's: #383, #386, #387 and #388 landed after 0.10.0 and closed five entries that were in this ledger.", "divergences": [ - { - "ids": [ - "c23-bundle-lone-surrogate/*" - ], - "count": 20, - "classification": "reference-defect", - "reason": "The revocation bundle carries a lone surrogate in log_id. Section 3.2.3 says a bundle a verifier cannot ground an answer on is reported as unverified for revocation, and the record's own verification continues. This implementation reports outcome unverified_for_revocation with cause bundle_malformed. The reference canonicalises the bundle to compute its digest before that point, and the canonicalisation error leaves verify_record as an exception, so the record is rejected for a defect in a third party's bundle. Every pair in this entry is that one difference showing through whichever record mutation the case also carries. A mutation that fails before the bundle is read, a malformed or missing signature, is that mutation's entry, not this one.", - "pairs": [ - [ - "canonicalization_failed", - "cnf_key_malformed" - ], - [ - "canonicalization_failed", - "cnf_key_mismatch" - ], - [ - "canonicalization_failed", - "cnf_key_unsupported" - ], - [ - "canonicalization_failed", - "schema_invalid" - ], - [ - "canonicalization_failed", - "signature_invalid" - ], - [ - "canonicalization_failed", - "signature_malformed" - ], - [ - "canonicalization_failed", - "verified" - ] - ] - }, { "ids": [ "c18-key-x-non-canonical/*" @@ -70,7 +32,7 @@ "ids": [ "*/m03-signature-non-canonical-pad-bits" ], - "count": 23, + "count": 24, "classification": "reference-defect", "reason": "The signature is re-spelled with non-zero unused trailing bits: a different string that decodes to the same 64 bytes. The reference pads and decodes leniently, so it verifies the record. This implementation refuses the spelling as signature_malformed. The difference matters beyond tidiness: section 3.1.3 digests the parent record with its signature member present, so two records that differ only in this spelling verify identically and have different chain digests.", "pairs": [ @@ -120,7 +82,7 @@ "ids": [ "*/m04-signature-truncated" ], - "count": 23, + "count": 24, "classification": "stricter-here", "reason": "The signature is truncated to a length that is still a valid base64url length but whose trailing bits are not zero. Both implementations reject. The reference decodes it and reports signature_invalid; this one reports signature_malformed before verifying.", "pairs": [ @@ -202,7 +164,7 @@ "*/m20-iat-float-form", "*/m21-iat-exponent-form" ], - "count": 30, + "count": 32, "classification": "language-level", "reason": "iat is written 1785000000.0 or 1.785e9. JSON has one number type and JavaScript has one number type, so after JSON.parse the value is indistinguishable from the integer, its RFC 8785 form is the same bytes, and the record verifies here. Python's json module preserves the distinction as float, and the reference requires an int. A verifier that holds the record text can reject the spelling before parsing; one that is handed a parsed value cannot. Worth stating in the specification rather than leaving to each language.", "pairs": [ @@ -224,178 +186,12 @@ ] ] }, - { - "ids": [ - "*/m27-subject-trailing-newline", - "*/m28-subject-carriage-return", - "*/m29-subject-line-separator" - ], - "count": 69, - "classification": "reference-defect", - "reason": "subject carries a trailing U+000A, U+000D or U+2028 inside a value that must match ^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$. Under ECMA-262, the dialect JSON Schema 2020-12 specifies for pattern, . excludes all three and $ does not match before a final newline, so this implementation reports schema_invalid. Python's re matches . against U+000D and U+2028 and $ before a trailing newline, so the reference accepts the record and verifies it.", - "pairs": [ - [ - "cnf_key_mismatch", - "schema_invalid" - ], - [ - "key_revoked", - "schema_invalid" - ], - [ - "nonce_mismatch", - "schema_invalid" - ], - [ - "record_in_future", - "schema_invalid" - ], - [ - "record_stale", - "schema_invalid" - ], - [ - "revocation_unavailable", - "schema_invalid" - ], - [ - "trusted_key_malformed", - "schema_invalid" - ], - [ - "trusted_key_missing", - "schema_invalid" - ], - [ - "trusted_key_unsupported", - "schema_invalid" - ], - [ - "verified", - "schema_invalid" - ] - ] - }, - { - "ids": [ - "*/m31-verifier-uri-trailing-newline" - ], - "count": 23, - "classification": "reference-defect", - "reason": "appraisal.verifier is a format: uri value with a trailing newline. Same cause as the subject entry: the reference's format checker anchors with a regex whose $ matches before a final newline, so it accepts and verifies; this implementation applies RFC 3986 with an ECMA-262 anchor and reports schema_invalid.", - "pairs": [ - [ - "cnf_key_mismatch", - "schema_invalid" - ], - [ - "key_revoked", - "schema_invalid" - ], - [ - "nonce_mismatch", - "schema_invalid" - ], - [ - "record_in_future", - "schema_invalid" - ], - [ - "record_stale", - "schema_invalid" - ], - [ - "revocation_unavailable", - "schema_invalid" - ], - [ - "trusted_key_malformed", - "schema_invalid" - ], - [ - "trusted_key_missing", - "schema_invalid" - ], - [ - "trusted_key_unsupported", - "schema_invalid" - ], - [ - "verified", - "schema_invalid" - ] - ] - }, - { - "ids": [ - "*/m32-verifier-uri-ipv6-leading-zero" - ], - "count": 23, - "classification": "reference-defect", - "reason": "appraisal.verifier is https://[::1.2.3.04]/. RFC 3986's dec-octet excludes a leading zero, so the IPv4-mapped literal is not a URI and this implementation reports schema_invalid. The reference's format checker accepts it and verifies the record.", - "pairs": [ - [ - "cnf_key_mismatch", - "schema_invalid" - ], - [ - "key_revoked", - "schema_invalid" - ], - [ - "nonce_mismatch", - "schema_invalid" - ], - [ - "record_in_future", - "schema_invalid" - ], - [ - "record_stale", - "schema_invalid" - ], - [ - "revocation_unavailable", - "schema_invalid" - ], - [ - "trusted_key_malformed", - "schema_invalid" - ], - [ - "trusted_key_missing", - "schema_invalid" - ], - [ - "trusted_key_unsupported", - "schema_invalid" - ], - [ - "verified", - "schema_invalid" - ] - ] - }, - { - "ids": [ - "*/m41-nonce-non-ascii" - ], - "count": 2, - "classification": "reference-defect", - "reason": "runtime.nonce is a string outside ASCII. Both implementations reject, but the reference leaves verify_record as a TypeError from hmac.compare_digest, which refuses a str outside ASCII, rather than as one of its documented outcomes. This implementation compares in constant time over UTF-16 code units and reports nonce_mismatch.", - "pairs": [ - [ - "nonce_compare_unsupported", - "nonce_mismatch" - ] - ] - }, { "ids": [ "*/m46-cnf-jwk-x-non-canonical", "*/m47-cnf-jwk-x-truncated" ], - "count": 16, + "count": 17, "classification": "stricter-here", "reason": "The record's cnf.jwk has an x that is not canonical base64url. Both implementations reject. This one refuses the encoding (cnf_key_malformed, or trusted_key_malformed when allowEmbeddedKey made that same key the trusted one); the reference decodes it leniently and reports the mismatch or the failed signature that follows.", "pairs": [ From bc7f090e7193b64fc669afb67ea442acb5fd0fad Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:52:44 +0300 Subject: [PATCH 14/22] fix(trust-record-verify): tighten three input guards encodeBase64url takes a Uint8Array and nothing else that views a buffer: a Uint16Array iterates 16-bit values, which the byte loop encoded as the wrong bytes, and a DataView is not iterable, which reached a native TypeError instead of the package's own error. canonicalJson refuses an object with a symbol-keyed member instead of dropping it: Object.keys never listed the symbol, so a value the module documents as unsupported canonicalized to a form missing a member. verifyDelegationLink holds the same plain-object contract for its options as verifyRecord; an array, a Date or a class instance was accepted before. A test for each, with the same-shaped value that is accepted beside it. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- trust-record-verify/src/base64url.ts | 5 +++- trust-record-verify/src/delegation.ts | 4 +-- trust-record-verify/src/jcs.ts | 6 ++++- trust-record-verify/tests/units.test.mjs | 34 ++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/trust-record-verify/src/base64url.ts b/trust-record-verify/src/base64url.ts index 5d600cc9..93aa1050 100644 --- a/trust-record-verify/src/base64url.ts +++ b/trust-record-verify/src/base64url.ts @@ -71,7 +71,10 @@ export function decodeBase64url(value: unknown): Uint8Array | null { /** Encode *bytes* as unpadded base64url. */ export function encodeBase64url(bytes: Uint8Array): string { - if (!ArrayBuffer.isView(bytes)) { + // A Uint8Array and nothing else that views a buffer: a Uint16Array iterates + // 16-bit values, which the byte loop below would encode as the wrong bytes, + // and a DataView does not iterate at all. Node's Buffer is a Uint8Array. + if (!(bytes instanceof Uint8Array)) { fail("invalid_argument", "encodeBase64url takes a Uint8Array"); } let out = ""; diff --git a/trust-record-verify/src/delegation.ts b/trust-record-verify/src/delegation.ts index afeac330..92a58622 100644 --- a/trust-record-verify/src/delegation.ts +++ b/trust-record-verify/src/delegation.ts @@ -91,8 +91,8 @@ export async function verifyDelegationLink( parent: unknown, options: DelegationLinkOptions = {}, ): Promise { - if (options === null || typeof options !== "object") { - fail("invalid_argument", "verifyDelegationLink takes an options object"); + if (!isPlainObject(options)) { + fail("invalid_argument", "verifyDelegationLink takes a plain options object"); } const configured = (options as DelegationLinkOptions).supportedDigestAlgorithms; if (configured !== undefined && (!Array.isArray(configured) || configured.some((a) => typeof a !== "string"))) { diff --git a/trust-record-verify/src/jcs.ts b/trust-record-verify/src/jcs.ts index 8723cb6d..226dc1a4 100644 --- a/trust-record-verify/src/jcs.ts +++ b/trust-record-verify/src/jcs.ts @@ -21,7 +21,8 @@ * - a string or member name holding an unpaired surrogate, which has no UTF-8 * form (RFC 8785 section 3.1 requires I-JSON input, RFC 7493 section 2.1); * - anything that is not JSON data: `undefined`, functions, symbols, bigints, - * class instances, array holes, and cyclic structures. + * class instances, array holes, cyclic structures, and an object with a + * symbol-keyed member, which `Object.keys` would silently leave out. */ import { fail } from "./errors.js"; @@ -125,6 +126,9 @@ function serialize( } parts.push("]"); } else if (isPlainObject(node)) { + if (Object.getOwnPropertySymbols(node).length > 0) { + fail("canonicalization_failed", `${describe(path)} has a symbol-keyed member, which has no JSON form`); + } const names = Object.keys(node).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); parts.push("{"); names.forEach((name, index) => { diff --git a/trust-record-verify/tests/units.test.mjs b/trust-record-verify/tests/units.test.mjs index 99ac8a35..d65697e8 100644 --- a/trust-record-verify/tests/units.test.mjs +++ b/trust-record-verify/tests/units.test.mjs @@ -9,8 +9,10 @@ import { decodeBase64url, encodeBase64url, jwkThumbprint, + parentRecordHash, TRACE_PROFILE_V0_2, TraceVerificationError, + verifyDelegationLink, verifyRecord, } from "../dist/index.js"; @@ -58,6 +60,38 @@ test("canonicalJson refuses what has no canonical form", () => { assert.throws(() => canonicalJson(cycle), TraceVerificationError); }); +test("canonicalJson refuses a symbol-keyed member rather than dropping it", async () => { + const sym = Symbol("s"); + const refused = (error) => + error instanceof TraceVerificationError && error.code === "canonicalization_failed"; + for (const value of [{ a: 1, [sym]: 2 }, { a: { b: 1, [sym]: 2 } }, [{ [sym]: 1 }]]) { + assert.throws(() => canonicalJson(value), refused); + } + await rejects(parentRecordHash({ a: 1, [sym]: 2 }), "canonicalization_failed"); + // The same object without the symbol has a canonical form, so the refusal is + // the symbol's and not the shape's. + assert.equal(canonicalJson({ a: { b: 1 } }), '{"a":{"b":1}}'); +}); + +test("encodeBase64url takes a Uint8Array and nothing else that views a buffer", () => { + const refused = (error) => error instanceof TraceVerificationError && error.code === "invalid_argument"; + assert.equal(encodeBase64url(new Uint8Array([1, 2, 3])), "AQID"); + assert.equal(encodeBase64url(Buffer.from([1, 2, 3])), "AQID"); + // A Uint16Array holds 16-bit values: iterated as bytes it would encode + // [1, 2] as "AQI" and lose two of its four bytes. A DataView is a view but + // not iterable, so the loop would reach a native TypeError. + for (const bad of [new Uint16Array([1, 2]), new DataView(new ArrayBuffer(4)), new ArrayBuffer(3), [1, 2, 3], "AQID"]) { + assert.throws(() => encodeBase64url(bad), refused); + } +}); + +test("verifyDelegationLink takes a plain options object, as verifyRecord does", async () => { + class Options {} + for (const options of [[], new Date(0), new Options(), Object.create({ supportedDigestAlgorithms: [] }), null, "sha256", 1]) { + await rejects(verifyDelegationLink({}, {}, options), "invalid_argument"); + } +}); + test("base64url decodes canonical input and refuses everything else", () => { assert.deepEqual([...decodeBase64url("")], []); assert.deepEqual([...decodeBase64url("AQID")], [1, 2, 3]); From c5e3c465ee2cb60b6e6153a762307964b61eb134 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:52:44 +0300 Subject: [PATCH 15/22] differential: the schema location and the key source are part of the verdict; duplicate ids fail the run compare.py treated two schema_invalid verdicts as agreeing whatever member each side faulted, and only listed the difference as commentary. The check order is shared, so the first member faulted must be the same one: the location is now part of the summary a case has to match, and a difference there is a divergence to be argued in the ledger like any other. At this head every one of the 600 schema failures agrees on the member. VerificationResult.trustedKeySource was outside the comparison, so a regression in it would pass as long as the thumbprint matched, which it does whenever the caller passes the key the record embeds. run.mjs now reports it; oracle.py states the same fact the way verify_record resolves it, since the Python result carries no such member; compare.py matches the two. A unit test pins the case the thumbprint cannot tell apart. Both runners key verdicts by case id, so a second case under one id replaced the first without a trace, and the external ids fold "/" into "-", which is one way two files can collide. generate_cases.py fails on a duplicate id and compare.py fails on a verdict file that repeats one. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- trust-record-verify/differential/compare.py | 24 +++++++++--------- .../differential/generate_cases.py | 9 +++++++ trust-record-verify/differential/oracle.py | 6 +++++ trust-record-verify/differential/run.mjs | 1 + trust-record-verify/tests/vectors.test.mjs | 25 +++++++++++++++++++ 5 files changed, 53 insertions(+), 12 deletions(-) diff --git a/trust-record-verify/differential/compare.py b/trust-record-verify/differential/compare.py index a31fde78..dc4e4b26 100644 --- a/trust-record-verify/differential/compare.py +++ b/trust-record-verify/differential/compare.py @@ -1,8 +1,11 @@ """Compare the two verdict files and report every case where they differ. A case agrees when both sides reach the same verdict for the same stated reason: -the same failure code for a rejection, the same canonical bytes or digest for the -other kinds, and for a record that verifies, the same key thumbprint and the same +the same failure code for a rejection, and for a schema failure the same location, +since both implementations run the same checks in the same order and the first +member they fault must be the same one; the same canonical bytes or digest for the +other kinds; and for a record that verifies, the same key thumbprint, the same +source of that key (the caller's, or the record's own `cnf.jwk`) and the same revocation outcome, cause and evidence. Evidence is compared with the two members that carry a human-readable message removed, since those are prose. @@ -37,6 +40,10 @@ def load(path: pathlib.Path) -> dict[str, dict[str, Any]]: for line in path.read_text(encoding="utf-8").split("\n"): if line.strip(): verdict = json.loads(line) + if verdict["id"] in out: + # A second verdict under one id would replace the first and take + # its case out of the comparison without a trace. + raise SystemExit(f"{path}: case id {verdict['id']!r} appears twice") out[verdict["id"]] = verdict return out @@ -45,12 +52,15 @@ def summarise(verdict: dict[str, Any]) -> tuple: """What has to match. Anything outside this tuple is commentary.""" kind = verdict["verdict"] if kind == "rejected": + if verdict.get("code") == "schema_invalid": + return ("rejected", "schema_invalid", verdict.get("path")) return ("rejected", verdict.get("code")) if kind == "verified": revocation = verdict.get("revocation") or {} return ( "verified", verdict.get("thumbprint"), + verdict.get("key_source"), revocation.get("outcome"), revocation.get("cause"), json.dumps(revocation.get("evidence"), sort_keys=True), @@ -133,7 +143,6 @@ def main() -> int: known: list[dict[str, Any]] = [] unexpected: list[dict[str, Any]] = [] unclassified: list[str] = [] - path_only: list[dict[str, Any]] = [] hits: dict[tuple[int, str], int] = {} for case_id in ids: @@ -146,12 +155,6 @@ def main() -> int: unclassified.append(f"{case_id}: {left.get('detail')}") if summarise(left) == summarise(right): agreed.append(case_id) - if left.get("code") == "schema_invalid" and left.get("path") != right.get("path"): - path_only.append({ - "id": case_id, - "python": left.get("path"), - "typescript": right.get("path"), - }) continue record = { "id": case_id, @@ -208,8 +211,6 @@ def main() -> int: print(f"unclassified Python exceptions: {len(unclassified)}") for line in unclassified[:20]: print(f" {line}") - if path_only: - print(f"schema locations that differ while the code agrees: {len(path_only)}") for record in unexpected: print(json.dumps(record, sort_keys=True)) @@ -232,7 +233,6 @@ def main() -> int: "ledger_unreached": stale, "known": known, "unexpected": unexpected, - "schema_location_differences": path_only, }, indent=1, sort_keys=True) + "\n", encoding="utf-8") return 1 if unexpected or unclassified or stale or external_short else 0 diff --git a/trust-record-verify/differential/generate_cases.py b/trust-record-verify/differential/generate_cases.py index c8bd6203..1d5a5639 100644 --- a/trust-record-verify/differential/generate_cases.py +++ b/trust-record-verify/differential/generate_cases.py @@ -469,6 +469,15 @@ def build(external: pathlib.Path | None) -> dict[str, Any]: cases += jcs_cases() cases += thumbprint_cases() cases += chain_digest_cases() + seen: dict[str, int] = {} + for case in cases: + seen[case["id"]] = seen.get(case["id"], 0) + 1 + duplicates = sorted(case_id for case_id, count in seen.items() if count > 1) + if duplicates: + # Both runners key their verdicts by id, so two cases under one id would + # leave one of them out of the comparison without a trace. The external + # ids fold "/" into "-", which is one way two files can collide. + raise SystemExit(f"duplicate case ids: {duplicates}") return { "signer_jwk": SIGNER_JWK, "other_jwk": OTHER_JWK, diff --git a/trust-record-verify/differential/oracle.py b/trust-record-verify/differential/oracle.py index ef527096..97109cfd 100644 --- a/trust-record-verify/differential/oracle.py +++ b/trust-record-verify/differential/oracle.py @@ -209,6 +209,12 @@ def run_verify(case: dict[str, Any]) -> dict[str, Any]: return { "verdict": "verified", "thumbprint": result.trusted_key_thumbprint, + # Which key verified the record. The result carries no such member, so + # this states it the way verify_record resolves it: the caller's key when + # one is passed, the record's own cnf.jwk otherwise. The TypeScript side + # reports what it used, and the two must agree even where the thumbprints + # would, as they do whenever the caller passes the key the record embeds. + "key_source": "caller" if options.get("trusted_key") is not None else "record", "revocation": { "outcome": result.revocation.outcome, "cause": result.revocation.cause, diff --git a/trust-record-verify/differential/run.mjs b/trust-record-verify/differential/run.mjs index 24edeab5..13a88748 100644 --- a/trust-record-verify/differential/run.mjs +++ b/trust-record-verify/differential/run.mjs @@ -92,6 +92,7 @@ async function runVerify(item) { return { verdict: "verified", thumbprint: result.trustedKeyThumbprint, + key_source: result.trustedKeySource, revocation: { outcome: result.revocation.outcome, cause: result.revocation.cause, diff --git a/trust-record-verify/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs index 64c2f5c4..eb2774fb 100644 --- a/trust-record-verify/tests/vectors.test.mjs +++ b/trust-record-verify/tests/vectors.test.mjs @@ -121,3 +121,28 @@ test("delegation-link vectors agree on the chain digest of section 3.1.3", async } assert.ok(checked > 0, "no delegation link resolved, so nothing was checked"); }); + +test("trustedKeySource names the key that verified, even when the record embeds the same key", async () => { + let picked; + for (const { vector } of vectors("canonicalization-boundary")) { + const got = await outcome( + verifyRecord(vector.record, { trustedKey: vector.trusted_key, now: vector.record.iat, maxAgeSeconds: null }), + ); + if (!got.rejected) { + picked = { vector, result: got.result }; + break; + } + } + assert.ok(picked, "no canonicalization-boundary vector verifies"); + const { vector, result } = picked; + assert.equal(result.trustedKeySource, "caller"); + const embedded = await verifyRecord(vector.record, { + allowEmbeddedKey: true, + now: vector.record.iat, + maxAgeSeconds: null, + }); + assert.equal(embedded.trustedKeySource, "record"); + // The same key both ways, so the thumbprint cannot tell the two results apart + // and only trustedKeySource can; the differential compares it for that reason. + assert.equal(embedded.trustedKeyThumbprint, result.trustedKeyThumbprint); +}); From cc339e6396a5175338dd7bc27eab0ca7d92fe40a Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:52:44 +0300 Subject: [PATCH 16/22] ci(trust-record-verify): run the differential on spec changes too A normative change under spec/ can move the verdict a case is expected to produce without touching the schemas, the examples or either implementation. The path filter listed all of those and not the specification itself, so such a change skipped the differential. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- .github/workflows/trust-record-verify.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/trust-record-verify.yml b/.github/workflows/trust-record-verify.yml index c800173e..db84b201 100644 --- a/.github/workflows/trust-record-verify.yml +++ b/.github/workflows/trust-record-verify.yml @@ -6,6 +6,7 @@ on: paths: - "trust-record-verify/**" - "schema/**" + - "spec/**" - "examples/**" - "src/agentrust_trace/**" - ".github/workflows/trust-record-verify.yml" @@ -13,6 +14,7 @@ on: paths: - "trust-record-verify/**" - "schema/**" + - "spec/**" - "examples/**" - "src/agentrust_trace/**" - ".github/workflows/trust-record-verify.yml" From 495722dbea371bed22888ae9b83e11fc087ffcdf Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:52:44 +0300 Subject: [PATCH 17/22] docs(trust-record-verify): align the README and the changelog with the ledger at this head The README said the 136 remaining cases were seven classes each appearing once per verifier configuration; three do (24 each), the others land on 32, 17 and 14 cases, and one is in the RFC 8785 corpus, not the mutation matrix. The retired entries were listed as "six" in five items, with two folded into one. The table now carries each entry with its pinned count, and the six retired entries are six items. The changelog entry still gave the first run's figures (1280 identical, 364 in 13 classes); it now gives this head's (1508, 136 in 7) and says what left and what remains. Both also say that the differential compares the location of a schema failure and the source of the verifying key. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- CHANGELOG.md | 2 +- trust-record-verify/README.md | 43 ++++++++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e5a88a..c04bbf41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. - **Vendor annex: Bernstein (software-only).** `docs/platforms/bernstein.md` maps a deterministic-scheduler producer with no hardware root onto the Trust Record: the journal each claim is derived from, the delegation link and aggregate composition under the section 3.1.3 preimage, where the two recomputable commitments of section 3.1.4 sit on such a record today, the mapping of the reproducibility claim onto the producer's re-derivation check, what a verifier can recompute without the producer, and the Level 0 ceiling a committed software-only corpus has by construction. Seven emitter-minted vectors, the supplementary-plane pair among them, are pinned by commit. Informative, per GOVERNANCE; the annex track is #231. -- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1280 agree exactly, every published vector among them, and the remaining 364 are 13 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. Four of the 13 are cases where the reference accepts a record this specification's own rules exclude: a signature re-spelled with non-zero unused base64 bits verifies (and section 3.1.3 digests the signature member, so two records that differ only in that spelling both verify and have different chain digests); a `subject` ending in U+000A, U+000D or U+2028 passes its `pattern`, and an `appraisal.verifier` ending in U+000A passes `format: uri`, because Python's `re` anchors `$` before a final newline and matches `.` against characters ECMA-262 excludes; and `https://[::1.2.3.04]/` passes `format: uri` although RFC 3986's `dec-octet` has no leading zero. Two are rejections of a different shape: a non-ASCII `runtime.nonce` leaves `verify_record` as a `TypeError` from `hmac.compare_digest`, and a revocation bundle carrying a lone surrogate makes the reference reject the record rather than report `unverified_for_revocation` as section 3.2.3 requires. Two more are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and a number of magnitude 2^53 or above has an RFC 8785 form on one side and none on the other. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. +- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record, and the differential compares the location of a schema failure and the source of the verifying key as part of the verdict. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1508 agree exactly, every published vector among them, and the remaining 136 are 7 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. The first run scored 1280 identical and 13 classes; six of those were defects the harness surfaced in the reference, fixed before this landed (#379 by #388, #380 by #387, #381 by #383, #382 by #386, and a present but falsy `signature` by #390), and each left the ledger as an entry no case reached. Of the 7 that remain, three are places where this implementation is stricter than the reference (a signature truncated to a still-valid base64url length, and a non-canonical `x` in the caller's key or in `cnf.jwk`: refused here before decoding, rejected by the reference for whatever follows a lenient decode), two are signature spellings the reference verifies or faults later (non-zero unused base64 bits, which section 3.1.3 makes visible in the chain digest, and a trailing newline, where Python's `re` anchors `$` before a final newline), and two are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and `1.0e+21` has an RFC 8785 form on one side and none on the other. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. - **Reproducibility claim: deterministic re-execution as evidence (section 3.1.4).** A record may now carry `reproducibility`, the claim that re-executing a named deterministic function of the run, the producer's coordination logic rather than the workload's side effects, over a pinned input closure yields a transcript whose RFC 8785 digest equals the claimed value. The claim has three explicit parts: the code identity (`code_identity`, with the `function` it exposes and an optional `code_resolver` where the digest alone does not locate the artifact), `input_closure` as `{id, digest, resolver}` entries covering everything the function reads, model calls included, and `transcript_digest`. A closure that omits anything which can change the transcript makes the claim malformed rather than weak. A verifier must hold the implementation, obtainable as a public artifact without the producer; the closure blobs, each matching the digest the producer signed, which is an integrity bar and not a provenance one, since a run's closure is run-private; and the claim itself from the signed record. If re-execution needs state only the producer's environment can supply, it is not reproduction, and a read beyond the closure fixes the outcome at `not-attempted` with the read named, whatever the re-run would otherwise have produced. The re-execution result is an appraisal under `appraisal.method: "re-execution"` with a tri-state `outcome`: `reproduced`, `diverged` (recording the verifier's observed digest) or `not-attempted` (carrying the reason), and `not-attempted` MUST NOT be reported as either of the other two. `appraisal.status` stays the closed EAR set of #190, and the section says why `not-attempted` is not `status: none`. Non-breaking: a new optional member, an optional `appraisal.method`, and `appraisal.re_execution` present exactly when the method is `re-execution`; no change to required fields or wire format. The schema, the reference model and `docs/schema.md` track the text in a follow-up, and vectors from a producer that makes the claim follow with them. Proposed and written by @chernistry in #249; carried by @imran-siddique as sponsoring maintainer. The vendor annex in #231 is sequenced after it. diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md index 4cad8c29..faefa49b 100644 --- a/trust-record-verify/README.md +++ b/trust-record-verify/README.md @@ -62,7 +62,6 @@ same first failure for the same record. It is documented beside the function. - No key or bundle fetching. Both are passed in, which is what keeps the package offline. - No ES256 or ES384 bundle signatures. The bundle schema admits them; a signature nobody checked grounds nothing, so they are reported as `bundle_signature_unsupported`. - ## Building ``` @@ -96,7 +95,11 @@ the manifest digest. The corpus is one JSON file holding each record as *text*, because several cases exist to probe a difference that only survives in text. Both runners parse the same bytes with their own JSON parser. A case agrees when both sides reach the same verdict for the same stated -reason, and every disagreement is argued in +reason: the same failure code, and for a schema failure the same member, since the check +order is shared and the first member faulted must be the same one; for a record that +verifies, the same key thumbprint, the same source of that key (the caller's or the +record's own `cnf.jwk`, which the thumbprint alone cannot tell apart when they are the same +key) and the same revocation outcome, cause and evidence. Every disagreement is argued in [`differential/known-divergences.json`](differential/known-divergences.json), matched on both the case and the pair of reported reasons, so a new disagreement in a family already listed is reported rather than absorbed. The ledger holds in the other direction too: each @@ -116,15 +119,33 @@ Latest run, against this repository's implementation at the commit under test (i | Published conformance vectors | 12 | 12 | | **Total** | **1644** | **1508** | -Every published vector agrees. The 136 remaining cases are 7 documented divergence classes -in the adversarial matrix, each one appearing once per verifier configuration. Six classes -left the ledger when the reference stopped diverging: the lone-surrogate revocation bundle -(#382, fixed by #386), `subject` and `appraisal.verifier` under ECMA-262 pattern semantics -(#379, fixed by #388), the IPv4-in-IPv6 literal with a leading zero (#380, fixed by #387), -the non-ASCII nonce (#381, fixed by #383), and present falsy signatures -(#390). The TypeScript signature guard now also distinguishes absent members, null -values and empty strings in the same order as the Python reference. The harness reported each of them as an -entry no case reached, which is how a fixed divergence is meant to leave. +Every published vector agrees. The 136 remaining cases are the ledger's 7 entries, each +pinned to the number of cases that land on it. Three appear once per verifier +configuration; the other three matrix entries land only in the configurations where their +check is reached before another failure; one is in the RFC 8785 corpus. + +| Ledger entry | Cases | Where | +|---|---:|---| +| Signature re-spelled with non-zero unused trailing bits (`m03`) | 24 | mutation matrix, once per verifier configuration | +| Signature truncated to a still-valid base64url length (`m04`) | 24 | mutation matrix, once per configuration | +| Signature with a trailing newline (`m05`) | 24 | mutation matrix, once per configuration | +| `iat` written `1785000000.0` or `1.785e9` (`m20`, `m21`) | 32 | mutation matrix, both spellings in the 16 configurations that reach the `iat` check | +| The record's `cnf.jwk.x` not canonical base64url (`m46`, `m47`) | 17 | mutation matrix, one case in each of the 17 configurations that read that key | +| The caller's trusted key with a non-canonical `x` (`c18`) | 14 | one configuration, the 14 mutations that reach the trusted key | +| `1.0e+21`: a float in Python, an integer past 2^53 here (`jcs-10`) | 1 | RFC 8785 corpus | + +Six entries left the ledger when the reference stopped diverging, each reported by the +harness as an entry no case reached, which is how a fixed divergence is meant to leave: + +- the lone-surrogate revocation bundle (`c23`; #382, fixed by #386); +- `subject` with a trailing U+000A, U+000D or U+2028 under ECMA-262 `pattern` semantics + (`m27`; #379, fixed by #388); +- `appraisal.verifier` with a trailing newline under `format: uri` (`m31`; #379, fixed by #388); +- the IPv4-in-IPv6 literal with a leading zero (`m32`; #380, fixed by #387); +- the non-ASCII nonce (`m41`; #381, fixed by #383); +- a `signature` member that is present but `0`, `false`, `[]` or `{}` (`m08`; #390). The + TypeScript signature guard now also distinguishes absent members, null values and empty + strings in the same order as the Python reference. The differential compares verdicts, so it is silent on anything that does not change a verdict. Whether `timingSafeEqual` is constant-time is not observable in a verdict at all, From d5dc00035758ee9bc4b7365c8f9423f117d1c8bb Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:52:44 +0300 Subject: [PATCH 18/22] docs(trust-record-verify): state the section 3.2.3 subset this package implements The revocation consumer checks the bundle signature and applies a statement naming the trusted key as the section's fallback for a record without a usable receipt. It does not verify a statement's own signature against the section 3.2.1 hierarchy, and it takes no inclusion entry ID, so the last_valid_entry_id rule is not applied. The module header said so; the README's limits and the option's own documentation now say it too, and name it as the same subset the Python implementation documents. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- trust-record-verify/README.md | 14 +++++++++++++- trust-record-verify/src/verify.ts | 8 +++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md index faefa49b..5f53307c 100644 --- a/trust-record-verify/README.md +++ b/trust-record-verify/README.md @@ -46,7 +46,7 @@ affirming appraisal. | Freshness: `iat` is an integer, the record is not past `maxAgeSeconds` and not further ahead than `maxFutureSkewSeconds` | spec section 3.2.2 | | Revocation is keyed on the trusted key and never on the record's own `cnf.jwk` | spec section 3.2.3 | | A revocation bundle a verifier cannot ground an answer on is reported as `unverified_for_revocation`, which MUST NOT be read as an affirming appraisal | spec section 3.2.3 | -| A statement naming the key rejects the record whatever the bundle's age | spec section 3.2.3 | +| A statement naming the key rejects the record whatever the bundle's age; no inclusion entry ID is taken, so this is the section's fallback for a record without a usable receipt | spec section 3.2.3 | | `delegation.parent_record_hash` is taken over the complete parent record, signature included | spec section 3.1.3 | | A chain digest whose prefix names an unsupported algorithm MUST be rejected rather than computed with another | spec section 3.1.3 | @@ -62,6 +62,18 @@ same first failure for the same record. It is documented beside the function. - No key or bundle fetching. Both are passed in, which is what keeps the package offline. - No ES256 or ES384 bundle signatures. The bundle schema admits them; a signature nobody checked grounds nothing, so they are reported as `bundle_signature_unsupported`. +- No statement-level signatures. Section 3.2.3 authenticates a revocation statement with + the signature of the bundle that carries it, and that is the signature this package + checks. A statement's own signature against the section 3.2.1 key hierarchy is not + verified, because a verifier does not hold that hierarchy. This is the subset the Python + implementation documents in `src/agentrust_trace/revocation.py`. +- No inclusion-entry ordering. `verifyRecord` takes no SCITT inclusion entry ID, so the + `last_valid_entry_id` rule of section 3.2.3 is not applied: a statement naming the trusted + key rejects every record that key signed, which is the section's fallback for a record + without a usable receipt. The Python implementation applies the same fallback. A verifier + that holds a record's entry ID needs the receipt path in both implementations, and it is + not in this package. + ## Building ``` diff --git a/trust-record-verify/src/verify.ts b/trust-record-verify/src/verify.ts index 6b000e16..25e0d1bc 100644 --- a/trust-record-verify/src/verify.ts +++ b/trust-record-verify/src/verify.ts @@ -80,7 +80,13 @@ export interface VerifyOptions { /** A challenge nonce the record's `runtime.nonce` must echo. */ readonly expectedNonce?: string; readonly revocation?: RevocationStore; - /** A `TraceRevocationBundle/1.0`, validated here against its schema. */ + /** + * A `TraceRevocationBundle/1.0`, validated here against its schema. Its bundle + * signature authenticates the statements; a statement's own signature is not + * checked, and no inclusion entry ID is taken, so a statement naming the trusted + * key rejects every record that key signed (section 3.2.3's fallback for a record + * without a usable receipt). + */ readonly revocationBundle?: unknown; /** JWKs whose signature the caller accepts on a bundle. */ readonly trustedBundleKeys?: readonly unknown[]; From 197550f35efd0df2faaacc474c5ab4e788e31d23 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:48:41 +0300 Subject: [PATCH 19/22] fix(trust-record-verify): re-baseline the differential on the 0.11.0 reference; decode the signature where it does Against main the differential no longer held. The reference's decoder refuses base64url that is not canonical since #418 (0.11.0), with two messages the oracle did not classify, and it reports a padded signature or one in the standard alphabet at its decoding step, which comes before its schema check. This implementation consulted the schema first. The run read 1644 cases, 1429 identical, 57 known, 158 unexpected, 4 ledger entries unreached. - oracle.py classifies "signature is not canonical base64url" and "JWK 'x' is not canonical base64url". - verifyRecord decodes the signature ahead of the schema, which is the order the module header already promised. A padded signature, one in the standard alphabet, one with a trailing newline and one of an impossible length are signature_malformed on both sides; the empty string decodes to no bytes and is still refused by the schema's pattern on both sides. A test pins all five. - Five ledger entries that no case reaches are retired: non-zero unused bits in a signature, a truncated signature, a non-canonical x in cnf.jwk or in the caller's key (the reference refuses them now), and the trailing newline (the order here moved). Now: 1644 cases, 1611 identical, 33 known, 0 unexpected, 2 ledger entries, none unreached, 12 published vectors. README and CHANGELOG carry those figures, and the CHANGELOG entry is back under Unreleased, where the merge with main had left it inside the 0.11.0 section. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- CHANGELOG.md | 5 +- trust-record-verify/README.md | 43 +++-- .../differential/known-divergences.json | 161 +----------------- trust-record-verify/differential/oracle.py | 2 + trust-record-verify/src/verify.ts | 28 +-- trust-record-verify/tests/vectors.test.mjs | 35 ++++ 6 files changed, 84 insertions(+), 190 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b03b84..941fe8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record, and the differential compares the location of a schema failure and the source of the verifying key as part of the verdict. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1611 agree exactly, every published vector among them, and the remaining 33 are 2 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. The first run scored 1280 identical and 13 classes, and eleven of those have left the ledger, each as an entry no case reached. Six were defects the harness surfaced in the reference, fixed before this landed (#379 by #388, #380 by #387, #381 by #383, #382 by #386, and a present but falsy `signature` by #390). Four were base64url that is not canonical, which this implementation refused from the start and the reference's decoder refuses since 0.11.0 (#418): non-zero unused bits in a signature, which the reference used to verify and which section 3.1.3 makes visible in the chain digest, a signature truncated to a still-valid length, and a non-canonical `x` in the caller's key or in `cnf.jwk`. The eleventh, a signature with a trailing newline, was a difference in order: the reference decodes the signature before it consults the schema, and this implementation now does the same, so both report `signature_malformed` for it, for a padded signature and for one in the standard alphabet. The 2 that remain are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and `1.0e+21` has an RFC 8785 form on one side and none on the other. That move in the order of two checks is the one adjustment made to meet the other side; no verdict was changed to match. Contributed by @chernistry. + - Propose explicit verifier profile declarations and verifier-result field requirements for obligations 2 and 3 of #116, carried by Imran Siddique following the discussion with @lywinged. Obligations 1 and 4 remain deferred; the v0.1 cutover is unchanged. - **Breaking, proposed: `silent` mode allows a policy-denied action (#143, following #28).** Section 4.3 now says a deny MUST NOT block in `silent` mode and the audit chain MUST record every would-have-denied decision. v0.9.0 and v0.10.0 described `silent` as evaluated and enforced under the same `eat_profile`, so a producer that followed that text and blocks on deny no longer conforms, and a `silent` record does not say which meaning it was written under. Classified breaking under GOVERNANCE.md, so adoption needs the 30-day comment period and Project Lead sign-off. Section 4.3 also orders the modes: `advisory` is not weaker than `silent`. Enforce remains the default; schema constraints and runtime behavior are unchanged. Classification raised by @lywinged. @@ -91,9 +93,6 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. - **The schema, the reference model and the schema reference carry the reproducibility claim (section 3.1.4).** `schema/trace-claim.json` and its packaged copy gain the optional top-level `reproducibility` object: `function`, `code_identity`, an optional `code_resolver`, `input_closure` as `{id, digest, resolver}` entries with every member required, and `transcript_digest`. `appraisal` gains `method`, the closed set `re-execution`, and `re_execution`: `outcome` of `reproduced`, `diverged` or `not-attempted`, `observed_digest`, `reason`, and an optional `verifier_code_identity`. The three presence rules the section states are schema conditionals and model validators alike: `re_execution` is present exactly when `method` is `re-execution`, `observed_digest` is required on `diverged`, `reason` is required on `not-attempted`. `models.py` adds `Reproducibility`, `ClosureEntry` and `ReExecution`; `docs/schema.md` documents every member in its section. `examples/reproducibility-claim/` holds 21 signed vectors from a seeded key: five accepted records, a claim with no result and one result per outcome, and sixteen rejected ones, two per rule, with the transcript and closure digests recomputable from the JSON they are taken over. Tracks #364; closes #366. - **Vendor annex: Bernstein (software-only).** `docs/platforms/bernstein.md` maps a deterministic-scheduler producer with no hardware root onto the Trust Record: the journal each claim is derived from, the delegation link and aggregate composition under the section 3.1.3 preimage, where the two recomputable commitments of section 3.1.4 sit on such a record today, the mapping of the reproducibility claim onto the producer's re-derivation check, what a verifier can recompute without the producer, and the Level 0 ceiling a committed software-only corpus has by construction. Seven emitter-minted vectors, the supplementary-plane pair among them, are pinned by commit. Informative, per GOVERNANCE; the annex track is #231. - -- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record, and the differential compares the location of a schema failure and the source of the verifying key as part of the verdict. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1508 agree exactly, every published vector among them, and the remaining 136 are 7 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. The first run scored 1280 identical and 13 classes; six of those were defects the harness surfaced in the reference, fixed before this landed (#379 by #388, #380 by #387, #381 by #383, #382 by #386, and a present but falsy `signature` by #390), and each left the ledger as an entry no case reached. Of the 7 that remain, three are places where this implementation is stricter than the reference (a signature truncated to a still-valid base64url length, and a non-canonical `x` in the caller's key or in `cnf.jwk`: refused here before decoding, rejected by the reference for whatever follows a lenient decode), two are signature spellings the reference verifies or faults later (non-zero unused base64 bits, which section 3.1.3 makes visible in the chain digest, and a trailing newline, where Python's `re` anchors `$` before a final newline), and two are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and `1.0e+21` has an RFC 8785 form on one side and none on the other. Nothing was adjusted to match the other side; each is written down. Contributed by @chernistry. - - **Reproducibility claim: deterministic re-execution as evidence (section 3.1.4).** A record may now carry `reproducibility`, the claim that re-executing a named deterministic function of the run, the producer's coordination logic rather than the workload's side effects, over a pinned input closure yields a transcript whose RFC 8785 digest equals the claimed value. The claim has three explicit parts: the code identity (`code_identity`, with the `function` it exposes and an optional `code_resolver` where the digest alone does not locate the artifact), `input_closure` as `{id, digest, resolver}` entries covering everything the function reads, model calls included, and `transcript_digest`. A closure that omits anything which can change the transcript makes the claim malformed rather than weak. A verifier must hold the implementation, obtainable as a public artifact without the producer; the closure blobs, each matching the digest the producer signed, which is an integrity bar and not a provenance one, since a run's closure is run-private; and the claim itself from the signed record. If re-execution needs state only the producer's environment can supply, it is not reproduction, and a read beyond the closure fixes the outcome at `not-attempted` with the read named, whatever the re-run would otherwise have produced. The re-execution result is an appraisal under `appraisal.method: "re-execution"` with a tri-state `outcome`: `reproduced`, `diverged` (recording the verifier's observed digest) or `not-attempted` (carrying the reason), and `not-attempted` MUST NOT be reported as either of the other two. `appraisal.status` stays the closed EAR set of #190, and the section says why `not-attempted` is not `status: none`. Non-breaking: a new optional member, an optional `appraisal.method`, and `appraisal.re_execution` present exactly when the method is `re-execution`; no change to required fields or wire format. The schema, the reference model and `docs/schema.md` track the text in a follow-up, and vectors from a producer that makes the claim follow with them. Proposed and written by @chernistry in #249; carried by @imran-siddique as sponsoring maintainer. The vendor annex in #231 is sequenced after it. - **A verifier can declare the set of profile URIs it supports, and the verification statement says what it ran under (#116).** Compliance evidence outlives the verifier build that reads it, and `docs/verification.md` says nothing about what a verifier owes when it meets a record written under semantics it does not implement. Two of the four obligations proposed on #116 are verifier behaviour and need no normative text, and they are what this adds. `DEFAULT_ACCEPTED_PROFILES` makes addressable the rule `spec/trace-v0.2.md` already states under "Changes from v0.1": accept `tag:agentrust-io.com,2026:trace-v0.2`, reject the superseded identifier, never both. `verify_record()` takes an `accepted_profiles` set, refuses a record whose `eat_profile` is outside it, and refuses the configuration itself where the declared set names the superseded identifier or a profile this build carries no schema for. `validate.profiles_with_schema()` reads the packaged schemas for their `eat_profile` const and is that ceiling, so support cannot be declared for a shape that cannot be checked. `VerificationResult` gains `profile` and `accepted_profiles`: a declaration living only in the verifier's documentation cannot be recovered from a result read years later, which is the condition the issue opens with. Eight conformance vectors in `examples/verifier-compatibility/`, and a separation guard that runs eight wrong implementations against the whole set and records what each is caught by, so the figure fails loudly rather than expiring quietly the next time a second gate lands over the same inputs. Measured: the set separates 4 of its 8 vectors against a verifier implementing none of the obligations, with one recorded shortfall that no vector written against this build can close. Each vector's `failure` is informative: a verifier that applies every rule and reports one generic refusal conforms, and two controls hold that open. This library's own refusal messages are pinned separately. The normative `docs/verification.md` text for these obligations is not part of this change and is not proposed here. Obligations 1 and 4 of the issue stay deferred as unobservable against the current schema. diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md index 5f53307c..b72ed79d 100644 --- a/trust-record-verify/README.md +++ b/trust-record-verify/README.md @@ -119,35 +119,31 @@ entry declares how many cases land on it, and an entry, a pair or a count the ru bear out fails it, so a divergence that stops happening on either side has to leave the ledger rather than stay as a claim nothing checks. -Latest run, against this repository's implementation at the commit under test (its version string reads 0.10.0; #383, #386, #387, #388 and #390 landed after that release): +Latest run, against this repository's implementation at the commit under test (its version string reads 0.11.0): | Group | Cases | Identical | |---|---:|---:| -| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1281 | +| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1384 | | RFC 8785 canonicalization corpus | 34 | 33 | | RFC 7638 thumbprints | 14 | 14 | | Chain digests over the delegation corpus | 134 | 134 | | This repository's conformance vectors | 34 | 34 | | Published conformance vectors | 12 | 12 | -| **Total** | **1644** | **1508** | +| **Total** | **1644** | **1611** | -Every published vector agrees. The 136 remaining cases are the ledger's 7 entries, each -pinned to the number of cases that land on it. Three appear once per verifier -configuration; the other three matrix entries land only in the configurations where their -check is reached before another failure; one is in the RFC 8785 corpus. +Every published vector agrees. The 33 remaining cases are the ledger's 2 entries, each +pinned to the number of cases that land on it. Both are the JSON number model, which the +two languages hold differently: one lands in the configurations that reach the `iat` +check, and one is a single case in the RFC 8785 corpus. | Ledger entry | Cases | Where | |---|---:|---| -| Signature re-spelled with non-zero unused trailing bits (`m03`) | 24 | mutation matrix, once per verifier configuration | -| Signature truncated to a still-valid base64url length (`m04`) | 24 | mutation matrix, once per configuration | -| Signature with a trailing newline (`m05`) | 24 | mutation matrix, once per configuration | | `iat` written `1785000000.0` or `1.785e9` (`m20`, `m21`) | 32 | mutation matrix, both spellings in the 16 configurations that reach the `iat` check | -| The record's `cnf.jwk.x` not canonical base64url (`m46`, `m47`) | 17 | mutation matrix, one case in each of the 17 configurations that read that key | -| The caller's trusted key with a non-canonical `x` (`c18`) | 14 | one configuration, the 14 mutations that reach the trusted key | | `1.0e+21`: a float in Python, an integer past 2^53 here (`jcs-10`) | 1 | RFC 8785 corpus | -Six entries left the ledger when the reference stopped diverging, each reported by the -harness as an entry no case reached, which is how a fixed divergence is meant to leave: +Eleven entries have left the ledger, each reported by the harness as an entry no case +reached, which is how a divergence that stopped is meant to leave. Six went when the +reference stopped diverging after 0.10.0: - the lone-surrogate revocation bundle (`c23`; #382, fixed by #386); - `subject` with a trailing U+000A, U+000D or U+2028 under ECMA-262 `pattern` semantics @@ -159,6 +155,25 @@ harness as an entry no case reached, which is how a fixed divergence is meant to TypeScript signature guard now also distinguishes absent members, null values and empty strings in the same order as the Python reference. +Four went with 0.11.0, whose decoder refuses base64url that is not canonical (#418), which +this implementation had refused from the start: + +- a signature re-spelled with non-zero unused trailing bits (`m03`), which the reference + used to verify; +- a signature truncated to a still-valid base64url length (`m04`); +- the record's `cnf.jwk.x` not canonical base64url (`m46`, `m47`); +- the caller's trusted key with a non-canonical `x` (`c18`). + +The eleventh went when this implementation moved, not the reference: a signature with a +trailing newline (`m05`). The reference decodes the signature before it consults the +schema, and this implementation consulted the schema first, so the two rejected that +signature for different stated reasons. While the reference's decoder was lenient, the +difference in order showed in that one case. Once it was strict, a padded signature and +one in the standard alphabet showed it too, and the decoding here moved ahead of the +schema, to where the reference has it. Both now report `signature_malformed` for all +three, and the empty string, which decodes to no bytes, is still refused by the schema's +pattern on both sides. + The differential compares verdicts, so it is silent on anything that does not change a verdict. Whether `timingSafeEqual` is constant-time is not observable in a verdict at all, and `codePointLength` cannot disagree with `value.length` on anything the current schema diff --git a/trust-record-verify/differential/known-divergences.json b/trust-record-verify/differential/known-divergences.json index d51c97f2..bcd81e64 100644 --- a/trust-record-verify/differential/known-divergences.json +++ b/trust-record-verify/differential/known-divergences.json @@ -1,147 +1,7 @@ { "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. Entries are tried in order and a case lands on the first that matches; count is how many cases land on the entry in the committed corpus, and compare.py fails when an entry, a pair or a count is not borne out by the run, so a divergence that stops happening has to leave the ledger. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", - "reference": "agentrust-trace, this repository's implementation at the commit under test. The package version string reads 0.10.0, which is also the last release, but the behaviour recorded here is the tree's: #383, #386, #387, #388 and #390 landed after 0.10.0 and closed six entries that were in this ledger.", + "reference": "agentrust-trace, this repository's implementation at the commit under test; its version string reads 0.11.0. Eleven entries have left this ledger, each as an entry no case reached. Six went when #383, #386, #387, #388 and #390 landed after 0.10.0. Four went with 0.11.0, whose decoder refuses base64url that is not canonical (#418): a signature with non-zero unused bits, a signature truncated to a still-valid length, and a non-canonical x in the caller's key or in cnf.jwk. The eleventh, a signature with a trailing newline, went when this implementation's signature decoding moved ahead of its schema check, which is where the reference decodes.", "divergences": [ - { - "ids": [ - "c18-key-x-non-canonical/*" - ], - "count": 14, - "classification": "stricter-here", - "reason": "The caller's trusted key has an x whose unused trailing bits are not zero, so it is not canonical base64url (RFC 4648 section 3.5). This implementation refuses the key as trusted_key_malformed. The reference decodes it leniently, obtaining a different key, and rejects later for a different stated reason. Both reject; the reason differs, and the pairs record which reason the reference reached under each record mutation. A mutation that this implementation refuses before it reads the trusted key, a schema or signature defect, is that mutation's entry, not this one.", - "pairs": [ - [ - "cnf_key_malformed", - "trusted_key_malformed" - ], - [ - "cnf_key_mismatch", - "trusted_key_malformed" - ], - [ - "cnf_key_unsupported", - "trusted_key_malformed" - ], - [ - "signature_invalid", - "trusted_key_malformed" - ] - ] - }, - { - "ids": [ - "*/m03-signature-non-canonical-pad-bits" - ], - "count": 24, - "classification": "reference-defect", - "reason": "The signature is re-spelled with non-zero unused trailing bits: a different string that decodes to the same 64 bytes. The reference pads and decodes leniently, so it verifies the record. This implementation refuses the spelling as signature_malformed. The difference matters beyond tidiness: section 3.1.3 digests the parent record with its signature member present, so two records that differ only in this spelling verify identically and have different chain digests.", - "pairs": [ - [ - "cnf_key_mismatch", - "signature_malformed" - ], - [ - "key_revoked", - "signature_malformed" - ], - [ - "nonce_mismatch", - "signature_malformed" - ], - [ - "record_in_future", - "signature_malformed" - ], - [ - "record_stale", - "signature_malformed" - ], - [ - "revocation_unavailable", - "signature_malformed" - ], - [ - "trusted_key_malformed", - "signature_malformed" - ], - [ - "trusted_key_missing", - "signature_malformed" - ], - [ - "trusted_key_unsupported", - "signature_malformed" - ], - [ - "verified", - "signature_malformed" - ] - ] - }, - { - "ids": [ - "*/m04-signature-truncated" - ], - "count": 24, - "classification": "stricter-here", - "reason": "The signature is truncated to a length that is still a valid base64url length but whose trailing bits are not zero. Both implementations reject. The reference decodes it and reports signature_invalid; this one reports signature_malformed before verifying.", - "pairs": [ - [ - "cnf_key_mismatch", - "signature_malformed" - ], - [ - "key_revoked", - "signature_malformed" - ], - [ - "nonce_mismatch", - "signature_malformed" - ], - [ - "record_in_future", - "signature_malformed" - ], - [ - "record_stale", - "signature_malformed" - ], - [ - "revocation_unavailable", - "signature_malformed" - ], - [ - "signature_invalid", - "signature_malformed" - ], - [ - "trusted_key_malformed", - "signature_malformed" - ], - [ - "trusted_key_missing", - "signature_malformed" - ], - [ - "trusted_key_unsupported", - "signature_malformed" - ] - ] - }, - { - "ids": [ - "*/m05-signature-trailing-newline" - ], - "count": 24, - "classification": "reference-defect", - "reason": "The signature carries a trailing newline. Both implementations reject. The schema pattern is ^[A-Za-z0-9_-]+$, and in JSON Schema 2020-12 a pattern is an ECMA-262 regular expression, where $ does not match before a final newline. This implementation therefore reports schema_invalid. Python's re module matches $ before a trailing newline, so the reference's schema check passes and its base64 decoder reports the failure instead.", - "pairs": [ - [ - "signature_malformed", - "schema_invalid" - ] - ] - }, { "ids": [ "*/m20-iat-float-form", @@ -169,25 +29,6 @@ ] ] }, - { - "ids": [ - "*/m46-cnf-jwk-x-non-canonical", - "*/m47-cnf-jwk-x-truncated" - ], - "count": 17, - "classification": "stricter-here", - "reason": "The record's cnf.jwk has an x that is not canonical base64url. Both implementations reject. This one refuses the encoding (cnf_key_malformed, or trusted_key_malformed when allowEmbeddedKey made that same key the trusted one); the reference decodes it leniently and reports the mismatch or the failed signature that follows.", - "pairs": [ - [ - "cnf_key_mismatch", - "cnf_key_malformed" - ], - [ - "signature_invalid", - "trusted_key_malformed" - ] - ] - }, { "ids": [ "jcs-10" diff --git a/trust-record-verify/differential/oracle.py b/trust-record-verify/differential/oracle.py index 97109cfd..f43eed4f 100644 --- a/trust-record-verify/differential/oracle.py +++ b/trust-record-verify/differential/oracle.py @@ -65,6 +65,7 @@ def _call_sites() -> dict[str, int]: ("record has no 'signature' field", "signature_missing"), ("signature must be a base64url string", "signature_malformed"), ("signature is not valid base64url", "signature_malformed"), + ("signature is not canonical base64url", "signature_malformed"), ("record does not conform to the TRACE v0.2 schema", "schema_invalid"), ("verify_record requires a trusted key", "trusted_key_missing"), ("record has no cnf.jwk and no public key was supplied", "trusted_key_missing"), @@ -84,6 +85,7 @@ def _call_sites() -> dict[str, int]: ("JWK missing 'x' field", "malformed"), ("JWK 'x' must be a base64url string", "malformed"), ("JWK 'x' is not valid base64url", "malformed"), + ("JWK 'x' is not canonical base64url", "malformed"), ("An Ed25519 public key is 32 bytes", "malformed"), ("public key is 32 bytes", "malformed"), ] diff --git a/trust-record-verify/src/verify.ts b/trust-record-verify/src/verify.ts index 25e0d1bc..c18153be 100644 --- a/trust-record-verify/src/verify.ts +++ b/trust-record-verify/src/verify.ts @@ -12,11 +12,12 @@ * `tag:agentrust-io.com,2026:trace-v0.2` and MUST reject the v0.1 identifier * (spec/trace-v0.2.md, "Changes from v0.1"): `profile_missing`, * `profile_superseded`, `profile_unsupported`. - * 4. The embedded signature is present and is a string: `signature_missing`, - * `signature_malformed`. + * 4. The embedded signature is present and decodes as canonical unpadded + * base64url (section 3.2.2): `signature_missing`, `signature_malformed`. + * Ahead of the schema, as in the reference, so a padded signature or one + * in the standard alphabet is reported as the encoding fault it is. The + * empty string decodes, to no bytes, and is left to the schema's pattern. * 5. The record conforms to the schema: `schema_invalid`, with the location. - * Then the signature is decoded as canonical unpadded base64url - * (section 3.2.2), after the schema's own pattern for the member has run. * 6. A trusted key: the caller's, or `cnf.jwk` only when `allowEmbeddedKey` is * set, and it is an Ed25519 key: `trusted_key_missing`, * `trusted_key_unsupported`, `trusted_key_malformed`. @@ -258,6 +259,16 @@ export async function verifyRecord(record: unknown, options?: VerifyOptions): Pr if (typeof encodedSignature !== "string") { fail("signature_malformed", "the signature must be a base64url string"); } + // Decoded here, ahead of the schema, which is where the reference decodes it: + // the alphabet, no padding, a possible length, and unused trailing bits zero + // (RFC 4648 section 3.5). The last rule is one the schema's pattern cannot + // state. Without it one signature has several spellings, and section 3.1.3 + // digests the record with the signature member present, so the spellings are + // different records that both verify. + const signature = decodeBase64url(encodedSignature); + if (signature === null) { + fail("signature_malformed", "the signature is not canonical unpadded base64url"); + } const violations = recordSchemaViolations(record); if (violations.length > 0) { @@ -267,15 +278,6 @@ export async function verifyRecord(record: unknown, options?: VerifyOptions): Pr path, }); } - // The schema's own pattern for `signature` has run by now, so what this adds is - // the canonical-encoding rule the pattern cannot state: no padding, and unused - // trailing bits zero (RFC 4648 section 3.5). Without it one signature has - // several spellings, and section 3.1.3 digests the record with the signature - // member present, so the spellings are different records that both verify. - const signature = decodeBase64url(encodedSignature); - if (signature === null) { - fail("signature_malformed", "the signature is not canonical unpadded base64url"); - } // From here the schema holds: cnf.jwk is an object and iat an integer in range. const cnf = record["cnf"] as Record; diff --git a/trust-record-verify/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs index eb2774fb..e7a00139 100644 --- a/trust-record-verify/tests/vectors.test.mjs +++ b/trust-record-verify/tests/vectors.test.mjs @@ -146,3 +146,38 @@ test("trustedKeySource names the key that verified, even when the record embeds // and only trustedKeySource can; the differential compares it for that reason. assert.equal(embedded.trustedKeyThumbprint, result.trustedKeyThumbprint); }); + +async function aVerifyingVector() { + for (const { vector } of vectors("canonicalization-boundary")) { + const options = { trustedKey: vector.trusted_key, now: vector.record.iat, maxAgeSeconds: null }; + const got = await outcome(verifyRecord(vector.record, options)); + if (!got.rejected) { + return { vector, options, result: got.result }; + } + } + assert.fail("no canonicalization-boundary vector verifies"); +} + +test("the signature's encoding is judged before the schema, as the reference judges it", async () => { + const { vector, options } = await aVerifyingVector(); + const signature = vector.record.signature; + const respelled = { + padded: `${signature}==`, + "standard alphabet": `${signature.slice(0, -2)}+${signature.slice(-1)}`, + "trailing newline": `${signature}\n`, + "impossible length": signature.slice(0, -1), + }; + // Each of these also fails the schema's pattern or would fail verification. + // The encoding is the first thing wrong with them, and both implementations say so. + for (const [name, value] of Object.entries(respelled)) { + const got = await outcome(verifyRecord({ ...vector.record, signature: value }, options)); + assert.deepEqual(got, { rejected: true, code: "signature_malformed" }, name); + } + // The empty string decodes, to no bytes, so the schema's pattern is what refuses it. + await assert.rejects(verifyRecord({ ...vector.record, signature: "" }, options), (error) => { + assert.ok(error instanceof TraceVerificationError); + assert.equal(error.code, "schema_invalid"); + assert.equal(error.path, "signature"); + return true; + }); +}); From 06555968e82fe260827167c9a1d38a30ff7ed900 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:48:54 +0300 Subject: [PATCH 20/22] fix(trust-record-verify): type tests that do not depend on the realm; run the differential on dependency changes Review points on #376. encodeBase64url tested `instanceof Uint8Array`, which is false for a Uint8Array made in another frame or worker. It now reads the typed array's [[TypedArrayName]] through the %TypedArray% toStringTag getter: true for a Uint8Array of any realm, Buffer included, and false for a Uint16Array, a DataView, an ArrayBuffer, an array, and an object that only carries its own Symbol.toStringTag, which a test on Object.prototype.toString would accept. isPlainObject compared the prototype with this realm's Object.prototype, so a plain object from another realm was refused: the options of verifyDelegationLink and verifyRecord, and equally a record parsed by another frame's JSON.parse, a JWK, and any object reaching the canonicalizer. It now accepts an object with no prototype or with a prototype that has none of its own. Arrays, Dates, class instances and Object.create({}) are refused as before, from either realm. The revocation store was tested with `instanceof Set`, the same fault; it now asks the Set size getter, which answers for a Set of any realm and throws for anything else. Tests build each value in a second realm with node:vm: a record, its options and a revocation set from there verify as the same values do here. The workflow's path filters gain requirements/** and pyproject.toml, which the differential job installs the reference from. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- .github/workflows/trust-record-verify.yml | 8 ++++ trust-record-verify/src/base64url.ts | 6 ++- trust-record-verify/src/text.ts | 51 +++++++++++++++++++++- trust-record-verify/src/verify.ts | 6 +-- trust-record-verify/tests/units.test.mjs | 34 +++++++++++++++ trust-record-verify/tests/vectors.test.mjs | 31 +++++++++++++ 6 files changed, 129 insertions(+), 7 deletions(-) diff --git a/.github/workflows/trust-record-verify.yml b/.github/workflows/trust-record-verify.yml index db84b201..512a00a7 100644 --- a/.github/workflows/trust-record-verify.yml +++ b/.github/workflows/trust-record-verify.yml @@ -9,6 +9,10 @@ on: - "spec/**" - "examples/**" - "src/agentrust_trace/**" + # The differential job installs the reference from these two, so a change + # to either can change its verdicts without touching anything above. + - "requirements/**" + - "pyproject.toml" - ".github/workflows/trust-record-verify.yml" pull_request: paths: @@ -17,6 +21,10 @@ on: - "spec/**" - "examples/**" - "src/agentrust_trace/**" + # The differential job installs the reference from these two, so a change + # to either can change its verdicts without touching anything above. + - "requirements/**" + - "pyproject.toml" - ".github/workflows/trust-record-verify.yml" env: diff --git a/trust-record-verify/src/base64url.ts b/trust-record-verify/src/base64url.ts index 93aa1050..4424f002 100644 --- a/trust-record-verify/src/base64url.ts +++ b/trust-record-verify/src/base64url.ts @@ -10,6 +10,7 @@ */ import { fail } from "./errors.js"; +import { isUint8Array } from "./text.js"; const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; @@ -73,8 +74,9 @@ export function decodeBase64url(value: unknown): Uint8Array | null { export function encodeBase64url(bytes: Uint8Array): string { // A Uint8Array and nothing else that views a buffer: a Uint16Array iterates // 16-bit values, which the byte loop below would encode as the wrong bytes, - // and a DataView does not iterate at all. Node's Buffer is a Uint8Array. - if (!(bytes instanceof Uint8Array)) { + // and a DataView does not iterate at all. Node's Buffer is a Uint8Array, and + // so is one made in another frame or worker, which `instanceof` would refuse. + if (!isUint8Array(bytes)) { fail("invalid_argument", "encodeBase64url takes a Uint8Array"); } let out = ""; diff --git a/trust-record-verify/src/text.ts b/trust-record-verify/src/text.ts index 8112c83d..f9df8e49 100644 --- a/trust-record-verify/src/text.ts +++ b/trust-record-verify/src/text.ts @@ -85,12 +85,59 @@ export function timingSafeEqual(a: string, b: string): boolean { return diff === 0; } +/* + * What kind of value this is, asked in a way that does not depend on the realm + * the value was made in. A record parsed by another frame's `JSON.parse`, an + * options object built there, a `Uint8Array` or a `Set` handed across: each has + * that frame's prototype, which is a different object from this module's with the + * same role, so `instanceof` and a comparison with `Object.prototype` call them + * all foreign. The three tests below read the shape or an internal slot instead. + */ + +/** + * True for an object written as `{...}` or made by `Object.create(null)`, in any + * realm: it has no prototype, or a prototype that has none of its own, which is + * what `Object.prototype` is everywhere. An array, a `Date`, a class instance + * and `Object.create({})` each have a longer chain. + */ export function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false; } - const proto = Object.getPrototypeOf(value) as unknown; - return proto === Object.prototype || proto === null; + const proto = Object.getPrototypeOf(value) as object | null; + return proto === null || Object.getPrototypeOf(proto) === null; +} + +// The %TypedArray%.prototype[@@toStringTag] getter reads the [[TypedArrayName]] +// slot: the constructor's name for a typed array of any realm, and undefined for +// everything else, an object carrying its own Symbol.toStringTag included. +const typedArrayName = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype) as object, + Symbol.toStringTag, +)?.get as ((this: unknown) => string | undefined) | undefined; + +/** True for a `Uint8Array` of any realm, Node's `Buffer` included, and nothing else. */ +export function isUint8Array(value: unknown): value is Uint8Array { + return typedArrayName !== undefined && typedArrayName.call(value) === "Uint8Array"; +} + +// The `size` getter throws unless its receiver has [[SetData]], which a Set of +// any realm has and nothing else does. +const setSize = Object.getOwnPropertyDescriptor(Set.prototype, "size")?.get as + | ((this: unknown) => number) + | undefined; + +/** True for a `Set` of any realm and nothing else. */ +export function isSet(value: unknown): value is ReadonlySet { + if (setSize === undefined) { + return false; + } + try { + setSize.call(value); + return true; + } catch { + return false; + } } /** Own-property read, so an inherited member never stands in for an absent one. */ diff --git a/trust-record-verify/src/verify.ts b/trust-record-verify/src/verify.ts index c18153be..dc2256d8 100644 --- a/trust-record-verify/src/verify.ts +++ b/trust-record-verify/src/verify.ts @@ -44,7 +44,7 @@ import { canonicalize } from "./jcs.js"; import { keyIdentifiers } from "./jwk.js"; import { checkRevocationBundle, NO_CHECK, type RevocationCheck } from "./revocation.js"; import { recordSchemaViolations } from "./schema.js"; -import { isPlainObject, own, timingSafeEqual } from "./text.js"; +import { isPlainObject, isSet, own, timingSafeEqual } from "./text.js"; /** The profile URI this build implements, and the only one `verifyRecord` accepts. */ export const TRACE_PROFILE_V0_2 = "tag:agentrust-io.com,2026:trace-v0.2"; @@ -156,7 +156,7 @@ function settings(options: unknown): Settings { if ( revocation !== undefined && typeof revocation !== "function" && - !(revocation instanceof Set) && + !isSet(revocation) && !(Array.isArray(revocation) && revocation.every((item) => typeof item === "string")) ) { fail("invalid_argument", "revocation must be a Set or array of key identifiers, or a lookup function"); @@ -209,7 +209,7 @@ async function checkStore(identifiers: readonly string[], store: RevocationStore "not a boolean, so it has not answered", ); } - } else if (store instanceof Set) { + } else if (isSet(store)) { revoked = store.has(identifier); } else { revoked = (store as readonly string[]).includes(identifier); diff --git a/trust-record-verify/tests/units.test.mjs b/trust-record-verify/tests/units.test.mjs index d65697e8..d617cdc8 100644 --- a/trust-record-verify/tests/units.test.mjs +++ b/trust-record-verify/tests/units.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import test from "node:test"; +import vm from "node:vm"; import { canonicalJson, @@ -85,6 +86,23 @@ test("encodeBase64url takes a Uint8Array and nothing else that views a buffer", } }); +test("encodeBase64url takes a Uint8Array made in another realm, and still nothing else", () => { + const refused = (error) => error instanceof TraceVerificationError && error.code === "invalid_argument"; + // Another realm has its own Uint8Array, as a frame or a worker does in a + // browser, so `instanceof` against this realm's constructor is false for it. + const foreign = vm.runInNewContext("new Uint8Array([1, 2, 3])"); + assert.equal(foreign instanceof Uint8Array, false); + assert.equal(encodeBase64url(foreign), "AQID"); + for (const source of ["new Uint16Array([1, 2])", "new DataView(new ArrayBuffer(4))", "new ArrayBuffer(3)", "[1, 2, 3]"]) { + assert.throws(() => encodeBase64url(vm.runInNewContext(source)), refused, source); + } + // An object that only says it is one: the tag is its own property, and it + // iterates, so a test on `Object.prototype.toString` would encode it. + const impostor = { [Symbol.toStringTag]: "Uint8Array", length: 3, *[Symbol.iterator]() { yield* [1, 2, 3]; } }; + assert.equal(Object.prototype.toString.call(impostor), "[object Uint8Array]"); + assert.throws(() => encodeBase64url(impostor), refused); +}); + test("verifyDelegationLink takes a plain options object, as verifyRecord does", async () => { class Options {} for (const options of [[], new Date(0), new Options(), Object.create({ supportedDigestAlgorithms: [] }), null, "sha256", 1]) { @@ -92,6 +110,22 @@ test("verifyDelegationLink takes a plain options object, as verifyRecord does", } }); +test("a plain object made in another realm is a plain object", async () => { + // `{}` from another realm inherits from that realm's Object.prototype. The + // options are accepted, so the call goes on to the child record and reports + // what is wrong with that: it has no delegation block. + const foreign = vm.runInNewContext("({})"); + assert.notEqual(Object.getPrototypeOf(foreign), Object.prototype); + await rejects(verifyDelegationLink({}, {}, foreign), "delegation_absent"); + await rejects(verifyDelegationLink(vm.runInNewContext("({})"), {}, {}), "delegation_absent"); + assert.equal(canonicalJson(vm.runInNewContext('({ b: 1, a: { c: [true, null] } })')), '{"a":{"c":[true,null]},"b":1}'); + // What was refused in this realm is refused from the other one too. + for (const source of ["[]", "new Date(0)", "new (class Options {})()", "Object.create({ supportedDigestAlgorithms: [] })"]) { + await rejects(verifyDelegationLink({}, {}, vm.runInNewContext(source)), "invalid_argument"); + } + assert.throws(() => canonicalJson(vm.runInNewContext("new Date(0)")), TraceVerificationError); +}); + test("base64url decodes canonical input and refuses everything else", () => { assert.deepEqual([...decodeBase64url("")], []); assert.deepEqual([...decodeBase64url("AQID")], [1, 2, 3]); diff --git a/trust-record-verify/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs index e7a00139..88e21f90 100644 --- a/trust-record-verify/tests/vectors.test.mjs +++ b/trust-record-verify/tests/vectors.test.mjs @@ -10,6 +10,7 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import vm from "node:vm"; import { parentRecordHash, TraceVerificationError, verifyRecord } from "../dist/index.js"; @@ -181,3 +182,33 @@ test("the signature's encoding is judged before the schema, as the reference jud return true; }); }); + +test("a record, its options and a revocation set made in another realm verify as they do here", async () => { + const { vector, options, result } = await aVerifyingVector(); + // Another realm's JSON.parse returns objects and arrays of that realm, which is + // what a record read in a frame or a worker is to the code that verifies it. + const parse = vm.runInNewContext("JSON.parse"); + const record = parse(JSON.stringify(vector.record)); + const foreignOptions = parse(JSON.stringify(options)); + assert.notEqual(Object.getPrototypeOf(record), Object.prototype); + const foreign = await verifyRecord(record, foreignOptions); + assert.deepEqual(foreign, result); + + const makeSet = vm.runInNewContext("(items) => new Set(items)"); + const unrelated = makeSet(["not-this-key"]); + assert.equal(unrelated instanceof Set, false); + const here = await verifyRecord(vector.record, { ...options, revocation: new Set(["not-this-key"]) }); + assert.equal(here.revocation.outcome, "verified"); + assert.deepEqual(await verifyRecord(vector.record, { ...options, revocation: unrelated }), here); + const revoking = makeSet([result.trustedKeyThumbprint]); + assert.deepEqual(await outcome(verifyRecord(vector.record, { ...options, revocation: revoking })), { + rejected: true, + code: "key_revoked", + }); + // An object that answers `has` is not a Set, here or there. + const impostor = { [Symbol.toStringTag]: "Set", has: () => false, size: 0 }; + assert.deepEqual(await outcome(verifyRecord(vector.record, { ...options, revocation: impostor })), { + rejected: true, + code: "invalid_argument", + }); +}); From 8af1510bc775adf8781ccdec6c0aa21f612a0e45 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:43:00 +0300 Subject: [PATCH 21/22] fix(trust-record-verify): own-member reads, a stricter plain-object test, profile on the result, symbol keys on arrays Four points from review of 0655596. isPlainObject accepted Object.create(Object.create(null)): a prototype with a null prototype is not Object.prototype. It now accepts a null prototype or a realm's Object.prototype, recognised by its own constructor being that realm's Object, and nothing else; cross-realm {} and Object.create(null) stay accepted. verifyRecord read cnf, iat and runtime through the prototype chain while the signature pre-image is built from own members; every read is now own(), and an inherited cnf or iat stands for nothing. Tests cover the one-hop prototype in both realms, and a record from a realm whose Object.prototype carries a non-enumerable iat or cnf, which the schema sees and verification does not. VerificationResult reports profile and the complete acceptedProfiles set, as section 3.3 requires; ACCEPTED_PROFILES is exported. Both runners of the differential emit the two fields and compare.py folds them into the verified equivalence. canonicalJson refused a symbol-keyed member on an object and not on an array; the array branch now refuses it too, with a test. The differential against current main: 1643 of 1644 identical. The ledger entry for iat written as a float or with an exponent is unreached since #404 and leaves the ledger; README, CHANGELOG and the ledger note carry the figures. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- CHANGELOG.md | 2 +- trust-record-verify/README.md | 25 +++++--- trust-record-verify/differential/compare.py | 2 + .../differential/known-divergences.json | 63 ++++++------------- trust-record-verify/differential/oracle.py | 2 + trust-record-verify/differential/run.mjs | 2 + trust-record-verify/src/index.ts | 1 + trust-record-verify/src/jcs.ts | 3 + trust-record-verify/src/text.ts | 24 +++++-- trust-record-verify/src/verify.ts | 34 ++++++++-- trust-record-verify/tests/units.test.mjs | 22 +++++++ trust-record-verify/tests/vectors.test.mjs | 50 ++++++++++++++- 12 files changed, 164 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffc4119e..53d663c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] -- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record, and the differential compares the location of a schema failure and the source of the verifying key as part of the verdict. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1611 agree exactly, every published vector among them, and the remaining 33 are 2 divergence classes argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. The first run scored 1280 identical and 13 classes, and eleven of those have left the ledger, each as an entry no case reached. Six were defects the harness surfaced in the reference, fixed before this landed (#379 by #388, #380 by #387, #381 by #383, #382 by #386, and a present but falsy `signature` by #390). Four were base64url that is not canonical, which this implementation refused from the start and the reference's decoder refuses since 0.11.0 (#418): non-zero unused bits in a signature, which the reference used to verify and which section 3.1.3 makes visible in the chain digest, a signature truncated to a still-valid length, and a non-canonical `x` in the caller's key or in `cnf.jwk`. The eleventh, a signature with a trailing newline, was a difference in order: the reference decodes the signature before it consults the schema, and this implementation now does the same, so both report `signature_malformed` for it, for a padded signature and for one in the standard alphabet. The 2 that remain are the JSON number model: `iat` written `1785000000.0` is an integer after `JSON.parse` and a float after `json.loads`, and `1.0e+21` has an RFC 8785 form on one side and none on the other. That move in the order of two checks is the one adjustment made to meet the other side; no verdict was changed to match. Contributed by @chernistry. +- **A second, independent verifier: `@agentrust-io/trust-record-verify`, TypeScript on WebCrypto alone.** `ROADMAP.md` lists multi-language verification libraries, and the reason is not coverage for its own sake: a specification with one implementation says whatever that implementation does. `trust-record-verify/` is a verifier for section 3.2.2 records written from the specification text and `schema/`, with no network, no filesystem, no platform module and no runtime dependency, so a browser, an edge worker or a sidecar can check a record where there is no Python runtime. It implements the profile cutover of section 2 (the v0.1 identifier is rejected with its own code rather than falling through to `profile_unsupported`), RFC 8785 canonicalization including the safe-integer MUST of section 3.2.2 and the I-JSON rule that a lone surrogate has no canonical form, Ed25519 over the pre-image with `signature` absent, canonical unpadded base64url (RFC 4648 sections 5 and 3.5), RFC 7638 thumbprints, the confirmation-key binding, freshness and nonce, the three-state revocation outcome of section 3.2.3 against a passed-in bundle, and the chain digest of section 3.1.3 over the complete parent record. The schemas are compiled ahead of time with ajv (Draft 2020-12), so `pattern` is read as ECMA-262 as the dialect requires and `format: uri` is asserted rather than annotated; a test pins the SHA-256 of every schema file the validators were built from against `schema/`. The check order is the Python implementation's, so both report the same first failure for the same record, and the differential compares the location of a schema failure, the source of the verifying key, and the profile and complete accepted set the result reports (section 3.3) as part of the verdict. **A differential harness scores the two against each other**: 1644 cases over a signed record under 24 verifier configurations with 59 mutations each, an RFC 8785 corpus, RFC 7638 thumbprints, chain digests over the whole delegation-link corpus, this repository's own vectors and the published conformance vectors. 1643 agree exactly, every published vector among them, and the remaining 1 is a divergence class argued in `differential/known-divergences.json`, matched on both the case and the pair of reported reasons so a new disagreement inside a listed family is reported rather than absorbed, and pinned in the other direction too: each entry declares how many cases land on it, and an entry, a pair or a count the run does not bear out fails it, so a divergence that stops happening on either side has to leave the ledger. CI reads the published conformance vectors from a pinned checkout and asserts their case count. The first run scored 1280 identical and 13 classes, and twelve of those have left the ledger, each as an entry no case reached. Six were defects the harness surfaced in the reference, fixed before this landed (#379 by #388, #380 by #387, #381 by #383, #382 by #386, and a present but falsy `signature` by #390). Four were base64url that is not canonical, which this implementation refused from the start and the reference's decoder refuses since 0.11.0 (#418): non-zero unused bits in a signature, which the reference used to verify and which section 3.1.3 makes visible in the chain digest, a signature truncated to a still-valid length, and a non-canonical `x` in the caller's key or in `cnf.jwk`. The eleventh, a signature with a trailing newline, was a difference in order: the reference decodes the signature before it consults the schema, and this implementation now does the same, so both report `signature_malformed` for it, for a padded signature and for one in the standard alphabet. The twelfth, `iat` written `1785000000.0`, an integer after `JSON.parse` and a float after `json.loads`, left when the reference began to decide integer-typed members by value (#404). The 1 that remains is the JSON number model: `1.0e+21` has an RFC 8785 form on one side and none on the other. That move in the order of two checks is the one adjustment made to meet the other side; no verdict was changed to match. Contributed by @chernistry. - **`verify_record` reports what a caller-supplied appraiser found about `runtime.measurement`, per layer (#279 platform-measurement row, #431).** A matching composite measurement says diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md index b72ed79d..16dc17a5 100644 --- a/trust-record-verify/README.md +++ b/trust-record-verify/README.md @@ -24,6 +24,8 @@ const result = await verifyRecord(record, { trustedBundleKeys: [logJwk], }); +result.profile; // the record's eat_profile, accepted by this verifier +result.acceptedProfiles; // every profile this verifier accepts, in full (section 3.3) result.trustedKeyThumbprint; // RFC 7638 thumbprint of the verifying key result.revocation.outcome; // "verified" | "unverified_for_revocation" | "no_check_performed" ``` @@ -111,7 +113,8 @@ reason: the same failure code, and for a schema failure the same member, since t order is shared and the first member faulted must be the same one; for a record that verifies, the same key thumbprint, the same source of that key (the caller's or the record's own `cnf.jwk`, which the thumbprint alone cannot tell apart when they are the same -key) and the same revocation outcome, cause and evidence. Every disagreement is argued in +key), the same profile and complete accepted set (section 3.3), and the same revocation +outcome, cause and evidence. Every disagreement is argued in [`differential/known-divergences.json`](differential/known-divergences.json), matched on both the case and the pair of reported reasons, so a new disagreement in a family already listed is reported rather than absorbed. The ledger holds in the other direction too: each @@ -123,25 +126,23 @@ Latest run, against this repository's implementation at the commit under test (i | Group | Cases | Identical | |---|---:|---:| -| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1384 | +| Signed record under 24 verifier configurations, 59 record mutations each | 1416 | 1416 | | RFC 8785 canonicalization corpus | 34 | 33 | | RFC 7638 thumbprints | 14 | 14 | | Chain digests over the delegation corpus | 134 | 134 | | This repository's conformance vectors | 34 | 34 | | Published conformance vectors | 12 | 12 | -| **Total** | **1644** | **1611** | +| **Total** | **1644** | **1643** | -Every published vector agrees. The 33 remaining cases are the ledger's 2 entries, each -pinned to the number of cases that land on it. Both are the JSON number model, which the -two languages hold differently: one lands in the configurations that reach the `iat` -check, and one is a single case in the RFC 8785 corpus. +Every published vector agrees. The 1 remaining case is the ledger's 1 entry, pinned to +the number of cases that land on it: the JSON number model, which the two languages hold +differently, in a single case in the RFC 8785 corpus. | Ledger entry | Cases | Where | |---|---:|---| -| `iat` written `1785000000.0` or `1.785e9` (`m20`, `m21`) | 32 | mutation matrix, both spellings in the 16 configurations that reach the `iat` check | | `1.0e+21`: a float in Python, an integer past 2^53 here (`jcs-10`) | 1 | RFC 8785 corpus | -Eleven entries have left the ledger, each reported by the harness as an entry no case +Twelve entries have left the ledger, each reported by the harness as an entry no case reached, which is how a divergence that stopped is meant to leave. Six went when the reference stopped diverging after 0.10.0: @@ -174,6 +175,12 @@ schema, to where the reference has it. Both now report `signature_malformed` for three, and the empty string, which decodes to no bytes, is still refused by the schema's pattern on both sides. +The twelfth went with #404, which has the reference decide integer-typed members by value: +`iat` written `1785000000.0` or `1.785e9` (`m20`, `m21`) is an integer after `JSON.parse` +and a float after `json.loads`, and the reference required an `int`. It now accepts a whole +number however it is written, as this implementation always had, so the 32 cases that +landed on that entry agree. + The differential compares verdicts, so it is silent on anything that does not change a verdict. Whether `timingSafeEqual` is constant-time is not observable in a verdict at all, and `codePointLength` cannot disagree with `value.length` on anything the current schema diff --git a/trust-record-verify/differential/compare.py b/trust-record-verify/differential/compare.py index dc4e4b26..a9922127 100644 --- a/trust-record-verify/differential/compare.py +++ b/trust-record-verify/differential/compare.py @@ -59,6 +59,8 @@ def summarise(verdict: dict[str, Any]) -> tuple: revocation = verdict.get("revocation") or {} return ( "verified", + verdict.get("profile"), + json.dumps(sorted(verdict.get("accepted_profiles") or [])), verdict.get("thumbprint"), verdict.get("key_source"), revocation.get("outcome"), diff --git a/trust-record-verify/differential/known-divergences.json b/trust-record-verify/differential/known-divergences.json index bcd81e64..d72afd8a 100644 --- a/trust-record-verify/differential/known-divergences.json +++ b/trust-record-verify/differential/known-divergences.json @@ -1,47 +1,20 @@ { - "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. Entries are tried in order and a case lands on the first that matches; count is how many cases land on the entry in the committed corpus, and compare.py fails when an entry, a pair or a count is not borne out by the run, so a divergence that stops happening has to leave the ledger. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", - "reference": "agentrust-trace, this repository's implementation at the commit under test; its version string reads 0.11.0. Eleven entries have left this ledger, each as an entry no case reached. Six went when #383, #386, #387, #388 and #390 landed after 0.10.0. Four went with 0.11.0, whose decoder refuses base64url that is not canonical (#418): a signature with non-zero unused bits, a signature truncated to a still-valid length, and a non-canonical x in the caller's key or in cnf.jwk. The eleventh, a signature with a trailing newline, went when this implementation's signature decoding moved ahead of its schema check, which is where the reference decodes.", - "divergences": [ - { - "ids": [ - "*/m20-iat-float-form", - "*/m21-iat-exponent-form" - ], - "count": 32, - "classification": "language-level", - "reason": "iat is written 1785000000.0 or 1.785e9. JSON has one number type and JavaScript has one number type, so after JSON.parse the value is indistinguishable from the integer, its RFC 8785 form is the same bytes, and the record verifies here. Python's json module preserves the distinction as float, and the reference requires an int. A verifier that holds the record text can reject the spelling before parsing; one that is handed a parsed value cannot. Worth stating in the specification rather than leaving to each language.", - "pairs": [ - [ - "iat_invalid", - "nonce_mismatch" - ], - [ - "iat_invalid", - "record_in_future" - ], - [ - "iat_invalid", - "record_stale" - ], - [ - "iat_invalid", - "verified" - ] - ] - }, - { - "ids": [ - "jcs-10" - ], - "count": 1, - "classification": "language-level", - "reason": "The value is 1.0e+21. In Python that is a float, and RFC 8785 gives it the form 1e+21. In JavaScript it is an integer beyond 2^53-1, where section 3.2.2 requires rejection because two distinct integers share one canonical form. A number written with an exponent cannot be told from a float in a language with one number type, so the two rules meet here. The same value inside a record would be rejected by this implementation and canonicalised by the reference.", - "pairs": [ - [ - "canonical", - "canonicalization_failed" - ] - ] - } - ] + "note": "Every entry is a case where the two implementations disagree, with the reason argued and the pair of reported outcomes pinned. An entry matches only when both the case and the pair match, so a new disagreement in the same family is reported rather than absorbed. Entries are tried in order and a case lands on the first that matches; count is how many cases land on the entry in the committed corpus, and compare.py fails when an entry, a pair or a count is not borne out by the run, so a divergence that stops happening has to leave the ledger. classification is one of: reference-defect (this implementation follows the specification and the reference does not), stricter-here (both reject and the stated reason differs because this implementation refuses a non-canonical encoding earlier), language-level (the two languages' number models differ and the specification does not settle it).", + "reference": "agentrust-trace, this repository's implementation at the commit under test; its version string reads 0.11.0. Twelve entries have left this ledger, each as an entry no case reached. Six went when #383, #386, #387, #388 and #390 landed after 0.10.0. Four went with 0.11.0, whose decoder refuses base64url that is not canonical (#418): a signature with non-zero unused bits, a signature truncated to a still-valid length, and a non-canonical x in the caller's key or in cnf.jwk. The eleventh, a signature with a trailing newline, went when this implementation's signature decoding moved ahead of its schema check, which is where the reference decodes. The twelfth, iat written 1785000000.0 or 1.785e9, went when the reference began to decide integer-typed members by value (#404), as this implementation always had.", + "divergences": [ + { + "ids": [ + "jcs-10" + ], + "count": 1, + "classification": "language-level", + "reason": "The value is 1.0e+21. In Python that is a float, and RFC 8785 gives it the form 1e+21. In JavaScript it is an integer beyond 2^53-1, where section 3.2.2 requires rejection because two distinct integers share one canonical form. A number written with an exponent cannot be told from a float in a language with one number type, so the two rules meet here. The same value inside a record would be rejected by this implementation and canonicalised by the reference.", + "pairs": [ + [ + "canonical", + "canonicalization_failed" + ] + ] + } + ] } diff --git a/trust-record-verify/differential/oracle.py b/trust-record-verify/differential/oracle.py index f43eed4f..55324470 100644 --- a/trust-record-verify/differential/oracle.py +++ b/trust-record-verify/differential/oracle.py @@ -210,6 +210,8 @@ def run_verify(case: dict[str, Any]) -> dict[str, Any]: return {"verdict": "rejected", **classify(exc)} return { "verdict": "verified", + "profile": result.profile, + "accepted_profiles": list(result.accepted_profiles), "thumbprint": result.trusted_key_thumbprint, # Which key verified the record. The result carries no such member, so # this states it the way verify_record resolves it: the caller's key when diff --git a/trust-record-verify/differential/run.mjs b/trust-record-verify/differential/run.mjs index 13a88748..725df083 100644 --- a/trust-record-verify/differential/run.mjs +++ b/trust-record-verify/differential/run.mjs @@ -91,6 +91,8 @@ async function runVerify(item) { const result = await verifyRecord(record, settings); return { verdict: "verified", + profile: result.profile, + accepted_profiles: [...result.acceptedProfiles], thumbprint: result.trustedKeyThumbprint, key_source: result.trustedKeySource, revocation: { diff --git a/trust-record-verify/src/index.ts b/trust-record-verify/src/index.ts index b824a346..96cbd98e 100644 --- a/trust-record-verify/src/index.ts +++ b/trust-record-verify/src/index.ts @@ -37,6 +37,7 @@ export { type SchemaViolation, } from "./schema.js"; export { + ACCEPTED_PROFILES, TRACE_PROFILE_V0_2, verifyRecord, type RevocationStore, diff --git a/trust-record-verify/src/jcs.ts b/trust-record-verify/src/jcs.ts index 226dc1a4..fe0a9650 100644 --- a/trust-record-verify/src/jcs.ts +++ b/trust-record-verify/src/jcs.ts @@ -112,6 +112,9 @@ function serialize( } ancestors.add(node); if (Array.isArray(node)) { + if (Object.getOwnPropertySymbols(node).length > 0) { + fail("canonicalization_failed", `${describe(path)} has a symbol-keyed member, which has no JSON form`); + } parts.push("["); for (let i = 0; i < node.length; i++) { if (!Object.hasOwn(node, i)) { diff --git a/trust-record-verify/src/text.ts b/trust-record-verify/src/text.ts index f9df8e49..54508970 100644 --- a/trust-record-verify/src/text.ts +++ b/trust-record-verify/src/text.ts @@ -94,18 +94,34 @@ export function timingSafeEqual(a: string, b: string): boolean { * all foreign. The three tests below read the shape or an internal slot instead. */ +// Each realm has its own `Object`, and all of them print the same source text. +const objectConstructorSource = Function.prototype.toString.call(Object); + /** * True for an object written as `{...}` or made by `Object.create(null)`, in any - * realm: it has no prototype, or a prototype that has none of its own, which is - * what `Object.prototype` is everywhere. An array, a `Date`, a class instance - * and `Object.create({})` each have a longer chain. + * realm, and for nothing else: its prototype is null, or it is a realm's + * `Object.prototype`, which has no prototype of its own and whose own + * `constructor` is that realm's `Object`. An array, a `Date`, a class instance, + * `Object.create({})` and `Object.create(Object.create(null))` are each refused; + * the last has a null-prototype prototype, which is not `Object.prototype`. */ export function isPlainObject(value: unknown): value is Record { if (value === null || typeof value !== "object" || Array.isArray(value)) { return false; } const proto = Object.getPrototypeOf(value) as object | null; - return proto === null || Object.getPrototypeOf(proto) === null; + if (proto === null) { + return true; + } + if (Object.getPrototypeOf(proto) !== null) { + return false; + } + const constructor = Object.getOwnPropertyDescriptor(proto, "constructor")?.value as unknown; + return ( + typeof constructor === "function" && + constructor.prototype === proto && + Function.prototype.toString.call(constructor) === objectConstructorSource + ); } // The %TypedArray%.prototype[@@toStringTag] getter reads the [[TypedArrayName]] diff --git a/trust-record-verify/src/verify.ts b/trust-record-verify/src/verify.ts index dc2256d8..033cfa62 100644 --- a/trust-record-verify/src/verify.ts +++ b/trust-record-verify/src/verify.ts @@ -49,6 +49,9 @@ import { isPlainObject, isSet, own, timingSafeEqual } from "./text.js"; /** The profile URI this build implements, and the only one `verifyRecord` accepts. */ export const TRACE_PROFILE_V0_2 = "tag:agentrust-io.com,2026:trace-v0.2"; +/** Every profile this build accepts, reported whole on each result (section 3.3). */ +export const ACCEPTED_PROFILES: readonly string[] = Object.freeze([TRACE_PROFILE_V0_2]); + /** The superseded v0.1 identifier, named only so its rejection can say why. */ const TRACE_PROFILE_V0_1 = "tag:agentrust.io,2026:trace-v0.1"; @@ -96,6 +99,10 @@ export interface VerifyOptions { } export interface VerificationResult { + /** The `eat_profile` the record carries, which this verifier accepted. */ + readonly profile: string; + /** The complete set of profiles this verifier accepts, `ACCEPTED_PROFILES`. */ + readonly acceptedProfiles: readonly string[]; /** What the revocation check reported. Only `verified` is a revocation check that passed. */ readonly revocation: RevocationCheck; /** RFC 7638 thumbprint of the key the signature was verified against. */ @@ -279,9 +286,17 @@ export async function verifyRecord(record: unknown, options?: VerifyOptions): Pr }); } - // From here the schema holds: cnf.jwk is an object and iat an integer in range. - const cnf = record["cnf"] as Record; - const embeddedJwk = cnf["jwk"]; + // From here the schema holds for the members it saw. A schema validator reads + // through the prototype chain; the signature pre-image is built from own + // members only, so every member read from here on is own, and an inherited + // `cnf` or `iat` stands for nothing. + const cnf = own(record, "cnf"); + if (!isPlainObject(cnf)) { + fail("schema_invalid", "the record does not conform to the TRACE v0.2 schema at cnf: not a plain object of the record's own", { + path: "cnf", + }); + } + const embeddedJwk = own(cnf, "jwk"); let trustedJwk: unknown; let trustedKeySource: "caller" | "record"; @@ -329,7 +344,7 @@ export async function verifyRecord(record: unknown, options?: VerifyOptions): Pr fail("cnf_key_mismatch", "the record's cnf.jwk does not identify the trusted key that verifies its signature"); } - const iat = record["iat"]; + const iat = own(record, "iat"); if (typeof iat !== "number" || !Number.isSafeInteger(iat)) { fail("iat_invalid", "the record has no valid integer iat for the freshness check"); } @@ -344,7 +359,8 @@ export async function verifyRecord(record: unknown, options?: VerifyOptions): Pr fail("record_stale", `the record is ${age}s old, past the maximum age of ${s.maxAgeSeconds}s`); } if (s.expectedNonce !== undefined) { - const nonce = own(record["runtime"] as Record, "nonce"); + const runtime = own(record, "runtime"); + const nonce = isPlainObject(runtime) ? own(runtime, "nonce") : undefined; if (typeof nonce !== "string" || !timingSafeEqual(nonce, s.expectedNonce)) { fail("nonce_mismatch", "the record's runtime.nonce does not match the expected nonce"); } @@ -364,5 +380,11 @@ export async function verifyRecord(record: unknown, options?: VerifyOptions): Pr fail("signature_invalid", "the signature does not verify over the record's RFC 8785 form"); } - return { revocation, trustedKeyThumbprint: trustedIds[0] as string, trustedKeySource }; + return { + profile, + acceptedProfiles: ACCEPTED_PROFILES, + revocation, + trustedKeyThumbprint: trustedIds[0] as string, + trustedKeySource, + }; } diff --git a/trust-record-verify/tests/units.test.mjs b/trust-record-verify/tests/units.test.mjs index d617cdc8..0c936a1d 100644 --- a/trust-record-verify/tests/units.test.mjs +++ b/trust-record-verify/tests/units.test.mjs @@ -193,3 +193,25 @@ for (const [label, signature] of [ test("an empty signature string reaches schema validation", async () => { await rejects(verifyRecord({ eat_profile: TRACE_PROFILE_V0_2, signature: "" }), "schema_invalid"); }); + +test("canonicalJson refuses a symbol-keyed member on an array as it does on an object", () => { + const sym = Symbol("s"); + const refused = (error) => + error instanceof TraceVerificationError && error.code === "canonicalization_failed"; + assert.throws(() => canonicalJson(Object.assign([1, 2], { [sym]: 3 })), refused); + assert.throws(() => canonicalJson({ a: Object.assign([], { [sym]: 1 }) }), refused); + assert.equal(canonicalJson([1, 2]), "[1,2]"); +}); + +test("a plain object is one whose prototype is null or a realm's Object.prototype, and nothing else", () => { + const refused = (error) => + error instanceof TraceVerificationError && error.code === "canonicalization_failed"; + assert.equal(canonicalJson(Object.create(null)), "{}"); + assert.equal(canonicalJson(vm.runInNewContext("({ a: 1 })")), '{"a":1}'); + assert.equal(canonicalJson(vm.runInNewContext("Object.create(null)")), "{}"); + // One hop past a null prototype is not Object.prototype, in any realm. + assert.throws(() => canonicalJson(Object.create(Object.create(null))), refused); + assert.throws(() => canonicalJson(vm.runInNewContext("Object.create(Object.create(null))")), refused); + assert.throws(() => canonicalJson(Object.create({})), refused); + assert.throws(() => canonicalJson(new (class Record {})()), refused); +}); diff --git a/trust-record-verify/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs index 88e21f90..1d3bb836 100644 --- a/trust-record-verify/tests/vectors.test.mjs +++ b/trust-record-verify/tests/vectors.test.mjs @@ -12,7 +12,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; -import { parentRecordHash, TraceVerificationError, verifyRecord } from "../dist/index.js"; +import { ACCEPTED_PROFILES, parentRecordHash, TRACE_PROFILE_V0_2, TraceVerificationError, verifyRecord } from "../dist/index.js"; const EXAMPLES = join(dirname(dirname(dirname(fileURLToPath(import.meta.url)))), "examples"); @@ -212,3 +212,51 @@ test("a record, its options and a revocation set made in another realm verify as code: "invalid_argument", }); }); + +function verifying() { + const found = vectors("canonicalization-boundary").find(({ vector }) => vector.expected.outcome === "verified"); + assert.ok(found, "no verifying vector in canonicalization-boundary"); + return found.vector; +} + +test("a verified result reports the profile and the complete accepted set (section 3.3)", async () => { + const vector = verifying(); + const result = await verifyRecord(vector.record, { + trustedKey: vector.trusted_key, + now: vector.record.iat, + maxAgeSeconds: null, + }); + assert.equal(result.profile, TRACE_PROFILE_V0_2); + assert.deepEqual([...result.acceptedProfiles], [...ACCEPTED_PROFILES]); + assert.deepEqual([...ACCEPTED_PROFILES], [TRACE_PROFILE_V0_2]); + assert.ok(Object.isFrozen(ACCEPTED_PROFILES)); +}); + +test("an inherited cnf or iat does not stand in for an absent one", async (t) => { + // A record from another realm is a plain object; that realm's Object.prototype + // is polluted with the member, so a prototype-chain read finds it and an own + // read does not. The schema reads through the chain; verification must not. + const vector = verifying(); + // Non-enumerable, so the pollution is invisible to for-in and the schema's + // additionalProperties check on nested objects; only a chain read finds it. + const inherit = vm.runInNewContext( + "(json, member) => { const r = JSON.parse(json); " + + "Object.defineProperty(Object.prototype, member, { value: r[member], configurable: true }); " + + "delete r[member]; return r; }", + ); + const options = { trustedKey: vector.trusted_key, now: vector.record.iat, maxAgeSeconds: null }; + for (const [member, code] of [["iat", "iat_invalid"], ["cnf", "schema_invalid"]]) { + await t.test(member, async () => { + const record = inherit(JSON.stringify(vector.record), member); + assert.equal(record[member] !== undefined, true, "the member is reachable through the chain"); + assert.equal(Object.hasOwn(record, member), false, "and is not own"); + await assert.rejects( + verifyRecord(record, options), + (error) => + error instanceof TraceVerificationError && + error.code === code && + (code !== "schema_invalid" || error.path === member), + ); + }); + } +}); From ec42b2e8cd761121667b8b461f8907b0190cad75 Mon Sep 17 00:00:00 2001 From: Alex Chernysh <73943355+chernistry@users.noreply.github.com> Date: Mon, 12 Oct 2026 07:17:43 +0300 Subject: [PATCH 22/22] fix(trust-record-verify): compile the schema validators with ownProperties Review of e4300b7: the signature pre-image is built from a record's own members, while ajv's required and properties tests and its for-in walks also saw inherited ones. cnf, iat and runtime were read with own(); the other required members (subject, model, policy and the rest) were not held to the same rule by the schema. The validators are now generated with ownProperties: true, so required and properties test own properties and every object walk uses Object.keys. A regression test moves each member of a signed record onto a cross-realm Object.prototype, enumerable and not: a required member is schema_invalid, eat_profile is profile_missing, an optional member falls outside the pre-image and is signature_invalid, and cnf.jwk one level down is schema_invalid at cnf. Without the option 11 of those cases fail. Differential against current main: 1643 of 1644 identical, unchanged. Signed-off-by: Alex Chernysh <73943355+chernistry@users.noreply.github.com> --- trust-record-verify/README.md | 5 ++ .../scripts/generate-validators.mjs | 5 ++ trust-record-verify/tests/vectors.test.mjs | 56 +++++++++++-------- 3 files changed, 44 insertions(+), 22 deletions(-) diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md index 16dc17a5..8c0fb065 100644 --- a/trust-record-verify/README.md +++ b/trust-record-verify/README.md @@ -90,6 +90,11 @@ the SHA-256 of each schema file it read. A test compares those digests against ` so the validators in a build are known to come from exactly those bytes: a build made against another schema directory (`TRACE_SCHEMA_DIR`) fails that test. +The validators are compiled with `ownProperties`, so the schema judges a record's own +members, the same members the signature pre-image is built from. A member a record only +inherits, from any realm's prototype, is absent to both: a required one fails the schema, +an optional one is outside what was signed. + ## The differential harness ``` diff --git a/trust-record-verify/scripts/generate-validators.mjs b/trust-record-verify/scripts/generate-validators.mjs index c9d70a41..2f200ac4 100644 --- a/trust-record-verify/scripts/generate-validators.mjs +++ b/trust-record-verify/scripts/generate-validators.mjs @@ -27,6 +27,11 @@ const digests = Object.fromEntries( const ajv = new Ajv2020.default({ schemas: files.map((name) => schemas[name]), allErrors: true, + // The signature pre-image is built from a record's own members, so the + // schema must judge the same members: `required` and `properties` test own + // properties and every object walk uses Object.keys, never for-in. An + // inherited member, cross-realm or not, is absent to both. + ownProperties: true, // The schemas use if/then without a sibling "type" and union types in // canonicalizableValue; both are ordinary Draft 2020-12 and not errors. strict: true, diff --git a/trust-record-verify/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs index 1d3bb836..fe8e3f6e 100644 --- a/trust-record-verify/tests/vectors.test.mjs +++ b/trust-record-verify/tests/vectors.test.mjs @@ -232,31 +232,43 @@ test("a verified result reports the profile and the complete accepted set (secti assert.ok(Object.isFrozen(ACCEPTED_PROFILES)); }); -test("an inherited cnf or iat does not stand in for an absent one", async (t) => { +test("an inherited member does not stand in for an absent one, at any depth", async (t) => { // A record from another realm is a plain object; that realm's Object.prototype - // is polluted with the member, so a prototype-chain read finds it and an own - // read does not. The schema reads through the chain; verification must not. + // carries the member, so a prototype-chain read finds it and an own read does + // not. The signature pre-image takes own members only, and so does the schema + // (ownProperties), so every member the schema requires must be the record's own. const vector = verifying(); - // Non-enumerable, so the pollution is invisible to for-in and the schema's - // additionalProperties check on nested objects; only a chain read finds it. - const inherit = vm.runInNewContext( - "(json, member) => { const r = JSON.parse(json); " + - "Object.defineProperty(Object.prototype, member, { value: r[member], configurable: true }); " + - "delete r[member]; return r; }", - ); const options = { trustedKey: vector.trusted_key, now: vector.record.iat, maxAgeSeconds: null }; - for (const [member, code] of [["iat", "iat_invalid"], ["cnf", "schema_invalid"]]) { - await t.test(member, async () => { - const record = inherit(JSON.stringify(vector.record), member); - assert.equal(record[member] !== undefined, true, "the member is reachable through the chain"); - assert.equal(Object.hasOwn(record, member), false, "and is not own"); - await assert.rejects( - verifyRecord(record, options), - (error) => - error instanceof TraceVerificationError && - error.code === code && - (code !== "schema_invalid" || error.path === member), + const inherit = (path, enumerable) => + vm.runInNewContext( + "(json, path, enumerable) => { const r = JSON.parse(json); const keys = path.split('.'); " + + "const name = keys.pop(); const holder = keys.reduce((o, k) => o[k], r); " + + "Object.defineProperty(Object.prototype, name, { value: holder[name], enumerable, configurable: true }); " + + "delete holder[name]; return r; }", + )(JSON.stringify(vector.record), path, enumerable); + const rejected = async (record, accept) => + assert.rejects(verifyRecord(record, options), (e) => e instanceof TraceVerificationError && accept(e)); + // A required member is missing to the schema; eat_profile is read by own() + // before the schema and has its own code. An optional member is absent from + // the pre-image, so the signature no longer covers the record as signed. + const schema = JSON.parse(readFileSync(join(dirname(EXAMPLES), "schema", "trace-claim.json"), "utf8")); + const expected = (name) => + name === "eat_profile" ? "profile_missing" : schema.required.includes(name) ? "schema_invalid" : "signature_invalid"; + const members = Object.keys(vector.record).filter((name) => name !== "signature"); + assert.ok(schema.required.every((name) => members.includes(name)), "the vector carries every required member"); + for (const name of members) { + for (const enumerable of [false, true]) { + await t.test(`${name}${enumerable ? " (enumerable)" : ""}`, () => + rejected(inherit(name, enumerable), (e) => e.code === expected(name)), ); - }); + } } + await t.test("cnf.jwk, one level down", () => + rejected(inherit("cnf.jwk", false), (e) => e.code === "schema_invalid" && e.path === "cnf"), + ); + await t.test("the same record with every member its own still verifies", async () => { + const own = vm.runInNewContext("(json) => JSON.parse(json)")(JSON.stringify(vector.record)); + const result = await verifyRecord(own, options); + assert.equal(result.profile, TRACE_PROFILE_V0_2); + }); });