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
2 changes: 1 addition & 1 deletion AUDIT.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ public class SecurityConfig {
// CyclesEvidence retrieval is public by design (cycles-protocol-v0
// getEvidence, security: []): the evidence_id is an unguessable
// content-hash capability and the envelope is content-addressed + signed.
"/v1/evidence/**"
"/v1/evidence/**",
// The signer JWK Set is public (cycles-protocol-v0 getEvidenceJwks,
// security: []): public keys only — the private signing key is never
// served — and the set is itself the trust anchor consumers resolve.
// API-base-relative (under /v1), per the spec's authority-scope rule.
"/v1/.well-known/**"
};
@Bean
SecurityFilterChain securityFilterChain(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package io.runcycles.protocol.api.controller;

import io.runcycles.protocol.api.evidence.JwksDocuments;
import io.runcycles.protocol.data.exception.CyclesProtocolException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

/**
* Publishes the signer's CyclesEvidence JWK Set (cycles-protocol-v0
* {@code getEvidenceJwks}) — the publication half of the additive
* signer-key-resolution layer (cycles-evidence v0.2).
*
* <p>No auth — see {@code SecurityConfig.PUBLIC_PATHS} and the spec's
* {@code getEvidenceJwks} description: a JWK Set is public keys only (the
* private signing key is never served), and the set is itself the trust anchor
* consumers resolve, so it must be reachable without credentials.
*
* <p>Located API-base-relative at {@code /v1/.well-known/cycles-jwks.json}
* (the spec path is {@code {server_id}/.well-known/cycles-jwks.json} and
* {@code server_id} already carries {@code /v1}) — deliberately NOT
* origin-rooted, so key authority stays anchored to the base the
* {@code did:cycles} hash commits to.
*
* <p>Reads the public signing identity ({@code signer-did}) directly via
* {@code @Value} — the same shared properties the worker and {@code EvidenceEmitter}
* use — plus the JWK {@code kid} and {@code cycles_nbf_ms}. Holding no injected
* collaborator keeps it loadable in any context without extra wiring.
*/
@RestController
@RequestMapping("/v1/.well-known")
@Tag(name = "Evidence")
public class JwksController {

private static final Logger LOG = LoggerFactory.getLogger(JwksController.class);

private final String signerDid;
private final String kid;
private final long nbfMs;

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) {
this.signerDid = signerDid == null ? "" : signerDid.trim();
this.kid = kid == null ? "" : kid.trim();
this.nbfMs = nbfMs;
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");
}
}

@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)
.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.
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.cacheControl(CacheControl.maxAge(5, TimeUnit.MINUTES).cachePublic())
.body(jwks);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package io.runcycles.protocol.api.evidence;

import java.util.Base64;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;

