ubl_auth/
lib.rs

1#![forbid(unsafe_code)]
2use serde::{Deserialize, Serialize};
3use serde_json::Value as Json;
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64URL, Engine as _};
5
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct Claims {
8    pub iss: Option<String>,
9    pub sub: String,
10    pub aud: Option<Json>,
11    pub iat: Option<i64>,
12    pub exp: Option<i64>,
13    pub jti: Option<String>,
14    #[serde(flatten)]
15    pub extra: Json,
16}
17
18fn b64url_decode_to_vec(s: &str) -> Result<Vec<u8>, String> {
19    B64URL.decode(s.as_bytes()).map_err(|_| "bad b64".into())
20}
21
22/// Verify a JWT signed with Ed25519 against a JWKS json (EdDSA/OKP keys).
23/// Returns decoded claims if signature verifies and issuer/audience (if provided) match.
24pub fn verify_ed25519_jwt_with_jwks(token: &str, jwks_json: &str, expected_iss: Option<&str>, expected_aud: Option<&str>) -> Result<Claims, String> {
25    let parts: Vec<&str> = token.split('.').collect();
26    if parts.len() != 3 { return Err("bad jwt".into()); }
27    let header_json = String::from_utf8(b64url_decode_to_vec(parts[0]).map_err(|_| "bad header b64")?).map_err(|_| "utf8 header")?;
28    let payload_json = String::from_utf8(b64url_decode_to_vec(parts[1]).map_err(|_| "bad payload b64")?).map_err(|_| "utf8 payload")?;
29    let header: Json = serde_json::from_str(&header_json).map_err(|_| "json header")?;
30    let payload: Claims = serde_json::from_str(&payload_json).map_err(|_| "json payload")?;
31
32    if let Some(iss) = expected_iss {
33        if payload.iss.as_deref() != Some(iss) { return Err("iss mismatch".into()); }
34    }
35    if let Some(aud) = expected_aud {
36        let ok = match &payload.aud {
37            Some(Json::String(a)) => a == aud,
38            Some(Json::Array(arr)) => arr.iter().any(|v| v.as_str() == Some(aud)),
39            _ => false
40        };
41        if !ok { return Err("aud mismatch".into()); }
42    }
43
44    // Resolve Ed25519 key from JWKS by kid (or thumbprint)
45    let kid = header.get("kid").and_then(|v| v.as_str()).ok_or_else(|| "missing kid".to_string())?;
46    let jwks: Json = serde_json::from_str(jwks_json).map_err(|_| "bad jwks")?;
47    let keys = jwks.get("keys").and_then(|v| v.as_array()).ok_or_else(|| "no keys".to_string())?;
48    let mut x_b64: Option<&str> = None;
49    for k in keys {
50        if k.get("kty").and_then(|v| v.as_str()) != Some("OKP") { continue; }
51        if k.get("crv").and_then(|v| v.as_str()) != Some("Ed25519") { continue; }
52        let x = k.get("x").and_then(|v| v.as_str()).unwrap_or("");
53        let this_kid = k.get("kid").and_then(|v| v.as_str()).unwrap_or("");
54        if this_kid == kid || this_kid.is_empty() {
55            x_b64 = Some(x);
56            break;
57        }
58    }
59    let x = x_b64.ok_or_else(|| "kid not found".to_string())?;
60    let pk_bytes = b64url_decode_to_vec(x).map_err(|_| "bad x b64")?;
61    if pk_bytes.len() != 32 { return Err("bad ed25519 pk".into()); }
62
63    // Verify signature
64    use ed25519_dalek::{VerifyingKey, Signature};
65    let vk = VerifyingKey::from_bytes(pk_bytes.as_slice().try_into().map_err(|_| "pk size")?).map_err(|_| "pk parse")?;
66    let signing_input = format!("{}.{}", parts[0], parts[1]);
67    let sig_bytes = b64url_decode_to_vec(parts[2]).map_err(|_| "bad sig b64")?;
68    let sig = Signature::from_bytes(sig_bytes.as_slice().try_into().map_err(|_| "sig size")?);
69    vk.verify_strict(signing_input.as_bytes(), &sig).map_err(|_| "sig verify")?;
70
71    Ok(payload)
72}
73
74#[cfg(feature = "assets")]
75pub mod assets {
76    pub fn sqlite_migration_001() -> &'static str { include_str!("../assets/sql/001_add_did.sql") }
77    pub fn postgres_migration_001() -> &'static str { include_str!("../assets/sql/001_add_did.pg.sql") }
78    pub fn receipt_action_v1_schema() -> &'static str { include_str!("../assets/receipts/action.v1.schema.json") }
79}