Skip to content

Latest commit

 

History

History
352 lines (272 loc) · 18.1 KB

revocation.md

File metadata and controls

352 lines (272 loc) · 18.1 KB

Revocation

Prose specification. VAID. Implements ADR-0001. Revision 2, incorporating Phase 0 findings of 27 July 2026.

Terminology follows the repo: a VAID is the document (Vaid); lineage is the child to parent ancestry (parent_vaid, lineage_hash); mint, attenuate and verify are used as in the crates.


Companion document. encoding.md takes the opposite posture: it is normative and specifies what is inside the conformance surface — the encoding rules that decide the canonical bytes. Read it for what conformance requires; read this for where it stops.

R.1 Revocation and conformance

Revocation is outside the VAID conformance surface.

Conformance is defined over the minting, attenuation and verification of VAID documents, and is fixed by the frozen conformance vectors. Conformance does not extend to:

  • revocation list format
  • revocation distribution or transport
  • revocation check semantics
  • revocation storage or durability

An implementation MAY provide no revocation mechanism and remain conformant. Two conformant implementations MAY differ arbitrarily in revocation behaviour.

This exclusion is deliberate. It is stated here rather than left implicit because a specification that omits revocation without saying so is indistinguishable from one that overlooked it.

R.2 Why revocation is excluded

VAID's central claim is that a conforming implementation in any language produces and verifies documents that agree byte for byte with every other conforming implementation. That claim is worth making only if the conformance surface is small enough to be verified exhaustively by frozen vectors and stable enough to build against.

Revocation does not currently meet either condition. Its semantics are coupled to deployment topology in ways the document format is not. A single-process mint, a multi-instance service, an offline verifier, and a cross-organisation verifier that does not trust the issuing mint's availability have materially different requirements. Specifying one as normative would either exclude the others from conformance or produce a specification loose enough to be meaningless.

Freezing a revocation format now would freeze a design that no implementer outside the authoring organisation has built against.

Revocation is expected to move inside the conformance surface in a future version, once there is implementation evidence for the normative shape. That change will be made by amendment, with vectors, and announced as a conformance-surface change.

R.3 Implementations SHOULD provide revocation

Excluding revocation from the conformance surface does not make it optional in production.

VAIDs are bearer documents. A deployment that issues them with no means of invalidating an outstanding VAID before expiry has no response to key compromise, leakage, or a misbehaving agent other than waiting out the TTL.

Implementations intended for production SHOULD provide revocation. Section R.4 defines a common shape so that independent implementations converge voluntarily ahead of any normative requirement.

R.4 The RevocationCheck seam (non-normative)

Non-normative: an implementation departing from this remains conformant. It is specified in detail because no conformance vector covers revocation, and there is therefore nothing else preventing independent implementations from diverging.

R.4.1 Division of responsibility

Two distinct jobs, and conflating them is the principal design error available here.

The verifier assembles the lineage. It resolves the ordered ancestry of the VAID under verification, root first, leaf last. The check does not perform lookups and is not given the means to.

The check answers about a lineage it is handed. It receives an ordered list of VAID identifiers and reports revocation status for that list.

This split keeps the check implementable against any backing store without that store needing knowledge of lineage resolution.

R.4.2 Lineage assembly

A VAID carries parent_vaid, which is one hop, and lineage_hash, which is one-way and encodes only the immediate parent. The full lineage is not recoverable from the VAID itself. Assembly requires a resolver, and the reference implementation's resolver is the issuer's in-process lineage map.

Assembly therefore has three outcomes, and all three MUST be representable:

Outcome Condition
Complete Every hop resolved from leaf to a VAID with parent_vaid absent.
Incomplete Some parent_vaid present but unresolvable.
Trivially complete Leaf has no parent_vaid. It is its own root.

An incomplete lineage MUST be reported to the caller as Unavailable under R.4.3. It MUST NOT be presented to the check as though it were the whole lineage, and the resulting verification MUST NOT succeed.

This requirement exists because of a specific failure. The reference resolver is an in-memory map that is empty after process restart. A child VAID verified against an empty map resolves nothing, and a naive implementation returns a single-element lineage containing only the leaf. That result is indistinguishable from a legitimately rootless VAID and passes verification. The revocation of every ancestor is silently discarded.

An implementation that cannot distinguish "this VAID has no parent" from "I could not resolve this VAID's parent" does not satisfy this section.

R.4.3 Return

The check returns exactly three states:

State Meaning
NotRevoked The check completed against a complete lineage. Nothing in it is revoked.
Revoked At least one VAID in the lineage is revoked.
Unavailable Status could not be determined.

Unavailable covers both failure modes: the revocation store could not be consulted, and the lineage could not be completely assembled. The caller MUST be able to distinguish Unavailable from NotRevoked.

