Skip to content

feat(evidence): publish retired-key rotation history in the JWK Set (v0.1.25.33)#196

Merged
amavashev merged 6 commits into
mainfrom
feat/evidence-jwks-rotation-keys
Jun 18, 2026
Merged

feat(evidence): publish retired-key rotation history in the JWK Set (v0.1.25.33)#196
amavashev merged 6 commits into
mainfrom
feat/evidence-jwks-rotation-keys

Conversation

@amavashev

Copy link
Copy Markdown
Collaborator

Summary

Extends getEvidenceJwks (GET /v1/.well-known/cycles-jwks.json) to publish operator-configured retired signing keys (rotation history) alongside the active key — the v0.2-store follow-up flagged in v0.1.25.32. Evidence signed before a key rotation now still verifies against the key whose [cycles_nbf_ms, cycles_exp_ms) window covers its issued_at_ms (never "the current key").

v0.1.25.33 · no spec change · no wire change to existing endpoints.

What changed

  • JwksController — new @Value cycles.evidence.signing.retired-keys (env EVIDENCE_SIGNING_RETIRED_KEYS): a JSON array of {signer_did (raw 64-hex), kid, nbf_ms, exp_ms}, parsed via a local ObjectMapper (no injected bean, so @WebMvcTest loadability is preserved). Fail-safe to empty on malformed/non-array config — the active key always still publishes.
  • JwksDocuments.jwkSet — 4-arg overload (the old 3-arg delegates with List.of()) that appends each retired key as a bounded-window JWK (status:retired, cycles_exp_ms EXCLUSIVE/required).
  • Defensive skips (logged, never fatal): malformed hex; null exp_ms; empty/inverted window (exp <= nbf); missing/non-integral nbf_ms (NOT coerced to epoch 0); same key material as the active key (ambiguous window); duplicate kid (set-wide uniqueness).

Rotation procedure

On rotation: set the new key as signer-did active, append the old one to retired-keys with exp_ms = the rotation time.

Review

Codex read-only review surfaced 3 Medium window-validation findings (required-integral nbf_ms, empty/inverted-window guard, active/retired overlap guard) — all applied in this PR.

Scope

Publication of configured rotation history only. Redis-backed auto-rotation mechanics and did:cycles-form signer_did producer stamping remain separate follow-ups.

Tests

JwksDocumentsTest +7, JwksControllerTest +4; both classes 100% line-covered. Full mvn verify green; jacoco 95% gate met. AUDIT.md updated.

…v0.1.25.33)

Extend getEvidenceJwks (GET /v1/.well-known/cycles-jwks.json) to publish
operator-configured RETIRED signing keys alongside the active key, so
CyclesEvidence signed before a key rotation still verifies against the key
whose [cycles_nbf_ms, cycles_exp_ms) window covers its issued_at_ms.

- JwksController: new @value cycles.evidence.signing.retired-keys (env
  EVIDENCE_SIGNING_RETIRED_KEYS), a JSON array parsed via a local ObjectMapper
  (no injected bean, preserving @WebMvcTest loadability); fail-safe to empty on
  malformed/non-array config — the active key always still publishes.
- JwksDocuments.jwkSet: 4-arg overload (3-arg delegates with List.of()) that
  appends each retired key as a bounded-window JWK (status:retired, cycles_exp_ms
  EXCLUSIVE/required).
- Defensive skips (codex review, 3 Medium): malformed hex; null exp_ms;
  empty/inverted window (exp <= nbf); missing/non-integral nbf_ms (not coerced
  to epoch 0); same key material as the active key (ambiguous window); duplicate
  kid (set-wide uniqueness).

No spec change (CyclesEvidenceJwks already supports multi-key + windows +
status:retired) and no wire change to existing endpoints. Scope: publication of
configured rotation history only — Redis-backed auto-rotation and did:cycles-form
producer stamping remain follow-ups.

Tests: JwksDocumentsTest +7, JwksControllerTest +4; both classes 100% line-
covered. Full mvn verify green; jacoco 95% gate met. AUDIT.md updated.
…ries

