0032. Status-list freshness and replay protection
Context
The signed Token Status List (ADR-0005) is cached at the edge and read by Vör (ADR-0010). A signature stops forgery — but it does not stop replay: a network attacker who can serve an edge node a signed but stale status list pins an old "nothing revoked" state and defeats revocation entirely. ADR-0005 specifies propagation as a budget (a target) but never says the verifier enforces freshness. That gap is recorded here as a refinement, before the verification contract hardens.
Decision
-
The status list carries a signed, monotonic version and an issued-at timestamp. Each published list is strictly newer (a monotonically increasing counter) than the previous, and is timestamped, both under the signature.
-
Vör enforces a max-age and monotonicity. Verification rejects — or treats as "must re-fetch before trusting" — a status list older than a configured freshness bound, and rejects a list whose counter is older than the last one the edge accepted (a replayed older list is detected by the counter going backwards; a re-fetched list with an equal counter is the same list and is accepted — publish cadence is routinely slower than refresh cadence, and treating the normal case as replay would fail-close a healthy edge). Counters are scoped per list: with per-realm keys and lists (ADR-0023, ADR-0012) each list carries its own counter, and an edge keeps one high-water mark per list. This refines the Vör contract (ADR-0010): the cached status list is not merely signed, it is fresh.
-
Fail closed on staleness. If the edge cannot refresh within the max-age, it fails closed for the affected tokens — consistent with the cold-start posture (ADR-0010 §4) — rather than trusting a stale list. The freshness bound is a stated budget (numbers later, shape now), registered and validated in the budget system (ADR-0039).
-
The high-water mark is persistent edge state. Monotonicity is only as strong as the edge's memory of the last accepted counter: a verifier that forgets it on restart re-opens a replay window as wide as the max-age. The last-accepted counter and issued-at per list are therefore locally persisted verifier state — edge-local, never a core read, so rule 1 is untouched. A restarted edge that cannot recover its high-water mark treats its cache as cold (ADR-0010 §4) until it fetches a list and re-establishes one. The residual exposure — replaying a list newer than the persisted mark but older than current, within max-age — is bounded by the max-age budget and accepted as such.
Consequences
- Revocation can no longer be defeated by replaying an old signed status list — the single most dangerous attack on the revocation model is closed.
- The ADR-0005 propagation budget becomes a verifier-enforced property, not just a publishing target; it is testable (an abuse-case: a stale/replayed list is rejected, ADR-0028).
- The freshness-vs-availability trade is explicit and aligned with the fail-closed posture (ADR-0010): a verifier never trusts what it cannot prove is current.
- Refines ADR-0005 (the status-list format gains a monotonic version + timestamp) and ADR-0010 (the Vör contract gains a freshness check and persistent per-list high-water marks).
- The same counter + issued-at + max-age discipline applies to every signed projection the edge trusts — the published JWKS included — under ADR-0043; this record owns the status-list case, ADR-0043 generalizes it (a signed-but-stale JWKS would otherwise re-animate an emergency-removed key, ADR-0036).