An interface returning a boolean does not satisfy this section. Neither does one that expresses Unavailable only as an error the caller may discard.

R.4.4 Revocation is inherited

A VAID is revoked if any VAID in its lineage is revoked. Revoking a parent revokes every VAID attenuated from it, transitively.

An implementation checking only the presented leaf leaves revocation bypassable by minting a child, and does not satisfy this section.

R.4.5 Behaviour on Unavailable

Verification MUST fail closed on Unavailable. A VAID whose revocation status cannot be determined is rejected.

An implementation MAY offer fail-open as an explicit configuration option for deployments prioritising availability. Where offered:

  • it MUST NOT be the default
  • it MUST be named to state what it does rather than obscure it
  • selecting it MUST be recorded in verification output for every verification performed under it

A verifier operating fail-open is, for that period, a verifier without revocation. That is a legitimate deployment choice and an illegitimate silent default.

R.4.6 Durability

Revocation state and lineage state that do not survive process restart do not provide revocation. A store that is empty after restart reports NotRevoked for every VAID ever revoked, and does so without any indication that anything is wrong.

Implementations providing non-durable stores MUST make state loss detectable, so it surfaces as Unavailable rather than as NotRevoked. An in-memory store that cannot distinguish "initialised and empty" from "never populated" does not satisfy this section.

The reference implementation currently ships non-durable in-memory stores for both revocation and lineage. See R.6.

Durability is two stores, and half of it is an outage rather than a hole. The requirement above binds the revocation store and the lineage resolver, and the two fail in opposite directions:

Persisted Child VAID Root VAID
both verifies revocation in force
lineage only verifies verifies — the revocation is gone
revocation only Unavailable revocation in force

Persisting the revoked set alone leaves every VAID carrying a parent_vaid unresolvable after restart, which is Incomplete under R.4.2, which is Unavailable under R.4.3, which fails closed under R.4.5. Every rootless VAID is unaffected, because it is trivially complete and never consults the resolver. The result is a failure that is total for delegated credentials, invisible for root ones, arrives at restart rather than at deploy, and — since nothing was revoked — is first diagnosed as a signing or clock problem. This is the specification working as intended: an outage is the correct choice over a child that masquerades as rootless and discards its ancestors' revocations. It is stated here because the symptom is not derivable from the requirement, and the diagnosis is not derivable from the symptom.

An implementation offering an injection point for one store and not the other therefore offers its users only the configuration that produces this outage. Implementations SHOULD make the two durable stores replaceable together, so that the half-configuration cannot be reached by omitting an argument. It remains a legitimate deployment choice for a single process that mints and verifies and is never restarted, and where offered it SHOULD require that choice to be named rather than defaulted into.

Sequencing, for a deployment migrating to durable stores: make the resolver durable first, or both in the same change. The revoked set first is the ordering that produces the outage. An intermediate posture this section already blesses — holding the revocation store in absent state, so it reports Unavailable and verification fails closed, until state has been loaded — is the safe way to deploy either half: a deliberate, brief, visible outage rather than a silent window of unrevoked credentials.

R.4.7 Invocation

Consulted during verification.

MAY additionally be consulted during attenuation, so a revoked parent cannot mint a child. Implementations that do not are not deficient: the child carries its parent in its lineage and fails verification under R.4.4 regardless.

Not consulted during minting of a root VAID. A VAID that does not yet exist cannot be revoked.

R.5 Time to live is not revocation

VAIDs carry a short time to live and implementations enforce it strictly.

TTL bounds the window in which a compromised VAID remains usable. It does not close that window on demand and it is not a revocation mechanism. It is defence in depth and is described as such throughout.

An implementation relying on TTL expiry alone has no response to compromise other than waiting.

R.6 Implementation status

Stated plainly because it is the current state and an evaluator will determine it from the source within minutes.

0.1.2 (superseded) 0.2 onward (current: 0.11.0)
Seam present Yes, Rust and Python Yes, Rust, Python and TypeScript
Return type Boolean Three-state per R.4.3
Lineage-aware No, leaf identifier only Yes, per R.4.2 and R.4.4
Assembly failure detected No Yes
Behaviour on store failure Not representable Fail closed per R.4.5
Revocation store durability In-memory only In-memory only — unchanged, see note
Lineage store durability In-memory only, issuer-local In-memory only, issuer-local — unchanged, see note
Both stores replaceable together No Yes, since 0.9.0 (RevocationBackend)
Reference default posture Vouching Vouching until 0.7.0; absent, fails closed, since 0.9.0

