Skip to content

Commit 851db21

Browse files
authored
Merge pull request #196 from runcycles/feat/evidence-jwks-rotation-keys
feat(evidence): publish retired-key rotation history in the JWK Set (v0.1.25.33)
2 parents ddf074c + ceab9ee commit 851db21

7 files changed

Lines changed: 466 additions & 36 deletions

File tree

AUDIT.md

Lines changed: 16 additions & 1 deletion
Large diffs are not rendered by default.

cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package io.runcycles.protocol.api.controller;
22

3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
35
import io.runcycles.protocol.api.evidence.JwksDocuments;
6+
import io.runcycles.protocol.api.evidence.JwksDocuments.RetiredKey;
47
import io.runcycles.protocol.data.exception.CyclesProtocolException;
58
import io.swagger.v3.oas.annotations.Operation;
69
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -14,6 +17,8 @@
1417
import org.springframework.web.bind.annotation.RequestMapping;
1518
import org.springframework.web.bind.annotation.RestController;
1619

20+
import java.util.ArrayList;
21+
import java.util.List;
1722
import java.util.Map;
1823
import java.util.concurrent.TimeUnit;
1924

@@ -48,26 +53,98 @@ public class JwksController {
4853
private final String signerDid;
4954
private final String kid;
5055
private final long nbfMs;
56+
private final List<RetiredKey> retiredKeys;
5157

5258
public JwksController(
5359
@Value("${cycles.evidence.signing.signer-did:}") String signerDid,
5460
@Value("${cycles.evidence.signing.kid:}") String kid,
55-
@Value("${cycles.evidence.signing.nbf-ms:0}") long nbfMs) {
61+
@Value("${cycles.evidence.signing.nbf-ms:0}") long nbfMs,
62+
@Value("${cycles.evidence.signing.retired-keys:}") String retiredKeysJson) {
5663
this.signerDid = signerDid == null ? "" : signerDid.trim();
5764
this.kid = kid == null ? "" : kid.trim();
5865
this.nbfMs = nbfMs;
66+
this.retiredKeys = parseRetiredKeys(retiredKeysJson);
5967
if (!this.signerDid.isBlank() && !JwksDocuments.isRawHexKey(this.signerDid)) {
6068
LOG.info("evidence signer_did is not a raw 64-hex key (did:cycles or other); JWKS "
6169
+ "publication needs a raw-hex public key, so GET /v1/.well-known/cycles-jwks.json "
6270
+ "will return 404 until one is configured");
6371
}
72+
if (!this.retiredKeys.isEmpty()) {
73+
LOG.info("evidence JWKS: {} retired key(s) configured for rotation history", this.retiredKeys.size());
74+
if (activeKeyWindowPredatesRetirement(this.nbfMs, this.retiredKeys)) {
75+
LOG.warn("evidence JWKS: configured active key cycles_nbf_ms ({}) is at/before a retired key's "
76+
+ "window end; the published active window is advanced to the latest retired exp so the "
77+
+ "current key cannot resolve as valid for pre-rotation evidence. Set "
78+
+ "cycles.evidence.signing.nbf-ms to the rotation time to make this explicit.", this.nbfMs);
79+
}
80+
}
81+
}
82+
83+
/**
84+
* True when the active key's {@code nbf-ms} starts before a retired key's
85+
* window ends — i.e. retired keys exist (a rotation happened) but the active
86+
* key's window was not advanced to the rotation time, so the active key is
87+
* still published as authoritative for pre-rotation {@code issued_at_ms}.
88+
*/
89+
static boolean activeKeyWindowPredatesRetirement(long activeNbfMs, List<RetiredKey> retired) {
90+
long latestRetiredExp = retired.stream()
91+
.filter(r -> r != null && r.expMs() != null && r.expMs() > r.nbfMs())
92+
.mapToLong(RetiredKey::expMs)
93+
.max()
94+
.orElse(Long.MIN_VALUE);
95+
return activeNbfMs < latestRetiredExp;
96+
}
97+
98+
/**
99+
* Parse {@code cycles.evidence.signing.retired-keys} — a JSON array of
100+
* {@code {"signer_did","kid","nbf_ms","exp_ms"}} — into retired-key records.
101+
* Malformed/incomplete entries are dropped here (logged) or skipped later by
102+
* {@link JwksDocuments}; a parse failure yields no retired keys (the active
103+
* key still publishes), never a crash.
104+
*/
105+
private static List<RetiredKey> parseRetiredKeys(String json) {
106+
if (json == null || json.isBlank()) {
107+
return List.of();
108+
}
109+
List<RetiredKey> out = new ArrayList<>();
110+
try {
111+
JsonNode arr = new ObjectMapper().readTree(json);
112+
if (!arr.isArray()) {
113+
LOG.warn("cycles.evidence.signing.retired-keys is not a JSON array; ignoring");
114+
return List.of();
115+
}
116+
for (JsonNode n : arr) {
117+
JsonNode nbfNode = n.path("nbf_ms");
118+
JsonNode expNode = n.path("exp_ms");
119+
// Both window bounds MUST be explicit integral epoch-ms that fit in a
120+
// long. A missing or non-integral nbf_ms is NOT coerced to 0 (epoch) —
121+
// that would silently widen the validity window; and isIntegralNumber()
122+
// alone accepts out-of-long-range integers that asLong() would wrap
123+
// (e.g. 2^63 → Long.MIN_VALUE), so require canConvertToLong() too.
124+
if (!nbfNode.isIntegralNumber() || !nbfNode.canConvertToLong()
125+
|| !expNode.isIntegralNumber() || !expNode.canConvertToLong()) {
126+
LOG.warn("retired key '{}' has a missing/non-integral/out-of-range nbf_ms or exp_ms; skipping",
127+
n.path("kid").asText(""));
128+
continue;
129+
}
130+
out.add(new RetiredKey(
131+
n.path("signer_did").asText(""),
132+
n.path("kid").asText(""),
133+
nbfNode.asLong(),
134+
expNode.asLong()));
135+
}
136+
} catch (Exception e) {
137+
LOG.warn("could not parse cycles.evidence.signing.retired-keys; ignoring: {}", e.getMessage());
138+
return List.of();
139+
}
140+
return out;
64141
}
65142

66143
@GetMapping(value = "/cycles-jwks.json", produces = MediaType.APPLICATION_JSON_VALUE)
67144
@Operation(operationId = "getEvidenceJwks",
68145
summary = "Fetch the signer's CyclesEvidence JWK Set (signer-key resolution)")
69146
public ResponseEntity<Map<String, Object>> getEvidenceJwks() {
70-
Map<String, Object> jwks = JwksDocuments.jwkSet(signerDid, kid, nbfMs)
147+
Map<String, Object> jwks = JwksDocuments.jwkSet(signerDid, kid, nbfMs, retiredKeys)
71148
.orElseThrow(() -> CyclesProtocolException.notFound("cycles-jwks.json"));
72149
// Short, public cache — the set changes only on key rotation, so unlike a
73150
// content-addressed envelope it MUST NOT be immutable.

cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java

Lines changed: 125 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package io.runcycles.protocol.api.evidence;
22

3+
import java.util.ArrayList;
34
import java.util.Base64;
5+
import java.util.HashMap;
46
import java.util.HexFormat;
57
import java.util.LinkedHashMap;
8+
import java.util.LinkedHashSet;
69
import java.util.List;
710
import java.util.Map;
811
import java.util.Optional;
12+
import java.util.Set;
913
import java.util.regex.Pattern;
1014

1115
/**
@@ -15,17 +19,19 @@
1519
* (cycles-evidence v0.2). Pure function over the configured signing identity;
1620
* no Spring, no I/O.
1721
*
18-
* <p>v0.1 scope: publishes the single currently-configured RAW-HEX
19-
* {@code signer_did} as one active Ed25519 OKP JWK. A {@code did:cycles}
20-
* {@code signer_did} carries no key bytes, so it cannot be published from
21-
* {@code signer_did} alone — that (and retired-key rotation history) is the
22-
* v0.2-store follow-up; until then this returns {@link Optional#empty()} and
23-
* the endpoint 404s, leaving consumers on the raw-hex + {@code expected_signer}
24-
* pinning path.
22+
* <p>Publishes the currently-configured RAW-HEX {@code signer_did} as the single
23+
* ACTIVE Ed25519 OKP JWK (open-ended window), PLUS any configured RETIRED keys —
24+
* each with a bounded {@code [cycles_nbf_ms, cycles_exp_ms)} window — so that
25+
* evidence signed before a key rotation still verifies against the key that was
26+
* valid at its {@code issued_at_ms}. Retaining retired keys is the load-bearing
27+
* rotation rule: a verifier selects the key whose window covers the envelope's
28+
* issuance time, never "the current key". A {@code did:cycles} {@code signer_did}
29+
* carries no key bytes, so the active key cannot be published from it alone;
30+
* the set is empty (endpoint 404s) until a raw-hex active key is configured.
2531
*
26-
* <p>Key bytes match what {@code EnvelopeSigner} signs with: the JWK {@code x}
27-
* is {@code base64url(hex-decode(signer_did))}, the same 32 raw public-key
28-
* bytes — so a verifier resolving this set authenticates the same signatures.
32+
* <p>Key bytes match what {@code EnvelopeSigner} signs with: a JWK {@code x} is
33+
* {@code base64url(hex-decode(<raw-hex pubkey>))}, the same 32 raw bytes — so a
34+
* verifier resolving this set authenticates the same signatures.
2935
*/
3036
public final class JwksDocuments {
3137

@@ -34,41 +40,132 @@ public final class JwksDocuments {
3440
private JwksDocuments() {
3541
}
3642

43+
/**
44+
* A previously-active signing key, retained in the published set so evidence
45+
* signed during its validity window still resolves after rotation.
46+
*
47+
* @param signerDid the retired key as a raw 64-hex Ed25519 public key
48+
* @param kid its stable key id (must be unique across the set)
49+
* @param nbfMs valid-from (epoch ms, inclusive)
50+
* @param expMs valid-until (epoch ms, EXCLUSIVE) — REQUIRED for a retired
51+
* key (a retired key has a closed window); a null exp is a
52+
* config error and the entry is skipped.
53+
*/
54+
public record RetiredKey(String signerDid, String kid, long nbfMs, Long expMs) {
55+
}
56+
3757
/** True when {@code signerDid} is a publishable raw 64-hex Ed25519 key. */
3858
public static boolean isRawHexKey(String signerDid) {
3959
return signerDid != null && RAW_HEX_32.matcher(signerDid.trim()).matches();
4060
}
4161

62+
/** Single active-key set (no rotation history). */
63+
public static Optional<Map<String, Object>> jwkSet(String signerDid, String kid, long nbfMs) {
64+
return jwkSet(signerDid, kid, nbfMs, List.of());
65+
}
66+
4267
/**
43-
* The signer's JWK Set, or empty when no raw-hex signing key is configured
44-
* (blank, or a {@code did:cycles} form that carries no key bytes).
68+
* The signer's JWK Set — the active key plus any retired keys — or empty
69+
* when no raw-hex active key is configured. Invalid retired entries are
70+
* skipped defensively (a bad history entry never breaks publication of the
71+
* active key): malformed hex; a missing {@code expMs} (a retired key needs a
72+
* closed window); an empty/inverted window ({@code expMs <= nbfMs}, since
73+
* {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as an earlier
74+
* retired key with an OVERLAPPING window — raw-hex selection by key bytes
75+
* plus {@code issued_at_ms} would be ambiguous, though disjoint windows for
76+
* a reused key are fine; or a {@code kid} colliding with the active key or an
77+
* earlier retired key (a duplicate {@code kid} is never emitted — set-wide
78+
* kid uniqueness is required).
4579
*
46-
* @param signerDid the configured {@code cycles.evidence.signing.signer-did}
47-
* @param kid configured key id, or blank to derive a stable default
48-
* @param nbfMs {@code cycles_nbf_ms} validity-from (epoch ms, inclusive)
80+
* @param signerDid the active key ({@code cycles.evidence.signing.signer-did})
81+
* @param kid active key id, or blank to derive a stable default
82+
* @param nbfMs active {@code cycles_nbf_ms} (epoch ms, inclusive); if it
83+
* was left below the latest retired key's {@code exp_ms} the
84+
* published active window is advanced to that boundary, so the
85+
* active key is never valid for pre-rotation {@code issued_at_ms}
86+
* @param retired retired keys to retain in the set (may be empty/null)
4987
*/
50-
public static Optional<Map<String, Object>> jwkSet(String signerDid, String kid, long nbfMs) {
88+
public static Optional<Map<String, Object>> jwkSet(
89+
String signerDid, String kid, long nbfMs, List<RetiredKey> retired) {
5190
if (!isRawHexKey(signerDid)) {
5291
return Optional.empty();
5392
}
54-
String did = signerDid.trim();
55-
byte[] publicKey = HexFormat.of().parseHex(did);
56-
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey);
93+
String activeDid = signerDid.trim();
94+
String activeKid = (kid == null || kid.isBlank()) ? defaultKid(activeDid) : kid.trim();
95+
96+
// Safety floor (fail-safe, not just a warning): the active key MUST NOT be
97+
// published as valid before the latest retired key's window ends, or the
98+
// current key could sign a backdated envelope (issued_at_ms before the
99+
// rotation) that still resolves as authentic. If the configured nbf-ms was
100+
// left below that boundary, advance the published active window up to it.
101+
// Floor on EVERY declared bounded retired window — even one whose key
102+
// material is malformed (so it won't be published): a typo in rotation
103+
// history must not reopen the pre-rotation backdating hole on the active key.
104+
long latestRetiredExp = (retired == null ? List.<RetiredKey>of() : retired).stream()
105+
.filter(r -> r != null && r.expMs() != null && r.expMs() > r.nbfMs())
106+
.mapToLong(RetiredKey::expMs)
107+
.max()
108+
.orElse(Long.MIN_VALUE);
109+
long activeNbf = Math.max(nbfMs, latestRetiredExp);
110+
111+
List<Map<String, Object>> keys = new ArrayList<>();
112+
Set<String> kids = new LinkedHashSet<>();
113+
// Emitted [nbf, exp) windows per key material (lowercased hex), so the same
114+
// key republished with an OVERLAPPING window is never emitted twice — raw-hex
115+
// selection is key-bytes + issued_at_ms, which would otherwise be ambiguous.
116+
// The active key needs no entry: the nbf clamp above puts its window at/after
117+
// every retired exp, so it is always disjoint from a same-material retired key.
118+
Map<String, List<long[]>> windowsByMaterial = new HashMap<>();
119+
keys.add(buildJwk(activeDid, activeKid, activeNbf, null, "active"));
120+
kids.add(activeKid);
57121

122+
if (retired != null) {
123+
for (RetiredKey r : retired) {
124+
if (r == null || !isRawHexKey(r.signerDid()) || r.expMs() == null) {
125+
continue; // malformed hex or no closed window — skip
126+
}
127+
long rNbf = r.nbfMs();
128+
long rExp = r.expMs();
129+
if (rExp <= rNbf) {
130+
continue; // empty or inverted window (exp is EXCLUSIVE) — skip
131+
}
132+
String rDid = r.signerDid().trim();
133+
String material = rDid.toLowerCase();
134+
List<long[]> seen = windowsByMaterial.get(material);
135+
if (seen != null && seen.stream().anyMatch(w -> rNbf < w[1] && w[0] < rExp)) {
136+
continue; // same key material with an overlapping window (active or retired) — ambiguous, skip
137+
}
138+
String rKid = (r.kid() == null || r.kid().isBlank()) ? defaultKid(rDid) : r.kid().trim();
139+
if (!kids.add(rKid)) {
140+
continue; // duplicate kid — never emit (set-wide uniqueness)
141+
}
142+
keys.add(buildJwk(rDid, rKid, rNbf, rExp, "retired"));
143+
windowsByMaterial.computeIfAbsent(material, k -> new ArrayList<>()).add(new long[]{rNbf, rExp});
144+
}
145+
}
146+
147+
Map<String, Object> jwks = new LinkedHashMap<>();
148+
jwks.put("keys", keys);
149+
return Optional.of(jwks);
150+
}
151+
152+
/** One Ed25519 OKP JWK. {@code expMs == null} ⇒ active (open-ended, exp
153+
* omitted); otherwise a bounded window with {@code status: retired}. */
154+
private static Map<String, Object> buildJwk(String didHex, String kid, long nbfMs, Long expMs, String status) {
155+
byte[] publicKey = HexFormat.of().parseHex(didHex);
58156
Map<String, Object> jwk = new LinkedHashMap<>();
59157
jwk.put("kty", "OKP");
60158
jwk.put("crv", "Ed25519");
61159
jwk.put("alg", "EdDSA");
62-
jwk.put("x", x);
63-
jwk.put("kid", (kid == null || kid.isBlank()) ? defaultKid(did) : kid.trim());
160+
jwk.put("x", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey));
161+
jwk.put("kid", kid);
64162
jwk.put("cycles_nbf_ms", nbfMs);
65-
// cycles_exp_ms omitted ⇒ active (open-ended); `status` is advisory only —
66-
// selection is by validity window, never by status.
67-
jwk.put("status", "active");
68-
69-
Map<String, Object> jwks = new LinkedHashMap<>();
70-
jwks.put("keys", List.of(jwk));
71-
return Optional.of(jwks);
163+
if (expMs != null) {
164+
jwk.put("cycles_exp_ms", expMs);
165+
}
166+
// `status` is advisory only — selection is by validity window, never by status.
167+
jwk.put("status", status);
168+
return jwk;
72169
}
73170

74171
/** Stable default key id when none is configured: the first 16 hex chars of

cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,15 @@ cycles.evidence.signing.signer-did=${EVIDENCE_SIGNING_SIGNER_DID:}
8888
# (epoch ms, inclusive; default 0 = valid since epoch, correct for a never-rotated key).
8989
cycles.evidence.signing.kid=${EVIDENCE_SIGNING_KID:}
9090
cycles.evidence.signing.nbf-ms=${EVIDENCE_SIGNING_NBF_MS:0}
91+
# Retired signing keys retained in the published set (rotation history) so
92+
# evidence signed before a rotation still verifies against the key valid at its
93+
# issued_at_ms. JSON array of {"signer_did":<raw 64-hex>,"kid":...,"nbf_ms":...,
94+
# "exp_ms":...}; exp_ms (EXCLUSIVE end of the key's window) is required per entry.
95+
# On rotation: set the new active key as signer-did, set nbf-ms (above) to the
96+
# rotation time, and append the old key here with exp_ms = that same rotation
97+
# time, so the windows meet without overlapping. If nbf-ms is left below the
98+
# latest retired exp_ms, the published active cycles_nbf_ms is warned on and
99+
# fail-safe clamped up to that boundary (so the active key cannot resolve for
100+
# pre-rotation issued_at_ms) — but set nbf-ms explicitly rather than relying on
101+
# the clamp. Empty = single active key (never rotated).
102+
cycles.evidence.signing.retired-keys=${EVIDENCE_SIGNING_RETIRED_KEYS:}

0 commit comments

Comments
 (0)