From bfcc76ead4931623c5c0e6ea6524286308aa3d66 Mon Sep 17 00:00:00 2001 From: opento-suggestions Date: Tue, 1 Sep 2026 09:43:52 -0600 Subject: [PATCH 1/2] feat: verify_record consumes the revocation bundle and reports its check Section 3.2.3 publishes TraceRevocationBundle/1.0 and nothing read it: valid_until appeared zero times under src/. The section names three states a verifier must distinguish and forbids reporting any of them as an affirming appraisal. verify_record now returns a VerificationResult whose revocation field carries one of them as a value, in the section's own words: verified, unverified_for_revocation, or no_check_performed, with the cause and the evidence a second run needs to reach the same outcome from retained facts. Previously the function returned None and a caller could not tell a verified key from one nobody checked (#246). New parameters: revocation_bundle, trusted_bundle_keys, max_bundle_age_seconds, and now. Two bounds govern bundle age, the issuer's valid_until and the caller's maximum measured from issued_at, and the tighter governs: a deployment can be stricter than an issuer and is never forced looser (#190). An expired outcome names which bound tripped. Bounds are inclusive on the valid side. now pins one clock for both the record freshness check and the bundle age check; it defaults to the wall clock and a vector supplies it. A bundle that is malformed, signed by a key the caller does not trust for bundles, signed with an algorithm this build cannot verify, or issued in the future yields unverified_for_revocation with the cause named. It does not raise: inability to check is not evidence of a defect. A statement on the bundle's log naming the trusted key raises, under the section 3.2.3 fallback the existing store already implements. A store that answers "not listed" reports verified with source "store" and no horizon. Neither a bundle nor a store reports no_check_performed. Schema validation resolves the statement $ref from copies packaged with the module through a referencing.Registry, never over the network; a test validates with sockets refused and fails legibly if that changes. referencing is declared because it is imported. examples/revocation-bundle/ carries 25 generated vectors: the four rows of the truth table over the two bounds, each boundary with a one-second margin vector, the two inclusive boundaries, and every non-verified state twice. The generator writes LF bytes explicitly so the set does not depend on the platform's text-mode convention. The test file implements the five candidate staleness rules and two grace-period shortcuts as stubs; over the nine age vectors only tighter-governs survives. The set is registered with the adequacy harness and both packaged schemas are classified and held byte-identical to schema/. Not done here, and stated in the docstring rather than implied: entry-ID scoped revocation (no entry ID reaches verify_record), and per-statement signature verification against the section 3.2.1 hierarchy. Which appraisal.status value an unresolved check carries stays open on #190. Closes #246. Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: opento-suggestions --- CHANGELOG.md | 2 + docs/verification.md | 25 +- .../01-fresh-well-inside-both-bounds.json | 87 ++++ .../02-fresh-at-issuer-horizon.json | 87 ++++ .../03-fresh-at-deployment-maximum.json | 87 ++++ .../04-deployment-bound-tripped-wide.json | 90 ++++ ...eployment-bound-tripped-by-one-second.json | 90 ++++ .../06-issuer-bound-tripped-wide.json | 90 ++++ ...07-issuer-bound-tripped-by-one-second.json | 90 ++++ .../08-both-bounds-tripped-wide.json | 91 ++++ .../09-both-bounds-tripped-by-one-second.json | 91 ++++ .../10-empty-statements-is-evidence.json | 87 ++++ ...ement-names-trusted-key-by-thumbprint.json | 92 ++++ ...12-statement-names-trusted-key-by-kid.json | 92 ++++ .../13-statement-names-a-different-key.json | 101 +++++ .../14-no-bundle-no-check-performed.json | 64 +++ .../15-signature-value-corrupted.json | 89 ++++ .../16-signed-bytes-do-not-match.json | 89 ++++ .../17-bundle-key-unknown-to-caller.json | 89 ++++ .../18-bundle-signed-by-record-key.json | 89 ++++ .../19-malformed-valid-until-absent.json | 82 ++++ ...20-malformed-statement-on-another-log.json | 97 +++++ .../21-signature-alg-es256-unsupported.json | 89 ++++ .../22-signature-alg-es384-unsupported.json | 89 ++++ .../23-issued-in-future-past-skew.json | 89 ++++ .../24-issued-in-future-by-a-day.json | 89 ++++ ...no-bundle-with-bundle-keys-configured.json | 70 +++ examples/revocation-bundle/README.md | 90 ++++ .../gen_revocation_vectors.py | 406 ++++++++++++++++++ pyproject.toml | 4 + src/agentrust_trace/__init__.py | 8 + src/agentrust_trace/revocation.py | 290 +++++++++++++ .../schema/trace-revocation-bundle.json | 69 +++ .../schema/trace-revocation.json | 81 ++++ src/agentrust_trace/sign.py | 130 +++++- tests/test_adequacy_all_sets.py | 13 + ...blic_functions_raise_what_they_document.py | 26 +- tests/test_revocation_bundle.py | 400 +++++++++++++++++ tests/test_safe_integer_range.py | 6 + 39 files changed, 3719 insertions(+), 31 deletions(-) create mode 100644 examples/revocation-bundle/01-fresh-well-inside-both-bounds.json create mode 100644 examples/revocation-bundle/02-fresh-at-issuer-horizon.json create mode 100644 examples/revocation-bundle/03-fresh-at-deployment-maximum.json create mode 100644 examples/revocation-bundle/04-deployment-bound-tripped-wide.json create mode 100644 examples/revocation-bundle/05-deployment-bound-tripped-by-one-second.json create mode 100644 examples/revocation-bundle/06-issuer-bound-tripped-wide.json create mode 100644 examples/revocation-bundle/07-issuer-bound-tripped-by-one-second.json create mode 100644 examples/revocation-bundle/08-both-bounds-tripped-wide.json create mode 100644 examples/revocation-bundle/09-both-bounds-tripped-by-one-second.json create mode 100644 examples/revocation-bundle/10-empty-statements-is-evidence.json create mode 100644 examples/revocation-bundle/11-statement-names-trusted-key-by-thumbprint.json create mode 100644 examples/revocation-bundle/12-statement-names-trusted-key-by-kid.json create mode 100644 examples/revocation-bundle/13-statement-names-a-different-key.json create mode 100644 examples/revocation-bundle/14-no-bundle-no-check-performed.json create mode 100644 examples/revocation-bundle/15-signature-value-corrupted.json create mode 100644 examples/revocation-bundle/16-signed-bytes-do-not-match.json create mode 100644 examples/revocation-bundle/17-bundle-key-unknown-to-caller.json create mode 100644 examples/revocation-bundle/18-bundle-signed-by-record-key.json create mode 100644 examples/revocation-bundle/19-malformed-valid-until-absent.json create mode 100644 examples/revocation-bundle/20-malformed-statement-on-another-log.json create mode 100644 examples/revocation-bundle/21-signature-alg-es256-unsupported.json create mode 100644 examples/revocation-bundle/22-signature-alg-es384-unsupported.json create mode 100644 examples/revocation-bundle/23-issued-in-future-past-skew.json create mode 100644 examples/revocation-bundle/24-issued-in-future-by-a-day.json create mode 100644 examples/revocation-bundle/25-no-bundle-with-bundle-keys-configured.json create mode 100644 examples/revocation-bundle/README.md create mode 100644 examples/revocation-bundle/gen_revocation_vectors.py create mode 100644 src/agentrust_trace/revocation.py create mode 100644 src/agentrust_trace/schema/trace-revocation-bundle.json create mode 100644 src/agentrust_trace/schema/trace-revocation.json create mode 100644 tests/test_revocation_bundle.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 712b5641..97e457ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ### Added +- **`verify_record()` consumes the section 3.2.3 revocation bundle and reports what it checked (#190, closes #246).** The bundle format merged with #187 and nothing read it. `verify_record()` now takes `revocation_bundle`, `trusted_bundle_keys`, `max_bundle_age_seconds` and `now`, and returns a `VerificationResult` whose `revocation` field carries one of section 3.2.3's three states as a value: `verified`, `unverified_for_revocation`, or `no_check_performed`, with the cause and the evidence a second verifier needs. Previously the function returned `None` and a caller could not tell a verified key from one nobody checked, which is #246. Two bounds govern bundle age, the issuer's `valid_until` and the caller's maximum measured from `issued_at`, and the tighter governs; an expired outcome names which bound tripped. `examples/revocation-bundle/` carries 25 conformance vectors, generated, covering both bounds with margin and every non-verified state. No `appraisal.status` value is named; where an unresolvable check is recorded in the record stays open on #190. Callers that ignored the old `None` return are unaffected; a caller asserting `is None` on the return will see a change. + - **Section 3.3.4: disclosed gaps in a receipt chain (#117).** Under a profile requiring action receipts, a specification that offers only "complete" and "broken" rewards concealment: an operator who backfills a lost receipt scores better than one who reports the loss. A `GapDisclosure` is a signed chain element stating that receipts which would have occupied its position were never emitted. Coverage is structural rather than asserted: the disclosure links back to the element before the gap, the next element emitted links back to the disclosure, and verification is two link checks a verifier already performs on every ordinary element. No range fields exist, because a hash chain cannot express a range and an emitter cannot know its successor's hash at write time. The action-receipt outcome `receipt_missing_required` is narrowed to silent absence, and `receipt_gap_disclosed` is added beside it, distinct by requirement, with acceptance a verifier policy input. It never satisfies a profile requiring independently proven completeness: a disclosed gap does not establish that the missing receipts existed, how many were lost, or that omission was not selective. A disclosure at the live tail, where no successor exists to seal it, is unverified rather than disclosed or invalid: a chain truncated immediately after a disclosure is indistinguishable from an honest tail, so whatever the tail is granted, truncation is granted too. Conformance vectors in `examples/action-receipts/gap-disclosure/`, two per rule with a byte-for-byte generator; the tail case is pinned by its own test. Proposed and authored from production operation of a per-action receipt emitter; carried per the maintainer-carry provision in CONTRIBUTING. diff --git a/docs/verification.md b/docs/verification.md index 5c56b573..ab54a8ee 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -99,7 +99,7 @@ The five steps above are self-contained: given the record and a trusted key, the **Offline is a state you report, not a check you skip.** Revocation statements are anchored in the same transparency log as the records they govern, and verifiers cache a signed bundle carrying `valid_until`. A verifier offline says what it checked against, "verified against revocation bundle valid at T", rather than reporting an affirming appraisal it did not earn. §3.2.3 states that an expired bundle *"MUST report the record as unverified for revocation rather than as verified"*, and that a verifier with no bundle *"MUST report that it performed no revocation check"*. -A record with no usable inclusion entry ID has no anchor to place it before or after the compromise, so §3.2.3 falls back to binary revocation for it: *"a verifier MUST reject every record signed by the revoked key"*. That fallback is what the current `verify_record()` store implements, and it is the correct behaviour for deployments carrying no receipts. +A record with no usable inclusion entry ID has no anchor to place it before or after the compromise, so §3.2.3 falls back to binary revocation for it: *"a verifier MUST reject every record signed by the revoked key"*. That fallback is what `verify_record()` implements for both the store and the bundle, and it is the correct behaviour for deployments carrying no receipts. `verify_record()` takes a `revocation` store to do this. Pass a container of revoked identifiers, or a callable that performs a live lookup: @@ -124,12 +124,27 @@ Both failure modes raise `ValueError`, including a store that cannot answer: |---|---| | Key listed as revoked | Rejected | | Store raises (endpoint down, timeout) | Rejected; an unavailable source is not evidence a key is unrevoked | -| Key absent from the store | Verification continues | -| No `revocation` passed | Check skipped; verification is offline and proves nothing about current key status | +| Key absent from the store | Verification continues; the result reports `verified` with `source: "store"` and no horizon, because a store has none | +| No `revocation` passed and no bundle | Verification continues; the result reports `no_check_performed` | -The last row is the honest default. Omitting the store is a legitimate mode, since air-gapped audit of archived records has no other option, but the result means "this record was validly signed by this key", not "this key is still trusted". +The last row is the honest default. Omitting the store is a legitimate mode, since air-gapped audit of archived records has no other option, but the result means "this record was validly signed by this key", not "this key is still trusted", and the result says so rather than leaving it implied. -What the store does not yet do is entry-ID-scoped revocation. It answers "is this key revoked", which is the §3.2.3 fallback, so a key revoked after a long run of legitimate records currently invalidates all of them rather than the ones logged after `last_valid_entry_id`. Carrying the entry ID through `verify_record()` is implementation work tracked in the issue that produced §3.2.3, and the schemas the bundle format needs are published at [`schema/trace-revocation.json`](https://github.com/agentrust-io/trace-spec/blob/main/schema/trace-revocation.json) and [`schema/trace-revocation-bundle.json`](https://github.com/agentrust-io/trace-spec/blob/main/schema/trace-revocation-bundle.json). +`verify_record()` also consumes the bundle format §3.2.3 publishes. Pass `revocation_bundle`, a `TraceRevocationBundle/1.0` object, and `trusted_bundle_keys`, the JWKs whose signatures the caller accepts on a bundle: + +```python +result = verify_record( + record, trusted_jwk, + revocation_bundle=bundle, trusted_bundle_keys=[bundle_signer_jwk], + max_bundle_age_seconds=86400, now=verification_time, +) +result.revocation.outcome # "verified" | "unverified_for_revocation" | "no_check_performed" +result.revocation.cause # why a supplied bundle could not ground "verified", or None +result.revocation.evidence # what a second verifier needs to reach the same outcome +``` + +The three outcomes are §3.2.3's own words, and none of them is an appraisal: where a verifier records an unresolvable check in the record itself is the question [#190](https://github.com/agentrust-io/trace-spec/issues/190) holds open. A bundle is evidence only while both age bounds hold, the issuer's `valid_until` and the caller's `max_bundle_age_seconds` measured from `issued_at`; the tighter bound governs, and an expired outcome names which one tripped. `now` pins the verification moment so the outcome reproduces from retained facts. A bundle that is malformed, signed by a key not in `trusted_bundle_keys`, signed with an algorithm this build cannot verify, or dated in the future yields `unverified_for_revocation` with the cause named; it does not raise, because inability to check is not evidence of a defect. A statement on the bundle's log naming the trusted key raises, under the fallback above. [`examples/revocation-bundle/`](../examples/revocation-bundle/) carries the conformance vectors. + +What neither path does yet is entry-ID-scoped revocation. Both answer "is this key revoked", which is the §3.2.3 fallback, so a key revoked after a long run of legitimate records currently invalidates all of them rather than the ones logged after `last_valid_entry_id`. Carrying the entry ID through `verify_record()` is implementation work tracked in the issue that produced §3.2.3. The bundle path also verifies the bundle signature only, not each statement's own signature against the §3.2.1 hierarchy; that check needs the hierarchy, and it is stated here rather than implied. ## Verifying hardware-rooted records diff --git a/examples/revocation-bundle/01-fresh-well-inside-both-bounds.json b/examples/revocation-bundle/01-fresh-well-inside-both-bounds.json new file mode 100644 index 00000000..7ba1e703 --- /dev/null +++ b/examples/revocation-bundle/01-fresh-well-inside-both-bounds.json @@ -0,0 +1,87 @@ +{ + "id": "TRACE-RBUN-001", + "name": "fresh-well-inside-both-bounds", + "description": "Issued an hour ago, valid for thirty days. Neither bound tripped; verified against the bundle valid at T.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1787592000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "GmG--iXqB1DNC-4bpFB3TPa28C_PBgCnlTIDNF2On3IiJhbCVmuRkg2CWly4K56EqvCJi6sjFRIQDffgfkj6BQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [], + "outcome": "verified", + "cause": null, + "evidence": { + "bundle_digest": "sha256:69671f198ab220674650e2709eb6d5a2adefa00ea67dc964c19b7503df0cb58d", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1787592000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "statements_count": 0 + } + } +} diff --git a/examples/revocation-bundle/02-fresh-at-issuer-horizon.json b/examples/revocation-bundle/02-fresh-at-issuer-horizon.json new file mode 100644 index 00000000..d14e1626 --- /dev/null +++ b/examples/revocation-bundle/02-fresh-at-issuer-horizon.json @@ -0,0 +1,87 @@ +{ + "id": "TRACE-RBUN-002", + "name": "fresh-at-issuer-horizon", + "description": "now equals valid_until exactly. The issuer bound is inclusive on the valid side, so this is still evidence.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785000000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "LnPrgjJK7eVsayM4hpY89QH4irlq_28Hg3rc1ivG3J4uFNx1FAECITGQEZ_LDBDi3Z8SUdP0FtSrB04ToWnLAQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [], + "outcome": "verified", + "cause": null, + "evidence": { + "bundle_digest": "sha256:966c7f87f4d0a2ee2da2e594bdd1e8e9e4ac741d7e7b366935f597794da5dd90", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785000000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "statements_count": 0 + } + } +} diff --git a/examples/revocation-bundle/03-fresh-at-deployment-maximum.json b/examples/revocation-bundle/03-fresh-at-deployment-maximum.json new file mode 100644 index 00000000..91774b76 --- /dev/null +++ b/examples/revocation-bundle/03-fresh-at-deployment-maximum.json @@ -0,0 +1,87 @@ +{ + "id": "TRACE-RBUN-003", + "name": "fresh-at-deployment-maximum", + "description": "Age equals max_bundle_age_seconds exactly. The deployment bound is inclusive on the valid side.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784913600, + "valid_until": 1787592000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "w5KqSNwTwkEoYv3BKx-1Wt5IcEBqbM23l5fun4O2ZLVl_Tlmuuf1kzBDFVpClB3Rc4Mz6uvQCCWUOS4Ty8fYBg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [], + "outcome": "verified", + "cause": null, + "evidence": { + "bundle_digest": "sha256:02c42458c1f262fccbc3d92b72a4be542a739b1c13bd29c1527c53194740077f", + "log_id": "https://log.example/trace", + "issued_at": 1784913600, + "valid_until": 1787592000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "statements_count": 0 + } + } +} diff --git a/examples/revocation-bundle/04-deployment-bound-tripped-wide.json b/examples/revocation-bundle/04-deployment-bound-tripped-wide.json new file mode 100644 index 00000000..0d9c4184 --- /dev/null +++ b/examples/revocation-bundle/04-deployment-bound-tripped-wide.json @@ -0,0 +1,90 @@ +{ + "id": "TRACE-RBUN-004", + "name": "deployment-bound-tripped-wide", + "description": "Issued 48 hours ago under a 24 hour maximum, valid_until thirty days out. An implementation honouring valid_until alone passes this and is wrong.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784827200, + "valid_until": 1787592000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "f1vrVjhmqmR_TNt6rsy3MCAZylbJBTGOGMZ0hlPBhLoiLh_gOtiCWDjmFcFkiJDGWyWSWfCvFQyplnspr2rDAg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_expired", + "deployment_bound_tripped" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_expired", + "evidence": { + "bundle_digest": "sha256:0ee093cdbbf2c98416d48a3854f247fbf6a7876ff25011c43bfd86b908b4fa5e", + "log_id": "https://log.example/trace", + "issued_at": 1784827200, + "valid_until": 1787592000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bound_tripped": "deployment" + } + } +} diff --git a/examples/revocation-bundle/05-deployment-bound-tripped-by-one-second.json b/examples/revocation-bundle/05-deployment-bound-tripped-by-one-second.json new file mode 100644 index 00000000..49273013 --- /dev/null +++ b/examples/revocation-bundle/05-deployment-bound-tripped-by-one-second.json @@ -0,0 +1,90 @@ +{ + "id": "TRACE-RBUN-005", + "name": "deployment-bound-tripped-by-one-second", + "description": "Age is max_bundle_age_seconds plus one. The margin vector for the deployment boundary.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784913599, + "valid_until": 1787592000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "L7Nphk60jXDIjVhwl_OrQ1HgpOdAUM7QwltefESIVQDtoamKKf28wiOqrWFTGz5VSeRQuaCZRZdK4rNTfayWBA" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_expired", + "deployment_bound_tripped" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_expired", + "evidence": { + "bundle_digest": "sha256:8d40033e81d1e2d05551e9d9747e62d6a865244868791ed7fd06c1b578edabc0", + "log_id": "https://log.example/trace", + "issued_at": 1784913599, + "valid_until": 1787592000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bound_tripped": "deployment" + } + } +} diff --git a/examples/revocation-bundle/06-issuer-bound-tripped-wide.json b/examples/revocation-bundle/06-issuer-bound-tripped-wide.json new file mode 100644 index 00000000..6c6f6a00 --- /dev/null +++ b/examples/revocation-bundle/06-issuer-bound-tripped-wide.json @@ -0,0 +1,90 @@ +{ + "id": "TRACE-RBUN-006", + "name": "issuer-bound-tripped-wide", + "description": "Issued an hour ago, valid_until thirty minutes ago. An implementation honouring the maximum age alone passes this and is wrong.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1784998200, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "GP8EWNftDlBuhUlQrr6tAlXltCK0h-_3bdVVkURVsD3c9f547WjmHJhjAQUL9f0yyHo88fuqGo3ezDd0D-uJAg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_expired", + "issuer_bound_tripped" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_expired", + "evidence": { + "bundle_digest": "sha256:160e46e9e2a448db72cc804a11b74fb7b41b96b7eb1810c2c71394c4bbc2c5c3", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1784998200, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bound_tripped": "issuer" + } + } +} diff --git a/examples/revocation-bundle/07-issuer-bound-tripped-by-one-second.json b/examples/revocation-bundle/07-issuer-bound-tripped-by-one-second.json new file mode 100644 index 00000000..ef078281 --- /dev/null +++ b/examples/revocation-bundle/07-issuer-bound-tripped-by-one-second.json @@ -0,0 +1,90 @@ +{ + "id": "TRACE-RBUN-007", + "name": "issuer-bound-tripped-by-one-second", + "description": "valid_until is one second ago. The margin vector for the issuer boundary.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1784999999, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "hfakO9w9wKh06mpo0HmyDjmczQw-dkW33Q_F8IIlvA2i7jtWLogwmw_KCbfa63iJytJeeoBMSkWxpWr1-kCaCA" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_expired", + "issuer_bound_tripped" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_expired", + "evidence": { + "bundle_digest": "sha256:e770f0f8c9087368fc6cbfad21cf99ff746eb66aec49e67b8e53ec08a526517e", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1784999999, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bound_tripped": "issuer" + } + } +} diff --git a/examples/revocation-bundle/08-both-bounds-tripped-wide.json b/examples/revocation-bundle/08-both-bounds-tripped-wide.json new file mode 100644 index 00000000..9df7a5a9 --- /dev/null +++ b/examples/revocation-bundle/08-both-bounds-tripped-wide.json @@ -0,0 +1,91 @@ +{ + "id": "TRACE-RBUN-008", + "name": "both-bounds-tripped-wide", + "description": "Issued 48 hours ago and valid_until thirty minutes ago. Without this vector, a verifier that ORs the two bounds is indistinguishable from one that checks nothing.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784827200, + "valid_until": 1784998200, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "gm6tytGWRKjEQRWFm2xXaxHtn4K12kOaCGqvsHR1091Q9_viE13J0xDHx1YAmYdovhmXkhtH3MO_TJTtl677Dg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_expired", + "issuer_bound_tripped", + "deployment_bound_tripped" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_expired", + "evidence": { + "bundle_digest": "sha256:3d7b5d3f3c7bd3934c17b34264ecbd696683d514fde0bc1019c6209d899cdfc3", + "log_id": "https://log.example/trace", + "issued_at": 1784827200, + "valid_until": 1784998200, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bound_tripped": "both" + } + } +} diff --git a/examples/revocation-bundle/09-both-bounds-tripped-by-one-second.json b/examples/revocation-bundle/09-both-bounds-tripped-by-one-second.json new file mode 100644 index 00000000..c498501b --- /dev/null +++ b/examples/revocation-bundle/09-both-bounds-tripped-by-one-second.json @@ -0,0 +1,91 @@ +{ + "id": "TRACE-RBUN-009", + "name": "both-bounds-tripped-by-one-second", + "description": "Both bounds exceeded by exactly one second. The margin vector for the both-tripped row.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784913599, + "valid_until": 1784999999, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "WHnVYAhvVUgupo57CtA4nH9tFTbu2z3PSn_Zf6WAkKiW-dZPbZwQsQqaNHZcgo5jaX2tm_48iRpbBPF_l3oXAg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_expired", + "issuer_bound_tripped", + "deployment_bound_tripped" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_expired", + "evidence": { + "bundle_digest": "sha256:f597ca83d21746964461220a74a336bf3561c9489dbd1c08e481c99d725bfcd2", + "log_id": "https://log.example/trace", + "issued_at": 1784913599, + "valid_until": 1784999999, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bound_tripped": "both" + } + } +} diff --git a/examples/revocation-bundle/10-empty-statements-is-evidence.json b/examples/revocation-bundle/10-empty-statements-is-evidence.json new file mode 100644 index 00000000..b0875fd1 --- /dev/null +++ b/examples/revocation-bundle/10-empty-statements-is-evidence.json @@ -0,0 +1,87 @@ +{ + "id": "TRACE-RBUN-010", + "name": "empty-statements-is-evidence", + "description": "A fresh bundle with no statements. The schema says an empty array is meaningful: as of issued_at the issuer knew of no revoked keys on this log. Verified.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "1pp_hYb3kC6jeGa2tZG1WO2y0wgFhrB6OTg2BV0o3Ihjm20VoVDVgW4vHGw3eFyQirf8toZwowQFjqSBd-KfCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [], + "outcome": "verified", + "cause": null, + "evidence": { + "bundle_digest": "sha256:c095847c00c8dba98e1a3050aa0143a01f1a76e3b015d1c610a8e4836d63032e", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "statements_count": 0 + } + } +} diff --git a/examples/revocation-bundle/11-statement-names-trusted-key-by-thumbprint.json b/examples/revocation-bundle/11-statement-names-trusted-key-by-thumbprint.json new file mode 100644 index 00000000..f8b06884 --- /dev/null +++ b/examples/revocation-bundle/11-statement-names-trusted-key-by-thumbprint.json @@ -0,0 +1,92 @@ +{ + "id": "TRACE-RBUN-011", + "name": "statement-names-trusted-key-by-thumbprint", + "description": "A fresh bundle carrying a statement whose compromised_key_id is the trusted key's RFC 7638 thumbprint. No entry ID reaches the verifier, so the 3.2.3 fallback rejects the record.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "yXtpv-ONw0EPK3ZnfQZl_we6sfTzdf4i5H5wuOn1vKU", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/trace", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "vUGwaJX0PzXSbI28XY_PY6RwDGGeNZIS3n0cO7uM7-arZgS35G1qewFSDcSo1I3ekM2iXoicHCn_NpTito6qCg" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "CcKv1NhdmuWhLIwKHLfpDlwy1Mvpp3CdVlwejp4m37Ns42it_kzQrtFaKGwRhgqlbTizFa3VMSdvj5yxjuu0AA" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": true, + "codes": [ + "key_revoked" + ] + } +} diff --git a/examples/revocation-bundle/12-statement-names-trusted-key-by-kid.json b/examples/revocation-bundle/12-statement-names-trusted-key-by-kid.json new file mode 100644 index 00000000..c62f814a --- /dev/null +++ b/examples/revocation-bundle/12-statement-names-trusted-key-by-kid.json @@ -0,0 +1,92 @@ +{ + "id": "TRACE-RBUN-012", + "name": "statement-names-trusted-key-by-kid", + "description": "As 11, but the statement names the key by its kid. A match on either identifier revokes the key.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "issuer-key-2026", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/trace", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "qU4COaYNrF5ps7HhiRAcrX53-CdnYVT8n9aq-HcPrQIBGRBoVOxO9dvFwVf_Pzl18h6mEpRIjARt9mRoBcW0Cw" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "OuTNZuInrtCsKe7YEdHdyQ9cZ-N7ZSZeXQC4YI6A39gnX3qHYyAfy4YbbWJvN1jVYsq3HmKwtpYPzS2Bo1gzBQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": true, + "codes": [ + "key_revoked" + ] + } +} diff --git a/examples/revocation-bundle/13-statement-names-a-different-key.json b/examples/revocation-bundle/13-statement-names-a-different-key.json new file mode 100644 index 00000000..9b65313a --- /dev/null +++ b/examples/revocation-bundle/13-statement-names-a-different-key.json @@ -0,0 +1,101 @@ +{ + "id": "TRACE-RBUN-013", + "name": "statement-names-a-different-key", + "description": "A fresh bundle whose only statement names some other key. This record's key is not named; verified, with the statement counted.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "PZRBlg3Q6rr6RuscJHtUK0kIXwhrHybHGbHXjo3ldys", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/trace", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "n7GrY1V9czyyoFLbcEG0sX43vAlPYKAxApaAW9NEIxNmDUNwUGdRUIcS36iZ3ZS_Aj8je5FuxfVFLoi16P_xDg" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "mCdUnVDwHJmrplnCGUXv2gABK-YUImuKT4hcQOY_e7wyWgGI_aJnYRBuYVhYQd0CNIpNeeHQDfEt70tLCbZdAg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [], + "outcome": "verified", + "cause": null, + "evidence": { + "bundle_digest": "sha256:a71bb8691efc5c967f26fdc26a6ffa98fb47144cca865fb518f9c9406dc584e1", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "statements_count": 1 + } + } +} diff --git a/examples/revocation-bundle/14-no-bundle-no-check-performed.json b/examples/revocation-bundle/14-no-bundle-no-check-performed.json new file mode 100644 index 00000000..4bb0df08 --- /dev/null +++ b/examples/revocation-bundle/14-no-bundle-no-check-performed.json @@ -0,0 +1,64 @@ +{ + "id": "TRACE-RBUN-014", + "name": "no-bundle-no-check-performed", + "description": "Neither a bundle nor a store was supplied. The verifier reports that it performed no revocation check, which is what 3.2.3 requires and what the old None return withheld.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [], + "bundle": null + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "no_check_performed" + ], + "outcome": "no_check_performed", + "cause": null, + "evidence": {} + } +} diff --git a/examples/revocation-bundle/15-signature-value-corrupted.json b/examples/revocation-bundle/15-signature-value-corrupted.json new file mode 100644 index 00000000..76e3e370 --- /dev/null +++ b/examples/revocation-bundle/15-signature-value-corrupted.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-015", + "name": "signature-value-corrupted", + "description": "One character of sig.value changed. The signature does not verify; unverified, and the bundle is not read further.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "App_hYb3kC6jeGa2tZG1WO2y0wgFhrB6OTg2BV0o3Ihjm20VoVDVgW4vHGw3eFyQirf8toZwowQFjqSBd-KfCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_signature_invalid" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_signature_invalid", + "evidence": { + "bundle_digest": "sha256:9744f89e7e1d813d521a87239b21518a468efa07c19a0fe34156ae1b2edebb5b", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ" + } + } +} diff --git a/examples/revocation-bundle/16-signed-bytes-do-not-match.json b/examples/revocation-bundle/16-signed-bytes-do-not-match.json new file mode 100644 index 00000000..78d67c6e --- /dev/null +++ b/examples/revocation-bundle/16-signed-bytes-do-not-match.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-016", + "name": "signed-bytes-do-not-match", + "description": "valid_until extended after signing. The signature was made over other bytes, so an issuer horizon nobody signed is not evidence.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785172800, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "1pp_hYb3kC6jeGa2tZG1WO2y0wgFhrB6OTg2BV0o3Ihjm20VoVDVgW4vHGw3eFyQirf8toZwowQFjqSBd-KfCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_signature_invalid" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_signature_invalid", + "evidence": { + "bundle_digest": "sha256:f99d921525cd7b1d41893e16cc0d386cb8c825a8b154e8f5d28fe43230378cbd", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785172800, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ" + } + } +} diff --git a/examples/revocation-bundle/17-bundle-key-unknown-to-caller.json b/examples/revocation-bundle/17-bundle-key-unknown-to-caller.json new file mode 100644 index 00000000..54230d9c --- /dev/null +++ b/examples/revocation-bundle/17-bundle-key-unknown-to-caller.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-017", + "name": "bundle-key-unknown-to-caller", + "description": "A correctly signed bundle from a key the caller does not trust for bundles. Untrusted, not invalid: the signature is fine, the signer is not accepted.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [], + "bundle_key_id": "vF02qAO6IC7uo8E3QX6b5LzKwNFUHx9WmITW_-RDhqY", + "sig": { + "alg": "ed25519", + "value": "ZUJa_1gpzhMUTl1bOsEthARjMs04FXvfodf57MExLKCvGlkxyrsqvASjGIzCKqvAfB17MJRJ-ybCVU4ulSlsCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_key_untrusted" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_key_untrusted", + "evidence": { + "bundle_digest": "sha256:ae49b38cc9cf175771109e5ecb2eafcbad98eb4bbdc320dd98f8cc5f56d1bc41", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bundle_key_id": "vF02qAO6IC7uo8E3QX6b5LzKwNFUHx9WmITW_-RDhqY" + } + } +} diff --git a/examples/revocation-bundle/18-bundle-signed-by-record-key.json b/examples/revocation-bundle/18-bundle-signed-by-record-key.json new file mode 100644 index 00000000..9c620c04 --- /dev/null +++ b/examples/revocation-bundle/18-bundle-signed-by-record-key.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-018", + "name": "bundle-signed-by-record-key", + "description": "The record-signing key signed the bundle about itself. It is not in trusted_bundle_keys, so the bundle is untrusted; 3.2.3's signing-key independence is the reason it must not be.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [], + "bundle_key_id": "yXtpv-ONw0EPK3ZnfQZl_we6sfTzdf4i5H5wuOn1vKU", + "sig": { + "alg": "ed25519", + "value": "6OjaEPPkBFWle-6eIXehUDDXPw0mXhGxOWqaNfLlvNz6PX78B6XDBDKDOWfLSeMqPiwoxQhwFpiQKHGyKkMZCg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_key_untrusted" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_key_untrusted", + "evidence": { + "bundle_digest": "sha256:fcd8377884499efaa3850cf82ccf8ac217caae6cca2c81d91707fd05bc064dec", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "bundle_key_id": "yXtpv-ONw0EPK3ZnfQZl_we6sfTzdf4i5H5wuOn1vKU" + } + } +} diff --git a/examples/revocation-bundle/19-malformed-valid-until-absent.json b/examples/revocation-bundle/19-malformed-valid-until-absent.json new file mode 100644 index 00000000..1f812379 --- /dev/null +++ b/examples/revocation-bundle/19-malformed-valid-until-absent.json @@ -0,0 +1,82 @@ +{ + "id": "TRACE-RBUN-019", + "name": "malformed-valid-until-absent", + "description": "A required field is missing. Malformed, with the path named; nothing after shape is checked.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "1pp_hYb3kC6jeGa2tZG1WO2y0wgFhrB6OTg2BV0o3Ihjm20VoVDVgW4vHGw3eFyQirf8toZwowQFjqSBd-KfCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_malformed" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_malformed", + "evidence": { + "path": "/" + } + } +} diff --git a/examples/revocation-bundle/20-malformed-statement-on-another-log.json b/examples/revocation-bundle/20-malformed-statement-on-another-log.json new file mode 100644 index 00000000..c9204379 --- /dev/null +++ b/examples/revocation-bundle/20-malformed-statement-on-another-log.json @@ -0,0 +1,97 @@ +{ + "id": "TRACE-RBUN-020", + "name": "malformed-statement-on-another-log", + "description": "Schema-valid, but one statement names a different log from the bundle. One log per bundle; entry IDs across logs are not comparable.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "PZRBlg3Q6rr6RuscJHtUK0kIXwhrHybHGbHXjo3ldys", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/other", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "n7GrY1V9czyyoFLbcEG0sX43vAlPYKAxApaAW9NEIxNmDUNwUGdRUIcS36iZ3ZS_Aj8je5FuxfVFLoi16P_xDg" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "7Apmc8VTzRBWf92dV_57eLSm6gJThKBgIOiEW93qA2du558jCBduhj-VgepiiSyCjtDYxiM55iql23cKXccjCA" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_malformed" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_malformed", + "evidence": { + "path": "statements/0/log_id" + } + } +} diff --git a/examples/revocation-bundle/21-signature-alg-es256-unsupported.json b/examples/revocation-bundle/21-signature-alg-es256-unsupported.json new file mode 100644 index 00000000..db7519bd --- /dev/null +++ b/examples/revocation-bundle/21-signature-alg-es256-unsupported.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-021", + "name": "signature-alg-es256-unsupported", + "description": "The schema admits ES256; this build verifies Ed25519 only. Reported as unsupported rather than skipped.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ES256", + "value": "1pp_hYb3kC6jeGa2tZG1WO2y0wgFhrB6OTg2BV0o3Ihjm20VoVDVgW4vHGw3eFyQirf8toZwowQFjqSBd-KfCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_signature_unsupported" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_signature_unsupported", + "evidence": { + "bundle_digest": "sha256:086b0b39f4049c47ed49b2f728b96a6da8d180692324cf4bcd3856c65ab575a6", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "alg": "ES256" + } + } +} diff --git a/examples/revocation-bundle/22-signature-alg-es384-unsupported.json b/examples/revocation-bundle/22-signature-alg-es384-unsupported.json new file mode 100644 index 00000000..46907c68 --- /dev/null +++ b/examples/revocation-bundle/22-signature-alg-es384-unsupported.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-022", + "name": "signature-alg-es384-unsupported", + "description": "As 21, with ES384. The margin vector for the unsupported-algorithm boundary.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ES384", + "value": "1pp_hYb3kC6jeGa2tZG1WO2y0wgFhrB6OTg2BV0o3Ihjm20VoVDVgW4vHGw3eFyQirf8toZwowQFjqSBd-KfCQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_signature_unsupported" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_signature_unsupported", + "evidence": { + "bundle_digest": "sha256:77366b4662ad2bf41bce1289f709951bd93a568a85b4de6472bfbdc04ebc74be", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1785086400, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "alg": "ES384" + } + } +} diff --git a/examples/revocation-bundle/23-issued-in-future-past-skew.json b/examples/revocation-bundle/23-issued-in-future-past-skew.json new file mode 100644 index 00000000..434a81d2 --- /dev/null +++ b/examples/revocation-bundle/23-issued-in-future-past-skew.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-023", + "name": "issued-in-future-past-skew", + "description": "issued_at is one second past the tolerated clock skew. Nothing can have observed this bundle yet.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1785000301, + "valid_until": 1787592000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "9FPyEZhOGDl9lly-oHI1qFKb63tfN0XDjlEA_521az-TjgErRdxIJjtmAtk25x1qOvMq0fwpW8JZQ4YvoXi5Bg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_issued_in_future" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_issued_in_future", + "evidence": { + "bundle_digest": "sha256:aeb0e646e11890e59db1e650ef8f22e94f4c70585b0ac4efda5280e585587056", + "log_id": "https://log.example/trace", + "issued_at": 1785000301, + "valid_until": 1787592000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300 + } + } +} diff --git a/examples/revocation-bundle/24-issued-in-future-by-a-day.json b/examples/revocation-bundle/24-issued-in-future-by-a-day.json new file mode 100644 index 00000000..4299408f --- /dev/null +++ b/examples/revocation-bundle/24-issued-in-future-by-a-day.json @@ -0,0 +1,89 @@ +{ + "id": "TRACE-RBUN-024", + "name": "issued-in-future-by-a-day", + "description": "issued_at is a day ahead. The margin vector for the future-issue boundary.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1785086400, + "valid_until": 1787592000, + "statements": [], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "PpbjNGQkfwz0O0qCPOpfXpZQB_ucPI4Bqe8PHp9Nca-CC6nuOcVlKsuorjlwWdBR_FKPJU8cFpVstD4CoDdfDw" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "bundle_issued_in_future" + ], + "outcome": "unverified_for_revocation", + "cause": "bundle_issued_in_future", + "evidence": { + "bundle_digest": "sha256:f27979793a59c85b8fcbb82a70ca3ebf9c1a999ae1c4afedd41515b9d716ccf6", + "log_id": "https://log.example/trace", + "issued_at": 1785086400, + "valid_until": 1787592000, + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300 + } + } +} diff --git a/examples/revocation-bundle/25-no-bundle-with-bundle-keys-configured.json b/examples/revocation-bundle/25-no-bundle-with-bundle-keys-configured.json new file mode 100644 index 00000000..bd0603d7 --- /dev/null +++ b/examples/revocation-bundle/25-no-bundle-with-bundle-keys-configured.json @@ -0,0 +1,70 @@ +{ + "id": "TRACE-RBUN-025", + "name": "no-bundle-with-bundle-keys-configured", + "description": "Bundle keys are configured and no bundle was supplied. Configuration is not a check; still no check performed. The margin vector for the no-check row, against an implementation that reads configuration as evidence.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": null + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": false, + "codes": [ + "no_check_performed" + ], + "outcome": "no_check_performed", + "cause": null, + "evidence": {} + } +} diff --git a/examples/revocation-bundle/README.md b/examples/revocation-bundle/README.md new file mode 100644 index 00000000..5e075183 --- /dev/null +++ b/examples/revocation-bundle/README.md @@ -0,0 +1,90 @@ +# Revocation-bundle conformance vectors + +Conformance material for the consumer side of +[spec section 3.2.3](../../spec/trace-v0.2.md): a verifier holding a +`TraceRevocationBundle/1.0` and a trusted record-signing key, deciding what it may +report. The bundle format is normative and merged; these vectors pin the three +states 3.2.3 requires a verifier to distinguish, and the precedence rule settled on +[#190](https://github.com/agentrust-io/trace-spec/issues/190) between the two age +bounds that govern whether a bundle is still evidence. + +The vectors score `agentrust_trace.verify_record`. To score another implementation, +read them directly; each is self-contained. + +## Running them + +``` +python -m pytest tests/test_revocation_bundle.py tests/test_adequacy_all_sets.py +``` + +## What a vector is + +One JSON file, one scenario: + +| Field | | +|---|---| +| `id` | `TRACE-RBUN-NNN`, stable, never reused | +| `context.now` | the verification moment, Unix seconds; every age is measured from here, never from the clock | +| `context.max_bundle_age_seconds` | the deployment's bound, measured from the bundle's `issued_at` | +| `context.max_future_skew_seconds` | tolerated clock skew for `issued_at` | +| `context.trusted_key` | the JWK the verifier trusts for the record's signature; carries a `kid` so a statement can name the key either way | +| `context.trusted_bundle_keys` | JWKs whose signatures the verifier accepts on a bundle | +| `context.bundle` | the bundle under test, or `null` | +| `records` | one signed record | +| `expected` | `rejected`, `outcome`, `cause`, `codes`, and the `evidence` fields a second verifier must find | + +`expected.evidence` is a subset: every listed field must be present with that value +in what the verifier retains. It is not an exhaustive list of what the verifier +retains, since error message text is not something two implementations agree on. + +## The three states, and a fourth + +| `outcome` | 3.2.3 says | vectors | +|---|---|---| +| `verified` | "verified against revocation bundle valid at T" | 01, 02, 03, 10, 13 | +| `unverified_for_revocation` | "MUST report the record as unverified for revocation rather than as verified" | 04 to 09, 15 to 24 | +| `no_check_performed` | "MUST report that it performed no revocation check" | 14, 25 | +| `rejected` | the key is named by a statement on the bundle's log; no entry ID is available, so the 3.2.3 fallback applies | 11, 12 | + +None of the first three is an appraisal. Which `appraisal.status` value the second +and third carry is the question #190 holds open, and nothing here answers it. + +## Precedence, and why there are four age rows with margin + +Two bounds govern bundle age: the issuer's `valid_until`, inside the signed bytes, +and the deployment's maximum, supplied per call. Tighter governs. Five +implementations of "too old" are plausible (`min`, `issuer`, `deploy`, `max`, +`none`), and vectors 01, 04, 06 and 08 are the complete truth table of the two +booleans every candidate is a function of. Vector 04 alone separates `min` from +`issuer`; 06 alone separates `min` from `deploy`; without 08, `max` and `none` +give identical answers to every other row. Each boundary is carried by a second +vector one second past it (05, 07, 09): a shortcut that gives either bound a +minute of grace survives every wide row and fails that bound's one-second row, +which the stub table in the tests shows. Vectors 02 and 03 sit +exactly on the two bounds and must verify: the bounds are inclusive on the valid +side. + +`tests/test_revocation_bundle.py` implements the five rules as stubs and shows the +four rows reject all but `min`. + +## What is not here + +- **A bundle that could not be obtained.** A bundle is bytes in hand. What a + verifier does when the reference cannot be resolved depends on the state of the + world at that moment and cannot be serialised as a record; it is a harness + question. Every vector here is offline-decidable. +- **Statement-level signatures.** The bundle signature authenticates the set and + its horizon. Whether each statement's signer sits above the compromised key in + the 3.2.1 hierarchy is a separate check that needs the hierarchy, and this set + does not carry one. +- **Entry-ID scoping.** No SCITT inclusion entry ID reaches `verify_record`, so a + named key rejects every record it signed. That is 3.2.3's fallback and the + existing behaviour of the `revocation` store. + +## Regenerating + +`gen_revocation_vectors.py` derives every key from one published seed and writes +each file as bytes with LF line endings, so regeneration does not depend on the +platform's text-mode convention. It has been held byte-identical on Windows and +on CI's Linux. +`tests/test_generators_reproduce_fixtures.py` holds it to that. diff --git a/examples/revocation-bundle/gen_revocation_vectors.py b/examples/revocation-bundle/gen_revocation_vectors.py new file mode 100644 index 00000000..46104691 --- /dev/null +++ b/examples/revocation-bundle/gen_revocation_vectors.py @@ -0,0 +1,406 @@ +"""Generate the revocation-bundle conformance vectors (spec section 3.2.3). + +The bundle format merged with #187 and nothing consumed it: `valid_until` appeared +zero times under `src/`. Issue #190 greenlit a consumer that distinguishes three +states, verified against a bundle, unverified for revocation, and no check +performed, and settled two calls inside that scope. These vectors pin both. + +**Precedence.** Two bounds govern bundle age. `valid_until` is the issuer's, +signed into the bundle. The maximum bundle age is the deployment's, supplied by +the caller and measured from `issued_at`, which is the only field an age can be +measured from; the inference is safe because the schema offers no alternative, +not because the spec says so. The tighter bound governs. A deployment must be able +to be stricter than an issuer and must never be forced looser. + +Five implementations of "is this bundle too old" are plausible, one of them a +one-character mistake: `min` (tighter governs), `issuer` (`valid_until` alone), +`deploy` (maximum age alone), `max` (either party may extend, `or` where `and` was +meant), and `none`. Vectors A, B, C and D are the complete truth table of the two +booleans every candidate is a function of, so two rules that differ anywhere +differ on one of those four rows. A alone separates `min` from `issuer`; B alone +separates `min` from `deploy`; without D, `max` and `none` are indistinguishable. +Each boundary is carried by two vectors, per the margin rule in #124, so a +shortcut that happens to reject one of them does not pass the boundary. + +**The context block.** `max_bundle_age_seconds` has no home in the record and none +in the bundle: a bundle asserting its own acceptable staleness is the same shape +of problem as a record asserting its own approval threshold. It lives in the +vector's `context`, beside `now`, exactly as `max_depth` and `trusted_builders` +do in the two merged sets. The outcome then reproduces from retained facts. + +**Every vector is offline.** No vector needs a resolver. A bundle is bytes in hand; +what a verifier does when it cannot obtain one is a harness question, not a +record question, and this set does not pretend otherwise. + +Keys derive from one published seed, per role, so the whole set regenerates +byte-for-byte and a third party can reissue any of it. Files are written as bytes +with LF line endings, so the set regenerates identically on every platform. +""" + +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/revocation-bundle") +V0_2 = "tag:agentrust-io.com,2026:trace-v0.2" +SPEC = "spec/trace-v0.2.md#323-revocation-of-record-signing-keys" + +SEED = hashlib.sha256(b"trace-spec revocation-bundle fixture key").digest() +ROLES = ("issuer", "bundler", "stranger", "other-issuer") + +#: Fixed verification moment. Vectors that turn on time move the bundle, never the clock. +NOW = 1785000000 +#: Section 3.2.2's default maximum age, applied to bundles by 3.2.3's "same +#: maximum-age model" sentence. 3.2.3 names no bundle default of its own. +MAX_AGE = 86400 +SKEW = 300 +LOG = "https://log.example/trace" +DAY = 86400 +HOUR = 3600 + + +def key_for(role: str) -> Ed25519PrivateKey: + return Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(SEED + b"|" + role.encode()).digest() + ) + + +def b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def jwk_for(role: str, *, kid: str | None = None) -> dict[str, str]: + raw = key_for(role).public_key().public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + jwk = {"kty": "OKP", "crv": "Ed25519", "x": b64u(raw)} + if kid is not None: + jwk["kid"] = kid + return jwk + + +def thumbprint(jwk: dict[str, str]) -> str: + """RFC 7638 for an OKP key: crv, kty, x, in that order, JCS bytes, sha256.""" + members = {"crv": jwk["crv"], "kty": jwk["kty"], "x": jwk["x"]} + return b64u(hashlib.sha256(rfc8785.dumps(members)).digest()) + + +def base_record(*, iat: int) -> dict[str, Any]: + """A schema-valid v0.2 record with nothing optional except what the set needs.""" + return { + "eat_profile": V0_2, + "iat": iat, + "subject": "spiffe://acme.example/agent/issuer", + "model": {"provider": "anthropic", "model_id": "claude-sonnet-4-6"}, + "runtime": {"platform": "software-only", "measurement": "sha256:" + "00" * 32}, + "policy": {"bundle_hash": "sha256:" + "aa" * 32, "enforcement_mode": "enforce"}, + "data_class": "internal", + "build_provenance": {"slsa_level": 0, "digest": "sha256:" + "bb" * 32}, + "appraisal": {"status": "affirming", "verifier": "https://verifier.example/v1"}, + } + + +def signed_record(body: dict[str, Any], *, sign_key: str) -> dict[str, Any]: + record = dict(body) + record["cnf"] = {"jwk": jwk_for(sign_key)} + record["signature"] = b64u(key_for(sign_key).sign(rfc8785.dumps(record))) + return record + + +def statement(*, compromised: str, issuer: str = "bundler") -> dict[str, Any]: + body = { + "type": "TraceRevocation/1.0", + "compromised_key_id": compromised, + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": LOG, + "reason": "key compromise", + "revocation_key_id": thumbprint(jwk_for(issuer)), + } + value = b64u(key_for(issuer).sign(rfc8785.dumps(body))) + return {**body, "sig": {"alg": "ed25519", "value": value}} + + +def bundle( + *, + issued_at: int, + valid_until: int, + statements: list[dict[str, Any]] | None = None, + sign_key: str = "bundler", + key_id: str | None = None, +) -> dict[str, Any]: + body = { + "type": "TraceRevocationBundle/1.0", + "log_id": LOG, + "issued_at": issued_at, + "valid_until": valid_until, + "statements": statements or [], + "bundle_key_id": key_id or thumbprint(jwk_for(sign_key)), + } + value = b64u(key_for(sign_key).sign(rfc8785.dumps(body))) + return {**body, "sig": {"alg": "ed25519", "value": value}} + + +def bundle_digest(b: dict[str, Any]) -> str: + return "sha256:" + hashlib.sha256(rfc8785.dumps(b)).hexdigest() + + +def expired_by(issued_at: int, valid_until: int) -> str | None: + """The consumer's own arithmetic, restated here so `expected` cannot drift from it.""" + issuer = NOW > valid_until + deploy = (NOW - issued_at) > MAX_AGE + if issuer and deploy: + return "both" + if issuer: + return "issuer" + if deploy: + return "deployment" + return None + + +RECORD = signed_record(base_record(iat=NOW - HOUR), sign_key="issuer") +TRUSTED = jwk_for("issuer", kid="issuer-key-2026") +TRUSTED_ID = thumbprint(TRUSTED) +BUNDLE_KEYS = [jwk_for("bundler")] + + +def vector( + n: int, + name: str, + description: str, + *, + b: dict[str, Any] | None, + outcome: str | None, + cause: str | None = None, + codes: list[str], + rejected: bool = False, + evidence: dict[str, Any] | None = None, + trusted_bundle_keys: list[dict[str, str]] | None = None, + trusted_key: dict[str, str] = TRUSTED, +) -> tuple[str, dict[str, Any]]: + expected: dict[str, Any] = {"rejected": rejected, "codes": codes} + if not rejected: + expected["outcome"] = outcome + expected["cause"] = cause + expected["evidence"] = evidence or {} + return f"{n:02d}-{name}.json", { + "id": f"TRACE-RBUN-{n:03d}", + "name": name, + "description": description, + "spec": SPEC, + "context": { + "now": NOW, + "max_bundle_age_seconds": MAX_AGE, + "max_future_skew_seconds": SKEW, + "trusted_key": trusted_key, + "trusted_bundle_keys": ( + BUNDLE_KEYS if trusted_bundle_keys is None else trusted_bundle_keys + ), + "bundle": b, + }, + "records": [RECORD], + "expected": expected, + } + + +def fresh_evidence(b: dict[str, Any], **extra: Any) -> dict[str, Any]: + return { + "bundle_digest": bundle_digest(b), + "log_id": LOG, + "issued_at": b["issued_at"], + "valid_until": b["valid_until"], + "now": NOW, + "max_bundle_age_seconds": MAX_AGE, + **extra, + } + + +def age_vector( + n: int, name: str, description: str, *, issued_at: int, valid_until: int +) -> tuple[str, dict[str, Any]]: + b = bundle(issued_at=issued_at, valid_until=valid_until) + tripped = expired_by(issued_at, valid_until) + if tripped is None: + return vector(n, name, description, b=b, outcome="verified", codes=[], + evidence=fresh_evidence(b, statements_count=0)) + codes = ["bundle_expired"] + ( + ["issuer_bound_tripped", "deployment_bound_tripped"] if tripped == "both" + else [f"{tripped}_bound_tripped"] + ) + return vector(n, name, description, b=b, outcome="unverified_for_revocation", + cause="bundle_expired", codes=codes, + evidence=fresh_evidence(b, bound_tripped=tripped)) + + +def main() -> None: + out: list[tuple[str, dict[str, Any]]] = [] + + # The truth table, with margin. C: neither bound tripped. + out.append(age_vector(1, "fresh-well-inside-both-bounds", + "Issued an hour ago, valid for thirty days. Neither bound tripped; verified against the " + "bundle valid at T.", + issued_at=NOW - HOUR, valid_until=NOW + 30 * DAY)) + out.append(age_vector(2, "fresh-at-issuer-horizon", + "now equals valid_until exactly. The issuer bound is inclusive on the valid side, so this " + "is still evidence.", + issued_at=NOW - HOUR, valid_until=NOW)) + out.append(age_vector(3, "fresh-at-deployment-maximum", + "Age equals max_bundle_age_seconds exactly. The deployment bound is inclusive on the valid " + "side.", + issued_at=NOW - MAX_AGE, valid_until=NOW + 30 * DAY)) + # A: only the deployment bound tripped. The sole discriminator between min and issuer. + out.append(age_vector(4, "deployment-bound-tripped-wide", + "Issued 48 hours ago under a 24 hour maximum, valid_until thirty days out. An " + "implementation honouring valid_until alone passes this and is wrong.", + issued_at=NOW - 2 * DAY, valid_until=NOW + 30 * DAY)) + out.append(age_vector(5, "deployment-bound-tripped-by-one-second", + "Age is max_bundle_age_seconds plus one. The margin vector for the deployment boundary.", + issued_at=NOW - MAX_AGE - 1, valid_until=NOW + 30 * DAY)) + # B: only the issuer bound tripped. The sole discriminator between min and deploy. + out.append(age_vector(6, "issuer-bound-tripped-wide", + "Issued an hour ago, valid_until thirty minutes ago. An implementation honouring the " + "maximum age alone passes this and is wrong.", + issued_at=NOW - HOUR, valid_until=NOW - 30 * 60)) + out.append(age_vector(7, "issuer-bound-tripped-by-one-second", + "valid_until is one second ago. The margin vector for the issuer boundary.", + issued_at=NOW - HOUR, valid_until=NOW - 1)) + # D: both tripped. Separates max from none. + out.append(age_vector(8, "both-bounds-tripped-wide", + "Issued 48 hours ago and valid_until thirty minutes ago. Without this vector, a verifier " + "that ORs the two bounds is indistinguishable from one that checks nothing.", + issued_at=NOW - 2 * DAY, valid_until=NOW - 30 * 60)) + out.append(age_vector(9, "both-bounds-tripped-by-one-second", + "Both bounds exceeded by exactly one second. The margin vector for the both-tripped row.", + issued_at=NOW - MAX_AGE - 1, valid_until=NOW - 1)) + + # What the set says, once it is evidence. + b10 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY) + out.append(vector(10, "empty-statements-is-evidence", + "A fresh bundle with no statements. The schema says an empty array is meaningful: as of " + "issued_at the issuer knew of no revoked keys on this log. Verified.", + b=b10, outcome="verified", codes=[], evidence=fresh_evidence(b10, statements_count=0))) + b11 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY, + statements=[statement(compromised=TRUSTED_ID)]) + out.append(vector(11, "statement-names-trusted-key-by-thumbprint", + "A fresh bundle carrying a statement whose compromised_key_id is the trusted key's RFC " + "7638 thumbprint. No entry ID reaches the verifier, so the 3.2.3 fallback rejects the " + "record.", + b=b11, outcome=None, codes=["key_revoked"], rejected=True)) + b12 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY, + statements=[statement(compromised="issuer-key-2026")]) + out.append(vector(12, "statement-names-trusted-key-by-kid", + "As 11, but the statement names the key by its kid. A match on either identifier revokes " + "the key.", + b=b12, outcome=None, codes=["key_revoked"], rejected=True)) + b13 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY, + statements=[statement(compromised=thumbprint(jwk_for("other-issuer")))]) + out.append(vector(13, "statement-names-a-different-key", + "A fresh bundle whose only statement names some other key. This record's key is not named; " + "verified, with the statement counted.", + b=b13, outcome="verified", codes=[], evidence=fresh_evidence(b13, statements_count=1))) + + # No bundle at all. + out.append(vector(14, "no-bundle-no-check-performed", + "Neither a bundle nor a store was supplied. The verifier reports that it performed no " + "revocation check, which is what 3.2.3 requires and what the old None return withheld.", + b=None, outcome="no_check_performed", codes=["no_check_performed"], evidence={}, + trusted_bundle_keys=[])) + out.append(vector(25, "no-bundle-with-bundle-keys-configured", + "Bundle keys are configured and no bundle was supplied. Configuration is not a check; " + "still no check performed. The margin vector for the no-check row, against an " + "implementation that reads configuration as evidence.", + b=None, outcome="no_check_performed", codes=["no_check_performed"], evidence={})) + + # The bundle cannot ground anything. + b15 = copy.deepcopy(b10) + value = b15["sig"]["value"] + b15["sig"]["value"] = ("A" if value[0] != "A" else "B") + value[1:] + out.append(vector(15, "signature-value-corrupted", + "One character of sig.value changed. The signature does not verify; unverified, and the " + "bundle is not read further.", + b=b15, outcome="unverified_for_revocation", cause="bundle_signature_invalid", + codes=["bundle_signature_invalid"], + evidence=fresh_evidence(b15, bundle_key_id=b15["bundle_key_id"]))) + b16 = copy.deepcopy(b10) + b16["valid_until"] = b16["valid_until"] + DAY + out.append(vector(16, "signed-bytes-do-not-match", + "valid_until extended after signing. The signature was made over other bytes, so an issuer " + "horizon nobody signed is not evidence.", + b=b16, outcome="unverified_for_revocation", cause="bundle_signature_invalid", + codes=["bundle_signature_invalid"], + evidence=fresh_evidence(b16, bundle_key_id=b16["bundle_key_id"]))) + b17 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY, sign_key="stranger") + out.append(vector(17, "bundle-key-unknown-to-caller", + "A correctly signed bundle from a key the caller does not trust for bundles. Untrusted, " + "not invalid: the signature is fine, the signer is not accepted.", + b=b17, outcome="unverified_for_revocation", cause="bundle_key_untrusted", + codes=["bundle_key_untrusted"], + evidence=fresh_evidence(b17, bundle_key_id=b17["bundle_key_id"]))) + b18 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY, sign_key="issuer") + out.append(vector(18, "bundle-signed-by-record-key", + "The record-signing key signed the bundle about itself. It is not in trusted_bundle_keys, " + "so the bundle is untrusted; 3.2.3's signing-key independence is the reason it must not " + "be.", + b=b18, outcome="unverified_for_revocation", cause="bundle_key_untrusted", + codes=["bundle_key_untrusted"], + evidence=fresh_evidence(b18, bundle_key_id=b18["bundle_key_id"]))) + b19 = copy.deepcopy(b10) + del b19["valid_until"] + out.append(vector(19, "malformed-valid-until-absent", + "A required field is missing. Malformed, with the path named; nothing after shape is " + "checked.", + b=b19, outcome="unverified_for_revocation", cause="bundle_malformed", + codes=["bundle_malformed"], evidence={"path": "/"})) + b20 = bundle(issued_at=NOW - HOUR, valid_until=NOW + DAY, + statements=[{ + **statement(compromised=thumbprint(jwk_for("other-issuer"))), + "log_id": "https://log.example/other", + }]) + out.append(vector(20, "malformed-statement-on-another-log", + "Schema-valid, but one statement names a different log from the bundle. One log per " + "bundle; entry IDs across logs are not comparable.", + b=b20, outcome="unverified_for_revocation", cause="bundle_malformed", + codes=["bundle_malformed"], evidence={"path": "statements/0/log_id"})) + b21 = {**b10, "sig": {"alg": "ES256", "value": b10["sig"]["value"]}} + out.append(vector(21, "signature-alg-es256-unsupported", + "The schema admits ES256; this build verifies Ed25519 only. Reported as unsupported rather " + "than skipped.", + b=b21, outcome="unverified_for_revocation", cause="bundle_signature_unsupported", + codes=["bundle_signature_unsupported"], evidence=fresh_evidence(b21, alg="ES256"))) + b22 = {**b10, "sig": {"alg": "ES384", "value": b10["sig"]["value"]}} + out.append(vector(22, "signature-alg-es384-unsupported", + "As 21, with ES384. The margin vector for the unsupported-algorithm boundary.", + b=b22, outcome="unverified_for_revocation", cause="bundle_signature_unsupported", + codes=["bundle_signature_unsupported"], evidence=fresh_evidence(b22, alg="ES384"))) + b23 = bundle(issued_at=NOW + SKEW + 1, valid_until=NOW + 30 * DAY) + out.append(vector(23, "issued-in-future-past-skew", + "issued_at is one second past the tolerated clock skew. Nothing can have observed this " + "bundle yet.", + b=b23, outcome="unverified_for_revocation", cause="bundle_issued_in_future", + codes=["bundle_issued_in_future"], + evidence=fresh_evidence(b23, max_future_skew_seconds=SKEW))) + b24 = bundle(issued_at=NOW + DAY, valid_until=NOW + 30 * DAY) + out.append(vector(24, "issued-in-future-by-a-day", + "issued_at is a day ahead. The margin vector for the future-issue boundary.", + b=b24, outcome="unverified_for_revocation", cause="bundle_issued_in_future", + codes=["bundle_issued_in_future"], + evidence=fresh_evidence(b24, max_future_skew_seconds=SKEW))) + + OUT.mkdir(parents=True, exist_ok=True) + for name, doc in sorted(out): + text = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" + (OUT / name).write_bytes(text.encode("utf-8")) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index c398adc3..1d30a298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,10 @@ dependencies = [ # GPLv3, into an Apache-2.0 package's dependency tree. "rfc3986-validator>=0.1.1", "cryptography>=42.0", + # `revocation.py` resolves the bundle schema's statement `$ref` from the packaged + # copy through a `referencing.Registry`, so validation never fetches. `referencing` + # already arrives through `jsonschema`; it is declared because it is imported. + "referencing>=0.28", "rfc8785>=0.1.4", ] diff --git a/src/agentrust_trace/__init__.py b/src/agentrust_trace/__init__.py index 99bfac46..3c896b08 100644 --- a/src/agentrust_trace/__init__.py +++ b/src/agentrust_trace/__init__.py @@ -34,6 +34,11 @@ sign_record, verify_record, ) +from agentrust_trace.revocation import ( + RevocationCheck, + VerificationResult, + check_bundle, +) from agentrust_trace.validate import ( iter_errors, SCHEMA, @@ -71,7 +76,10 @@ "RuntimeInfo", "ToolTranscript", "TrustRecord", + "RevocationCheck", "RevocationStore", + "VerificationResult", + "check_bundle", "TRACE_PROFILE_V0_2", "SCHEMA", "iter_errors", diff --git a/src/agentrust_trace/revocation.py b/src/agentrust_trace/revocation.py new file mode 100644 index 00000000..052e8d42 --- /dev/null +++ b/src/agentrust_trace/revocation.py @@ -0,0 +1,290 @@ +"""Revocation-bundle consumer for ``verify_record`` (spec section 3.2.3). + +Section 3.2.3 publishes a bundle format, ``schema/trace-revocation-bundle.json``, +and states what a verifier reports under it: "verified against revocation bundle +valid at T"; "unverified for revocation" when the newest bundle is older than the +profile's maximum age; "performed no revocation check" when there is no bundle at +all. It closes with the sentence this module is built around: "Neither may be +reported as an affirming appraisal." + +Those three phrases are the three values of ``RevocationCheck.outcome``. They are +lifted from the normative text rather than coined here, and nothing in this module +names or sets an ``appraisal.status`` value. Where an unresolvable check is finally +recorded in the record itself is the question issue #190 holds open; this module +stops one step short of it, as that thread asked. + +The outcome is a value in the result, not an exception and not an out-parameter. +A caller who does not know to ask for it gets a ``VerificationResult`` whose +``revocation`` field says ``no_check_performed``, which is the same information the +old ``None`` return silently withheld. A caller who discards the result has the +fail-open problem the old return had; that is stated in ``verify_record``'s +docstring rather than hidden, and it is the shape chosen over the alternatives on +issue #190, with the reason on the record there. + +Two bounds govern bundle age, and the tighter one wins. ``valid_until`` is the +issuer's horizon, inside the bundle's signed bytes. The deployment's maximum bundle +age is the caller's, supplied per call; 3.2.3 caches bundles "under the same +maximum-age model as section 3.2.2", and 3.2.2's default under that model is 24 +hours, so 86400 is the default here. 3.2.3 itself names no bundle-specific default +and defers the value to the deployment profile. A bundle is evidence only while +both bounds hold, and an expired outcome names which bound tripped, so a verifier +re-running from the retained facts alone, with no clock and no network, reaches +the same outcome. Whether a second implementation agrees is what the conformance +vectors are for. + +What this module does not do, stated so it is not mistaken for something it does: + +- It does not verify each statement's own signature against the section 3.2.1 key + hierarchy. The bundle signature authenticates the set and its horizon; statement + signer independence is a separate check that needs the hierarchy, which this + module does not hold. +- It does not apply the entry-ID rule. No SCITT inclusion entry ID reaches + ``verify_record`` today, so a key named by a statement on the bundle's log is the + section 3.2.3 fallback: every record it signed is rejected. That is the existing + behaviour of the ``revocation`` store, unchanged. +- It does not fetch anything. Schema references are resolved from the two files + packaged beside this module, never over the network. +""" + +from __future__ import annotations + +import hashlib +import importlib.resources +import json +from collections.abc import Iterable +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Any, Literal + +import jsonschema +import referencing +import referencing.jsonschema + +from agentrust_trace.sign import ( + _b64url_decode, + _canonical_bytes, + _key_identifiers, + _pubkey_from_jwk, +) + +Outcome = Literal["verified", "unverified_for_revocation", "no_check_performed"] +"""Section 3.2.3's three reportable states, in its own words.""" + +Cause = Literal[ + "bundle_expired", + "bundle_malformed", + "bundle_key_untrusted", + "bundle_signature_invalid", + "bundle_signature_unsupported", + "bundle_issued_in_future", +] +"""Why a supplied bundle could not ground a verified outcome.""" + +BoundTripped = Literal["issuer", "deployment", "both"] + + +@dataclass(frozen=True) +class RevocationCheck: + """What the revocation check reported, and the facts a second verifier needs. + + ``evidence`` is JSON-serialisable by construction so it can be retained beside + the record and compared byte-for-byte against a conformance vector's + ``expected`` block. + """ + + outcome: Outcome + cause: Cause | None = None + evidence: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class VerificationResult: + """The result of ``verify_record``. Success is no longer ``None``. + + Every other rejection still raises, as before. This type exists so that the + outcomes section 3.2.3 says may not be reported as an affirming appraisal have + somewhere to be reported, alongside the checks that passed. + """ + + revocation: RevocationCheck + trusted_key_thumbprint: str + + +NO_CHECK = RevocationCheck(outcome="no_check_performed") +"""The result when neither a bundle nor a store was supplied.""" + + +def _unverified(cause: Cause, evidence: dict[str, Any]) -> RevocationCheck: + return RevocationCheck(outcome="unverified_for_revocation", cause=cause, evidence=evidence) + + +@lru_cache(maxsize=1) +def _bundle_validator() -> jsonschema.Draft202012Validator: + """A validator for the bundle schema that resolves its statement ``$ref`` locally. + + ``statements.items.$ref`` is an absolute URL. A validator built from the bundle + file alone resolves it by fetching the published schema, which checks a nested + statement against whatever is deployed rather than the file next to it, and + fails outright with no network. Both schemas are packaged beside this module + and registered under their ``$id`` so resolution never leaves the process. + ``tests/test_revocation_bundle.py`` validates with sockets blocked to hold it + to that. + """ + pkg = importlib.resources.files("agentrust_trace") / "schema" + bundle_schema = json.loads( + (pkg / "trace-revocation-bundle.json").read_text(encoding="utf-8") + ) + statement_schema = json.loads((pkg / "trace-revocation.json").read_text(encoding="utf-8")) + resource = referencing.Resource.from_contents( + statement_schema, default_specification=referencing.jsonschema.DRAFT202012 + ) + return jsonschema.Draft202012Validator( + bundle_schema, registry=resource @ referencing.Registry() + ) + + +def bundle_digest(bundle: dict[str, Any]) -> str: + """The bundle's identity: sha256 over its RFC 8785 form, signature included. + + Signature included, because two bundles with identical content and different + signatures are two different pieces of evidence, and the digest names which one + the outcome was reached against. + """ + if not isinstance(bundle, dict): + raise ValueError(f"bundle must be a JSON object, got {type(bundle).__name__}") + try: + canonical = _canonical_bytes(bundle) + except ValueError: + raise + except Exception as exc: + raise ValueError(f"bundle has no RFC 8785 form: {exc}") from exc + return "sha256:" + hashlib.sha256(canonical).hexdigest() + + +def _trusted_bundle_key( + bundle_key_id: str, trusted_bundle_keys: Iterable[dict[str, Any]] +) -> dict[str, Any] | None: + for jwk in trusted_bundle_keys: + if bundle_key_id in _key_identifiers(jwk): + return jwk + return None + + +def check_bundle( + bundle: dict[str, Any], + *, + trusted_key_identifiers: Iterable[str], + trusted_bundle_keys: Iterable[dict[str, Any]], + now: int, + max_bundle_age_seconds: int, + max_future_skew_seconds: int, +) -> RevocationCheck: + """Decide what a bundle lets a verifier report about the trusted record key. + + The order of checks is fixed and is the order a second verifier must follow to + reproduce the outcome: shape, then who signed the set, then whether the set is + still evidence, then what the set says. A bundle that fails an earlier step is + not read further, so the cause names the first thing wrong, not everything. + + Raises ``ValueError`` when a statement on the bundle's log names the trusted + key. That is evidence failing rather than evidence absent, and it fails closed + like the ``revocation`` store does. + """ + trusted_ids = list(trusted_key_identifiers) + trusted_bundle_keys = list(trusted_bundle_keys) + + # 3a. Shape, against the packaged schema pair. The first error by path, so the + # evidence points at one place rather than listing the file. + errors = sorted( + _bundle_validator().iter_errors(bundle), + key=lambda e: list(map(str, e.absolute_path)), + ) + if errors: + first = errors[0] + return _unverified("bundle_malformed", { + "path": "/".join(str(p) for p in first.absolute_path) or "/", + "error": first.message, + }) + + log_id = bundle["log_id"] + statements = bundle["statements"] + + # 3b. One log per bundle. The schema pins that on the bundle and on each + # statement separately; equality between them is this module's check. + for index, statement in enumerate(statements): + if statement["log_id"] != log_id: + return _unverified("bundle_malformed", { + "path": f"statements/{index}/log_id", + "error": f"statement names log {statement['log_id']!r}; bundle is for {log_id!r}", + }) + + issued_at = bundle["issued_at"] + valid_until = bundle["valid_until"] + base = { + "bundle_digest": bundle_digest(bundle), + "log_id": log_id, + "issued_at": issued_at, + "valid_until": valid_until, + "now": now, + "max_bundle_age_seconds": max_bundle_age_seconds, + } + + # 3c. Who signed the set. Only Ed25519 can be verified here; the schema also + # admits ES256 and ES384, and those are reported as unsupported rather than + # skipped, because a signature nobody checked grounds nothing. + alg = bundle["sig"]["alg"] + if alg != "ed25519": + return _unverified("bundle_signature_unsupported", {**base, "alg": alg}) + key = _trusted_bundle_key(bundle["bundle_key_id"], trusted_bundle_keys) + if key is None: + return _unverified( + "bundle_key_untrusted", {**base, "bundle_key_id": bundle["bundle_key_id"]} + ) + try: + public = _pubkey_from_jwk(key) + except ValueError as exc: + return _unverified( + "bundle_signature_unsupported", {**base, "alg": alg, "key": str(exc)} + ) + unsigned = {k: v for k, v in bundle.items() if k != "sig"} + try: + signature = _b64url_decode(bundle["sig"]["value"], field="bundle sig.value") + public.verify(signature, _canonical_bytes(unsigned)) + except Exception: + return _unverified( + "bundle_signature_invalid", {**base, "bundle_key_id": bundle["bundle_key_id"]} + ) + + # 3d. A bundle from the future has an issued_at nothing can have observed. + if issued_at > now + max_future_skew_seconds: + return _unverified("bundle_issued_in_future", { + **base, "max_future_skew_seconds": max_future_skew_seconds, + }) + + # 3e. Age. Tighter governs: the bundle is evidence only while both bounds hold. + # Inclusive on the valid side, so now == valid_until and age == max are fresh. + issuer_tripped = now > valid_until + deployment_tripped = (now - issued_at) > max_bundle_age_seconds + if issuer_tripped or deployment_tripped: + tripped: BoundTripped = ( + "both" if issuer_tripped and deployment_tripped + else "issuer" if issuer_tripped + else "deployment" + ) + return _unverified("bundle_expired", {**base, "bound_tripped": tripped}) + + # 3f. What the set says about this key. Fallback rule: no entry ID is available + # here, so a named key rejects every record it signed. + for statement in statements: + if statement["compromised_key_id"] in trusted_ids: + raise ValueError( + f"signing key is revoked: statement on log {log_id!r} names it as " + f"{statement['compromised_key_id']!r} (bundle {base['bundle_digest']}). " + "No inclusion entry ID is available to place this record before the " + "revocation, so section 3.2.3's fallback applies and the record is rejected." + ) + + # 3g. Verified against this bundle, valid at T, with T retained. + return RevocationCheck( + outcome="verified", evidence={**base, "statements_count": len(statements)} + ) diff --git a/src/agentrust_trace/schema/trace-revocation-bundle.json b/src/agentrust_trace/schema/trace-revocation-bundle.json new file mode 100644 index 00000000..b16b6f76 --- /dev/null +++ b/src/agentrust_trace/schema/trace-revocation-bundle.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentrust-io.com/schema/trace-revocation-bundle-v1.json", + "title": "TRACE Revocation Bundle", + "description": "A signed, cacheable set of TraceRevocation/1.0 statements with an explicit validity horizon. Spec trace-v0.2 section 3.2.3. The bundle exists so revocation does not require a callback at verification time: a verifier offline states what it checked against rather than skipping the check silently.", + "type": "object", + "required": [ + "type", + "log_id", + "issued_at", + "valid_until", + "statements", + "bundle_key_id", + "sig" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "TraceRevocationBundle/1.0" + }, + "log_id": { + "type": "string", + "minLength": 1, + "description": "The transparency log every statement in this bundle refers to. One log per bundle, so an entry-ID comparison can never be made across logs by accident." + }, + "issued_at": { + "type": "integer", + "minimum": 1700000000, + "maximum": 9007199254740991, + "description": "When the bundle was assembled, Unix epoch seconds." + }, + "valid_until": { + "type": "integer", + "minimum": 1700000000, + "maximum": 9007199254740991, + "description": "The horizon this bundle may be relied on to. Past it, a verifier reports the record as unverified for revocation rather than verified: an expired bundle is not evidence a key is still trusted, and spec section 3.2.3 forbids reporting it as an affirming appraisal." + }, + "statements": { + "type": "array", + "description": "The revocation statements. An empty array is meaningful and legitimate: it asserts that as of issued_at the issuer knew of no revoked keys on this log, which is different from having no bundle at all.", + "items": { + "$ref": "https://agentrust-io.com/schema/trace-revocation-v1.json" + } + }, + "bundle_key_id": { + "type": "string", + "minLength": 1, + "description": "The key signing this bundle. The bundle signature authenticates the set and its horizon; each statement inside stays independently signed, so a bundle assembler cannot add a revocation it was not authorised to issue." + }, + "sig": { + "type": "object", + "required": ["alg", "value"], + "additionalProperties": false, + "description": "Signature over the RFC 8785 canonical form of this object with `sig` absent.", + "properties": { + "alg": { + "type": "string", + "enum": ["ed25519", "ES256", "ES384"] + }, + "value": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "base64url, no padding." + } + } + } + } +} diff --git a/src/agentrust_trace/schema/trace-revocation.json b/src/agentrust_trace/schema/trace-revocation.json new file mode 100644 index 00000000..c5dbd7d0 --- /dev/null +++ b/src/agentrust_trace/schema/trace-revocation.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agentrust-io.com/schema/trace-revocation-v1.json", + "title": "TRACE Revocation Statement", + "description": "A TraceRevocation/1.0 statement withdrawing a record-signing key from a transparency-log entry onward. Spec trace-v0.2 section 3.2.3. The boundary is a log entry ID rather than a timestamp: a compromised record-signing key also signs the iat it would be judged against, so any time-anchored rule is defeated by backdating.", + "type": "object", + "required": [ + "type", + "compromised_key_id", + "last_valid_entry_id", + "log_id", + "revocation_key_id", + "sig" + ], + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "TraceRevocation/1.0", + "description": "Claim type identifier." + }, + "compromised_key_id": { + "type": "string", + "minLength": 1, + "description": "The revoked record-signing key, as an RFC 7638 JWK thumbprint or a kid. Matching on either identifier rejects the record." + }, + "last_valid_entry_id": { + "type": "string", + "minLength": 1, + "description": "The highest log entry ID at which this key's records remain valid. A record whose inclusion entry ID is less than or equal to this value is unaffected; a greater one is rejected." + }, + "revoked_after_entry": { + "type": "string", + "minLength": 1, + "description": "The next entry ID after last_valid_entry_id. Redundant by construction and carried so a reader does not have to know the log's successor function to see where the boundary falls." + }, + "log_id": { + "type": "string", + "minLength": 1, + "description": "The transparency log the entry IDs refer to. Entry IDs from a different log are not comparable and must not satisfy the verifier rule." + }, + "reason": { + "type": "string", + "description": "Why the key was revoked. Free text: the verifier rule does not branch on it, and constraining it would invite a false sense that it is machine-actionable.", + "examples": [ + "key compromise", + "superseded", + "operator request" + ] + }, + "revoked_at": { + "type": "integer", + "minimum": 1700000000, + "maximum": 9007199254740991, + "description": "When the revocation was issued, Unix epoch seconds. Informational only. It is deliberately NOT the boundary: see the description of this schema." + }, + "revocation_key_id": { + "type": "string", + "minLength": 1, + "description": "The key signing this statement. Spec section 3.2.3 requires it to sit above compromised_key_id in the section 3.2.1 hierarchy, or to be an organisational recovery key with an independent compromise domain. A statement the compromised key could sign for itself is a tool for whoever stole it." + }, + "sig": { + "type": "object", + "required": ["alg", "value"], + "additionalProperties": false, + "description": "Signature over the RFC 8785 canonical form of this object with `sig` absent.", + "properties": { + "alg": { + "type": "string", + "enum": ["ed25519", "ES256", "ES384"], + "description": "Signature algorithm, matching the set spec section 3.2.1 allows." + }, + "value": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "base64url, no padding." + } + } + } + } +} diff --git a/src/agentrust_trace/sign.py b/src/agentrust_trace/sign.py index 903b9775..8be2dd37 100644 --- a/src/agentrust_trace/sign.py +++ b/src/agentrust_trace/sign.py @@ -14,8 +14,11 @@ import json import os import warnings -from collections.abc import Callable, Container -from typing import Any, TypeAlias +from collections.abc import Callable, Container, Iterable +from typing import TYPE_CHECKING, Any, TypeAlias + +if TYPE_CHECKING: + from agentrust_trace.revocation import VerificationResult import rfc8785 from jsonschema import ValidationError @@ -413,7 +416,11 @@ def verify_record( max_future_skew_seconds: int = 300, expected_nonce: str | None = None, revocation: RevocationStore | None = None, -) -> None: + revocation_bundle: dict[str, Any] | None = None, + trusted_bundle_keys: Iterable[dict[str, Any]] | None = None, + max_bundle_age_seconds: int = 86400, + now: int | None = None, +) -> VerificationResult: """Verify an Ed25519 signature on a signed TRACE Trust Record. A trusted key is REQUIRED. Pass an ``Ed25519PublicKey`` or a JWK dict via @@ -422,7 +429,8 @@ def verify_record( Raises ``InvalidSignature`` if the signature does not verify, and ``ValueError`` for every other rejection (wrong or missing profile, no signature, no trusted key, malformed input, unsupported JWK type, stale record, nonce mismatch, or - revoked key). Returns ``None`` on success. All checks fail closed. + revoked key). Returns a ``VerificationResult`` on success, carrying what the + revocation check reported; see below. All checks fail closed. Profile (fail closed): The record's ``eat_profile`` must be exactly ``TRACE_PROFILE_V0_2``. @@ -452,25 +460,77 @@ def verify_record( a record dated further in the future is rejected. If ``expected_nonce`` is given, it is compared in constant time against ``record["runtime"]["nonce"]``. A stale record or nonce mismatch raises - ``ValueError``. - - Revocation (fail closed): - ``spec/trace-v0.2.md`` §3.2.1 requires a verifier to consult current - revocation status at verification time. Pass a ``revocation`` store, either - a container of revoked key identifiers or a callable performing a live - CRL/status/SCITT lookup. The trusted key is rejected if it is listed, or if - the store cannot answer: a callable that raises has not answered, and so has - one that returns anything other than ``True`` or ``False``. Identifiers are - the key's RFC 7638 thumbprint (``jwk_thumbprint``) and its ``kid``. - - ``revocation=None`` (the default) skips the check and keeps verification - purely offline. Offline verification cannot prove non-revocation: a - signature made by a compromised key stays cryptographically valid forever, - and nothing inside the record can retract it. See ``LIMITATIONS.md``. + ``ValueError``. ``now`` pins the verification moment (Unix epoch seconds) + for both the record-age check and the bundle-age check below; it defaults + to the clock, and a conformance vector supplies it so the outcome + reproduces from retained facts rather than from when the test ran. + + Revocation (reported, never implied): + ``spec/trace-v0.2.md`` section 3.2.3 separates three states and forbids + reporting any of them as an affirming appraisal: verified against a + revocation bundle valid at T; unverified for revocation, because the newest + bundle is past the profile's maximum age; and no revocation check performed, + because there was no bundle. The result's ``revocation`` field carries which + one applied and the facts a second verifier needs to reach it again. + + Pass ``revocation_bundle``, a dict validated here against + ``schema/trace-revocation-bundle.json``, together with + ``trusted_bundle_keys``, the JWKs whose signatures the caller accepts on a + bundle. The bundle is evidence only while both age bounds hold: the + issuer's ``valid_until`` and the caller's ``max_bundle_age_seconds``, + measured from ``issued_at``. The tighter bound governs. 86400 is section + 3.2.2's default maximum age, applied to bundles by 3.2.3's "same + maximum-age model" sentence; 3.2.3 names no bundle default of its own and + defers the value to the deployment profile. A bundle that is malformed, + signed by a key not in ``trusted_bundle_keys``, signed with an algorithm + this build cannot verify, dated in the future, or expired under either + bound yields ``unverified_for_revocation`` with the cause named; it does + not raise, because inability to check is not evidence of a defect. A + statement on the bundle's log naming the trusted key raises ``ValueError``: + no inclusion entry ID reaches this function, so 3.2.3's fallback applies + and every record the key signed is rejected. + + ``revocation`` is the older store interface and still works: a container + of revoked key identifiers or a callable performing a live lookup. The + trusted key is rejected if it is listed, or if the store cannot answer. + A store that answers "not listed" is a check performed; the result reports + ``verified`` with ``source: "store"`` and no horizon, because a store has + none. Identifiers are the key's RFC 7638 thumbprint (``jwk_thumbprint``) + and its ``kid``, and the check reads the trusted key, never + ``record["cnf"]["jwk"]``. + + With neither a bundle nor a store the result reports + ``no_check_performed``. That is the honest offline default, and it is + what the old ``None`` return withheld: verification that proves the record + was validly signed by this key, and nothing about whether the key is still + trusted. See ``LIMITATIONS.md``. + + The outcome is a value in the result rather than an exception or a + separate entry point, so a caller has to handle it to know it. A caller + who discards the return has the fail-open behaviour the old signature + had; the alternatives were worse, and the reasoning is on issue #190. """ import time from hmac import compare_digest + from agentrust_trace.revocation import ( + NO_CHECK, + RevocationCheck, + VerificationResult, + check_bundle, + ) + + if now is None: + verification_time = int(time.time()) + elif isinstance(now, bool) or not isinstance(now, int): + raise ValueError("now must be an integer Unix timestamp in seconds, or None") + else: + verification_time = now + if max_bundle_age_seconds < 0: + raise ValueError("max_bundle_age_seconds must be non-negative") + if max_future_skew_seconds < 0: + raise ValueError("max_future_skew_seconds must be non-negative") + from cryptography.exceptions import InvalidSignature as _InvalidSignature # noqa: F401 # Profile first: refuse semantics this build does not implement before spending @@ -563,6 +623,29 @@ def verify_record( if revocation is not None: _check_not_revoked(trusted_jwk, revocation) + # What the revocation check reports. The bundle governs when present; a store + # consulted beside it is recorded as consulted. Neither present: say so. + revocation_check: RevocationCheck + if revocation_bundle is not None: + revocation_check = check_bundle( + revocation_bundle, + trusted_key_identifiers=_key_identifiers(trusted_jwk), + trusted_bundle_keys=trusted_bundle_keys or (), + now=verification_time, + max_bundle_age_seconds=max_bundle_age_seconds, + max_future_skew_seconds=max_future_skew_seconds, + ) + if revocation is not None: + revocation_check = RevocationCheck( + outcome=revocation_check.outcome, + cause=revocation_check.cause, + evidence={**revocation_check.evidence, "store": "consulted"}, + ) + elif revocation is not None: + revocation_check = RevocationCheck(outcome="verified", evidence={"source": "store"}) + else: + revocation_check = NO_CHECK + # The signature binding is defined as a signature made by the key in cnf. # Verifying with a caller-pinned key is necessary for authenticity, but it # must not permit a trusted signer to authenticate a record that names a @@ -578,12 +661,10 @@ def verify_record( ) # Freshness: bound the age of the record against its issued-at timestamp. - if max_future_skew_seconds < 0: - raise ValueError("max_future_skew_seconds must be non-negative") iat = record.get("iat") if not isinstance(iat, int) or isinstance(iat, bool): raise ValueError("record has no valid integer 'iat' for freshness check") - age = time.time() - iat + age = verification_time - iat if age < -max_future_skew_seconds: raise ValueError( f"record is dated {int(-age)}s in the future, exceeds " @@ -607,3 +688,8 @@ def verify_record( msg = _canonical_bytes(record_no_sig) pub.verify(sig_bytes, msg) # raises InvalidSignature on failure + + return VerificationResult( + revocation=revocation_check, + trusted_key_thumbprint=jwk_thumbprint(trusted_jwk), + ) diff --git a/tests/test_adequacy_all_sets.py b/tests/test_adequacy_all_sets.py index bec5453e..6eb604bd 100644 --- a/tests/test_adequacy_all_sets.py +++ b/tests/test_adequacy_all_sets.py @@ -114,8 +114,21 @@ def delegation_link() -> list[Vector]: lambda e: e["classification"], lambda e: list(e.get("codes") or [])) +def revocation_bundle() -> list[Vector]: + """The revocation-bundle set. Boundaries are its codes, one code per rule. + + `rejected` is a fourth outcome beside 3.2.3's three: the key was named by a + statement and the record refused. It counts as non-accepting here, which is + what it is. + """ + return _load("revocation-bundle", + lambda e: "rejected" if e["rejected"] else e["outcome"], + lambda e: list(e.get("codes") or [])) + + SETS = { "build-provenance-depth": (build_provenance_depth, _depth_boundary), + "revocation-bundle": (revocation_bundle, None), "canonicalization-boundary": (canonicalization_boundary, None), "delegation-link": (delegation_link, None), } diff --git a/tests/test_public_functions_raise_what_they_document.py b/tests/test_public_functions_raise_what_they_document.py index a123903a..2a4fb300 100644 --- a/tests/test_public_functions_raise_what_they_document.py +++ b/tests/test_public_functions_raise_what_they_document.py @@ -35,7 +35,7 @@ import agentrust_trace as at from agentrust_trace import (content_marking, generate_key, intent_bridge, key_to_jwk, - provenance, sign, validate) + provenance, revocation, sign, validate) #: Values a caller can supply where an object, a string, a key or bytes is expected. #: The last five are the ones that separate a strict canonicalizer from a permissive @@ -52,6 +52,7 @@ "content_marking": ("ContentMarkingError", "RecordMismatch"), "intent_bridge": ("IntentBridgeError", "AuthorizationDenied", "AuthorizationMismatch"), "provenance": ("ProvenanceError", "ToolCatalogMismatch"), + "revocation": ("ValueError", "UnanchorableValue"), "sign": ("ValueError", "UnanchorableValue", "InvalidSignature"), "validate": ("ValueError", "ValidationError"), "models": ("ValidationError",), @@ -77,6 +78,11 @@ "provenance.sign_record": lambda v: provenance.sign_record(v, _KEY), "provenance.tool_catalog_hash": provenance.tool_catalog_hash, "provenance.verify_record": lambda v: provenance.verify_record(v, _JWK), + "revocation.bundle_digest": revocation.bundle_digest, + "revocation.check_bundle": lambda v: revocation.check_bundle( + v, trusted_key_identifiers=[], trusted_bundle_keys=[_JWK], now=1785000000, + max_bundle_age_seconds=86400, max_future_skew_seconds=300, + ), "sign.anchor_bytes": sign.anchor_bytes, "sign.jwk_thumbprint": sign.jwk_thumbprint, "sign.key_to_jwk": sign.key_to_jwk, @@ -170,6 +176,7 @@ def test_no_public_function_raises_an_undocumented_exception(name: str) -> None: "provenance.sign_record": (None, "ProvenanceError"), "provenance.tool_catalog_hash": (None, "ProvenanceError"), "provenance.verify_record": (None, "ProvenanceError"), + "revocation.bundle_digest": (None, "ValueError"), "sign.anchor_bytes": (b"bytes", "UnanchorableValue"), "sign.jwk_thumbprint": (None, "ValueError"), "sign.key_to_jwk": (None, "ValueError"), @@ -181,9 +188,20 @@ def test_no_public_function_raises_an_undocumented_exception(name: str) -> None: def test_every_swept_function_has_a_witness() -> None: - """`iter_errors` is excluded on purpose and named, rather than silently absent: - it returns findings instead of raising, and its witness is the next test.""" - assert set(REACHES) == set(CALLS) - {"validate.iter_errors"} + """Two are excluded on purpose and named, rather than silently absent. `iter_errors` + returns findings instead of raising, and its witness is the next test. + `check_bundle` reports a bundle it cannot use as an outcome rather than raising, + by design (spec 3.2.3: inability to check is not evidence of a defect), and its + witness is `test_check_bundle_reports_junk_as_an_outcome` below.""" + assert set(REACHES) == set(CALLS) - {"validate.iter_errors", "revocation.check_bundle"} + + +@pytest.mark.parametrize("value", JUNK, ids=[repr(v)[:12] for v in JUNK]) +def test_check_bundle_reports_junk_as_an_outcome(value: Any) -> None: + """The sweep reaches `check_bundle`, and what comes back is a result, not a raise.""" + result = CALLS["revocation.check_bundle"](value) + assert result.outcome == "unverified_for_revocation" + assert result.cause == "bundle_malformed" @pytest.mark.parametrize("name", sorted(REACHES)) diff --git a/tests/test_revocation_bundle.py b/tests/test_revocation_bundle.py new file mode 100644 index 00000000..84f3d7d1 --- /dev/null +++ b/tests/test_revocation_bundle.py @@ -0,0 +1,400 @@ +"""The revocation-bundle consumer, held to section 3.2.3 and to its own vectors. + +Three groups. The vector runner puts every fixture under `examples/revocation-bundle/` +through `verify_record` and compares what came back to the fixture's `expected` +block, evidence included. The invariant tests pin the properties the module's +docstring claims, each as a run rather than a sentence. The discrimination test +implements the five candidate staleness rules as stubs and shows the age vectors +reject all but tighter-governs, which is the claim made on #190 in a table, +executed. +""" + +from __future__ import annotations + +import json +import pathlib +import re +import subprocess +import sys + +import pytest + +from agentrust_trace import verify_record +from dataclasses import FrozenInstanceError + +from agentrust_trace.revocation import NO_CHECK, VerificationResult, check_bundle +from agentrust_trace.sign import jwk_thumbprint + +ROOT = pathlib.Path(__file__).resolve().parents[1] +VECTORS = ROOT / "examples" / "revocation-bundle" +FILES = sorted(VECTORS.glob("*.json")) +assert FILES, "no revocation-bundle vectors on disk; the runner would measure nothing" + + +def _load(path: pathlib.Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def _run(doc: dict) -> VerificationResult: + ctx = doc["context"] + return verify_record( + doc["records"][0], + ctx["trusted_key"], + now=ctx["now"], + max_bundle_age_seconds=ctx["max_bundle_age_seconds"], + max_future_skew_seconds=ctx["max_future_skew_seconds"], + revocation_bundle=ctx["bundle"], + trusted_bundle_keys=ctx["trusted_bundle_keys"], + ) + + +# ---- the vectors -------------------------------------------------------------- + + +@pytest.mark.parametrize("path", FILES, ids=[p.stem for p in FILES]) +def test_vector(path: pathlib.Path) -> None: + doc = _load(path) + expected = doc["expected"] + if expected["rejected"]: + with pytest.raises(ValueError, match="revoked"): + _run(doc) + return + result = _run(doc) + check = result.revocation + assert check.outcome == expected["outcome"], path.name + assert check.cause == expected["cause"], path.name + for key, value in expected["evidence"].items(): + assert check.evidence.get(key) == value, f"{path.name}: evidence[{key!r}]" + + +def test_every_vector_has_a_stable_id_and_the_ids_are_unique() -> None: + ids = [_load(p)["id"] for p in FILES] + assert len(ids) == len(set(ids)) + assert all(re.fullmatch(r"TRACE-RBUN-\d{3}", i) for i in ids) + + +def test_evidence_is_json_serialisable_for_every_vector() -> None: + """I6 needs the evidence to be retainable beside the record, which means bytes.""" + for path in FILES: + doc = _load(path) + if doc["expected"]["rejected"]: + continue + json.dumps(_run(doc).revocation.evidence) + + +# ---- invariants --------------------------------------------------------------- + + +def _fresh() -> tuple[dict, dict]: + doc = _load(VECTORS / "01-fresh-well-inside-both-bounds.json") + return doc, doc["context"] + + +def test_I1_outcome_is_always_one_of_three_and_never_raised() -> None: + seen = set() + for path in FILES: + doc = _load(path) + if doc["expected"]["rejected"]: + continue + seen.add(_run(doc).revocation.outcome) + assert seen == {"verified", "unverified_for_revocation", "no_check_performed"} + + +def test_I2_no_check_performed_iff_no_bundle_and_no_store() -> None: + doc, ctx = _fresh() + neither = verify_record(doc["records"][0], ctx["trusted_key"], now=ctx["now"]) + assert neither.revocation == NO_CHECK + store_only = verify_record( + doc["records"][0], ctx["trusted_key"], now=ctx["now"], revocation=set() + ) + assert store_only.revocation.outcome == "verified" + assert store_only.revocation.evidence == {"source": "store"} + both = verify_record( + doc["records"][0], ctx["trusted_key"], now=ctx["now"], revocation=set(), + revocation_bundle=ctx["bundle"], trusted_bundle_keys=ctx["trusted_bundle_keys"], + max_bundle_age_seconds=ctx["max_bundle_age_seconds"], + ) + assert both.revocation.outcome == "verified" + assert both.revocation.evidence["store"] == "consulted" + assert "bundle_digest" in both.revocation.evidence, "the bundle governs when both are present" + + +def test_I4_tighter_governs_is_true_exactly_on_the_intersection() -> None: + """The four rows of the truth table, each carried by a signed vector. + + A bundle cannot be re-aged in a test without re-signing it, and the generator + is not imported here, so the rows are read from the committed vectors whose + bundles were signed over exactly these ages. + """ + rows = { + "01-fresh-well-inside-both-bounds": None, + "04-deployment-bound-tripped-wide": "deployment", + "06-issuer-bound-tripped-wide": "issuer", + "08-both-bounds-tripped-wide": "both", + } + for name, tripped in rows.items(): + check = _run(_load(VECTORS / f"{name}.json")).revocation + if tripped is None: + assert check.outcome == "verified", name + else: + got = (check.cause, check.evidence["bound_tripped"]) + assert got == ("bundle_expired", tripped), name + + +def test_I5_every_expired_outcome_names_which_bound_tripped() -> None: + for path in FILES: + doc = _load(path) + if doc["expected"].get("cause") == "bundle_expired": + tripped = _run(doc).revocation.evidence["bound_tripped"] + assert tripped in {"issuer", "deployment", "both"}, path.name + + +def test_I6_a_second_verifier_reproduces_the_outcome_from_retained_facts() -> None: + """Same bundle, same retained now and max age, no clock: identical result.""" + for path in FILES: + doc = _load(path) + if doc["expected"]["rejected"] or doc["context"]["bundle"] is None: + continue + first = _run(doc).revocation + ev = first.evidence + second = check_bundle( + doc["context"]["bundle"], + trusted_key_identifiers=[jwk_thumbprint(doc["context"]["trusted_key"])], + trusted_bundle_keys=doc["context"]["trusted_bundle_keys"], + now=ev.get("now", doc["context"]["now"]), + max_bundle_age_seconds=ev.get( + "max_bundle_age_seconds", doc["context"]["max_bundle_age_seconds"] + ), + max_future_skew_seconds=doc["context"]["max_future_skew_seconds"], + ) + assert second == first, path.name + + +def test_I7_nothing_under_src_or_this_test_reaches_the_network() -> None: + """Import lines only, with a control that fires in the same call. + + This file is swept too, and the socket-blocking test below has to be blind + to that sweep by construction rather than by exemption: it names `socket` + through `monkeypatch.setattr`'s dotted-string form and never imports it, so + there is no import here to find and nothing for a reader to wonder about. + """ + pattern = re.compile( + r"^\s*(import|from)\s+(socket|urllib|http\.client|httpx|requests|aiohttp)\b" + ) + files = sorted((ROOT / "src" / "agentrust_trace").glob("*.py")) + [pathlib.Path(__file__)] + hits = [ + f"{path.name}:{n}: {line.strip()}" + for path in files + for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1) + if pattern.match(line) + ] + assert hits == [], hits + assert pattern.match("import socket") and pattern.match("from urllib import request") + + +def test_I8_absence_is_an_outcome_and_only_failing_evidence_raises() -> None: + doc, ctx = _fresh() + for name in ( + "15-signature-value-corrupted", + "17-bundle-key-unknown-to-caller", + "19-malformed-valid-until-absent", + ): + outcome = _run(_load(VECTORS / f"{name}.json")).revocation.outcome + assert outcome == "unverified_for_revocation", name + with pytest.raises(ValueError, match="revoked"): + _run(_load(VECTORS / "11-statement-names-trusted-key-by-thumbprint.json")) + + +def test_I9_the_module_names_no_appraisal_status_value() -> None: + source = (ROOT / "src" / "agentrust_trace" / "revocation.py").read_text(encoding="utf-8") + for value in ("affirming", "warning", "contraindicated", '"none"'): + assert value not in source.replace("affirming appraisal", ""), value + assert "affirming appraisal" in source, "the 3.2.3 sentence the module is built around" + + +# ---- the truth table, executed ------------------------------------------------- + + +def _stubs(now: int, max_age: int): + return { + "min": lambda b: now > b["valid_until"] or now - b["issued_at"] > max_age, + "issuer": lambda b: now > b["valid_until"], + "deploy": lambda b: now - b["issued_at"] > max_age, + "max": lambda b: now > b["valid_until"] and now - b["issued_at"] > max_age, + "none": lambda b: False, + # Two shortcuts, not rules: each honours one bound exactly and gives the + # other a minute of grace. They are what the one-second margin vectors catch. + "deploy_grace": lambda b: now > b["valid_until"] or now - b["issued_at"] > max_age + 60, + "issuer_grace": lambda b: now - b["issued_at"] > max_age or now > b["valid_until"] + 60, + } + + +AGE_ROWS = {"01", "02", "03", "04", "05", "06", "07", "08", "09"} + + +def test_the_age_vectors_reject_every_rule_but_tighter_governs() -> None: + """Seven implementations of "too old", nine vectors, one survivor. + + The five rules are functions of the two booleans, issuer bound tripped and + deployment bound tripped, so the four wide rows (01, 04, 06, 08) separate + them completely: this is the table posted on #190, run. The two grace + shortcuts are not functions of those booleans; they move a threshold by a + minute, which is exactly why the four rows cannot see them and the one-second + rows (05, 07) exist. Over all nine, only tighter-governs survives. + """ + age_vectors = [p for p in FILES if p.name[:2] in AGE_ROWS] + docs = [_load(p) for p in age_vectors] + now = docs[0]["context"]["now"] + max_age = docs[0]["context"]["max_bundle_age_seconds"] + survivors = [] + for rule, expired in _stubs(now, max_age).items(): + agrees = all( + expired(d["context"]["bundle"]) == (d["expected"].get("cause") == "bundle_expired") + for d in docs + ) + if agrees: + survivors.append(rule) + assert survivors == ["min"], survivors + + +def test_the_margin_vectors_catch_a_shortcut_the_wide_vectors_miss() -> None: + """The reason 05 and 07 exist, shown. A minute of grace on one bound agrees with + every wide row and fails on that bound's one-second row.""" + now = _load(FILES[0])["context"]["now"] + max_age = _load(FILES[0])["context"]["max_bundle_age_seconds"] + stubs = _stubs(now, max_age) + + def agrees(rule, prefix: str) -> bool: + doc = next(_load(p) for p in FILES if p.name[:2] == prefix) + expected = doc["expected"].get("cause") == "bundle_expired" + return rule(doc["context"]["bundle"]) == expected + + for name, fails_on in (("deploy_grace", "05"), ("issuer_grace", "07")): + rule = stubs[name] + assert all(agrees(rule, p) for p in ("04", "06", "08")), f"{name} should survive wide rows" + assert not agrees(rule, fails_on), f"{name} should fail on {fails_on}" + + +def test_without_vector_D_max_and_none_are_indistinguishable() -> None: + """The reason D is kept, shown rather than asserted.""" + docs = [_load(p) for p in FILES if p.name[:2] in {"01", "04", "06"}] + now = docs[0]["context"]["now"] + max_age = docs[0]["context"]["max_bundle_age_seconds"] + stubs = _stubs(now, max_age) + answers = {rule: [f(d["context"]["bundle"]) for d in docs] for rule, f in stubs.items()} + assert answers["max"] == answers["none"] + + +# ---- the shape imran chose, and its stated cost --------------------------------- + + +def test_a_caller_who_discards_the_result_gets_no_exception_and_no_warning(recwarn) -> None: + """F1, pinned as a fact, and nothing else: the call is made, the return is + dropped, and no exception or warning carries the outcome to the caller. The + outcome itself is asserted in the next test, so a change to the return type + cannot make this one red for a reason that is not the silence.""" + doc = _load(VECTORS / "14-no-bundle-no-check-performed.json") + ctx = doc["context"] + verify_record(doc["records"][0], ctx["trusted_key"], now=ctx["now"]) + assert not [w for w in recwarn if "revocation" in str(w.message).lower()] + + +def test_the_discarded_result_would_have_said_no_check_performed() -> None: + """The other half of F1: what the caller above threw away.""" + doc = _load(VECTORS / "14-no-bundle-no-check-performed.json") + assert _run(doc).revocation.outcome == "no_check_performed" + + +def test_verification_result_is_immutable() -> None: + doc, _ = _fresh() + result = _run(doc) + with pytest.raises(FrozenInstanceError): + result.revocation = NO_CHECK # type: ignore[misc] + with pytest.raises(FrozenInstanceError): + result.revocation.evidence = {} # type: ignore[misc] + + +# ---- the packaged schemas --------------------------------------------------------- + + +@pytest.mark.parametrize("name", ["trace-revocation.json", "trace-revocation-bundle.json"]) +def test_packaged_revocation_schema_is_byte_identical_to_its_source(name: str) -> None: + source = (ROOT / "schema" / name).read_bytes() + packaged = (ROOT / "src" / "agentrust_trace" / "schema" / name).read_bytes() + assert source == packaged, f"{name}: the packaged copy has drifted from schema/" + + +def _refuse_sockets(monkeypatch) -> None: + """Block every connection this process could open, by name, with no import. + + The dotted-string form resolves inside pytest, so this file carries no + network import for I7 to find. The replacement is a class, not a function: + `ssl` does `class SSLSocket(socket)` at import, so a function there breaks + the fetch path with a TypeError about code objects before any connection + is attempted, and the red says nothing about the network. Any class can be + subclassed, so this one need not descend from the real socket class, and + it refuses on construction. `create_connection` is not patched separately + because it builds its socket through this same module global; the control + below goes through it. `test_the_socket_block_is_live` proves the block is + real. + """ + class RefusingSocket: + def __init__(self, *args, **kwargs): + raise AssertionError("bundle validation opened a socket") + + monkeypatch.setattr("socket.socket", RefusingSocket) + + +def test_bundle_validation_resolves_the_statement_ref_with_sockets_blocked(monkeypatch) -> None: + """The $ref is an absolute URL, and on a networked machine a validator without + the packaged registry fetches it and passes anyway. So the witness blocks + sockets for the duration: with the registry, validation reaches step 3b; without + it, this test reds on any machine rather than only on an offline one. + """ + _refuse_sockets(monkeypatch) + # A fresh validator, so a cached one built before the block cannot stand in. + from agentrust_trace import revocation as module + module._bundle_validator.cache_clear() + try: + doc = _load(VECTORS / "20-malformed-statement-on-another-log.json") + try: + result = _run(doc) + except Exception as exc: # noqa: BLE001 - the chain is the diagnostic + # referencing wraps retrieval errors, so the block's message sits at + # the root of the chain; print the whole chain so the red is legible. + chain, err = [], exc + while err is not None: + chain.append(f"{type(err).__name__}: {err}") + err = err.__cause__ or err.__context__ + pytest.fail("bundle validation tried to leave the process:\n " + "\n ".join(chain)) + assert result.revocation.evidence["path"] == "statements/0/log_id" + finally: + module._bundle_validator.cache_clear() + + +def test_the_socket_block_is_live(monkeypatch) -> None: + """The control. Under the same block, a connection attempt to the discard port + must fail with the block's own message, and with nothing else: change the + message and the match fails; remove the block and this test fails. A real + connection attempt is not shown here, because the only thing that loads + `socket` into this process is the block itself, and this file does not + import it; the match on the block's message is the witness.""" + _refuse_sockets(monkeypatch) + with pytest.raises(AssertionError, match="opened a socket"): + sys.modules["socket"].create_connection(("127.0.0.1", 9), timeout=0.2) + + +def test_the_generator_reproduces_the_committed_vectors_byte_for_byte( + tmp_path: pathlib.Path, +) -> None: + """LF bytes on every platform. `test_generators_reproduce_fixtures` also covers + this; it is repeated here so a failure in this set is reported beside the set.""" + import shutil + target = tmp_path / "examples" / "revocation-bundle" + shutil.copytree(ROOT / "examples" / "revocation-bundle", target) + subprocess.run( + [sys.executable, "examples/revocation-bundle/gen_revocation_vectors.py"], + cwd=tmp_path, check=True, capture_output=True, + ) + for path in FILES: + assert (target / path.name).read_bytes() == path.read_bytes(), path.name diff --git a/tests/test_safe_integer_range.py b/tests/test_safe_integer_range.py index 30b3100a..6e7f3092 100644 --- a/tests/test_safe_integer_range.py +++ b/tests/test_safe_integer_range.py @@ -59,6 +59,12 @@ def _signing_input(record: dict[str, Any]) -> dict[str, Any]: "schema/trace-revocation.json", "schema/trace-revocation-bundle.json", "src/agentrust_trace/schema/trace-v0.2.json", + # Packaged copies of the two revocation schemas, read by `revocation.py` so the + # installed package validates bundles without the repository and without the + # network. `tests/test_revocation_bundle.py` holds each byte-identical to its + # source under `schema/`. + "src/agentrust_trace/schema/trace-revocation.json", + "src/agentrust_trace/schema/trace-revocation-bundle.json", ) UNBOUNDED_SCHEMAS = { "src/agentrust_trace/schema/trace-v0.1.json": ( From b32e4a18a71251d5581cb704093570a8b53b6aff Mon Sep 17 00:00:00 2001 From: opento-suggestions Date: Tue, 1 Sep 2026 13:37:48 -0600 Subject: [PATCH 2/2] fix: read an authenticated statement before the bundle age check check_bundle applied the two age bounds before scanning the bundle's statements, so a bundle carrying a statement that names the trusted key stopped being read the moment it aged out: the same bundle, signature and statement rejected the record ten seconds before valid_until and reported unverified_for_revocation ten seconds after, on either bound. The bounds govern what the bundle's silence is worth. An absent statement means "none known as of issued_at", which is informative only while issued_at is recent. A present statement was authenticated with the bundle's signature at step 3c, section 3.2.3 gives it no expiry and no withdrawal, and the schema puts valid_until on the bundle rather than on the statement. Acting on a stale statement risks rejecting a key whose revocation was later withdrawn, which is visible and recoverable; ignoring one risks accepting a compromised key in the one check built to catch that. The statement scan now runs directly after signature verification, ahead of both the future-dated check and the age check. Three vectors carry the same statement as vector 11 inside bundles that are stale by the issuer bound, stale by the deployment bound, and dated in the future. All three reject; their statement-free counterparts (07, 05, 24) still report unverified, so the ordering is what separates them and reversing it turns the three red. The six existing expiry vectors carry no statements and are unchanged. The docstrings and docs/verification.md state the ordering and the reason, since section 3.2.3 mandates the outcome for a stale bundle and says nothing about one carrying a positive hit; the implementation has to choose, and this chooses rather than inherits. Raised in review of #271 by lywinged, who supplied the argument, the reordering, and the run. Co-authored-by: LouieLuNZ <48041247+lywinged@users.noreply.github.com> Co-Authored-By: Claude Fable 5 Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: opento-suggestions --- docs/verification.md | 2 +- ...ale-by-issuer-statement-still-rejects.json | 93 +++++++++++++++++++ ...by-deployment-statement-still-rejects.json | 93 +++++++++++++++++++ ...future-issued-statement-still-rejects.json | 93 +++++++++++++++++++ examples/revocation-bundle/README.md | 14 ++- .../gen_revocation_vectors.py | 22 +++++ src/agentrust_trace/revocation.py | 51 ++++++---- tests/test_revocation_bundle.py | 20 ++++ 8 files changed, 369 insertions(+), 19 deletions(-) create mode 100644 examples/revocation-bundle/26-stale-by-issuer-statement-still-rejects.json create mode 100644 examples/revocation-bundle/27-stale-by-deployment-statement-still-rejects.json create mode 100644 examples/revocation-bundle/28-future-issued-statement-still-rejects.json diff --git a/docs/verification.md b/docs/verification.md index ab54a8ee..b53ed413 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -142,7 +142,7 @@ result.revocation.cause # why a supplied bundle could not ground "verified" result.revocation.evidence # what a second verifier needs to reach the same outcome ``` -The three outcomes are §3.2.3's own words, and none of them is an appraisal: where a verifier records an unresolvable check in the record itself is the question [#190](https://github.com/agentrust-io/trace-spec/issues/190) holds open. A bundle is evidence only while both age bounds hold, the issuer's `valid_until` and the caller's `max_bundle_age_seconds` measured from `issued_at`; the tighter bound governs, and an expired outcome names which one tripped. `now` pins the verification moment so the outcome reproduces from retained facts. A bundle that is malformed, signed by a key not in `trusted_bundle_keys`, signed with an algorithm this build cannot verify, or dated in the future yields `unverified_for_revocation` with the cause named; it does not raise, because inability to check is not evidence of a defect. A statement on the bundle's log naming the trusted key raises, under the fallback above. [`examples/revocation-bundle/`](../examples/revocation-bundle/) carries the conformance vectors. +The three outcomes are §3.2.3's own words, and none of them is an appraisal: where a verifier records an unresolvable check in the record itself is the question [#190](https://github.com/agentrust-io/trace-spec/issues/190) holds open. A bundle is evidence only while both age bounds hold, the issuer's `valid_until` and the caller's `max_bundle_age_seconds` measured from `issued_at`; the tighter bound governs, and an expired outcome names which one tripped. `now` pins the verification moment so the outcome reproduces from retained facts. A bundle that is malformed, signed by a key not in `trusted_bundle_keys`, signed with an algorithm this build cannot verify, dated in the future, or expired under either bound yields `unverified_for_revocation` with the cause named; it does not raise, because inability to check is not evidence of a defect. A statement on the bundle's log naming the trusted key raises, under the fallback above, and it is read before the time checks: the bounds say what the bundle's silence is worth, and an authenticated statement has no expiry of its own. [`examples/revocation-bundle/`](../examples/revocation-bundle/) carries the conformance vectors. What neither path does yet is entry-ID-scoped revocation. Both answer "is this key revoked", which is the §3.2.3 fallback, so a key revoked after a long run of legitimate records currently invalidates all of them rather than the ones logged after `last_valid_entry_id`. Carrying the entry ID through `verify_record()` is implementation work tracked in the issue that produced §3.2.3. The bundle path also verifies the bundle signature only, not each statement's own signature against the §3.2.1 hierarchy; that check needs the hierarchy, and it is stated here rather than implied. diff --git a/examples/revocation-bundle/26-stale-by-issuer-statement-still-rejects.json b/examples/revocation-bundle/26-stale-by-issuer-statement-still-rejects.json new file mode 100644 index 00000000..18d6ebfa --- /dev/null +++ b/examples/revocation-bundle/26-stale-by-issuer-statement-still-rejects.json @@ -0,0 +1,93 @@ +{ + "id": "TRACE-RBUN-026", + "name": "stale-by-issuer-statement-still-rejects", + "description": "valid_until is one second past and the bundle names the trusted key. The statement was authenticated with the bundle's signature and has no expiry of its own, so the record is rejected; a verifier that aged the bundle out before reading it would report unverified instead.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784996400, + "valid_until": 1784999999, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "yXtpv-ONw0EPK3ZnfQZl_we6sfTzdf4i5H5wuOn1vKU", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/trace", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "vUGwaJX0PzXSbI28XY_PY6RwDGGeNZIS3n0cO7uM7-arZgS35G1qewFSDcSo1I3ekM2iXoicHCn_NpTito6qCg" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "VSlkeHF1iP1R9p5D57YXwemnnWdCFtwY0Ff1Vx9pYbTSPEmptbIJt4RkjoPcCqRFJK1SGbv_GaLpCuJN8onqAQ" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": true, + "codes": [ + "key_revoked", + "statement_outlives_bundle" + ] + } +} diff --git a/examples/revocation-bundle/27-stale-by-deployment-statement-still-rejects.json b/examples/revocation-bundle/27-stale-by-deployment-statement-still-rejects.json new file mode 100644 index 00000000..8ee05d29 --- /dev/null +++ b/examples/revocation-bundle/27-stale-by-deployment-statement-still-rejects.json @@ -0,0 +1,93 @@ +{ + "id": "TRACE-RBUN-027", + "name": "stale-by-deployment-statement-still-rejects", + "description": "Age is one second past max_bundle_age_seconds and the bundle names the trusted key. Same rule on the deployment bound: rejected.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1784913599, + "valid_until": 1787592000, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "yXtpv-ONw0EPK3ZnfQZl_we6sfTzdf4i5H5wuOn1vKU", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/trace", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "vUGwaJX0PzXSbI28XY_PY6RwDGGeNZIS3n0cO7uM7-arZgS35G1qewFSDcSo1I3ekM2iXoicHCn_NpTito6qCg" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "TNLqjZH2gJqznP28VKTg3VwYVxIhRGSg-EjIwDZbO4quktTGbCsAiD3s9JiccYdaoN02jzbeg7P9kOt7lfGcCw" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": true, + "codes": [ + "key_revoked", + "statement_outlives_bundle" + ] + } +} diff --git a/examples/revocation-bundle/28-future-issued-statement-still-rejects.json b/examples/revocation-bundle/28-future-issued-statement-still-rejects.json new file mode 100644 index 00000000..c2b7e745 --- /dev/null +++ b/examples/revocation-bundle/28-future-issued-statement-still-rejects.json @@ -0,0 +1,93 @@ +{ + "id": "TRACE-RBUN-028", + "name": "future-issued-statement-still-rejects", + "description": "issued_at is a day ahead and the bundle names the trusted key. A future-dated bundle vouches for nothing it is silent about, but what it says was signed by a trusted bundle key: rejected.", + "spec": "spec/trace-v0.2.md#323-revocation-of-record-signing-keys", + "context": { + "now": 1785000000, + "max_bundle_age_seconds": 86400, + "max_future_skew_seconds": 300, + "trusted_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw", + "kid": "issuer-key-2026" + }, + "trusted_bundle_keys": [ + { + "kty": "OKP", + "crv": "Ed25519", + "x": "JfQ5mktI1ldOWzNpLVgad2rHpi8TfeRnX1gVZnP-2Sw" + } + ], + "bundle": { + "type": "TraceRevocationBundle/1.0", + "log_id": "https://log.example/trace", + "issued_at": 1785086400, + "valid_until": 1787592000, + "statements": [ + { + "type": "TraceRevocation/1.0", + "compromised_key_id": "yXtpv-ONw0EPK3ZnfQZl_we6sfTzdf4i5H5wuOn1vKU", + "last_valid_entry_id": "41", + "revoked_after_entry": "42", + "log_id": "https://log.example/trace", + "reason": "key compromise", + "revocation_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "vUGwaJX0PzXSbI28XY_PY6RwDGGeNZIS3n0cO7uM7-arZgS35G1qewFSDcSo1I3ekM2iXoicHCn_NpTito6qCg" + } + } + ], + "bundle_key_id": "zMc-RO2DTev16r8Pw0K_1gx_but1-5lSwbTAmpHQewQ", + "sig": { + "alg": "ed25519", + "value": "NsbGTbJ8fWkQ4I2f05W_JWlf0cc6uBhIRF2QzAvCL-3vB8ElYtys8SvW3xZHdDNEpoWUqw3_ixDabgK2Ysi3Dg" + } + } + }, + "records": [ + { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1784996400, + "subject": "spiffe://acme.example/agent/issuer", + "model": { + "provider": "anthropic", + "model_id": "claude-sonnet-4-6" + }, + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + }, + "policy": { + "bundle_hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "enforcement_mode": "enforce" + }, + "data_class": "internal", + "build_provenance": { + "slsa_level": 0, + "digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "appraisal": { + "status": "affirming", + "verifier": "https://verifier.example/v1" + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "kSLjMUUzj7yIOGvh9Ff8WSHnNKiFBR0kchby761Uuaw" + } + }, + "signature": "cNXX2aeZWv47FIFqe1DSAtHDlG7FUcw3U75o5g4r-9r3cAqoXFLSC2ZlIY9fjiPOI9KiaP_XttbMmZruOSvyCQ" + } + ], + "expected": { + "rejected": true, + "codes": [ + "key_revoked", + "statement_outlives_bundle" + ] + } +} diff --git a/examples/revocation-bundle/README.md b/examples/revocation-bundle/README.md index 5e075183..c40f159d 100644 --- a/examples/revocation-bundle/README.md +++ b/examples/revocation-bundle/README.md @@ -44,7 +44,7 @@ retains, since error message text is not something two implementations agree on. | `verified` | "verified against revocation bundle valid at T" | 01, 02, 03, 10, 13 | | `unverified_for_revocation` | "MUST report the record as unverified for revocation rather than as verified" | 04 to 09, 15 to 24 | | `no_check_performed` | "MUST report that it performed no revocation check" | 14, 25 | -| `rejected` | the key is named by a statement on the bundle's log; no entry ID is available, so the 3.2.3 fallback applies | 11, 12 | +| `rejected` | the key is named by a statement on the bundle's log; no entry ID is available, so the 3.2.3 fallback applies | 11, 12, 26, 27, 28 | None of the first three is an appraisal. Which `appraisal.status` value the second and third carry is the question #190 holds open, and nothing here answers it. @@ -67,6 +67,18 @@ side. `tests/test_revocation_bundle.py` implements the five rules as stubs and shows the four rows reject all but `min`. +## A statement outlives its carrier + +The two age bounds say what a bundle's *silence* is worth: an absent statement +means "none known as of `issued_at`", which is informative only while +`issued_at` is recent. A *present* statement was authenticated with the bundle's +signature, has no expiry of its own in 3.2.3 or in the schema, and is read +before either time check. Vectors 26, 27 and 28 carry the same statement as 11 +inside bundles that are stale by each bound and dated in the future; all three +reject, where their statement-free counterparts 07, 05 and 24 report unverified. +This ordering was raised in review of the pull request that added the consumer +and chosen there rather than inherited from the shape of the procedure. + ## What is not here - **A bundle that could not be obtained.** A bundle is bytes in hand. What a diff --git a/examples/revocation-bundle/gen_revocation_vectors.py b/examples/revocation-bundle/gen_revocation_vectors.py index 46104691..286a140f 100644 --- a/examples/revocation-bundle/gen_revocation_vectors.py +++ b/examples/revocation-bundle/gen_revocation_vectors.py @@ -395,6 +395,28 @@ def main() -> None: codes=["bundle_issued_in_future"], evidence=fresh_evidence(b24, max_future_skew_seconds=SKEW))) + # A statement outlives its carrier. The bounds govern the bundle's silence; + # an authenticated statement naming the key is read before either time check. + named = [statement(compromised=TRUSTED_ID)] + b26 = bundle(issued_at=NOW - HOUR, valid_until=NOW - 1, statements=named) + out.append(vector(26, "stale-by-issuer-statement-still-rejects", + "valid_until is one second past and the bundle names the trusted key. The " + "statement was authenticated with the bundle's signature and has no expiry " + "of its own, so the record is rejected; a verifier that aged the bundle out " + "before reading it would report unverified instead.", + b=b26, outcome=None, codes=["key_revoked", "statement_outlives_bundle"], rejected=True)) + b27 = bundle(issued_at=NOW - MAX_AGE - 1, valid_until=NOW + 30 * DAY, statements=named) + out.append(vector(27, "stale-by-deployment-statement-still-rejects", + "Age is one second past max_bundle_age_seconds and the bundle names the " + "trusted key. Same rule on the deployment bound: rejected.", + b=b27, outcome=None, codes=["key_revoked", "statement_outlives_bundle"], rejected=True)) + b28 = bundle(issued_at=NOW + DAY, valid_until=NOW + 30 * DAY, statements=named) + out.append(vector(28, "future-issued-statement-still-rejects", + "issued_at is a day ahead and the bundle names the trusted key. A " + "future-dated bundle vouches for nothing it is silent about, but what it " + "says was signed by a trusted bundle key: rejected.", + b=b28, outcome=None, codes=["key_revoked", "statement_outlives_bundle"], rejected=True)) + OUT.mkdir(parents=True, exist_ok=True) for name, doc in sorted(out): text = json.dumps(doc, indent=2, ensure_ascii=False) + "\n" diff --git a/src/agentrust_trace/revocation.py b/src/agentrust_trace/revocation.py index 052e8d42..0ce6c250 100644 --- a/src/agentrust_trace/revocation.py +++ b/src/agentrust_trace/revocation.py @@ -30,7 +30,10 @@ both bounds hold, and an expired outcome names which bound tripped, so a verifier re-running from the retained facts alone, with no clock and no network, reaches the same outcome. Whether a second implementation agrees is what the conformance -vectors are for. +vectors are for. The bounds govern the bundle's silence, not its speech: a +statement naming the trusted key was authenticated with the bundle's signature, +has no expiry of its own, and rejects the record whether the bundle that carried +it is fresh or not. What this module does not do, stated so it is not mistaken for something it does: @@ -182,9 +185,14 @@ def check_bundle( """Decide what a bundle lets a verifier report about the trusted record key. The order of checks is fixed and is the order a second verifier must follow to - reproduce the outcome: shape, then who signed the set, then whether the set is - still evidence, then what the set says. A bundle that fails an earlier step is - not read further, so the cause names the first thing wrong, not everything. + reproduce the outcome: shape, then who signed the set, then what the set says + about this key, then whether the set is still evidence for what it does not + say. A bundle that fails an earlier step is not read further, so the cause + names the first thing wrong, not everything. Statements are read before the + time checks on purpose: the freshness bounds exist so that an absent statement + means something, and a present, authenticated one needs no clock to mean what + it says. That ordering was raised in review of the pull request that added + this module and chosen rather than inherited. Raises ``ValueError`` when a statement on the bundle's log names the trusted key. That is evidence failing rather than evidence absent, and it fails closed @@ -255,13 +263,33 @@ def check_bundle( "bundle_signature_invalid", {**base, "bundle_key_id": bundle["bundle_key_id"]} ) - # 3d. A bundle from the future has an issued_at nothing can have observed. + # 3d. What the set says about this key, read before either time check. A + # statement's signature was verified with the bundle's at 3c, so it is an + # authenticated assertion that the issuer declared this key compromised; + # section 3.2.3 gives a statement no expiry and no withdrawal, and the + # schema puts valid_until on the bundle, not on the statement. The time + # bounds below govern what the bundle's silence is worth; they do not make + # its speech untrue. Fallback rule: no entry ID is available here, so a + # named key rejects every record it signed. + for statement in statements: + if statement["compromised_key_id"] in trusted_ids: + raise ValueError( + f"signing key is revoked: statement on log {log_id!r} names it as " + f"{statement['compromised_key_id']!r} (bundle {base['bundle_digest']}). " + "No inclusion entry ID is available to place this record before the " + "revocation, so section 3.2.3's fallback applies and the record is rejected." + ) + + # 3e. A bundle from the future has an issued_at nothing can have observed, so + # its silence about other keys is not evidence of anything. if issued_at > now + max_future_skew_seconds: return _unverified("bundle_issued_in_future", { **base, "max_future_skew_seconds": max_future_skew_seconds, }) - # 3e. Age. Tighter governs: the bundle is evidence only while both bounds hold. + # 3f. Age. Tighter governs: the bundle vouches for the absence of a statement + # only while both bounds hold, since an absent statement means "none known as + # of issued_at" and that is only informative while issued_at is recent. # Inclusive on the valid side, so now == valid_until and age == max are fresh. issuer_tripped = now > valid_until deployment_tripped = (now - issued_at) > max_bundle_age_seconds @@ -273,17 +301,6 @@ def check_bundle( ) return _unverified("bundle_expired", {**base, "bound_tripped": tripped}) - # 3f. What the set says about this key. Fallback rule: no entry ID is available - # here, so a named key rejects every record it signed. - for statement in statements: - if statement["compromised_key_id"] in trusted_ids: - raise ValueError( - f"signing key is revoked: statement on log {log_id!r} names it as " - f"{statement['compromised_key_id']!r} (bundle {base['bundle_digest']}). " - "No inclusion entry ID is available to place this record before the " - "revocation, so section 3.2.3's fallback applies and the record is rejected." - ) - # 3g. Verified against this bundle, valid at T, with T retained. return RevocationCheck( outcome="verified", evidence={**base, "statements_count": len(statements)} diff --git a/tests/test_revocation_bundle.py b/tests/test_revocation_bundle.py index 84f3d7d1..854c0b6f 100644 --- a/tests/test_revocation_bundle.py +++ b/tests/test_revocation_bundle.py @@ -212,6 +212,26 @@ def test_I9_the_module_names_no_appraisal_status_value() -> None: assert "affirming appraisal" in source, "the 3.2.3 sentence the module is built around" +def test_I11_an_authenticated_statement_is_read_before_either_time_check() -> None: + """Vectors 26, 27 and 28 carry the same statement as 11 inside bundles that are + stale by the issuer, stale by the deployment, and dated in the future. All + three reject. Their statement-free counterparts (07, 05, 24) report unverified, + so the ordering is what separates them, and reversing it turns these red.""" + for name in ( + "26-stale-by-issuer-statement-still-rejects", + "27-stale-by-deployment-statement-still-rejects", + "28-future-issued-statement-still-rejects", + ): + with pytest.raises(ValueError, match="revoked"): + _run(_load(VECTORS / f"{name}.json")) + for name, cause in ( + ("07-issuer-bound-tripped-by-one-second", "bundle_expired"), + ("05-deployment-bound-tripped-by-one-second", "bundle_expired"), + ("24-issued-in-future-by-a-day", "bundle_issued_in_future"), + ): + assert _run(_load(VECTORS / f"{name}.json")).revocation.cause == cause, name + + # ---- the truth table, executed -------------------------------------------------