ppoppo_token/jwks.rs
1//! JWKS (JSON Web Key Set) — RFC 7517 + RFC 8037 OKP/Ed25519 publication.
2//!
3//! Phase 6/8 (RFC_2026-05-04_jwt-full-adoption §6.7 + §6.9). PAS publishes
4//! its trusted Ed25519 verification keys at `/.well-known/jwks.json`;
5//! consumers (chat-auth, ppoppo-pas-external SDK) fetch + cache to populate
6//! `KeySet`.
7//!
8//! ── Pure RFC 7517 design (no extension fields) ─────────────────────────
9//!
10//! No `status`, no `cache_ttl_seconds`. The rotation lifecycle is "key in
11//! JWKS = trusted; key removed = revoked", and TTL is communicated via
12//! the `Cache-Control: max-age=N` HTTP header. The result is consumable
13//! by any RFC 7517 library (`jsonwebtoken::jwk::JwkSet`, jose-jwk,
14//! python-jose, ...) — no ppoppo-specific knowledge required.
15//!
16//! ── Shape (RFC 8037 §2 for Ed25519) ─────────────────────────────────────
17//!
18//! ```json
19//! {
20//! "keys": [
21//! {"kty":"OKP","crv":"Ed25519","use":"sig","alg":"EdDSA","kid":"...","x":"<b64url(32B pubkey)>"}
22//! ]
23//! }
24//! ```
25//!
26//! `kty=OKP` (Octet Key Pair, RFC 8037), `crv=Ed25519`, `alg=EdDSA`. The
27//! 32-byte public key is base64url-encoded without padding into `x`.
28//! `use=sig` (signature; RFC 7517 §4.2).
29
30use base64::Engine;
31use jsonwebtoken::DecodingKey;
32use serde::{Deserialize, Serialize};
33
34use crate::{Algorithm, KeySet};
35
36/// JSON Web Key Set — collection of trusted public keys per RFC 7517 §5.
37///
38/// Equality + Clone derive enables ergonomic Arc-wrapping at the wiring
39/// site without polluting the public surface.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct Jwks {
42 pub keys: Vec<Jwk>,
43}
44
45impl Jwks {
46 /// Build a JWKS from a slice of (kid, 32-byte Ed25519 public key)
47 /// tuples. Keys land in the order supplied — callers control which
48 /// keys appear (typically: filter Revoked at the call site, supply
49 /// Active + Retiring to the builder).
50 #[must_use]
51 pub fn from_ed25519_keys(keys: &[(&str, &[u8; 32])]) -> Self {
52 Self {
53 keys: keys.iter().map(|(kid, pk)| Jwk::ed25519(kid, pk)).collect(),
54 }
55 }
56
57 /// Find the key with the matching kid that satisfies `use=sig`.
58 /// Returns the 32-byte Ed25519 public key bytes when present and
59 /// well-formed; `None` for missing kid or wrong key type. Used by
60 /// consumer-side verification flows to bind a token's `kid` header
61 /// to a trusted public key.
62 #[must_use]
63 pub fn find_ed25519(&self, kid: &str) -> Option<[u8; 32]> {
64 let jwk = self.keys.iter().find(|k| k.kid == kid)?;
65 jwk.ed25519_bytes()
66 }
67
68 /// Convert the JWKS into the engine's `KeySet`. Every well-formed
69 /// `kty=OKP / crv=Ed25519` entry becomes a `(kid, DecodingKey)`
70 /// binding; entries with any other shape are silently skipped (the
71 /// engine cannot verify them anyway, and a future JWKS may legitimately
72 /// carry mixed key types — RSA for legacy clients, EC for some federated
73 /// IdP). The skip-or-fail tradeoff favours skip: a single malformed
74 /// entry must not break key rotation for the well-formed siblings.
75 ///
76 /// Returns `Err(JwksError::DuplicateKid)` only if two entries share a
77 /// kid — that is a control-plane bug (every kid is supposed to be
78 /// globally unique), and admitting both would create non-determinism
79 /// in `KeySet::get`.
80 pub fn into_key_set(self) -> Result<KeySet, JwksError> {
81 let mut key_set = KeySet::new();
82 let mut seen: std::collections::HashSet<String> = Default::default();
83 for jwk in self.keys {
84 let Some(pk_bytes) = jwk.ed25519_bytes() else {
85 continue;
86 };
87 if !seen.insert(jwk.kid.clone()) {
88 return Err(JwksError::DuplicateKid(jwk.kid));
89 }
90 // `from_ed_der` is a misnomer for Ed25519: jsonwebtoken hands the
91 // bytes to `VerifyingKey::from_bytes(&bytes[..32])`, so it wants
92 // the raw 32-byte public key (the JWK `x`), not an SPKI blob. The
93 // SPKI prefix once prepended here made every JWKS-derived key
94 // wrong — invisible until M75 began verifying signatures, and
95 // pinned by `tests/keyset_jwks.rs`.
96 key_set.insert(jwk.kid, DecodingKey::from_ed_der(&pk_bytes));
97 }
98 Ok(key_set)
99 }
100}
101
102/// JWKS-side errors surfaced to consumers of `into_key_set`.
103///
104/// Distinct from `AuthError` because this fires at *configuration* time
105/// (boot / cache refresh), not per-request verify time. Operators see
106/// these in startup logs; users never do.
107#[derive(Debug, thiserror::Error, PartialEq, Eq)]
108pub enum JwksError {
109 /// Two JWK entries share a kid. Engine refuses to insert both
110 /// because `KeySet::get` would be non-deterministic. Operator must
111 /// fix the upstream JWKS source.
112 #[error("duplicate kid in JWKS: '{0}'")]
113 DuplicateKid(String),
114}
115
116/// A single JWK entry. Pinned to the OKP/Ed25519/EdDSA shape — other
117/// `kty` values (`EC`, `RSA`, `oct`) deserialize but `ed25519_bytes()`
118/// returns `None` so the engine never accidentally accepts a non-Ed25519
119/// key.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Jwk {
122 pub kty: String,
123 pub crv: String,
124 #[serde(rename = "use", default)]
125 pub use_: String,
126 pub alg: String,
127 pub kid: String,
128 pub x: String,
129}
130
131impl Jwk {
132 /// Construct an Ed25519 JWK from its raw 32-byte public key. The
133 /// `kty=OKP`, `crv=Ed25519`, `use=sig` fields are RFC 8037 §2 curve
134 /// facts (verbatim, independent of the JWS algorithm); `alg` projects
135 /// from [`Algorithm::as_jose_str`] so the JOSE `alg` string lives in one
136 /// place across the JWKS document and the discovery metadata.
137 #[must_use]
138 pub fn ed25519(kid: &str, public_key: &[u8; 32]) -> Self {
139 Self {
140 kty: "OKP".to_string(),
141 crv: "Ed25519".to_string(),
142 use_: "sig".to_string(),
143 alg: Algorithm::EdDSA.as_jose_str().to_string(),
144 kid: kid.to_string(),
145 x: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key),
146 }
147 }
148
149 /// Decode the 32-byte Ed25519 public key carried in `x` when this
150 /// JWK is shaped as `kty=OKP / crv=Ed25519`. Returns `None` for any
151 /// other shape so consumers cannot accidentally feed a `kty=EC` or
152 /// `kty=RSA` key to an Ed25519 verifier.
153 #[must_use]
154 pub fn ed25519_bytes(&self) -> Option<[u8; 32]> {
155 if self.kty != "OKP" || self.crv != "Ed25519" {
156 return None;
157 }
158 let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
159 .decode(self.x.as_bytes())
160 .ok()?;
161 decoded.try_into().ok()
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
168 use super::*;
169
170 fn sample_pubkey() -> [u8; 32] {
171 // Deterministic test vector — first 32 bytes of a known Ed25519
172 // public key. Value is irrelevant to the codec test; we only
173 // care about round-trip fidelity.
174 let mut bytes = [0u8; 32];
175 for (i, b) in bytes.iter_mut().enumerate() {
176 *b = i as u8;
177 }
178 bytes
179 }
180
181 #[test]
182 fn ed25519_jwk_carries_rfc_8037_shape() {
183 let jwk = Jwk::ed25519("k4.pid.test", &sample_pubkey());
184 assert_eq!(jwk.kty, "OKP", "RFC 8037 §2: kty MUST be OKP for Ed25519");
185 assert_eq!(jwk.crv, "Ed25519");
186 assert_eq!(jwk.use_, "sig", "RFC 7517 §4.2: signature key");
187 assert_eq!(jwk.alg, "EdDSA", "RFC 8037 §3.1: alg = EdDSA");
188 assert_eq!(jwk.kid, "k4.pid.test");
189 }
190
191 #[test]
192 fn ed25519_x_round_trips_through_base64url() {
193 let pk = sample_pubkey();
194 let jwk = Jwk::ed25519("kid-1", &pk);
195 let recovered = jwk.ed25519_bytes().expect("must decode");
196 assert_eq!(recovered, pk, "x must round-trip the raw public key bytes");
197 }
198
199 #[test]
200 fn ed25519_x_is_base64url_no_pad() {
201 // RFC 7515 §2 + RFC 7518: JWK fields use base64url without
202 // padding. A `=` in the encoded form would be a wire-shape bug.
203 let jwk = Jwk::ed25519("k", &sample_pubkey());
204 assert!(
205 !jwk.x.contains('='),
206 "base64url MUST NOT carry padding: {}",
207 jwk.x
208 );
209 assert!(
210 !jwk.x.contains('+') && !jwk.x.contains('/'),
211 "base64url MUST NOT use std-b64 chars: {}",
212 jwk.x
213 );
214 }
215
216 #[test]
217 fn non_ed25519_kty_returns_none_from_bytes() {
218 // Belt-and-suspenders for the engine: even if a JWKS publishes
219 // an EC key with `x` carrying 32 bytes, ed25519_bytes refuses.
220 let mut jwk = Jwk::ed25519("kid", &sample_pubkey());
221 jwk.kty = "EC".to_string();
222 assert!(jwk.ed25519_bytes().is_none(), "non-OKP must return None");
223 }
224
225 #[test]
226 fn non_ed25519_crv_returns_none_from_bytes() {
227 let mut jwk = Jwk::ed25519("kid", &sample_pubkey());
228 jwk.crv = "X25519".to_string(); // OKP-but-key-agreement (RFC 8037 §3.2)
229 assert!(
230 jwk.ed25519_bytes().is_none(),
231 "X25519 is OKP but for ECDH, not signing — must return None",
232 );
233 }
234
235 #[test]
236 fn jwks_round_trips_through_json() {
237 let original =
238 Jwks::from_ed25519_keys(&[("kid-a", &sample_pubkey()), ("kid-b", &sample_pubkey())]);
239 let json = serde_json::to_string(&original).unwrap();
240 let parsed: Jwks = serde_json::from_str(&json).unwrap();
241 assert_eq!(parsed, original, "JWKS must serde round-trip");
242 }
243
244 #[test]
245 fn jwks_find_returns_matching_key() {
246 let pk = sample_pubkey();
247 let jwks = Jwks::from_ed25519_keys(&[("active-kid", &pk)]);
248 let found = jwks
249 .find_ed25519("active-kid")
250 .expect("active-kid must be findable");
251 assert_eq!(found, pk);
252 }
253
254 #[test]
255 fn jwks_find_returns_none_for_unknown_kid() {
256 let jwks = Jwks::from_ed25519_keys(&[("only-kid", &sample_pubkey())]);
257 assert!(jwks.find_ed25519("missing-kid").is_none());
258 }
259
260 #[test]
261 fn into_key_set_admits_well_formed_ed25519_entries() {
262 let jwks =
263 Jwks::from_ed25519_keys(&[("kid-a", &sample_pubkey()), ("kid-b", &sample_pubkey())]);
264 let key_set = jwks.into_key_set().expect("well-formed JWKS must convert");
265 // KeySet::get is pub(crate); we don't have direct visibility into
266 // its contents from here, but the absence of an error and the
267 // duplicate-kid guard fires only when entries land — so success
268 // here implies both entries inserted.
269 let _ = key_set;
270 }
271
272 #[test]
273 fn into_key_set_skips_non_ed25519_entries() {
274 // A JWKS legitimately may carry other key types in a federation
275 // scenario. ed25519_bytes returns None for them, so they get
276 // silently skipped. Test by hand-constructing an EC-shaped entry
277 // alongside a valid Ed25519 entry.
278 let pk = sample_pubkey();
279 let mut jwks = Jwks {
280 keys: vec![
281 Jwk::ed25519("ed-kid", &pk),
282 Jwk {
283 kty: "EC".to_string(),
284 crv: "P-256".to_string(),
285 use_: "sig".to_string(),
286 alg: "ES256".to_string(),
287 kid: "ec-kid".to_string(),
288 x: "irrelevant".to_string(),
289 },
290 ],
291 };
292 // Sanity: the EC entry would fail Ed25519 decode.
293 assert!(jwks.keys[1].ed25519_bytes().is_none());
294 // Conversion succeeds — only the Ed25519 entry lands in KeySet.
295 // (Non-Ed25519 silently skipped, no error.)
296 let _ = jwks.into_key_set().expect("mixed-type JWKS must convert");
297 // Add a duplicate to prove the dup-kid path is reachable.
298 jwks = Jwks::from_ed25519_keys(&[("dup", &pk), ("dup", &pk)]);
299 // KeySet doesn't impl Debug/PartialEq (it carries opaque
300 // DecodingKey values from jsonwebtoken), so we assert on the
301 // Err variant directly instead of through Result equality.
302 let err = jwks
303 .into_key_set()
304 .expect_err("duplicate kid must surface as Err");
305 assert_eq!(err, JwksError::DuplicateKid("dup".to_string()));
306 }
307
308 #[test]
309 fn into_key_set_round_trips_through_jwks_json_for_engine_verify() {
310 // End-to-end smoke: a real signing key's public half goes into a
311 // Jwks document, then comes back out as a KeySet that the engine
312 // can use to verify a token signed by the matching private half.
313 // This is the integration that 8.3 / 6.4 rely on.
314 use crate::SigningKey;
315 let (signer, _direct_key_set) = SigningKey::test_pair();
316
317 // Reach into the test_pair PEM constants and re-derive the public
318 // key bytes for the JWKS path. We use the documented test_pair
319 // public-key PEM (from signing_key.rs) — base64 of the SPKI DER's
320 // last 32 bytes.
321 const TEST_PUBLIC_KEY_DER_B64: &str =
322 "MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=";
323 use base64::Engine as _;
324 let der = base64::engine::general_purpose::STANDARD
325 .decode(TEST_PUBLIC_KEY_DER_B64)
326 .unwrap();
327 // Last 32 bytes of the 44-byte SPKI are the raw public key.
328 let pk_bytes: [u8; 32] = der[12..].try_into().unwrap();
329
330 let jwks = Jwks::from_ed25519_keys(&[(signer.kid(), &pk_bytes)]);
331 let _key_set = jwks.into_key_set().expect("well-formed JWKS must convert");
332 // A full sign-then-verify round-trip lives in tests/keyset_jwks.rs;
333 // here we only verify the conversion path itself.
334 }
335
336 #[test]
337 fn jwks_json_shape_is_rfc_7517_compliant() {
338 // Snapshot test — locks in the wire shape so a future refactor
339 // (e.g. swapping to a different serde struct) cannot silently
340 // drift to a non-standard shape.
341 let pk = sample_pubkey();
342 let jwks = Jwks::from_ed25519_keys(&[("test-kid", &pk)]);
343 let value: serde_json::Value = serde_json::to_value(&jwks).unwrap();
344 let key = &value["keys"][0];
345 assert_eq!(key["kty"], "OKP");
346 assert_eq!(key["crv"], "Ed25519");
347 assert_eq!(key["use"], "sig");
348 assert_eq!(key["alg"], "EdDSA");
349 assert_eq!(key["kid"], "test-kid");
350 assert!(key["x"].is_string());
351 // No status, no cache_ttl_seconds — those are out (deliberate).
352 assert!(value.get("cache_ttl_seconds").is_none());
353 assert!(key.get("status").is_none());
354 }
355}