Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 20 additions & 5 deletions docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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, 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.

## Verifying hardware-rooted records

Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
87 changes: 87 additions & 0 deletions examples/revocation-bundle/02-fresh-at-issuer-horizon.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
87 changes: 87 additions & 0 deletions examples/revocation-bundle/03-fresh-at-deployment-maximum.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading