From 01b86e849e8627877351faba25ec65fff6363515 Mon Sep 17 00:00:00 2001 From: Imran Siddique Date: Tue, 18 Aug 2026 11:17:34 -0700 Subject: [PATCH] spec: anchor key revocation to log entry ordering, not iat (#67) The editorial decision recorded on #67, landed. Section 3.2.1 required verifiers to "consult current revocation status at verification time", which contradicts the offline-verification property section 3.3 is built on, and the document said both things. WHY NOT A TIMESTAMP 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. Any revocation rule anchored to a value the compromised key controls is defeated by the compromise it exists to contain, and no clock-skew tolerance changes that. Section 3.2.3 anchors to the SCITT inclusion entry ID instead. Entry IDs are monotonic and bound to the Merkle structure, so an attacker cannot choose one for a record submitted after the log moved past it, and cannot reorder committed entries. Ordering survives the compromise; a timestamp does not. WHAT LANDS TraceRevocation/1.0, with the rule that a record from a revoked key is valid iff its inclusion entry ID is at or below last_valid_entry_id on the log the statement names. Entry IDs from a different log are not comparable and cannot satisfy it. Signing-key independence is a MUST: a revocation statement for K is signed by a key above K in the 3.2.1 hierarchy or by a recovery key with an independent compromise domain. A statement K could sign for itself hands whoever stole K the ability to set last_valid_entry_id wherever they like, which turns the mechanism into a tool for the attacker. Distribution is a cached signed bundle with valid_until, anchored in the same log as the records it governs, which is what keeps 3.3's no-callback property. The rejected alternative was a well-known status endpoint: it would require a callback at verification time and withdraw the property the specification exists to provide. Two honest states rather than a silent skip. An expired bundle reports the record as unverified for revocation, not verified. A verifier with no bundle reports that it performed no revocation check. Neither may be presented as an affirming appraisal, because "I could not check" and "I checked and it was fine" are the two answers a relying party most needs told apart. FALLBACK A record with no usable inclusion entry ID has no external anchor, so nothing places it before or after the compromise. Revocation is binary for it: reject every record from the key. That is not a lesser mode chosen for convenience, it is what the absence of an anchor costs, and it is already the behaviour for deployments carrying no receipts. SCHEMAS schema/trace-revocation.json and schema/trace-revocation-bundle.json. Both closed to unknown fields, because revocation is the one place where an invented field could read as narrowing the revocation. revoked_at exists and is explicitly informational: requiring it would invite an implementation to treat it as the boundary, which is the defect this section is about. A statement keeps its own signature and signer inside the bundle, so a bundle assembler cannot add a revocation it was not authorised to issue. DOCS docs/verification.md quoted the 3.2.1 sentence being replaced, so it would have become false. It now explains the entry-ID boundary and the two offline states, with every requirement keyword attributed to 3.2.3 per the guard in tests/test_requirement_keywords_are_attributable.py, and states plainly that verify_record()'s current store implements the binary fallback rather than the entry-ID-scoped rule. NOT IN SCOPE HERE Carrying the entry ID through verify_record(), and aligning verifier revocation-status output with the PIC binary-revocation vectors. Both are implementation work against the section this lands. VERIFICATION 11 new schema-pair tests. Full suite 489 passed, 1 skipped, including the docs keyword-attribution guard. Refs #67, #46 Signed-off-by: Imran Siddique Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 ++ docs/verification.md | 10 ++- schema/trace-revocation-bundle.json | 67 +++++++++++++++++ schema/trace-revocation.json | 80 ++++++++++++++++++++ spec/trace-v0.2.md | 36 ++++++++- tests/test_revocation_schema.py | 112 ++++++++++++++++++++++++++++ 6 files changed, 309 insertions(+), 2 deletions(-) create mode 100644 schema/trace-revocation-bundle.json create mode 100644 schema/trace-revocation.json create mode 100644 tests/test_revocation_schema.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 607fcc1..d09c93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/verification.md b/docs/verification.md index 2ebfbee..a5a2b2e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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: @@ -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`: diff --git a/schema/trace-revocation-bundle.json b/schema/trace-revocation-bundle.json new file mode 100644 index 0000000..d971579 --- /dev/null +++ b/schema/trace-revocation-bundle.json @@ -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." + } + } + } + } +} diff --git a/schema/trace-revocation.json b/schema/trace-revocation.json new file mode 100644 index 0000000..d7f1b56 --- /dev/null +++ b/schema/trace-revocation.json @@ -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." + } + } + } + } +} diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index 2e8bcf0..8f37afa 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -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 @@ -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": "", + "last_valid_entry_id": "", + "revoked_after_entry": "", + "log_id": "", + "reason": "key compromise | superseded | operator request | ...", + "revocation_key_id": "", + "sig": { "alg": "ed25519", "value": "" } +} +``` + +**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: @@ -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. diff --git a/tests/test_revocation_schema.py b/tests/test_revocation_schema.py new file mode 100644 index 0000000..670154a --- /dev/null +++ b/tests/test_revocation_schema.py @@ -0,0 +1,112 @@ +"""The revocation schema pair must hold together (spec §3.2.3, issue #67). + +Two files that reference each other by `$id` are one artifact with a seam down +the middle, and nothing else in the repository reads them yet, so a broken +`$ref` or a drifted field name would ship unnoticed until the first +implementation tried to validate against them. + +The assertions worth making mechanically are the ones that encode the design +decisions rather than the shape: that the boundary field is an entry ID, that no +timestamp is load-bearing, and that a statement carries its own signer +separately from the bundle's. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +SCHEMA_DIR = Path(__file__).resolve().parents[1] / "schema" +STATEMENT = SCHEMA_DIR / "trace-revocation.json" +BUNDLE = SCHEMA_DIR / "trace-revocation-bundle.json" + + +def _load(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +@pytest.fixture(scope="module") +def statement() -> dict: + return _load(STATEMENT) + + +@pytest.fixture(scope="module") +def bundle() -> dict: + return _load(BUNDLE) + + +def test_both_schemas_are_draft_2020_12(statement, bundle): + for schema in (statement, bundle): + assert schema["$schema"] == "https://json-schema.org/draft/2020-12/schema" + + +def test_bundle_items_reference_the_statement_id(statement, bundle): + """The seam. A renamed `$id` on one side silently unhooks the other.""" + assert bundle["properties"]["statements"]["items"]["$ref"] == statement["$id"] + + +def test_claim_type_is_pinned(statement, bundle): + assert statement["properties"]["type"]["const"] == "TraceRevocation/1.0" + assert bundle["properties"]["type"]["const"] == "TraceRevocationBundle/1.0" + + +def test_the_boundary_is_an_entry_id_and_is_required(statement): + """§3.2.3: the boundary is a log entry ID because a compromised key signs + the timestamp it would otherwise be judged against.""" + assert "last_valid_entry_id" in statement["required"] + assert statement["properties"]["last_valid_entry_id"]["type"] == "string" + + +def test_no_timestamp_is_required_on_a_statement(statement): + """`revoked_at` exists and is informational. Requiring it would invite an + implementation to treat it as the boundary, which is the defect §3.2.3 is + about.""" + assert "revoked_at" not in statement["required"] + assert "informational" in statement["properties"]["revoked_at"]["description"].lower() + + +def test_log_id_is_required_on_both(statement, bundle): + """Entry IDs from different logs are not comparable, so neither side may be + silent about which log it means.""" + assert "log_id" in statement["required"] + assert "log_id" in bundle["required"] + + +def test_statement_and_bundle_carry_separate_signers(statement, bundle): + """A bundle assembler authenticates the set and its horizon. It must not be + able to add a revocation it was not authorised to issue, which is why each + statement keeps its own signature and signer.""" + assert "revocation_key_id" in statement["required"] + assert "sig" in statement["required"] + assert "bundle_key_id" in bundle["required"] + assert "sig" in bundle["required"] + + +def test_bundle_horizon_is_required(bundle): + """An expired bundle is not a pass, so there has to be something to expire.""" + assert "valid_until" in bundle["required"] + assert "issued_at" in bundle["required"] + + +def test_signature_algorithms_match_the_spec_set(statement, bundle): + """§3.2.1 allows ES256, ES384 and EdDSA. A schema admitting more would let a + conforming document carry an algorithm the spec does not.""" + expected = {"ed25519", "ES256", "ES384"} + for schema in (statement, bundle): + assert set(schema["properties"]["sig"]["properties"]["alg"]["enum"]) == expected + + +def test_neither_schema_admits_unknown_fields(statement, bundle): + """Revocation is the one place where a field an implementation invents could + read as narrowing the revocation, so both are closed.""" + for schema in (statement, bundle): + assert schema["additionalProperties"] is False + + +def test_an_empty_statement_list_is_representable(bundle): + """A bundle asserting no known revocations is different from having no + bundle, and the schema has to be able to say the first one.""" + items = bundle["properties"]["statements"] + assert items.get("minItems") is None