/**
* Builds the {@code CyclesEvidenceJwks} document served by
* {@code getEvidenceJwks} ({@code GET /v1/.well-known/cycles-jwks.json}) — the
* publication half of the additive signer-key-resolution layer
* (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>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.
*/
public final class JwksDocuments {

private static final Pattern RAW_HEX_32 = Pattern.compile("[0-9a-fA-F]{64}");

private JwksDocuments() {
}

/** 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();
}

/**
* 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).
*
* @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)
*/
public static Optional<Map<String, Object>> jwkSet(String signerDid, String kid, long nbfMs) {
if (!isRawHexKey(signerDid)) {
return Optional.empty();
}
String did = signerDid.trim();
byte[] publicKey = HexFormat.of().parseHex(did);
String x = Base64.getUrlEncoder().withoutPadding().encodeToString(publicKey);

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("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);
}

/** Stable default key id when none is configured: the first 16 hex chars of
* the (lowercased) public key — deterministic and stable per key. */
private static String defaultKid(String didHex) {
return didHex.substring(0, 16).toLowerCase();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,9 @@ cycles.evidence.store.key-prefix=${EVIDENCE_STORE_KEY_PREFIX:evidence:envelope:}
# no evidence_id is computed or returned.
cycles.evidence.server-id=${EVIDENCE_SERVER_ID:}
cycles.evidence.signing.signer-did=${EVIDENCE_SIGNING_SIGNER_DID:}
# Signer JWK Set publication (getEvidenceJwks, GET /v1/.well-known/cycles-jwks.json).
# Served only when signer-did is a raw 64-hex key. kid: the JWK key id (defaults to
# the first 16 hex chars of the public key); nbf-ms: cycles_nbf_ms validity-from
# (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}
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ void publicPathsShouldContainExpectedEntries() {
"/.well-known/**",
"/actuator/health",
"/actuator/prometheus",
"/v1/evidence/**"
"/v1/evidence/**",
"/v1/.well-known/**"
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package io.runcycles.protocol.api.controller;

import io.runcycles.protocol.api.auth.ApiKeyAuthenticationFilter;
import io.runcycles.protocol.api.contract.ContractValidationConfig;
import io.runcycles.protocol.api.exception.GlobalExceptionHandler;
import io.runcycles.protocol.data.exception.CyclesProtocolException;
import io.runcycles.protocol.data.service.ReservationExpiryService;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.Test;
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;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.FilterType;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(controllers = JwksController.class,
excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE,
classes = {ApiKeyAuthenticationFilter.class, ReservationExpiryService.class}))
@AutoConfigureMockMvc(addFilters = false)
@Import({GlobalExceptionHandler.class, ContractValidationConfig.class})
@TestPropertySource(properties = {
"cycles.evidence.signing.signer-did=" + JwksControllerTest.SIGNER_DID,
"cycles.evidence.signing.kid=2026-06",
"cycles.evidence.signing.nbf-ms=1810000000000"
})
class JwksControllerTest {

static final String SIGNER_DID =
"207a067892821e25d770f1fba0c47c11ff4b813e54162ece9eb839e076231ab6";

@Autowired private MockMvc mockMvc;
// This project's @WebMvcTest loads all controllers (ContractValidationConfig),
// so the other controllers' collaborators must be present as mocks too.
@MockitoBean private io.runcycles.protocol.data.repository.EvidenceStoreReader store;
@MockitoBean private io.runcycles.protocol.data.repository.RedisReservationRepository reservationRepository;
@MockitoBean private io.runcycles.protocol.data.service.EventEmitterService eventEmitter;
@MockitoBean private io.runcycles.protocol.data.repository.AuditRepository auditRepository;
@MockitoBean private io.runcycles.protocol.data.metrics.CyclesMetrics cyclesMetrics;
@MockitoBean private io.runcycles.protocol.data.service.EvidenceEmitter evidenceEmitter;

@Test
void returns200WithJwkSetAndShortPublicCache() throws Exception {
mockMvc.perform(get("/v1/.well-known/cycles-jwks.json"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
// a key set MUST be cacheable but NOT immutable (it rotates)
.andExpect(header().string("Cache-Control", Matchers.containsString("max-age")))
.andExpect(header().string("Cache-Control", Matchers.containsString("public")))
.andExpect(header().string("Cache-Control", Matchers.not(Matchers.containsString("immutable"))))
.andExpect(jsonPath("$.keys[0].kty").value("OKP"))
.andExpect(jsonPath("$.keys[0].crv").value("Ed25519"))
.andExpect(jsonPath("$.keys[0].alg").value("EdDSA"))
.andExpect(jsonPath("$.keys[0].x").isNotEmpty())
.andExpect(jsonPath("$.keys[0].kid").value("2026-06"))
.andExpect(jsonPath("$.keys[0].cycles_nbf_ms").value(1810000000000L))
.andExpect(jsonPath("$.keys[0].status").value("active"));
}

@Test
void unconfiguredSigner_throwsNotFound() {
// Direct construction (no key configured) — the endpoint 404s via the
// standard NOT_FOUND ErrorResponse path; a server not doing signer-key
// resolution publishes nothing.
JwksController controller = new JwksController("", "", 0L);
assertThatThrownBy(controller::getEvidenceJwks)
.isInstanceOf(CyclesProtocolException.class);
}

@Test
void didCyclesSigner_throwsNotFound() {
JwksController controller = new JwksController(
"did:cycles:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08#k", "", 0L);
assertThatThrownBy(controller::getEvidenceJwks)
.isInstanceOf(CyclesProtocolException.class);
}

@Test
void configuredSigner_returnsBodyDirectly() {
JwksController controller = new JwksController(SIGNER_DID, "k1", 5L);
assertThat(controller.getEvidenceJwks().getStatusCode().value()).isEqualTo(200);
assertThat(controller.getEvidenceJwks().getBody()).containsKey("keys");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package io.runcycles.protocol.api.evidence;

import org.junit.jupiter.api.Test;

import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

class JwksDocumentsTest {

// A valid raw 64-hex Ed25519 public key (lowercase).
private static final String RAW_HEX =
"207a067892821e25d770f1fba0c47c11ff4b813e54162ece9eb839e076231ab6";

@Test
void rawHexKey_buildsOneActiveEd25519Jwk() {
Optional<Map<String, Object>> set = JwksDocuments.jwkSet(RAW_HEX, "", 0L);

assertThat(set).isPresent();
@SuppressWarnings("unchecked")
List<Map<String, Object>> keys = (List<Map<String, Object>>) set.get().get("keys");
assertThat(keys).hasSize(1);

Map<String, Object> jwk = keys.get(0);
assertThat(jwk).containsEntry("kty", "OKP")
.containsEntry("crv", "Ed25519")
.containsEntry("alg", "EdDSA")
.containsEntry("cycles_nbf_ms", 0L)
.containsEntry("status", "active");
// active ⇒ cycles_exp_ms omitted entirely.
assertThat(jwk).doesNotContainKey("cycles_exp_ms");
}

@Test
void jwkX_isBase64UrlOfTheRawSignerDidBytes() {
Map<String, Object> jwk = firstKey(JwksDocuments.jwkSet(RAW_HEX, "", 0L));

String x = (String) jwk.get("x");
byte[] decoded = Base64.getUrlDecoder().decode(x);
// x decodes back to exactly the 32 raw bytes hex-decoded from signer_did.
assertThat(decoded).isEqualTo(HexFormat.of().parseHex(RAW_HEX));
assertThat(x).doesNotContain("=").doesNotContain("+").doesNotContain("/");
}

@Test
void defaultKid_isFirst16HexCharsLowercased() {
Map<String, Object> jwk = firstKey(JwksDocuments.jwkSet(RAW_HEX.toUpperCase(), " ", 0L));
assertThat(jwk).containsEntry("kid", RAW_HEX.substring(0, 16));
}

@Test
void configuredKid_overridesTheDefault() {
Map<String, Object> jwk = firstKey(JwksDocuments.jwkSet(RAW_HEX, "2026-06", 0L));
assertThat(jwk).containsEntry("kid", "2026-06");
}

@Test
void nbfMs_isCarriedThrough() {
Map<String, Object> jwk = firstKey(JwksDocuments.jwkSet(RAW_HEX, "k", 1810000000000L));
assertThat(jwk).containsEntry("cycles_nbf_ms", 1810000000000L);
}

@Test
void blankOrNullSignerDid_isEmpty() {
assertThat(JwksDocuments.jwkSet("", "", 0L)).isEmpty();
assertThat(JwksDocuments.jwkSet(" ", "", 0L)).isEmpty();
assertThat(JwksDocuments.jwkSet(null, "", 0L)).isEmpty();
}

@Test
void didCyclesForm_isEmpty_becauseItCarriesNoKeyBytes() {
String didCycles = "did:cycles:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08#2026-06";
assertThat(JwksDocuments.jwkSet(didCycles, "", 0L)).isEmpty();
assertThat(JwksDocuments.isRawHexKey(didCycles)).isFalse();
}

@Test
void malformedHex_isEmpty() {
assertThat(JwksDocuments.jwkSet("xyz", "", 0L)).isEmpty(); // non-hex
assertThat(JwksDocuments.jwkSet(RAW_HEX.substring(1), "", 0L)).isEmpty(); // 63 chars
assertThat(JwksDocuments.jwkSet(RAW_HEX + "ab", "", 0L)).isEmpty(); // 66 chars
}

@Test
void isRawHexKey_acceptsTrimmedAndMixedCase() {
assertThat(JwksDocuments.isRawHexKey(" " + RAW_HEX.toUpperCase() + " ")).isTrue();
}

private static Map<String, Object> firstKey(Optional<Map<String, Object>> set) {
assertThat(set).isPresent();
@SuppressWarnings("unchecked")
List<Map<String, Object>> keys = (List<Map<String, Object>>) set.get().get("keys");
return keys.get(0);
}
}
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.31</revision>
<revision>0.1.25.32</revision>
<java.version>21</java.version>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
Expand Down