use base64::Engine;
use jsonwebtoken::DecodingKey;
use serde::{Deserialize, Serialize};
use crate::KeySet;
const ED25519_SPKI_PREFIX: [u8; 12] =
[0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Jwks {
pub keys: Vec<Jwk>,
}
impl Jwks {
#[must_use]
pub fn from_ed25519_keys(keys: &[(&str, &[u8; 32])]) -> Self {
Self {
keys: keys
.iter()
.map(|(kid, pk)| Jwk::ed25519(kid, *pk))
.collect(),
}
}
#[must_use]
pub fn find_ed25519(&self, kid: &str) -> Option<[u8; 32]> {
let jwk = self.keys.iter().find(|k| k.kid == kid)?;
jwk.ed25519_bytes()
}
pub fn into_key_set(self) -> Result<KeySet, JwksError> {
let mut key_set = KeySet::new();
let mut seen: std::collections::HashSet<String> = Default::default();
for jwk in self.keys {
let Some(pk_bytes) = jwk.ed25519_bytes() else {
continue;
};
if !seen.insert(jwk.kid.clone()) {
return Err(JwksError::DuplicateKid(jwk.kid));
}
let mut der = Vec::with_capacity(ED25519_SPKI_PREFIX.len() + pk_bytes.len());
der.extend_from_slice(&ED25519_SPKI_PREFIX);
der.extend_from_slice(&pk_bytes);
key_set.insert(jwk.kid, DecodingKey::from_ed_der(&der));
}
Ok(key_set)
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum JwksError {
#[error("duplicate kid in JWKS: '{0}'")]
DuplicateKid(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Jwk {
pub kty: String,
pub crv: String,
#[serde(rename = "use", default)]
pub use_: String,
pub alg: String,
pub kid: String,
pub x: String,
}
impl Jwk {
#[must_use]
pub fn ed25519(kid: &str, public_key: &[u8; 32]) -> Self {
Self {
kty: "OKP".to_string(),
crv: "Ed25519".to_string(),
use_: "sig".to_string(),
alg: "EdDSA".to_string(),
kid: kid.to_string(),
x: base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key),
}
}
#[must_use]
pub fn ed25519_bytes(&self) -> Option<[u8; 32]> {
if self.kty != "OKP" || self.crv != "Ed25519" {
return None;
}
let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(self.x.as_bytes())
.ok()?;
decoded.try_into().ok()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn sample_pubkey() -> [u8; 32] {
let mut bytes = [0u8; 32];
for (i, b) in bytes.iter_mut().enumerate() {
*b = i as u8;
}
bytes
}
#[test]
fn ed25519_jwk_carries_rfc_8037_shape() {
let jwk = Jwk::ed25519("k4.pid.test", &sample_pubkey());
assert_eq!(jwk.kty, "OKP", "RFC 8037 §2: kty MUST be OKP for Ed25519");
assert_eq!(jwk.crv, "Ed25519");
assert_eq!(jwk.use_, "sig", "RFC 7517 §4.2: signature key");
assert_eq!(jwk.alg, "EdDSA", "RFC 8037 §3.1: alg = EdDSA");
assert_eq!(jwk.kid, "k4.pid.test");
}
#[test]
fn ed25519_x_round_trips_through_base64url() {
let pk = sample_pubkey();
let jwk = Jwk::ed25519("kid-1", &pk);
let recovered = jwk.ed25519_bytes().expect("must decode");
assert_eq!(recovered, pk, "x must round-trip the raw public key bytes");
}
#[test]
fn ed25519_x_is_base64url_no_pad() {
let jwk = Jwk::ed25519("k", &sample_pubkey());
assert!(
!jwk.x.contains('='),
"base64url MUST NOT carry padding: {}",
jwk.x
);
assert!(
!jwk.x.contains('+') && !jwk.x.contains('/'),
"base64url MUST NOT use std-b64 chars: {}",
jwk.x
);
}
#[test]
fn non_ed25519_kty_returns_none_from_bytes() {
let mut jwk = Jwk::ed25519("kid", &sample_pubkey());
jwk.kty = "EC".to_string();
assert!(jwk.ed25519_bytes().is_none(), "non-OKP must return None");
}
#[test]
fn non_ed25519_crv_returns_none_from_bytes() {
let mut jwk = Jwk::ed25519("kid", &sample_pubkey());
jwk.crv = "X25519".to_string(); assert!(
jwk.ed25519_bytes().is_none(),
"X25519 is OKP but for ECDH, not signing — must return None",
);
}
#[test]
fn jwks_round_trips_through_json() {
let original = Jwks::from_ed25519_keys(&[
("kid-a", &sample_pubkey()),
("kid-b", &sample_pubkey()),
]);
let json = serde_json::to_string(&original).unwrap();
let parsed: Jwks = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, original, "JWKS must serde round-trip");
}
#[test]
fn jwks_find_returns_matching_key() {
let pk = sample_pubkey();
let jwks = Jwks::from_ed25519_keys(&[("active-kid", &pk)]);
let found = jwks
.find_ed25519("active-kid")
.expect("active-kid must be findable");
assert_eq!(found, pk);
}
#[test]
fn jwks_find_returns_none_for_unknown_kid() {
let jwks = Jwks::from_ed25519_keys(&[("only-kid", &sample_pubkey())]);
assert!(jwks.find_ed25519("missing-kid").is_none());
}
#[test]
fn into_key_set_admits_well_formed_ed25519_entries() {
let jwks = Jwks::from_ed25519_keys(&[
("kid-a", &sample_pubkey()),
("kid-b", &sample_pubkey()),
]);
let key_set = jwks.into_key_set().expect("well-formed JWKS must convert");
let _ = key_set;
}
#[test]
fn into_key_set_skips_non_ed25519_entries() {
let pk = sample_pubkey();
let mut jwks = Jwks {
keys: vec![
Jwk::ed25519("ed-kid", &pk),
Jwk {
kty: "EC".to_string(),
crv: "P-256".to_string(),
use_: "sig".to_string(),
alg: "ES256".to_string(),
kid: "ec-kid".to_string(),
x: "irrelevant".to_string(),
},
],
};
assert!(jwks.keys[1].ed25519_bytes().is_none());
let _ = jwks.into_key_set().expect("mixed-type JWKS must convert");
jwks = Jwks::from_ed25519_keys(&[("dup", &pk), ("dup", &pk)]);
let err = jwks
.into_key_set()
.err()
.expect("duplicate kid must surface as Err");
assert_eq!(err, JwksError::DuplicateKid("dup".to_string()));
}
#[test]
fn into_key_set_round_trips_through_jwks_json_for_engine_verify() {
use crate::SigningKey;
let (signer, _direct_key_set) = SigningKey::test_pair();
const TEST_PUBLIC_KEY_DER_B64: &str = "MCowBQYDK2VwAyEAh//e6j3It3xhjghg8Kpn2pM0jMCH/cvemGu4vv7D1Q4=";
use base64::Engine as _;
let der = base64::engine::general_purpose::STANDARD
.decode(TEST_PUBLIC_KEY_DER_B64)
.unwrap();
let pk_bytes: [u8; 32] = der[12..].try_into().unwrap();
let jwks = Jwks::from_ed25519_keys(&[(signer.kid(), &pk_bytes)]);
let _key_set = jwks.into_key_set().expect("well-formed JWKS must convert");
}
#[test]
fn jwks_json_shape_is_rfc_7517_compliant() {
let pk = sample_pubkey();
let jwks = Jwks::from_ed25519_keys(&[("test-kid", &pk)]);
let value: serde_json::Value = serde_json::to_value(&jwks).unwrap();
let key = &value["keys"][0];
assert_eq!(key["kty"], "OKP");
assert_eq!(key["crv"], "Ed25519");
assert_eq!(key["use"], "sig");
assert_eq!(key["alg"], "EdDSA");
assert_eq!(key["kid"], "test-kid");
assert!(key["x"].is_string());
assert!(value.get("cache_ttl_seconds").is_none());
assert!(key.get("status").is_none());
}
}