diff --git a/AUDIT.md b/AUDIT.md index f33f158..aaa1838 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 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). 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` +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) 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..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 @@ -69,17 +69,64 @@ 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 (retiredConfigured && JwksDocuments.isRawHexKey(this.signerDid)) { + // 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); + } } } + /** + * 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 df5e2a8..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 @@ -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,51 @@ 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.*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 + @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() { 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