Skip to main content

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