diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile new file mode 100644 index 0000000..6283585 --- /dev/null +++ b/.clusterfuzzlite/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +COPY . $SRC/examples +COPY .clusterfuzzlite/build.sh $SRC/build.sh +WORKDIR $SRC/examples diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh new file mode 100644 index 0000000..ebf84ac --- /dev/null +++ b/.clusterfuzzlite/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash -eu +# Build the fuzz targets for ClusterFuzzLite. +# +# The targets import the example modules straight from their directories, the +# way a reader runs them. cryptography is the only third-party dependency, and +# it comes from the same hashed lock the receipts CI job uses. + +cd "$SRC/examples" +pip3 install --no-cache-dir --require-hashes -r requirements/receipts-tests.txt + +# compile_python_fuzzer bundles each target with PyInstaller, which follows +# static imports only. The cryptography stack reaches 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. +PYI_ARGS=( + --collect-submodules=email + --paths="$SRC/examples/embodied-action-receipts" + --paths="$SRC/examples/agentic-commerce-accountability" + --paths="$SRC/examples/industrial-embodied-ai" +) + +for target in "$SRC"/examples/.clusterfuzzlite/fuzz_*.py; do + compile_python_fuzzer "$target" "${PYI_ARGS[@]}" +done + +# Seed each JSON target with the committed fixtures, so the fuzzer starts from +# documents that already get past parsing and, for receipts, past signature +# verification. +zip -j "$OUT/fuzz_receipts_seed_corpus.zip" embodied-action-receipts/fixtures/*.json +zip -j "$OUT/fuzz_purchase_seed_corpus.zip" agentic-commerce-accountability/fixtures/*.json diff --git a/.clusterfuzzlite/fuzz_controller.py b/.clusterfuzzlite/fuzz_controller.py new file mode 100644 index 0000000..75f601d --- /dev/null +++ b/.clusterfuzzlite/fuzz_controller.py @@ -0,0 +1,93 @@ +#!/usr/bin/python3 +"""Fuzz the industrial example's mock safety controller. + +request_motion() receives the motion request an agent sends through the cMCP +gateway, arriving as JSON over HTTP. It must either accept a motion inside the +controller's envelope or raise SafetyRejected. The target mints a genuine state +token most of the time, so inputs get past the HMAC and reach the envelope +checks, then fuzzes the rest of the request. + +Two bugs sat here before the target: a NaN speed compared false against both +bounds and was accepted, and an integer too large for a float raised +OverflowError instead of SafetyRejected. +""" +import math +import sys +from pathlib import Path + +import atheris + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "industrial-embodied-ai")) + +with atheris.instrument_imports(): + from controller import ( + ALLOWED_TARGETS, + MAX_SPEED_MPS, + IndependentSafetyController, + SafetyRejected, + ) + +_TARGETS = sorted(ALLOWED_TARGETS) + + +class _Clock: + value = 1_781_179_200.0 + + def __call__(self) -> float: + return self.value + + +def _speed(fdp: atheris.FuzzedDataProvider) -> object: + kind = fdp.ConsumeIntInRange(0, 6) + if kind == 0: + return fdp.ConsumeRegularFloat() + if kind == 1: + return fdp.ConsumeFloat() # includes NaN and the infinities + if kind == 2: + return fdp.ConsumeInt(fdp.ConsumeIntInRange(1, 256)) + if kind == 3: + return fdp.ConsumeUnicodeNoSurrogates(24) + if kind == 4: + return fdp.ConsumeBool() + if kind == 5: + return None + return [fdp.ConsumeRegularFloat()] + + +def TestOneInput(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + clock = _Clock() + controller = IndependentSafetyController(clock=clock, token_key=b"fuzz-only-controller-key") + token = controller.read_safety_state()["state_token"] + if fdp.ConsumeIntInRange(0, 7) == 0: + token = fdp.ConsumeUnicodeNoSurrogates(256) + clock.value += fdp.ConsumeIntInRange(0, 6000) / 1000 + request: dict[str, object] = {"safety_state_token": token} + if fdp.ConsumeBool(): + request["motion_id"] = fdp.ConsumeUnicodeNoSurrogates(16) + if fdp.ConsumeBool(): + request["target"] = ( + _TARGETS[fdp.ConsumeIntInRange(0, len(_TARGETS) - 1)] + if fdp.ConsumeBool() + else fdp.ConsumeUnicodeNoSurrogates(24) + ) + if fdp.ConsumeIntInRange(0, 9): + request["max_speed_mps"] = _speed(fdp) + + try: + result = controller.request_motion(request) + except SafetyRejected: + return + speed = float(request["max_speed_mps"]) + assert math.isfinite(speed) and 0 <= speed <= MAX_SPEED_MPS, request + assert request["target"] in ALLOWED_TARGETS, request + assert result["controller_decision"] == "accepted", result + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_purchase.py b/.clusterfuzzlite/fuzz_purchase.py new file mode 100644 index 0000000..5fcf971 --- /dev/null +++ b/.clusterfuzzlite/fuzz_purchase.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +"""Fuzz the agentic-commerce purchase verifier. + +verify() decides whether a purchase bundle (authority grant, request, policy +decision, runtime evidence, merchant receipt) hangs together. An empty error +list is an acceptance, so the property checked is the one that matters: when it +accepts, every constraint it claims to check really holds. Malformed shapes may +raise KeyError, TypeError, AttributeError or RecursionError, which the CLI turns +into a non-zero exit; that is a refusal, not an acceptance. + +A NaN spending ceiling, a negative amount and a string allow-list (which turns +membership into a substring test) each used to be accepted. +""" +import json +import sys +from pathlib import Path + +import atheris + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "agentic-commerce-accountability")) + +with atheris.instrument_imports(): + from verify_purchase import digest, verify + +_REFUSALS = (KeyError, TypeError, AttributeError, RecursionError) + + +def _check_acceptance(bundle: dict) -> None: + grant = bundle["authority_grant"] + request = bundle["purchase_request"] + decision = bundle["policy_decision"] + evidence = bundle["runtime_evidence"] + receipt = bundle["purchase_receipt"] + amount, ceiling = request["amount_minor"], grant["max_amount_minor"] + assert type(amount) is int and type(ceiling) is int, (amount, ceiling) + assert 0 < amount <= ceiling, (amount, ceiling) + assert isinstance(grant["allowed_operations"], list) + assert isinstance(grant["allowed_merchants"], list) + assert request["operation"] in grant["allowed_operations"] + assert request["merchant_id"] in grant["allowed_merchants"] + assert request["currency"] == grant["currency"] + assert evidence["runtime_identity"] == grant["delegate"] + assert decision["outcome"] == "allow" + assert decision["request_digest"] == digest(request) + assert decision["authority_digest"] == digest(grant) + assert evidence["policy_decision_digest"] == digest(decision) + assert receipt["request_digest"] == digest(request) + assert receipt["runtime_evidence_digest"] == digest(evidence) + + +def _rebind(bundle: dict) -> None: + """Re-derive every digest link from the fuzzed content. + + A fuzzer cannot find SHA-256 preimages, so without this nearly every + mutation dies on a digest mismatch and the constraint checks behind it are + never reached with a consistent chain. + """ + request, grant = bundle["purchase_request"], bundle["authority_grant"] + decision, evidence = bundle["policy_decision"], bundle["runtime_evidence"] + decision["request_digest"] = digest(request) + decision["authority_digest"] = digest(grant) + evidence["policy_decision_digest"] = digest(decision) + receipt = bundle["purchase_receipt"] + receipt["request_digest"] = digest(request) + receipt["runtime_evidence_digest"] = digest(evidence) + + +def _check(bundle: dict) -> None: + try: + errors = verify(bundle) + except _REFUSALS: + return + assert isinstance(errors, list), errors + if not errors: + _check_acceptance(bundle) + + +def TestOneInput(data: bytes) -> None: + try: + text = data.decode("utf-8") + _check(json.loads(text)) + except (UnicodeDecodeError, ValueError, RecursionError): + return + # The same document again with its digest chain made consistent. + rebound = json.loads(text) + try: + _rebind(rebound) + except _REFUSALS: + return + _check(rebound) + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.clusterfuzzlite/fuzz_receipts.py b/.clusterfuzzlite/fuzz_receipts.py new file mode 100644 index 0000000..f9ff450 --- /dev/null +++ b/.clusterfuzzlite/fuzz_receipts.py @@ -0,0 +1,55 @@ +#!/usr/bin/python3 +"""Fuzz the embodied-action receipt verifier. + +verify_text() reads a fixture an auditor was handed: a TRACE reference, an +action and a chain of controller-signed receipts. Every byte is attacker-chosen +until the Ed25519 check on each receipt passes. + +The property is the documented contract: it always returns a verdict, with +"valid" or "invalid" as the result, and never raises. Before this target +existed a wrong shape escaped as KeyError, TypeError, AttributeError or +binascii.Error, and a signature with stray characters in its base64 still +verified. The seed corpus is the committed fixtures, so mutations start from a +chain that already verifies. +""" +import sys +from pathlib import Path + +import atheris + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "embodied-action-receipts")) + +with atheris.instrument_imports(): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + from verify_receipts import TrustedSigner, b64url_decode, verify_text + +# The public test key from embodied-action-receipts/trusted-keys.json, inlined +# because PyInstaller bundles code, not the data files beside it. +_TRUSTED = { + "robot-cell-7-controller": TrustedSigner( + issuer="spiffe://factory.example/controller/robot-cell-7", + public_key=Ed25519PublicKey.from_public_bytes( + b64url_decode("A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg") + ), + ) +} + + +def TestOneInput(data: bytes) -> None: + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return + result = verify_text(text, _TRUSTED) + assert isinstance(result, dict), result + assert result.get("result") in {"valid", "invalid"}, result + assert isinstance(result.get("receipt_state"), str), result + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/cflite_batch.yml b/.github/workflows/cflite_batch.yml new file mode 100644 index 0000000..5d0634a --- /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. The budget is shared across + # the three targets and leaves the build room inside the job timeout. + 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 0000000..89cc672 --- /dev/null +++ b/.github/workflows/cflite_pr.yml @@ -0,0 +1,45 @@ +name: ClusterFuzzLite PR + +on: + pull_request: + paths: + - 'embodied-action-receipts/**' + - 'agentic-commerce-accountability/*.py' + - 'agentic-commerce-accountability/fixtures/**' + - 'industrial-embodied-ai/controller.py' + - 'requirements/receipts-tests.*' + - '.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 is what keeps + # this inside a PR's time budget. The nightly batch covers the rest. + mode: code-change + sanitizer: address diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2314bd4..7e84462 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,10 +144,12 @@ jobs: with: python-version: "3.11" cache: "pip" - cache-dependency-path: embodied-action-receipts/requirements.txt + cache-dependency-path: requirements/receipts-tests.txt - name: Install dependencies - run: python -m pip install -r requirements.txt + # The job cwd is embodied-action-receipts (defaults above), so the + # lock is one level up. + run: python -m pip install --require-hashes -r ../requirements/receipts-tests.txt - name: Verify embodied-action receipt fixtures run: python -m unittest discover -s tests -v diff --git a/agentic-commerce-accountability/README.md b/agentic-commerce-accountability/README.md index aec0665..8140426 100644 --- a/agentic-commerce-accountability/README.md +++ b/agentic-commerce-accountability/README.md @@ -3,7 +3,7 @@ For released UCP schema validation, authenticated HTTP artifacts, one-purchase authority, and retry/concurrency tests, see the separate [released-UCP verification harness](released_ucp/README.md). The illustrative -example below remains intentionally unchanged in behavior. +example below stays deliberately small. This runnable example asks whether an auditor can connect a completed purchase to the authority the user granted, the exact request evaluated by policy, and @@ -25,8 +25,9 @@ python verify_purchase.py fixtures/overspend-tamper.json ## Security boundary This is a deterministic composition example, not a claim of UCP, AGT, cA2A, or -TRACE conformance. It verifies constraints and cross-artifact bindings. It does -not verify signatures, merchant settlement, hardware quotes, revocation, or a -live transparency-log receipt. Production use should replace each illustrative +TRACE conformance. It verifies constraints, that the runtime named in the +evidence is the grant's delegate, and cross-artifact bindings. It does not +verify signatures, grant expiry, merchant settlement, hardware quotes, +revocation, or a live transparency-log receipt. Production use should replace each illustrative artifact with the corresponding protocol's signed, independently verifiable record. diff --git a/agentic-commerce-accountability/tests/test_verify_purchase.py b/agentic-commerce-accountability/tests/test_verify_purchase.py index 70a51fb..0566bb0 100644 --- a/agentic-commerce-accountability/tests/test_verify_purchase.py +++ b/agentic-commerce-accountability/tests/test_verify_purchase.py @@ -7,7 +7,20 @@ EXAMPLE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(EXAMPLE)) from generate_fixtures import build # noqa: E402 -from verify_purchase import verify # noqa: E402 +from verify_purchase import digest, verify # noqa: E402 + + +def rebind_request(bundle: dict, **changes: object) -> dict: + """Change the request and re-derive every digest that covers it.""" + bundle["purchase_request"].update(changes) + decision = bundle["policy_decision"] + decision["request_digest"] = digest(bundle["purchase_request"]) + evidence = bundle["runtime_evidence"] + evidence["policy_decision_digest"] = digest(decision) + receipt = bundle["purchase_receipt"] + receipt["request_digest"] = digest(bundle["purchase_request"]) + receipt["runtime_evidence_digest"] = digest(evidence) + return bundle class PurchaseVerificationTests(unittest.TestCase): @@ -26,6 +39,49 @@ def test_merchant_substitution_is_rejected(self) -> None: bundle["purchase_request"]["merchant_id"] = "merchant:attacker" self.assertIn("merchant is outside delegated authority", verify(bundle)) + def test_runtime_must_be_the_delegate(self) -> None: + # Every digest link can hold while the evidence names a runtime the + # grant never delegated to. Rebind the chain so only identity differs. + bundle = build() + evidence = bundle["runtime_evidence"] + evidence["runtime_identity"] = "spiffe://attacker.example/agent/other" + bundle["purchase_receipt"]["runtime_evidence_digest"] = digest(evidence) + self.assertEqual( + verify(bundle), ["runtime is not the delegate named in the authority grant"] + ) + + def test_amount_must_be_a_positive_integer(self) -> None: + for amount in (-50_000, 0, 150.5, True, "100"): + with self.subTest(amount=amount): + bundle = rebind_request(build(), amount_minor=amount) + self.assertEqual( + verify(bundle), ["amount is not a positive integer in minor units"] + ) + + def test_ceiling_must_be_an_integer(self) -> None: + # json.loads accepts NaN, and every integer compares false against it. + for ceiling in (float("nan"), float("inf"), 20_000.0, None): + with self.subTest(ceiling=ceiling): + bundle = build() + grant = bundle["authority_grant"] + grant["max_amount_minor"] = ceiling + bundle["policy_decision"]["authority_digest"] = digest(grant) + rebind_request(bundle) + self.assertEqual( + verify(bundle), ["authority grant ceiling is not an integer"] + ) + + def test_allow_list_must_be_a_list(self) -> None: + # "merchant:hotel-example" in "merchant:hotel-example-annex" is True. + bundle = build() + grant = bundle["authority_grant"] + grant["allowed_merchants"] = "merchant:hotel-example-annex" + bundle["policy_decision"]["authority_digest"] = digest(grant) + rebind_request(bundle) + errors = verify(bundle) + self.assertIn("authority grant allow-lists are not lists", errors) + self.assertIn("merchant is outside delegated authority", errors) + def test_committed_fixture_matches_generator(self) -> None: committed = json.loads((EXAMPLE / "fixtures" / "valid-purchase.json").read_text()) self.assertEqual(committed, build()) diff --git a/agentic-commerce-accountability/verify_purchase.py b/agentic-commerce-accountability/verify_purchase.py index a9b6e6b..1eb9792 100644 --- a/agentic-commerce-accountability/verify_purchase.py +++ b/agentic-commerce-accountability/verify_purchase.py @@ -21,14 +21,30 @@ def verify(bundle: dict[str, Any]) -> list[str]: decision = bundle["policy_decision"] evidence = bundle["runtime_evidence"] receipt = bundle["purchase_receipt"] - if request["operation"] not in grant["allowed_operations"]: + # A string here would turn membership into a substring test. + operations = grant["allowed_operations"] + merchants = grant["allowed_merchants"] + if not isinstance(operations, list) or not isinstance(merchants, list): + errors.append("authority grant allow-lists are not lists") + operations, merchants = [], [] + if request["operation"] not in operations: errors.append("operation is outside delegated authority") if request["currency"] != grant["currency"]: errors.append("currency differs from delegated authority") - if request["merchant_id"] not in grant["allowed_merchants"]: + if request["merchant_id"] not in merchants: errors.append("merchant is outside delegated authority") - if request["amount_minor"] > grant["max_amount_minor"]: + amount = request["amount_minor"] + ceiling = grant["max_amount_minor"] + # bool is an int subclass; a negative or zero amount would pass the ceiling, + # and every amount compares false against a NaN ceiling. + if type(amount) is not int or amount <= 0: + errors.append("amount is not a positive integer in minor units") + elif type(ceiling) is not int: + errors.append("authority grant ceiling is not an integer") + elif amount > ceiling: errors.append("amount exceeds delegated authority") + if evidence["runtime_identity"] != grant["delegate"]: + errors.append("runtime is not the delegate named in the authority grant") if decision["request_digest"] != digest(request): errors.append("policy decision is not bound to the purchase request") if decision["authority_digest"] != digest(grant): diff --git a/ards/README.md b/ards/README.md index 37871a8..0f08f25 100644 --- a/ards/README.md +++ b/ards/README.md @@ -10,7 +10,7 @@ This directory shows how agentrust-io.com participates in the [Agentic Resource ## The integration point -ARDS `trustManifest.attestations` accepts any attestation type. TRACE-v0.2 is a **runtime governance attestation** — it proves an agent ran under a specific Cedar policy in a verified TEE, with a signed tool-call transcript, in one independently verifiable artifact. +ARDS `trustManifest.attestations` accepts any attestation type. TRACE-v0.2 is a **runtime governance attestation**: it binds the Cedar policy an agent ran under and a signed tool-call transcript into one independently verifiable artifact. Only a record whose `trace.runtime` carries hardware attestation evidence also shows the run happened in a verified TEE; a `software-only` dev-mode record makes no TEE claim. ```json { diff --git a/ca2a-delegation/README.md b/ca2a-delegation/README.md index b26d009..ee6328b 100644 --- a/ca2a-delegation/README.md +++ b/ca2a-delegation/README.md @@ -59,7 +59,7 @@ Now the bureau connector tries to grant itself write:risk-report ... reason: hop 2 scope exceeds parent grant ``` -The demo writes both chains to `chain-output/`. Verify either from the CLI, which checks the same four invariants: +The demo writes both chains to `chain-output/`. Check either from the CLI. `ca2a verify-chain` in ca2a-runtime 0.2.0 takes no trusted root, so it checks invariants 1 to 4 below but not the root: ```bash ca2a verify-chain --chain chain-output/credit-delegation-chain.json @@ -75,12 +75,13 @@ ca2a verify-chain --chain chain-output/escalation-attempt.json `verify_chain` fails on the first violation: +0. **Trusted root**: the first hop's issuer is in `trusted_root_issuers` (`UNTRUSTED_DELEGATION_ROOT` otherwise). The demo passes the credit platform's key. Leave the argument out and `verify_chain` is structural only: a chain an attacker signs from a key of their own passes 1 to 4. 1. **Signature** on every hop against the issuer's Ed25519 public key. 2. **Continuity**: each hop's issuer is the previous hop's subject. 3. **Attenuation**: each hop's scope is a subset of its parent's scope (`SCOPE_ESCALATION` otherwise). 4. **Anti-replay / structure**: unique `credential_id`s, `parent_id` links to the previous hop, depth increments by one and stays within `max_depth`. -Because the write authority is withheld at the first delegation, attenuation alone guarantees that no descendant, however many hops down, can write the risk report. That is separation of duties enforced by the credential, not by convention. +Because the write authority is withheld at the first delegation, attenuation from a trusted root guarantees that no descendant, however many hops down, can write the risk report. That is separation of duties enforced by the credential, not by convention. --- diff --git a/ca2a-delegation/delegation_agent.py b/ca2a-delegation/delegation_agent.py index 7ad5182..152c574 100644 --- a/ca2a-delegation/delegation_agent.py +++ b/ca2a-delegation/delegation_agent.py @@ -76,7 +76,7 @@ def main() -> None: print("The write:risk-report authority stays with the lead agent by construction:") print("it is not in any delegated child's scope, so no descendant can regain it.") print() - print("Verify either chain from the CLI (same four invariants):") + print("Check either chain's structure from the CLI (it takes no trusted root):") print(" ca2a verify-chain --chain chain-output/credit-delegation-chain.json") diff --git a/ca2a-delegation/delegation_scenario.py b/ca2a-delegation/delegation_scenario.py index 951f86a..6797c10 100644 --- a/ca2a-delegation/delegation_scenario.py +++ b/ca2a-delegation/delegation_scenario.py @@ -40,6 +40,11 @@ frozenset({CAP_READ_BUREAU}), ] +# The credit platform's root key. A verifier trusts this issuer, and only this +# issuer, to start a chain. In a deployment it is configured, not generated; +# here it is minted once per process so the demo stays offline. +_PLATFORM_ROOT_PRIV, PLATFORM_ROOT = new_keypair() + HOP_LABELS = [ "credit-platform -> lead-credit-agent", "lead-credit-agent -> screening-sub-agent", @@ -53,7 +58,7 @@ def _build(scopes: list[frozenset[str]]) -> list[DelegationCredential]: Continuity is preserved: each hop's issuer is the previous hop's subject. """ chain: list[DelegationCredential] = [] - priv, pub = new_keypair() + priv, pub = _PLATFORM_ROOT_PRIV, PLATFORM_ROOT parent_id: str | None = None for depth, scope in enumerate(scopes): next_priv, next_pub = new_keypair() @@ -91,5 +96,10 @@ def as_chain_document(chain: list[DelegationCredential]) -> dict[str, Any]: def verify(chain: list[DelegationCredential]) -> None: - """Raise a ca2a_runtime error on the first invariant violation.""" - verify_chain(chain) + """Raise a ca2a_runtime error on the first invariant violation. + + trusted_root_issuers is what makes this an authorization check. Without it + verify_chain is structural only, and a self-consistent chain an attacker + signs from a key of their own passes every other invariant. + """ + verify_chain(chain, trusted_root_issuers={PLATFORM_ROOT}) diff --git a/ca2a-delegation/tests/test_delegation_scenario.py b/ca2a-delegation/tests/test_delegation_scenario.py index 0f2631d..c7d0f4b 100644 --- a/ca2a-delegation/tests/test_delegation_scenario.py +++ b/ca2a-delegation/tests/test_delegation_scenario.py @@ -8,7 +8,9 @@ sys.path.insert(0, str(EXAMPLE_DIR)) import delegation_scenario as scenario # noqa: E402 +from ca2a_runtime.delegation import DelegationCredential, new_keypair # noqa: E402 from ca2a_runtime.delegation.credential import ScopeEscalation # noqa: E402 +from ca2a_runtime.errors import UntrustedDelegationRoot # noqa: E402 class DelegationScenarioTests(unittest.TestCase): @@ -40,6 +42,22 @@ def test_escalation_attempt_is_rejected(self) -> None: scenario.verify(chain) self.assertEqual(getattr(ctx.exception, "code", None), "SCOPE_ESCALATION") + def test_chain_from_an_untrusted_root_is_rejected(self) -> None: + # Signatures, continuity and attenuation all hold on a chain an + # attacker mints from a key of their own. Only the root check stops it. + attacker_priv, attacker_pub = new_keypair() + _, agent_pub = new_keypair() + forged = DelegationCredential( + credential_id="credit-cred-0", + issuer=attacker_pub, + subject=agent_pub, + scope=frozenset({scenario.CAP_WRITE_REPORT}), + depth=0, + parent_id=None, + ).sign(attacker_priv) + with self.assertRaises(UntrustedDelegationRoot): + scenario.verify([forged]) + def test_chain_document_shape(self) -> None: doc = scenario.as_chain_document(scenario.build_credit_chain()) self.assertIn("chain", doc) diff --git a/embodied-action-receipts/tests/test_verify_receipts.py b/embodied-action-receipts/tests/test_verify_receipts.py index 21bb6d9..2dc8468 100644 --- a/embodied-action-receipts/tests/test_verify_receipts.py +++ b/embodied-action-receipts/tests/test_verify_receipts.py @@ -2,6 +2,7 @@ import json import sys +import tempfile import unittest from copy import deepcopy from pathlib import Path @@ -62,6 +63,56 @@ def test_receipt_sequences_must_be_contiguous(self) -> None: path.unlink(missing_ok=True) self.assertEqual(result, {"result": "invalid", "receipt_state": "sequence_mismatch"}) + def verify_mutated(self, mutate) -> dict: + fixture = json.loads((ROOT / "fixtures" / "valid-chain.json").read_text()) + mutate(fixture) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "fixture.json" + path.write_text(json.dumps(fixture)) + return verify_fixture(path) + + def test_signature_encoding_is_not_malleable(self) -> None: + # A lenient base64 decoder drops characters outside the alphabet and + # ignores surplus padding, so one signature had many accepted spellings. + def junk(fixture: dict) -> None: + sig = fixture["receipts"][-1]["signature"] + fixture["receipts"][-1]["signature"] = sig[:12] + "!!!!" + sig[12:] + + def padded(fixture: dict) -> None: + fixture["receipts"][-1]["signature"] += "====" + + for mutate in (junk, padded): + with self.subTest(mutation=mutate.__name__): + self.assertEqual( + self.verify_mutated(mutate), + {"result": "invalid", "receipt_state": "signature_format"}, + ) + + def test_malformed_fixture_is_invalid_not_a_crash(self) -> None: + mutations = { + "receipts_not_a_list": lambda f: f.__setitem__("receipts", "x"), + "missing_sequence": lambda f: f["receipts"][0].pop("sequence"), + "signature_not_a_string": lambda f: f["receipts"][0].__setitem__("signature", 5), + "mixed_sequence_types": lambda f: f["receipts"][0].__setitem__("sequence", "1"), + "action_not_an_object": lambda f: f.__setitem__("action", []), + "receipt_not_an_object": lambda f: f["receipts"].append(7), + } + for name, mutate in mutations.items(): + with self.subTest(mutation=name): + self.assertEqual( + self.verify_mutated(mutate), + {"result": "invalid", "receipt_state": "malformed"}, + ) + + def test_truncated_signature_is_a_format_error(self) -> None: + def truncate(fixture: dict) -> None: + fixture["receipts"][0]["signature"] = "ed25519:" + "A" * 5 + + self.assertEqual( + self.verify_mutated(truncate), + {"result": "invalid", "receipt_state": "signature_format"}, + ) + if __name__ == "__main__": unittest.main() diff --git a/embodied-action-receipts/verify_receipts.py b/embodied-action-receipts/verify_receipts.py index ea6adb9..0ced0e8 100644 --- a/embodied-action-receipts/verify_receipts.py +++ b/embodied-action-receipts/verify_receipts.py @@ -5,6 +5,7 @@ import base64 import hashlib import json +import re import sys from dataclasses import dataclass from pathlib import Path @@ -14,6 +15,8 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey ROOT = Path(__file__).parent +MALFORMED = {"result": "invalid", "receipt_state": "malformed"} +_B64URL = re.compile(r"[A-Za-z0-9_-]*") @dataclass(frozen=True) @@ -27,8 +30,13 @@ def canonical_bytes(value: Any) -> bytes: def b64url_decode(value: str) -> bytes: - padding = "=" * (-len(value) % 4) - return base64.urlsafe_b64decode(value + padding) + """Strict unpadded base64url: one accepted spelling per byte string.""" + if not isinstance(value, str) or _B64URL.fullmatch(value) is None: + raise ValueError("not unpadded base64url") + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode() != value: + raise ValueError("non-canonical base64url") + return decoded def sha256_ref(value: Any) -> str: @@ -68,10 +76,23 @@ def load_trusted_keys(path: Path = ROOT / "trusted-keys.json") -> dict[str, Trus def verify_fixture( path: Path, trusted_keys: dict[str, TrustedSigner] | None = None ) -> dict[str, Any]: - fixture = json.loads(path.read_text()) if trusted_keys is None: trusted_keys = load_trusted_keys() + return verify_text(path.read_text(), trusted_keys) + +def verify_text(text: str, trusted_keys: dict[str, TrustedSigner]) -> dict[str, Any]: + """Verify fixture JSON. Always returns a verdict; never raises on bad input.""" + try: + return verify_document(json.loads(text), trusted_keys) + except (KeyError, TypeError, AttributeError, ValueError, RecursionError): + # The fixture is untrusted input: a wrong shape is a verdict, not a crash. + return dict(MALFORMED) + + +def verify_document( + fixture: dict[str, Any], trusted_keys: dict[str, TrustedSigner] +) -> dict[str, Any]: trace = fixture["trace"] action = fixture["action"] receipts = fixture.get("receipts", []) @@ -112,9 +133,13 @@ def verify_fixture( if not signature.startswith("ed25519:"): return {"result": "invalid", "receipt_state": "signature_format"} + try: + signature_bytes = b64url_decode(signature.removeprefix("ed25519:")) + except ValueError: + return {"result": "invalid", "receipt_state": "signature_format"} try: signer.public_key.verify( - b64url_decode(signature.removeprefix("ed25519:")), + signature_bytes, canonical_bytes(receipt_preimage(receipt)), ) except InvalidSignature: diff --git a/financial-services/README.md b/financial-services/README.md index c9515e3..bc52c50 100644 --- a/financial-services/README.md +++ b/financial-services/README.md @@ -18,7 +18,7 @@ The rules in `policy/allow.cedar` encode the bank's controls directly. Each forb The agent screens the client, pulls a bureau report, aggregates group exposure and runs the PD/LGD model, then passes the outcome of those steps (CDD status, IFRS 9 stage, concentration breach, facility amount) into the write call. The Cedar guardrails act on those values, so the deny reflects the actual credit decision. **4. Attestation-gated data access (DORA Art. 9)** -A Cedar rule forbids confidential (`mnpi`) tools when no attestation evidence is present: confidential financial data only flows through attested runtimes. +A Cedar rule forbids confidential (`mnpi`) tools when the runtime reports no attestation platform at all (`attestation_platform == "unknown"`). A software-only dev-mode runtime reports `software-only` and passes this gate, which is how the demo runs without a TEE. To require hardware attestation for `mnpi` data, forbid unless `attestation_platform` is one of the hardware platforms you accept. --- diff --git a/healthcare/README.md b/healthcare/README.md index 9c896bd..6a9811f 100644 --- a/healthcare/README.md +++ b/healthcare/README.md @@ -15,7 +15,7 @@ The Cedar policy blocks any treatment plan write where `patient_risk_category == The agent runs `ehr.drug_interaction_check` against the patient's current medications and documented allergies, then passes `has_severe_contraindication` into the write call. A Cedar rule blocks the write when a severe contraindication is present, so the guardrail acts on the actual interaction result rather than on a static flag. **3. HIPAA PHI protection at the tool boundary** -All four tools are classified `compliance_domain: hipaa_phi` in the attested catalog. A Cedar rule forbids PHI tools when no attestation evidence is present, enforcing "PHI only flows through attested runtimes" at the policy layer. +All four tools are classified `compliance_domain: hipaa_phi` in the attested catalog. A Cedar rule forbids PHI tools when the runtime reports no attestation platform at all (`attestation_platform == "unknown"`). A software-only dev-mode runtime reports `software-only` and passes this gate, which is how the demo runs without a TEE. To require hardware attestation for PHI, forbid unless `attestation_platform` is one of the hardware platforms you accept. **4. Cryptographic proof of the tool call sequence** Every call is recorded in a hash-chained audit log persisted to SQLite. Closing the session seals the chain into a signed `RuntimeClaim` (the TRACE Trust Record): which tools ran, in what order, what was denied - verifiable without trusting the agent process. diff --git a/industrial-embodied-ai/controller.py b/industrial-embodied-ai/controller.py index 6c8413c..94c43fc 100644 --- a/industrial-embodied-ai/controller.py +++ b/industrial-embodied-ai/controller.py @@ -10,6 +10,7 @@ import hashlib import hmac import json +import math import secrets import time from typing import Any, Callable @@ -147,14 +148,15 @@ def request_motion(self, request: dict[str, Any]) -> dict[str, Any]: target = request.get("target") try: speed = float(request["max_speed_mps"]) - except (KeyError, TypeError, ValueError) as exc: + except (KeyError, TypeError, ValueError, OverflowError) as exc: raise SafetyRejected("invalid_motion_request") from exc if not isinstance(motion_id, str) or not motion_id: raise SafetyRejected("invalid_motion_request") if not isinstance(target, str) or target not in ALLOWED_TARGETS: raise SafetyRejected("target_outside_approved_zone") - if speed < 0 or speed > MAX_SPEED_MPS: + # NaN fails both comparisons, so it must be refused explicitly. + if not math.isfinite(speed) or speed < 0 or speed > MAX_SPEED_MPS: raise SafetyRejected("speed_exceeds_controller_limit") request_for_hash = { diff --git a/industrial-embodied-ai/tests/test_controller.py b/industrial-embodied-ai/tests/test_controller.py index 5234b09..10f1d8d 100644 --- a/industrial-embodied-ai/tests/test_controller.py +++ b/industrial-embodied-ai/tests/test_controller.py @@ -92,6 +92,29 @@ def test_speed_limit_is_controller_authoritative(self) -> None: self.request(snapshot["state_token"], max_speed_mps=0.8) ) + def test_non_finite_speed_is_rejected(self) -> None: + # NaN compares false against both bounds, so a range check alone lets + # it through. json.loads accepts a bare NaN, so it can arrive by wire. + for speed in (float("nan"), "nan", "NaN", float("inf"), "-inf"): + with self.subTest(speed=speed): + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex( + SafetyRejected, + "speed_exceeds_controller_limit", + ): + self.controller.request_motion( + self.request(snapshot["state_token"], max_speed_mps=speed) + ) + + def test_speed_too_large_for_float_is_rejected(self) -> None: + # float() of an integer past the double range raises OverflowError, + # which is not a SafetyRejected and would escape the controller. + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex(SafetyRejected, "invalid_motion_request"): + self.controller.request_motion( + self.request(snapshot["state_token"], max_speed_mps=10**400) + ) + def test_target_must_be_in_approved_zone(self) -> None: snapshot = self.controller.read_safety_state() with self.assertRaisesRegex( diff --git a/industrial-embodied-ai/tests/test_validate_artifacts.py b/industrial-embodied-ai/tests/test_validate_artifacts.py new file mode 100644 index 0000000..046a90c --- /dev/null +++ b/industrial-embodied-ai/tests/test_validate_artifacts.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "validate_artifacts.py" + + +class ValidateArtifactsTests(unittest.TestCase): + def test_checks_survive_python_optimize(self) -> None: + # validate_artifacts.py needs the published stack, which this job does + # not install, so this pins the property statically. Under python -O + # every assert is stripped, and a tampered artifact then printed + # "valid" for all four checks. + tree = ast.parse(SCRIPT.read_text(encoding="utf-8")) + asserts = [node.lineno for node in ast.walk(tree) if isinstance(node, ast.Assert)] + self.assertEqual(asserts, [], f"assert used for validation at lines {asserts}") + + +if __name__ == "__main__": + unittest.main() diff --git a/industrial-embodied-ai/validate_artifacts.py b/industrial-embodied-ai/validate_artifacts.py index 3131d5b..16cc680 100644 --- a/industrial-embodied-ai/validate_artifacts.py +++ b/industrial-embodied-ai/validate_artifacts.py @@ -18,6 +18,16 @@ BASE = Path(__file__).resolve().parent +class ArtifactValidationError(Exception): + """A committed artifact failed validation.""" + + +def require(condition: object, message: str) -> None: + # Not `assert`: python -O strips asserts, and every check here would pass. + if not condition: + raise ArtifactValidationError(message) + + def canonical_bytes(value: Any) -> bytes: return canonicalize(value) @@ -92,8 +102,15 @@ def merkle_tree_hash(nodes: list[bytes]) -> bytes: def verify_manifest_signature(manifest: dict[str, Any]) -> None: public_key = json.loads((BASE / "manifest-public-key.json").read_text()) - assert manifest["signature"]["key_id"] == public_key["key_id"] + require( + manifest["signature"]["key_id"] == public_key["key_id"], + "manifest signed by an unexpected key", + ) signed_fields = manifest["signature"]["signed_fields"] + # signed_fields travels inside the manifest, so a field it leaves out is + # unauthenticated. Every top-level field except the signature must be listed. + unsigned = set(manifest) - {"signature"} - set(signed_fields) + require(not unsigned, f"manifest fields outside the signature: {sorted(unsigned)}") body = {key: manifest[key] for key in signed_fields if key in manifest} Ed25519PublicKey.from_public_bytes( b64url_decode(public_key["public_key_base64url"]) @@ -120,7 +137,10 @@ def main() -> None: definition_hash = hash_bytes( canonical_bytes(entry["approved_definition"]) ) - assert definition_hash == entry["definition_hash"], entry["tool_name"] + require( + definition_hash == entry["definition_hash"], + f"catalog definition_hash mismatch: {entry['tool_name']}", + ) policy_hash = compute_policy_bundle_hash() catalog_hash = compute_cmcp_catalog_hash(catalog) @@ -131,16 +151,29 @@ def main() -> None: (BASE / "artifacts/system-prompt.txt").read_bytes() ) - assert expected["cmcp_policy_bundle_hash"] == policy_hash - assert expected["cmcp_catalog_hash"] == catalog_hash - assert expected["agent_manifest_tool_catalog_root"] == manifest_catalog_root - assert expected["system_prompt_hash"] == prompt_hash - assert manifest["artifacts"]["policy_bundle"]["hash"] == policy_hash - assert ( + require( + expected["cmcp_policy_bundle_hash"] == policy_hash, + "policy bundle hash mismatch", + ) + require(expected["cmcp_catalog_hash"] == catalog_hash, "cMCP catalog hash mismatch") + require( + expected["agent_manifest_tool_catalog_root"] == manifest_catalog_root, + "tool catalog root mismatch", + ) + require(expected["system_prompt_hash"] == prompt_hash, "system prompt hash mismatch") + require( + manifest["artifacts"]["policy_bundle"]["hash"] == policy_hash, + "manifest policy bundle hash mismatch", + ) + require( manifest["artifacts"]["tool_manifest"]["catalog_hash"] - == manifest_catalog_root + == manifest_catalog_root, + "manifest tool catalog hash mismatch", + ) + require( + manifest["artifacts"]["system_prompt"]["hash"] == prompt_hash, + "manifest system prompt hash mismatch", ) - assert manifest["artifacts"]["system_prompt"]["hash"] == prompt_hash verify_manifest_signature(manifest) verification = verify_trace_claim( @@ -161,15 +194,22 @@ def main() -> None: "attestation_freshness", "audit_chain", } - assert required <= set(verification.verified_fields) - assert claim["trace"]["runtime"]["platform"] == "software-only" + missing = required - set(verification.verified_fields) + require(not missing, f"TRACE claim fields not verified: {sorted(missing)}") + require( + claim["trace"]["runtime"]["platform"] == "software-only", + "committed fixture is expected to be a software-only record", + ) bundle_verification = verify_audit_bundle( audit_bundle, claim, external_evidence_keys=external_evidence_keys(), ) - assert bundle_verification.verified, bundle_verification.failures + require( + bundle_verification.verified, + f"audit bundle failed verification: {bundle_verification.failures}", + ) receipt_count = sum( 1 for entry in audit_bundle.get("entries", []) diff --git a/requirements/receipts-tests.in b/requirements/receipts-tests.in new file mode 100644 index 0000000..be1c409 --- /dev/null +++ b/requirements/receipts-tests.in @@ -0,0 +1,6 @@ +# The embodied-action-receipts verifier and its tests. cryptography is the only +# dependency, and nothing here is a published subject under test, so it is +# pinned like the rest of the harness. Compile with: +# uv pip compile requirements/receipts-tests.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/receipts-tests.txt +cryptography>=50.0.1 diff --git a/requirements/receipts-tests.txt b/requirements/receipts-tests.txt new file mode 100644 index 0000000..568df4f --- /dev/null +++ b/requirements/receipts-tests.txt @@ -0,0 +1,156 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/receipts-tests.in --generate-hashes --universal --python-version 3.11 -o requirements/receipts-tests.txt +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --hash=sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e \ + --hash=sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66 \ + --hash=sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2 \ + --hash=sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0 \ + --hash=sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6 \ + --hash=sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971 \ + --hash=sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c \ + --hash=sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d \ + --hash=sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9 \ + --hash=sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517 \ + --hash=sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735 \ + --hash=sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80 \ + --hash=sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f \ + --hash=sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1 \ + --hash=sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29 \ + --hash=sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8 \ + --hash=sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c \ + --hash=sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e \ + --hash=sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48 \ + --hash=sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813 \ + --hash=sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac \ + --hash=sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632 \ + --hash=sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6 \ + --hash=sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1 \ + --hash=sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659 \ + --hash=sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688 \ + --hash=sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004 \ + --hash=sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0 \ + --hash=sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062 \ + --hash=sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779 \ + --hash=sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94 \ + --hash=sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50 \ + --hash=sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab \ + --hash=sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac \ + --hash=sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6 \ + --hash=sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676 \ + --hash=sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1 \ + --hash=sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9 \ + --hash=sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf \ + --hash=sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13 \ + --hash=sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e \ + --hash=sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e \ + --hash=sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973 \ + --hash=sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527 \ + --hash=sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72 \ + --hash=sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890 \ + --hash=sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c \ + --hash=sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990 \ + --hash=sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd \ + --hash=sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9 \ + --hash=sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94 \ + --hash=sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3 \ + --hash=sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80 \ + --hash=sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41 \ + --hash=sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5 \ + --hash=sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c \ + --hash=sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a \ + --hash=sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4 \ + --hash=sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e \ + --hash=sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6 \ + --hash=sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98 \ + --hash=sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b \ + --hash=sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1 \ + --hash=sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03 \ + --hash=sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af \ + --hash=sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231 \ + --hash=sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2 \ + --hash=sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3 \ + --hash=sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836 \ + --hash=sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5 \ + --hash=sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399 \ + --hash=sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96 \ + --hash=sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e \ + --hash=sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be \ + --hash=sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf \ + --hash=sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc \ + --hash=sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455 \ + --hash=sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0 \ + --hash=sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12 \ + --hash=sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b \ + --hash=sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7 \ + --hash=sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692 \ + --hash=sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54 \ + --hash=sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3 \ + --hash=sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b \ + --hash=sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be \ + --hash=sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d \ + --hash=sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358 \ + --hash=sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a \ + --hash=sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7 \ + --hash=sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc \ + --hash=sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960 \ + --hash=sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125 \ + --hash=sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb \ + --hash=sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a \ + --hash=sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa \ + --hash=sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf \ + --hash=sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3 \ + --hash=sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4 \ + --hash=sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264 + # via cryptography +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via -r requirements/receipts-tests.in +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi