11package io .runcycles .protocol .api .evidence ;
22
3+ import java .util .ArrayList ;
34import java .util .Base64 ;
5+ import java .util .HashMap ;
46import java .util .HexFormat ;
57import java .util .LinkedHashMap ;
8+ import java .util .LinkedHashSet ;
69import java .util .List ;
710import java .util .Map ;
811import java .util .Optional ;
12+ import java .util .Set ;
913import java .util .regex .Pattern ;
1014
1115/**
1519 * (cycles-evidence v0.2). Pure function over the configured signing identity;
1620 * no Spring, no I/O.
1721 *
18- * <p>v0.1 scope: publishes the single currently-configured RAW-HEX
19- * {@code signer_did} as one active Ed25519 OKP JWK. A {@code did:cycles}
20- * {@code signer_did} carries no key bytes, so it cannot be published from
21- * {@code signer_did} alone — that (and retired-key rotation history) is the
22- * v0.2-store follow-up; until then this returns {@link Optional#empty()} and
23- * the endpoint 404s, leaving consumers on the raw-hex + {@code expected_signer}
24- * pinning path.
22+ * <p>Publishes the currently-configured RAW-HEX {@code signer_did} as the single
23+ * ACTIVE Ed25519 OKP JWK (open-ended window), PLUS any configured RETIRED keys —
24+ * each with a bounded {@code [cycles_nbf_ms, cycles_exp_ms)} window — so that
25+ * evidence signed before a key rotation still verifies against the key that was
26+ * valid at its {@code issued_at_ms}. Retaining retired keys is the load-bearing
27+ * rotation rule: a verifier selects the key whose window covers the envelope's
28+ * issuance time, never "the current key". A {@code did:cycles} {@code signer_did}
29+ * carries no key bytes, so the active key cannot be published from it alone;
30+ * the set is empty (endpoint 404s) until a raw-hex active key is configured.
2531 *
26- * <p>Key bytes match what {@code EnvelopeSigner} signs with: the JWK {@code x}
27- * is {@code base64url(hex-decode(signer_did ))}, the same 32 raw public-key
28- * bytes — so a verifier resolving this set authenticates the same signatures.
32+ * <p>Key bytes match what {@code EnvelopeSigner} signs with: a JWK {@code x} is
33+ * {@code base64url(hex-decode(<raw-hex pubkey> ))}, the same 32 raw bytes — so a
34+ * verifier resolving this set authenticates the same signatures.
2935 */
3036public final class JwksDocuments {
3137
@@ -34,41 +40,132 @@ public final class JwksDocuments {
3440 private JwksDocuments () {
3541 }
3642
43+ /**
44+ * A previously-active signing key, retained in the published set so evidence
45+ * signed during its validity window still resolves after rotation.
46+ *
47+ * @param signerDid the retired key as a raw 64-hex Ed25519 public key
48+ * @param kid its stable key id (must be unique across the set)
49+ * @param nbfMs valid-from (epoch ms, inclusive)
50+ * @param expMs valid-until (epoch ms, EXCLUSIVE) — REQUIRED for a retired
51+ * key (a retired key has a closed window); a null exp is a
52+ * config error and the entry is skipped.
53+ */
54+ public record RetiredKey (String signerDid , String kid , long nbfMs , Long expMs ) {
55+ }
56+
3757 /** True when {@code signerDid} is a publishable raw 64-hex Ed25519 key. */
3858 public static boolean isRawHexKey (String signerDid ) {
3959 return signerDid != null && RAW_HEX_32 .matcher (signerDid .trim ()).matches ();
4060 }
4161
62+ /** Single active-key set (no rotation history). */
63+ public static Optional <Map <String , Object >> jwkSet (String signerDid , String kid , long nbfMs ) {
64+ return jwkSet (signerDid , kid , nbfMs , List .of ());
65+ }
66+
4267 /**
43- * The signer's JWK Set, or empty when no raw-hex signing key is configured
44- * (blank, or a {@code did:cycles} form that carries no key bytes).
68+ * The signer's JWK Set — the active key plus any retired keys — or empty
69+ * when no raw-hex active key is configured. Invalid retired entries are
70+ * skipped defensively (a bad history entry never breaks publication of the
71+ * active key): malformed hex; a missing {@code expMs} (a retired key needs a
72+ * closed window); an empty/inverted window ({@code expMs <= nbfMs}, since
73+ * {@code cycles_exp_ms} is EXCLUSIVE); the SAME key material as an earlier
74+ * retired key with an OVERLAPPING window — raw-hex selection by key bytes
75+ * plus {@code issued_at_ms} would be ambiguous, though disjoint windows for
76+ * a reused key are fine; or a {@code kid} colliding with the active key or an
77+ * earlier retired key (a duplicate {@code kid} is never emitted — set-wide
78+ * kid uniqueness is required).
4579 *
46- * @param signerDid the configured {@code cycles.evidence.signing.signer-did}
47- * @param kid configured key id, or blank to derive a stable default
48- * @param nbfMs {@code cycles_nbf_ms} validity-from (epoch ms, inclusive)
80+ * @param signerDid the active key ({@code cycles.evidence.signing.signer-did})
81+ * @param kid active key id, or blank to derive a stable default
82+ * @param nbfMs active {@code cycles_nbf_ms} (epoch ms, inclusive); if it
83+ * was left below the latest retired key's {@code exp_ms} the
84+ * published active window is advanced to that boundary, so the
85+ * active key is never valid for pre-rotation {@code issued_at_ms}
86+ * @param retired retired keys to retain in the set (may be empty/null)
4987 */
50- public static Optional <Map <String , Object >> jwkSet (String signerDid , String kid , long nbfMs ) {
88+ public static Optional <Map <String , Object >> jwkSet (
89+ String signerDid , String kid , long nbfMs , List <RetiredKey > retired ) {
5190 if (!isRawHexKey (signerDid )) {
5291 return Optional .empty ();
5392 }
54- String did = signerDid .trim ();
55- byte [] publicKey = HexFormat .of ().parseHex (did );
56- String x = Base64 .getUrlEncoder ().withoutPadding ().encodeToString (publicKey );
93+ String activeDid = signerDid .trim ();
94+ String activeKid = (kid == null || kid .isBlank ()) ? defaultKid (activeDid ) : kid .trim ();
95+
96+ // Safety floor (fail-safe, not just a warning): the active key MUST NOT be
97+ // published as valid before the latest retired key's window ends, or the
98+ // current key could sign a backdated envelope (issued_at_ms before the
99+ // rotation) that still resolves as authentic. If the configured nbf-ms was
100+ // left below that boundary, advance the published active window up to it.
101+ // Floor on EVERY declared bounded retired window — even one whose key
102+ // material is malformed (so it won't be published): a typo in rotation
103+ // history must not reopen the pre-rotation backdating hole on the active key.
104+ long latestRetiredExp = (retired == null ? List .<RetiredKey >of () : retired ).stream ()
105+ .filter (r -> r != null && r .expMs () != null && r .expMs () > r .nbfMs ())
106+ .mapToLong (RetiredKey ::expMs )
107+ .max ()
108+ .orElse (Long .MIN_VALUE );
109+ long activeNbf = Math .max (nbfMs , latestRetiredExp );
110+
111+ List <Map <String , Object >> keys = new ArrayList <>();
112+ Set <String > kids = new LinkedHashSet <>();
113+ // Emitted [nbf, exp) windows per key material (lowercased hex), so the same
114+ // key republished with an OVERLAPPING window is never emitted twice — raw-hex
115+ // selection is key-bytes + issued_at_ms, which would otherwise be ambiguous.
116+ // The active key needs no entry: the nbf clamp above puts its window at/after
117+ // every retired exp, so it is always disjoint from a same-material retired key.
118+ Map <String , List <long []>> windowsByMaterial = new HashMap <>();
119+ keys .add (buildJwk (activeDid , activeKid , activeNbf , null , "active" ));
120+ kids .add (activeKid );
57121
122+ if (retired != null ) {
123+ for (RetiredKey r : retired ) {
124+ if (r == null || !isRawHexKey (r .signerDid ()) || r .expMs () == null ) {
125+ continue ; // malformed hex or no closed window — skip
126+ }
127+ long rNbf = r .nbfMs ();
128+ long rExp = r .expMs ();
129+ if (rExp <= rNbf ) {
130+ continue ; // empty or inverted window (exp is EXCLUSIVE) — skip
131+ }
132+ String rDid = r .signerDid ().trim ();
133+ String material = rDid .toLowerCase ();
134+ List <long []> seen = windowsByMaterial .get (material );
135+ if (seen != null && seen .stream ().anyMatch (w -> rNbf < w [1 ] && w [0 ] < rExp )) {
136+ continue ; // same key material with an overlapping window (active or retired) — ambiguous, skip
137+ }
138+ String rKid = (r .kid () == null || r .kid ().isBlank ()) ? defaultKid (rDid ) : r .kid ().trim ();
139+ if (!kids .add (rKid )) {
140+ continue ; // duplicate kid — never emit (set-wide uniqueness)
141+ }
142+ keys .add (buildJwk (rDid , rKid , rNbf , rExp , "retired" ));
143+ windowsByMaterial .computeIfAbsent (material , k -> new ArrayList <>()).add (new long []{rNbf , rExp });
144+ }
145+ }
146+
147+ Map <String , Object > jwks = new LinkedHashMap <>();
148+ jwks .put ("keys" , keys );
149+ return Optional .of (jwks );
150+ }
151+
152+ /** One Ed25519 OKP JWK. {@code expMs == null} ⇒ active (open-ended, exp
153+ * omitted); otherwise a bounded window with {@code status: retired}. */
154+ private static Map <String , Object > buildJwk (String didHex , String kid , long nbfMs , Long expMs , String status ) {
155+ byte [] publicKey = HexFormat .of ().parseHex (didHex );
58156 Map <String , Object > jwk = new LinkedHashMap <>();
59157 jwk .put ("kty" , "OKP" );
60158 jwk .put ("crv" , "Ed25519" );
61159 jwk .put ("alg" , "EdDSA" );
62- jwk .put ("x" , x );
63- jwk .put ("kid" , ( kid == null || kid . isBlank ()) ? defaultKid ( did ) : kid . trim () );
160+ jwk .put ("x" , Base64 . getUrlEncoder (). withoutPadding (). encodeToString ( publicKey ) );
161+ jwk .put ("kid" , kid );
64162 jwk .put ("cycles_nbf_ms" , nbfMs );
65- // cycles_exp_ms omitted ⇒ active (open-ended); `status` is advisory only —
66- // selection is by validity window, never by status.
67- jwk .put ("status" , "active" );
68-
69- Map <String , Object > jwks = new LinkedHashMap <>();
70- jwks .put ("keys" , List .of (jwk ));
71- return Optional .of (jwks );
163+ if (expMs != null ) {
164+ jwk .put ("cycles_exp_ms" , expMs );
165+ }
166+ // `status` is advisory only — selection is by validity window, never by status.
167+ jwk .put ("status" , status );
168+ return jwk ;
72169 }
73170
74171 /** Stable default key id when none is configured: the first 16 hex chars of
0 commit comments