ADR-045: Signer-Key Cache — Split Positive/Negative TTL Revocation Window

On this page

Status

Accepted (2026-06-28). Drives the #747 fix (Step 1 of the craig-intake hardening + total-coverage plan, epic &61 — plan). Amends the in-process signer-key caching behavior described in ADR-018 (the signer-keys/by-kid hot-path lookup). The full evict-on-revoke mechanism that would supersede the bounded window is tracked separately in #892.

Context

ADR-018 made intake fetch a partner signer’s public key from craig-security by kid (GET /v1/security/signer-keys/by-kid/{kid}) and cache the result in-process. A single TTL (60s) governed both a positive result (an approved key whose owning partner is active) and a negative result (a 404 — no such approved/active key).

The 2026-06-28 craig-intake total-coverage audit (gap CHAOS-02, #747) found that this caches the positive result for up to 60s with no invalidation path: a signer key revoked or expired in craig-security keeps verifying signed mandated reports for up to 60 seconds. This is an authorization-correctness defect on the signed submission channel — the only path that accepts cryptographically-signed mandated reports (#730). The cache module (signer_auth.rs) had zero unit tests, and the existing revoked-key integration test (tests/api/signer_key_expiry.rs) deliberately exercises only the cold path (it revokes before any cache entry exists); the warm-cache-then-revoke staleness window was acknowledged in code comments but asserted nowhere.

Three mechanisms were weighed to close the window:

  1. Zero / near-zero positive TTL — re-verify every signed submission against craig-security. Strongest (zero staleness window) but pays a craig-security round-trip per signed report and defeats the cache entirely.

  2. Evict-on-revoke — a keyring→verifier invalidation signal so a revoke immediately drops the cached entry. Strongest and keeps the cache, but requires a signal that does not exist today (an event-bus subscription, a revalidated version/ETag, or a push from the approve/revoke path) — materially larger work.

  3. Bounded positive window — shorten only the positive TTL to a small value, keeping the negative TTL long.

Decision

Split the single cache TTL into two, stored per cache entry (so approved and absent keys coexist in one map under different lifetimes):

  • POSITIVE_CACHE_TTL = 5s (maintainer-ratified 2026-06-28). A revoked or expired approved key keeps verifying for at most 5 seconds — a 12× reduction from 60s — while still coalescing rapid resubmissions from one signer. This 5s window is the accepted residual risk.

  • NEGATIVE_CACHE_TTL = 60s (unchanged). A definitively-absent key is safe to remember longer: caching the absence absorbs kid-enumeration floods (#707) with no staleness risk — a newly-approved key waits at most 60s for its first successful verify, which is an availability cost, not a security one.

  • The transport / non-200-or-404 error branches remain uncached (unchanged): the cache is populated only after the OK/NOT_FOUND match, so an upstream outage or unexpected status never leaves a (mis-)cached entry.

Why 5s and not 0: signed mandated reports are low-volume but arrive in bursts (e.g. a hospital system submitting several at once). A 5s window coalesces a burst into a single craig-security lookup while keeping the revocation-staleness bound small. Zeroing the TTL would re-verify every submission; the maintainer ratified the 5s bounded window over both the 0s (no positive cache) and 10s options on 2026-06-28.

Deferred: full evict-on-revoke (#892). When the keyring→verifier invalidation signal lands, eviction — not time — becomes the freshness mechanism, the positive TTL may be revisited (potentially lengthened back toward the negative TTL), and this ADR updated or superseded accordingly.

Consequences

Positive. Revocation/expiry now takes effect within ≤5s on the signed channel (was ≤60s). The cache mechanics are unit-tested for the first time: TTL selection by result kind; an approved key served within its window; a revoked key past its window forced to re-verify; positive/negative entries expiring independently; and error paths never caching. The negative-cache absorption of kid-enumeration floods is preserved.

Residual risk. A ≤5s staleness window remains until #892. An operator revoking a compromised key should treat ≤5s of continued acceptance on the signed channel as the current bound. Per-signer submissions spaced more than 5s apart incur one extra craig-security lookup — negligible at this channel’s volume.

No wire/schema/contract change. The craig-security by-kid endpoint and its response shape are unchanged; this is an intake-internal caching change only.

References

  • #747 — the fix this ADR records (epic &61, Step 1).

  • #892 — deferred full evict-on-revoke (keyring→verifier invalidation signal).

  • ADR-018 — the signer-key by-kid lookup + in-process cache.

  • #707 — kid-enumeration uniform-401 / registry-outage-500 split (the negative cache’s DoS-absorption rationale).

  • #730 — mandated reports must be cryptographically signed (the channel this defect affects).

Edit this page · latest