use crate::vc::credential::VerifiableCredential;
use crate::{DidError, DidResult};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JwtVcHeader {
pub typ: String,
pub alg: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
}
impl JwtVcHeader {
pub fn ed25519(kid: Option<String>) -> Self {
Self {
typ: "vc+jwt".into(),
alg: "EdDSA".into(),
kid,
}
}
pub fn es256(kid: Option<String>) -> Self {
Self {
typ: "vc+jwt".into(),
alg: "ES256".into(),
kid,
}
}
pub fn validate(&self) -> DidResult<()> {
if self.typ.to_lowercase() != "vc+jwt" {
return Err(DidError::InvalidCredential(format!(
"JWT-VC `typ` must be \"vc+jwt\", got {:?}",
self.typ
)));
}
if self.alg.eq_ignore_ascii_case("none") {
return Err(DidError::InvalidCredential(
"JWT-VC `alg` must not be \"none\"".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtVcPayload {
pub iss: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
pub iat: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<i64>,
pub vc: Value,
}
impl JwtVcPayload {
pub fn from_vc(vc: &VerifiableCredential) -> DidResult<Self> {
let iss = vc.issuer.did().to_string();
let sub = vc
.credential_subject
.subjects()
.into_iter()
.next()
.and_then(|s| s.id.clone());
let jti = vc.id.clone();
let iat = Utc::now().timestamp();
let nbf = vc
.valid_from
.as_ref()
.or(vc.issuance_date.as_ref())
.map(|d| d.timestamp());
let exp = vc
.valid_until
.as_ref()
.or(vc.expiration_date.as_ref())
.map(|d| d.timestamp());
let vc_value = serde_json::to_value(vc)
.map_err(|e| DidError::InvalidCredential(format!("Failed to serialize VC: {e}")))?;
Ok(Self {
iss,
sub,
jti,
iat,
nbf,
exp,
vc: vc_value,
})
}
pub fn validate(&self, now: DateTime<Utc>) -> DidResult<()> {
let now_ts = now.timestamp();
if let Some(exp) = self.exp {
if now_ts > exp {
return Err(DidError::InvalidCredential(format!(
"JWT-VC has expired (exp={exp}, now={now_ts})"
)));
}
}
if let Some(nbf) = self.nbf {
if now_ts < nbf {
return Err(DidError::InvalidCredential(format!(
"JWT-VC is not yet valid (nbf={nbf}, now={now_ts})"
)));
}
}
if self.iss.is_empty() {
return Err(DidError::InvalidCredential(
"JWT-VC `iss` must not be empty".into(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct JwtVc {
pub header_b64: String,
pub payload_b64: String,
pub signature_b64: String,
}
impl JwtVc {
pub fn new(header_b64: String, payload_b64: String, signature_b64: String) -> Self {
Self {
header_b64,
payload_b64,
signature_b64,
}
}
pub fn to_compact(&self) -> String {
format!(
"{}.{}.{}",
self.header_b64, self.payload_b64, self.signature_b64
)
}
pub fn from_compact(compact: &str) -> DidResult<Self> {
let parts: Vec<&str> = compact.split('.').collect();
if parts.len() != 3 {
return Err(DidError::InvalidCredential(format!(
"JWT must have 3 parts separated by '.', got {}",
parts.len()
)));
}
Ok(Self {
header_b64: parts[0].to_string(),
payload_b64: parts[1].to_string(),
signature_b64: parts[2].to_string(),
})
}
pub fn decode_header(&self) -> DidResult<JwtVcHeader> {
let bytes = URL_SAFE_NO_PAD
.decode(&self.header_b64)
.map_err(|e| DidError::InvalidCredential(format!("Invalid base64url header: {e}")))?;
serde_json::from_slice(&bytes)
.map_err(|e| DidError::InvalidCredential(format!("Invalid header JSON: {e}")))
}
pub fn decode_payload(&self) -> DidResult<JwtVcPayload> {
let bytes = URL_SAFE_NO_PAD
.decode(&self.payload_b64)
.map_err(|e| DidError::InvalidCredential(format!("Invalid base64url payload: {e}")))?;
serde_json::from_slice(&bytes)
.map_err(|e| DidError::InvalidCredential(format!("Invalid payload JSON: {e}")))
}
pub fn signing_input_bytes(&self) -> Vec<u8> {
format!("{}.{}", self.header_b64, self.payload_b64).into_bytes()
}
}
pub fn encode_vc_as_jwt<F>(
vc: &VerifiableCredential,
header: JwtVcHeader,
sign: F,
) -> DidResult<JwtVc>
where
F: FnOnce(&[u8]) -> DidResult<Vec<u8>>,
{
header.validate()?;
let payload = JwtVcPayload::from_vc(vc)?;
let header_json = serde_json::to_string(&header)
.map_err(|e| DidError::InvalidCredential(format!("Failed to serialize JWT header: {e}")))?;
let payload_json = serde_json::to_string(&payload).map_err(|e| {
DidError::InvalidCredential(format!("Failed to serialize JWT payload: {e}"))
})?;
let header_b64 = URL_SAFE_NO_PAD.encode(header_json.as_bytes());
let payload_b64 = URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let signing_input = format!("{header_b64}.{payload_b64}");
let sig_bytes = sign(signing_input.as_bytes())?;
let signature_b64 = URL_SAFE_NO_PAD.encode(&sig_bytes);
Ok(JwtVc::new(header_b64, payload_b64, signature_b64))
}
pub fn decode_jwt_vc(compact: &str, now: DateTime<Utc>) -> DidResult<(JwtVcHeader, JwtVcPayload)> {
let jwt = JwtVc::from_compact(compact)?;
let header = jwt.decode_header()?;
header.validate()?;
let payload = jwt.decode_payload()?;
payload.validate(now)?;
Ok((header, payload))
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_vc_json() -> &'static str {
r#"{
"@context": ["https://www.w3.org/ns/credentials/v2"],
"type": ["VerifiableCredential"],
"issuer": {"id": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuias8sisDArDJF"},
"validFrom": "2024-01-01T00:00:00Z",
"credentialSubject": {"id": "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp"}
}"#
}
#[test]
fn test_header_ed25519_type() {
let h = JwtVcHeader::ed25519(None);
assert_eq!(h.typ, "vc+jwt");
assert_eq!(h.alg, "EdDSA");
assert!(h.kid.is_none());
}
#[test]
fn test_header_validation_wrong_typ() {
let h = JwtVcHeader {
typ: "JWT".into(),
alg: "EdDSA".into(),
kid: None,
};
assert!(h.validate().is_err());
}
#[test]
fn test_header_validation_alg_none_rejected() {
let h = JwtVcHeader {
typ: "vc+jwt".into(),
alg: "none".into(),
kid: None,
};
assert!(h.validate().is_err());
}
#[test]
fn test_jwt_compact_round_trip() {
let jwt = JwtVc::new("header".into(), "payload".into(), "sig".into());
let compact = jwt.to_compact();
let parsed = JwtVc::from_compact(&compact).unwrap();
assert_eq!(parsed, jwt);
}
#[test]
fn test_jwt_from_compact_wrong_parts() {
assert!(JwtVc::from_compact("only.two").is_err());
assert!(JwtVc::from_compact("a.b.c.d").is_err());
}
#[test]
fn test_encode_and_decode_round_trip() {
let vc: serde_json::Value = serde_json::from_str(minimal_vc_json()).unwrap();
let header = JwtVcHeader::ed25519(Some("did:key:z6Mk#z6Mk".into()));
let _header_json = serde_json::to_string(&header).unwrap();
let _payload_json = serde_json::to_string(&vc).unwrap();
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes());
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&vc).unwrap().as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(b"dummy_signature_bytes");
let jwt = JwtVc::new(header_b64, payload_b64, sig_b64);
let compact = jwt.to_compact();
let parsed = JwtVc::from_compact(&compact).unwrap();
let decoded_header = parsed.decode_header().unwrap();
assert_eq!(decoded_header.typ, "vc+jwt");
assert_eq!(decoded_header.alg, "EdDSA");
}
#[test]
fn test_payload_validate_expired() {
let past = Utc::now().timestamp() - 3600;
let payload = JwtVcPayload {
iss: "did:key:z6Mk".into(),
sub: None,
jti: None,
iat: past - 100,
nbf: None,
exp: Some(past), vc: serde_json::Value::Null,
};
assert!(payload.validate(Utc::now()).is_err());
}
#[test]
fn test_payload_validate_not_yet_valid() {
let future = Utc::now().timestamp() + 3600;
let payload = JwtVcPayload {
iss: "did:key:z6Mk".into(),
sub: None,
jti: None,
iat: Utc::now().timestamp(),
nbf: Some(future),
exp: None,
vc: serde_json::Value::Null,
};
assert!(payload.validate(Utc::now()).is_err());
}
#[test]
fn test_payload_validate_empty_iss_rejected() {
let payload = JwtVcPayload {
iss: "".into(),
sub: None,
jti: None,
iat: Utc::now().timestamp(),
nbf: None,
exp: None,
vc: serde_json::Value::Null,
};
assert!(payload.validate(Utc::now()).is_err());
}
#[test]
fn test_signing_input_format() {
let jwt = JwtVc::new("H".into(), "P".into(), "S".into());
assert_eq!(String::from_utf8(jwt.signing_input_bytes()).unwrap(), "H.P");
}
}