Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AUDIT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RetiredKey> retired) {
return JwksDocuments.jwkSet(signerDid, kid, nbfMs, retired)
.map(set -> (List<Map<String, Object>>) 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -121,6 +124,51 @@ void malformedRetiredKeysJson_stillPublishesActiveKey() {
assertThat((List<Map<String, Object>>) 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<String, Object> body = controller.getEvidenceJwks().getBody();
assertThat((List<Map<String, Object>>) 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<String, Object> body = controller.getEvidenceJwks().getBody();
assertThat((List<Map<String, Object>>) 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<Map<String, Object>> keys = (List<Map<String, Object>>) 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() {
Expand Down
2 changes: 1 addition & 1 deletion cycles-protocol-service/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<module>cycles-protocol-service-api</module>
</modules>
<properties>
<revision>0.1.25.34</revision>
<revision>0.1.25.35</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
Expand Down