The CyclesEvidence run (the getEvidenceJwks integration test, v0.1.25.22
through .33, and the two WIP entries) had been crammed onto a single
physical line as one run-on paragraph, and each entry was a 130-360 word
wall of nested parentheticals, ALL-CAPS, and review-round play-by-play —
inconsistent with the clean ~110-170 word prose entries from v0.1.25.21
down.

- Split the run-on line so each entry sits on its own line, matching the
  format used for v0.1.25.20 and below.
- Rewrite each CyclesEvidence entry to the established compact voice:
  flowing prose, one level of parentheses, no ALL-CAPS, a simple closing
  test/build line. Every entry's version, what-changed, why, and test
  outcome are preserved; only the verbosity is cut.

Docs-only; no code, spec, or wire change.
… retired key material

Two review findings on the retired-key JWK Set:

- Rotation must advance the active key's nbf-ms. The previous rotation
  doc only set exp_ms on the retiring key; leaving nbf-ms at the default 0
  publishes the new active key as valid since epoch, so it could sign a
  backdated envelope (issued_at_ms before the rotation) that still resolves
  as authentic — defeating window-gated selection. Fixed the documented
  procedure and added a startup warning when retired keys are configured
  but the active nbf-ms is at/before a retired window's end.

- Skip same key material with an overlapping window. JwksDocuments now
  tracks emitted [nbf, exp) windows per key material (including the active
  key's open-ended window) and skips a retired entry whose window overlaps
  an already-published one for the same material; disjoint windows for one
  key (legitimate reuse across non-overlapping periods) are preserved.
  Replaces the unconditional active-material skip, which wrongly dropped a
  disjoint retired window.

JwksDocumentsTest +10, JwksControllerTest +6; both classes 100% line-
covered. Full mvn verify green, jacoco 95% gate met. AUDIT.md updated.
Folded into v0.1.25.33 (unmerged).
…-safe)

Review finding: detecting active nbf-ms < latest retired exp_ms but only
warning still publishes the unsafe JWKS — a logged warning doesn't close
the hole, so the current key can sign backdated evidence (issued_at_ms
before the rotation) that resolves as authentic.

Fail closed by clamping instead of failing the endpoint: JwksDocuments
advances the published active cycles_nbf_ms up to the latest declared
retired exp_ms (Math.max), so the active key is never valid for
pre-rotation issued_at_ms while the endpoint stays up and post-rotation
evidence still resolves. The floor counts EVERY declared bounded retired
window — including one whose key material is malformed and therefore not
published — so a typo in rotation history can't reopen the hole. The
controller warning is kept as an operator nudge and its predicate aligned
to the same bounded-window filter.

Also drops the now-redundant active-key overlap seed: the clamp guarantees
the active window starts at/after every retired exp, so a same-material
retired key is always disjoint (its history is preserved, not dropped).

JwksDocumentsTest +13, JwksControllerTest +6; both classes 100% line-
covered. Full mvn verify green, jacoco 95% gate met. AUDIT.md updated.
Folded into v0.1.25.33 (unmerged).
The retired-keys property comment still said leaving nbf-ms at 0 "publishes
the new active key as valid since epoch" — stale since the active
cycles_nbf_ms is now warned on and fail-safe clamped up to the latest
retired exp_ms. Reword to describe the clamp while still recommending an
explicit nbf-ms. Comment-only; no code/behavior change.
… wording

- parseRetiredKeys checked isIntegralNumber() then asLong(), which wraps
  out-of-range integers (Jackson treats a BigInteger as integral, and
  asLong() saturates 2^63 to Long.MIN_VALUE). Also require canConvertToLong()
  for both nbf_ms and exp_ms so an out-of-range bound is dropped, not wrapped
  into a corrupt window. JwksControllerTest +1 (out-of-range exp skipped).

- AUDIT.md said retired entries are skipped when "key material matching the
  active key," but same-material entries now publish once the clamp makes
  their window disjoint. Corrected the skip list (and noted out-of-range
  bounds are rejected).

Both classes 100% line-covered; full mvn verify green, 95% gate met.
Folded into v0.1.25.33 (unmerged).
@amavashev amavashev merged commit 851db21 into main Jun 18, 2026
8 checks passed
@amavashev amavashev deleted the feat/evidence-jwks-rotation-keys branch June 18, 2026 14:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant