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
17 changes: 16 additions & 1 deletion AUDIT.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package io.runcycles.protocol.api.controller;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.runcycles.protocol.api.evidence.JwksDocuments;
import io.runcycles.protocol.api.evidence.JwksDocuments.RetiredKey;
import io.runcycles.protocol.data.exception.CyclesProtocolException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
Expand All @@ -14,6 +17,8 @@
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

Expand Down Expand Up @@ -48,26 +53,98 @@ public class JwksController {
private final String signerDid;
private final String kid;
private final long nbfMs;
private final List<RetiredKey> retiredKeys;

public JwksController(
@Value("${cycles.evidence.signing.signer-did:}") String signerDid,
@Value("${cycles.evidence.signing.kid:}") String kid,
@Value("${cycles.evidence.signing.nbf-ms:0}") long nbfMs) {
@Value("${cycles.evidence.signing.nbf-ms:0}") long nbfMs,
@Value("${cycles.evidence.signing.retired-keys:}") String retiredKeysJson) {
this.signerDid = signerDid == null ? "" : signerDid.trim();
this.kid = kid == null ? "" : kid.trim();
this.nbfMs = nbfMs;
this.retiredKeys = parseRetiredKeys(retiredKeysJson);
if (!this.signerDid.isBlank() && !JwksDocuments.isRawHexKey(this.signerDid)) {
LOG.info("evidence signer_did is not a raw 64-hex key (did:cycles or other); JWKS "
+ "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());
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);
}
}
}

/**
* 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
* key's window was not advanced to the rotation time, so the active key is
* still published as authoritative for pre-rotation {@code issued_at_ms}.
*/
static boolean activeKeyWindowPredatesRetirement(long activeNbfMs, List<RetiredKey> retired) {
long latestRetiredExp = retired.stream()
.filter(r -> r != null && r.expMs() != null && r.expMs() > r.nbfMs())
.mapToLong(RetiredKey::expMs)
.max()
.orElse(Long.MIN_VALUE);
return activeNbfMs < latestRetiredExp;
}

/**
* Parse {@code cycles.evidence.signing.retired-keys} — a JSON array of
* {@code {"signer_did","kid","nbf_ms","exp_ms"}} — into retired-key records.
* Malformed/incomplete entries are dropped here (logged) or skipped later by
* {@link JwksDocuments}; a parse failure yields no retired keys (the active
* key still publishes), never a crash.
*/
private static List<RetiredKey> parseRetiredKeys(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
List<RetiredKey> out = new ArrayList<>();
try {
JsonNode arr = new ObjectMapper().readTree(json);
if (!arr.isArray()) {
LOG.warn("cycles.evidence.signing.retired-keys is not a JSON array; ignoring");
return List.of();
}
for (JsonNode n : arr) {
JsonNode nbfNode = n.path("nbf_ms");
JsonNode expNode = n.path("exp_ms");
// Both window bounds MUST be explicit integral epoch-ms that fit in a
// long. A missing or non-integral nbf_ms is NOT coerced to 0 (epoch) —
// that would silently widen the validity window; and isIntegralNumber()
// alone accepts out-of-long-range integers that asLong() would wrap
// (e.g. 2^63 → Long.MIN_VALUE), so require canConvertToLong() too.
if (!nbfNode.isIntegralNumber() || !nbfNode.canConvertToLong()
|| !expNode.isIntegralNumber() || !expNode.canConvertToLong()) {
LOG.warn("retired key '{}' has a missing/non-integral/out-of-range nbf_ms or exp_ms; skipping",
n.path("kid").asText(""));
continue;
}
out.add(new RetiredKey(
n.path("signer_did").asText(""),
n.path("kid").asText(""),
nbfNode.asLong(),
expNode.asLong()));
}
} catch (Exception e) {
LOG.warn("could not parse cycles.evidence.signing.retired-keys; ignoring: {}", e.getMessage());
return List.of();
}
return out;
}

@GetMapping(value = "/cycles-jwks.json", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(operationId = "getEvidenceJwks",
summary = "Fetch the signer's CyclesEvidence JWK Set (signer-key resolution)")
public ResponseEntity<Map<String, Object>> getEvidenceJwks() {
Map<String, Object> jwks = JwksDocuments.jwkSet(signerDid, kid, nbfMs)
Map<String, Object> jwks = JwksDocuments.jwkSet(signerDid, kid, nbfMs, retiredKeys)
.orElseThrow(() -> CyclesProtocolException.notFound("cycles-jwks.json"));
// Short, public cache — the set changes only on key rotation, so unlike a
// content-addressed envelope it MUST NOT be immutable.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package io.runcycles.protocol.api.evidence;

import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;

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

Expand All @@ -34,41 +40,132 @@ public final class JwksDocuments {
private JwksDocuments() {
}

/**
* A previously-active signing key, retained in the published set so evidence
* signed during its validity window still resolves after rotation.
*
* @param signerDid the retired key as a raw 64-hex Ed25519 public key
* @param kid its stable key id (must be unique across the set)
* @param nbfMs valid-from (epoch ms, inclusive)
* @param expMs valid-until (epoch ms, EXCLUSIVE) — REQUIRED for a retired
* key (a retired key has a closed window); a null exp is a
* config error and the entry is skipped.
*/
public record RetiredKey(String signerDid, String kid, long nbfMs, Long expMs) {
}

/** True when {@code signerDid} is a publishable raw 64-hex Ed25519 key. */
public static boolean isRawHexKey(String signerDid) {
return signerDid != null && RAW_HEX_32.matcher(signerDid.trim()).matches();
}

/** Single active-key set (no rotation history). */
public static Optional<Map<String, Object>> jwkSet(String signerDid, String kid, long nbfMs) {
return jwkSet(signerDid, kid, nbfMs, List.of());
}

/**
* The signer's JWK Set, or empty when no raw-hex signing key is configured
* (blank, or a {@code did:cycles} form that carries no key bytes).
* The signer's JWK Set — the active key plus any retired keys — or empty
* when no raw-hex active key is configured. Invalid retired entries are
* skipped defensively (a bad history entry never breaks publication of the
* active key): malformed hex; a missing {@code expMs} (a retired key needs a
* closed window); an empty/inverted window ({@code expMs <= nbfMs}, since
* {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as an earlier
* retired key with an OVERLAPPING window — raw-hex selection by key bytes
* plus {@code issued_at_ms} would be ambiguous, though disjoint windows for
* a reused key are fine; or a {@code kid} colliding with the active key or an
* earlier retired key (a duplicate {@code kid} is never emitted — set-wide
* kid uniqueness is required).
*
* @param signerDid the configured {@code cycles.evidence.signing.signer-did}
* @param kid configured key id, or blank to derive a stable default
* @param nbfMs {@code cycles_nbf_ms} validity-from (epoch ms, inclusive)
* @param signerDid the active key ({@code cycles.evidence.signing.signer-did})
* @param kid active key id, or blank to derive a stable default
* @param nbfMs active {@code cycles_nbf_ms} (epoch ms, inclusive); if it
* was left below the latest retired key's {@code exp_ms} the
* published active window is advanced to that boundary, so the
* active key is never valid for pre-rotation {@code issued_at_ms}
* @param retired retired keys to retain in the set (may be empty/null)
*/
public static Optional<Map<String, Object>> jwkSet(String signerDid, String kid, long nbfMs) {
public static Optional<Map<String, Object>> jwkSet(
String signerDid, String kid, long nbfMs, List<RetiredKey> retired) {
if (!isRawHexKey(signerDid)) {
return Optional.empty();
}
String did = signerDid.trim();
byte[] publicKey = HexFormat.of().parseHex(did);
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey);
String activeDid = signerDid.trim();
String activeKid = (kid == null || kid.isBlank()) ? defaultKid(activeDid) : kid.trim();

// Safety floor (fail-safe, not just a warning): the active key MUST NOT be
// published as valid before the latest retired key's window ends, or the
// current key could sign a backdated envelope (issued_at_ms before the
// rotation) that still resolves as authentic. If the configured nbf-ms was
// left below that boundary, advance the published active window up to it.
// Floor on EVERY declared bounded retired window — even one whose key
// material is malformed (so it won't be published): a typo in rotation
// history must not reopen the pre-rotation backdating hole on the active key.
long latestRetiredExp = (retired == null ? List.<RetiredKey>of() : retired).stream()
.filter(r -> r != null && r.expMs() != null && r.expMs() > r.nbfMs())
.mapToLong(RetiredKey::expMs)
.max()
.orElse(Long.MIN_VALUE);
long activeNbf = Math.max(nbfMs, latestRetiredExp);

List<Map<String, Object>> keys = new ArrayList<>();
Set<String> kids = new LinkedHashSet<>();
// Emitted [nbf, exp) windows per key material (lowercased hex), so the same
// key republished with an OVERLAPPING window is never emitted twice — raw-hex
// selection is key-bytes + issued_at_ms, which would otherwise be ambiguous.
// The active key needs no entry: the nbf clamp above puts its window at/after
// every retired exp, so it is always disjoint from a same-material retired key.
Map<String, List<long[]>> windowsByMaterial = new HashMap<>();
keys.add(buildJwk(activeDid, activeKid, activeNbf, null, "active"));
kids.add(activeKid);

if (retired != null) {
for (RetiredKey r : retired) {
if (r == null || !isRawHexKey(r.signerDid()) || r.expMs() == null) {
continue; // malformed hex or no closed window — skip
}
long rNbf = r.nbfMs();
long rExp = r.expMs();
if (rExp <= rNbf) {
continue; // empty or inverted window (exp is EXCLUSIVE) — skip
}
String rDid = r.signerDid().trim();
String material = rDid.toLowerCase();
List<long[]> seen = windowsByMaterial.get(material);
if (seen != null && seen.stream().anyMatch(w -> rNbf < w[1] && w[0] < rExp)) {
continue; // same key material with an overlapping window (active or retired) — ambiguous, skip
}
String rKid = (r.kid() == null || r.kid().isBlank()) ? defaultKid(rDid) : r.kid().trim();
if (!kids.add(rKid)) {
continue; // duplicate kid — never emit (set-wide uniqueness)
}
keys.add(buildJwk(rDid, rKid, rNbf, rExp, "retired"));
windowsByMaterial.computeIfAbsent(material, k -> new ArrayList<>()).add(new long[]{rNbf, rExp});
}
}

Map<String, Object> jwks = new LinkedHashMap<>();
jwks.put("keys", keys);
return Optional.of(jwks);
}

/** One Ed25519 OKP JWK. {@code expMs == null} ⇒ active (open-ended, exp
* omitted); otherwise a bounded window with {@code status: retired}. */
private static Map<String, Object> buildJwk(String didHex, String kid, long nbfMs, Long expMs, String status) {
byte[] publicKey = HexFormat.of().parseHex(didHex);
Map<String, Object> jwk = new LinkedHashMap<>();
jwk.put("kty", "OKP");
jwk.put("crv", "Ed25519");
jwk.put("alg", "EdDSA");
jwk.put("x", x);
jwk.put("kid", (kid == null || kid.isBlank()) ? defaultKid(did) : kid.trim());
jwk.put("x", Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey));
jwk.put("kid", kid);
jwk.put("cycles_nbf_ms", nbfMs);
// cycles_exp_ms omitted ⇒ active (open-ended); `status` is advisory only —
// selection is by validity window, never by status.
jwk.put("status", "active");

Map<String, Object> jwks = new LinkedHashMap<>();
jwks.put("keys", List.of(jwk));
return Optional.of(jwks);
if (expMs != null) {
jwk.put("cycles_exp_ms", expMs);
}
// `status` is advisory only — selection is by validity window, never by status.
jwk.put("status", status);
return jwk;
}

/** Stable default key id when none is configured: the first 16 hex chars of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,15 @@ cycles.evidence.signing.signer-did=${EVIDENCE_SIGNING_SIGNER_DID:}
# (epoch ms, inclusive; default 0 = valid since epoch, correct for a never-rotated key).
cycles.evidence.signing.kid=${EVIDENCE_SIGNING_KID:}
cycles.evidence.signing.nbf-ms=${EVIDENCE_SIGNING_NBF_MS:0}
# Retired signing keys retained in the published set (rotation history) so
# evidence signed before a rotation still verifies against the key valid at its
# issued_at_ms. JSON array of {"signer_did":<raw 64-hex>,"kid":...,"nbf_ms":...,
# "exp_ms":...}; exp_ms (EXCLUSIVE end of the key's window) is required per entry.
# On rotation: set the new active key as signer-did, set nbf-ms (above) to the
# rotation time, and append the old key here with exp_ms = that same rotation
# time, so the windows meet without overlapping. If nbf-ms is left below the
# latest retired exp_ms, the published active cycles_nbf_ms is warned on and
# fail-safe clamped up to that boundary (so the active key cannot resolve for
# pre-rotation issued_at_ms) — but set nbf-ms explicitly rather than relying on
# the clamp. Empty = single active key (never rotated).
cycles.evidence.signing.retired-keys=${EVIDENCE_SIGNING_RETIRED_KEYS:}
Loading
Loading