diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 00000000..e2f0ae13 --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +COPY . $SRC/trace-spec +COPY .clusterfuzzlite/build.sh $SRC/build.sh +WORKDIR $SRC/trace-spec diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100755 index 00000000..f8cda505 --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash -eu +# Build the fuzz targets for ClusterFuzzLite. +# +# Third-party dependencies come from the same hash-pinned lock CI uses, and the +# package then goes in with --no-deps, so the targets run against the versions +# the test suite runs against rather than whatever resolves on the day. + +cd "$SRC/trace-spec" +pip3 install --no-cache-dir --require-hashes -r requirements/dev.txt +pip3 install --no-cache-dir --no-deps . + +# compile_python_fuzzer bundles each target with PyInstaller, which follows +# static imports only. The cryptography and pydantic stacks reach email.mime +# lazily, so without this the bundled target dies at runtime with +# "ModuleNotFoundError: No module named 'email.mime'" and libFuzzer reports it +# as a crash in the target. The package schemas are data files PyInstaller does +# not see through importlib.resources, and neither are the metaschemas jsonschema +# loads the same way, so both are collected explicitly. +PYI_ARGS=( + --collect-submodules=email + --collect-data=agentrust_trace + --collect-data=jsonschema_specifications +) + +for target in "$SRC"/trace-spec/.clusterfuzzlite/fuzz_*.py; do + compile_python_fuzzer "$target" "${PYI_ARGS[@]}" +done + +# Seed corpora: the committed example records and bundles, plus one valid input +# per target built with the library itself, so the fuzzer starts past the +# schema instead of spending its budget discovering JSON. +python3 .clusterfuzzlite/make_seeds.py "$OUT" diff --git a/.clusterfuzzlite/fuzz_canonicalize.py b/.clusterfuzzlite/fuzz_canonicalize.py new file mode 100644 index 00000000..eeff6129 --- /dev/null +++ b/.clusterfuzzlite/fuzz_canonicalize.py @@ -0,0 +1,87 @@ +#!/usr/bin/python3 +"""Fuzz the two canonicalizations: JCS for signatures, anchor form for digests. + +Input: JSON text. ``sign._canonical_bytes`` is the signature pre-image of spec +section 3.2.2 and ``sign.anchor_bytes`` is registry-anchor-v1 section 1. + +Properties, stronger than "does not crash": + +- Round trip. Parsing either output must reproduce the input, up to JSON's + single number type: a float whose shortest form has no fraction re-parses as + an int, which is correct RFC 8785 output. A canonicalization that silently + drops or merges a member still produces bytes somebody signs. +- Determinism. The same value canonicalizes to the same bytes twice. +- Declared refusals only. JCS refuses with ``rfc8785.CanonicalizationError`` + (a ``ValueError``) and the anchor form with ``UnanchorableValue``. +""" + +import json +import math +import sys + +import atheris + +with atheris.instrument_imports(): + import rfc8785 + + from agentrust_trace.sign import UnanchorableValue, _canonical_bytes, anchor_bytes + + +def _json_equal(a, b) -> bool: + if isinstance(a, bool) or isinstance(b, bool): + return a is b + if isinstance(a, (int, float)) and isinstance(b, (int, float)): + return float(a) == float(b) + if isinstance(a, dict) and isinstance(b, dict): + return a.keys() == b.keys() and all(_json_equal(a[k], b[k]) for k in a) + if isinstance(a, list) and isinstance(b, list): + return len(a) == len(b) and all(_json_equal(x, y) for x, y in zip(a, b, strict=True)) + return type(a) is type(b) and a == b + + +def _has_nonfinite(value) -> bool: + if isinstance(value, float): + return not math.isfinite(value) + if isinstance(value, dict): + return any(_has_nonfinite(v) for v in value.values()) + if isinstance(value, list): + return any(_has_nonfinite(v) for v in value) + return False + + +def TestOneInput(data: bytes) -> None: + try: + value = json.loads(data) + except (ValueError, RecursionError): + return + try: + if not isinstance(value, dict) or _has_nonfinite(value): + # The signature pre-image is always an object, and Python's json + # accepts NaN and Infinity, which have no JSON form to return to. + return + except RecursionError: + return + try: + jcs = _canonical_bytes(value) + except (rfc8785.CanonicalizationError, RecursionError): + # RecursionError: nesting deeper than the interpreter stack. The + # verifiers refuse such documents before they reach this function. + jcs = None + if jcs is not None: + assert _json_equal(json.loads(jcs), value), f"JCS output did not round-trip: {jcs!r}" + assert _canonical_bytes(value) == jcs, "JCS is not deterministic" + try: + anchored = anchor_bytes(value) + except (UnanchorableValue, RecursionError): + return + assert _json_equal(json.loads(anchored), value), f"anchor form did not round-trip: {anchored!r}" + assert anchor_bytes(value) == anchored, "anchor form is not deterministic" + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_content_marking.py b/.clusterfuzzlite/fuzz_content_marking.py new file mode 100644 index 00000000..a88e9b0d --- /dev/null +++ b/.clusterfuzzlite/fuzz_content_marking.py @@ -0,0 +1,73 @@ +#!/usr/bin/python3 +"""Fuzz the C2PA content-marking assertion checker. + +Input: one mode byte, then a JSON object ``{"assertion": ..., "record": ...}`` +where ``record`` is the text served at the assertion's URL. With mode bit 0 +set, the assertion is rebuilt over those exact bytes with ``build_assertion``, +so the fuzzer reaches the record parsing and subject and profile comparison +that only run once the hash matches. + +Property: ``verify_assertion`` and ``build_assertion`` document +``ContentMarkingError`` (``RecordMismatch`` is a subclass). Both the assertion +and the served record come from whoever controls the asset and the URL, so any +other exception is that party crashing the checker. +""" + +import json +import sys + +import atheris + +with atheris.instrument_imports(): + from agentrust_trace.content_marking import ( + ContentMarkingError, + build_assertion, + verify_assertion, + ) + + +def TestOneInput(data: bytes) -> None: + if not data: + return + mode, body = data[0], data[1:] + try: + case = json.loads(body) + except (ValueError, RecursionError): + return + if not isinstance(case, dict): + return + record_text = case.get("record") + if not isinstance(record_text, str): + return + try: + record_bytes = record_text.encode("utf-8") + except UnicodeEncodeError: + return + assertion = case.get("assertion") + if mode & 1 and isinstance(assertion, dict): + data_obj = assertion.get("data") + ref = data_obj.get("record") if isinstance(data_obj, dict) else None + url = ref.get("url") if isinstance(ref, dict) else None + try: + assertion = build_assertion( + record_bytes, + url=url if isinstance(url, str) else "https://records.example/r.json", + alg="sha384" if mode & 2 else "sha256", + anchor=assertion.get("anchor") if mode & 4 else None, + ) + except ContentMarkingError: + return + try: + record = verify_assertion(assertion, record_bytes) + except ContentMarkingError: + return + assert isinstance(record, dict), type(record) + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_intent_bridge.py b/.clusterfuzzlite/fuzz_intent_bridge.py new file mode 100644 index 00000000..5cf5ab9e --- /dev/null +++ b/.clusterfuzzlite/fuzz_intent_bridge.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +"""Fuzz ``intent_bridge.verify_bridge`` and the successor-observation evaluator. + +Input: one mode byte, then a JSON object carrying the verifier's inputs: +``bridge``, ``declaration``, ``pic_intent_digest``, ``pic_args_digest``, +``tool_call`` and ``transcript``. With mode bit 0 set, ``bridge.authorization`` +is signed with the pinned authorizer key, so the fuzzer reaches the scope, +digest and transcript checks behind the signature. + +Property: ``verify_bridge`` documents ``IntentBridgeError`` and its two +subclasses; ``evaluate_successor_observation`` documents +``SuccessorObservationError``. Every argument here is execution evidence from +outside the verifier, so any other exception is that evidence crashing it. An +accepted bridge must carry the decision ``allow``. +""" + +import json +import sys + +import atheris + +with atheris.instrument_imports(): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from agentrust_trace.intent_bridge import IntentBridgeError, sign_bridge, verify_bridge + from agentrust_trace.sign import key_to_jwk + from agentrust_trace.successor_observation import ( + SuccessorObservationError, + evaluate_successor_observation, + ) + +KEY = Ed25519PrivateKey.from_private_bytes(bytes(range(96, 128))) +TRUSTED_JWK = {**key_to_jwk(KEY), "kid": "authorizer-key-1"} +NOW = 150 + + +def _predicate(observation): + status = observation.get("status") + if not isinstance(status, str): + return None + return {"accepted": True, "rejected": False}.get(status) + + +def TestOneInput(data: bytes) -> None: + if not data: + return + mode, body = data[0], data[1:] + try: + case = json.loads(body) + except (ValueError, RecursionError): + return + if not isinstance(case, dict): + return + bridge = case.get("bridge") + if mode & 1 and isinstance(bridge, dict): + try: + bridge = sign_bridge(bridge.get("authorization"), KEY) + except IntentBridgeError: + return + try: + authorization = verify_bridge( + bridge, + TRUSTED_JWK, + declaration=case.get("declaration"), + pic_intent_digest=case.get("pic_intent_digest"), + pic_args_digest=case.get("pic_args_digest"), + tool_call=case.get("tool_call"), + transcript=case.get("transcript"), + now=NOW, + ) + except IntentBridgeError: + pass + else: + assert authorization["decision"] == "allow", authorization + + transcript = case.get("transcript") + after = transcript.get("after") if isinstance(transcript, dict) else None + try: + evaluate_successor_observation( + after, + expected_successor_digest=case.get("successor_digest", "sha256:" + "0" * 64), + trusted_observers=["observer-1"], + executor_id="executor-1" if mode & 2 else None, + independence_required=bool(mode & 4), + now=NOW, + max_age_seconds=3600, + predicate=_predicate, + ) + except SuccessorObservationError: + pass + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_provenance.py b/.clusterfuzzlite/fuzz_provenance.py new file mode 100644 index 00000000..2b279561 --- /dev/null +++ b/.clusterfuzzlite/fuzz_provenance.py @@ -0,0 +1,82 @@ +#!/usr/bin/python3 +"""Fuzz the MCP server provenance verifier. + +Input: one mode byte, then JSON text. A JSON object with a ``record`` member is +read as ``{"record": ..., "tools": ...}``; anything else is the record, with an +empty tool list. With mode bit 0 set the record is signed with the pinned key +first, so the fuzzer gets past the signature to the tool-catalog comparison. + +Property: ``verify_record``, ``check_tool_catalog`` and ``tool_catalog_hash`` +document ``ProvenanceError`` (``ToolCatalogMismatch`` is a subclass). The +tools are exactly what a live, untrusted server returned, so any other +exception is that server crashing its verifier. +""" + +import json +import sys + +import atheris + +with atheris.instrument_imports(): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from agentrust_trace.provenance import ( + FORMAT, + FORMAT_V2, + ProvenanceError, + check_tool_catalog, + sign_record, + tool_catalog_hash, + verify_record, + ) + from agentrust_trace.sign import key_to_jwk + +KEY = Ed25519PrivateKey.from_private_bytes(bytes(range(64, 96))) +TRUSTED_JWK = key_to_jwk(KEY) +NOW = 1_785_000_000 + + +def TestOneInput(data: bytes) -> None: + if not data: + return + mode, body = data[0], data[1:] + try: + value = json.loads(body) + except (ValueError, RecursionError): + return + if isinstance(value, dict) and "record" in value: + record, tools = value.get("record"), value.get("tools", []) + else: + record, tools = value, [] + if mode & 1 and isinstance(record, dict): + try: + record = sign_record(record, KEY) + except ProvenanceError: + return + try: + verify_record( + record, + TRUSTED_JWK, + now=NOW, + max_age_seconds=None if mode & 2 else 86400, + required_format=FORMAT_V2 if mode & 4 else None, + ) + except ProvenanceError: + pass + try: + check_tool_catalog(record, tools) + except ProvenanceError: + pass + try: + tool_catalog_hash(tools, format=FORMAT_V2 if mode & 8 else FORMAT) + except ProvenanceError: + pass + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_revocation_bundle.py b/.clusterfuzzlite/fuzz_revocation_bundle.py new file mode 100644 index 00000000..68671906 --- /dev/null +++ b/.clusterfuzzlite/fuzz_revocation_bundle.py @@ -0,0 +1,81 @@ +#!/usr/bin/python3 +"""Fuzz ``revocation.check_bundle``, the revocation-bundle consumer. + +Input: one mode byte, then JSON text for the bundle. With mode bit 0 set the +bundle is signed with the pinned bundle key first, so the fuzzer reaches the +statement and time checks behind the signature. + +Property: a bundle the verifier cannot use is reported as an outcome, never +raised, and the one documented raise is ``ValueError`` for a statement naming +the trusted key. Any other exception is a bundle crashing the verifier. A +``verified`` outcome must only be reachable through a bundle signed by the +trusted key. +""" + +import base64 +import json +import sys + +import atheris + +with atheris.instrument_imports(): + import rfc8785 + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from agentrust_trace.revocation import check_bundle + from agentrust_trace.sign import jwk_thumbprint, key_to_jwk + +BUNDLE_KEY = Ed25519PrivateKey.from_private_bytes(bytes(range(32, 64))) +BUNDLE_JWK = key_to_jwk(BUNDLE_KEY) +BUNDLE_KID = jwk_thumbprint(BUNDLE_JWK) +RECORD_KEY_ID = "record-key-1" +NOW = 1_785_000_000 +OUTCOMES = {"verified", "unverified_for_revocation"} + + +def _sign(bundle: dict) -> dict: + bundle = {**bundle, "bundle_key_id": BUNDLE_KID} + unsigned = {k: v for k, v in bundle.items() if k != "sig"} + value = base64.urlsafe_b64encode(BUNDLE_KEY.sign(rfc8785.dumps(unsigned))) + return {**bundle, "sig": {"alg": "ed25519", "value": value.rstrip(b"=").decode()}} + + +def TestOneInput(data: bytes) -> None: + if not data: + return + mode, body = data[0], data[1:] + try: + bundle = json.loads(body) + except (ValueError, RecursionError): + return + signed = False + if mode & 1 and isinstance(bundle, dict): + try: + bundle = _sign(bundle) + signed = True + except (ValueError, RecursionError): + return + try: + check = check_bundle( + bundle, + trusted_key_identifiers=[RECORD_KEY_ID], + trusted_bundle_keys=[BUNDLE_JWK], + now=NOW, + max_bundle_age_seconds=86400, + max_future_skew_seconds=300, + ) + except ValueError: + return + assert check.outcome in OUTCOMES, check.outcome + if check.outcome == "verified": + assert signed or bundle.get("bundle_key_id") == BUNDLE_KID, bundle + json.dumps(check.evidence) + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_verify_record.py b/.clusterfuzzlite/fuzz_verify_record.py new file mode 100644 index 00000000..4e21d199 --- /dev/null +++ b/.clusterfuzzlite/fuzz_verify_record.py @@ -0,0 +1,82 @@ +#!/usr/bin/python3 +"""Fuzz ``sign.verify_record``, the Trust Record verifier. + +Input: one mode byte, then JSON text. The JSON is the record as a verifier +receives it. With mode bit 0 set, the record is first signed with the pinned +key, so the fuzzer reaches the checks that only run after the schema gate and +the signature (freshness, nonce, revocation, citation resolution) instead of +stopping at ``InvalidSignature`` every time. + +Property: ``verify_record`` documents ``InvalidSignature`` for a bad signature +and ``ValueError`` for every other refusal. Anything else escaping is a caller +written against that contract crashing on untrusted bytes. A successful return +must carry the profile the verifier was configured to accept. +""" + +import json +import sys + +import atheris + +with atheris.instrument_imports(): + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + + from agentrust_trace.sign import ( + DEFAULT_ACCEPTED_PROFILES, + key_to_jwk, + sign_record, + verify_record, + ) + +KEY = Ed25519PrivateKey.from_private_bytes(bytes(range(32))) +TRUSTED_JWK = key_to_jwk(KEY) +NOW = 1_785_000_000 + + +def _resolver(uri: str) -> bytes: + if len(uri) % 3 == 0: + raise OSError("unreachable") + return uri.encode("utf-8", "surrogatepass") + + +def _revocation(identifier: str) -> bool: + return identifier.startswith("revoked") + + +def TestOneInput(data: bytes) -> None: + if not data: + return + mode, body = data[0], data[1:] + try: + record = json.loads(body) + except (ValueError, RecursionError): + # Parsing is the caller's step, not the verifier's. + return + if mode & 1 and isinstance(record, dict): + try: + record = sign_record(record, KEY) + except ValueError: + return + try: + result = verify_record( + record, + TRUSTED_JWK, + now=NOW, + max_age_seconds=None if mode & 2 else 86400 * 3650, + expected_nonce="n-0" if mode & 4 else None, + revocation=_revocation if mode & 8 else None, + citation_resolver=_resolver if mode & 16 else None, + ) + except (ValueError, InvalidSignature): + return + assert result.profile in DEFAULT_ACCEPTED_PROFILES, result.profile + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/make_seeds.py b/.clusterfuzzlite/make_seeds.py new file mode 100644 index 00000000..72ac483d --- /dev/null +++ b/.clusterfuzzlite/make_seeds.py @@ -0,0 +1,136 @@ +"""Write a seed corpus zip per fuzz target into the directory given as argv[1]. + +Seeds are the committed example records and revocation bundles, plus one valid +input per target built with the library itself. Every target reads a mode byte +followed by JSON text, except ``fuzz_canonicalize`` which reads JSON alone; bit 0 +of the mode byte asks the target to re-sign the input, so each valid seed is +written once as-is and once with that bit set. +""" + +from __future__ import annotations + +import json +import pathlib +import sys +import zipfile + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from agentrust_trace.content_marking import build_assertion +from agentrust_trace.intent_bridge import digest_jcs, sign_bridge +from agentrust_trace.provenance import FORMAT_V2, build_record + +ROOT = pathlib.Path(__file__).resolve().parent.parent + + +def _walk(value, found): + if isinstance(value, dict): + if isinstance(value.get("eat_profile"), str) and "iat" in value: + found["record"].append(value) + if value.get("type") == "TraceRevocationBundle/1.0": + found["bundle"].append(value) + for item in value.values(): + _walk(item, found) + elif isinstance(value, list): + for item in value: + _walk(item, found) + + +def _examples(): + found = {"record": [], "bundle": []} + for path in sorted((ROOT / "examples").rglob("*.json")): + try: + _walk(json.loads(path.read_text(encoding="utf-8")), found) + except ValueError: + continue + return found + + +def _bridge_case(): + tool_call = {"name": "send_invoice", "arguments": {"invoice_id": "INV-7"}} + declaration = {"impact": "external-side-effect", "purpose": "send invoice"} + after = {"observation": {"status": "accepted"}, "observer": "observer-1", "observed_at": 120} + authorization = { + "authorization_id": "auth-7", + "decision": "allow", + "authorizer": "finance-policy", + "authorizer_key_id": "authorizer-key-1", + "authorized_at": 100, + "expires_at": 200, + "scope": {"tools": ["send_invoice"], "impacts": ["external-side-effect"]}, + "pic": { + "profile": "PIC-CJSON/1.0", + "intent_digest": "sha256:" + "1" * 64, + "args_digest": "sha256:" + "2" * 64, + }, + "declaration_digest": digest_jcs(declaration), + "tool_call_digest": digest_jcs(tool_call), + "successor_observation_digest": digest_jcs(after), + "transcript_required": True, + } + # Signed with a throwaway key: bit 0 re-signs with the target's pinned key. + bridge = sign_bridge(authorization, Ed25519PrivateKey.generate()) + return { + "bridge": bridge, + "declaration": declaration, + "pic_intent_digest": authorization["pic"]["intent_digest"], + "pic_args_digest": authorization["pic"]["args_digest"], + "tool_call": tool_call, + "transcript": {"before": {"tool_call": tool_call}, "after": after}, + "successor_digest": authorization["successor_observation_digest"], + } + + +def _provenance_case(): + tools = [ + { + "name": "search", + "description": "search the docs", + "inputSchema": {"type": "object", "properties": {"q": {"type": "string"}}}, + "annotations": {"readOnlyHint": True}, + } + ] + record = build_record( + kind="publisher-asserted", + publisher="did:web:acme.example", + tools=tools, + artifact={"package": "acme-search", "digest": "sha256:" + "a" * 64}, + issued_at=1_785_000_000, + format=FORMAT_V2, + ) + return {"record": record, "tools": tools} + + +def _content_marking_case(record): + text = json.dumps(record, sort_keys=True) + assertion = build_assertion(text.encode("utf-8"), url="https://records.example/r.json") + return {"assertion": assertion, "record": text} + + +def _write(out: pathlib.Path, target: str, seeds: list[bytes]) -> None: + with zipfile.ZipFile(out / f"{target}_seed_corpus.zip", "w") as archive: + for index, seed in enumerate(seeds): + archive.writestr(f"seed-{index:04d}", seed) + + +def _moded(values) -> list[bytes]: + seeds = [] + for value in values: + body = json.dumps(value).encode("utf-8") + seeds.extend([b"\x00" + body, b"\x01" + body]) + return seeds + + +def main(out: pathlib.Path) -> None: + found = _examples() + records = found["record"] + _write(out, "fuzz_verify_record", _moded(records)) + _write(out, "fuzz_revocation_bundle", _moded(found["bundle"])) + _write(out, "fuzz_provenance", _moded([_provenance_case()])) + _write(out, "fuzz_intent_bridge", _moded([_bridge_case()])) + _write(out, "fuzz_content_marking", _moded([_content_marking_case(r) for r in records[:8]])) + _write(out, "fuzz_canonicalize", [json.dumps(r).encode("utf-8") for r in records]) + + +if __name__ == "__main__": + main(pathlib.Path(sys.argv[1])) diff --git a/.github/workflows/cflite_batch.yml b/.github/workflows/cflite_batch.yml new file mode 100644 index 00000000..7ae870c3 --- /dev/null +++ b/.github/workflows/cflite_batch.yml @@ -0,0 +1,37 @@ +name: ClusterFuzzLite batch + +on: + schedule: + # 04:40 UTC daily, off the hour so it does not queue behind everything else + # that runs at midnight. + - cron: "40 4 * * *" + workflow_dispatch: + +permissions: read-all + +concurrency: + group: cflite-batch + cancel-in-progress: false + +jobs: + fuzz: + name: Batch fuzz + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Build fuzzers + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + github-token: ${{ secrets.GITHUB_TOKEN }} + sanitizer: address + + - name: Run fuzzers + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + # Every target, not just what changed. Thirty minutes shared across the + # six targets leaves the build and upload inside the 45 minute job. + fuzz-seconds: 1800 + mode: batch + sanitizer: address diff --git a/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml new file mode 100644 index 00000000..79ee9460 --- /dev/null +++ b/.github/workflows/cflite_pr.yml @@ -0,0 +1,42 @@ +name: ClusterFuzzLite PR + +on: + pull_request: + paths: + - "src/agentrust_trace/**" + - "requirements/dev.txt" + - ".clusterfuzzlite/**" + - ".github/workflows/cflite_pr.yml" + +# Read-only. Findings surface in the job log and the uploaded crash artifact +# rather than as code-scanning alerts, so no security-events: write is needed. +permissions: read-all + +concurrency: + group: cflite-pr-${{ github.ref }} + cancel-in-progress: true + +jobs: + fuzz: + name: Fuzz changed code + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + # Only the address sanitizer. The targets are pure Python under Atheris, + # where undefined-behaviour instrumentation has nothing to instrument. + - name: Build fuzzers + uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + language: python + github-token: ${{ secrets.GITHUB_TOKEN }} + sanitizer: address + + - name: Run fuzzers + uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + fuzz-seconds: 300 + # code-change fuzzes only what the PR touched, which keeps this inside + # a PR's time budget. The nightly batch covers the rest. + mode: code-change + sanitizer: address diff --git a/.github/workflows/runtime-evidence.yml b/.github/workflows/runtime-evidence.yml index cc4f080d..d49c516d 100644 --- a/.github/workflows/runtime-evidence.yml +++ b/.github/workflows/runtime-evidence.yml @@ -20,6 +20,7 @@ on: - "schema/trace-claim-v0.3-draft.json" - "docs/rfcs/runtime-evidence-profile.md" - ".github/workflows/runtime-evidence.yml" + - "requirements/runtime-evidence.txt" permissions: contents: read @@ -60,14 +61,17 @@ jobs: # goes through the package `__init__`, which needs it; TDX verification # itself needs only `cryptography`. # - # `cryptography` is installed unbounded, as everywhere else in this repository, - # while the verifier declares `>=42,<51`. A release past that bound will fail - # this job rather than quietly change what the grades mean, which is the - # direction to fail in; bump the pinned commit when it happens. + # Everything comes from hash-pinned locks, as in ci.yml, and the local + # package goes in with --no-deps. `cryptography` is pinned by + # requirements/dev.txt, while the verifier declares `>=42,<51`. A lock bump + # past that bound will fail this job rather than quietly change what the + # grades mean, which is the direction to fail in; bump the pinned commit + # when it happens. - name: Install package, dev deps, and the verifier's import requirements run: | - pip install -e ".[dev]" - pip install cbor2 + pip install --require-hashes -r requirements/dev.txt + pip install --require-hashes -r requirements/runtime-evidence.txt + pip install --no-deps -e . - name: Appraise the corpus with the real verifier env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a221822..459ac18e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,25 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. - Regenerate the v0.3 draft after canonical reproducibility additions and make the schema inventory test use portable repository paths on Windows. +- Keep untrusted input on each verifier's documented error type. Nesting a few + hundred levels deep in `cnf.jwk`, a tool call, a provenance record or served + content-marking bytes raised `RecursionError`; a 4,400 digit URL port raised + Python's integer-string `ValueError`; a float or unsafe integer in an offered + tool's `input_schema` raised `UnanchorableValue` from `check_tool_catalog`. + All were refusals already, none was an acceptance. Found by the new + ClusterFuzzLite targets in `.clusterfuzzlite/`; regression tests in + `tests/test_untrusted_input_stays_on_the_documented_error.py`. + +- Decode signatures and JWK `x` as canonical unpadded base64url only. The + decoder took the standard alphabet, `=` padding and stray characters, and + ignored the unused bits of the last character, so one signature had several + spellings that all verified, and a revocation bundle's `bundle_digest` (which + covers `sig.value`) gave one bundle several identities. The record-level rule + for `signature` is the normative proposal in #401 and is not changed here. + `verify_assertion` and `build_assertion` also refuse served record bytes that + name one member twice, since `json.loads` keeps the last and another parser + keeps the first. Tests in `tests/test_signature_spelling_is_canonical.py`. + ### Added - **`condition-appraisal` is a registered `references` relation, and the registry says what it is.** #191, #209, #220 and later #321 each asked for a way to bind a governance fact held outside the record, and #226 read them as one question about the `rel` registry of section 3.1.2. This settles the part ruled on there: the registry is informative and open, the verifier rules of section 3.1.2 stay normative and section 3.1.2 now says so below them, and `docs/references-registry.md` holds each registered value's referenced object, what a relying party may establish from a resolved one, and the steps by which a name is added. The fourth value, `condition-appraisal`, is an independent check's finding on whether a stated condition is established by a stated subject: a test run, a schema validation, a contract check. Its referenced object binds the condition and the subject by digest, names its issuer and key, carries the outcome in the issuer's own closed vocabulary, and signs the canonical bytes of the rest; the reference's `digest` is over the object as retained. What a relying party may establish is bounded to the object: that the cited bytes are the cited bytes and, where it holds the issuer's key, that the issuer signed them; neither reaches the record, a `pass` is not attested evidence that the condition held, and a `fail` is not a finding against the record. `examples/condition-appraisal/` carries five signed records from a seeded generator against an appraisal store: confirmed, altered after issue, a fail that verifies exactly as the pass does, unresolvable, and an issuer whose key the relying party does not hold; `tests/test_condition_appraisal_fixtures.py` recomputes every verdict from the committed bytes and re-runs the generator against them. Re-running a cited check is a separate proposal and no part of this change. Proposed by @nutstrut on #226; ruled on by @imran-siddique. Informative: no requirement, schema shape or wire format changes; the schema's `rel` description gains the name. diff --git a/requirements/dev.txt b/requirements/dev.txt index ea7f16f9..a3bf5222 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -69,7 +69,7 @@ attrs==26.1.0 \ # via # jsonschema # referencing -cffi==2.1.1 \ +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ @@ -171,7 +171,11 @@ cffi==2.1.1 \ --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 # via cryptography -coverage[toml]==7.16.0 \ +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via pytest +coverage==7.16.0 \ --hash=sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06 \ --hash=sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11 \ --hash=sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b \ @@ -354,7 +358,7 @@ jsonschema-specifications==2025.9.1 \ --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d # via jsonschema -librt==0.15.0 \ +librt==0.15.0 ; platform_python_implementation != 'PyPy' \ --hash=sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738 \ --hash=sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302 \ --hash=sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad \ @@ -565,7 +569,7 @@ pluggy==1.6.0 \ # via # pytest # pytest-cov -pycparser==3.0 \ +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi @@ -865,6 +869,55 @@ ruff==0.16.8 \ --hash=sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a \ --hash=sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773 # via -r requirements/dev.in +tomli==2.4.1 ; python_full_version <= '3.11' \ + --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ + --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ + --hash=sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5 \ + --hash=sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d \ + --hash=sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd \ + --hash=sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26 \ + --hash=sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54 \ + --hash=sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6 \ + --hash=sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c \ + --hash=sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a \ + --hash=sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd \ + --hash=sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f \ + --hash=sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5 \ + --hash=sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9 \ + --hash=sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662 \ + --hash=sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9 \ + --hash=sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1 \ + --hash=sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585 \ + --hash=sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e \ + --hash=sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c \ + --hash=sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41 \ + --hash=sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f \ + --hash=sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085 \ + --hash=sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15 \ + --hash=sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7 \ + --hash=sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c \ + --hash=sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36 \ + --hash=sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076 \ + --hash=sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac \ + --hash=sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8 \ + --hash=sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232 \ + --hash=sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece \ + --hash=sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a \ + --hash=sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897 \ + --hash=sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d \ + --hash=sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4 \ + --hash=sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917 \ + --hash=sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396 \ + --hash=sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a \ + --hash=sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc \ + --hash=sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba \ + --hash=sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f \ + --hash=sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257 \ + --hash=sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30 \ + --hash=sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf \ + --hash=sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9 \ + --hash=sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049 + # via coverage types-jsonschema==4.26.0.20260518 \ --hash=sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307 \ --hash=sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6 diff --git a/requirements/runtime-evidence.in b/requirements/runtime-evidence.in new file mode 100644 index 00000000..1b4dd8af --- /dev/null +++ b/requirements/runtime-evidence.in @@ -0,0 +1,7 @@ +# Import requirements of agent-manifest's TDX verifier that TRACE itself does +# not depend on, for .github/workflows/runtime-evidence.yml. Installed with +# --require-hashes after requirements/dev.txt. Compile with: +# uv pip compile requirements/runtime-evidence.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/runtime-evidence.txt +# The verifier at the pinned commit declares cbor2>=5.6,<7. +cbor2>=5.6,<7 diff --git a/requirements/runtime-evidence.txt b/requirements/runtime-evidence.txt new file mode 100644 index 00000000..aa9710ec --- /dev/null +++ b/requirements/runtime-evidence.txt @@ -0,0 +1,53 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/runtime-evidence.in --generate-hashes --universal --python-version 3.11 -o requirements/runtime-evidence.txt +cbor2==6.1.4 \ + --hash=sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9 \ + --hash=sha256:032ce71cbdc9267e9cec42132f6ac6c40f441ec27ebc87ca9dd209dcab6804ee \ + --hash=sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22 \ + --hash=sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883 \ + --hash=sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54 \ + --hash=sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b \ + --hash=sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e \ + --hash=sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae \ + --hash=sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69 \ + --hash=sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195 \ + --hash=sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220 \ + --hash=sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a \ + --hash=sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242 \ + --hash=sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905 \ + --hash=sha256:3cfb7eec57406b86aab47183f6dc90ece888e03721d37a4d33315e79e486898d \ + --hash=sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2 \ + --hash=sha256:41aae72e8863ac0d50705ea8290b72c49db90a6d953110feaa91360ae526fa8d \ + --hash=sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8 \ + --hash=sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245 \ + --hash=sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b \ + --hash=sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f \ + --hash=sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9 \ + --hash=sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f \ + --hash=sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76 \ + --hash=sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18 \ + --hash=sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9 \ + --hash=sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f \ + --hash=sha256:7b697c2a18eab9a3326447fe0f1c927db065a096febc57f064f0f5ed67fe96fc \ + --hash=sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257 \ + --hash=sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2 \ + --hash=sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6 \ + --hash=sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da \ + --hash=sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025 \ + --hash=sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0 \ + --hash=sha256:ae14eb3f2f251102d72625ab02a28c83a8d11adf3d0383bd6d7bd9d672e18119 \ + --hash=sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8 \ + --hash=sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f \ + --hash=sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c \ + --hash=sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e \ + --hash=sha256:c435720a25c1de9b241df883b5890b35aff3bf64365efc8037d4d26df085724e \ + --hash=sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4 \ + --hash=sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f \ + --hash=sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6 \ + --hash=sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd \ + --hash=sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2 \ + --hash=sha256:deff027a56e04caebfee649029bfd1836f4fb468d2cf66d2a396ae1018f655db \ + --hash=sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0 \ + --hash=sha256:e8097e8259b78c8feb5dd5b833b23f83ed588e821ef44cf1455082cfc9434440 \ + --hash=sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c + # via -r requirements/runtime-evidence.in diff --git a/src/agentrust_trace/content_marking.py b/src/agentrust_trace/content_marking.py index 754ca91e..d0265914 100644 --- a/src/agentrust_trace/content_marking.py +++ b/src/agentrust_trace/content_marking.py @@ -20,6 +20,7 @@ from agentrust_trace._patterns import _PYTHON_PATTERNS import hashlib +import json import re from typing import Any @@ -57,6 +58,34 @@ class RecordMismatch(ContentMarkingError): """ +def _no_duplicate_members(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """``object_pairs_hook`` that refuses a JSON object naming one member twice. + + ``json.loads`` keeps the last of two same-named members without a word, and a + parser that keeps the first reads different values out of the same bytes. The + hash binds bytes, so the record this module compares ``subject`` and + ``eat_profile`` against has to be the only reading those bytes have. + """ + obj: dict[str, Any] = {} + for name, value in pairs: + if name in obj: + raise ContentMarkingError(f"duplicate member {name!r} in a JSON object") + obj[name] = value + return obj + + +def _loads(data: bytes | bytearray, what: str) -> Any: + try: + return json.loads(data, object_pairs_hook=_no_duplicate_members) + except ContentMarkingError as exc: + raise ContentMarkingError(f"{what} is not usable JSON: {exc}") from exc + except (ValueError, RecursionError) as exc: + # RecursionError: nesting deeper than the interpreter stack. The bytes can + # come from whoever serves the URL, so a caller written against + # ContentMarkingError has to be able to refuse them. + raise ContentMarkingError(f"{what} is not JSON: {exc}") from exc + + def _digest(data: bytes, alg: str) -> str: if alg not in _ALGS: raise ContentMarkingError(f"unsupported digest algorithm {alg!r}; use sha256 or sha384") @@ -100,8 +129,14 @@ def _record_url(value: Any) -> str: elif not hostport: raise ContentMarkingError(message) - if port is not None and int(port) > 65535: - raise ContentMarkingError(message) + # Length before int(): RFC 3986 puts no bound on a port's digits, and int() of + # more than 4,300 of them raises Python's own ValueError about its integer-string + # limit rather than this module's refusal. Leading zeros are legal, so they are + # not counted. + if port is not None: + significant = port.lstrip("0") + if len(significant) > 5 or (significant and int(significant) > 65535): + raise ContentMarkingError(message) return value @@ -144,12 +179,7 @@ def build_assertion( f"{anchor!r}" ) - import json - - try: - record = json.loads(record_bytes) - except ValueError as exc: - raise ContentMarkingError(f"record_bytes is not JSON: {exc}") from exc + record = _loads(record_bytes, "record_bytes") if not isinstance(record, dict): raise ContentMarkingError( f"record_bytes must decode to a JSON object, got {type(record).__name__}. A " @@ -189,8 +219,6 @@ def verify_assertion(assertion: dict[str, Any], record_bytes: bytes) -> dict[str assertion points at the record it claims, and nothing about whether either was signed by anyone it trusts. """ - import json - if not isinstance(assertion, dict): raise ContentMarkingError("assertion must be an object") if assertion.get("label") != ASSERTION_LABEL: @@ -251,11 +279,7 @@ def verify_assertion(assertion: dict[str, Any], record_bytes: bytes) -> dict[str "the URL is serving a different one." ) - record: dict[str, Any] - try: - record = json.loads(record_bytes) - except ValueError as exc: - raise ContentMarkingError(f"record at {url} is not JSON: {exc}") from exc + record = _loads(record_bytes, f"record at {url}") if not isinstance(record, dict): raise ContentMarkingError( f"the record at {url} must decode to a JSON object, got " diff --git a/src/agentrust_trace/provenance.py b/src/agentrust_trace/provenance.py index 418ddad0..a8f0670a 100644 --- a/src/agentrust_trace/provenance.py +++ b/src/agentrust_trace/provenance.py @@ -27,6 +27,7 @@ from agentrust_trace.sign import ( JCS_SAFE_INTEGER, RevocationStore, + UnanchorableValue, _b64url_decode, _canonical_bytes, _check_not_revoked, @@ -150,7 +151,35 @@ def tool_catalog_hash(tools: list[dict[str, Any]], *, format: str = FORMAT) -> s untrusted party that function exists to check -- so a malformed entry here is not a hypothetical, it is the shape a live attack, or simply a broken server, takes. + + Also raises :class:`ProvenanceError` for a catalog with no anchor form: a + non-integer number or an integer outside the safe range anywhere in an + input schema, or nesting too deep to walk. The catalog cannot be hashed, so + it cannot be matched. """ + try: + return _tool_catalog_hash(tools, format) + except UnanchorableValue as exc: + # `anchor_bytes` refuses by name, but with its own type, and this function + # documents ProvenanceError. The tools are whatever the server returned, so + # a `maximum: 1.5` in one input schema escaped every caller written against + # that contract, `check_tool_catalog` included. The raised type is both, so + # a caller already catching UnanchorableValue here still does. + raise _UnanchorableCatalog( + f"the offered tool catalog has no anchor form, so it cannot be hashed: {exc}" + ) from exc + except RecursionError: + # The sort key's str() and the anchor walk are both recursive. + raise _UnanchorableCatalog( + "the offered tool catalog nests too deeply to hash" + ) from None + + +class _UnanchorableCatalog(ProvenanceError, UnanchorableValue): + """A tool catalog with no anchor form: this module's refusal, and the anchor one.""" + + +def _tool_catalog_hash(tools: list[dict[str, Any]], format: str) -> str: _check_format(format) if not isinstance(tools, list): raise ProvenanceError(f"tools must be a list, got {type(tools).__name__}") diff --git a/src/agentrust_trace/sign.py b/src/agentrust_trace/sign.py index 20fbb6df..d9163527 100644 --- a/src/agentrust_trace/sign.py +++ b/src/agentrust_trace/sign.py @@ -278,8 +278,23 @@ def _canonical_bytes(d: dict[str, Any]) -> bytes: ``json.dumps(sort_keys=True)`` diverges from JCS for non-ASCII strings and for IEEE 754 number formatting, which would break cross-implementation verification, so a conformant library is used instead. + + Raises ``rfc8785.CanonicalizationError`` for a value JCS has no form for. That + includes nesting deeper than the interpreter stack: ``rfc8785`` walks the value + recursively, and a document a few kilobytes long reaches past the default limit. + The ``RecursionError`` is reported as the library's own refusal so that every + caller already written against ``CanonicalizationError`` refuses it too. """ - return rfc8785.dumps(d) + try: + return rfc8785.dumps(d) + except RecursionError: + raise _NestingTooDeep( + "value nests too deeply to canonicalize; no RFC 8785 form can be computed" + ) from None + + +class _NestingTooDeep(rfc8785.CanonicalizationError): + """A value nests past what the canonicalizer can walk on the interpreter stack.""" # The JCS safe-integer range, RFC 8785 Appendix B note 1, which spec section 3.2.2 @@ -333,11 +348,22 @@ def anchor_bytes(value: Any) -> bytes: and the only symptom is that a proof does not verify. Refusing the value here, by name, is that diagnostic. """ - _reject_unanchorable(value) + try: + _reject_unanchorable(value) + except RecursionError: + # Both walks, this one and `json.dumps`, are recursive. A value nested past + # the interpreter stack has no anchor form this function can produce. + raise UnanchorableValue( + "$ nests too deeply to walk, so it has no anchor form" + ) from None try: return json.dumps( value, sort_keys=True, separators=(",", ":"), ensure_ascii=True ).encode("ascii") + except RecursionError: + raise UnanchorableValue( + "$ nests too deeply to walk, so it has no anchor form" + ) from None except TypeError as exc: # `_reject_unanchorable` names the two cases section 1 puts outside the # profile. A type JSON cannot serialize at all is a third, and it reached @@ -350,19 +376,43 @@ def anchor_bytes(value: Any) -> bytes: ) from exc -def _b64url_decode(value: str, *, field: str) -> bytes: - """Decode an unpadded base64url string, raising ValueError on malformed input. +_B64URL_CHARS = frozenset( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" +) + - Restores the padding the encoder stripped and surfaces ``binascii`` decode - failures as ``ValueError`` so callers see one consistent failure type. +def _b64url_decode(value: str, *, field: str) -> bytes: + """Decode canonical unpadded base64url, raising ValueError on anything else. + + Canonical means the RFC 4648 section 5 alphabet only, no ``=`` padding, and the + unused low bits of a final partial character zero (section 3.5), so each byte + string has exactly one accepted spelling. ``base64.urlsafe_b64decode`` alone is + lenient on all three: it takes ``+`` and ``/``, padding, and characters outside + any alphabet, which it discards, and it ignores the unused bits. One signature + then had many spellings that all verified, and where the spelling is itself + hashed, as ``sig.value`` is in a revocation bundle's digest, one piece of + evidence had several identities. """ if not isinstance(value, str): raise ValueError(f"{field} must be a base64url string") + if not _B64URL_CHARS.issuperset(value): + raise ValueError( + f"{field} is not valid base64url: only A-Z, a-z, 0-9, '-' and '_' are " + "allowed, with no padding" + ) + if len(value) % 4 == 1: + raise ValueError(f"{field} is not valid base64url: no byte string has this length") padded = value + "=" * (-len(value) % 4) try: - return base64.urlsafe_b64decode(padded) + decoded = base64.urlsafe_b64decode(padded) except (binascii.Error, ValueError) as exc: raise ValueError(f"{field} is not valid base64url: {exc}") from exc + if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode("ascii") != value: + raise ValueError( + f"{field} is not canonical base64url: the unused bits of its last character " + "are not zero, so it is a second spelling of other bytes" + ) + return decoded def _check_seconds( diff --git a/src/agentrust_trace/validate.py b/src/agentrust_trace/validate.py index 238a2935..3bb27beb 100644 --- a/src/agentrust_trace/validate.py +++ b/src/agentrust_trace/validate.py @@ -101,15 +101,37 @@ def profiles_with_schema() -> frozenset[str]: SCHEMA: dict[str, Any] = copy.deepcopy(_schema()) +def _too_deep() -> jsonschema.ValidationError: + # `cnf.jwk` admits extra members of any canonicalizable shape, recursively, and + # jsonschema walks them recursively. A few hundred nested arrays, well under a + # kilobyte, exhaust the interpreter stack, and the RecursionError escaped every + # caller written against ValidationError, `sign.verify_record` included. A + # record the validator cannot walk has not been shown to conform, so it is a + # violation, reported as one. + return jsonschema.ValidationError( + "record nests too deeply to validate against the schema" + ) + + def validate_json(record: dict[str, Any]) -> None: """Validate *record* against the canonical TRACE v0.2 JSON Schema. - Raises :class:`jsonschema.ValidationError` on the first violation found. + Raises :class:`jsonschema.ValidationError` on the first violation found, + including nesting too deep for the validator to walk. Use :func:`iter_errors` for all violations. """ - _validator().validate(record) + try: + _validator().validate(record) + except RecursionError: + raise _too_deep() from None def iter_errors(record: dict[str, Any]) -> list[jsonschema.exceptions.ValidationError]: - """Return all JSON Schema violations for *record* (empty list if valid).""" - return list(_validator().iter_errors(record)) + """Return all JSON Schema violations for *record* (empty list if valid). + + Nesting too deep for the validator to walk is returned as the single violation. + """ + try: + return list(_validator().iter_errors(record)) + except RecursionError: + return [_too_deep()] diff --git a/tests/test_signature_spelling_is_canonical.py b/tests/test_signature_spelling_is_canonical.py new file mode 100644 index 00000000..7a809b2b --- /dev/null +++ b/tests/test_signature_spelling_is_canonical.py @@ -0,0 +1,197 @@ +"""One signature, one spelling: the verifiers decode base64url strictly. + +``sign._b64url_decode`` used ``base64.urlsafe_b64decode``, which is lenient: it +accepts the standard alphabet's ``+`` and ``/``, trailing ``=`` padding, and +characters outside any alphabet (discarded without a word), and it ignores the +unused low bits of the final character. So one set of signature bytes had many +spellings that all verified. Wherever a spelling is itself signed or digested, +that is malleability: a revocation bundle's ``bundle_digest`` covers +``sig.value``, so two spellings of one bundle produced two different digests +for the same evidence, each reported as ``verified``. + +The decoder now accepts only canonical unpadded base64url (RFC 4648 section 5 +alphabet, no padding, no other characters, unused bits zero), for every field +it reads: the Trust Record signature, a JWK ``x``, the provenance signature, +the PIC/TRACE bridge signature and the bundle signature. + +The Trust Record's own ``signature`` pattern and the requirement that its +unused bits be zero are the normative half of this, proposed separately in +#401 and not changed here. This file tests the reference decoder. +""" + +from __future__ import annotations + +import base64 +import time + +import pytest + +from agentrust_trace import intent_bridge, provenance +from agentrust_trace.revocation import check_bundle +from agentrust_trace.sign import ( + TRACE_PROFILE_V0_2, + _b64url_decode, + _canonical_bytes, + generate_key, + jwk_thumbprint, + key_to_jwk, + sign_record, + verify_record, +) + + +def _respell_unused_bits(value: str) -> str: + """Same decoded bytes, different final character: set the unused low bits.""" + alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" + unused = {2: 4, 3: 2}[len(value) % 4] + last = alphabet.index(value[-1]) + return value[:-1] + alphabet[last | ((1 << unused) - 1)] + + +def _to_standard_alphabet(value: str) -> str: + return value.replace("-", "+").replace("_", "/") + + +# --- the decoder ------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "spelling", + [ + "AAE=", # padding + "AA E", # a character outside the alphabet + "AA.E", + "AAE\n", + "+/8", # the standard alphabet + "AAF", # nonzero unused bits (canonical form is AAE) + "A", # a length no byte string encodes to + "AAE", # non-ASCII lookalikes + ], +) +def test_the_decoder_refuses_every_non_canonical_spelling(spelling): + with pytest.raises(ValueError, match="base64url"): + _b64url_decode(spelling, field="f") + + +@pytest.mark.parametrize("raw", [b"", b"\x00", b"\x00\x01", b"\xff\xfe\xfd", bytes(range(64))]) +def test_the_decoder_round_trips_every_canonical_spelling(raw): + spelled = base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + assert _b64url_decode(spelled, field="f") == raw + + +# --- each verifier that decodes a signature ------------------------------------------ + + +def _trust_record(key): + return sign_record( + { + "eat_profile": TRACE_PROFILE_V0_2, + "iat": int(time.time()), + "subject": "spiffe://acme.example/agent/a", + "model": {"provider": "acme", "model_id": "m-1"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "0" * 64}, + "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"}, + }, + key, + ) + + +def test_trust_record_signature_with_nonzero_unused_bits_is_refused(): + key = generate_key() + record = _trust_record(key) + verify_record(record, key_to_jwk(key)) + record["signature"] = _respell_unused_bits(record["signature"]) + with pytest.raises(ValueError, match="canonical"): + verify_record(record, key_to_jwk(key)) + + +@pytest.mark.parametrize("respell", [_respell_unused_bits, _to_standard_alphabet, + lambda s: s + "==", lambda s: s[:10] + "\n" + s[10:]]) +def test_provenance_signature_respellings_are_refused(respell): + key = generate_key() + record = provenance.sign_record( + provenance.build_record( + kind="publisher-asserted", + publisher="did:web:acme.example", + tools=[{"name": "t", "description": "d", "input_schema": {}}], + artifact={"package": "p", "digest": "sha256:" + "a" * 64}, + ), + key, + ) + provenance.verify_record(record, key_to_jwk(key)) + respelled = respell(record["signature"]) + if respelled == record["signature"]: + pytest.skip("this signature has no - or _ to respell") + record["signature"] = respelled + with pytest.raises(provenance.ProvenanceError): + provenance.verify_record(record, key_to_jwk(key)) + + +def test_bridge_signature_with_nonzero_unused_bits_is_refused(): + key = generate_key() + tool_call = {"name": "t", "arguments": {}} + declaration = {"impact": "i"} + authorization = { + "authorization_id": "a-1", + "decision": "allow", + "authorizer": "policy", + "authorizer_key_id": "k-1", + "authorized_at": 100, + "expires_at": 200, + "scope": {"tools": ["t"], "impacts": ["i"]}, + "pic": { + "profile": "PIC-CJSON/1.0", + "intent_digest": "sha256:" + "1" * 64, + "args_digest": "sha256:" + "2" * 64, + }, + "declaration_digest": intent_bridge.digest_jcs(declaration), + "tool_call_digest": intent_bridge.digest_jcs(tool_call), + "transcript_required": False, + } + bridge = intent_bridge.sign_bridge(authorization, key) + kwargs = { + "declaration": declaration, + "pic_intent_digest": "sha256:" + "1" * 64, + "pic_args_digest": "sha256:" + "2" * 64, + "tool_call": tool_call, + "now": 150, + } + trusted = {**key_to_jwk(key), "kid": "k-1"} + intent_bridge.verify_bridge(bridge, trusted, **kwargs) + bridge["signature"] = _respell_unused_bits(bridge["signature"]) + with pytest.raises(intent_bridge.IntentBridgeError, match="base64url"): + intent_bridge.verify_bridge(bridge, trusted, **kwargs) + + +def test_a_respelled_bundle_signature_is_not_a_second_verified_bundle(): + """``bundle_digest`` covers ``sig.value``. Two spellings of one signature used to + give one bundle two identities, both ``verified``.""" + bundle_key = generate_key() + bundle_jwk = key_to_jwk(bundle_key) + now = 1_785_000_000 + bundle = { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": now - 60, + "valid_until": now + 3600, + "statements": [], + "bundle_key_id": jwk_thumbprint(bundle_jwk), + } + value = base64.urlsafe_b64encode(bundle_key.sign(_canonical_bytes(bundle))) + bundle["sig"] = {"alg": "ed25519", "value": value.rstrip(b"=").decode()} + kwargs = { + "trusted_key_identifiers": ["record-key"], + "trusted_bundle_keys": [bundle_jwk], + "now": now, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + } + assert check_bundle(bundle, **kwargs).outcome == "verified" + for respelled in (_respell_unused_bits(bundle["sig"]["value"]), + bundle["sig"]["value"] + "\n"): + altered = {**bundle, "sig": {"alg": "ed25519", "value": respelled}} + check = check_bundle(altered, **kwargs) + assert check.outcome == "unverified_for_revocation", respelled diff --git a/tests/test_untrusted_input_stays_on_the_documented_error.py b/tests/test_untrusted_input_stays_on_the_documented_error.py new file mode 100644 index 00000000..59b1a827 --- /dev/null +++ b/tests/test_untrusted_input_stays_on_the_documented_error.py @@ -0,0 +1,270 @@ +"""Inputs the ClusterFuzzLite targets found escaping the documented error type. + +Each case is a document a verifier receives from somebody else. Every one was +refused before this file existed, so none of them was accepted, but each was +refused with an exception its module does not document: ``RecursionError`` +from nesting a few hundred levels deep, a bare ``ValueError`` from Python's +integer-string limit, and ``UnanchorableValue`` from the anchor canonicalization +under a function documented to raise ``ProvenanceError``. A caller written +against the documented type does not catch any of them, so a single small +document took down the process that was checking it. + +The reproducers are the smallest shapes the fuzz targets in ``.clusterfuzzlite/`` +reported, rebuilt by hand so they do not depend on a corpus file. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import time + +import jsonschema +import pytest +from cryptography.exceptions import InvalidSignature + +from agentrust_trace import content_marking, intent_bridge, provenance, validate +from agentrust_trace.sign import ( + TRACE_PROFILE_V0_2, + UnanchorableValue, + anchor_bytes, + generate_key, + key_to_jwk, + sign_record, + verify_record, +) + +#: Deep enough to exhaust the default interpreter stack in every recursive walker +#: involved (jsonschema needs a few hundred levels, rfc8785 about a thousand). +DEPTH = 5000 + + +def _nested(depth: int = DEPTH) -> list: + value: list = [] + for _ in range(depth): + value = [value] + return value + + +def _signed_record(key): + record = { + "eat_profile": TRACE_PROFILE_V0_2, + "iat": int(time.time()), + "subject": "spiffe://acme.example/agent/a", + "model": {"provider": "acme", "model_id": "m-1"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "0" * 64}, + "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"}, + } + return sign_record(record, key) + + +# --- sign.verify_record / validate -------------------------------------------------- + + +@pytest.mark.parametrize("depth", [300, DEPTH]) +def test_verify_record_refuses_a_deeply_nested_jwk_member_with_value_error(depth): + """``cnf.jwk`` admits extra members of any canonicalizable shape, recursively. + Three hundred nested arrays, about six hundred bytes, exhausted jsonschema's + stack and ``RecursionError`` escaped ``verify_record``.""" + key = generate_key() + record = _signed_record(key) + verify_record(record, key_to_jwk(key)) + record["cnf"]["jwk"]["ext"] = _nested(depth) + with pytest.raises(ValueError, match="nest"): + verify_record(record, key_to_jwk(key)) + + +def test_verify_record_still_reports_a_shallow_extra_member_as_a_bad_signature(): + """The guard is for nesting the validator cannot walk, not for nesting.""" + key = generate_key() + record = _signed_record(key) + record["cnf"]["jwk"]["ext"] = _nested(20) + with pytest.raises(InvalidSignature): + verify_record(record, key_to_jwk(key)) + + +def test_validate_json_and_iter_errors_report_nesting_as_a_schema_violation(): + record = _signed_record(generate_key()) + record["cnf"]["jwk"]["ext"] = _nested() + with pytest.raises(jsonschema.ValidationError, match="nest"): + validate.validate_json(record) + errors = validate.iter_errors(record) + assert len(errors) == 1 and "nest" in errors[0].message + + +def test_anchor_bytes_refuses_nesting_it_cannot_walk_by_name(): + with pytest.raises(UnanchorableValue, match="nest"): + anchor_bytes({"a": _nested()}) + + +# --- provenance ------------------------------------------------------------------- + + +def _tools(input_schema): + return [{"name": "search", "description": "d", "input_schema": input_schema}] + + +@pytest.mark.parametrize( + "input_schema", + [ + {"type": "number", "maximum": 1.5}, + {"type": "integer", "maximum": 2**64}, + {"type": "array", "items": _nested()}, + ], + ids=["float", "unsafe-integer", "deep"], +) +def test_check_tool_catalog_refuses_an_unhashable_server_catalog_with_provenance_error( + input_schema, +): + """The tools are what a live server returned. A JSON Schema ``maximum`` of 1.5 + is ordinary content, and ``UnanchorableValue`` escaped a function documented + to raise ``ProvenanceError``.""" + key = generate_key() + record = provenance.sign_record( + provenance.build_record( + kind="publisher-asserted", + publisher="did:web:acme.example", + tools=_tools({"type": "object"}), + artifact={"package": "p", "digest": "sha256:" + "a" * 64}, + ), + key, + ) + with pytest.raises(provenance.ProvenanceError): + provenance.check_tool_catalog(record, _tools(input_schema)) + with pytest.raises(provenance.ProvenanceError) as caught: + provenance.tool_catalog_hash(_tools(input_schema)) + # Still the anchor layer's refusal too, which tests/test_safe_integer_range.py + # and any caller written before this change catch. + assert isinstance(caught.value, UnanchorableValue) + + +def test_provenance_verify_record_refuses_a_deeply_nested_member_with_provenance_error(): + """``identity.artifact`` is checked for the members it names and nothing else, so + an extra member reaches the RFC 8785 walk and exhausted its stack.""" + key = generate_key() + record = provenance.sign_record( + provenance.build_record( + kind="publisher-asserted", + publisher="did:web:acme.example", + tools=_tools({"type": "object"}), + artifact={"package": "p", "digest": "sha256:" + "a" * 64}, + ), + key, + ) + record["identity"]["artifact"]["extra"] = _nested() + with pytest.raises(provenance.ProvenanceError, match="nest"): + provenance.verify_record(record, key_to_jwk(key)) + + +# --- intent bridge ---------------------------------------------------------------- + + +def test_verify_bridge_refuses_a_deeply_nested_tool_call_with_intent_bridge_error(): + """``tool_call`` is the execution evidence, digested with RFC 8785. Nesting past + the interpreter stack escaped as ``RecursionError``.""" + key = generate_key() + tool_call = {"name": "send_invoice", "arguments": {}} + declaration = {"impact": "external-side-effect"} + authorization = { + "authorization_id": "a-1", + "decision": "allow", + "authorizer": "policy", + "authorizer_key_id": "k-1", + "authorized_at": 100, + "expires_at": 200, + "scope": {"tools": ["send_invoice"], "impacts": ["external-side-effect"]}, + "pic": { + "profile": "PIC-CJSON/1.0", + "intent_digest": "sha256:" + "1" * 64, + "args_digest": "sha256:" + "2" * 64, + }, + "declaration_digest": intent_bridge.digest_jcs(declaration), + "tool_call_digest": intent_bridge.digest_jcs(tool_call), + "transcript_required": False, + } + bridge = intent_bridge.sign_bridge(authorization, key) + deep_call = {**tool_call, "arguments": {"x": _nested()}} + with pytest.raises(intent_bridge.IntentBridgeError, match="canonical"): + intent_bridge.verify_bridge( + bridge, + {**key_to_jwk(key), "kid": "k-1"}, + declaration=declaration, + pic_intent_digest="sha256:" + "1" * 64, + pic_args_digest="sha256:" + "2" * 64, + tool_call=deep_call, + now=150, + ) + with pytest.raises(intent_bridge.IntentBridgeError): + intent_bridge.digest_jcs({"x": _nested()}) + + +# --- content marking -------------------------------------------------------------- + + +def _record_bytes() -> bytes: + return json.dumps( + {"eat_profile": TRACE_PROFILE_V0_2, "subject": "spiffe://acme.example/agent/a"} + ).encode() + + +def test_verify_assertion_refuses_a_port_past_the_integer_string_limit(): + """RFC 3986 puts no length on a port. ``int()`` of a 4,400 digit string raised + Python's own ``ValueError`` about its integer-string limit instead of the + ``ContentMarkingError`` every other malformed URL gets.""" + assertion = content_marking.build_assertion( + _record_bytes(), url="https://records.example/r.json" + ) + assertion["data"]["record"]["url"] = "https://records.example:" + "9" * 4400 + "/r.json" + with pytest.raises(content_marking.ContentMarkingError, match="absolute http"): + content_marking.verify_assertion(assertion, _record_bytes()) + + +def test_record_url_still_accepts_a_port_with_leading_zeros(): + url = "https://records.example:00443/r.json" + assertion = content_marking.build_assertion(_record_bytes(), url=url) + assert assertion["data"]["record"]["url"] == url + + +def test_verify_assertion_refuses_deeply_nested_served_bytes_with_content_marking_error(): + """The bytes come from whoever serves the URL, and the hash matching them says + only that they are the bytes the assertion was built over. ``json.loads`` of a + deeply nested document raised ``RecursionError``.""" + deep = b"[" * 200_000 + b"]" * 200_000 + assertion = content_marking.build_assertion( + _record_bytes(), url="https://records.example/r.json" + ) + tampered = copy.deepcopy(assertion) + tampered["data"]["record"]["hash"] = "sha256:" + hashlib.sha256(deep).hexdigest() + with pytest.raises(content_marking.ContentMarkingError, match="not JSON"): + content_marking.verify_assertion(tampered, deep) + with pytest.raises(content_marking.ContentMarkingError, match="not JSON"): + content_marking.build_assertion(deep, url="https://records.example/r.json") + + +@pytest.mark.parametrize( + "served", + [ + b'{"eat_profile": "P", "subject": "spiffe://acme.example/agent/a",' + b' "subject": "spiffe://acme.example/agent/b"}', + b'{"eat_profile": "P", "subject": "spiffe://acme.example/agent/a",' + b' "x": {"k": 1, "k": 2}}', + ], + ids=["top-level", "nested"], +) +def test_served_record_with_a_duplicate_member_is_refused(served): + """``json.loads`` keeps the last of two same-named members and says nothing. + A parser that keeps the first reads a different subject out of the same bytes, + so the binding this module checks would hold for one reader and not another. + RFC 8259 leaves duplicate names undefined; the checker refuses them.""" + with pytest.raises(content_marking.ContentMarkingError, match="duplicate"): + content_marking.build_assertion(served, url="https://records.example/r.json") + assertion = content_marking.build_assertion( + _record_bytes(), url="https://records.example/r.json" + ) + assertion["data"]["record"]["hash"] = "sha256:" + hashlib.sha256(served).hexdigest() + with pytest.raises(content_marking.ContentMarkingError, match="duplicate"): + content_marking.verify_assertion(assertion, served)