diff --git a/docs/verification.md b/docs/verification.md index 1504f98..fcd25b0 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -156,14 +156,15 @@ Keep the verification results separate: | Action issuance evidence | Canonical action digest, receipt signature, trusted issuer key, session or call binding, chain order | Successful physical completion | | Outcome evidence | Controller or monitor decision carried by the receipt payload | Functional-safety certification unless the issuer and profile explicitly claim it | -For action receipts, a verifier should distinguish four common outcomes: +For action receipts, a verifier should distinguish five common outcomes: | Outcome | Meaning | |---|---| | `receipt_valid_accepted` | The receipt is well-formed, trusted, bound to the call, and reports acceptance. | | `receipt_valid_rejected` | The receipt is well-formed, trusted, bound to the call, and reports controller or policy rejection. This is valid negative evidence. | | `receipt_missing_required` | The profile required a receipt, but none was present for the consequential action. | -| `receipt_invalid` | The receipt is present but fails signature, digest, issuer, freshness, ordering, or call-binding checks. | +| `receipt_invalid` | The receipt is present but fails signature, digest, freshness, ordering, or call-binding checks against a key the verifier holds. | +| `receipt_unverified` | The receipt names an issuer key the verifier has not pinned, and nothing else failed. Per section 3.3.1 of the spec this is unverified, not invalid: the receipt confers no trust and proves no wrongdoing, surfaced with an advisory rather than a failure. | The key boundary is that a valid rejection is not malformed evidence. It is evidence that the downstream authority declined the action. A valid acceptance diff --git a/examples/action-receipts/README.md b/examples/action-receipts/README.md index 66d0a0f..3233107 100644 --- a/examples/action-receipts/README.md +++ b/examples/action-receipts/README.md @@ -89,6 +89,26 @@ 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-unknown.json` | `receipt_unverified` with advisory | unknown | The issuer key is not in the verifier's pinned set. Unverifiable is not invalid (spec §3.3.1): no trust is conferred and no forgery is proven, surfaced as an `issuer_key_unknown` advisory. | +| `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_unknown` a receipt authenticates itself — a signature verifies against +whatever key it names, and only the pinned set decides which keys the verifier can +check at all; per spec §3.3.1 the outcome is `receipt_unverified`, not +`receipt_invalid`, but a verifier that never consults its pinned set would report such +a receipt as fully valid, which is what the vector distinguishes. 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-unknown.json b/examples/action-receipts/conformance/14-receipt-issuer-key-unknown.json new file mode 100644 index 0000000..e3937dc --- /dev/null +++ b/examples/action-receipts/conformance/14-receipt-issuer-key-unknown.json @@ -0,0 +1,54 @@ +{ + "name": "receipt-issuer-key-unknown", + "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. Not holding the key is an inability to check, not evidence of forgery: the receipt is unverified, not invalid.", + "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_unverified", + "controller_outcome": "unknown", + "failures": [], + "warnings": [ + "issuer_key_unknown" + ] + } +} 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..e761735 --- /dev/null +++ b/examples/action-receipts/conformance/gen_rule_coverage_vectors.py @@ -0,0 +1,238 @@ +"""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)) + + # issuer_key_unknown — the one non-failure in this set. Spec section 3.3.1: a + # receipt whose issuer key is unknown to the verifier is unverified, not invalid. + # An unpinned key means the signature cannot be checked, which confers no trust and + # proves no forgery, so the expected block is written by hand rather than through + # build()'s invalid-with-one-failure shape. + f = build( + "receipt-issuer-key-unknown", + "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. Not holding the key is an inability to " + "check, not evidence of forgery: the receipt is unverified, not invalid.", + "issuer_key_unknown", + trusted={f"{ISSUER}#ed25519-some-other-key": jwk()}, + ) + f["expected"] = { + "status": "receipt_unverified", + "controller_outcome": "unknown", + "failures": [], + "warnings": ["issuer_key_unknown"], + } + fixtures.append(("14-receipt-issuer-key-unknown.json", f)) + + 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..b87ca7a 100644 --- a/tests/test_action_receipt_fixtures.py +++ b/tests/test_action_receipt_fixtures.py @@ -92,7 +92,12 @@ def _verify_fixture(fixture: dict[str, Any]) -> ReceiptResult: trusted_jwk = fixture["trusted_issuer_keys"].get(receipt["issuer_key_id"]) if trusted_jwk is None: - failures.append("issuer_key_untrusted") + # Spec section 3.3.1: a receipt whose issuer key is unknown to the verifier is + # unverified, not invalid. An unpinned key is an inability to check, not + # evidence of forgery, so this is an advisory rather than a failure; the + # structural checks below still run, and any of them failing is positive + # evidence that does make the receipt invalid. + warnings.append("issuer_key_unknown") else: try: _verify_signature(receipt, trusted_jwk) @@ -128,6 +133,18 @@ def _verify_fixture(fixture: dict[str, Any]) -> ReceiptResult: warnings=warnings, ) + if trusted_jwk is None: + # Nothing failed, but nothing was signed by a key the verifier could check + # either. The receipt confers no trust and proves no wrongdoing, and the + # controller outcome stays unknown because the evidence is only as good as + # the unverified receipt that binds it. + return ReceiptResult( + status="receipt_unverified", + controller_outcome="unknown", + failures=[], + warnings=warnings, + ) + status = "receipt_valid_accepted" if decision == "accepted" else "receipt_valid_rejected" return ReceiptResult( status=status, @@ -151,6 +168,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-unknown.json", + "15-receipt-from-future.json", + "16-decision-not-in-enum.json", ]