The shipped seam satisfies R.4. The three-state, lineage-aware seam landed in vaid-mint 0.2.0 on 2026-07-27 as a deliberate breaking change with no compatibility shim, and it has been the only seam since; it is published on crates.io, PyPI and npm. The right-hand column is therefore what you get by installing the package, not a roadmap. The rows did not all change on one date, and the table says so rather than implying a single flip:

  • Return type, lineage-awareness, assembly-failure detection and store-failure behaviour changed at 0.2.0 (2026-07-27), when the boolean leaf-only check was replaced wholesale. ADR-0001 records the decision to ship no shim: two traits named RevocationCheck with different safety properties was the outcome being avoided.
  • Seam present in all three languages completed at 0.3.0 on npm (2026-07-30), when TypeScript became the third reference implementation. Before that date the right-hand column was accurate for Rust and Python only.
  • Both stores replaceable together, and the default posture changed at 0.9.0 (2026-08-23), in one release and in all three languages at once. Two breaking changes: the single-half injection point (with_revocation_check) was removed in favour of RevocationBackend, which has no single-half constructor; and the reference default flipped from vouching to absent. See ADR-0008.

The two durability rows did not change, and are not scheduled to. Both stores are still in-memory in every published version. This is the one place where the earlier edition of this table was right and remains right, and it is called out because the rest of the row set moving could otherwise be read as durability having moved with it.

What changed at 0.9.0 is not durability but replaceability and posture: the reference stores are as non-durable as they ever were, and the two new rows record that a host can now replace both together, and that the non-durable revocation store no longer vouches. Read the durability rows and the posture row as separate claims; conflating them is how "the default fails closed" becomes the false belief that the reference now persists anything.

0.1.2 is retained above as history, not as deployment advice. That seam accepted a single identifier and returned a boolean, so revocation of a parent did not affect an attenuated child and a failed check was indistinguishable from a clean one. It has not been the current release since 2026-07-27 and no supported deployment runs it; the left-hand column exists so that a reader holding an older copy of this document can see what changed and when.

Durable backing stores are a host-application responsibility in all versions — both of them: durable revocation and durable lineage resolution. The reference implementation ships in-memory stores for development. R.4.6 governs how those stores must behave when their state is absent, not whether the reference implementation must become durable.

Note on the reference default (0.9 onward). The reference issuer's revocation store starts absent: it has not been populated, cannot vouch for anything, and reports Unavailable, so verification fails closed (R.4.5) until revocation state is loaded into it. This is the posture R.4.6 already blessed as the safe way to run a non-durable store, now taken as the default rather than offered as an alternative.

A caller who wants a fresh issuer to verify immediately asks for that by name — assuming_nothing_revoked() in Rust and Python, assumingNothingRevoked() in TypeScript — which installs the vouching store described below. That is fail-open, and R.4.5 permits it precisely in this form: as an explicit configuration, named to state what it does, never as a default.

Note on the reference default, 0.2 through 0.7 (historical). Until 0.9.0 the reference issuer defaulted its revocation store to a vouching posture (the constructor is named assume_nothing_revoked, not for its empty state but for what it does): it answered NotRevoked over an empty set so a fresh issuer verified out of the box. Being non-durable, it could not detect its own restart — after a restart it was reconstructed empty and again vouched NotRevoked, so a VAID revoked before the restart verified clean. It was a deliberate trade of restart-detection for out-of-the-box usability in development, disclosed here from the start, and it was argued legitimate under R.4.6 (which requires only that an absent store report Unavailable, and that absent be distinguishable from vouching — both of which held) and explicitly not under R.4.5 as a configuration: it was not a fail-open verifier setting, it was the reference's development default, and it was named to be unmisreadable.

That argument was sound, and it was a carve-out — one that existed only because the posture was a default. Removing the default removes the need for it, which is the reason the flip is an improvement to this specification's position rather than merely to the package's. R.4.5 now applies to the reference implementation on its own terms: the fail-open posture exists, it is not the default, and it is named.

R.7 Authenticity is separate from policy

Verifying a VAID answers two different questions, and they must not be collapsed:

  • Authenticity — was this document genuinely issued under a given kernel key, and is it internally consistent? This needs only the issuer's public key. It is answered by verify_vaid_authenticity (checks the signature-scheme version, the kernel signature over the canonical document, and lineage_hash consistency), and it is available to any third party — cross-organisation, offline, or after the fact — because a signature needs only the public key to check.
  • Standing — may this VAID be used right now? That is policy: at minimum expiry (a temporal check) and revocation (this section). Standing is evaluated on top of authenticity, by the party that holds the relevant state.

verify_vaid_authenticity deliberately answers authenticity only. It does not check expiry, and — the load-bearing point for this specification — it does not consult revocation. Gating authenticity on revocation would reintroduce the R.4.2 problem in a new place: a resolver-less verifier cannot perform the lineage/revocation lookup, so every third-party verification would fail closed, and the portable, publicly-checkable authenticity that is the point of a VAID would be lost. Revocation status is therefore reported on a separate path (RevocationCheck / the reference's revocation_status), or not at all where the verifier has no revocation state — never as a precondition of confirming the document is real.