1
2#![forbid(unsafe_code)]
3
4pub use json_atomic;
6
7use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine as _};
8use ed25519_dalek::{VerifyingKey, Signature};
9use once_cell::sync::Lazy;
10use parking_lot::Mutex;
11use serde::{Deserialize, Serialize};
12use serde_json::Value as Json;
13use std::{collections::HashMap, time::{SystemTime, UNIX_EPOCH}};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Claims {
17 pub sub: String,
18 #[serde(default)]
19 pub iss: Option<String>,
20 #[serde(default)]
21 pub aud: Option<Aud>,
22 #[serde(default)]
23 pub exp: Option<i64>,
24 #[serde(default)]
25 pub nbf: Option<i64>,
26 #[serde(default)]
27 pub iat: Option<i64>,
28 #[serde(default)]
29 pub jti: Option<String>,
30 #[serde(default)]
31 pub scope: Option<String>,
32 #[serde(flatten)]
33 pub extra: HashMap<String, Json>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(untagged)]
38pub enum Aud {
39 One(String),
40 Many(Vec<String>),
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VerifyOptions {
45 pub leeway_secs: i64,
46 pub issuer: Option<String>,
47 pub audience: Option<String>,
48 pub now: Option<i64>,
49}
50impl Default for VerifyOptions {
51 fn default() -> Self {
52 Self { leeway_secs: 300, issuer: None, audience: None, now: None }
53 }
54}
55impl VerifyOptions {
56 pub fn with_issuer(mut self, iss: &str) -> Self { self.issuer = Some(iss.to_string()); self }
57 pub fn with_audience(mut self, aud: &str) -> Self { self.audience = Some(aud.to_string()); self }
58 pub fn with_leeway(mut self, secs: i64) -> Self { self.leeway_secs = secs; self }
59 pub fn with_now(mut self, now: i64) -> Self { self.now = Some(now); self }
60}
61
62#[derive(Debug, thiserror::Error)]
63pub enum VerifyError {
64 #[error("bad token format")]
65 BadFormat,
66 #[error("base64 decode failed")]
67 Base64,
68 #[error("json parse failed")]
69 Json,
70 #[error("alg not allowed (expected EdDSA)")]
71 Alg,
72 #[error("missing kid in JWT header")]
73 Kid,
74 #[error("jwks http error: {0}")]
75 JwksHttp(String),
76 #[error("jwks parse error")]
77 JwksJson,
78 #[error("no matching key for kid")]
79 NoKey,
80 #[error("invalid signature")]
81 Signature,
82 #[error("claim 'exp' expired")]
83 Expired,
84 #[error("claim 'nbf' in future")]
85 NotYetValid,
86 #[error("issuer mismatch")]
87 Issuer,
88 #[error("audience mismatch")]
89 Audience,
90 #[error("missing sub")]
91 MissingSub,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Jwk { pub kty:String, #[serde(default)] pub crv:Option<String>, #[serde(default)] pub x:Option<String>, #[serde(default)] pub kid:Option<String> }
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Jwks { pub keys: Vec<Jwk> }
98
99#[derive(Debug, Clone)]
100pub struct JwksCacheEntry { pub jwks: Jwks, pub fetched_at: i64 }
101#[derive(Debug)]
102pub struct JwksCache { ttl_secs: i64, inner: Mutex<HashMap<String, JwksCacheEntry>> }
103
104static GLOBAL_JWKS: Lazy<JwksCache> = Lazy::new(|| JwksCache::new(300));
105
106impl JwksCache {
107 pub fn new(ttl_secs: i64) -> Self { Self { ttl_secs, inner: Mutex::new(HashMap::new()) } }
108 pub fn put(&self, uri: &str, jwks: Jwks) {
109 let mut m = self.inner.lock();
110 m.insert(uri.to_string(), JwksCacheEntry{ jwks, fetched_at: now_ts() });
111 }
112 pub fn get_fresh(&self, uri: &str) -> Option<Jwks> {
113 let m = self.inner.lock();
114 if let Some(entry) = m.get(uri) {
115 if now_ts() - entry.fetched_at <= self.ttl_secs {
116 return Some(entry.jwks.clone());
117 }
118 }
119 None
120 }
121}
122
123pub fn verify_ed25519_jwt_with_jwks(token: &str, jwks_uri: &str, opts: &VerifyOptions) -> Result<Claims, VerifyError> {
124 verify_ed25519_jwt_with_cache(token, jwks_uri, &GLOBAL_JWKS, opts)
125}
126
127pub fn verify_ed25519_jwt_with_cache(token: &str, jwks_uri: &str, cache: &JwksCache, opts: &VerifyOptions) -> Result<Claims, VerifyError> {
128 let (header, payload, sig, signing_input) = split_and_decode(token)?;
129
130 let alg = header.get("alg").and_then(|v| v.as_str()).ok_or(VerifyError::Alg)?;
131 if alg != "EdDSA" { return Err(VerifyError::Alg); }
132 let kid = header.get("kid").and_then(|v| v.as_str()).ok_or(VerifyError::Kid)?;
133
134 let jwks = if let Some(j) = cache.get_fresh(jwks_uri) { j } else {
135 let fetched = fetch_jwks(jwks_uri)?;
136 cache.put(jwks_uri, fetched.clone());
137 fetched
138 };
139 let vk = key_by_kid(&jwks, kid).ok_or(VerifyError::NoKey)?;
140
141 vk.verify_strict(signing_input.as_bytes(), &sig).map_err(|_| VerifyError::Signature)?;
142
143 let claims: Claims = serde_json::from_value(payload).map_err(|_| VerifyError::Json)?;
144 check_claims(&claims, opts)?;
145 Ok(claims)
146}
147
148fn split_and_decode(token: &str) -> Result<(Json, Json, Signature, String), VerifyError> {
149 let parts: Vec<&str> = token.split('.').collect();
150 if parts.len() != 3 { return Err(VerifyError::BadFormat); }
151 let header_json = String::from_utf8(B64URL.decode(parts[0].as_bytes()).map_err(|_| VerifyError::Base64)?).map_err(|_| VerifyError::Base64)?;
152 let payload_json = String::from_utf8(B64URL.decode(parts[1].as_bytes()).map_err(|_| VerifyError::Base64)?).map_err(|_| VerifyError::Base64)?;
153 let sig_bytes = B64URL.decode(parts[2].as_bytes()).map_err(|_| VerifyError::Base64)?;
154 let sig = Signature::from_bytes(sig_bytes[..].try_into().map_err(|_| VerifyError::Signature)?);
155 let header: Json = serde_json::from_str(&header_json).map_err(|_| VerifyError::Json)?;
156 let payload: Json = serde_json::from_str(&payload_json).map_err(|_| VerifyError::Json)?;
157 Ok((header, payload, sig, format!("{}.{}", parts[0], parts[1])))
158}
159
160fn fetch_jwks(uri: &str) -> Result<Jwks, VerifyError> {
161 let resp = ureq::get(uri).call().map_err(|e| VerifyError::JwksHttp(e.to_string()))?;
162 let body = resp.into_string().map_err(|e| VerifyError::JwksHttp(e.to_string()))?;
163 serde_json::from_str(&body).map_err(|_| VerifyError::JwksJson)
164}
165
166fn key_by_kid(jwks: &Jwks, kid: &str) -> Option<VerifyingKey> {
167 for k in &jwks.keys {
168 if k.kty != "OKP" { continue; }
169 if k.crv.as_deref() != Some("Ed25519") { continue; }
170 let k_kid = k.kid.as_deref().unwrap_or_default();
171 if k_kid == kid || k_kid.is_empty() {
172 if let Some(x) = &k.x {
173 if let Ok(bytes) = B64URL.decode(x.as_bytes()) {
174 if let Ok(vk) = VerifyingKey::from_bytes(bytes[..].try_into().ok()?) {
175 return Some(vk);
176 }
177 }
178 }
179 }
180 }
181 None
182}
183
184pub fn now_ts() -> i64 {
185 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() as i64;
186 now
187}
188
189fn check_claims(c: &Claims, opts: &VerifyOptions) -> Result<(), VerifyError> {
190 let now = opts.now.unwrap_or_else(now_ts);
191 if c.sub.is_empty() { return Err(VerifyError::MissingSub); }
192 if let Some(exp) = c.exp {
193 if now > exp + opts.leeway_secs { return Err(VerifyError::Expired); }
194 }
195 if let Some(nbf) = c.nbf {
196 if now + opts.leeway_secs < nbf { return Err(VerifyError::NotYetValid); }
197 }
198 if let Some(iat) = c.iat {
199 if iat > now + opts.leeway_secs { return Err(VerifyError::NotYetValid); }
200 }
201 if let Some(ref iss) = opts.issuer {
202 if c.iss.as_deref() != Some(iss) { return Err(VerifyError::Issuer); }
203 }
204 if let Some(ref aud) = opts.audience {
205 match &c.aud {
206 None => return Err(VerifyError::Audience),
207 Some(Aud::One(s)) if s != aud => return Err(VerifyError::Audience),
208 Some(Aud::Many(v)) if !v.iter().any(|x| x == aud) => return Err(VerifyError::Audience),
209 _ => {}
210 }
211 }
212 Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use rand::{SeedableRng, rngs::StdRng};
219 use ed25519_dalek::{SigningKey, Signer};
220 use serde_json::json;
221 use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64URL;
222 use json_atomic::canonize;
223
224 #[test]
225 fn roundtrip_sign_and_verify_with_cache() {
226 let mut rng = StdRng::seed_from_u64(42);
227 let sk = SigningKey::generate(&mut rng);
228 let vk = sk.verifying_key();
229 let x = B64URL.encode(vk.to_bytes());
230
231 let cache = JwksCache::new(3600);
232 cache.put("mem://jwks", Jwks{ keys: vec![ Jwk{ kty:"OKP".into(), crv:Some("Ed25519".into()), x:Some(x), kid:Some("test".into()) } ]});
233
234 let header = json!({"alg":"EdDSA","kid":"test","typ":"JWT"});
235 let now = now_ts();
236 let payload = json!({
237 "sub":"did:key:zTest",
238 "iss":"https://id.ubl.agency",
239 "aud":"demo",
240 "iat": now,
241 "nbf": now - 5,
242 "exp": now + 3600
243 });
244 let hdr = B64URL.encode(canonize(&header).unwrap());
245 let pld = B64URL.encode(canonize(&payload).unwrap());
246 let msg = format!("{}.{}", hdr, pld);
247 let sig = sk.sign(msg.as_bytes());
248 let jwt = format!("{}.{}", msg, B64URL.encode(sig.to_bytes()));
249
250 let opts = VerifyOptions::default().with_issuer("https://id.ubl.agency").with_audience("demo");
251 let claims = verify_ed25519_jwt_with_cache(&jwt, "mem://jwks", &cache, &opts).expect("verify");
252 assert_eq!(claims.sub, "did:key:zTest");
253 }
254}