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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

### Added

- **Record-signing key revocation is anchored to transparency-log entry ordering, not to `iat`.** New spec section 3.2.3 defines the `TraceRevocation/1.0` claim type: a record from a revoked key is valid if and only if its SCITT inclusion entry ID is at or below `last_valid_entry_id` on the log the statement names. The intuitive time-based rule cannot work, because a compromised record-signing key also signs the `iat` it would be judged against, so an attacker backdates the record and the rule passes. Entry IDs are monotonic and bound to the Merkle structure, so ordering survives the compromise a timestamp does not.

Distribution keeps section 3.3's no-callback property: statements are anchored in the same log as the records they govern, and verifiers cache a signed bundle carrying `valid_until`. An expired bundle is not a pass, and a verifier with none reports that it performed no revocation check rather than reporting an affirming appraisal. A revocation statement MUST be signed by a key above the revoked one in the section 3.2.1 hierarchy, or by a recovery key with an independent compromise domain, because a statement the compromised key could sign for itself is a tool for whoever stole it. Records with no usable inclusion entry ID fall back to binary revocation, which is the existing behaviour. Schemas: `schema/trace-revocation.json`, `schema/trace-revocation-bundle.json`. Resolves [#67](https://github.com/agentrust-io/trace-spec/issues/67).

Section 3.2.1 previously required verifiers to "consult current revocation status at verification time", which contradicted the offline-verification property in the same document. It now points at 3.2.3.

- **`build_provenance` now declares verification depth.** A new optional `provenance_depth` (`surface`, `builder`, `transitive`) says how far down the supply chain the issuer claims to have walked, and a new optional `appraisal.provenance_depth_verified` records how far the verifier actually walked. Spec section 3.3 step 7 previously left three stopping points equally conformant, so two verifiers could reach opposite conclusions on the same record with no way to say why. Both fields are optional and a record omitting `provenance_depth` is read as `surface`, so existing records keep their meaning. Evidence that does not resolve and evidence that resolves and contradicts the record are separate outcomes: the first downgrades the recorded depth and names what was missing, the second fails the appraisal and cannot be downgraded away. Resolves [#50](https://github.com/agentrust-io/trace-spec/issues/50).

### Security
Expand Down
10 changes: 9 additions & 1 deletion docs/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,13 @@ print(f"✓ Appraisal: {status}")

The five steps above are self-contained: given the record and a trusted key, they run with no network. That is the property TRACE is built for, and it has exactly one gap. A signature is valid forever, so a record signed by a key that was later compromised and revoked still passes every offline step. Nothing inside the record can withdraw the key that signed it.

[§3.2.1 of the spec](../spec/trace-v0.2.md) therefore requires the online half: *"Verifiers MUST consult current revocation status at verification time."*
[§3.2.3 of the spec](../spec/trace-v0.2.md) closes that gap without giving up offline verification. Two things are worth knowing before reading the code below.

**The boundary is a log entry ID, not a time.** The intuitive rule is to reject a record from a revoked key when its `iat` falls after the compromise. A compromised record-signing key also signs `iat`, so whoever holds it backdates the record and the rule passes. §3.2.3 anchors to the SCITT inclusion entry ID instead, because entry IDs are monotonic and bound to the Merkle structure, so ordering survives the compromise of the signing key in a way a timestamp does not. In §3.2.3's words, a record from a revoked key is valid *"if and only if its SCITT inclusion entry ID is less than or equal to `last_valid_entry_id`"*, on the log named in the statement.

**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.

`verify_record()` takes a `revocation` store to do this. Pass a container of revoked identifiers, or a callable that performs a live lookup:

Expand Down Expand Up @@ -123,6 +129,8 @@ Both failure modes raise `ValueError`, including a store that cannot answer:

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".

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).

## Verifying hardware-rooted records

For Level 2 records (TEE-issued), additionally verify that the `cnf.jwk` key is bound to the hardware measurement in `runtime`:
Expand Down
67 changes: 67 additions & 0 deletions schema/trace-revocation-bundle.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"$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,
"description": "When the bundle was assembled, Unix epoch seconds."
},
"valid_until": {
"type": "integer",
"minimum": 1700000000,
"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."
}
}
}
}
}
80 changes: 80 additions & 0 deletions schema/trace-revocation.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
{
"$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,
"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."
}
}
}
}
}
36 changes: 35 additions & 1 deletion spec/trace-v0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ A Trust Record normally describes an execution and is produced by the runtime th
- **JWT contexts (RFC 7515):** `ES256`, `ES384`, or `EdDSA` (Ed25519). Composite chains across silicon-root and workload segments are expressed as nested JWTs with `x5c` chains or `kid` resolving into vendor RIM directories.
- **CBOR-COSE contexts (RFC 9052/9053):** `COSE_Sign1` for single-signer records; `COSE_Sign` for multi-signer records.
- **Key hierarchy:** silicon root key (vendor-managed, hardware-bound) → platform attestation key (e.g., Intel TDX Quote signing key, AMD VCEK/VLEK, NVIDIA NRAS) → workload attestation key (TEE-bound, ephemeral) → record-signing key (per workload, optionally per session).
- **Revocation:** silicon-root revocation is consumed from existing vendor channels. Workload-level keys SHOULD rotate at TEE-image boundaries. Verifiers MUST consult current revocation status at verification time.
- **Revocation:** silicon-root revocation is consumed from existing vendor channels. Workload-level keys SHOULD rotate at TEE-image boundaries. Record-signing key revocation is defined in section 3.2.3, which anchors to transparency-log entry ordering rather than to a status callback, so it does not withdraw the offline-verification property of section 3.3.
- **Hash agility:** SHA-256 minimum; SHA-384 required for FIPS-aligned profiles. Algorithm signaled in the EAT envelope per RFC 9711 §6.

