From a5dccdb9db88a2773ec4525ca02948c5f874bac1 Mon Sep 17 00:00:00 2001 From: Carlos Hernandez-Vaquero Date: Fri, 17 Jul 2026 12:46:49 +0200 Subject: [PATCH] test: add action receipt conformance fixtures Signed-off-by: Carlos Hernandez-Vaquero --- examples/action-receipts/README.md | 53 +++++- .../01-valid-controller-accepted.json | 52 ++++++ .../02-valid-controller-rejected.json | 52 ++++++ .../03-missing-required-receipt.json | 35 ++++ .../04-signature-key-mismatch.json | 54 ++++++ .../conformance/05-action-ref-mismatch.json | 54 ++++++ .../conformance/06-stale-receipt.json | 54 ++++++ .../conformance/07-receipt-chain-gap.json | 54 ++++++ .../08-same-party-self-report.json | 54 ++++++ .../09-unsupported-physical-completion.json | 54 ++++++ tests/test_action_receipt_fixtures.py | 166 ++++++++++++++++++ 11 files changed, 676 insertions(+), 6 deletions(-) create mode 100644 examples/action-receipts/conformance/01-valid-controller-accepted.json create mode 100644 examples/action-receipts/conformance/02-valid-controller-rejected.json create mode 100644 examples/action-receipts/conformance/03-missing-required-receipt.json create mode 100644 examples/action-receipts/conformance/04-signature-key-mismatch.json create mode 100644 examples/action-receipts/conformance/05-action-ref-mismatch.json create mode 100644 examples/action-receipts/conformance/06-stale-receipt.json create mode 100644 examples/action-receipts/conformance/07-receipt-chain-gap.json create mode 100644 examples/action-receipts/conformance/08-same-party-self-report.json create mode 100644 examples/action-receipts/conformance/09-unsupported-physical-completion.json create mode 100644 tests/test_action_receipt_fixtures.py diff --git a/examples/action-receipts/README.md b/examples/action-receipts/README.md index 10343c5..0ab915b 100644 --- a/examples/action-receipts/README.md +++ b/examples/action-receipts/README.md @@ -49,14 +49,55 @@ The verifier checks the receipt independently of the core Trust Record: - verify `previous_receipt_hash` when the profile uses hash-chain ordering; and - report the receipt result separately from the controller decision. -## Fixture cases +## Conformance fixtures -| Case | Receipt verification result | Controller outcome | Interpretation | +The files under `conformance/` provide machine-checkable cases for the +informative action-receipt rules. The fixtures use +`trace.action_receipt.conformance.v0` as a test-profile identifier. This +fixture set leaves the TRACE wire profile and `schema/trace-claim.json` +unchanged. + +Each fixture contains: + +- `context`, which supplies the expected session, call, receipt-chain + predecessor, and freshness policy; +- `action`, including the canonical action preimage and its `action_ref`; +- `trusted_issuer_keys`, keyed by the pinned `issuer_key_id`; +- detached `evidence` and a signed `receipt`, except in the missing-receipt + case; and +- `expected`, which records the result, controller outcome, failure codes, and + warnings a conforming verifier should return. + +The fixture contract uses RFC 8785 JSON Canonicalization Scheme (JCS) bytes for +three operations: + +1. `action_ref` is SHA-256 over `agent_id`, `action_type`, `action_scope`, and + `action_timestamp`. +2. `evidence_hash` is SHA-256 over the detached `evidence` object. +3. The Ed25519 signature covers the receipt object with only `signature` + removed. The verifier resolves the key through `trusted_issuer_keys`; the + receipt cannot authenticate itself with an embedded key. + +| Fixture | Receipt result | Controller outcome | Required interpretation | |---|---|---|---| -| Valid accepted action | `receipt_valid_accepted` | `accepted` | The action request was well evidenced and accepted by the controller. | -| Missing required receipt | `receipt_missing_required` | unknown | The profile required action evidence, but the consequential action had no receipt. | -| Signature or key mismatch | `receipt_invalid` | unknown | The receipt cannot be trusted, even if its payload claims success. | -| Valid controller rejection | `receipt_valid_rejected` | `rejected` | The downstream authority rejected the action; this is valid negative evidence, not malformed evidence. | +| `01-valid-controller-accepted.json` | `receipt_valid_accepted` | `accepted` | The controller accepted the bound action. Physical completion remains unproven. | +| `02-valid-controller-rejected.json` | `receipt_valid_rejected` | `aborted` | The verifier accepts the signed abort as valid negative evidence. | +| `03-missing-required-receipt.json` | `receipt_missing_required` | unknown | The profile required a receipt and the action has none. | +| `04-signature-key-mismatch.json` | `receipt_invalid` | unknown | The signature does not verify under the pinned issuer key. | +| `05-action-ref-mismatch.json` | `receipt_invalid` | unknown | The signed receipt binds to a different action. | +| `06-stale-receipt.json` | `receipt_invalid` | unknown | The authentic receipt falls outside the configured freshness window. | +| `07-receipt-chain-gap.json` | `receipt_invalid` | unknown | The receipt does not link to the expected predecessor. | +| `08-same-party-self-report.json` | `receipt_valid_accepted` with warning | `accepted` | The evidence verifies, but the issuer is not independent from the gateway. | +| `09-unsupported-physical-completion.json` | `receipt_invalid` | unknown | Base TRACE cannot verify the asserted physical-completion claim. | + +`tests/test_action_receipt_fixtures.py` recomputes each digest, verifies each +signature against the pinned key, checks session and call binding, enforces +freshness and receipt-chain ordering, and compares the result with each +fixture's `expected` object. The tests need no ROS installation or network +access. + +The pinned JWKs and signatures are public test material. Deployments must use +their own trusted issuer keys. ## Boundary diff --git a/examples/action-receipts/conformance/01-valid-controller-accepted.json b/examples/action-receipts/conformance/01-valid-controller-accepted.json new file mode 100644 index 0000000..853b029 --- /dev/null +++ b/examples/action-receipts/conformance/01-valid-controller-accepted.json @@ -0,0 +1,52 @@ +{ + "name": "valid-controller-accepted", + "description": "A trusted controller accepts the bound action without claiming physical completion.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:cf43260df01d89b5ce9ed0dce484ae1e3331fb477efda92cd0fd9365df9089d7", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + "signature": "rJTXe4XR0yUz1h3rMFP-KFra2mds_zFvHl3L5xmD_7m4jxokIPa87oaBAnAI4j3-WQ_lnzeuYf_rb11O3JcbDg" + }, + "expected": { + "status": "receipt_valid_accepted", + "controller_outcome": "accepted", + "failures": [], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/02-valid-controller-rejected.json b/examples/action-receipts/conformance/02-valid-controller-rejected.json new file mode 100644 index 0000000..0223517 --- /dev/null +++ b/examples/action-receipts/conformance/02-valid-controller-rejected.json @@ -0,0 +1,52 @@ +{ + "name": "valid-controller-rejected", + "description": "A trusted controller aborts the bound action; the receipt remains valid negative evidence.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "aborted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:828f180b492a22d616fbe0dcdd9af66a7a4e75f62f3dd7c29b5715c46365d40f", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "rejected", + "signature": "Hf0PYlVG3ACx9k0dtNbkDy_zxRBcUtlFFvXLQ1PcdtxLUqx-oPPlXKSnzSZNUYOtMNChKPVo_3dP0QcGOpGkCw" + }, + "expected": { + "status": "receipt_valid_rejected", + "controller_outcome": "aborted", + "failures": [], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/03-missing-required-receipt.json b/examples/action-receipts/conformance/03-missing-required-receipt.json new file mode 100644 index 0000000..840487f --- /dev/null +++ b/examples/action-receipts/conformance/03-missing-required-receipt.json @@ -0,0 +1,35 @@ +{ + "name": "missing-required-receipt", + "description": "The profile requires an action receipt, but the consequential action has none.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "expected": { + "status": "receipt_missing_required", + "controller_outcome": "unknown", + "failures": [ + "receipt_missing" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/04-signature-key-mismatch.json b/examples/action-receipts/conformance/04-signature-key-mismatch.json new file mode 100644 index 0000000..13e6f25 --- /dev/null +++ b/examples/action-receipts/conformance/04-signature-key-mismatch.json @@ -0,0 +1,54 @@ +{ + "name": "signature-key-mismatch", + "description": "The receipt signature does not verify against the pinned issuer key.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:cf43260df01d89b5ce9ed0dce484ae1e3331fb477efda92cd0fd9365df9089d7", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + "signature": "AXHgPUo3mdXmZcTlre0bzKQuVLi3AqyJ4zYTG2hZ_jrRVI_auF5ktk2j2NFDvdqrIaFPwZmcOgTaxNKTNTbmCQ" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "signature_or_key_mismatch" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/05-action-ref-mismatch.json b/examples/action-receipts/conformance/05-action-ref-mismatch.json new file mode 100644 index 0000000..a703bd2 --- /dev/null +++ b/examples/action-receipts/conformance/05-action-ref-mismatch.json @@ -0,0 +1,54 @@ +{ + "name": "action-ref-mismatch", + "description": "The signed receipt is not bound to the expected canonical action reference.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:cf43260df01d89b5ce9ed0dce484ae1e3331fb477efda92cd0fd9365df9089d7", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + "signature": "mwnX6Uh36J6wZkcKEagfROP35AQLZ6zcfcHDKlOL_XJ20pk-6zZKeX2L5o3aArz3uDkc0-F_TNEiYgMu0McNAg" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "action_ref_mismatch" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/06-stale-receipt.json b/examples/action-receipts/conformance/06-stale-receipt.json new file mode 100644 index 0000000..b0f433b --- /dev/null +++ b/examples/action-receipts/conformance/06-stale-receipt.json @@ -0,0 +1,54 @@ +{ + "name": "stale-receipt", + "description": "The receipt is authentic but older than the profile freshness window.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:cf43260df01d89b5ce9ed0dce484ae1e3331fb477efda92cd0fd9365df9089d7", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:00:00Z", + "decision": "accepted", + "signature": "M4yALg3h8lfb0syFk-c2kKGZg9HlIhE8bniCMsvmz-VAIBOSpkfqRGxT2UzS-fvuTi3sGhobGwiioXAaQSxxCQ" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "receipt_stale" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/07-receipt-chain-gap.json b/examples/action-receipts/conformance/07-receipt-chain-gap.json new file mode 100644 index 0000000..7bb2e0a --- /dev/null +++ b/examples/action-receipts/conformance/07-receipt-chain-gap.json @@ -0,0 +1,54 @@ +{ + "name": "receipt-chain-gap", + "description": "The receipt points to a different predecessor than the verifier expects.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:cf43260df01d89b5ce9ed0dce484ae1e3331fb477efda92cd0fd9365df9089d7", + "previous_receipt_hash": "sha256:d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + "signature": "PzIKr70s5WfN4S8zIThfbk-WPk6W-_SRUeZv-nj52LGqj_QAhEjMfLrr6keR8lMy0_TkDx5yR20476O4AU_tCQ" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "receipt_chain_gap" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/08-same-party-self-report.json b/examples/action-receipts/conformance/08-same-party-self-report.json new file mode 100644 index 0000000..0107599 --- /dev/null +++ b/examples/action-receipts/conformance/08-same-party-self-report.json @@ -0,0 +1,54 @@ +{ + "name": "same-party-self-report", + "description": "The receipt is valid but comes from the same party that handed off the action.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "spiffe://factory.example/gateway/cmcp#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "spiffe://factory.example/gateway/cmcp", + "issuer_key_id": "spiffe://factory.example/gateway/cmcp#ed25519-2026q3", + "issuer_independence": "gateway_self_report", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:cf43260df01d89b5ce9ed0dce484ae1e3331fb477efda92cd0fd9365df9089d7", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + "signature": "VvYNIC6xtZ8hiqPfv6TUrUR8VPmYXZaJo9EnAx3UHq_k12TQsifCD2YhXA-as6B0Nv99ou4HtwOEsYf5DIDmAQ" + }, + "expected": { + "status": "receipt_valid_accepted", + "controller_outcome": "accepted", + "failures": [], + "warnings": [ + "issuer_not_independent" + ] + } +} diff --git a/examples/action-receipts/conformance/09-unsupported-physical-completion.json b/examples/action-receipts/conformance/09-unsupported-physical-completion.json new file mode 100644 index 0000000..c1f1482 --- /dev/null +++ b/examples/action-receipts/conformance/09-unsupported-physical-completion.json @@ -0,0 +1,54 @@ +{ + "name": "unsupported-physical-completion", + "description": "The evidence claims physical completion under the base profile, which does not verify that claim.", + "profile": "trace.action_receipt.conformance.v0", + "context": { + "call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "require_receipt": true, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1" + }, + "action": { + "agent_id": "spiffe://factory.example/agent/ros2-fibonacci/dev", + "action_type": "ros2.action.example_interfaces/Fibonacci", + "action_scope": "/abort_fibonacci_process", + "action_timestamp": "2026-07-06T15:22:13Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0EqyMnQrtKs6E2i9RhXk5tAiSrcaAWuvhSCjMsl3hzc" + } + }, + "evidence": { + "terminal_state": "succeeded", + "physical_completion_claim": "completed", + "completeness_claim": "not_proven" + }, + "receipt": { + "issuer": "did:web:factory.example:safety-controller", + "issuer_key_id": "did:web:factory.example:safety-controller#ed25519-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T15:22:11Z", + "action_ref": "sha256:c2e97956de8b86d825780fcecea601a6eea02616855e6f0aa19d10bd351fe034", + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": "sha256:237ccf384654aeaee08635826b293ccb72b6dcc601d985f890f7a2a4fce53e3a", + "previous_receipt_hash": "sha256:c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1c1", + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + "signature": "V1wHYGUn3Be4jaRsKNSk-3Gc1672r6YwlGgkD5oAZPjBqUG8DiXOTcyOrc7UYxd7XlYsGECJd7yWR7yP3zPmCA" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "unsupported_physical_completion_claim" + ], + "warnings": [] + } +} diff --git a/tests/test_action_receipt_fixtures.py b/tests/test_action_receipt_fixtures.py new file mode 100644 index 0000000..63ec6ee --- /dev/null +++ b/tests/test_action_receipt_fixtures.py @@ -0,0 +1,166 @@ +"""Conformance checks for the informative action-receipt fixture set.""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +import pytest +import rfc8785 +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +FIXTURE_DIR = Path(__file__).parent.parent / "examples" / "action-receipts" / "conformance" +ACTION_REF_FIELDS = ("agent_id", "action_type", "action_scope", "action_timestamp") + + +@dataclass(frozen=True) +class ReceiptResult: + status: str + controller_outcome: str + failures: list[str] + warnings: list[str] + + +def _load_fixture(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _sha256_jcs(value: dict[str, Any]) -> str: + return "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() + + +def _decode_base64url(value: str) -> bytes: + padded = value + "=" * (-len(value) % 4) + try: + return base64.b64decode(padded, altchars=b"-_", validate=True) + except binascii.Error as exc: + raise ValueError("fixture value is not valid base64url") from exc + + +def _parse_timestamp(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def _verify_signature(receipt: dict[str, Any], trusted_jwk: dict[str, str]) -> None: + if trusted_jwk.get("kty") != "OKP" or trusted_jwk.get("crv") != "Ed25519": + raise ValueError("fixture key must be an Ed25519 OKP JWK") + public_key = Ed25519PublicKey.from_public_bytes(_decode_base64url(trusted_jwk["x"])) + signing_input = {key: value for key, value in receipt.items() if key != "signature"} + public_key.verify(_decode_base64url(receipt["signature"]), rfc8785.dumps(signing_input)) + + +def _verify_fixture(fixture: dict[str, Any]) -> ReceiptResult: + context = fixture["context"] + action = fixture["action"] + receipt = fixture.get("receipt") + + if receipt is None: + if context["require_receipt"]: + return ReceiptResult( + status="receipt_missing_required", + controller_outcome="unknown", + failures=["receipt_missing"], + warnings=[], + ) + raise AssertionError("the conformance set has no optional missing-receipt case") + + failures: list[str] = [] + warnings: list[str] = [] + + action_preimage = {field: action[field] for field in ACTION_REF_FIELDS} + expected_action_ref = _sha256_jcs(action_preimage) + if action["action_ref"] != expected_action_ref: + failures.append("action_ref_invalid") + if receipt["action_ref"] != action["action_ref"]: + failures.append("action_ref_mismatch") + + if receipt["linked_call_id"] != context["call_id"]: + failures.append("call_id_mismatch") + if receipt["session_id"] != context["session_id"]: + failures.append("session_id_mismatch") + + evidence = fixture["evidence"] + if receipt["evidence_hash"] != _sha256_jcs(evidence): + failures.append("evidence_hash_mismatch") + + trusted_jwk = fixture["trusted_issuer_keys"].get(receipt["issuer_key_id"]) + if trusted_jwk is None: + failures.append("issuer_key_untrusted") + else: + try: + _verify_signature(receipt, trusted_jwk) + except (InvalidSignature, ValueError): + failures.append("signature_or_key_mismatch") + + receipt_age = _parse_timestamp(context["verification_time"]) - _parse_timestamp( + receipt["issued_at"] + ) + if receipt_age.total_seconds() > context["max_receipt_age_seconds"]: + failures.append("receipt_stale") + if receipt_age.total_seconds() < 0: + failures.append("receipt_from_future") + + if receipt["previous_receipt_hash"] != context["expected_previous_receipt_hash"]: + failures.append("receipt_chain_gap") + + if evidence["physical_completion_claim"] != "none": + failures.append("unsupported_physical_completion_claim") + + if receipt["issuer_independence"] == "gateway_self_report": + warnings.append("issuer_not_independent") + + decision = receipt["decision"] + if decision not in {"accepted", "rejected"}: + failures.append("decision_invalid") + + if failures: + return ReceiptResult( + status="receipt_invalid", + controller_outcome="unknown", + failures=failures, + warnings=warnings, + ) + + status = "receipt_valid_accepted" if decision == "accepted" else "receipt_valid_rejected" + return ReceiptResult( + status=status, + controller_outcome=evidence["terminal_state"], + failures=[], + warnings=warnings, + ) + + +FIXTURE_PATHS = sorted(FIXTURE_DIR.glob("*.json")) + + +def test_fixture_set_is_complete() -> None: + assert [path.name for path in FIXTURE_PATHS] == [ + "01-valid-controller-accepted.json", + "02-valid-controller-rejected.json", + "03-missing-required-receipt.json", + "04-signature-key-mismatch.json", + "05-action-ref-mismatch.json", + "06-stale-receipt.json", + "07-receipt-chain-gap.json", + "08-same-party-self-report.json", + "09-unsupported-physical-completion.json", + ] + + +@pytest.mark.parametrize("fixture_path", FIXTURE_PATHS, ids=lambda path: path.stem) +def test_action_receipt_conformance_fixture(fixture_path: Path) -> None: + fixture = _load_fixture(fixture_path) + assert fixture["profile"] == "trace.action_receipt.conformance.v0" + result = _verify_fixture(fixture) + + assert result.status == fixture["expected"]["status"] + assert result.controller_outcome == fixture["expected"]["controller_outcome"] + assert result.failures == fixture["expected"]["failures"] + assert result.warnings == fixture["expected"]["warnings"]