diff --git a/.github/workflows/trust-record-verify.yml b/.github/workflows/trust-record-verify.yml new file mode 100644 index 00000000..512a00a7 --- /dev/null +++ b/.github/workflows/trust-record-verify.yml @@ -0,0 +1,123 @@ +name: trust-record-verify + +on: + push: + branches: ["main", "feat/**", "fix/**"] + paths: + - "trust-record-verify/**" + - "schema/**" + - "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: + - "trust-record-verify/**" + - "schema/**" + - "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: + 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: trust-record-verify + + - name: Build and test + run: npm test + working-directory: trust-record-verify + + 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. 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 + + - 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 trust-record-verify + + - name: Lint the harness + run: ruff check trust-record-verify/differential + + - name: Build the verifier + 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: 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: trust-record-verify + + - name: Upload the report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: differential-report + path: | + trust-record-verify/differential/build/report.json + trust-record-verify/differential/build/manifest.json + if-no-files-found: ignore diff --git a/trust-record-verify/.gitignore b/trust-record-verify/.gitignore new file mode 100644 index 00000000..629021ef --- /dev/null +++ b/trust-record-verify/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +src/generated/ +differential/build/ diff --git a/trust-record-verify/README.md b/trust-record-verify/README.md new file mode 100644 index 00000000..8c0fb065 --- /dev/null +++ b/trust-record-verify/README.md @@ -0,0 +1,193 @@ +# @agentrust-io/trust-record-verify + +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 "@agentrust-io/trust-record-verify"; + +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.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" +``` + +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; 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 | + +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`. +- 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 + +``` +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 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 + +``` +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 --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: 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), 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 +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 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 | 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** | **1643** | + +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 | +|---|---:|---| +| `1.0e+21`: a float in Python, an integer past 2^53 here (`jcs-10`) | 1 | RFC 8785 corpus | + +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: + +- 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. + +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 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 +constrains (no `maxLength`; every `minLength` is 1). Those rest on the unit tests and on +reading the code, not on the 1644. diff --git a/trust-record-verify/differential/compare.py b/trust-record-verify/differential/compare.py new file mode 100644 index 00000000..a9922127 --- /dev/null +++ b/trust-record-verify/differential/compare.py @@ -0,0 +1,244 @@ +"""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, 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. + +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. + +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 + + +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) + 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 + + +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("profile"), + json.dumps(sorted(verdict.get("accepted_profiles") or [])), + verdict.get("thumbprint"), + verdict.get("key_source"), + 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] +) -> 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 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 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__) + 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) + 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) + 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] = [] + hits: dict[tuple[int, str], int] = {} + + 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) + 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"}}, + } + 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"], + "classification": entry["classification"], + }) + 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]: + print(f" {line}") + 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), + "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, + }, indent=1, sort_keys=True) + "\n", encoding="utf-8") + + return 1 if unexpected or unclassified or stale or external_short else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/trust-record-verify/differential/generate_cases.py b/trust-record-verify/differential/generate_cases.py new file mode 100644 index 00000000..1d5a5639 --- /dev/null +++ b/trust-record-verify/differential/generate_cases.py @@ -0,0 +1,503 @@ +"""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.""" + + +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"{CORPUS_SEED_DOMAIN}/{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 `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. + """ + 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() + 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") + # `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: + 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, + }) + 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" + ) + + +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() + 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, + "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/trust-record-verify/differential/known-divergences.json b/trust-record-verify/differential/known-divergences.json new file mode 100644 index 00000000..d72afd8a --- /dev/null +++ b/trust-record-verify/differential/known-divergences.json @@ -0,0 +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. 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 new file mode 100644 index 00000000..55324470 --- /dev/null +++ b/trust-record-verify/differential/oracle.py @@ -0,0 +1,291 @@ +"""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"), + ("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"), + ("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"), + ("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"), + ("JWK 'x' is not canonical 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, 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: + 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 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"] + 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"), + "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": bundle, + "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", + "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 + # 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, + "evidence": scrub(dict(result.revocation.evidence)), + }, + } + + +def run_jcs(case: dict[str, Any]) -> dict[str, Any]: + try: + 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")} + except BaseException as exc: # noqa: BLE001 + return {"verdict": "rejected", **classify(exc)} + + +def run_thumbprint(case: dict[str, Any]) -> dict[str, Any]: + try: + 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]: + 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) + 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/trust-record-verify/differential/run.mjs b/trust-record-verify/differential/run.mjs new file mode 100644 index 00000000..725df083 --- /dev/null +++ b/trust-record-verify/differential/run.mjs @@ -0,0 +1,167 @@ +// 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", + profile: result.profile, + accepted_profiles: [...result.acceptedProfiles], + thumbprint: result.trustedKeyThumbprint, + key_source: result.trustedKeySource, + 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) { + 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(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(value, 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}`); diff --git a/trust-record-verify/package-lock.json b/trust-record-verify/package-lock.json new file mode 100644 index 00000000..5c195ce6 --- /dev/null +++ b/trust-record-verify/package-lock.json @@ -0,0 +1,934 @@ +{ + "name": "@agentrust-io/trust-record-verify", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@agentrust-io/trust-record-verify", + "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/trust-record-verify/package.json b/trust-record-verify/package.json new file mode 100644 index 00000000..dde34b5d --- /dev/null +++ b/trust-record-verify/package.json @@ -0,0 +1,36 @@ +{ + "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.", + "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/trust-record-verify/scripts/generate-validators.mjs b/trust-record-verify/scripts/generate-validators.mjs new file mode 100644 index 00000000..2f200ac4 --- /dev/null +++ b/trust-record-verify/scripts/generate-validators.mjs @@ -0,0 +1,83 @@ +// 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 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, + 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/trust-record-verify/src/base64url.ts b/trust-record-verify/src/base64url.ts new file mode 100644 index 00000000..4424f002 --- /dev/null +++ b/trust-record-verify/src/base64url.ts @@ -0,0 +1,97 @@ +/** + * 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"; +import { isUint8Array } from "./text.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 { + // 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, 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 = ""; + 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/trust-record-verify/src/crypto.ts b/trust-record-verify/src/crypto.ts new file mode 100644 index 00000000..d7026017 --- /dev/null +++ b/trust-record-verify/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/trust-record-verify/src/delegation.ts b/trust-record-verify/src/delegation.ts new file mode 100644 index 00000000..92a58622 --- /dev/null +++ b/trust-record-verify/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 (!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"))) { + 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/trust-record-verify/src/errors.ts b/trust-record-verify/src/errors.ts new file mode 100644 index 00000000..2a4ad08c --- /dev/null +++ b/trust-record-verify/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/trust-record-verify/src/index.ts b/trust-record-verify/src/index.ts new file mode 100644 index 00000000..96cbd98e --- /dev/null +++ b/trust-record-verify/src/index.ts @@ -0,0 +1,46 @@ +/** + * 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 { + ACCEPTED_PROFILES, + TRACE_PROFILE_V0_2, + verifyRecord, + type RevocationStore, + type VerificationResult, + type VerifyOptions, +} from "./verify.js"; diff --git a/trust-record-verify/src/jcs.ts b/trust-record-verify/src/jcs.ts new file mode 100644 index 00000000..fe0a9650 --- /dev/null +++ b/trust-record-verify/src/jcs.ts @@ -0,0 +1,173 @@ +/** + * 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, cyclic structures, and an object with a + * symbol-keyed member, which `Object.keys` would silently leave out. + */ + +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)) { + 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)) { + 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)) { + 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) => { + 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/trust-record-verify/src/jwk.ts b/trust-record-verify/src/jwk.ts new file mode 100644 index 00000000..3d084e3b --- /dev/null +++ b/trust-record-verify/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/trust-record-verify/src/revocation.ts b/trust-record-verify/src/revocation.ts new file mode 100644 index 00000000..ef61f44e --- /dev/null +++ b/trust-record-verify/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/trust-record-verify/src/schema.ts b/trust-record-verify/src/schema.ts new file mode 100644 index 00000000..313b6f70 --- /dev/null +++ b/trust-record-verify/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/trust-record-verify/src/text.ts b/trust-record-verify/src/text.ts new file mode 100644 index 00000000..54508970 --- /dev/null +++ b/trust-record-verify/src/text.ts @@ -0,0 +1,162 @@ +/** 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; +} + +/* + * 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. + */ + +// 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, 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; + 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]] +// 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. */ +export function own(object: Record, key: string): unknown { + return Object.hasOwn(object, key) ? object[key] : undefined; +} diff --git a/trust-record-verify/src/uri.ts b/trust-record-verify/src/uri.ts new file mode 100644 index 00000000..2ec35a63 --- /dev/null +++ b/trust-record-verify/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 = `[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})`; +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/trust-record-verify/src/verify.ts b/trust-record-verify/src/verify.ts new file mode 100644 index 00000000..033cfa62 --- /dev/null +++ b/trust-record-verify/src/verify.ts @@ -0,0 +1,390 @@ +/** + * `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 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. + * 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, 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"; + +/** + * 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. 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[]; + /** Maximum bundle age in seconds, measured from `issued_at`. Default 86400. */ + readonly maxBundleAgeSeconds?: number; +} + +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. */ + 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" && + !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"); + } + 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 (isSet(store)) { + 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 (!Object.hasOwn(record, "signature")) { + fail("signature_missing", "the record has no embedded signature"); + } + 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) { + 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, + }); + } + + // 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"; + 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 = own(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 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"); + } + } + + 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 { + profile, + acceptedProfiles: ACCEPTED_PROFILES, + revocation, + trustedKeyThumbprint: trustedIds[0] as string, + trustedKeySource, + }; +} diff --git a/trust-record-verify/tests/hygiene.test.mjs b/trust-record-verify/tests/hygiene.test.mjs new file mode 100644 index 00000000..43a3d344 --- /dev/null +++ b/trust-record-verify/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/trust-record-verify/tests/units.test.mjs b/trust-record-verify/tests/units.test.mjs new file mode 100644 index 00000000..0c936a1d --- /dev/null +++ b/trust-record-verify/tests/units.test.mjs @@ -0,0 +1,217 @@ +// 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 vm from "node:vm"; + +import { + canonicalJson, + decodeBase64url, + encodeBase64url, + jwkThumbprint, + parentRecordHash, + TRACE_PROFILE_V0_2, + TraceVerificationError, + verifyDelegationLink, + 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("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("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]) { + await rejects(verifyDelegationLink({}, {}, options), "invalid_argument"); + } +}); + +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]); + 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", + ); +}); + +// Match the Python verifier after #390: presence is separate from value/type. +test("signature_missing is reserved for an absent member", async () => { + await rejects(verifyRecord({ eat_profile: TRACE_PROFILE_V0_2 }), "signature_missing"); +}); +for (const [label, signature] of [ + ["null", null], ["zero", 0], ["false", false], ["array", []], + ["object", {}], ["undefined", undefined], +]) { + test(`a present ${label} signature is malformed`, async () => { + await rejects(verifyRecord({ eat_profile: TRACE_PROFILE_V0_2, signature }), "signature_malformed"); + }); +} +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/uri.test.mjs b/trust-record-verify/tests/uri.test.mjs new file mode 100644 index 00000000..a8dc7f6e --- /dev/null +++ b/trust-record-verify/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 diff --git a/trust-record-verify/tests/vectors.test.mjs b/trust-record-verify/tests/vectors.test.mjs new file mode 100644 index 00000000..fe8e3f6e --- /dev/null +++ b/trust-record-verify/tests/vectors.test.mjs @@ -0,0 +1,274 @@ +// 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 vm from "node:vm"; + +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"); + +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"); +}); + +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); +}); + +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; + }); +}); + +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", + }); +}); + +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 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 + // 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(); + const options = { trustedKey: vector.trusted_key, now: vector.record.iat, maxAgeSeconds: null }; + 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); + }); +}); diff --git a/trust-record-verify/tsconfig.json b/trust-record-verify/tsconfig.json new file mode 100644 index 00000000..b57c6144 --- /dev/null +++ b/trust-record-verify/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"] +}