diff --git a/Cargo.toml b/Cargo.toml index 65f7e4b0f..4038e99a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ ssi-ucan = { path = "./ssi-ucan", version = "0.1" } ssi-vc = { path = "./ssi-vc", version = "0.2.0" } ssi-zcap-ld = { path = "./ssi-zcap-ld", version = "0.1.2" } ssi-caips = { path = "./ssi-caips", version = "0.1", default-features = false } +ssi-sd-jwt = { path = "./ssi-sd-jwt", version = "0.1" } [workspace] members = [ @@ -88,6 +89,7 @@ members = [ "ssi-dids", "ssi-jws", "ssi-jwt", + "ssi-sd-jwt", "ssi-tzkey", "ssi-ssh", "ssi-ldp", diff --git a/src/lib.rs b/src/lib.rs index 0882fca86..d0646e81c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,7 @@ pub use ssi_ldp as ldp; pub use ssi_ldp::eip712; #[deprecated = "Use ssi::ldp::soltx"] pub use ssi_ldp::soltx; +pub use ssi_sd_jwt as sd_jwt; pub use ssi_ssh as ssh; pub use ssi_tzkey as tzkey; pub use ssi_ucan as ucan; diff --git a/ssi-sd-jwt/Cargo.toml b/ssi-sd-jwt/Cargo.toml new file mode 100644 index 000000000..9f53578da --- /dev/null +++ b/ssi-sd-jwt/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "ssi-sd-jwt" +version = "0.1.0" +edition = "2021" +authors = ["Spruce Systems, Inc."] +license = "Apache-2.0" +description = "Implementation of SD-JWT for the ssi library." +repository = "https://github.com/spruceid/ssi/" +documentation = "https://docs.rs/ssi-sd-jwt/" + +[dependencies] +base64 = "0.12" +rand = { version = "0.8" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +ssi-jwk = { path = "../ssi-jwk", version = "0.1" } +ssi-jws = { path = "../ssi-jws", version = "0.1" } +ssi-jwt = { path = "../ssi-jwt", version = "0.1" } +thiserror = "1.0" + +[dev-dependencies] +hex-literal = "0.4.1" diff --git a/ssi-sd-jwt/src/decode.rs b/ssi-sd-jwt/src/decode.rs new file mode 100644 index 000000000..5f11c422a --- /dev/null +++ b/ssi-sd-jwt/src/decode.rs @@ -0,0 +1,213 @@ +use serde::de::DeserializeOwned; +use ssi_jwk::JWK; +use std::collections::BTreeMap; + +use crate::disclosure::{DecodedDisclosure, DisclosureKind}; +use crate::serialized::deserialize_string_format; +use crate::*; + +/// High level API to decode a fully encoded SD-JWT. That is a JWT and selective +/// disclosures separated by tildes +pub fn decode_verify( + serialized: &str, + key: &JWK, +) -> Result { + let deserialized = deserialize_string_format(serialized) + .ok_or(DecodeError::UnableToDeserializeStringFormat)?; + + decode_verify_disclosure_array(deserialized, key) +} + +/// Lower level API to decode an SD-JWT that has already been split into its +/// JWT and disclosure components +pub fn decode_verify_disclosure_array( + deserialized: Deserialized<'_>, + key: &JWK, +) -> Result { + let mut payload_claims: serde_json::Value = ssi_jwt::decode_verify(deserialized.jwt, key)?; + + let sd_alg = extract_sd_alg(&mut payload_claims)?; + + let mut disclosures = translate_to_in_progress_disclosures(&deserialized.disclosures, sd_alg)?; + + visit_claims(&mut payload_claims, &mut disclosures)?; + + for (_, disclosure) in disclosures { + if !disclosure.found { + return Err(DecodeError::UnusedDisclosure); + } + } + + Ok(serde_json::from_value(payload_claims)?) +} + +fn extract_sd_alg(claims: &mut serde_json::Value) -> Result { + let claims = claims.as_object_mut().ok_or(DecodeError::ClaimsWrongType)?; + + let sd_alg_claim = claims + .remove(SD_ALG_CLAIM_NAME) + .ok_or(DecodeError::MissingSdAlg)?; + + let sd_alg = sd_alg_claim.as_str().ok_or(DecodeError::SdAlgWrongType)?; + + SdAlg::try_from(sd_alg) +} + +fn translate_to_in_progress_disclosures( + disclosures: &[&str], + sd_alg: SdAlg, +) -> Result, DecodeError> { + let disclosure_vec: Result, DecodeError> = disclosures + .iter() + .map(|disclosure| InProgressDisclosure::new(disclosure, sd_alg)) + .collect(); + + let disclosure_vec = disclosure_vec?; + + let mut disclosure_map = BTreeMap::new(); + for disclosure in disclosure_vec { + let prev = disclosure_map.insert(disclosure.hash.clone(), disclosure); + + if prev.is_some() { + return Err(DecodeError::MultipleDisclosuresWithSameHash); + } + } + + Ok(disclosure_map) +} + +#[derive(Debug)] +struct InProgressDisclosure { + decoded: DecodedDisclosure, + hash: String, + found: bool, +} + +impl InProgressDisclosure { + fn new(disclosure: &str, sd_alg: SdAlg) -> Result { + Ok(InProgressDisclosure { + decoded: DecodedDisclosure::new(disclosure)?, + hash: hash_encoded_disclosure(sd_alg, disclosure), + found: false, + }) + } +} + +fn visit_claims( + payload_claims: &mut serde_json::Value, + disclosures: &mut BTreeMap, +) -> Result<(), DecodeError> { + let payload_claims = match payload_claims.as_object_mut() { + Some(obj) => obj, + None => return Ok(()), + }; + + // Visit children + for (_, child_claim) in payload_claims.iter_mut() { + visit_claims(child_claim, disclosures)? + } + + // Process _sd claim + let new_claims = if let Some(sd_claims) = payload_claims.remove(SD_CLAIM_NAME) { + decode_sd_claims(&sd_claims, disclosures)? + } else { + vec![] + }; + + for (new_claim_name, mut new_claim_value) in new_claims { + visit_claims(&mut new_claim_value, disclosures)?; + + let prev = payload_claims.insert(new_claim_name, new_claim_value); + + if prev.is_some() { + return Err(DecodeError::DisclosureClaimCollidesWithJwtClaim); + } + } + + // Process array claims + for (_, item) in payload_claims.iter_mut() { + if let Some(array) = item.as_array_mut() { + let mut new_array_items = decode_array_claims(array, disclosures)?; + + for item in new_array_items.iter_mut() { + visit_claims(item, disclosures)?; + } + + *array = new_array_items; + } + } + + Ok(()) +} + +fn decode_sd_claims( + sd_claims: &serde_json::Value, + disclosures: &mut BTreeMap, +) -> Result, DecodeError> { + let sd_claims = sd_claims + .as_array() + .ok_or(DecodeError::SdPropertyNotArray)?; + let mut found_disclosures = vec![]; + for disclosure_hash in sd_claims { + let disclosure_hash = disclosure_hash + .as_str() + .ok_or(DecodeError::SdClaimNotString)?; + + if let Some(in_progress_disclosure) = disclosures.get_mut(disclosure_hash) { + if in_progress_disclosure.found { + return Err(DecodeError::DisclosureUsedMultipleTimes); + } + in_progress_disclosure.found = true; + match in_progress_disclosure.decoded.kind { + DisclosureKind::ArrayItem(_) => { + return Err(DecodeError::ArrayDisclosureWhenExpectingProperty) + } + DisclosureKind::Property { + ref name, + ref value, + } => found_disclosures.push((name.clone(), value.clone())), + } + } + } + + Ok(found_disclosures) +} + +fn decode_array_claims( + array: &[serde_json::Value], + disclosures: &mut BTreeMap, +) -> Result, DecodeError> { + let mut new_items = vec![]; + for item in array.iter() { + if let Some(hash) = array_item_is_disclosure(item) { + if let Some(in_progress_disclosure) = disclosures.get_mut(hash) { + if in_progress_disclosure.found { + return Err(DecodeError::DisclosureUsedMultipleTimes); + } + in_progress_disclosure.found = true; + match in_progress_disclosure.decoded.kind { + DisclosureKind::ArrayItem(ref value) => { + new_items.push(value.clone()); + } + DisclosureKind::Property { .. } => { + return Err(DecodeError::PropertyDisclosureWhenExpectingArray) + } + } + } + } else { + new_items.push(item.clone()); + } + } + + Ok(new_items) +} + +fn array_item_is_disclosure(item: &serde_json::Value) -> Option<&str> { + let obj = item.as_object()?; + + if obj.len() != 1 { + return None; + } + + obj.get(ARRAY_CLAIM_ITEM_PROPERTY_NAME)?.as_str() +} diff --git a/ssi-sd-jwt/src/digest.rs b/ssi-sd-jwt/src/digest.rs new file mode 100644 index 000000000..34c9e6421 --- /dev/null +++ b/ssi-sd-jwt/src/digest.rs @@ -0,0 +1,69 @@ +use base64::URL_SAFE_NO_PAD; +use sha2::Digest; + +use crate::DecodeError; + +/// Elements of the _sd_alg claim +#[non_exhaustive] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum SdAlg { + /// SHA-256 Algortim for hashing disclosures + Sha256, +} + +impl SdAlg { + const SHA256_STR: &'static str = "sha-256"; +} + +impl SdAlg { + /// String encoding of _sd_alg field + pub fn to_str(&self) -> &'static str { + match self { + SdAlg::Sha256 => Self::SHA256_STR, + } + } +} + +impl TryFrom<&str> for SdAlg { + type Error = DecodeError; + + fn try_from(value: &str) -> Result { + Ok(match value { + Self::SHA256_STR => SdAlg::Sha256, + other => return Err(DecodeError::UnknownSdAlg(other.to_owned())), + }) + } +} + +impl From for &'static str { + fn from(value: SdAlg) -> Self { + value.to_str() + } +} + +/// Lower level API to generate the hash of a given disclosure string already converted +/// into base 64 +pub fn hash_encoded_disclosure(digest_algo: SdAlg, disclosure: &str) -> String { + match digest_algo { + SdAlg::Sha256 => { + let digest = sha2::Sha256::digest(disclosure.as_bytes()); + base64::encode_config(digest, URL_SAFE_NO_PAD) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_disclosure_hashing() { + assert_eq!( + hash_encoded_disclosure( + SdAlg::Sha256, + "WyI2cU1RdlJMNWhhaiIsICJmYW1pbHlfbmFtZSIsICJNw7ZiaXVzIl0" + ), + "uutlBuYeMDyjLLTpf6Jxi7yNkEF35jdyWMn9U7b_RYY", + ); + } +} diff --git a/ssi-sd-jwt/src/disclosure.rs b/ssi-sd-jwt/src/disclosure.rs new file mode 100644 index 000000000..c428dff96 --- /dev/null +++ b/ssi-sd-jwt/src/disclosure.rs @@ -0,0 +1,192 @@ +use base64::URL_SAFE_NO_PAD; + +use crate::DecodeError; + +#[derive(Debug, PartialEq)] +pub struct DecodedDisclosure { + pub salt: String, + pub kind: DisclosureKind, +} + +#[derive(Debug, PartialEq)] +pub enum DisclosureKind { + Property { + name: String, + value: serde_json::Value, + }, + ArrayItem(serde_json::Value), +} + +impl DecodedDisclosure { + pub fn new(encoded: &str) -> Result { + let bytes = base64::decode_config(encoded, URL_SAFE_NO_PAD) + .map_err(|_| DecodeError::DisclosureMalformed)?; + let json: serde_json::Value = serde_json::from_slice(&bytes)?; + + match json { + serde_json::Value::Array(values) => match values.as_slice() { + [salt, name, value] => validate_property_disclosure(salt, name, value), + [salt, value] => validate_array_item_disclosure(salt, value), + _ => Err(DecodeError::DisclosureMalformed), + }, + _ => Err(DecodeError::DisclosureMalformed), + } + } +} + +fn validate_property_disclosure( + salt: &serde_json::Value, + name: &serde_json::Value, + value: &serde_json::Value, +) -> Result { + let salt = salt.as_str().ok_or(DecodeError::DisclosureMalformed)?; + + let name = name.as_str().ok_or(DecodeError::DisclosureMalformed)?; + + Ok(DecodedDisclosure { + salt: salt.to_owned(), + kind: DisclosureKind::Property { + name: name.to_owned(), + value: value.clone(), + }, + }) +} + +fn validate_array_item_disclosure( + salt: &serde_json::Value, + value: &serde_json::Value, +) -> Result { + let salt = salt.as_str().ok_or(DecodeError::DisclosureMalformed)?; + + Ok(DecodedDisclosure { + salt: salt.to_owned(), + kind: DisclosureKind::ArrayItem(value.clone()), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::digest::{hash_encoded_disclosure, SdAlg}; + + fn verify_sd_disclosures_array( + digest_algo: SdAlg, + disclosures: &[&str], + sd_claim: &[&str], + ) -> Result { + let mut verfied_claims = serde_json::Map::new(); + + for disclosure in disclosures { + let disclosure_hash = hash_encoded_disclosure(digest_algo, disclosure); + + if !disclosure_hash_exists_in_sd_claims(&disclosure_hash, sd_claim) { + continue; + } + + let decoded = DecodedDisclosure::new(disclosure)?; + + match decoded.kind { + DisclosureKind::Property { name, value } => { + let orig = verfied_claims.insert(name, value); + + if orig.is_some() { + return Err(DecodeError::DisclosureUsedMultipleTimes); + } + } + DisclosureKind::ArrayItem(_) => { + return Err(DecodeError::ArrayDisclosureWhenExpectingProperty); + } + } + } + + Ok(serde_json::Value::Object(verfied_claims)) + } + + fn disclosure_hash_exists_in_sd_claims(disclosure_hash: &str, sd_claim: &[&str]) -> bool { + for sd_claim_item in sd_claim { + if &disclosure_hash == sd_claim_item { + return true; + } + } + + false + } + + #[test] + fn test_verify_disclosures() { + const DISCLOSURES: [&str; 7] = [ + "WyJyU0x1em5oaUxQQkRSWkUxQ1o4OEtRIiwgInN1YiIsICJqb2huX2RvZV80MiJd", + "WyJhYTFPYmdlUkJnODJudnpMYnRQTklRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd", + "WyI2VWhsZU5HUmJtc0xDOFRndTh2OFdnIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd", + "WyJ2S0t6alFSOWtsbFh2OWVkNUJ1ZHZRIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ", + "WyJVZEVmXzY0SEN0T1BpZDRFZmhPQWNRIiwgInBob25lX251bWJlciIsICIrMS0yMDItNTU1LTAxMDEiXQ", + "WyJOYTNWb0ZGblZ3MjhqT0FyazdJTlZnIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0", + "WyJkQW9mNHNlZTFGdDBXR2dHanVjZ2pRIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0", + ]; + + const SD_CLAIM: [&str; 7] = [ + "5nXy0Z3QiEba1V1lJzeKhAOGQXFlKLIWCLlhf_O-cmo", + "9gZhHAhV7LZnOFZq_q7Fh8rzdqrrNM-hRWsVOlW3nuw", + "S-JPBSkvqliFv1__thuXt3IzX5B_ZXm4W2qs4BoNFrA", + "bviw7pWAkbzI078ZNVa_eMZvk0tdPa5w2o9R3Zycjo4", + "o-LBCDrFF6tC9ew1vAlUmw6Y30CHZF5jOUFhpx5mogI", + "pzkHIM9sv7oZH6YKDsRqNgFGLpEKIj3c5G6UKaTsAjQ", + "rnAzCT6DTy4TsX9QCDv2wwAE4Ze20uRigtVNQkA52X0", + ]; + + let expected_claims: serde_json::Value = serde_json::json!({ + "sub": "john_doe_42", + "given_name": "John", + "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "address": {"street_address": "123 Main St", "locality": "Anytown", "region": "Anystate", "country": "US"}, + "birthdate": "1940-01-01" + }); + + let verified_claims = + verify_sd_disclosures_array(SdAlg::Sha256, &DISCLOSURES, &SD_CLAIM).unwrap(); + + assert_eq!(verified_claims, expected_claims) + } + + #[test] + fn test_verify_subset_of_disclosures() { + const DISCLOSURES: [&str; 2] = [ + "WyJhYTFPYmdlUkJnODJudnpMYnRQTklRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd", + "WyI2VWhsZU5HUmJtc0xDOFRndTh2OFdnIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd", + ]; + + const SD_CLAIM: [&str; 7] = [ + "5nXy0Z3QiEba1V1lJzeKhAOGQXFlKLIWCLlhf_O-cmo", + "9gZhHAhV7LZnOFZq_q7Fh8rzdqrrNM-hRWsVOlW3nuw", + "S-JPBSkvqliFv1__thuXt3IzX5B_ZXm4W2qs4BoNFrA", + "bviw7pWAkbzI078ZNVa_eMZvk0tdPa5w2o9R3Zycjo4", + "o-LBCDrFF6tC9ew1vAlUmw6Y30CHZF5jOUFhpx5mogI", + "pzkHIM9sv7oZH6YKDsRqNgFGLpEKIj3c5G6UKaTsAjQ", + "rnAzCT6DTy4TsX9QCDv2wwAE4Ze20uRigtVNQkA52X0", + ]; + + let expected_claims: serde_json::Value = serde_json::json!({ + "given_name": "John", + "family_name": "Doe", + }); + + let verified_claims = + verify_sd_disclosures_array(SdAlg::Sha256, &DISCLOSURES, &SD_CLAIM).unwrap(); + + assert_eq!(verified_claims, expected_claims) + } + + #[test] + fn decode_array_disclosure() { + assert_eq!( + DecodedDisclosure { + salt: "nPuoQnkRFq3BIeAm7AnXFA".to_owned(), + kind: DisclosureKind::ArrayItem(serde_json::json!("DE")) + }, + DecodedDisclosure::new("WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0").unwrap() + ) + } +} diff --git a/ssi-sd-jwt/src/encode.rs b/ssi-sd-jwt/src/encode.rs new file mode 100644 index 000000000..6b89f44a7 --- /dev/null +++ b/ssi-sd-jwt/src/encode.rs @@ -0,0 +1,243 @@ +use base64::URL_SAFE_NO_PAD; +use rand::{CryptoRng, Rng}; +use serde::Serialize; +use ssi_jwk::{Algorithm, JWK}; + +use crate::*; + +/// Disclosure as encoded +#[derive(Debug, PartialEq)] +pub struct Disclosure { + /// Base 64 of disclosure object + pub encoded: String, + + /// Base 64 of hash of disclosure object + pub hash: String, +} + +fn encode_disclosure_with_salt( + salt: &str, + claim_name: Option<&str>, + claim_value: &ClaimValue, +) -> Result { + let disclosure = match claim_name { + Some(claim_name) => serde_json::json!([salt, claim_name, claim_value]), + None => serde_json::json!([salt, claim_value]), + }; + + let json_string = serde_json::to_string(&disclosure)?; + + Ok(base64::encode_config(json_string, URL_SAFE_NO_PAD)) +} + +pub fn encode_disclosure_with_rng( + rng: &mut Rand, + claim_name: Option<&str>, + claim_value: &ClaimValue, +) -> Result { + // TODO: link to rfc wrt suggested bit size of salt + const DEFAULT_SALT_SIZE: usize = 128 / 8; + + let mut salt_bytes = [0u8; DEFAULT_SALT_SIZE]; + + rng.fill_bytes(&mut salt_bytes); + + let salt = base64::encode_config(salt_bytes, URL_SAFE_NO_PAD); + + encode_disclosure_with_salt(&salt, claim_name, claim_value) +} + +fn encode_disclosure( + claim_name: Option<&str>, + claim_value: &ClaimValue, +) -> Result { + let mut rng = rand::rngs::OsRng {}; + encode_disclosure_with_rng(&mut rng, claim_name, claim_value) +} + +/// Lower level API to create a property style disclosure +pub fn encode_property_disclosure( + sd_alg: SdAlg, + claim_name: &str, + claim_value: &ClaimValue, +) -> Result { + let encoded = encode_disclosure(Some(claim_name), claim_value)?; + let hash = hash_encoded_disclosure(sd_alg, &encoded); + + Ok(Disclosure { encoded, hash }) +} + +/// Lower level API to create an array style disclosure +pub fn encode_array_disclosure( + sd_alg: SdAlg, + claim_value: &ClaimValue, +) -> Result { + let encoded = encode_disclosure(None, claim_value)?; + let hash = hash_encoded_disclosure(sd_alg, &encoded); + + Ok(Disclosure { encoded, hash }) +} + +/// High level API to create most SD-JWTs +pub fn encode_sign( + algorithm: Algorithm, + base_claims: &Claims, + key: &JWK, + sd_alg: SdAlg, + disclosures: Vec, +) -> Result<(String, Vec), EncodeError> { + let mut base_claims_json = serde_json::to_value(base_claims)?; + + let post_encoded_disclosures: Result, EncodeError> = disclosures + .iter() + .map(|disclosure| { + let encoded = disclosure.encode()?; + let hash = hash_encoded_disclosure(sd_alg, &encoded); + Ok(FullDisclosure { + encoded, + hash, + unencoded: disclosure.clone(), + }) + }) + .collect(); + + let post_encoded_disclosures = post_encoded_disclosures?; + + { + let base_claims_obj = base_claims_json + .as_object_mut() + .ok_or(EncodeError::EncodedAsNonObject)?; + + let prev_sd_alg = base_claims_obj.insert( + SD_ALG_CLAIM_NAME.to_owned(), + serde_json::json!(sd_alg.to_str()), + ); + + if prev_sd_alg.is_some() { + return Err(EncodeError::EncodedClaimsContainsReservedProperty); + } + + let mut sd_claim = vec![]; + + for disclosure in post_encoded_disclosures.iter() { + match disclosure.unencoded { + UnencodedDisclosure::Property(ref claim_name, _) => { + sd_claim.push(serde_json::Value::String(disclosure.hash.clone())); + base_claims_obj.remove(claim_name); + } + UnencodedDisclosure::ArrayItem(ref claim_name, _) => { + if !base_claims_obj.contains_key(claim_name) { + let _ = base_claims_obj.insert(claim_name.clone(), serde_json::json!([])); + } + + // unwrap() justified as id statement above adds claim_name to the map if it + // doesn't previously exist + let array = base_claims_obj.get_mut(claim_name).unwrap(); + let array = array.as_array_mut().ok_or(EncodeError::ExpectedArray)?; + + array.push(serde_json::json!({ARRAY_CLAIM_ITEM_PROPERTY_NAME: disclosure.hash.clone()})); + } + } + } + + let prev_sd = + base_claims_obj.insert(SD_CLAIM_NAME.to_owned(), serde_json::Value::Array(sd_claim)); + + if prev_sd.is_some() { + return Err(EncodeError::EncodedClaimsContainsReservedProperty); + } + } + + let jwt = ssi_jwt::encode_sign(algorithm, &base_claims_json, key)?; + + Ok((jwt, post_encoded_disclosures)) +} + +/// Represents a disclosure before encoding +#[derive(Clone, Debug)] +pub enum UnencodedDisclosure { + /// Property style disclosure + Property(String, serde_json::Value), + + /// Array style disclosure + ArrayItem(String, serde_json::Value), +} + +impl UnencodedDisclosure { + /// Create a new property style UnencodedDisclosure + pub fn new_property, Value: Serialize>( + name: S, + value: &Value, + ) -> Result { + Ok(UnencodedDisclosure::Property( + name.as_ref().to_owned(), + serde_json::to_value(value)?, + )) + } + + /// Create a new array style UnencodedDisclosure + pub fn new_array_item, Value: Serialize>( + parent: S, + value: &Value, + ) -> Result { + Ok(UnencodedDisclosure::ArrayItem( + parent.as_ref().to_owned(), + serde_json::to_value(value)?, + )) + } + + /// Obtain reference to the disclosure's JSON object + pub fn claim_value_as_ref(&self) -> &serde_json::Value { + match self { + UnencodedDisclosure::ArrayItem(_, value) => value, + UnencodedDisclosure::Property(_, value) => value, + } + } + + /// Obtain reference to the disclosure's name if it is an array style + /// disclosure + pub fn encoded_claim_name(&self) -> Option<&str> { + match self { + UnencodedDisclosure::Property(name, _) => Some(name), + UnencodedDisclosure::ArrayItem(_, _) => None, + } + } + + /// Encode the disclosure into the plaintext base64 string encoding + pub fn encode(&self) -> Result { + encode_disclosure(self.encoded_claim_name(), self.claim_value_as_ref()) + } +} + +#[derive(Debug)] +pub struct FullDisclosure { + pub encoded: String, + pub hash: String, + pub unencoded: UnencodedDisclosure, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_disclosure() { + assert_eq!( + encode_disclosure_with_salt( + "_26bc4LT-ac6q2KI6cBW5es", + Some("family_name"), + &"Möbius".to_owned(), + ) + .unwrap(), + "WyJfMjZiYzRMVC1hYzZxMktJNmNCVzVlcyIsImZhbWlseV9uYW1lIiwiTcO2Yml1cyJd", + ) + } + + #[test] + fn test_encode_array_disclosure() { + assert_eq!( + encode_disclosure_with_salt("nPuoQnkRFq3BIeAm7AnXFA", None, &"DE".to_owned()).unwrap(), + "WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwiREUiXQ" + ) + } +} diff --git a/ssi-sd-jwt/src/error.rs b/ssi-sd-jwt/src/error.rs new file mode 100644 index 000000000..37cbecf33 --- /dev/null +++ b/ssi-sd-jwt/src/error.rs @@ -0,0 +1,95 @@ +/// Errors in the decode pathway +#[derive(thiserror::Error, Debug)] +pub enum DecodeError { + /// Unable to deserialize string format of concatenated tildes + #[error("Unable to deserialize string format of concatenated tildes")] + UnableToDeserializeStringFormat, + + /// JWT payload claims were not a JSON object + #[error("JWT payload claims were not a JSON object")] + ClaimsWrongType, + + /// JWT is missing _sd_alg property + #[error("JWT is missing _sd_alg property")] + MissingSdAlg, + + /// Unknown value of _sd_alg + #[error("Unknown value of _sd_alg {0}")] + UnknownSdAlg(String), + + /// Type of _sd_alg was not string + #[error("Type of _sd_alg was not string")] + SdAlgWrongType, + + /// Multiple disclosures given with the same hash + #[error("Multiple disclosures given with the same hash")] + MultipleDisclosuresWithSameHash, + + /// An _sd claim wasn't a string + #[error("An _sd claim wasn't a string")] + SdClaimNotString, + + /// An _sd property was not an array type + #[error("An _sd property was not an array type")] + SdPropertyNotArray, + + /// A disclosure claim would collid with an existing JWT claim + #[error("A disclosure claim would collid with an existing JWT claim")] + DisclosureClaimCollidesWithJwtClaim, + + /// A disclosure is malformed + #[error("A disclosure is malformed")] + DisclosureMalformed, + + /// A single disclosure was used multiple times + #[error("A single disclosure was used multiple times")] + DisclosureUsedMultipleTimes, + + /// Found an array item disclosure when expecting a property type + #[error("Found an array item disclosure when expecting a property type")] + ArrayDisclosureWhenExpectingProperty, + + /// Found a property type disclosure when expecting an array item + #[error("Found a property type disclosure when expecting an array item")] + PropertyDisclosureWhenExpectingArray, + + /// A disclosure was not used during decoding + #[error("A disclosure was not used during decoding")] + UnusedDisclosure, + + /// Bubbled up error from ssi_jws + #[error(transparent)] + JWS(#[from] ssi_jws::Error), + + /// Bubbled up error from serde_json + #[error(transparent)] + JsonDeserialization(#[from] serde_json::Error), +} + +/// Errors in the Encode pathway +#[derive(thiserror::Error, Debug)] +pub enum EncodeError { + /// The base claims to encode did not become a JSON object + #[error("The base claims to encode did not become a JSON object")] + EncodedAsNonObject, + + /// The base claims to encode contained a property reserved by SD-JWT + #[error("The base claims to encode contained a property reserved by SD-JWT")] + EncodedClaimsContainsReservedProperty, + + /// A property for an array sd claim was not an array + #[error("A property for an array sd claim was not an array")] + ExpectedArray, + + /// A disclosure was not used during decoding + #[error("A disclosure was not used during decoding")] + UnusedDisclosure, + + /// Bubbled up error from ssi_jws + #[error(transparent)] + JWS(#[from] ssi_jws::Error), + + /// Bubbled up error from serde_json + #[error(transparent)] + JsonSerialization(#[from] serde_json::Error), +} diff --git a/ssi-sd-jwt/src/lib.rs b/ssi-sd-jwt/src/lib.rs new file mode 100644 index 000000000..49ec746c1 --- /dev/null +++ b/ssi-sd-jwt/src/lib.rs @@ -0,0 +1,40 @@ +#![warn(missing_docs)] + +//! SSI library for processing SD-JWTs + +mod decode; +pub(crate) mod digest; +pub(crate) mod disclosure; +pub(crate) mod encode; +mod error; +pub(crate) mod serialized; + +pub use decode::{decode_verify, decode_verify_disclosure_array}; +pub use digest::{hash_encoded_disclosure, SdAlg}; +pub use encode::{ + encode_array_disclosure, encode_property_disclosure, encode_sign, Disclosure, + UnencodedDisclosure, +}; +pub use error::{DecodeError, EncodeError}; +pub use serialized::{deserialize_string_format, serialize_string_format}; + +const SD_CLAIM_NAME: &str = "_sd"; +const SD_ALG_CLAIM_NAME: &str = "_sd_alg"; +const ARRAY_CLAIM_ITEM_PROPERTY_NAME: &str = "..."; + +/// SD-JWT components to be presented for decodindg and validation whtehre coming from +/// a compact representation, eveloping JWT, etc. +#[derive(Debug, PartialEq)] +pub struct Deserialized<'a> { + /// JWT who's claims can be selectively disclosed + pub jwt: &'a str, + /// Disclosures for associated JWT + pub disclosures: Vec<&'a str>, +} + +impl<'a> Deserialized<'a> { + /// Convert Deserialized into a compact serialized format + pub fn compact_serialize(&self) -> String { + serialize_string_format(self.jwt, &self.disclosures) + } +} diff --git a/ssi-sd-jwt/src/serialized.rs b/ssi-sd-jwt/src/serialized.rs new file mode 100644 index 000000000..034927d3b --- /dev/null +++ b/ssi-sd-jwt/src/serialized.rs @@ -0,0 +1,111 @@ +use crate::Deserialized; + +/// Lower level API to encode a fully encoded SD-JWT given a JWT and disclosure array +/// already fully encoded into their string representations +pub fn serialize_string_format(jwt: &str, disclosures: &[&str]) -> String { + let mut serialized = format!("{}~", jwt); + + for disclosure in disclosures { + serialized = format!("{}{}~", serialized, disclosure) + } + + serialized +} + +/// Lower level API to seserialize a fully encoded SD-JWT into it's JWT and disclosure +/// components +pub fn deserialize_string_format(serialized: &str) -> Option> { + if !serialized.contains('~') { + return None; + } + let mut items = serialized.split('~'); + let jwt = items.next()?; + + let mut disclosures: Vec<_> = items.collect(); + + // Remove Key Binding JWT + if disclosures.len() > 1 { + disclosures.pop(); + } + + Some(Deserialized { jwt, disclosures }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ENCODED: &str = concat!( + "eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1Fya", + "zFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZ", + "zZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZ", + "kx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1R", + "Ew4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSL", + "WFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzI", + "iwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAic", + "zBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzI", + "jogImh0dHBzOi8vZXhhbXBsZS5jb20vaXNzdWVyIiwgImlhdCI6IDE2ODMwMDAwMDAsI", + "CJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTS", + "jFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1c", + "FJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZ", + "Xd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2b", + "mNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4a", + "nZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25Vb", + "GRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0M", + "G9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R", + "3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.rFsowW-KSZe7EITlWsGajR9nnG", + "BLlQ78qgtdGIZg3FZuZnxtapP0H8CUMnffJAwPQJmGnpFpulTkLWHiI1kMmw~WyJHMDJ", + "OU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ~WyJs", + "a2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ~" + ); + + const JWT: &str = concat!( + "eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1Fya", + "zFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZ", + "zZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZ", + "kx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1R", + "Ew4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSL", + "WFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzI", + "iwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAic", + "zBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzI", + "jogImh0dHBzOi8vZXhhbXBsZS5jb20vaXNzdWVyIiwgImlhdCI6IDE2ODMwMDAwMDAsI", + "CJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTS", + "jFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1c", + "FJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZ", + "Xd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2b", + "mNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4a", + "nZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25Vb", + "GRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0M", + "G9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R", + "3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.rFsowW-KSZe7EITlWsGajR9nnG", + "BLlQ78qgtdGIZg3FZuZnxtapP0H8CUMnffJAwPQJmGnpFpulTkLWHiI1kMmw" + ); + + const DISCLOSURE_0: &str = + "WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ"; + const DISCLOSURE_1: &str = "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ"; + + #[test] + fn serialize() { + assert_eq!( + serialize_string_format(JWT, &[DISCLOSURE_0, DISCLOSURE_1]), + ENCODED, + ) + } + + #[test] + fn deserialize() { + assert_eq!( + deserialize_string_format(ENCODED), + Some(Deserialized { + jwt: JWT, + disclosures: vec![DISCLOSURE_0, DISCLOSURE_1], + }) + ) + } + + #[test] + fn deserialize_fails_with_emtpy() { + assert_eq!(deserialize_string_format(""), None) + } +} diff --git a/ssi-sd-jwt/tests/decode.rs b/ssi-sd-jwt/tests/decode.rs new file mode 100644 index 000000000..93a388ad3 --- /dev/null +++ b/ssi-sd-jwt/tests/decode.rs @@ -0,0 +1,127 @@ +use serde::{Deserialize, Serialize}; +use ssi_jwk::{Algorithm, JWK}; +use ssi_jwt::NumericDate; +use ssi_sd_jwt::{decode_verify_disclosure_array, Deserialized}; + +#[derive(Debug, Default, Deserialize, Serialize, PartialEq)] +struct ExampleClaims { + sub: Option, + given_name: Option, + family_name: Option, + email: Option, + phone_number: Option, + phone_number_verified: Option, + address: Option, + birthdate: Option, + updated_at: Option, + nationalities: Option>, +} + +#[derive(Debug, Default, Deserialize, Serialize, PartialEq)] +struct AddressClaim { + street_address: Option, + locality: Option, + region: Option, + country: Option, +} + +fn test_key() -> JWK { + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "d": "oYVImrMZjUclmWuhqa6bjzqGx5HFkbx76_00oWUHiLw", + "use": "sig", + "crv": "P-256", + "kid": "rpaXW8yADRnS2150CdsMtftwxtzSiVTV9bgHHG86v-E", + "x": "UX7TC8uQ9sn06c3DxXy1Ua5V9BK-cb9fQfukVrCLD8s", + "y": "yNXRKOnwBMTx536uajfNHklxpG9bAbdLlmVn6-XuK0Q", + "alg": "ES256" + })) + .unwrap() +} + +fn test_standard_sd_jwt() -> String { + let key = test_key(); + let claims = serde_json::json!({ + "_sd": [ + "CrQe7S5kqBAHt-nMYXgc6bdt2SH5aTY1sU_M-PgkjPI", + "JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE", + "PorFbpKuVu6xymJagvkFsFXAbRoc2JGlAUA2BA4o7cI", + "TGf4oLbgwd5JQaHyKVQZU9UdGE0w5rtDsrZzfUaomLo", + "XQ_3kPKt1XyX7KANkqVR6yZ2Va5NrPIvPYbyMvRKBMM", + "XzFrzwscM6Gn6CJDc6vVK8BkMnfG8vOSKfpPIZdAfdE", + "gbOsI4Edq2x2Kw-w5wPEzakob9hV1cRD0ATN3oQL9JM", + "jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4" + ], + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "user_42", + "nationalities": [ + { "...": "pFndjkZ_VCzmyTa6UjlZo3dh-ko8aIKQc9DlGzhaVYo" }, + { "...": "7Cf6JkPudry3lcbwHgeZ8khAv1U1OSlerP0VkBJrWZ0" } + ], + "_sd_alg": "sha-256" + }); + + ssi_jwt::encode_sign(Algorithm::ES256, &claims, &key).unwrap() +} + +// *Claim email*: +// * SHA-256 Hash: JzYjH4svliH0R3PyEMfeZu6Jt69u5qehZo7F7EPYlSE +// * Disclosure: +// WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VA +// ZXhhbXBsZS5jb20iXQ +// * Contents: ["6Ij7tM-a5iVPGboS5tmvVA", "email", +// "johndoe@example.com"] +const EMAIL_DISCLOSURE: &'static str = + "WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ"; + +// *Array Entry*: +// * SHA-256 Hash: 7Cf6JkPudry3lcbwHgeZ8khAv1U1OSlerP0VkBJrWZ0 +// * Disclosure: +// WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0 +// * Contents: ["nPuoQnkRFq3BIeAm7AnXFA", "DE"] +const NATIONALITY_DE_DISCLOSURE: &'static str = "WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIkRFIl0"; + +#[test] +fn decode_single() { + let claims = decode_verify_disclosure_array::( + Deserialized { + jwt: &test_standard_sd_jwt(), + disclosures: vec![EMAIL_DISCLOSURE], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + claims, + ExampleClaims { + sub: Some("user_42".to_owned()), + email: Some("johndoe@example.com".to_owned()), + nationalities: Some(vec![]), + ..Default::default() + }, + ) +} + +#[test] +fn decode_single_array_item() { + let claims = decode_verify_disclosure_array::( + Deserialized { + jwt: &test_standard_sd_jwt(), + disclosures: vec![NATIONALITY_DE_DISCLOSURE], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + claims, + ExampleClaims { + sub: Some("user_42".to_owned()), + nationalities: Some(vec!["DE".to_owned()]), + ..Default::default() + }, + ) +} diff --git a/ssi-sd-jwt/tests/full_pathway.rs b/ssi-sd-jwt/tests/full_pathway.rs new file mode 100644 index 000000000..983b83916 --- /dev/null +++ b/ssi-sd-jwt/tests/full_pathway.rs @@ -0,0 +1,247 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use ssi_jwk::{Algorithm, JWK}; +use ssi_sd_jwt::*; + +fn test_key() -> JWK { + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "d": "oYVImrMZjUclmWuhqa6bjzqGx5HFkbx76_00oWUHiLw", + "use": "sig", + "crv": "P-256", + "kid": "rpaXW8yADRnS2150CdsMtftwxtzSiVTV9bgHHG86v-E", + "x": "UX7TC8uQ9sn06c3DxXy1Ua5V9BK-cb9fQfukVrCLD8s", + "y": "yNXRKOnwBMTx536uajfNHklxpG9bAbdLlmVn6-XuK0Q", + "alg": "ES256" + })) + .unwrap() +} + +#[test] +fn full_pathway_regular_claim() { + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct BaseClaims { + sub: String, + disclosure0: Option, + disclosure1: Option, + } + + let base_claims = BaseClaims { + sub: "user".to_owned(), + disclosure0: None, + disclosure1: None, + }; + + let (jwt, disclosures) = encode_sign( + Algorithm::ES256, + &base_claims, + &test_key(), + SdAlg::Sha256, + vec![ + UnencodedDisclosure::new_property("disclosure0", &json!("value0")).unwrap(), + UnencodedDisclosure::new_property("disclosure1", &json!("value1")).unwrap(), + ], + ) + .unwrap(); + + let full_jwt_claims = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![&disclosures[0].encoded, &disclosures[1].encoded], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + BaseClaims { + sub: "user".to_owned(), + disclosure0: Some("value0".to_owned()), + disclosure1: Some("value1".to_owned()), + }, + full_jwt_claims, + ); + + let one_sd_claim = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![&disclosures[1].encoded], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + BaseClaims { + sub: "user".to_owned(), + disclosure0: None, + disclosure1: Some("value1".to_owned()) + }, + one_sd_claim, + ); +} + +#[test] +fn full_pathway_array() { + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct BaseClaims { + sub: String, + array_disclosure: Vec, + } + + let base_claims = BaseClaims { + sub: "user".to_owned(), + array_disclosure: vec![], + }; + + let (jwt, disclosures) = encode_sign( + Algorithm::ES256, + &base_claims, + &test_key(), + SdAlg::Sha256, + vec![ + UnencodedDisclosure::new_array_item("array_disclosure", &json!("value0")).unwrap(), + UnencodedDisclosure::new_array_item("array_disclosure", &json!("value1")).unwrap(), + ], + ) + .unwrap(); + + let full_jwt_claims = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![&disclosures[0].encoded, &disclosures[1].encoded], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + BaseClaims { + sub: "user".to_owned(), + array_disclosure: vec!["value0".to_owned(), "value1".to_owned()], + }, + full_jwt_claims + ); +} + +#[test] +fn nested_claims() { + const SD_ALG: SdAlg = SdAlg::Sha256; + + // Decode types + #[derive(Debug, Deserialize, PartialEq)] + struct InnerNestedClaim { + inner_property: String, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct OuterNestedClaim { + inner: Option, + } + + #[derive(Debug, Deserialize, PartialEq)] + struct Claims { + sub: String, + outer: Option, + } + + // Manually encode + let inner_disclosure = encode_property_disclosure( + SD_ALG, + "inner", + &serde_json::json!({"inner_property": "value"}), + ) + .unwrap(); + + let outer_disclosure = encode_property_disclosure( + SD_ALG, + "outer", + &serde_json::json!({ + "_sd": [ + inner_disclosure.hash + ] + }), + ) + .unwrap(); + + let jwt = ssi_jwt::encode_sign( + Algorithm::ES256, + &serde_json::json!({ + "_sd": [ + outer_disclosure.hash + ], + "_sd_alg": SD_ALG.to_str(), + "sub": "user", + }), + &test_key(), + ) + .unwrap(); + + // No claims provided + let no_sd_claims = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![], + }, + &test_key(), + ) + .unwrap(); + assert_eq!( + no_sd_claims, + Claims { + sub: "user".to_owned(), + outer: None, + } + ); + + // Outer provided + let outer_provided = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![&outer_disclosure.encoded], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + outer_provided, + Claims { + sub: "user".to_owned(), + outer: Some(OuterNestedClaim { inner: None }) + } + ); + + // Inner and outer provided + let inner_and_outer_provided = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![&outer_disclosure.encoded, &inner_disclosure.encoded], + }, + &test_key(), + ) + .unwrap(); + + assert_eq!( + inner_and_outer_provided, + Claims { + sub: "user".to_owned(), + outer: Some(OuterNestedClaim { + inner: Some(InnerNestedClaim { + inner_property: "value".to_owned(), + }) + }) + } + ); + + // Inner without outer errors + let result = decode_verify_disclosure_array::( + Deserialized { + jwt: &jwt, + disclosures: vec![&inner_disclosure.encoded], + }, + &test_key(), + ); + + assert!(result.is_err()); +} diff --git a/ssi-sd-jwt/tests/rfc_examples.rs b/ssi-sd-jwt/tests/rfc_examples.rs new file mode 100644 index 000000000..b2a5476b1 --- /dev/null +++ b/ssi-sd-jwt/tests/rfc_examples.rs @@ -0,0 +1,397 @@ +use serde::{Deserialize, Serialize}; +use ssi_sd_jwt::{decode_verify_disclosure_array, Deserialized}; + +fn rfc_a_5_key() -> ssi_jwk::JWK { + serde_json::from_value(serde_json::json!({ + "kty": "EC", + "crv": "P-256", + "x": "b28d4MwZMjw8-00CG4xfnn9SLMVMM19SlqZpVb_uNtQ", + "y": "Xv5zWwuoaTgdS6hV43yI6gBwTnjukmFQQnJ_kCxzqk8" + })) + .unwrap() +} + +#[test] +fn rfc_a_1_example_2_verification() { + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct Example2Address { + street_address: Option, + locality: Option, + region: Option, + country: Option, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct Example2Claims { + sub: Option, + given_name: Option, + family_name: Option, + email: Option, + phone_number: Option, + address: Example2Address, + birthdate: Option, + iss: String, + iat: u32, + exp: u32, + } + + const EXAMPLE_2_JWT: &str = concat!( + "eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1Fya", + "zFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZ", + "zZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZ", + "kx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1R", + "Ew4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSL", + "WFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzI", + "iwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAic", + "zBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzI", + "jogImh0dHBzOi8vZXhhbXBsZS5jb20vaXNzdWVyIiwgImlhdCI6IDE2ODMwMDAwMDAsI", + "CJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTS", + "jFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1c", + "FJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZ", + "Xd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2b", + "mNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4a", + "nZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25Vb", + "GRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0M", + "G9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R", + "3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.rFsowW-KSZe7EITlWsGajR9nnG", + "BLlQ78qgtdGIZg3FZuZnxtapP0H8CUMnffJAwPQJmGnpFpulTkLWHiI1kMmw" + ); + + const SUB_CLAIM_DISCLOSURE: &str = + "WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgInN1YiIsICI2YzVjMGE0OS1iNTg5LTQzMWQtYmFlNy0yMTkxMjJhOWVjMmMiXQ"; + const GIVEN_NAME_DISCLOSURE: &str = + "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImdpdmVuX25hbWUiLCAiXHU1OTJhXHU5MGNlIl0"; + const FAMILY_NAME_DISCLOSURE: &str = + "WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImZhbWlseV9uYW1lIiwgIlx1NWM3MVx1NzUzMCJd"; + const EMAIL_CLAIM_DISCLOSURE: &str = + "WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgImVtYWlsIiwgIlwidW51c3VhbCBlbWFpbCBhZGRyZXNzXCJAZXhhbXBsZS5qcCJd"; + const PHONE_NUMBER_DISCLOSURE: &str = + "WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgInBob25lX251bWJlciIsICIrODEtODAtMTIzNC01Njc4Il0"; + const ADDRESS_STREET_ADDRESS_DISCLOSURES: &str = + "WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgInN0cmVldF9hZGRyZXNzIiwgIlx1Njc3MVx1NGVhY1x1OTBmZFx1NmUyZlx1NTMzYVx1ODI5ZFx1NTE2Y1x1NTcxMlx1ZmYxNFx1NGUwMVx1NzZlZVx1ZmYxMlx1MjIxMlx1ZmYxOCJd"; + const ADDRESS_LOCALITY_DISCLOSURE: &str = + "WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZRIiwgImxvY2FsaXR5IiwgIlx1Njc3MVx1NGVhY1x1OTBmZCJd"; + const ADDRESS_REGION_DISCLOSURE: &str = + "WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ"; + const ADDRESS_COUNTRY_DISCLOSURE: &str = + "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ"; + const BIRTHDATE_DISCLOSURE: &str = + "WyJ5eXRWYmRBUEdjZ2wyckk0QzlHU29nIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0"; + + // Raw with no disclosures + let no_disclosures = decode_verify_disclosure_array::( + Deserialized { + jwt: EXAMPLE_2_JWT, + disclosures: vec![], + }, + &rfc_a_5_key(), + ) + .unwrap(); + + assert_eq!( + no_disclosures, + Example2Claims { + address: Default::default(), + iss: "https://example.com/issuer".to_owned(), + iat: 1683000000, + exp: 1883000000, + ..Default::default() + } + ); + + // Top level claim disclosed + let sub_claim_disclosed = decode_verify_disclosure_array::( + Deserialized { + jwt: EXAMPLE_2_JWT, + disclosures: vec![SUB_CLAIM_DISCLOSURE], + }, + &rfc_a_5_key(), + ) + .unwrap(); + + assert_eq!( + sub_claim_disclosed, + Example2Claims { + sub: Some("6c5c0a49-b589-431d-bae7-219122a9ec2c".to_owned()), + address: Default::default(), + iss: "https://example.com/issuer".to_owned(), + iat: 1683000000, + exp: 1883000000, + ..Default::default() + } + ); + + // Address claim disclosed + let address_country_disclosed = decode_verify_disclosure_array::( + Deserialized { + jwt: EXAMPLE_2_JWT, + disclosures: vec![ADDRESS_COUNTRY_DISCLOSURE], + }, + &rfc_a_5_key(), + ) + .unwrap(); + + assert_eq!( + address_country_disclosed, + Example2Claims { + address: Example2Address { + country: Some("JP".to_owned()), + ..Default::default() + }, + iss: "https://example.com/issuer".to_owned(), + iat: 1683000000, + exp: 1883000000, + ..Default::default() + } + ); + + // All claims disclosed + let all_claims = decode_verify_disclosure_array::( + Deserialized { + jwt: EXAMPLE_2_JWT, + disclosures: vec![ + SUB_CLAIM_DISCLOSURE, + GIVEN_NAME_DISCLOSURE, + FAMILY_NAME_DISCLOSURE, + EMAIL_CLAIM_DISCLOSURE, + PHONE_NUMBER_DISCLOSURE, + ADDRESS_STREET_ADDRESS_DISCLOSURES, + ADDRESS_LOCALITY_DISCLOSURE, + ADDRESS_REGION_DISCLOSURE, + ADDRESS_COUNTRY_DISCLOSURE, + BIRTHDATE_DISCLOSURE, + ], + }, + &rfc_a_5_key(), + ) + .unwrap(); + + assert_eq!( + all_claims, + Example2Claims { + sub: Some("6c5c0a49-b589-431d-bae7-219122a9ec2c".to_owned()), + given_name: Some("太郎".to_owned()), + family_name: Some("山田".to_owned()), + email: Some("\"unusual email address\"@example.jp".to_owned()), + phone_number: Some("+81-80-1234-5678".to_owned()), + address: Example2Address { + street_address: Some("東京都港区芝公園4丁目2−8".to_owned()), + locality: Some("東京都".to_owned()), + region: Some("港区".to_owned()), + country: Some("JP".to_owned()), + }, + birthdate: Some("1940-01-01".to_owned()), + iss: "https://example.com/issuer".to_owned(), + iat: 1683000000, + exp: 1883000000 + } + ); +} + +#[test] +fn rfc_a_2_example_3_verification() { + const EXAMPLE_3_JWT: &str = concat!( + "eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIi1hU3puSWQ5bVdNOG9jdVFvbENsbHN4V", + "mdncTEtdkhXNE90bmhVdFZtV3ciLCAiSUticllObjN2QTdXRUZyeXN2YmRCSmpERFVfR", + "XZRSXIwVzE4dlRScFVTZyIsICJvdGt4dVQxNG5CaXd6TkozTVBhT2l0T2w5cFZuWE9hR", + "UhhbF94a3lOZktJIl0sICJpc3MiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9pc3N1ZXIiL", + "CAiaWF0IjogMTY4MzAwMDAwMCwgImV4cCI6IDE4ODMwMDAwMDAsICJ2ZXJpZmllZF9jb", + "GFpbXMiOiB7InZlcmlmaWNhdGlvbiI6IHsiX3NkIjogWyI3aDRVRTlxU2N2REtvZFhWQ", + "3VvS2ZLQkpwVkJmWE1GX1RtQUdWYVplM1NjIiwgInZUd2UzcmFISUZZZ0ZBM3hhVUQyY", + "U14Rno1b0RvOGlCdTA1cUtsT2c5THciXSwgInRydXN0X2ZyYW1ld29yayI6ICJkZV9hb", + "WwiLCAiZXZpZGVuY2UiOiBbeyIuLi4iOiAidFlKMFREdWN5WlpDUk1iUk9HNHFSTzV2a", + "1BTRlJ4RmhVRUxjMThDU2wzayJ9XX0sICJjbGFpbXMiOiB7Il9zZCI6IFsiUmlPaUNuN", + "l93NVpIYWFka1FNcmNRSmYwSnRlNVJ3dXJSczU0MjMxRFRsbyIsICJTXzQ5OGJicEt6Q", + "jZFYW5mdHNzMHhjN2NPYW9uZVJyM3BLcjdOZFJtc01vIiwgIldOQS1VTks3Rl96aHNBY", + "jlzeVdPNklJUTF1SGxUbU9VOHI4Q3ZKMGNJTWsiLCAiV3hoX3NWM2lSSDliZ3JUQkppL", + "WFZSE5DTHQtdmpoWDFzZC1pZ09mXzlsayIsICJfTy13SmlIM2VuU0I0Uk9IbnRUb1FUO", + "EptTHR6LW1oTzJmMWM4OVhvZXJRIiwgImh2RFhod21HY0pRc0JDQTJPdGp1TEFjd0FNc", + "ERzYVUwbmtvdmNLT3FXTkUiXX19LCAiX3NkX2FsZyI6ICJzaGEtMjU2In0.Xtpp8nvAq", + "22k6wNRiYHGRoRnkn3EBaHdjcaa0sf0sYjCiyZnmSRlxv_C72gRwfVQkSA36ID_I46QS", + "TZvBrgm3g" + ); + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct VerificationEvidenceDocumentIssuer { + name: String, + country: String, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct VerificationEvidenceDocument { + #[serde(rename = "type")] + _type: String, + issuer: VerificationEvidenceDocumentIssuer, + number: String, + date_of_issuance: String, + date_of_expiry: String, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct VerificationEvidence { + #[serde(rename = "type")] + _type: Option, + method: Option, + time: Option, + document: Option, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct Verification { + trust_framework: String, + time: Option, + verification_process: Option, + evidence: Vec, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct PlaceOfBirth { + country: String, + locality: String, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct Address { + locality: String, + postal_code: String, + country: String, + street_address: String, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct VerifiedClaimsClaims { + given_name: Option, + family_name: Option, + nationalities: Option>, + birthdate: Option, + place_of_birth: Option, + address: Option
, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct VerifiedClaims { + verification: Verification, + claims: VerifiedClaimsClaims, + } + + #[derive(Debug, Default, Serialize, Deserialize, PartialEq)] + struct Example3Claims { + verified_claims: VerifiedClaims, + iss: String, + iat: u32, + exp: u32, + birth_middle_name: Option, + salutation: Option, + msisdn: Option, + } + + const VERIFIED_CLAIMS_TIME_DISCLOSURE: &str = + "WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgInRpbWUiLCAiMjAxMi0wNC0yM1QxODoyNVoiXQ"; + const VERIFIED_CLAIMS_VERIFICATION_PROCESS_DISCLOSURE: &str = + "WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgInZlcmlmaWNhdGlvbl9wcm9jZXNzIiwgImYyNGM2Zi02ZDNmLTRlYzUtOTczZS1iMGQ4NTA2ZjNiYzciXQ"; + const VERIFIED_CLAIMS_EVIDENCE_0_TYPE_DISCLOSURE: &str = + "WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgInR5cGUiLCAiZG9jdW1lbnQiXQ"; + const VERIFIED_CLAIMS_EVIDENCE_0_METHOD_DISCLOSURE: &str = + "WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgIm1ldGhvZCIsICJwaXBwIl0"; + const VERIFIED_CLAIMS_EVIDENCE_0_TIME_DISCLOSURE: &str = + "WyJRZ19PNjR6cUF4ZTQxMmExMDhpcm9BIiwgInRpbWUiLCAiMjAxMi0wNC0yMlQxMTozMFoiXQ"; + const VERIFIED_CLAIMS_EVIDENCE_0_DOCUMENT_DISCLOSURE: &str = + "WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImRvY3VtZW50IiwgeyJ0eXBlIjogImlkY2FyZCIsICJpc3N1ZXIiOiB7Im5hbWUiOiAiU3RhZHQgQXVnc2J1cmciLCAiY291bnRyeSI6ICJERSJ9LCAibnVtYmVyIjogIjUzNTU0NTU0IiwgImRhdGVfb2ZfaXNzdWFuY2UiOiAiMjAxMC0wMy0yMyIsICJkYXRlX29mX2V4cGlyeSI6ICIyMDIwLTAzLTIyIn1d"; + const VERIFIED_CLAIMS_EVIDENCE_0_DISCLOSURE: &str = + "WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZRIiwgeyJfc2QiOiBbIjl3cGpWUFd1RDdQSzBuc1FETDhCMDZsbWRnVjNMVnliaEh5ZFFwVE55TEkiLCAiRzVFbmhPQU9vVTlYXzZRTU52ekZYanBFQV9SYy1BRXRtMWJHX3djYUtJayIsICJJaHdGcldVQjYzUmNacTl5dmdaMFhQYzdHb3doM08ya3FYZUJJc3dnMUI0IiwgIldweFE0SFNvRXRjVG1DQ0tPZURzbEJfZW11Y1lMejJvTzhvSE5yMWJFVlEiXX1d"; + const VERIFIED_CLAIMS_CLAIMS_GIVEN_NAME_DISCLOSURE: &str = + "WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgImdpdmVuX25hbWUiLCAiTWF4Il0"; + const VERIFIED_CLAIMS_CLAIMS_FAMILY_NAME_DISCLOSURE: &str = + "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImZhbWlseV9uYW1lIiwgIk1cdTAwZmNsbGVyIl0"; + const VERIFIED_CLAIMS_CLAIMS_NATIONALITIES_DISCLOSURE: &str = + "WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIm5hdGlvbmFsaXRpZXMiLCBbIkRFIl1d"; + const VERIFIED_CLAIMS_CLAIMS_BIRTHDATE_DISCLOSURE: &str = + "WyI1YlBzMUlxdVpOYTBoa2FGenp6Wk53IiwgImJpcnRoZGF0ZSIsICIxOTU2LTAxLTI4Il0"; + const VERIFIED_CLAIMS_CLAIMS_PLACE_OF_BIRTH_DISCLOSURE: &str = + "WyI1YTJXMF9OcmxFWnpmcW1rXzdQcS13IiwgInBsYWNlX29mX2JpcnRoIiwgeyJjb3VudHJ5IjogIklTIiwgImxvY2FsaXR5IjogIlx1MDBkZXlra3ZhYlx1MDBlNmphcmtsYXVzdHVyIn1d"; + const VERIFIED_CLAIMS_CLAIMS_ADDRESS_DISCLOSURE: &str = + "WyJ5MXNWVTV3ZGZKYWhWZGd3UGdTN1JRIiwgImFkZHJlc3MiLCB7ImxvY2FsaXR5IjogIk1heHN0YWR0IiwgInBvc3RhbF9jb2RlIjogIjEyMzQ0IiwgImNvdW50cnkiOiAiREUiLCAic3RyZWV0X2FkZHJlc3MiOiAiV2VpZGVuc3RyYVx1MDBkZmUgMjIifV0"; + const BIRTH_MIDDLE_NAME_DISCLOSURE: &str = + "WyJIYlE0WDhzclZXM1FEeG5JSmRxeU9BIiwgImJpcnRoX21pZGRsZV9uYW1lIiwgIlRpbW90aGV1cyJd"; + const SALUTATION_DISCLOSURE: &str = + "WyJDOUdTb3VqdmlKcXVFZ1lmb2pDYjFBIiwgInNhbHV0YXRpb24iLCAiRHIuIl0"; + const MSISDN_DISCLOSURE: &str = + "WyJreDVrRjE3Vi14MEptd1V4OXZndnR3IiwgIm1zaXNkbiIsICI0OTEyMzQ1Njc4OSJd"; + + // All Claims + let all_claims = decode_verify_disclosure_array::( + Deserialized { + jwt: EXAMPLE_3_JWT, + disclosures: vec![ + VERIFIED_CLAIMS_TIME_DISCLOSURE, + VERIFIED_CLAIMS_VERIFICATION_PROCESS_DISCLOSURE, + VERIFIED_CLAIMS_EVIDENCE_0_TYPE_DISCLOSURE, + VERIFIED_CLAIMS_EVIDENCE_0_METHOD_DISCLOSURE, + VERIFIED_CLAIMS_EVIDENCE_0_TIME_DISCLOSURE, + VERIFIED_CLAIMS_EVIDENCE_0_DOCUMENT_DISCLOSURE, + VERIFIED_CLAIMS_EVIDENCE_0_DISCLOSURE, + VERIFIED_CLAIMS_CLAIMS_GIVEN_NAME_DISCLOSURE, + VERIFIED_CLAIMS_CLAIMS_FAMILY_NAME_DISCLOSURE, + VERIFIED_CLAIMS_CLAIMS_NATIONALITIES_DISCLOSURE, + VERIFIED_CLAIMS_CLAIMS_BIRTHDATE_DISCLOSURE, + VERIFIED_CLAIMS_CLAIMS_PLACE_OF_BIRTH_DISCLOSURE, + VERIFIED_CLAIMS_CLAIMS_ADDRESS_DISCLOSURE, + BIRTH_MIDDLE_NAME_DISCLOSURE, + SALUTATION_DISCLOSURE, + MSISDN_DISCLOSURE, + ], + }, + &rfc_a_5_key(), + ) + .unwrap(); + + assert_eq!( + all_claims, + Example3Claims { + verified_claims: VerifiedClaims { + verification: Verification { + trust_framework: "de_aml".to_owned(), + time: Some("2012-04-23T18:25Z".to_owned()), + verification_process: Some("f24c6f-6d3f-4ec5-973e-b0d8506f3bc7".to_owned()), + evidence: vec![VerificationEvidence { + _type: Some("document".to_owned()), + method: Some("pipp".to_owned()), + time: Some("2012-04-22T11:30Z".to_owned()), + document: Some(VerificationEvidenceDocument { + _type: "idcard".to_owned(), + issuer: VerificationEvidenceDocumentIssuer { + name: "Stadt Augsburg".to_owned(), + country: "DE".to_owned(), + }, + number: "53554554".to_owned(), + date_of_issuance: "2010-03-23".to_owned(), + date_of_expiry: "2020-03-22".to_owned(), + }) + }], + }, + claims: VerifiedClaimsClaims { + given_name: Some("Max".to_owned()), + family_name: Some("Müller".to_owned()), + nationalities: Some(vec!["DE".to_owned()]), + birthdate: Some("1956-01-28".to_owned()), + place_of_birth: Some(PlaceOfBirth { + country: "IS".to_owned(), + locality: "Þykkvabæjarklaustur".to_owned(), + }), + address: Some(Address { + locality: "Maxstadt".to_owned(), + postal_code: "12344".to_owned(), + country: "DE".to_owned(), + street_address: "Weidenstraße 22".to_owned(), + }), + }, + }, + iss: "https://example.com/issuer".to_owned(), + iat: 1683000000, + exp: 1883000000, + birth_middle_name: Some("Timotheus".to_owned()), + salutation: Some("Dr.".to_owned()), + msisdn: Some("49123456789".to_owned()), + } + ) +}