From 320674f059770eee7e1aa4f0b56e9bce8bfc9624 Mon Sep 17 00:00:00 2001 From: lywinged <48041247+lywinged@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:09:31 +1200 Subject: [PATCH] test: conformance vectors for seven unexercised receipt rules The receipt verifier applies rules that no fixture exercises. An implementation can omit each of these checks entirely and still pass the published set, which is the one thing a conformance suite is supposed to prevent. action_ref_invalid recomputing the action reference rather than trusting the declared value call_id_mismatch that the receipt is bound to this call session_id_mismatch that it is bound to this session evidence_hash_mismatch recomputing the evidence digest issuer_key_untrusted the signing key against a pinned set at all receipt_from_future a receipt issued after the verification time decision_invalid refusing to read an unknown verb as accept or reject Two are load-bearing for the trust model rather than tidiness. Without issuer_key_untrusted a receipt authenticates itself, since a signature verifies against whatever key it names and only a pinned set decides whether that key was entitled to speak. Without evidence_hash_mismatch the signature covers a digest whose document can be swapped, because the receipt signs the digest and not the evidence body. One fixture per rule, each triggering exactly that rule and nothing else, so a failure names the check that broke. They pin their own deterministic test key: the private half of the key behind 01-09 is not published, and each fixture already carries its own trusted_issuer_keys. gen_rule_coverage_vectors.py regenerates the set byte-for-byte and only public JWKs appear in the files. Found by walking the verifier's source for every failure code it can emit and comparing that against the codes the fixtures expect, rather than by reading the set and guessing what was missing. Signed-off-by: lywinged <48041247+lywinged@users.noreply.github.com> --- examples/action-receipts/README.md | 16 ++ .../10-action-ref-not-recomputable.json | 54 +++++ .../conformance/11-call-id-mismatch.json | 54 +++++ .../conformance/12-session-id-mismatch.json | 54 +++++ .../13-evidence-hash-mismatch.json | 54 +++++ .../14-receipt-issuer-key-untrusted.json | 54 +++++ .../conformance/15-receipt-from-future.json | 54 +++++ .../conformance/16-decision-not-in-enum.json | 54 +++++ .../conformance/gen_rule_coverage_vectors.py | 228 ++++++++++++++++++ tests/test_action_receipt_fixtures.py | 9 + 10 files changed, 631 insertions(+) create mode 100644 examples/action-receipts/conformance/10-action-ref-not-recomputable.json create mode 100644 examples/action-receipts/conformance/11-call-id-mismatch.json create mode 100644 examples/action-receipts/conformance/12-session-id-mismatch.json create mode 100644 examples/action-receipts/conformance/13-evidence-hash-mismatch.json create mode 100644 examples/action-receipts/conformance/14-receipt-issuer-key-untrusted.json create mode 100644 examples/action-receipts/conformance/15-receipt-from-future.json create mode 100644 examples/action-receipts/conformance/16-decision-not-in-enum.json create mode 100644 examples/action-receipts/conformance/gen_rule_coverage_vectors.py diff --git a/examples/action-receipts/README.md b/examples/action-receipts/README.md index 66d0a0f..d0fac39 100644 --- a/examples/action-receipts/README.md +++ b/examples/action-receipts/README.md @@ -89,6 +89,22 @@ three operations: | `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. | +| `10-action-ref-not-recomputable.json` | `receipt_invalid` | unknown | The declared `action_ref` is not the digest of its own preimage, so it binds nothing. | +| `11-call-id-mismatch.json` | `receipt_invalid` | unknown | An authentic receipt bound to a different call. | +| `12-session-id-mismatch.json` | `receipt_invalid` | unknown | An authentic receipt from a different session. | +| `13-evidence-hash-mismatch.json` | `receipt_invalid` | unknown | The receipt is authentic but the detached evidence was swapped after signing. | +| `14-receipt-issuer-key-untrusted.json` | `receipt_invalid` | unknown | The issuer key is not in the verifier's pinned set. | +| `15-receipt-from-future.json` | `receipt_invalid` | unknown | Issued after the verification time, so an upper bound on age never rejects it. | +| `16-decision-not-in-enum.json` | `receipt_invalid` | unknown | An unrecognised decision verb, which must not read as accept or reject. | + +Fixtures `10`–`16` each pin down one rule that the verifier applies and that no fixture +previously exercised. Every one was a check a conforming implementation could have +omitted entirely while passing this set. Two matter beyond tidiness: without +`issuer_key_untrusted` a receipt authenticates itself, and without +`evidence_hash_mismatch` the signature covers a digest whose document may have been +replaced. They pin their own deterministic test key, since the private half of the key +used by `01`–`09` is not published; `gen_rule_coverage_vectors.py` regenerates them +byte-for-byte and only public JWKs appear in the files. `tests/test_action_receipt_fixtures.py` recomputes each digest, verifies each signature against the pinned key, checks session and call binding, enforces diff --git a/examples/action-receipts/conformance/10-action-ref-not-recomputable.json b/examples/action-receipts/conformance/10-action-ref-not-recomputable.json new file mode 100644 index 0000000..683a303 --- /dev/null +++ b/examples/action-receipts/conformance/10-action-ref-not-recomputable.json @@ -0,0 +1,54 @@ +{ + "name": "action-ref-not-recomputable", + "description": "The action's declared action_ref is not the digest of its own canonical preimage. A verifier that trusts the declared value instead of recomputing it would accept an action reference that binds nothing.", + "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:dededededededededededededededededededededededededededededededede" + }, + "trusted_issuer_keys": { + "did:web:factory.example:safety-controller#ed25519-coverage-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "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-coverage-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:dededededededededededededededededededededededededededededededede", + "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": "Cbe4Tbr1XBZLqwnLTlel_ABjjQecA5-E0ygcI9qMP9xf0z8CmMKrGL8uIZqBlV3bKE4CY2IJRUH_WCoMnn2_BQ" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "action_ref_invalid" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/11-call-id-mismatch.json b/examples/action-receipts/conformance/11-call-id-mismatch.json new file mode 100644 index 0000000..48a942e --- /dev/null +++ b/examples/action-receipts/conformance/11-call-id-mismatch.json @@ -0,0 +1,54 @@ +{ + "name": "call-id-mismatch", + "description": "An authentic receipt bound to a different call. Without this check a valid receipt from one call could be presented as evidence for another.", + "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-coverage-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "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-coverage-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-0000-7c68-9ff8-000000000000", + "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": "X-kzP-GgEpcy30o8jzylNKN_QU86WldO4vN9mHziBnuOo_vGKio1_oRa1voVQWN396wQ82HCMH3vTTm9aVrKCg" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "call_id_mismatch" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/12-session-id-mismatch.json b/examples/action-receipts/conformance/12-session-id-mismatch.json new file mode 100644 index 0000000..40eb956 --- /dev/null +++ b/examples/action-receipts/conformance/12-session-id-mismatch.json @@ -0,0 +1,54 @@ +{ + "name": "session-id-mismatch", + "description": "An authentic receipt from a different session. Session binding is what stops a receipt being replayed into an unrelated run.", + "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-coverage-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "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-coverage-2026q3", + "issuer_independence": "separate_process", + "linked_call_id": "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337", + "session_id": "trace-session-2026-07-06T09:00:00Z", + "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": "lvIqDgwunh5ou0AIwNuJyDIXGYv7hGH_-S2KuAZzB8nwnER-_ee2QuTzTspT4zlVCRfA_O9UiUFkfFBGLt4rBA" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "session_id_mismatch" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/13-evidence-hash-mismatch.json b/examples/action-receipts/conformance/13-evidence-hash-mismatch.json new file mode 100644 index 0000000..38adf45 --- /dev/null +++ b/examples/action-receipts/conformance/13-evidence-hash-mismatch.json @@ -0,0 +1,54 @@ +{ + "name": "evidence-hash-mismatch", + "description": "The receipt is authentic but its evidence_hash does not match the detached evidence supplied with it. The signature covers the digest, not the document, so only recomputation catches a swapped evidence body.", + "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-coverage-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "evidence": { + "terminal_state": "rejected", + "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-coverage-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": "f1bVg8ZaBv6SaT5JGzs0J4XkzyVkvhS5wGNit-790NFeHAGLfw1kalsiqBxGC8HFNUVjHRcW2urOPl8A90sbDw" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "evidence_hash_mismatch" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/14-receipt-issuer-key-untrusted.json b/examples/action-receipts/conformance/14-receipt-issuer-key-untrusted.json new file mode 100644 index 0000000..53058ad --- /dev/null +++ b/examples/action-receipts/conformance/14-receipt-issuer-key-untrusted.json @@ -0,0 +1,54 @@ +{ + "name": "receipt-issuer-key-untrusted", + "description": "The receipt names an issuer key the verifier has not pinned. A signature verifies against whatever key it names; only a pinned set decides whether that key was ever entitled to speak.", + "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-some-other-key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "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-coverage-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": "f1bVg8ZaBv6SaT5JGzs0J4XkzyVkvhS5wGNit-790NFeHAGLfw1kalsiqBxGC8HFNUVjHRcW2urOPl8A90sbDw" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "issuer_key_untrusted" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/15-receipt-from-future.json b/examples/action-receipts/conformance/15-receipt-from-future.json new file mode 100644 index 0000000..2fcfacc --- /dev/null +++ b/examples/action-receipts/conformance/15-receipt-from-future.json @@ -0,0 +1,54 @@ +{ + "name": "receipt-from-future", + "description": "The receipt is issued after the verification time. A future-dated receipt is not stale, so the freshness ceiling alone never rejects it, and a verifier that only checks an upper bound on age accepts it.", + "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-coverage-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "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-coverage-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:26:00Z", + "decision": "accepted", + "signature": "RAYxeAudhBQ9tiJ-XspITdRS7CgZE49pY13yLi9az8QR3UpmWcX5JJKah4rSvmq93DojaT4ODzLpM6tA0v4GCA" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "receipt_from_future" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/16-decision-not-in-enum.json b/examples/action-receipts/conformance/16-decision-not-in-enum.json new file mode 100644 index 0000000..1006c18 --- /dev/null +++ b/examples/action-receipts/conformance/16-decision-not-in-enum.json @@ -0,0 +1,54 @@ +{ + "name": "decision-not-in-enum", + "description": "The controller decision is outside the accepted vocabulary. An unrecognised verb must not be read as either acceptance or rejection.", + "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-coverage-2026q3": { + "kty": "OKP", + "crv": "Ed25519", + "x": "GUrISuzIssGcot1U1RFv02lETpxCYoQ0tz421ALSdqE" + } + }, + "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-coverage-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": "deferred", + "signature": "nOVX5OxAkYwiBnxMrzdxRM3S3MzMKho8Ax1TDShkiHNRA0lZkw0Q-kcSWRcDHVWeBXnpqiS1g5vR7ZRdgyieBg" + }, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [ + "decision_invalid" + ], + "warnings": [] + } +} diff --git a/examples/action-receipts/conformance/gen_rule_coverage_vectors.py b/examples/action-receipts/conformance/gen_rule_coverage_vectors.py new file mode 100644 index 0000000..d9da66b --- /dev/null +++ b/examples/action-receipts/conformance/gen_rule_coverage_vectors.py @@ -0,0 +1,228 @@ +"""Close the seven receipt rules the conformance set never exercised. + +Each of these checks exists in the verifier with no vector behind it, so an +implementation could omit the check entirely and still pass. One fixture per rule, +each triggering exactly that rule and nothing else. + +The existing fixtures pin a key whose private half is not published, so these pin +their own deterministic test key. Public JWKs only. +""" + +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +from pathlib import Path +from typing import Any + +import rfc8785 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +OUT = Path("examples/action-receipts/conformance") +PROFILE = "trace.action_receipt.conformance.v0" +ISSUER = "did:web:factory.example:safety-controller" +KEY_ID = f"{ISSUER}#ed25519-coverage-2026q3" +ACTION_REF_FIELDS = ("agent_id", "action_type", "action_scope", "action_timestamp") + +KEY = Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(b"trace-spec action-receipt rule-coverage fixture key").digest() +) + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def jwk() -> dict[str, str]: + return { + "kty": "OKP", + "crv": "Ed25519", + "x": b64u( + KEY.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ), + } + + +def sha256_jcs(value: dict[str, Any]) -> str: + return "sha256:" + hashlib.sha256(rfc8785.dumps(value)).hexdigest() + + +CALL_ID = "01986d7c-6b2f-7c68-9ff8-3e2f9d0db337" +SESSION = "trace-session-2026-07-06T15:22:11Z" +PREV_HASH = "sha256:" + "c1" * 32 + +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", +} +EVIDENCE = { + "terminal_state": "accepted", + "physical_completion_claim": "none", + "completeness_claim": "not_proven", +} + + +def build( + name: str, + description: str, + failure: str, + *, + action: dict[str, Any] | None = None, + evidence: dict[str, Any] | None = None, + receipt_overrides: dict[str, Any] | None = None, + context_overrides: dict[str, Any] | None = None, + trusted: dict[str, Any] | None = None, +) -> dict[str, Any]: + act = copy.deepcopy(action or ACTION) + ev = copy.deepcopy(evidence or EVIDENCE) + + context = { + "call_id": CALL_ID, + "session_id": SESSION, + "require_receipt": True, + "verification_time": "2026-07-06T15:24:00Z", + "max_receipt_age_seconds": 300, + "expected_previous_receipt_hash": PREV_HASH, + } + context.update(context_overrides or {}) + + action_block = {**act, "action_ref": sha256_jcs({f: act[f] for f in ACTION_REF_FIELDS})} + + receipt: dict[str, Any] = { + "issuer": ISSUER, + "issuer_key_id": KEY_ID, + "issuer_independence": "separate_process", + "linked_call_id": CALL_ID, + "session_id": SESSION, + "action_ref": action_block["action_ref"], + "evidence_type": "application/vnd.agentrust.action-receipt+json", + "evidence_hash": sha256_jcs(ev), + "previous_receipt_hash": PREV_HASH, + "issued_at": "2026-07-06T15:22:15Z", + "decision": "accepted", + } + receipt.update(receipt_overrides or {}) + + body = rfc8785.dumps({k: v for k, v in receipt.items() if k != "signature"}) + receipt["signature"] = b64u(KEY.sign(body)) + + return { + "name": name, + "description": description, + "profile": PROFILE, + "context": context, + "action": action_block, + "trusted_issuer_keys": trusted if trusted is not None else {KEY_ID: jwk()}, + "evidence": ev, + "receipt": receipt, + "expected": { + "status": "receipt_invalid", + "controller_outcome": "unknown", + "failures": [failure], + "warnings": [], + }, + } + + +def main() -> None: + fixtures: list[tuple[str, dict[str, Any]]] = [] + + # action_ref_invalid — the declared action_ref is not the digest of its own preimage. + f = build( + "action-ref-not-recomputable", + "The action's declared action_ref is not the digest of its own canonical " + "preimage. A verifier that trusts the declared value instead of recomputing it " + "would accept an action reference that binds nothing.", + "action_ref_invalid", + ) + forged = "sha256:" + "de" * 32 + f["action"]["action_ref"] = forged + f["receipt"]["action_ref"] = forged # keep them equal, so only the digest rule fires + body = rfc8785.dumps({k: v for k, v in f["receipt"].items() if k != "signature"}) + f["receipt"]["signature"] = b64u(KEY.sign(body)) + fixtures.append(("10-action-ref-not-recomputable.json", f)) + + fixtures.append(( + "11-call-id-mismatch.json", + build( + "call-id-mismatch", + "An authentic receipt bound to a different call. Without this check a valid " + "receipt from one call could be presented as evidence for another.", + "call_id_mismatch", + receipt_overrides={"linked_call_id": "01986d7c-0000-7c68-9ff8-000000000000"}, + ), + )) + + fixtures.append(( + "12-session-id-mismatch.json", + build( + "session-id-mismatch", + "An authentic receipt from a different session. Session binding is what " + "stops a receipt being replayed into an unrelated run.", + "session_id_mismatch", + receipt_overrides={"session_id": "trace-session-2026-07-06T09:00:00Z"}, + ), + )) + + # evidence_hash_mismatch — the evidence is altered after the receipt was signed over + # its digest, so the receipt is authentic but describes different evidence. + f = build( + "evidence-hash-mismatch", + "The receipt is authentic but its evidence_hash does not match the detached " + "evidence supplied with it. The signature covers the digest, not the document, " + "so only recomputation catches a swapped evidence body.", + "evidence_hash_mismatch", + ) + f["evidence"]["terminal_state"] = "rejected" # after the hash was taken + fixtures.append(("13-evidence-hash-mismatch.json", f)) + + fixtures.append(( + "14-receipt-issuer-key-untrusted.json", + build( + "receipt-issuer-key-untrusted", + "The receipt names an issuer key the verifier has not pinned. A signature " + "verifies against whatever key it names; only a pinned set decides whether " + "that key was ever entitled to speak.", + "issuer_key_untrusted", + trusted={f"{ISSUER}#ed25519-some-other-key": jwk()}, + ), + )) + + fixtures.append(( + "15-receipt-from-future.json", + build( + "receipt-from-future", + "The receipt is issued after the verification time. A future-dated receipt " + "is not stale, so the freshness ceiling alone never rejects it, and a " + "verifier that only checks an upper bound on age accepts it.", + "receipt_from_future", + receipt_overrides={"issued_at": "2026-07-06T15:26:00Z"}, + ), + )) + + fixtures.append(( + "16-decision-not-in-enum.json", + build( + "decision-not-in-enum", + "The controller decision is outside the accepted vocabulary. An unrecognised " + "verb must not be read as either acceptance or rejection.", + "decision_invalid", + receipt_overrides={"decision": "deferred"}, + ), + )) + + for filename, doc in fixtures: + (OUT / filename).write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") + print("wrote", OUT / filename) + + +if __name__ == "__main__": + main() diff --git a/tests/test_action_receipt_fixtures.py b/tests/test_action_receipt_fixtures.py index 63ec6ee..43c29f2 100644 --- a/tests/test_action_receipt_fixtures.py +++ b/tests/test_action_receipt_fixtures.py @@ -151,6 +151,15 @@ def test_fixture_set_is_complete() -> None: "07-receipt-chain-gap.json", "08-same-party-self-report.json", "09-unsupported-physical-completion.json", + # 10-16 cover the receipt rules that had no fixture at all. Each was a check a + # conforming implementation could have omitted entirely while passing this set. + "10-action-ref-not-recomputable.json", + "11-call-id-mismatch.json", + "12-session-id-mismatch.json", + "13-evidence-hash-mismatch.json", + "14-receipt-issuer-key-untrusted.json", + "15-receipt-from-future.json", + "16-decision-not-in-enum.json", ]