#### 3.2.2 Mandatory signature and freshness binding
Expand Down Expand Up @@ -249,6 +249,39 @@ Each profile MUST declare which binding form it uses. A record with no verifiabl

**Conformance alignment.** The TRACE conformance suite (trace-tests) already enforces both rules: records without a verifiable signature fail at conformance level 1 and above, and the default 24-hour max-age is enforced.

#### 3.2.3 Revocation of record-signing keys

A signature stays valid forever. A record signed by a key that was later compromised passes every check in section 3.3, and nothing inside the record can withdraw the key that signed it. What a verifier needs is not "is this key trusted now" but "was this key trusted when this record was made", and the record cannot answer that about itself.

**Why `iat` cannot carry the boundary.** The obvious rule is to reject a record from a revoked key when its `iat` is later than the compromise time. A compromised record-signing key also signs the `iat` field, so an attacker holding the key backdates it and the rule passes. Any revocation rule anchored to a timestamp the compromised key controls is defeated by the compromise it is meant to contain. This is not a clock-skew problem and no tolerance setting fixes it.

**Anchor: transparency-log entry ordering.** Entry IDs in the log named by the record's SCITT receipt are monotonic and cryptographically bound to the Merkle structure. The attacker cannot choose an entry ID for a record submitted after the log has moved past it, and cannot reorder entries already committed. Ordering therefore survives the compromise of the record-signing key, which a timestamp does not.

`TraceRevocation/1.0` claim type:

```json
{
"type": "TraceRevocation/1.0",
"compromised_key_id": "<RFC 7638 JWK thumbprint or kid of the revoked key>",
"last_valid_entry_id": "<SCITT log entry ID>",
"revoked_after_entry": "<the next entry ID>",
"log_id": "<identifier of the transparency log the entry IDs refer to>",
"reason": "key compromise | superseded | operator request | ...",
"revocation_key_id": "<thumbprint of the key signing this statement>",
"sig": { "alg": "ed25519", "value": "<base64url, no padding>" }
}
```

**Verifier rule.** A record signed by a revoked key is valid if and only if its SCITT inclusion entry ID is less than or equal to `last_valid_entry_id` in the applicable revocation statement, and that entry ID is on the log named by `log_id`. A record whose entry ID is greater MUST be rejected. Entry IDs from a different log are not comparable and MUST NOT be used to satisfy the rule.

**Fallback for records with no usable receipt.** A record without a SCITT inclusion entry ID on the named log has no external anchor, so there is no reliable way to place it before or after the compromise. Revocation for such records is binary: a verifier MUST reject every record signed by the revoked key. This is a fallback rather than a lesser mode; it is what the absence of an anchor costs, and it is the existing behaviour for deployments that carry no receipts.

**Signing-key independence.** A revocation statement for key K MUST be signed by a key at a higher level in the section 3.2.1 hierarchy than K, or by a designated organisational recovery key whose compromise domain is independent of K. A statement K could sign for itself lets whoever holds a compromised key issue a revocation naming a `last_valid_entry_id` of their choosing, which converts the mechanism into a tool for the attacker.

**Distribution, offline-verifiable.** Revocation statements are anchored in the same transparency log as the records they govern, which preserves the no-callback property of section 3.3: a verifier that can resolve receipts can resolve revocations. Verifiers cache a signed revocation *bundle* carrying a `valid_until` field, under the same maximum-age model as section 3.2.2. A verifier operating offline states what it checked against: "verified against revocation bundle valid at T". This deliberately replaces a well-known status endpoint, which would require a callback at verification time and withdraw the property section 3.3 is built on.

An expired bundle is not a pass. A verifier whose newest bundle is older than the profile's maximum age MUST report the record as unverified for revocation rather than as verified, and a verifier with no bundle at all MUST report that it performed no revocation check. Neither may be reported as an affirming appraisal.

### 3.3 Verification

Any party — browser, CLI, in-cluster verifier, third-party auditor — verifies:
Expand All @@ -260,6 +293,7 @@ Any party — browser, CLI, in-cluster verifier, third-party auditor — verifie
5. Policy hash matches the policy bundle the verifier expects.
6. SCITT receipt resolves on the named transparency log.
7. SLSA provenance resolves to a trusted builder.
8. The record-signing key is not revoked as of the entry the record was logged at, per section 3.2.3. A verifier holding no revocation bundle, or only an expired one, reports that rather than treating it as a pass.

No callback to the issuer. No vendor in the trust path beyond silicon root and transparency log operators.

Expand Down
Loading