From 98840b01703b8d9cb3843bee67f3d9083422ab62 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Fri, 19 Jun 2026 08:22:52 -0400 Subject: [PATCH 1/3] fix(evidence): loud error when retired-keys config is unusable (v0.1.25.35) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The active-key nbf clamp (v0.1.25.33) only fires when a retired entry has a usable window. If cycles.evidence.signing.retired-keys is configured but produces ZERO usable entries — malformed JSON, or every entry dropped for bad bounds — there is nothing to clamp against, so the active key publishes unbounded at the configured nbf (default 0 = since epoch). That silently reverts a rotated server to the never-rotated posture: pre-rotation evidence won't resolve, and the current key could resolve a backdated envelope as authentic. JwksController now distinguishes "retired-keys not configured" (blank, no noise) from "configured but yielded zero usable entries", logging a clear ERROR for the latter that names the consequence. Deliberately NOT fail-closed (that would also break verification of all current evidence); the active key still publishes — the never-fail-closed guarantee is retained. Surfaced by the codex review of the rotation blog post (cycles-docs#724). JwksControllerTest +1 (OutputCaptureExtension: configured-but-unusable logs at ERROR and still publishes the active key). Full mvn verify green, jacoco 95% gate met. Observability-only — no wire/spec change, no change to the JWK Set. --- AUDIT.md | 8 ++++++++ .../protocol/api/controller/JwksController.java | 12 ++++++++++++ .../api/controller/JwksControllerTest.java | 17 +++++++++++++++++ cycles-protocol-service/pom.xml | 2 +- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/AUDIT.md b/AUDIT.md index f33f158..59677fb 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -5,6 +5,14 @@ --- +### 2026-06-19 — v0.1.25.35: loud error when a retired-keys config is unusable + +Closes a silent gap in the v0.1.25.33 rotation-history publication. The active-key `nbf` clamp only fires when a retired entry has a usable window; if `cycles.evidence.signing.retired-keys` is configured (non-blank) but produces ZERO usable entries — malformed JSON, or every entry dropped for bad bounds — there is nothing to clamp against, so the active key publishes UNBOUNDED at the configured `nbf` (default 0 = since epoch). That silently reverts a rotated server to the never-rotated posture: pre-rotation evidence won't resolve, and the current key could resolve a backdated envelope as authentic. + +`JwksController` now distinguishes "retired-keys not configured" (blank → legitimate never-rotated, no noise) from "configured but yielded zero usable entries," logging a clear ERROR for the latter that names the consequence and tells the operator to fix the config. Deliberately NOT fail-closed — refusing to publish would also break verification of all current evidence; the active key still publishes (the existing never-fail-closed guarantee is retained and tested). Surfaced by the codex review of the rotation blog post (runcycles/cycles-docs#724). + +`JwksControllerTest` +1 (`OutputCaptureExtension`: configured-but-unusable logs the ERROR and still publishes the active key). Full `mvn verify` green, jacoco 95% gate met. Observability-only — no wire/spec change, no change to the published JWK Set. + ### 2026-06-18 — Benchmark release gate: p99 metrics non-gating (no version bump) The release gate (`scripts/check-regression.py`) failed the v0.1.25.34 release on `commit_p99` (+94% vs baseline) while every p50 and throughput metric was within tolerance. diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index 25c52c4..d1807d7 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -77,6 +77,18 @@ public JwksController( + "current key cannot resolve as valid for pre-rotation evidence. Set " + "cycles.evidence.signing.nbf-ms to the rotation time to make this explicit.", this.nbfMs); } + } else if (retiredKeysJson != null && !retiredKeysJson.isBlank()) { + // Configured but produced ZERO usable entries (malformed JSON, or every entry + // invalid). Do NOT silently collapse to the never-rotated posture: with nothing + // to clamp against, the active key publishes UNBOUNDED at the configured nbf, so + // pre-rotation evidence won't resolve and a backdated envelope could resolve as + // authentic. Loud ERROR — but never fail closed, which would also break + // verification of all current evidence. + LOG.error("evidence JWKS: cycles.evidence.signing.retired-keys is set but produced no usable " + + "entries (malformed JSON, or every entry invalid); rotation history is NOT being " + + "published and the active key remains unbounded at cycles_nbf_ms={}. Evidence signed " + + "before a rotation will not resolve, and a backdated envelope could resolve as " + + "authentic — fix the retired-keys config.", this.nbfMs); } } diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java index df5e2a8..04d9e62 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -7,6 +7,9 @@ import io.runcycles.protocol.data.service.ReservationExpiryService; import org.hamcrest.Matchers; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -121,6 +124,20 @@ void malformedRetiredKeysJson_stillPublishesActiveKey() { assertThat((List>) body.get("keys")).hasSize(1); } + @Test + @ExtendWith(OutputCaptureExtension.class) + @SuppressWarnings("unchecked") + void configuredButUnusableRetiredKeys_logsErrorAndStillPublishes(CapturedOutput output) { + // A non-blank retired-keys config that yields zero usable entries (here: a valid + // array whose only entry has no window bounds) must not silently collapse to the + // never-rotated posture — loud ERROR, but the active key still publishes. + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, "[{\"kid\":\"x\"}]"); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(1); + // assert the LEVEL too — a regression from error() to warn() with the same text must fail + assertThat(output).containsPattern("ERROR.*retired-keys is set but produced no usable entries"); + } + @Test @SuppressWarnings("unchecked") void retiredKeyWithMissingNbf_isSkipped() { diff --git a/cycles-protocol-service/pom.xml b/cycles-protocol-service/pom.xml index 77a1b9f..eddea0a 100644 --- a/cycles-protocol-service/pom.xml +++ b/cycles-protocol-service/pom.xml @@ -18,7 +18,7 @@ cycles-protocol-service-api - 0.1.25.34 + 0.1.25.35 21 21 21 From 27a4135d75365463239e78542522225df04ccd6d Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Fri, 19 Jun 2026 08:32:15 -0400 Subject: [PATCH 2/3] fix(evidence): base unusable-retired-keys detection on published count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (High): the ERROR keyed off parseRetiredKeys returning empty, but the parser only validates integral/in-range bounds — JwksDocuments drops more on emission (empty/inverted window where exp_ms<=nbf_ms, malformed hex, duplicate kid, overlap). An empty window (nbf_ms==exp_ms) passes the parser (so the list is non-empty -> no ERROR), is excluded from the clamp/WARN (which filter exp>nbf), and is dropped by JwksDocuments -> only the unbounded active key publishes, with NO signal. The exact silent collapse this change closes. Base the decision on what JwksDocuments actually publishes: count retired keys in the emitted set (publishedRetiredCount, reusing jwkSet's full emission validation) and fire the ERROR when retired-keys is configured + raw-hex signer + zero published retired keys. The WARN/clamp basis is unchanged. JwksControllerTest +1 (empty window nbf==exp: parser-passing, emission-dropped, ERROR still fires); existing test updated to the new "no publishable retired entries" message. JwksController 100% line-covered; full mvn verify green, jacoco 95% gate met. --- AUDIT.md | 6 +-- .../api/controller/JwksController.java | 51 ++++++++++++++----- .../api/controller/JwksControllerTest.java | 17 ++++++- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 59677fb..2d64035 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -7,11 +7,11 @@ ### 2026-06-19 — v0.1.25.35: loud error when a retired-keys config is unusable -Closes a silent gap in the v0.1.25.33 rotation-history publication. The active-key `nbf` clamp only fires when a retired entry has a usable window; if `cycles.evidence.signing.retired-keys` is configured (non-blank) but produces ZERO usable entries — malformed JSON, or every entry dropped for bad bounds — there is nothing to clamp against, so the active key publishes UNBOUNDED at the configured `nbf` (default 0 = since epoch). That silently reverts a rotated server to the never-rotated posture: pre-rotation evidence won't resolve, and the current key could resolve a backdated envelope as authentic. +Closes a silent gap in the v0.1.25.33 rotation-history publication. The active-key `nbf` clamp only fires when a retired entry has a usable window; if `cycles.evidence.signing.retired-keys` is configured (non-blank) but produces ZERO PUBLISHABLE entries, there is nothing to clamp against, so the active key publishes UNBOUNDED at the configured `nbf` (default 0 = since epoch). That silently reverts a rotated server to the never-rotated posture: pre-rotation evidence won't resolve, and the current key could resolve a backdated envelope as authentic. -`JwksController` now distinguishes "retired-keys not configured" (blank → legitimate never-rotated, no noise) from "configured but yielded zero usable entries," logging a clear ERROR for the latter that names the consequence and tells the operator to fix the config. Deliberately NOT fail-closed — refusing to publish would also break verification of all current evidence; the active key still publishes (the existing never-fail-closed guarantee is retained and tested). Surfaced by the codex review of the rotation blog post (runcycles/cycles-docs#724). +`JwksController` distinguishes "retired-keys not configured" (blank → legitimate never-rotated, no noise) from "configured but nothing publishable," logging a clear ERROR for the latter that names the consequence. Crucially the decision is based on what `JwksDocuments` actually PUBLISHES, not on parser output — an entry can pass the lenient parser (integral, in-range bounds) yet be dropped on emission (empty/inverted window where `exp_ms <= nbf_ms`, malformed hex, duplicate kid, overlapping reuse), which the first cut missed (codex review of runcycles/cycles-docs#724). Deliberately NOT fail-closed — refusing to publish would also break verification of all current evidence; the active key still publishes (the never-fail-closed guarantee is retained and tested). -`JwksControllerTest` +1 (`OutputCaptureExtension`: configured-but-unusable logs the ERROR and still publishes the active key). Full `mvn verify` green, jacoco 95% gate met. Observability-only — no wire/spec change, no change to the published JWK Set. +`JwksControllerTest` +2 (`OutputCaptureExtension`, level-checked: a configured-but-unparseable config and a parser-passing-but-emission-dropped empty window both log the ERROR at `ERROR` and still publish the active key). Full `mvn verify` green, jacoco 95% gate met. Observability-only — no wire/spec change, no change to the published JWK Set. ### 2026-06-18 — Benchmark release gate: p99 metrics non-gating (no version bump) diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index d1807d7..65ac6c1 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -69,29 +69,52 @@ public JwksController( + "publication needs a raw-hex public key, so GET /v1/.well-known/cycles-jwks.json " + "will return 404 until one is configured"); } - if (!this.retiredKeys.isEmpty()) { - LOG.info("evidence JWKS: {} retired key(s) configured for rotation history", this.retiredKeys.size()); + // Base the rotation-history signal on what JwksDocuments will ACTUALLY publish, + // not on parser output: an entry can pass parsing yet be dropped on emission + // (empty/inverted window, malformed hex, duplicate kid, overlapping material), + // which would leave the active key unbounded with no signal at all. + long publishedRetired = publishedRetiredCount(this.signerDid, this.kid, this.nbfMs, this.retiredKeys); + boolean retiredConfigured = retiredKeysJson != null && !retiredKeysJson.isBlank(); + if (publishedRetired > 0) { + LOG.info("evidence JWKS: {} retired key(s) published for rotation history", publishedRetired); if (activeKeyWindowPredatesRetirement(this.nbfMs, this.retiredKeys)) { LOG.warn("evidence JWKS: configured active key cycles_nbf_ms ({}) is at/before a retired key's " + "window end; the published active window is advanced to the latest retired exp so the " + "current key cannot resolve as valid for pre-rotation evidence. Set " + "cycles.evidence.signing.nbf-ms to the rotation time to make this explicit.", this.nbfMs); } - } else if (retiredKeysJson != null && !retiredKeysJson.isBlank()) { - // Configured but produced ZERO usable entries (malformed JSON, or every entry - // invalid). Do NOT silently collapse to the never-rotated posture: with nothing - // to clamp against, the active key publishes UNBOUNDED at the configured nbf, so - // pre-rotation evidence won't resolve and a backdated envelope could resolve as - // authentic. Loud ERROR — but never fail closed, which would also break - // verification of all current evidence. - LOG.error("evidence JWKS: cycles.evidence.signing.retired-keys is set but produced no usable " - + "entries (malformed JSON, or every entry invalid); rotation history is NOT being " - + "published and the active key remains unbounded at cycles_nbf_ms={}. Evidence signed " - + "before a rotation will not resolve, and a backdated envelope could resolve as " - + "authentic — fix the retired-keys config.", this.nbfMs); + } else if (retiredConfigured && JwksDocuments.isRawHexKey(this.signerDid)) { + // Configured but NOTHING was publishable — malformed JSON, or every entry dropped + // for an invalid window / hex / duplicate kid / overlap. Do NOT silently collapse to + // the never-rotated posture: with nothing to clamp against, the active key publishes + // UNBOUNDED at the configured nbf, so pre-rotation evidence won't resolve and a + // backdated envelope could resolve as authentic. Loud ERROR — but never fail closed, + // which would also break verification of all current evidence. + LOG.error("evidence JWKS: cycles.evidence.signing.retired-keys is set but produced no publishable " + + "retired entries (malformed JSON, or every entry dropped for an invalid window / hex / " + + "duplicate kid / overlap); rotation history is NOT being published and the active key " + + "remains unbounded at cycles_nbf_ms={}. Evidence signed before a rotation will not " + + "resolve, and a backdated envelope could resolve as authentic — fix the retired-keys " + + "config.", this.nbfMs); } } + /** + * How many retired keys {@link JwksDocuments} will actually publish for this config — + * i.e. after the full emission validation (window validity, hex, duplicate-kid, + * overlap), not just the lenient parser. Returns 0 when the signer isn't a raw-hex + * key (the set 404s) or no retired entry survives emission. + */ + @SuppressWarnings("unchecked") + private static long publishedRetiredCount(String signerDid, String kid, long nbfMs, List retired) { + return JwksDocuments.jwkSet(signerDid, kid, nbfMs, retired) + .map(set -> (List>) set.get("keys")) + .orElse(List.of()) + .stream() + .filter(k -> "retired".equals(k.get("status"))) + .count(); + } + /** * True when the active key's {@code nbf-ms} starts before a retired key's * window ends — i.e. retired keys exist (a rotation happened) but the active diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java index 04d9e62..be7c54c 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -135,7 +135,22 @@ void configuredButUnusableRetiredKeys_logsErrorAndStillPublishes(CapturedOutput Map body = controller.getEvidenceJwks().getBody(); assertThat((List>) body.get("keys")).hasSize(1); // assert the LEVEL too — a regression from error() to warn() with the same text must fail - assertThat(output).containsPattern("ERROR.*retired-keys is set but produced no usable entries"); + assertThat(output).containsPattern("ERROR.*produced no publishable retired entries"); + } + + @Test + @ExtendWith(OutputCaptureExtension.class) + @SuppressWarnings("unchecked") + void retiredKeyParsedButDroppedOnEmission_logsError(CapturedOutput output) { + // The gap parser-output detection misses: an empty window (nbf_ms == exp_ms) PASSES + // the parser (both bounds integral + in range) but JwksDocuments drops it, so only the + // unbounded active key publishes. Detection is based on PUBLISHED retired keys, so the + // ERROR still fires. + String retired = "[{\"signer_did\":\"" + "ab".repeat(32) + "\",\"kid\":\"empty\",\"nbf_ms\":100,\"exp_ms\":100}]"; + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, retired); + Map body = controller.getEvidenceJwks().getBody(); + assertThat((List>) body.get("keys")).hasSize(1); // active only — retired dropped + assertThat(output).containsPattern("ERROR.*produced no publishable retired entries"); } @Test From fa7af64a4ebb2483c4ca593425777385585995a8 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Fri, 19 Jun 2026 08:40:03 -0400 Subject: [PATCH 3/3] fix(evidence): split unusable-retired-keys ERROR by whether the active key is bounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding (Low): the ERROR claimed the active key "remains unbounded" and backdating could resolve whenever zero retired keys are published. But the JwksDocuments clamp filters exp>nbf, NOT key material — so a retired entry with a valid window but malformed hex (not publishable) still clamps the active key up. In that case the active key IS bounded; the real consequence is missing rotation history, not backdating. Split the message on activeKeyWindowPredatesRetirement (whether a valid window clamped the active key): bounded -> report missing history without a backdating claim; no valid window -> warn the active key has no rotation boundary and a backdated envelope could resolve. The detection (published-count basis) is unchanged. JwksControllerTest +1 (valid-window + malformed-hex: active clamped to the exp, bounded ERROR, asserts no "backdated"). JwksController 100% line-covered; full mvn verify green, jacoco 95% gate met. --- AUDIT.md | 4 +-- .../api/controller/JwksController.java | 36 ++++++++++++------- .../api/controller/JwksControllerTest.java | 16 +++++++++ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 2d64035..aaa1838 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -9,9 +9,9 @@ Closes a silent gap in the v0.1.25.33 rotation-history publication. The active-key `nbf` clamp only fires when a retired entry has a usable window; if `cycles.evidence.signing.retired-keys` is configured (non-blank) but produces ZERO PUBLISHABLE entries, there is nothing to clamp against, so the active key publishes UNBOUNDED at the configured `nbf` (default 0 = since epoch). That silently reverts a rotated server to the never-rotated posture: pre-rotation evidence won't resolve, and the current key could resolve a backdated envelope as authentic. -`JwksController` distinguishes "retired-keys not configured" (blank → legitimate never-rotated, no noise) from "configured but nothing publishable," logging a clear ERROR for the latter that names the consequence. Crucially the decision is based on what `JwksDocuments` actually PUBLISHES, not on parser output — an entry can pass the lenient parser (integral, in-range bounds) yet be dropped on emission (empty/inverted window where `exp_ms <= nbf_ms`, malformed hex, duplicate kid, overlapping reuse), which the first cut missed (codex review of runcycles/cycles-docs#724). Deliberately NOT fail-closed — refusing to publish would also break verification of all current evidence; the active key still publishes (the never-fail-closed guarantee is retained and tested). +`JwksController` distinguishes "retired-keys not configured" (blank → legitimate never-rotated, no noise) from "configured but nothing publishable," logging a clear ERROR for the latter that names the consequence. Crucially the decision is based on what `JwksDocuments` actually PUBLISHES, not on parser output — an entry can pass the lenient parser (integral, in-range bounds) yet be dropped on emission (empty/inverted window where `exp_ms <= nbf_ms`, malformed hex, duplicate kid, overlapping reuse), which the first cut missed (codex review of runcycles/cycles-docs#724). The ERROR message is split by whether a valid retired WINDOW still clamped the active key (the clamp ignores key material, so a malformed-hex entry with a good window keeps the active key bounded even though it isn't published): when bounded, the message reports missing rotation history without a backdating claim; only when no valid window exists does it warn that the active key publishes with no rotation boundary and a backdated envelope could resolve as authentic. Deliberately NOT fail-closed — refusing to publish would also break verification of all current evidence; the active key still publishes (the never-fail-closed guarantee is retained and tested). -`JwksControllerTest` +2 (`OutputCaptureExtension`, level-checked: a configured-but-unparseable config and a parser-passing-but-emission-dropped empty window both log the ERROR at `ERROR` and still publish the active key). Full `mvn verify` green, jacoco 95% gate met. Observability-only — no wire/spec change, no change to the published JWK Set. +`JwksControllerTest` +3 (`OutputCaptureExtension`, level-checked: a configured-but-unparseable config and a parser-passing-but-emission-dropped empty window each log the unbounded ERROR; a valid-window-but-malformed-hex entry logs the bounded ERROR — active clamped, no backdating claim — and all still publish the active key). Full `mvn verify` green, jacoco 95% gate met. Observability-only — no wire/spec change, no change to the published JWK Set. ### 2026-06-18 — Benchmark release gate: p99 metrics non-gating (no version bump) diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java index 65ac6c1..0beb91d 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -84,18 +84,30 @@ public JwksController( + "cycles.evidence.signing.nbf-ms to the rotation time to make this explicit.", this.nbfMs); } } else if (retiredConfigured && JwksDocuments.isRawHexKey(this.signerDid)) { - // Configured but NOTHING was publishable — malformed JSON, or every entry dropped - // for an invalid window / hex / duplicate kid / overlap. Do NOT silently collapse to - // the never-rotated posture: with nothing to clamp against, the active key publishes - // UNBOUNDED at the configured nbf, so pre-rotation evidence won't resolve and a - // backdated envelope could resolve as authentic. Loud ERROR — but never fail closed, - // which would also break verification of all current evidence. - LOG.error("evidence JWKS: cycles.evidence.signing.retired-keys is set but produced no publishable " - + "retired entries (malformed JSON, or every entry dropped for an invalid window / hex / " - + "duplicate kid / overlap); rotation history is NOT being published and the active key " - + "remains unbounded at cycles_nbf_ms={}. Evidence signed before a rotation will not " - + "resolve, and a backdated envelope could resolve as authentic — fix the retired-keys " - + "config.", this.nbfMs); + // Configured but NOTHING was publishable — rotation history is NOT served either way, + // so pre-rotation evidence won't resolve. Split on whether a valid retired WINDOW + // existed to clamp the active key (the clamp ignores key material, so a malformed-hex + // entry with a good window still bounds the active key even though it isn't published). + if (activeKeyWindowPredatesRetirement(this.nbfMs, this.retiredKeys)) { + // A valid window clamped the active key up: it stays BOUNDED (no backdating); the + // problem is the unpublishable retired key (e.g. malformed key material). + LOG.error("evidence JWKS: cycles.evidence.signing.retired-keys is set but no retired key is " + + "publishable (e.g. malformed key material); rotation history is NOT being published, " + + "so evidence signed before the rotation will not resolve. The active key window was " + + "advanced to the latest valid retired exp, so it stays bounded — fix the retired-keys " + + "config."); + } else { + // No valid window to clamp against: the active key publishes at the configured nbf + // with no rotation boundary, so if that is the since-epoch default a backdated + // envelope could resolve as authentic. Loud ERROR — but never fail closed, which + // would also break verification of all current evidence. + LOG.error("evidence JWKS: cycles.evidence.signing.retired-keys is set but produced no " + + "publishable retired entries and no valid window to bound the active key (malformed " + + "JSON, empty/inverted windows, etc.); rotation history is NOT being published and the " + + "active key publishes at cycles_nbf_ms={} with no rotation boundary — if that is the " + + "default 0 (since epoch), a backdated envelope could resolve as authentic. Fix the " + + "retired-keys config.", this.nbfMs); + } } } diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java index be7c54c..19565d2 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -153,6 +153,22 @@ void retiredKeyParsedButDroppedOnEmission_logsError(CapturedOutput output) { assertThat(output).containsPattern("ERROR.*produced no publishable retired entries"); } + @Test + @ExtendWith(OutputCaptureExtension.class) + @SuppressWarnings("unchecked") + void clampCandidateButNothingPublished_logsBoundedErrorNotBackdating(CapturedOutput output) { + // A valid window with MALFORMED key material: the clamp (which ignores key material) + // still advances the active key, so it stays bounded — the entry just isn't publishable. + // The ERROR must report missing history with the active key bounded, NOT backdating. + String retired = "[{\"signer_did\":\"not-hex\",\"kid\":\"bad\",\"nbf_ms\":0,\"exp_ms\":100}]"; + JwksController controller = new JwksController(SIGNER_DID, "2026-06", 0L, retired); + List> keys = (List>) controller.getEvidenceJwks().getBody().get("keys"); + assertThat(keys).hasSize(1); // malformed retired not published + assertThat(keys.get(0)).containsEntry("cycles_nbf_ms", 100L); // active clamped up to the retired exp + assertThat(output).containsPattern("ERROR.*no retired key is publishable"); + assertThat(output).doesNotContain("backdated"); + } + @Test @SuppressWarnings("unchecked") void retiredKeyWithMissingNbf_isSkipped() {