From ce6c96206844eb2d418fd732f761cdc94db42538 Mon Sep 17 00:00:00 2001 From: Tristan Miller Date: Mon, 11 Sep 2023 14:40:21 -0600 Subject: [PATCH] Initial commit of ssi-sd-jwt --- Cargo.toml | 1 + ssi-sd-jwt/Cargo.toml | 23 ++++ ssi-sd-jwt/src/decode.rs | 197 +++++++++++++++++++++++++++++++ ssi-sd-jwt/src/digest.rs | 63 ++++++++++ ssi-sd-jwt/src/encode.rs | 177 +++++++++++++++++++++++++++ ssi-sd-jwt/src/lib.rs | 47 ++++++++ ssi-sd-jwt/src/verify.rs | 196 ++++++++++++++++++++++++++++++ ssi-sd-jwt/tests/decode.rs | 135 +++++++++++++++++++++ ssi-sd-jwt/tests/full_pathway.rs | 120 +++++++++++++++++++ 9 files changed, 959 insertions(+) create mode 100644 ssi-sd-jwt/Cargo.toml create mode 100644 ssi-sd-jwt/src/decode.rs create mode 100644 ssi-sd-jwt/src/digest.rs create mode 100644 ssi-sd-jwt/src/encode.rs create mode 100644 ssi-sd-jwt/src/lib.rs create mode 100644 ssi-sd-jwt/src/verify.rs create mode 100644 ssi-sd-jwt/tests/decode.rs create mode 100644 ssi-sd-jwt/tests/full_pathway.rs diff --git a/Cargo.toml b/Cargo.toml index 65f7e4b0f..51ac8675f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ members = [ "ssi-dids", "ssi-jws", "ssi-jwt", + "ssi-sd-jwt", "ssi-tzkey", "ssi-ssh", "ssi-ldp", diff --git a/ssi-sd-jwt/Cargo.toml b/ssi-sd-jwt/Cargo.toml new file mode 100644 index 000000000..25d5c251b --- /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] +jose-b64 = { version = "0.1", features = ["json"] } +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..a01f5b4ed --- /dev/null +++ b/ssi-sd-jwt/src/decode.rs @@ -0,0 +1,197 @@ +use serde::de::DeserializeOwned; +use serde::Deserialize; +use ssi_jwk::JWK; +use ssi_jwt::NumericDate; +use std::collections::BTreeMap; + +use crate::verify::{DecodedDisclosure, DisclosureKind}; +use crate::*; + +#[derive(Debug, Deserialize, PartialEq)] +pub struct ValidityClaims { + pub nbf: Option, + pub iat: Option, + pub exp: Option, +} + +pub fn decode_verify( + jwt: &str, + key: &JWK, + disclosures: &[&str], +) -> Result<(ValidityClaims, Claims), Error> { + let mut payload_claims: serde_json::Value = ssi_jwt::decode_verify(jwt, key)?; + + let validity_claims: ValidityClaims = serde_json::from_value(payload_claims.clone())?; + + let sd_alg = sd_alg(&payload_claims)?; + let _ = payload_claims + .as_object_mut() + .unwrap() + .remove(SD_ALG_CLAIM_NAME); + + let mut disclosures = translate_to_in_progress_disclosures(disclosures, sd_alg)?; + + visit_claims(&mut payload_claims, &mut disclosures)?; + + Ok((validity_claims, serde_json::from_value(payload_claims)?)) +} + +fn sd_alg(claims: &serde_json::Value) -> Result { + let alg_name = claims[SD_ALG_CLAIM_NAME] + .as_str() + .ok_or(Error::MissingSdAlg)?; + + SdAlg::try_from(alg_name) +} + +fn translate_to_in_progress_disclosures( + disclosures: &[&str], + sd_alg: SdAlg, +) -> Result, Error> { + let disclosure_vec: Result, Error> = 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(Error::MultipleDisclosuresWithSameHash); + } + } + + Ok(disclosure_map) +} + +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<(), Error> { + 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) = payload_claims[SD_CLAIM_NAME].as_array() { + decode_sd_claims(sd, disclosures)? + } else { + vec![] + }; + + if payload_claims.contains_key(SD_CLAIM_NAME) { + payload_claims.remove(SD_CLAIM_NAME); + } + + 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(Error::DisclosureClaimCollidesWithJwtClaim); + } + } + + // Process array claims + for (_, item) in payload_claims.iter_mut() { + if let Some(array) = item.as_array_mut() { + let new_array_items = decode_array_claims(array, disclosures)?; + *array = new_array_items; + } + } + + Ok(()) +} + +fn decode_sd_claims( + sd_claims: &Vec, + disclosures: &mut BTreeMap, +) -> Result, Error> { + let mut found_disclosures = vec![]; + for disclosure_hash in sd_claims { + let disclosure_hash = disclosure_hash.as_str().ok_or(Error::SdClaimNotString)?; + + if let Some(in_progress_disclosure) = disclosures.get_mut(disclosure_hash) { + if in_progress_disclosure.found { + return Err(Error::DisclosureUsedMultipleTimes); + } + in_progress_disclosure.found = true; + match in_progress_disclosure.decoded.kind { + DisclosureKind::ArrayItem(_) => { + return Err(Error::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, Error> { + 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(Error::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(Error::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..23aff5fb6 --- /dev/null +++ b/ssi-sd-jwt/src/digest.rs @@ -0,0 +1,63 @@ +use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; +use sha2::Digest; + +use crate::Error; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum SdAlg { + Sha256, +} + +impl SdAlg { + const SHA256_STR: &'static str = "sha-256"; +} + +impl SdAlg { + pub fn to_str(&self) -> &'static str { + match self { + SdAlg::Sha256 => Self::SHA256_STR, + } + } +} + +impl TryFrom<&str> for SdAlg { + type Error = Error; + + fn try_from(value: &str) -> Result { + Ok(match value { + Self::SHA256_STR => SdAlg::Sha256, + other => return Err(Error::UnknownSdAlg(other.to_owned())), + }) + } +} + +impl From for &'static str { + fn from(value: SdAlg) -> Self { + value.to_str() + } +} + +pub fn hash_encoded_disclosure(digest_algo: SdAlg, disclosure: &str) -> String { + match digest_algo { + SdAlg::Sha256 => { + let digest = sha2::Sha256::digest(disclosure.as_bytes()); + Base64UrlUnpadded::encode_string(&digest) + } + } +} + +#[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/encode.rs b/ssi-sd-jwt/src/encode.rs new file mode 100644 index 000000000..680f3a8b0 --- /dev/null +++ b/ssi-sd-jwt/src/encode.rs @@ -0,0 +1,177 @@ +use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; +use rand::{CryptoRng, Rng}; +use serde::Serialize; +use ssi_jwk::{Algorithm, JWK}; + +use crate::*; + +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_bytes = jose_b64::serde::Json::::new(disclosure)?; + + Ok(Base64UrlUnpadded::encode_string(json_bytes.as_ref())) +} + +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 = Base64UrlUnpadded::encode_string(&salt_bytes); + + encode_disclosure_with_salt(&salt, claim_name, claim_value) +} + +pub 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) +} + +pub fn encode_sign( + algorithm: Algorithm, + base_claims: &Claims, + key: &JWK, + sd_alg: SdAlg, + disclosures: Vec, +) -> Result<(String, Vec), Error> { + let mut base_claims_json = serde_json::to_value(base_claims)?; + + let post_encoded_disclosures: Result, Error> = disclosures + .iter() + .map(|disclosure| { + let encoded = disclosure.encode()?; + let hash = hash_encoded_disclosure(sd_alg, &encoded); + Ok(PostEncodedDisclosure { + 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(Error::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(Error::EncodedClaimsContainsReservedProperty); + } + + let mut sd_claim = vec![]; + + for disclosure in post_encoded_disclosures.iter() { + match disclosure.unencoded { + UnencodedDisclosure::Claim(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!([])); + } + + let array = base_claims_obj.get_mut(claim_name).unwrap(); + let array = array.as_array_mut().ok_or(Error::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(Error::EncodedClaimsContainsReservedProperty); + } + } + + let jwt = ssi_jwt::encode_sign(algorithm, &base_claims_json, key)?; + + Ok((jwt, post_encoded_disclosures)) +} + +#[derive(Clone, Debug)] +pub enum UnencodedDisclosure { + Claim(String, serde_json::Value), + ArrayItem(String, serde_json::Value), +} + +impl UnencodedDisclosure { + pub fn claim_value_as_ref(&self) -> &serde_json::Value { + match self { + UnencodedDisclosure::ArrayItem(_, value) => value, + UnencodedDisclosure::Claim(_, value) => value, + } + } + + pub fn encoded_claim_name(&self) -> Option<&str> { + match self { + UnencodedDisclosure::Claim(name, _) => Some(name), + UnencodedDisclosure::ArrayItem(_, _) => None, + } + } + + pub fn encode(&self) -> Result { + encode_disclosure(self.encoded_claim_name(), self.claim_value_as_ref()) + } +} + +#[derive(Debug)] +pub struct PostEncodedDisclosure { + 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/lib.rs b/ssi-sd-jwt/src/lib.rs new file mode 100644 index 000000000..57503e293 --- /dev/null +++ b/ssi-sd-jwt/src/lib.rs @@ -0,0 +1,47 @@ +mod decode; +pub(crate) mod digest; +pub(crate) mod encode; +pub(crate) mod verify; + +pub use decode::{decode_verify, ValidityClaims}; +pub use digest::{hash_encoded_disclosure, SdAlg}; +pub use encode::{encode_disclosure, encode_disclosure_with_rng, encode_sign, UnencodedDisclosure}; +pub use verify::verify_sd_disclosures_array; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("JWT is missing _sd_alg property")] + MissingSdAlg, + #[error("Unknown value of _sd_alg {0}")] + UnknownSdAlg(String), + #[error("Multiple disclosures given with the same hash")] + MultipleDisclosuresWithSameHash, + #[error("An _sd claim wasn't a string")] + SdClaimNotString, + #[error("A disclosure claim would collid with an existing JWT claim")] + DisclosureClaimCollidesWithJwtClaim, + #[error("A disclosure didn't have the correct array length")] + DisclosureArrayLength, + #[error("A disclosure didn't contain the right types for elements")] + DisclosureHasWrongType, + #[error("A single disclosure was used multiple times")] + DisclosureUsedMultipleTimes, + #[error("Found an array item disclosure when expecting a property type")] + ArrayDisclosureWhenExpectingProperty, + #[error("Found a property type disclosure when expecting an array item")] + PropertyDisclosureWhenExpectingArray, + #[error("The base claims to encode did not become a JSON object")] + EncodedAsNonObject, + #[error("The base claims to encode contained a property reserved by SD-JWT")] + EncodedClaimsContainsReservedProperty, + #[error("A property for an array sd claim was not an array")] + ExpectedArray, + #[error(transparent)] + JWS(#[from] ssi_jws::Error), + #[error(transparent)] + JsonSerialization(#[from] serde_json::Error), +} + +const SD_CLAIM_NAME: &str = "_sd"; +const SD_ALG_CLAIM_NAME: &str = "_sd_alg"; +const ARRAY_CLAIM_ITEM_PROPERTY_NAME: &str = "..."; diff --git a/ssi-sd-jwt/src/verify.rs b/ssi-sd-jwt/src/verify.rs new file mode 100644 index 000000000..241e65d42 --- /dev/null +++ b/ssi-sd-jwt/src/verify.rs @@ -0,0 +1,196 @@ +use jose_b64::base64ct::{Base64UrlUnpadded, Encoding}; + +use crate::digest::{hash_encoded_disclosure, SdAlg}; +use crate::Error; + +#[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 = Base64UrlUnpadded::decode_vec(encoded).unwrap(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + + match json { + serde_json::Value::Array(values) => match values.len() { + 3 => validate_property_disclosure(&values), + 2 => validate_array_item_disclosure(&values), + _ => Err(Error::DisclosureArrayLength), + }, + _ => todo!("handle other json: {:?}", json), + } + } +} + +fn validate_property_disclosure(values: &[serde_json::Value]) -> Result { + let salt = values[0].as_str().ok_or(Error::DisclosureHasWrongType)?; + + let name = values[1].as_str().ok_or(Error::DisclosureHasWrongType)?; + + Ok(DecodedDisclosure { + salt: salt.to_owned(), + kind: DisclosureKind::Property { + name: name.to_owned(), + value: values[2].clone(), + }, + }) +} + +fn validate_array_item_disclosure( + values: &[serde_json::Value], +) -> Result { + let salt = values[0].as_str().ok_or(Error::DisclosureHasWrongType)?; + + Ok(DecodedDisclosure { + salt: salt.to_owned(), + kind: DisclosureKind::ArrayItem(values[1].clone()), + }) +} + +pub 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 let Some(orig) = orig { + todo!( + "handle multiple claims with the same property name: {:?}", + orig, + ) + } + } + DisclosureKind::ArrayItem(_) => { + todo!("array item disclouse in sd claims: {:?}", decoded) + } + } + } + + Ok(serde_json::Value::Object(verfied_claims)) +} + +fn disclosure_hash_exists_in_sd_claims(disclosure_hash: &str, sd_claim: &[&str]) -> bool { + // Todo: Yeah, this is O(N^2) since it's embedded in the for loop in + // verify_disclosures(). I'm expecting small values of N for sd_claim + // where it's just easier to check them rather than + // going through the rigmarole of adding them to map structure beforehand. + // Validate this though. + for sd_claim_item in sd_claim { + // Todo: Does this need to be constant time? I can't think of a reason + // given that sd_claims are ostensibly public anyway, but probably + // should just to be safe. + if &disclosure_hash == sd_claim_item { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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/tests/decode.rs b/ssi-sd-jwt/tests/decode.rs new file mode 100644 index 000000000..fc5b99e3e --- /dev/null +++ b/ssi-sd-jwt/tests/decode.rs @@ -0,0 +1,135 @@ +use serde::{Deserialize, Serialize}; +use ssi_jwk::{Algorithm, JWK}; +use ssi_jwt::NumericDate; +use ssi_sd_jwt::{decode_verify, ValidityClaims}; + +#[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::(&test_standard_sd_jwt(), &test_key(), &[EMAIL_DISCLOSURE]) + .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::( + &test_standard_sd_jwt(), + &test_key(), + &[NATIONALITY_DE_DISCLOSURE], + ) + .unwrap(); + + assert_eq!( + claims, + ExampleClaims { + sub: Some("user_42".to_owned()), + nationalities: Some(vec!["DE".to_owned()]), + ..Default::default() + }, + ) +} + +#[test] +fn validitity_decodes() { + let (validity, _) = + decode_verify::(&test_standard_sd_jwt(), &test_key(), &[]).unwrap(); + + assert_eq!( + validity, + ValidityClaims { + nbf: None, + iat: Some(NumericDate::try_from_seconds(1683000000.0).unwrap()), + exp: Some(NumericDate::try_from_seconds(1883000000.0).unwrap()), + } + ) +} diff --git a/ssi-sd-jwt/tests/full_pathway.rs b/ssi-sd-jwt/tests/full_pathway.rs new file mode 100644 index 000000000..f9130fea8 --- /dev/null +++ b/ssi-sd-jwt/tests/full_pathway.rs @@ -0,0 +1,120 @@ +use serde::{Deserialize, Serialize}; +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::Claim("disclosure0".to_owned(), serde_json::json!("value0")), + UnencodedDisclosure::Claim("disclosure1".to_owned(), serde_json::json!("value1")), + ], + ) + .unwrap(); + + let (_, full_jwt_claims) = decode_verify::( + &jwt, + &test_key(), + &[&disclosures[0].encoded, &disclosures[1].encoded], + ) + .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::(&jwt, &test_key(), &[&disclosures[1].encoded]).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::ArrayItem( + "array_disclosure".to_owned(), + serde_json::json!("value0"), + ), + UnencodedDisclosure::ArrayItem( + "array_disclosure".to_owned(), + serde_json::json!("value1"), + ), + ], + ) + .unwrap(); + + let (_, full_jwt_claims) = decode_verify::( + &jwt, + &test_key(), + &[&disclosures[0].encoded, &disclosures[1].encoded], + ) + .unwrap(); + + assert_eq!( + BaseClaims { + sub: "user".to_owned(), + array_disclosure: vec!["value0".to_owned(), "value1".to_owned()], + }, + full_jwt_claims + ); +}