From 7cf8fcbcfc17848acc6b8f323e944c087bb87fc9 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Mon, 15 Jun 2026 08:57:23 -0400 Subject: [PATCH] feat(evidence): implement getEvidenceJwks JWK Set endpoint (v0.1.25.32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publication half of the v0.2 signer-key-resolution layer (design on #103 / aeoess#43; contract added to cycles-protocol-v0.yaml v0.1.25.6 in runcycles/cycles-protocol#113). - JwksController: public GET /v1/.well-known/cycles-jwks.json. Reads the shared cycles.evidence.signing.signer-did + new kid / nbf-ms via @Value (no injected bean, so it loads in every @WebMvcTest without extra wiring). Cache-Control: public, max-age=300 — NOT immutable (a key set rotates). 404 via the standard NOT_FOUND ErrorResponse when no raw-hex key is configured. - JwksDocuments: pure builder. Raw 64-hex signer-did -> one active Ed25519 OKP JWK {kty,crv,alg,x=base64url(hex-decode(signer_did)),kid (default first-16-hex), cycles_nbf_ms (default 0), status:active}; cycles_exp_ms omitted = open-ended. The x is the SAME 32 bytes EnvelopeSigner signs with, so a verifier resolving the set authenticates the emitted signatures. did:cycles / blank / malformed -> empty (404): a did:cycles signer_did carries no key bytes (that + retired-key rotation history are the v0.2-store follow-up). - SecurityConfig: /v1/.well-known/** public (public keys only; private key never served; API-base-relative per the authority-scope rule). SecurityConfigTest updated for the new entry. - application.properties: cycles.evidence.signing.kid / nbf-ms. pom .31 -> .32. Tests: JwksDocumentsTest (10), JwksControllerTest (4, contract-validated against #113's spec). Both new classes 100% line-covered; full mvn verify 906 green; jacoco 95% gate met. codex review: no findings. No change to existing endpoints. --- AUDIT.md | 2 +- .../protocol/api/auth/SecurityConfig.java | 7 +- .../api/controller/JwksController.java | 79 +++++++++++++++ .../protocol/api/evidence/JwksDocuments.java | 79 +++++++++++++++ .../src/main/resources/application.properties | 6 ++ .../protocol/api/auth/SecurityConfigTest.java | 3 +- .../api/controller/JwksControllerTest.java | 96 ++++++++++++++++++ .../api/evidence/JwksDocumentsTest.java | 99 +++++++++++++++++++ cycles-protocol-service/pom.xml | 2 +- 9 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java create mode 100644 cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java create mode 100644 cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java create mode 100644 cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java diff --git a/AUDIT.md b/AUDIT.md index 13cca2e..7211e91 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -1,6 +1,6 @@ # Cycles Protocol v0.1.25 — Server Implementation Audit -**Date:** 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent REPLAY. `ReservationController.create` emitted the `RESERVATION_DENIED` deny event and `emitBalanceEvents` (budget-state transitions) unconditionally, while `decide` (`DecisionController`) and `commit`/`release` (`ReservationController`) already skip side-effect emission when `response.isIdempotentReplay()`. A replay (a cached dry-run DENY, or a reserve body-cache replay carrying `idempotentReplay=true`) therefore double-counted those events — the original create already emitted them. FIX: wrap create's whole emission block in `if (!response.isIdempotentReplay())`, matching the other lifecycle endpoints. Test: `ReservationControllerTest` +1 (`shouldNotReemitEventsOnIdempotentReplay` — an idempotent DENY replay re-emits neither `RESERVATION_DENIED` nor balance events). No wire/spec change. NOTE: numbered .31 because v0.1.25.30 is held by the open byte-parity PR #187; merge #187 first.), 2026-06-14 (v0.1.25.30 — byte-parity hardening: extend `EvidenceIdComputerTest`'s golden-fixture coverage from the 3 reserve fixtures to the FULL 13-fixture set (all five artifact types — decide/reserve/commit/release/error), the SAME `cycles-evidence-fixtures` the event-tier `CyclesEvidenceCanonicalizer` verifies against and the APS verifier was generated with (the 10 added fixtures are byte-identical copies from cycles-server-events). This proves cycles-server's SYNCHRONOUS `evidence_id` computation reproduces the canonical id for EVERY artifact SHAPE — `reservation_id` hoisting on commit/release, `endpoint`+`http_status` on error — i.e. the JCS+sha256 recipe is byte-correct across the whole envelope domain, so whenever cycles-server emits a given shape its id matches the worker and the cross-check never dead-letters on drift. (Recipe parity over the canonical/verifier fixture set, NOT the set of shapes cycles-server emits: its emission policy is the budget/lifecycle-denial subset per `GlobalExceptionHandler.EVIDENCE_DENIAL_CODES`, so the error envelope it actually emits is the budget-exceeded kind — `11-reserve-live-budget-exceeded` — while `12-decide-live-forbidden` is a verifier-relevant FORBIDDEN shape cycles-server does NOT emit, included only to prove the computer handles it.) Test-only (no production change): `@ValueSource` 3→13 fixtures; `EvidenceIdComputerTest` 5→15 tests. Full `mvn verify` 431 data + 196 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.29 — include the OPTIONAL `request` body in `error` CyclesEvidence (review Minor: cycles-evidence-v0.1 `ErrorPayload.request` SHOULD be present for a full audit trail unless redaction applies; v0.1.25.28 always omitted it). The four core controllers (`create`/`commit`/`release`/`decide`) stash their parsed `@RequestBody` DTO under the `GlobalExceptionHandler.EVIDENCE_REQUEST_ATTRIBUTE` request attribute immediately after binding; `GlobalExceptionHandler` includes it as the `error` payload's `request` field when present (typed DTO → Jackson → JCS-canonical). Present for any post-binding denial on the four core endpoints; absent for pre-binding failures (validation/auth) and non-instrumented routes — a future redaction policy MAY drop it. No wire change to `ErrorResponse` (the `request` lives only inside the evidence payload). The `error` payload is now `{endpoint, http_status, [reservation_id], [request], response}`, completing the draft `ErrorPayload` shape. REVIEW FIX (codex, High — applies to ALL request-bearing artifacts, not just error): the request/response DTOs serialized into evidence payloads can emit null-valued properties (`ReservationCreateRequest.ttl_ms`/`overage_policy`/`metadata`, `CommitRequest.metrics`/`metadata`, `ReleaseRequest.reason` — none carry `@JsonInclude(NON_NULL)`, and the shared `ObjectMapper` does not omit nulls), but the cycles-evidence-v0.1 mirror schemas are `additionalProperties:false` with non-nullable typed fields, so a serialized `null` makes the signed envelope fail mirror validation. This latent defect already affected the merged reserve/commit/release success-path evidence (each `put("request", request)`), not only the new error path. Fix is CENTRALIZED in `EvidenceEmitter`: a private NON_NULL copy of the shared mapper (`evidencePayloadMapper`, lazily derived) null-strips the payload ONCE into a tree used for BOTH the `evidence_id` computation and the queued record, so cycles-server and the event-tier worker agree byte-for-byte. The DTOs and the shared mapper are deliberately NOT changed — the same DTO serialization backs idempotency payload hashes, reserve.lua args, and cached response bodies, so altering it would break those. Byte-parity fixtures unaffected (their requests carry no nulls). Tests: `GlobalExceptionHandlerTest` +2 (stashed-request-included, absent-request-omitted) + `ReservationControllerTest` +1 (end-to-end `@WebMvcTest`: a non-dry reserve denial through the controller stashes the request DTO, emits `error` evidence with `request`+`endpoint`, and surfaces `cycles_evidence` on the 409) + `EvidenceEmitterTest` +2 (null-valued payload properties stripped; `evidence_id` computed over the null-stripped payload). codex round 1 SHIP, round 2 (High) fixed. Full `mvn verify` 421 data + 196 api green; full integration suite 428 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.28 — CyclesEvidence fan-out to the `error` artifact, COMPLETING the lifecycle binding loop (decide/reserve/commit/release/error) (per cycles-protocol v0.1.25.5 / runcycles/cycles-protocol#109). `ErrorResponse` gains optional `cycles_evidence` (`CyclesEvidenceRef`). `GlobalExceptionHandler` (the central `CyclesProtocolException` handler) now emits an `error` CyclesEvidence source record over `{endpoint, http_status, response}` (+ `reservation_id` hoisted for the commit/release endpoints, so evidence-only readers can reconstruct the authorization→settlement chain) and stamps the returned ref onto the `ErrorResponse` — but ONLY for budget/lifecycle DENIAL codes raised on the four core endpoints: the budget denials (`BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `UNIT_MISMATCH`) AND the reservation terminal-state denials on commit/release (`RESERVATION_FINALIZED` 409, `RESERVATION_EXPIRED` 410 — settling an already-finalized/expired reservation; reservation_id is hoisted for these so the authorization→settlement-denial chain is reconstructable) (`POST /v1/decide` | `/v1/reservations` | `.../commit` | `.../release`, matched via `HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE` + method; `reservation_id` from `URI_TEMPLATE_VARIABLES_ATTRIBUTE`). The non-dry reserve 409 `BUDGET_EXCEEDED` is the highest-signal denial APS receipts bind to — the canonical wire shape per §ReservationCreateResponse.decision (NOT a 200 DENY). Pre-evaluation failures (validation/auth/malformed/not-found/idempotency-mismatch) and non-core routes (e.g. extend) emit NOTHING — no decision was made, nothing to attest; matches the spec's `cycles_evidence` "absent for errors raised before evidence could be emitted". The ref is stamped AFTER `evidence_id` is computed over the pre-stamp response (`ErrorResponse` is `@JsonInclude(NON_NULL)`, so the attested `payload.error.response` omits `cycles_evidence` — non-self-referential), and emission is fail-open via `EvidenceEmitter` (a push/identity failure never fails the error response). `GlobalExceptionHandler` switches to constructor injection of `EvidenceEmitter`. REVIEW FIXES (codex): (Low) a commit/release denial whose `reservation_id` path var is somehow absent now SKIPS emission rather than emit a spec-invalid `error` payload missing the required `reservation_id`. (Medium) RESERVATION_FINALIZED/RESERVATION_EXPIRED were initially excluded from the allowlist — they route through `handleScriptError` on commit/release and are genuine settlement-path denials (the spec's `ErrorResponseMirror` enum already includes both, and reservation_id hoisting exists for exactly this chain), so they are now included; the doc states the governing principle (a code belongs iff it is a server DECISION on a core endpoint; pre-evaluation validation/auth/not-found/idempotency-mismatch stay excluded). Tests: `GlobalExceptionHandlerTest` +9 (reserve-denial emit+stamp with payload-shape assertions, commit-denial `reservation_id` hoist, commit-FINALIZED + release-EXPIRED terminal-state emit with reservation_id, unconfigured-emitter-omits-ref, pre-evaluation-no-emit, no-matched-route-no-emit, extend-route-no-emit, commit-denial-without-reservation_id-no-emit); the five controller `@WebMvcTest`s gain `@MockitoBean EvidenceEmitter`. Full `mvn verify` 419 data + 193 api green; full integration suite 426 api green; jacoco 95% gate met. This is the LAST core-artifact fan-out — remaining evidence work is the `evidence.available` webhook + deploy/config of the shared identity env vars.), 2026-06-13 (v0.1.25.27 — CyclesEvidence fan-out to decide + generalized non-persisting idempotency machinery (per cycles-protocol v0.1.25.4 / runcycles/cycles-protocol#108). The `decide` artifact (pre-execution decision, no reservation) now emits CyclesEvidence over `{request, response}` (draft `DecidePayload`) and surfaces `cycles_evidence` on `DecisionResponse`. GENERALIZATION: the dry_run atomic-claim + wait machinery (from #181) is refactored into a shared `kind`-parameterized path serving both dry_run AND decide — `acquireIdempotencyClaim` (SET NX pending-claim), `waitForIdempotentReplayBody` (per-poll wait), `cacheIdempotentBody`, `clearIdempotencyClaim`, `idempotencyCacheKey`/`pendingPrefix`/`pendingMarker`/`isPending`/`validatePendingMarker`/`pendingPayloadHash`, and the `IdemClaim` record. Keys/markers are derived from `kind` so dry_run stays byte-identical (`idem::dry_run:`, `__dry_run_pending__:`) — its behavior + tests unchanged; decide uses `idem::decide:` + `__decide_pending__:`. decide() is restructured into an orchestrator (acquire → replay/pending/fresh; connections released before emit + wait, no pool nesting) + an extracted pure `evaluateDecisionBudget`; concurrent same-key decides now converge to ONE evaluation + ONE envelope (closes the duplicate-emit race, same bar as dry_run). `DecisionController` threads `trace_id`. `DecisionResponse` gains optional `cycles_evidence` + transient `idempotentReplay`. REVIEW FIXES (codex): (a) the decide orchestrator's outer catch now rethrows `CyclesProtocolException` and `RuntimeException` UNCHANGED and only wraps a remaining checked `Exception` — runtime failures propagate unwrapped (no double-wrap). (b) `cacheIdempotentBody` / `clearIdempotencyClaim` no longer require a caller-held connection — each self-acquires its own short-lived pooled `Jedis` and fails open on a Redis error (the dead `clearDecideClaimQuietly` helper removed); callers in both the dry_run and decide paths updated. (c) `DecisionController` guards the `RESERVATION_DENIED` event emission with `!response.isIdempotentReplay()` so a decide replay does not re-emit the original deny event. (d) POOL-NESTING on the DRY_RUN FAILURE PATH — `evaluateDryRun`'s catch previously cleared the pending claim via the self-acquiring `clearIdempotencyClaim` while the caller's evaluation `Jedis` was STILL checked out (peak 2 connections/req under concurrent failing dry-runs — the starvation pattern this work avoids elsewhere). Added a `clearIdempotencyClaim(Jedis, idemKey, marker)` overload that compare-and-deletes on the ALREADY-HELD connection; `evaluateDryRun`'s failure catch now uses it (a failing dry-run checks out exactly ONE pooled connection); the self-acquire overload delegates to it. decide was already correct (clears only after its eval connection closes) and is unchanged. Tests: `RedisReservationDecideEventTest` +4 (fresh decide stamp+cache, decide replay-verbatim-no-reemit, fresh-decide-releases-connection-before-emit `InOrder` guard, fresh-decide-clears-claim-on-eval-failure; the runtime-exception test asserts the original instance propagates unwrapped), `RedisReservationCrudTest` +2 (dry-run clear-eval-also-throws → original error still propagates; dry-run post-eval cache/clear pool-unavailable → still returns the decision) plus strengthened the existing dry-run eval-fail test with a `times(1).getResource()` no-nesting guard, `DecisionControllerTest` +2 (surface cycles_evidence; idempotent-replay DENY does NOT re-emit RESERVATION_DENIED); dry_run/commit/release/reserve tests unchanged + green. Full `mvn verify` 419 data + 184 api green; full integration suite 417 api green; jacoco 95% gate met. Forbidden/validation failures on /v1/decide remain `error`-artifact territory (next slice).), 2026-06-13 (v0.1.25.26 — CyclesEvidence fan-out to commit + release (per cycles-protocol v0.1.25.3 / runcycles/cycles-protocol#107). Extends the reserve evidence pattern to the rest of the budget lifecycle. `commit.lua`/`release.lua` now flag their idempotent-replay branch (`replay = true`) so Java distinguishes fresh from replay. `commitReservation`/`releaseReservation` gain a `traceId` overload (4-/5-arg; 3-/4-arg back-compat retained) and, on a FRESH terminal op, emit a `commit`/`release` CyclesEvidence source record over `{reservation_id, request, response}` (the draft `CommitPayload`/`ReleasePayload` shape; response AS-IS, before `cycles_evidence` is stamped so the attested body is non-self-referential), stamp the ref onto the response, and cache the full body at `commit:body:` / `release:body:` with a 30-DAY TTL matching the terminal reservation-hash TTL (`PEXPIRE 2592000000`). A REPLAY returns the cached body VERBATIM (per-poll connection wait, mirroring the #181 reserve hardening; the main connection is released before waiting), falling back to the Lua-rebuilt response (no evidence) on cache miss. `CommitResponse`/`ReleaseResponse` gain optional `cycles_evidence` + transient `idempotentReplay`. `ReservationController` threads `trace_id` and SKIPS commit event emission on a replay (the original already emitted; the cached body carries no internal event fields). Generic helpers `emitLifecycleEvidence` / `cacheLifecycleBody` / `readCachedLifecycleBody` shared across commit+release. Tests: `RedisReservationCommitReleaseTest` +4 (fresh commit/release stamp+cache, commit/release replay-verbatim-no-reemit), `ReservationControllerTest` +2 (commit surfaces cycles_evidence, commit replay skips overage event). REVIEW FIXES (#183): (a) POOL-NESTING — the fresh reserve/commit/release paths previously held the Lua connection while `emitLifecycleEvidence`→`EvidenceQueueRepository.push` checked out a SECOND pool connection (peak 2/req → pool starvation under load). All three now RELEASE the Lua connection before evidence emit + body-cache, and `cacheReserveResponse`/`cacheLifecycleBody` acquire their own short-lived connection (peak 1 at a time); the reserve path was affected identically and is fixed too. (b) AUDIT-REPLAY — the admin-release audit block is now guarded by `!response.isIdempotentReplay()`, so an admin retry of an already-released reservation no longer writes a second `audit:log` entry. Regression guards: `InOrder` tests assert the Lua connection is `close()`d BEFORE `evidenceEmitter.emit(...)` on all three fresh paths (reserve/commit/release), plus `adminReleaseReplayDoesNotReAudit`. Full `mvn verify` 411 data + 179 api green; full integration suite 414 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.25 — CyclesEvidence idempotency-race hardening (runcycles/cycles-server#181, on the v0.1.25.24 centralized flow). Closes two concurrency races: (1) RESERVE replay landing in the window between reserve.lua writing its `idem::reserve:` mapping and `createReservation` writing the `reserve:body:` cache previously fell back to rebuilt current balances; the replay now POLLS the body cache (≤4 attempts × 25ms) before the rebuild fallback. (2) Concurrent fresh DRY_RUN with the same key could both evaluate → duplicate evidence emission + non-idempotent responses; now an atomic `SET … NX PX 60000` pending-claim elects one evaluator and losers wait (`waitForDryRunReplay`) for the winner's cached result. WAIT MECHANICS (addressing review): the wait loops acquire a FRESH Jedis per poll (try-with-resources inside the loop) rather than holding the request connection across `Thread.sleep`, and the budget is 4 attempts (~100ms, not 1s); the reserve replay path closes its main connection before waiting. A waiter that still finds no result returns 500 `INTERNAL_ERROR` ("retry with the same idempotency_key") — transient, resolves on client retry. CLEANUP: the pending claim is released via an atomic compare-and-delete Lua (`GET==marker then DEL`), and is cleared if the caller-side cache write fails-open or evaluation throws (`DryRunResult` carries claimKey/marker). Refactor: `createDryRunReservation` / `rebuildReserveReplay` / `DryRunResult.pending(...)` extracted; no wire/spec change. Tests: `RedisReservationCrudTest` +5 (reserve wait-then-replay, dry-run pending-wait-replay, claim-set-on-store, clear-on-cache-fail, clear-on-eval-fail); full `mvn verify` 407 data + 179 api green; full integration suite 412 api green incl. thundering-herd 3/3; jacoco 95% gate met.), 2026-06-12 (v0.1.25.24 — CyclesEvidence centralized into the reservation-creation flow (review: two more High findings on v0.1.25.23). ROOT CAUSE addressed: evidence had lived in `ReservationController` while idempotency/caching/TTL live in `RedisReservationRepository` — that split caused every prior finding. Evidence is now emitted + stamped + cached INSIDE `createReservation` (the idempotent unit); `EvidenceEmitter` is injected into the repository and `trace_id` is threaded via a new `createReservation(request, tenant, traceId)` overload (2-arg back-compat retained). (1) BODY-CACHE TTL now matches reserve.lua's idempotency mapping — `max(ttl_ms + grace_ms, 86400000)` (was a fixed 24h, which expired before the Lua key for reservations whose tenant `max_reservation_ttl_ms` exceeds 24h, dropping replay back to rebuilt current balances). (2) DRY_RUN now EMITS `reserve` evidence for ALL outcomes (ALLOW and the early DENYs) per spec-authority — `drafts/cycles-evidence-v0.1.yaml` carries the `03-reserve-dry-run-deny` golden fixture and states dry-run ALLOW/DENY are captured as `reserve` evidence (a dry-run DENY is the canonical signed "would this be allowed?" attestation; non-dry denials are 409→`error` artifact). Reverses the v0.1.25.23 suppression. Dry-run evidence is stamped before caching the body under `idem::dry_run:` (now covers DENY outcomes too, making them idempotent), replayed verbatim. `ReservationController` no longer references `EvidenceEmitter`/caching — it just returns the response. Tests: repository gains fresh-reserve evidence+TTL (incl. 48h long-lived → 48h+grace, not 24h) + cache-hit verbatim replay; `ReservationControllerTest` simplified to pass-through (cycles_evidence present/absent + 3-arg threading); full `mvn verify` 402 data + 179 api green; full integration suite 412 api green incl. the thundering-herd idempotency test (3/3). jacoco 95% gate met.), 2026-06-12 (v0.1.25.23 — CyclesEvidence idempotent-replay-body fix (review: two High findings on v0.1.25.22). (1) Reserve replays now return the ORIGINAL full payload: a fresh non-dry-run create stamps `cycles_evidence` then caches the WHOLE response via `RedisReservationRepository.cacheReserveResponse` keyed by `reserve:body:` (NOT idempotency_key — so it can't go stale across an idem-key expiry + re-reserve, and never collides with reserve.lua's own `idem::reserve:` mapping; 24h TTL); the Lua-idempotency-hit path reads that key by the reservation_id the Lua returns and replays the body VERBATIM (original balances + original `cycles_evidence`, so the body matches the envelope the `evidence_id` points to), falling back to rebuild-from-hash only when the cache is absent. Replaces the v0.1.25.22 `persistEvidenceRef`/`storedEvidence*` ref-only approach — which left the rebuilt body's balances drifting from the envelope, still violating the NORMATIVE "return the original successful response payload" rule (cycles-protocol-v0.yaml IDEMPOTENCY). Also fixes the pre-existing balance-drift-on-replay bug. (2) dry_run reserves NO LONGER emit/surface evidence at all: a dry_run neither persists a reservation nor changes any budget, so there is nothing to attest or bind a receipt to — and the dry_run idempotency cache (cached pre-evidence) previously caused every replay to re-emit + recompute a fresh `evidence_id`. The controller now suppresses evidence when `dry_run=true` or `idempotentReplay`. Model: `ReservationCreateResponse` drops `storedEvidence*`, keeps transient `idempotentReplay`. Tests: `ReservationControllerTest` reworked (fresh-surface+cache, replay-verbatim-no-reemit, replay-omit-when-absent, dry-run-no-evidence, emitter-null-still-caches), `RedisReservationCrudTest` +3 (cache-hit verbatim replay, cache write, no-cache-without-key). 402 data + 181 api tests, jacoco 95% gate met.), 2026-06-12 (v0.1.25.22 — CyclesEvidence — synchronous `evidence_id` + `cycles_evidence` on the reserve response — WIP on `feat/evidence-id-sync`, closing the APS binding loop. `EvidenceIdComputer` (`@Component`) reproduces the `cycles-evidence/v0.1` content-hash recipe (RFC 8785 JCS via erdtman + sha256 over the envelope with `evidence_id`/`signature` emptied) byte-for-byte — proven against the 3 reserve golden fixtures the event-tier worker and APS verifier use. `EvidenceEmitter.emit` now computes the id SYNCHRONOUSLY when the PUBLIC identity (`cycles.evidence.server-id` + `cycles.evidence.signing.signer-did` — property names + env vars SHARED with the event-tier worker, so one var configures both; review finding: Low) is configured, stamps it on the source record (for the worker's cross-check) and returns `EvidenceRef {evidence_id, cycles_evidence_url}`; unconfigured → null (record still queued, no id). `cycles_evidence_url` = `{server_id}/evidence/{evidence_id}` — `server_id` already carries the `/v1` base, so no `/v1` double-prefix. `ReservationController.create` stamps `cycles_evidence` on the response AFTER the id is computed, so the attested `payload.reserve.response` never carries the ref (content hash stays non-self-referential). New `CyclesEvidenceRef` model; `ReservationCreateResponse` gains optional `cycles_evidence` (`@JsonInclude(NON_NULL)` — additive, non-breaking per the spec EVOLUTION CONTRACT). IDEMPOTENT (review finding: High): a fresh reserve computes + emits ONCE and persists the ref on the reservation hash (`persistEvidenceRef` → HSET `evidence_id`/`cycles_evidence_url`); an idempotent replay returns that stored ref VERBATIM (transient `idempotentReplay`/`storedEvidence*` on the response) and NEVER recomputes (replay balances reflect current state → would drift to a different `evidence_id`) or re-emits — honouring the reserve "return the original successful response" rule. Per cycles-protocol v0.1.25.1 (#105) + v0.1.25.2 (#106). Tests: `EvidenceIdComputer` x5 (golden-fixture byte-parity), `EvidenceEmitter` +5 (configured/unconfigured/url-join), `ReservationControllerTest` +4 (fresh-surface+persist, replay-verbatim-no-reemit, replay-omit-when-unpersisted, omit). Build needs the serving controller (#176) on main for `getEvidence` spec-coverage. Fan-out to decide/commit/release follows.), 2026-06-12 (CyclesEvidence serving endpoint — WIP on `feat/evidence-serving-impl`. Implements `getEvidence` (`GET /v1/evidence/{evidence_id}`, per `cycles-protocol-v0.yaml` revision 2026-06-12 / runcycles/cycles-protocol#104): `EvidenceController` reads the shared store via `EvidenceStoreReader` (`GET evidence:envelope:`, same `cycles.evidence.store.key-prefix` as the event-tier writer) and serves the signed envelope VERBATIM (bytes, `application/json`) with `Cache-Control: public, immutable`; `404` (`CyclesProtocolException.notFound`) when absent; `@Pattern` 64-hex `evidence_id` → `400`. PUBLIC: `/v1/evidence/**` added to `SecurityConfig.PUBLIC_PATHS` (no API key — capability-URL, per the spec). Tests: `EvidenceControllerTest` (standalone, 200 verbatim + immutable-cache, 404), `EvidenceStoreReaderTest` (get-by-key); the four controller `@WebMvcTest`s gain `@MockitoBean EvidenceStoreReader` (they load all controllers via `ContractValidationConfig`). `OpenApiContractDiffTest` passes against the #104 spec (validated locally via `-Dcontract.spec.url`); CI goes green once #104 merges to main. No change to existing endpoints.), 2026-06-12 (CyclesEvidence source emission, reserve endpoint — WIP on `feat/evidence-emit-reserve`, the producer half of the dedicated-channel emitter. `EvidenceQueueRepository` (LPUSH a source record to `evidence:pending`, key `cycles.evidence.queue.pending-key`) and `EvidenceEmitter` (`@Service`; stamps `artifact_type`, `issued_at_ms` (response time, matching the fixtures), `trace_id`, and the caller-supplied payload body — `server_id`/`signer_did` are added by the event-tier worker). DURABLE: the enqueue is SYNCHRONOUS — the source record is LPUSH'd to the same Redis as the just-committed ledger write before the response returns, so a committed op cannot return without its evidence queued (closes the async pre-Redis loss window; removes the unbounded-executor backlog/OOM risk). Fail-open: a push failure is logged + metered (`cycles.evidence.emit_failed` via `CyclesMetrics`) and never fails the already-committed response; expensive signing stays async in the event tier. `emit` takes the payload body because its shape varies by artifact (reserve/decide = `{request,response}`; commit/release add `reservation_id`; error = `{endpoint,http_status,request,response}`). Wired into `ReservationController.create` (covers ALLOW and DENY). Dedicated channel, not the webhook event stream. 4 tests (`EvidenceEmitter` x3 incl. sync-enqueue + fail-open, `EvidenceQueueRepository` LPUSH); `ReservationControllerTest` gains `@MockitoBean EvidenceEmitter`. No wire/spec change; fan-out to decide/commit/release + the error artifact follows.), 2026-05-22 (v0.1.25.21 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 time-window filters on `GET /v1/reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes runcycles/cycles-server#162. Four new query params mirroring the v0.1.25.20 `from`/`to` shape: `expires_*` binds to `expires_at_ms` (required field, every row), `finalized_*` binds to `finalized_at_ms` (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED normatively excluded). Three windows compose with AND. `finalized_at_ms` added as an optional field on `ReservationSummary` so clients filtering with `finalized_*` can see the timestamp without a follow-up `getReservation` — strict-schema-compatible because the field is `@JsonInclude(NON_NULL)`. `FilterHasher` extends with four more `Long` args (10 → 14) using independent gated emission per pair — preserves byte-exact back-compat for v0.1.25.18 cursors (golden `2f397ea0e8fb53b7`) AND v0.1.25.20 cursors with from/to set (golden `ad7204d521cfd133`). `RedisReservationRepository.listReservations` signature 14 → 18 args. Two new predicate helpers (`expiresAtInWindow`, `finalizedAtInWindow`) applied in both legacy SCAN-cursor and sorted paths. Validation: each new pair `from > to` → 400; malformed values → 400 with distinct per-param message; blank strings treated as unset. 557 tests pass (384 data + 173 api), +19 vs v0.1.25.20.), +**Date:** 2026-06-15 (v0.1.25.32 — CyclesEvidence signer-key resolution, publication half: implement `getEvidenceJwks` (`GET /v1/.well-known/cycles-jwks.json`, per `cycles-protocol-v0.yaml` v0.1.25.6 / runcycles/cycles-protocol#113, the v0.2 additive layer designed on #103 / aeoess#43). `JwksController` (public; reads the shared `cycles.evidence.signing.signer-did` + new `cycles.evidence.signing.kid` / `nbf-ms` via `@Value`, holds no injected bean so it loads in every `@WebMvcTest` without extra wiring) serves the JWK Set built by the pure `JwksDocuments.jwkSet(signerDid, kid, nbfMs)`: when `signer-did` is a raw 64-hex key it emits one active Ed25519 OKP JWK — `{kty:OKP, crv:Ed25519, alg:EdDSA, x:base64url(hex-decode(signer_did)), kid (default = first 16 hex of the key), cycles_nbf_ms (default 0), status:active}`, `cycles_exp_ms` omitted ⇒ open-ended; the JWK `x` is the SAME 32 raw bytes `EnvelopeSigner` signs with, so a verifier resolving this set authenticates the emitted signatures. Served with `Cache-Control: public, max-age=300` (NOT immutable — a key set rotates, unlike a content-addressed envelope). 404s via the standard NOT_FOUND `ErrorResponse` when no raw-hex key is configured (evidence off, or a `did:cycles` `signer_did` which carries no key bytes — that, plus retired-key rotation history, is the v0.2-store follow-up); consumers then stay on the raw-hex + `expected_signer` pinning (`binding_only`) path. PUBLIC: `/v1/.well-known/**` added to `SecurityConfig.PUBLIC_PATHS` — public keys only, the private key is never served, and the set is itself the trust anchor. API-base-relative (under `/v1`), per the spec's authority-scope rule (the `did:cycles` hash covers `server_id` WITH its path). Tests: `JwksDocumentsTest` (10 — raw-hex JWK shape, `x` round-trips to the key bytes, default/override kid, nbf carry-through, blank/null/did:cycles/malformed → empty), `JwksControllerTest` (4 — 200 + JWK-set body + short public non-immutable cache via `@WebMvcTest` contract-validated against #113's spec, unconfigured/did:cycles → NOT_FOUND, direct 200 body), `SecurityConfigTest` updated for the new public path. Both new classes 100% line-covered; full `mvn verify` 906 tests green; jacoco 95% gate met. No change to existing endpoints/wire.), 2026-06-14 (v0.1.25.31 — review fix [Medium]: `POST /v1/reservations` re-emitted side-effect events on an idempotent REPLAY. `ReservationController.create` emitted the `RESERVATION_DENIED` deny event and `emitBalanceEvents` (budget-state transitions) unconditionally, while `decide` (`DecisionController`) and `commit`/`release` (`ReservationController`) already skip side-effect emission when `response.isIdempotentReplay()`. A replay (a cached dry-run DENY, or a reserve body-cache replay carrying `idempotentReplay=true`) therefore double-counted those events — the original create already emitted them. FIX: wrap create's whole emission block in `if (!response.isIdempotentReplay())`, matching the other lifecycle endpoints. Test: `ReservationControllerTest` +1 (`shouldNotReemitEventsOnIdempotentReplay` — an idempotent DENY replay re-emits neither `RESERVATION_DENIED` nor balance events). No wire/spec change. NOTE: numbered .31 because v0.1.25.30 is held by the open byte-parity PR #187; merge #187 first.), 2026-06-14 (v0.1.25.30 — byte-parity hardening: extend `EvidenceIdComputerTest`'s golden-fixture coverage from the 3 reserve fixtures to the FULL 13-fixture set (all five artifact types — decide/reserve/commit/release/error), the SAME `cycles-evidence-fixtures` the event-tier `CyclesEvidenceCanonicalizer` verifies against and the APS verifier was generated with (the 10 added fixtures are byte-identical copies from cycles-server-events). This proves cycles-server's SYNCHRONOUS `evidence_id` computation reproduces the canonical id for EVERY artifact SHAPE — `reservation_id` hoisting on commit/release, `endpoint`+`http_status` on error — i.e. the JCS+sha256 recipe is byte-correct across the whole envelope domain, so whenever cycles-server emits a given shape its id matches the worker and the cross-check never dead-letters on drift. (Recipe parity over the canonical/verifier fixture set, NOT the set of shapes cycles-server emits: its emission policy is the budget/lifecycle-denial subset per `GlobalExceptionHandler.EVIDENCE_DENIAL_CODES`, so the error envelope it actually emits is the budget-exceeded kind — `11-reserve-live-budget-exceeded` — while `12-decide-live-forbidden` is a verifier-relevant FORBIDDEN shape cycles-server does NOT emit, included only to prove the computer handles it.) Test-only (no production change): `@ValueSource` 3→13 fixtures; `EvidenceIdComputerTest` 5→15 tests. Full `mvn verify` 431 data + 196 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.29 — include the OPTIONAL `request` body in `error` CyclesEvidence (review Minor: cycles-evidence-v0.1 `ErrorPayload.request` SHOULD be present for a full audit trail unless redaction applies; v0.1.25.28 always omitted it). The four core controllers (`create`/`commit`/`release`/`decide`) stash their parsed `@RequestBody` DTO under the `GlobalExceptionHandler.EVIDENCE_REQUEST_ATTRIBUTE` request attribute immediately after binding; `GlobalExceptionHandler` includes it as the `error` payload's `request` field when present (typed DTO → Jackson → JCS-canonical). Present for any post-binding denial on the four core endpoints; absent for pre-binding failures (validation/auth) and non-instrumented routes — a future redaction policy MAY drop it. No wire change to `ErrorResponse` (the `request` lives only inside the evidence payload). The `error` payload is now `{endpoint, http_status, [reservation_id], [request], response}`, completing the draft `ErrorPayload` shape. REVIEW FIX (codex, High — applies to ALL request-bearing artifacts, not just error): the request/response DTOs serialized into evidence payloads can emit null-valued properties (`ReservationCreateRequest.ttl_ms`/`overage_policy`/`metadata`, `CommitRequest.metrics`/`metadata`, `ReleaseRequest.reason` — none carry `@JsonInclude(NON_NULL)`, and the shared `ObjectMapper` does not omit nulls), but the cycles-evidence-v0.1 mirror schemas are `additionalProperties:false` with non-nullable typed fields, so a serialized `null` makes the signed envelope fail mirror validation. This latent defect already affected the merged reserve/commit/release success-path evidence (each `put("request", request)`), not only the new error path. Fix is CENTRALIZED in `EvidenceEmitter`: a private NON_NULL copy of the shared mapper (`evidencePayloadMapper`, lazily derived) null-strips the payload ONCE into a tree used for BOTH the `evidence_id` computation and the queued record, so cycles-server and the event-tier worker agree byte-for-byte. The DTOs and the shared mapper are deliberately NOT changed — the same DTO serialization backs idempotency payload hashes, reserve.lua args, and cached response bodies, so altering it would break those. Byte-parity fixtures unaffected (their requests carry no nulls). Tests: `GlobalExceptionHandlerTest` +2 (stashed-request-included, absent-request-omitted) + `ReservationControllerTest` +1 (end-to-end `@WebMvcTest`: a non-dry reserve denial through the controller stashes the request DTO, emits `error` evidence with `request`+`endpoint`, and surfaces `cycles_evidence` on the 409) + `EvidenceEmitterTest` +2 (null-valued payload properties stripped; `evidence_id` computed over the null-stripped payload). codex round 1 SHIP, round 2 (High) fixed. Full `mvn verify` 421 data + 196 api green; full integration suite 428 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.28 — CyclesEvidence fan-out to the `error` artifact, COMPLETING the lifecycle binding loop (decide/reserve/commit/release/error) (per cycles-protocol v0.1.25.5 / runcycles/cycles-protocol#109). `ErrorResponse` gains optional `cycles_evidence` (`CyclesEvidenceRef`). `GlobalExceptionHandler` (the central `CyclesProtocolException` handler) now emits an `error` CyclesEvidence source record over `{endpoint, http_status, response}` (+ `reservation_id` hoisted for the commit/release endpoints, so evidence-only readers can reconstruct the authorization→settlement chain) and stamps the returned ref onto the `ErrorResponse` — but ONLY for budget/lifecycle DENIAL codes raised on the four core endpoints: the budget denials (`BUDGET_EXCEEDED`, `BUDGET_FROZEN`, `BUDGET_CLOSED`, `OVERDRAFT_LIMIT_EXCEEDED`, `DEBT_OUTSTANDING`, `UNIT_MISMATCH`) AND the reservation terminal-state denials on commit/release (`RESERVATION_FINALIZED` 409, `RESERVATION_EXPIRED` 410 — settling an already-finalized/expired reservation; reservation_id is hoisted for these so the authorization→settlement-denial chain is reconstructable) (`POST /v1/decide` | `/v1/reservations` | `.../commit` | `.../release`, matched via `HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE` + method; `reservation_id` from `URI_TEMPLATE_VARIABLES_ATTRIBUTE`). The non-dry reserve 409 `BUDGET_EXCEEDED` is the highest-signal denial APS receipts bind to — the canonical wire shape per §ReservationCreateResponse.decision (NOT a 200 DENY). Pre-evaluation failures (validation/auth/malformed/not-found/idempotency-mismatch) and non-core routes (e.g. extend) emit NOTHING — no decision was made, nothing to attest; matches the spec's `cycles_evidence` "absent for errors raised before evidence could be emitted". The ref is stamped AFTER `evidence_id` is computed over the pre-stamp response (`ErrorResponse` is `@JsonInclude(NON_NULL)`, so the attested `payload.error.response` omits `cycles_evidence` — non-self-referential), and emission is fail-open via `EvidenceEmitter` (a push/identity failure never fails the error response). `GlobalExceptionHandler` switches to constructor injection of `EvidenceEmitter`. REVIEW FIXES (codex): (Low) a commit/release denial whose `reservation_id` path var is somehow absent now SKIPS emission rather than emit a spec-invalid `error` payload missing the required `reservation_id`. (Medium) RESERVATION_FINALIZED/RESERVATION_EXPIRED were initially excluded from the allowlist — they route through `handleScriptError` on commit/release and are genuine settlement-path denials (the spec's `ErrorResponseMirror` enum already includes both, and reservation_id hoisting exists for exactly this chain), so they are now included; the doc states the governing principle (a code belongs iff it is a server DECISION on a core endpoint; pre-evaluation validation/auth/not-found/idempotency-mismatch stay excluded). Tests: `GlobalExceptionHandlerTest` +9 (reserve-denial emit+stamp with payload-shape assertions, commit-denial `reservation_id` hoist, commit-FINALIZED + release-EXPIRED terminal-state emit with reservation_id, unconfigured-emitter-omits-ref, pre-evaluation-no-emit, no-matched-route-no-emit, extend-route-no-emit, commit-denial-without-reservation_id-no-emit); the five controller `@WebMvcTest`s gain `@MockitoBean EvidenceEmitter`. Full `mvn verify` 419 data + 193 api green; full integration suite 426 api green; jacoco 95% gate met. This is the LAST core-artifact fan-out — remaining evidence work is the `evidence.available` webhook + deploy/config of the shared identity env vars.), 2026-06-13 (v0.1.25.27 — CyclesEvidence fan-out to decide + generalized non-persisting idempotency machinery (per cycles-protocol v0.1.25.4 / runcycles/cycles-protocol#108). The `decide` artifact (pre-execution decision, no reservation) now emits CyclesEvidence over `{request, response}` (draft `DecidePayload`) and surfaces `cycles_evidence` on `DecisionResponse`. GENERALIZATION: the dry_run atomic-claim + wait machinery (from #181) is refactored into a shared `kind`-parameterized path serving both dry_run AND decide — `acquireIdempotencyClaim` (SET NX pending-claim), `waitForIdempotentReplayBody` (per-poll wait), `cacheIdempotentBody`, `clearIdempotencyClaim`, `idempotencyCacheKey`/`pendingPrefix`/`pendingMarker`/`isPending`/`validatePendingMarker`/`pendingPayloadHash`, and the `IdemClaim` record. Keys/markers are derived from `kind` so dry_run stays byte-identical (`idem::dry_run:`, `__dry_run_pending__:`) — its behavior + tests unchanged; decide uses `idem::decide:` + `__decide_pending__:`. decide() is restructured into an orchestrator (acquire → replay/pending/fresh; connections released before emit + wait, no pool nesting) + an extracted pure `evaluateDecisionBudget`; concurrent same-key decides now converge to ONE evaluation + ONE envelope (closes the duplicate-emit race, same bar as dry_run). `DecisionController` threads `trace_id`. `DecisionResponse` gains optional `cycles_evidence` + transient `idempotentReplay`. REVIEW FIXES (codex): (a) the decide orchestrator's outer catch now rethrows `CyclesProtocolException` and `RuntimeException` UNCHANGED and only wraps a remaining checked `Exception` — runtime failures propagate unwrapped (no double-wrap). (b) `cacheIdempotentBody` / `clearIdempotencyClaim` no longer require a caller-held connection — each self-acquires its own short-lived pooled `Jedis` and fails open on a Redis error (the dead `clearDecideClaimQuietly` helper removed); callers in both the dry_run and decide paths updated. (c) `DecisionController` guards the `RESERVATION_DENIED` event emission with `!response.isIdempotentReplay()` so a decide replay does not re-emit the original deny event. (d) POOL-NESTING on the DRY_RUN FAILURE PATH — `evaluateDryRun`'s catch previously cleared the pending claim via the self-acquiring `clearIdempotencyClaim` while the caller's evaluation `Jedis` was STILL checked out (peak 2 connections/req under concurrent failing dry-runs — the starvation pattern this work avoids elsewhere). Added a `clearIdempotencyClaim(Jedis, idemKey, marker)` overload that compare-and-deletes on the ALREADY-HELD connection; `evaluateDryRun`'s failure catch now uses it (a failing dry-run checks out exactly ONE pooled connection); the self-acquire overload delegates to it. decide was already correct (clears only after its eval connection closes) and is unchanged. Tests: `RedisReservationDecideEventTest` +4 (fresh decide stamp+cache, decide replay-verbatim-no-reemit, fresh-decide-releases-connection-before-emit `InOrder` guard, fresh-decide-clears-claim-on-eval-failure; the runtime-exception test asserts the original instance propagates unwrapped), `RedisReservationCrudTest` +2 (dry-run clear-eval-also-throws → original error still propagates; dry-run post-eval cache/clear pool-unavailable → still returns the decision) plus strengthened the existing dry-run eval-fail test with a `times(1).getResource()` no-nesting guard, `DecisionControllerTest` +2 (surface cycles_evidence; idempotent-replay DENY does NOT re-emit RESERVATION_DENIED); dry_run/commit/release/reserve tests unchanged + green. Full `mvn verify` 419 data + 184 api green; full integration suite 417 api green; jacoco 95% gate met. Forbidden/validation failures on /v1/decide remain `error`-artifact territory (next slice).), 2026-06-13 (v0.1.25.26 — CyclesEvidence fan-out to commit + release (per cycles-protocol v0.1.25.3 / runcycles/cycles-protocol#107). Extends the reserve evidence pattern to the rest of the budget lifecycle. `commit.lua`/`release.lua` now flag their idempotent-replay branch (`replay = true`) so Java distinguishes fresh from replay. `commitReservation`/`releaseReservation` gain a `traceId` overload (4-/5-arg; 3-/4-arg back-compat retained) and, on a FRESH terminal op, emit a `commit`/`release` CyclesEvidence source record over `{reservation_id, request, response}` (the draft `CommitPayload`/`ReleasePayload` shape; response AS-IS, before `cycles_evidence` is stamped so the attested body is non-self-referential), stamp the ref onto the response, and cache the full body at `commit:body:` / `release:body:` with a 30-DAY TTL matching the terminal reservation-hash TTL (`PEXPIRE 2592000000`). A REPLAY returns the cached body VERBATIM (per-poll connection wait, mirroring the #181 reserve hardening; the main connection is released before waiting), falling back to the Lua-rebuilt response (no evidence) on cache miss. `CommitResponse`/`ReleaseResponse` gain optional `cycles_evidence` + transient `idempotentReplay`. `ReservationController` threads `trace_id` and SKIPS commit event emission on a replay (the original already emitted; the cached body carries no internal event fields). Generic helpers `emitLifecycleEvidence` / `cacheLifecycleBody` / `readCachedLifecycleBody` shared across commit+release. Tests: `RedisReservationCommitReleaseTest` +4 (fresh commit/release stamp+cache, commit/release replay-verbatim-no-reemit), `ReservationControllerTest` +2 (commit surfaces cycles_evidence, commit replay skips overage event). REVIEW FIXES (#183): (a) POOL-NESTING — the fresh reserve/commit/release paths previously held the Lua connection while `emitLifecycleEvidence`→`EvidenceQueueRepository.push` checked out a SECOND pool connection (peak 2/req → pool starvation under load). All three now RELEASE the Lua connection before evidence emit + body-cache, and `cacheReserveResponse`/`cacheLifecycleBody` acquire their own short-lived connection (peak 1 at a time); the reserve path was affected identically and is fixed too. (b) AUDIT-REPLAY — the admin-release audit block is now guarded by `!response.isIdempotentReplay()`, so an admin retry of an already-released reservation no longer writes a second `audit:log` entry. Regression guards: `InOrder` tests assert the Lua connection is `close()`d BEFORE `evidenceEmitter.emit(...)` on all three fresh paths (reserve/commit/release), plus `adminReleaseReplayDoesNotReAudit`. Full `mvn verify` 411 data + 179 api green; full integration suite 414 api green; jacoco 95% gate met.), 2026-06-13 (v0.1.25.25 — CyclesEvidence idempotency-race hardening (runcycles/cycles-server#181, on the v0.1.25.24 centralized flow). Closes two concurrency races: (1) RESERVE replay landing in the window between reserve.lua writing its `idem::reserve:` mapping and `createReservation` writing the `reserve:body:` cache previously fell back to rebuilt current balances; the replay now POLLS the body cache (≤4 attempts × 25ms) before the rebuild fallback. (2) Concurrent fresh DRY_RUN with the same key could both evaluate → duplicate evidence emission + non-idempotent responses; now an atomic `SET … NX PX 60000` pending-claim elects one evaluator and losers wait (`waitForDryRunReplay`) for the winner's cached result. WAIT MECHANICS (addressing review): the wait loops acquire a FRESH Jedis per poll (try-with-resources inside the loop) rather than holding the request connection across `Thread.sleep`, and the budget is 4 attempts (~100ms, not 1s); the reserve replay path closes its main connection before waiting. A waiter that still finds no result returns 500 `INTERNAL_ERROR` ("retry with the same idempotency_key") — transient, resolves on client retry. CLEANUP: the pending claim is released via an atomic compare-and-delete Lua (`GET==marker then DEL`), and is cleared if the caller-side cache write fails-open or evaluation throws (`DryRunResult` carries claimKey/marker). Refactor: `createDryRunReservation` / `rebuildReserveReplay` / `DryRunResult.pending(...)` extracted; no wire/spec change. Tests: `RedisReservationCrudTest` +5 (reserve wait-then-replay, dry-run pending-wait-replay, claim-set-on-store, clear-on-cache-fail, clear-on-eval-fail); full `mvn verify` 407 data + 179 api green; full integration suite 412 api green incl. thundering-herd 3/3; jacoco 95% gate met.), 2026-06-12 (v0.1.25.24 — CyclesEvidence centralized into the reservation-creation flow (review: two more High findings on v0.1.25.23). ROOT CAUSE addressed: evidence had lived in `ReservationController` while idempotency/caching/TTL live in `RedisReservationRepository` — that split caused every prior finding. Evidence is now emitted + stamped + cached INSIDE `createReservation` (the idempotent unit); `EvidenceEmitter` is injected into the repository and `trace_id` is threaded via a new `createReservation(request, tenant, traceId)` overload (2-arg back-compat retained). (1) BODY-CACHE TTL now matches reserve.lua's idempotency mapping — `max(ttl_ms + grace_ms, 86400000)` (was a fixed 24h, which expired before the Lua key for reservations whose tenant `max_reservation_ttl_ms` exceeds 24h, dropping replay back to rebuilt current balances). (2) DRY_RUN now EMITS `reserve` evidence for ALL outcomes (ALLOW and the early DENYs) per spec-authority — `drafts/cycles-evidence-v0.1.yaml` carries the `03-reserve-dry-run-deny` golden fixture and states dry-run ALLOW/DENY are captured as `reserve` evidence (a dry-run DENY is the canonical signed "would this be allowed?" attestation; non-dry denials are 409→`error` artifact). Reverses the v0.1.25.23 suppression. Dry-run evidence is stamped before caching the body under `idem::dry_run:` (now covers DENY outcomes too, making them idempotent), replayed verbatim. `ReservationController` no longer references `EvidenceEmitter`/caching — it just returns the response. Tests: repository gains fresh-reserve evidence+TTL (incl. 48h long-lived → 48h+grace, not 24h) + cache-hit verbatim replay; `ReservationControllerTest` simplified to pass-through (cycles_evidence present/absent + 3-arg threading); full `mvn verify` 402 data + 179 api green; full integration suite 412 api green incl. the thundering-herd idempotency test (3/3). jacoco 95% gate met.), 2026-06-12 (v0.1.25.23 — CyclesEvidence idempotent-replay-body fix (review: two High findings on v0.1.25.22). (1) Reserve replays now return the ORIGINAL full payload: a fresh non-dry-run create stamps `cycles_evidence` then caches the WHOLE response via `RedisReservationRepository.cacheReserveResponse` keyed by `reserve:body:` (NOT idempotency_key — so it can't go stale across an idem-key expiry + re-reserve, and never collides with reserve.lua's own `idem::reserve:` mapping; 24h TTL); the Lua-idempotency-hit path reads that key by the reservation_id the Lua returns and replays the body VERBATIM (original balances + original `cycles_evidence`, so the body matches the envelope the `evidence_id` points to), falling back to rebuild-from-hash only when the cache is absent. Replaces the v0.1.25.22 `persistEvidenceRef`/`storedEvidence*` ref-only approach — which left the rebuilt body's balances drifting from the envelope, still violating the NORMATIVE "return the original successful response payload" rule (cycles-protocol-v0.yaml IDEMPOTENCY). Also fixes the pre-existing balance-drift-on-replay bug. (2) dry_run reserves NO LONGER emit/surface evidence at all: a dry_run neither persists a reservation nor changes any budget, so there is nothing to attest or bind a receipt to — and the dry_run idempotency cache (cached pre-evidence) previously caused every replay to re-emit + recompute a fresh `evidence_id`. The controller now suppresses evidence when `dry_run=true` or `idempotentReplay`. Model: `ReservationCreateResponse` drops `storedEvidence*`, keeps transient `idempotentReplay`. Tests: `ReservationControllerTest` reworked (fresh-surface+cache, replay-verbatim-no-reemit, replay-omit-when-absent, dry-run-no-evidence, emitter-null-still-caches), `RedisReservationCrudTest` +3 (cache-hit verbatim replay, cache write, no-cache-without-key). 402 data + 181 api tests, jacoco 95% gate met.), 2026-06-12 (v0.1.25.22 — CyclesEvidence — synchronous `evidence_id` + `cycles_evidence` on the reserve response — WIP on `feat/evidence-id-sync`, closing the APS binding loop. `EvidenceIdComputer` (`@Component`) reproduces the `cycles-evidence/v0.1` content-hash recipe (RFC 8785 JCS via erdtman + sha256 over the envelope with `evidence_id`/`signature` emptied) byte-for-byte — proven against the 3 reserve golden fixtures the event-tier worker and APS verifier use. `EvidenceEmitter.emit` now computes the id SYNCHRONOUSLY when the PUBLIC identity (`cycles.evidence.server-id` + `cycles.evidence.signing.signer-did` — property names + env vars SHARED with the event-tier worker, so one var configures both; review finding: Low) is configured, stamps it on the source record (for the worker's cross-check) and returns `EvidenceRef {evidence_id, cycles_evidence_url}`; unconfigured → null (record still queued, no id). `cycles_evidence_url` = `{server_id}/evidence/{evidence_id}` — `server_id` already carries the `/v1` base, so no `/v1` double-prefix. `ReservationController.create` stamps `cycles_evidence` on the response AFTER the id is computed, so the attested `payload.reserve.response` never carries the ref (content hash stays non-self-referential). New `CyclesEvidenceRef` model; `ReservationCreateResponse` gains optional `cycles_evidence` (`@JsonInclude(NON_NULL)` — additive, non-breaking per the spec EVOLUTION CONTRACT). IDEMPOTENT (review finding: High): a fresh reserve computes + emits ONCE and persists the ref on the reservation hash (`persistEvidenceRef` → HSET `evidence_id`/`cycles_evidence_url`); an idempotent replay returns that stored ref VERBATIM (transient `idempotentReplay`/`storedEvidence*` on the response) and NEVER recomputes (replay balances reflect current state → would drift to a different `evidence_id`) or re-emits — honouring the reserve "return the original successful response" rule. Per cycles-protocol v0.1.25.1 (#105) + v0.1.25.2 (#106). Tests: `EvidenceIdComputer` x5 (golden-fixture byte-parity), `EvidenceEmitter` +5 (configured/unconfigured/url-join), `ReservationControllerTest` +4 (fresh-surface+persist, replay-verbatim-no-reemit, replay-omit-when-unpersisted, omit). Build needs the serving controller (#176) on main for `getEvidence` spec-coverage. Fan-out to decide/commit/release follows.), 2026-06-12 (CyclesEvidence serving endpoint — WIP on `feat/evidence-serving-impl`. Implements `getEvidence` (`GET /v1/evidence/{evidence_id}`, per `cycles-protocol-v0.yaml` revision 2026-06-12 / runcycles/cycles-protocol#104): `EvidenceController` reads the shared store via `EvidenceStoreReader` (`GET evidence:envelope:`, same `cycles.evidence.store.key-prefix` as the event-tier writer) and serves the signed envelope VERBATIM (bytes, `application/json`) with `Cache-Control: public, immutable`; `404` (`CyclesProtocolException.notFound`) when absent; `@Pattern` 64-hex `evidence_id` → `400`. PUBLIC: `/v1/evidence/**` added to `SecurityConfig.PUBLIC_PATHS` (no API key — capability-URL, per the spec). Tests: `EvidenceControllerTest` (standalone, 200 verbatim + immutable-cache, 404), `EvidenceStoreReaderTest` (get-by-key); the four controller `@WebMvcTest`s gain `@MockitoBean EvidenceStoreReader` (they load all controllers via `ContractValidationConfig`). `OpenApiContractDiffTest` passes against the #104 spec (validated locally via `-Dcontract.spec.url`); CI goes green once #104 merges to main. No change to existing endpoints.), 2026-06-12 (CyclesEvidence source emission, reserve endpoint — WIP on `feat/evidence-emit-reserve`, the producer half of the dedicated-channel emitter. `EvidenceQueueRepository` (LPUSH a source record to `evidence:pending`, key `cycles.evidence.queue.pending-key`) and `EvidenceEmitter` (`@Service`; stamps `artifact_type`, `issued_at_ms` (response time, matching the fixtures), `trace_id`, and the caller-supplied payload body — `server_id`/`signer_did` are added by the event-tier worker). DURABLE: the enqueue is SYNCHRONOUS — the source record is LPUSH'd to the same Redis as the just-committed ledger write before the response returns, so a committed op cannot return without its evidence queued (closes the async pre-Redis loss window; removes the unbounded-executor backlog/OOM risk). Fail-open: a push failure is logged + metered (`cycles.evidence.emit_failed` via `CyclesMetrics`) and never fails the already-committed response; expensive signing stays async in the event tier. `emit` takes the payload body because its shape varies by artifact (reserve/decide = `{request,response}`; commit/release add `reservation_id`; error = `{endpoint,http_status,request,response}`). Wired into `ReservationController.create` (covers ALLOW and DENY). Dedicated channel, not the webhook event stream. 4 tests (`EvidenceEmitter` x3 incl. sync-enqueue + fail-open, `EvidenceQueueRepository` LPUSH); `ReservationControllerTest` gains `@MockitoBean EvidenceEmitter`. No wire/spec change; fan-out to decide/commit/release + the error artifact follows.), 2026-05-22 (v0.1.25.21 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 time-window filters on `GET /v1/reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22 (runcycles/cycles-protocol#98); closes runcycles/cycles-server#162. Four new query params mirroring the v0.1.25.20 `from`/`to` shape: `expires_*` binds to `expires_at_ms` (required field, every row), `finalized_*` binds to `finalized_at_ms` (populated only on COMMITTED/RELEASED; ACTIVE and EXPIRED normatively excluded). Three windows compose with AND. `finalized_at_ms` added as an optional field on `ReservationSummary` so clients filtering with `finalized_*` can see the timestamp without a follow-up `getReservation` — strict-schema-compatible because the field is `@JsonInclude(NON_NULL)`. `FilterHasher` extends with four more `Long` args (10 → 14) using independent gated emission per pair — preserves byte-exact back-compat for v0.1.25.18 cursors (golden `2f397ea0e8fb53b7`) AND v0.1.25.20 cursors with from/to set (golden `ad7204d521cfd133`). `RedisReservationRepository.listReservations` signature 14 → 18 args. Two new predicate helpers (`expiresAtInWindow`, `finalizedAtInWindow`) applied in both legacy SCAN-cursor and sorted paths. Validation: each new pair `from > to` → 400; malformed values → 400 with distinct per-param message; blank strings treated as unset. 557 tests pass (384 data + 173 api), +19 vs v0.1.25.20.), 2026-05-21 (v0.1.25.20 — `from` / `to` ISO-8601 time-window filter on `GET /v1/reservations` per cycles-protocol revision 2026-05-21; closes runcycles/cycles-server#159. Two new query params on `listReservations`, both `string`/`format: date-time`, both inclusive bounds on `created_at_ms`, both bind to `created_at_ms` regardless of `sort_by`. Implemented in both the legacy SCAN-cursor and sorted paths. `FilterHasher.hash(...)` now folds `fromMs`/`toMs` into the canonical hash so sorted-path cursors invalidate on window change (the legacy Redis-SCAN cursor is not window-validated, matching how it already treats every other filter). Validation: malformed values → 400, `from > to` → 400 before any repository call, blank strings treated as unset, missing/unparseable `created_at` rows defensively excluded when either bound supplied. Pure additive wire change — all v0.1.25.x clients that don't send the params continue to work byte-for-byte. 538 tests pass (375 data + 163 api).), 2026-05-21 (v0.1.25.19 — supply-chain CVE patch; re-pin `tomcat.version=10.1.55` in `cycles-protocol-service/pom.xml` to close 7 new CVEs flagged by Trivy against `tomcat-embed-core 10.1.54` (CRITICAL: CVE-2026-43512, CVE-2026-43515, CVE-2026-41293; HIGH: CVE-2026-43513, CVE-2026-42498, CVE-2026-41284; LOW: CVE-2026-43514 — all fixed in 10.1.55 / 11.0.22). Mirrors the v0.1.25.16 pattern; the override was dropped in v0.1.25.18 when SB 3.5.14's BOM caught up to 10.1.54, now re-added one patch higher because Trivy DB updates between 2026-05-11 (last green main run) and 2026-05-21 surfaced a new wave on the same artifact. Removable once Spring Boot ships with 10.1.55+ as its managed version. `commons-lang3.version=3.18.0` retained (CVE-2025-48924 still unfixed in SB 3.5.14's managed 3.17.0). No production code or test changes; all 537 protocol-service tests pass.), 2026-04-26 (v0.1.25.18 — dependency hygiene matching `cycles-server-events` v0.1.25.12: bump `spring-boot-starter-parent` 3.5.13 → 3.5.14 (patch with upstream security hardening — constant-time comparison for remote DevTools secret, `RandomValuePropertySource` SecureRandom, hostname verification applied consistently for Cassandra/RabbitMQ SSL, plus symlink-handling fixes); **drop `10.1.54` override** since Spring Boot 3.5.14's BOM now manages 10.1.54 directly (verified against `spring-boot-dependencies-3.5.14.pom`); commons-lang3 3.18.0 override retained — Spring Boot 3.5.14's BOM still manages 3.17.0. **Jedis 7.4.1 → 6.2.0** to align all three services on the same Redis client major (events at 6.2.0 since v0.1.25.12, admin at 6.2.0 in v0.1.25.41); all call sites use stable APIs (`Jedis`, `JedisPool`, `Pipeline`, `Response`, `ScanParams`, `ScanResult`, `JedisNoScriptException`) — no 7.x-only API usage. No code changes; all 152 tests pass.), diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/auth/SecurityConfig.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/auth/SecurityConfig.java index 5c4430b..be41898 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/auth/SecurityConfig.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/auth/SecurityConfig.java @@ -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( 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 new file mode 100644 index 0000000..d242833 --- /dev/null +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/controller/JwksController.java @@ -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). + * + *

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. + * + *

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. + * + *

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> getEvidenceJwks() { + Map 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); + } +} diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java new file mode 100644 index 0000000..5bb650d --- /dev/null +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/java/io/runcycles/protocol/api/evidence/JwksDocuments.java @@ -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. + * + *

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. + * + *

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> 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 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 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(); + } +} diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties index 4bc82e0..110010f 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties +++ b/cycles-protocol-service/cycles-protocol-service-api/src/main/resources/application.properties @@ -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} diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/auth/SecurityConfigTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/auth/SecurityConfigTest.java index 6e13227..35d345e 100644 --- a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/auth/SecurityConfigTest.java +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/auth/SecurityConfigTest.java @@ -21,7 +21,8 @@ void publicPathsShouldContainExpectedEntries() { "/.well-known/**", "/actuator/health", "/actuator/prometheus", - "/v1/evidence/**" + "/v1/evidence/**", + "/v1/.well-known/**" ); } 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 new file mode 100644 index 0000000..8542777 --- /dev/null +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/controller/JwksControllerTest.java @@ -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"); + } +} diff --git a/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java new file mode 100644 index 0000000..383ed10 --- /dev/null +++ b/cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/evidence/JwksDocumentsTest.java @@ -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> set = JwksDocuments.jwkSet(RAW_HEX, "", 0L); + + assertThat(set).isPresent(); + @SuppressWarnings("unchecked") + List> keys = (List>) set.get().get("keys"); + assertThat(keys).hasSize(1); + + Map 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 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 jwk = firstKey(JwksDocuments.jwkSet(RAW_HEX.toUpperCase(), " ", 0L)); + assertThat(jwk).containsEntry("kid", RAW_HEX.substring(0, 16)); + } + + @Test + void configuredKid_overridesTheDefault() { + Map jwk = firstKey(JwksDocuments.jwkSet(RAW_HEX, "2026-06", 0L)); + assertThat(jwk).containsEntry("kid", "2026-06"); + } + + @Test + void nbfMs_isCarriedThrough() { + Map 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 firstKey(Optional> set) { + assertThat(set).isPresent(); + @SuppressWarnings("unchecked") + List> keys = (List>) set.get().get("keys"); + return keys.get(0); + } +} diff --git a/cycles-protocol-service/pom.xml b/cycles-protocol-service/pom.xml index 8348b61..fe8e8f2 100644 --- a/cycles-protocol-service/pom.xml +++ b/cycles-protocol-service/pom.xml @@ -18,7 +18,7 @@ cycles-protocol-service-api - 0.1.25.31 + 0.1.25.32 21 21 21