use crate::{DidError, DidResult};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Disclosure {
pub salt: String,
pub claim_name: String,
pub claim_value: Value,
}
impl Disclosure {
pub fn new(salt: impl Into<String>, claim_name: impl Into<String>, claim_value: Value) -> Self {
Self {
salt: salt.into(),
claim_name: claim_name.into(),
claim_value,
}
}
pub fn generate(claim_name: impl Into<String>, claim_value: Value) -> Self {
let salt = uuid::Uuid::new_v4().to_string().replace('-', "");
Self::new(salt, claim_name, claim_value)
}
pub fn to_base64url(&self) -> DidResult<String> {
let arr = serde_json::json!([self.salt, self.claim_name, self.claim_value]);
let json = serde_json::to_string(&arr).map_err(|e| {
DidError::InvalidCredential(format!("Failed to serialize disclosure: {e}"))
})?;
Ok(URL_SAFE_NO_PAD.encode(json.as_bytes()))
}
pub fn from_base64url(encoded: &str) -> DidResult<Self> {
let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|e| {
DidError::InvalidCredential(format!("Invalid base64url disclosure: {e}"))
})?;
let arr: Value = serde_json::from_slice(&bytes)
.map_err(|e| DidError::InvalidCredential(format!("Invalid disclosure JSON: {e}")))?;
let arr = arr
.as_array()
.ok_or_else(|| DidError::InvalidCredential("Disclosure must be a JSON array".into()))?;
if arr.len() != 3 {
return Err(DidError::InvalidCredential(format!(
"Disclosure array must have 3 elements, got {}",
arr.len()
)));
}
let salt = arr[0]
.as_str()
.ok_or_else(|| DidError::InvalidCredential("Disclosure salt must be a string".into()))?
.to_string();
let claim_name = arr[1]
.as_str()
.ok_or_else(|| {
DidError::InvalidCredential("Disclosure claim_name must be a string".into())
})?
.to_string();
let claim_value = arr[2].clone();
Ok(Self {
salt,
claim_name,
claim_value,
})
}
pub fn hash(&self) -> DidResult<String> {
let encoded = self.to_base64url()?;
let digest = Sha256::digest(encoded.as_bytes());
Ok(URL_SAFE_NO_PAD.encode(digest))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SdJwtClaims {
#[serde(rename = "_sd", default, skip_serializing_if = "Vec::is_empty")]
pub sd_hashes: Vec<String>,
#[serde(rename = "_sd_alg", default, skip_serializing_if = "String::is_empty")]
pub sd_alg: String,
}
impl SdJwtClaims {
pub fn new_sha256(hashes: Vec<String>) -> Self {
Self {
sd_hashes: hashes,
sd_alg: "sha-256".into(),
}
}
}
pub struct SdJwtIssuer {
pub public_claims: HashMap<String, Value>,
pub disclosures: Vec<Disclosure>,
}
impl SdJwtIssuer {
pub fn new() -> Self {
Self {
public_claims: HashMap::new(),
disclosures: Vec::new(),
}
}
pub fn public_claim(mut self, key: impl Into<String>, value: Value) -> Self {
self.public_claims.insert(key.into(), value);
self
}
pub fn sd_claim(mut self, key: impl Into<String>, value: Value) -> Self {
self.disclosures.push(Disclosure::generate(key, value));
self
}
pub fn build_sd_claims(&self) -> DidResult<SdJwtClaims> {
let hashes = self
.disclosures
.iter()
.map(|d| d.hash())
.collect::<DidResult<Vec<_>>>()?;
Ok(SdJwtClaims::new_sha256(hashes))
}
pub fn build_compact(&self, jwt_compact: &str) -> DidResult<String> {
let mut parts = vec![jwt_compact.to_string()];
for d in &self.disclosures {
parts.push(d.to_base64url()?);
}
Ok(parts.join("~") + "~")
}
}
impl Default for SdJwtIssuer {
fn default() -> Self {
Self::new()
}
}
pub struct SdJwtVerifier;
impl SdJwtVerifier {
pub fn parse(compact: &str) -> DidResult<(String, Vec<Disclosure>)> {
let parts: Vec<&str> = compact.split('~').collect();
if parts.is_empty() {
return Err(DidError::InvalidCredential(
"Empty SD-JWT compact serialization".into(),
));
}
let jwt = parts[0].to_string();
let mut disclosures = Vec::new();
for part in &parts[1..] {
if part.is_empty() {
continue;
}
disclosures.push(Disclosure::from_base64url(part)?);
}
Ok((jwt, disclosures))
}
pub fn verify_disclosures(
disclosures: &[Disclosure],
sd_claims: &SdJwtClaims,
) -> DidResult<HashMap<String, Value>> {
let mut verified = HashMap::new();
for d in disclosures {
let hash = d.hash()?;
if sd_claims.sd_hashes.contains(&hash) {
verified.insert(d.claim_name.clone(), d.claim_value.clone());
} else {
return Err(DidError::InvalidCredential(format!(
"Disclosure hash mismatch for claim {:?}",
d.claim_name
)));
}
}
Ok(verified)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_disclosure_round_trip() {
let d = Disclosure::new("salt123", "name", json!("Alice"));
let encoded = d.to_base64url().unwrap();
let decoded = Disclosure::from_base64url(&encoded).unwrap();
assert_eq!(decoded, d);
}
#[test]
fn test_disclosure_hash_deterministic() {
let d = Disclosure::new("fixed_salt", "age", json!(42));
let h1 = d.hash().unwrap();
let h2 = d.hash().unwrap();
assert_eq!(h1, h2, "hash must be deterministic");
}
#[test]
fn test_disclosure_hash_sha256() {
let d = Disclosure::new("abc", "sub", json!("user_42"));
let encoded = d.to_base64url().unwrap();
let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(encoded.as_bytes()));
assert_eq!(d.hash().unwrap(), expected);
}
#[test]
fn test_disclosure_wrong_array_size() {
let json_arr = json!(["salt", "key"]);
let encoded = URL_SAFE_NO_PAD.encode(json_arr.to_string().as_bytes());
assert!(Disclosure::from_base64url(&encoded).is_err());
}
#[test]
fn test_disclosure_generate_random_salt() {
let d1 = Disclosure::generate("field", json!("v"));
let d2 = Disclosure::generate("field", json!("v"));
assert_ne!(d1.salt, d2.salt, "salts must be different");
}
#[test]
fn test_sd_jwt_issuer_build_sd_claims() {
let issuer = SdJwtIssuer::new()
.public_claim("iss", json!("did:key:z6Mk"))
.sd_claim("name", json!("Alice"))
.sd_claim("age", json!(30));
let claims = issuer.build_sd_claims().unwrap();
assert_eq!(claims.sd_hashes.len(), 2, "two sd claims → two hashes");
assert_eq!(claims.sd_alg, "sha-256");
}
#[test]
fn test_sd_jwt_compact_parse_round_trip() {
let issuer = SdJwtIssuer::new().sd_claim("email", json!("alice@example.com"));
let jwt_compact = "header.payload.sig";
let compact = issuer.build_compact(jwt_compact).unwrap();
assert!(compact.starts_with(jwt_compact), "must start with JWT");
assert!(compact.ends_with('~'), "must end with trailing ~");
let (parsed_jwt, disclosures) = SdJwtVerifier::parse(&compact).unwrap();
assert_eq!(parsed_jwt, jwt_compact);
assert_eq!(disclosures.len(), 1);
assert_eq!(disclosures[0].claim_name, "email");
}
#[test]
fn test_sd_jwt_verify_disclosures_success() {
let issuer = SdJwtIssuer::new().sd_claim("city", json!("Tokyo"));
let sd_claims = issuer.build_sd_claims().unwrap();
let verified = SdJwtVerifier::verify_disclosures(&issuer.disclosures, &sd_claims).unwrap();
assert_eq!(verified.get("city"), Some(&json!("Tokyo")));
}
#[test]
fn test_sd_jwt_verify_disclosures_tampered_value() {
let issuer = SdJwtIssuer::new().sd_claim("city", json!("Tokyo"));
let sd_claims = issuer.build_sd_claims().unwrap();
let mut tampered = issuer.disclosures.clone();
tampered[0].claim_value = json!("Osaka");
let result = SdJwtVerifier::verify_disclosures(&tampered, &sd_claims);
assert!(result.is_err(), "tampered disclosure must fail hash check");
}
#[test]
fn test_sd_jwt_empty_disclosures() {
let sd_claims = SdJwtClaims::default();
let verified = SdJwtVerifier::verify_disclosures(&[], &sd_claims).unwrap();
assert!(verified.is_empty());
}
#[test]
fn test_sd_jwt_parse_no_disclosures() {
let (jwt, disclosures) = SdJwtVerifier::parse("h.p.s~").unwrap();
assert_eq!(jwt, "h.p.s");
assert!(disclosures.is_empty(), "trailing ~ → empty disclosures");
}
}