From 95325c87de91a25bfb215d457e3e9c8bed9a1c54 Mon Sep 17 00:00:00 2001 From: Sergey Minaev Date: Mon, 11 Dec 2023 21:53:52 +0500 Subject: [PATCH] Initial implementation (#3) * Initial implementation Signed-off-by: Sergey Minaev --- .github/workflows/rust.yml | 22 +++ .gitignore | 14 ++ Cargo.toml | 16 ++ README.md | 51 +++++-- src/disclosure.rs | 31 ++++ src/holder.rs | 216 +++++++++++++++++++++++++++ src/issuer.rs | 291 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 167 +++++++++++++++++++++ src/verifier.rs | 186 ++++++++++++++++++++++++ 9 files changed, 983 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/rust.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/disclosure.rs create mode 100644 src/holder.rs create mode 100644 src/issuer.rs create mode 100644 src/lib.rs create mode 100644 src/verifier.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..31000a2 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6985cf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..179438a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sd-jwt-rs" +version = "0.1.0" +edition = "2021" +rust-version = "1.74.0" +authors = ["Sergey Minaev "] + +[dependencies] +base64 = "0.21" +hmac = "0.12" +jsonwebtoken = "9.2" +lazy_static = "1.4" +log = "0.4" +rand = "0.8" +serde_json = { version = "1.0", features = ["preserve_order"] } +sha2 = "0.10" diff --git a/README.md b/README.md index f9ab0c0..51aa402 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,47 @@ This is the reference implementation of the [IETF SD-JWT specification](https:// Note: while the project is started as a reference implementation, it is intended to be evolved to a production-ready, high-performance implementations in the long-run. -## External Dependencies +## Repository structure + +### SD-JWT Rust crate +SD-JWT crate is the root of the repository. + +To build the project simply perform: +```shell +cargo build +``` + +To run tests: +```shell +cargo test +``` + +### Interoperability testing tool +TBD -Dual license (MIT/Apache 2.0) dependencies: -- [base64](https://crates.io/crates/base64) -- [log](https://crates.io/crates/log) -- [serde_json](https://crates.io/crates/serde_json) -- [sha2](https://crates.io/crates/sha2) -- [rand](https://crates.io/crates/rand) -- [hmac](https://crates.io/crates/hmac) +## API +Note: the current version of the crate is 0.0.x, so the API should be considered as experimental. +Proposals about API improvements are highly appreciated. + +TBD + +## Functionality + +Please refer to the list, unchecked items are in progress and will be supported soon +- [x] Issuance of SD-JWT +- [x] Presentation of SD-JWT with custom schema +- [x] Verification of SD-JWT +- [x] CI pipelines for Ubuntu +- [ ] Support of multiple hash / sign algorithms +- [ ] JWT Key Binding support +- [ ] Selective disclosure of arrays elements +- [ ] Extended error handling +- [ ] Extended CI/CD + +## External Dependencies -MIT license dependencies: -- [jsonwebtoken](https://crates.io/crates/jsonwebtoken) +Dual license (MIT/Apache 2.0) dependencies: [base64](https://crates.io/crates/base64), [lazy_static](https://crates.io/crates/lazy_static) [log](https://crates.io/crates/log), [serde_json](https://crates.io/crates/serde_json), [sha2](https://crates.io/crates/sha2), [rand](https://crates.io/crates/rand), [hmac](https://crates.io/crates/hmac). +MIT license dependencies: [jsonwebtoken](https://crates.io/crates/jsonwebtoken). Note: the list of dependencies may be changed in the future. @@ -28,4 +57,4 @@ Note: the list of dependencies may be changed in the future. - [x] Create MAINTAINERS.md file using the [format documented on the TAC site](https://tac.openwallet.foundation/governance/maintainers-file-content/). - [ ] Create a CONTRIBUTING.md file that documents steps for contributing to the project - [X] Create a CODEOWNERS file -- [ ] Update the README.md file as necessary \ No newline at end of file +- [X] Update the README.md file as necessary \ No newline at end of file diff --git a/src/disclosure.rs b/src/disclosure.rs new file mode 100644 index 0000000..b176f72 --- /dev/null +++ b/src/disclosure.rs @@ -0,0 +1,31 @@ +use crate::SDJWTCommon; + +#[derive(Debug)] +pub(crate) struct SDJWTDisclosure { + pub raw_b64: String, + pub hash: String, +} + +impl SDJWTDisclosure { + pub(crate) fn new(key: Option, value: V, inner: &SDJWTCommon) -> Self where V: ToString { + let salt = SDJWTCommon::generate_salt(key.clone()); + let mut value_str = value.to_string(); + value_str = value_str.replace(":[", ": [").replace(',', ", "); + let (_data, raw_b64) = if let Some(key) = &key { //TODO remove data? + let data = format!(r#"["{}", "{}", {}]"#, salt, key, value_str); + let raw_b64 = SDJWTCommon::base64url_encode(data.as_bytes()); + (data, raw_b64) + } else { + let data = format!("[{}, {}]", salt, value_str); + let raw_b64 = SDJWTCommon::base64url_encode(data.as_bytes()); + (data, raw_b64) + }; + + let hash = inner.b64hash(raw_b64.as_bytes()); + + Self { + raw_b64, + hash, + } + } +} diff --git a/src/holder.rs b/src/holder.rs new file mode 100644 index 0000000..79f7f9f --- /dev/null +++ b/src/holder.rs @@ -0,0 +1,216 @@ +use std::collections::HashMap; +use std::time; + +use jsonwebtoken::EncodingKey; +use serde_json::{json, Map, Value}; + +use crate::{COMBINED_SERIALIZATION_FORMAT_SEPARATOR, DEFAULT_SIGNING_ALG, SD_DIGESTS_KEY}; +use crate::SDJWTCommon; + +pub struct SDJWTHolder { + sd_jwt_engine: SDJWTCommon, + hs_disclosures: Vec, + key_binding_jwt_header: HashMap, + key_binding_jwt_payload: HashMap, + //FIXME restore key_binding_jwt: JWS, + serialized_key_binding_jwt: String, + sd_jwt_payload: Map, + serialized_sd_jwt: String, + sd_jwt: String, + pub sd_jwt_presentation: String, +} + +impl SDJWTHolder { + pub fn new(sd_jwt_with_disclosures: String, serialization_format: String) -> Self { + let serialization_format = serialization_format.to_lowercase(); + if serialization_format != "compact" && serialization_format != "json" { + panic!("Unknown serialization format: {}", serialization_format); + } + + let mut holder = SDJWTHolder { + sd_jwt_engine: SDJWTCommon { + serialization_format, + ..Default::default() + }, + hs_disclosures: Vec::new(), + key_binding_jwt_header: HashMap::new(), + key_binding_jwt_payload: HashMap::new(), + serialized_key_binding_jwt: "".to_string(), + sd_jwt_presentation: "".to_string(), + sd_jwt_payload: Map::new(), + serialized_sd_jwt: "".to_string(), + sd_jwt: "".to_string(), + }; + + holder.sd_jwt_engine.parse_sd_jwt(sd_jwt_with_disclosures).unwrap(); + + //TODO Verify signature before accepting the JWT + holder.sd_jwt_payload = holder.sd_jwt_engine.unverified_input_sd_jwt_payload.take().unwrap(); + holder.serialized_sd_jwt = holder.sd_jwt_engine.unverified_sd_jwt.take().unwrap(); + + holder + } + + pub fn create_presentation( + mut self, + claims_to_disclose: Map, + nonce: Option, + aud: Option, + holder_key: Option, + sign_alg: Option, + ) -> String { + self.sd_jwt_engine.create_hash_mappings().unwrap(); + self.hs_disclosures = self.select_disclosures(&self.sd_jwt_payload, claims_to_disclose); + + match (nonce, aud, holder_key) { + (Some(nonce), Some(aud), Some(holder_key)) => self.create_key_binding_jwt(nonce, aud, &holder_key, sign_alg), + (None, None, None) => {} + _ => panic!("Inconsistency in parameters to determine JWT KB by holder") + } + + if self.sd_jwt_engine.serialization_format == "compact" { + let mut combined: Vec<&str> = Vec::with_capacity(self.hs_disclosures.len()+2); + combined.push(&self.serialized_sd_jwt); + combined.extend(self.hs_disclosures.iter().map(|s| s.as_str())); + combined.push(&self.serialized_key_binding_jwt); + let joined = combined.join(COMBINED_SERIALIZATION_FORMAT_SEPARATOR); + self.sd_jwt_presentation = joined.to_string(); + } else { + let mut sd_jwt_parsed: Map = serde_json::from_str(&self.sd_jwt).unwrap(); + sd_jwt_parsed.insert(crate::JWS_KEY_DISCLOSURES.to_owned(), self.hs_disclosures.clone().into()); + if !self.serialized_key_binding_jwt.is_empty() { + sd_jwt_parsed.insert(crate::JWS_KEY_KB_JWT.to_owned(), self.serialized_key_binding_jwt.clone().into()); + } + self.sd_jwt_presentation = serde_json::to_string(&sd_jwt_parsed).unwrap(); + } + + self.sd_jwt_presentation + } + + fn select_disclosures( + &self, + sd_jwt_claims: &Map, + claims_to_disclose: Map, + ) -> Vec { + let mut hash_to_disclosure = Vec::new(); + + let default_list = Vec::new(); + let sd_map: HashMap<&str, (&Value, &str)> = sd_jwt_claims[SD_DIGESTS_KEY].as_array().unwrap_or(&default_list).iter().map(|digest| { + let digest = digest.as_str().unwrap(); + let disclosure = self.sd_jwt_engine.hash_to_decoded_disclosure[digest].as_array().unwrap(); + (disclosure[1].as_str().unwrap(), (&disclosure[2], digest)) + }).collect(); //TODO split to 2 maps + + for (key_to_disclose, value_to_disclose) in claims_to_disclose { + match value_to_disclose { + Value::Null | Value::Bool(true) | Value::Number(_) | Value::String(_) => { /* disclose without children */ } + Value::Array(_) => { + unimplemented!() + } + Value::Object(next_disclosure) if (!next_disclosure.is_empty()) => { + let next_sd_jwt_claims = if let Some(next) = sd_jwt_claims.get(&key_to_disclose).and_then(Value::as_object) { + next + } else { + sd_map[key_to_disclose.as_str()].0.as_object().unwrap() + }; + hash_to_disclosure.append(&mut self.select_disclosures(next_sd_jwt_claims, next_disclosure)); + } + Value::Object(_) => { /* disclose without children */ } + Value::Bool(false) => { + // skip unrevealed + continue + } + } + if sd_jwt_claims.contains_key(&key_to_disclose) { + continue; + } else if let Some((_, digest)) = sd_map.get(key_to_disclose.as_str()) { + hash_to_disclosure.push(self.sd_jwt_engine.hash_to_disclosure[*digest].to_owned()); + } else { panic!("Requested claim doesn't exist") } + } + + hash_to_disclosure + } + + fn create_key_binding_jwt( + &mut self, + nonce: String, + aud: String, + _holder_key: &EncodingKey, + sign_alg: Option, + ) { + let _alg = sign_alg.unwrap_or_else(|| DEFAULT_SIGNING_ALG.to_string()); + + self.key_binding_jwt_header.insert("alg".to_string(), _alg.into()); + self.key_binding_jwt_header.insert("typ".to_string(), crate::KB_JWT_TYP_HEADER.into()); + + self.key_binding_jwt_payload.insert("nonce".to_string(), nonce.into()); + self.key_binding_jwt_payload.insert("aud".to_string(), aud.into()); + let timestamp = time::SystemTime::now().duration_since(time::UNIX_EPOCH).unwrap().as_secs(); + self.key_binding_jwt_payload.insert("iat".to_string(), timestamp.into()); + + let _payload = json!(self.key_binding_jwt_payload); + + //FIXME jsonwebtoken signature self.serialized_key_binding_jwt = payload.sign_with_key(holder_key).unwrap(); + } +} + +#[cfg(test)] +mod tests { + use jsonwebtoken::EncodingKey; + use serde_json::{json, Map, Value}; + use crate::issuer::SDJWTClaimsStrategy; + use crate::{COMBINED_SERIALIZATION_FORMAT_SEPARATOR, SDJWTHolder, SDJWTIssuer}; + + const PRIVATE_ISSUER_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUr2bNKuBPOrAaxsR\nnbSH6hIhmNTxSGXshDSUD1a1y7ihRANCAARvbx3gzBkyPDz7TQIbjF+ef1IsxUwz\nX1KWpmlVv+421F7+c1sLqGk4HUuoVeN8iOoAcE547pJhUEJyf5Asc6pP\n-----END PRIVATE KEY-----\n"; + + #[test] + fn create_full_presentation() { + let user_claims = json!({ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "iss": "https://example.com/issuer", + "iat": "1683000000", + "exp": "1883000000", + "address": { + "street_address": "Schulstr. 12", + "locality": "Schulpforta", + "region": "Sachsen-Anhalt", + "country": "DE" + } + }); + let private_issuer_bytes = PRIVATE_ISSUER_PEM.as_bytes(); + let issuer_key = EncodingKey::from_ec_pem(private_issuer_bytes).unwrap(); + let sd_jwt = SDJWTIssuer::issue_sd_jwt(user_claims.clone(), SDJWTClaimsStrategy::Full, issuer_key, None, None, false, "compact".to_owned()); + let presentation = SDJWTHolder::new(sd_jwt.serialized_sd_jwt.clone(), "compact".to_ascii_lowercase()).create_presentation(user_claims.as_object().unwrap().clone(),None,None,None,None); + assert_eq!(sd_jwt.serialized_sd_jwt, presentation); + } + #[test] + fn create_presentation_empty_object_as_disclosure_value() { + let mut user_claims = json!({ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "address": { + "street_address": "Schulstr. 12", + "locality": "Schulpforta", + "region": "Sachsen-Anhalt", + "country": "DE" + } + }); + let private_issuer_bytes = PRIVATE_ISSUER_PEM.as_bytes(); + let issuer_key = EncodingKey::from_ec_pem(private_issuer_bytes).unwrap(); + + let sd_jwt = SDJWTIssuer::issue_sd_jwt(user_claims.clone(), SDJWTClaimsStrategy::Full, issuer_key, None, None, false, "compact".to_owned()); + let issued = sd_jwt.serialized_sd_jwt.clone(); + user_claims["address"] = Value::Object(Map::new()); + let presentation = SDJWTHolder::new(sd_jwt.serialized_sd_jwt, "compact".to_ascii_lowercase()).create_presentation(user_claims.as_object().unwrap().clone(),None,None,None,None); + + let mut parts: Vec<&str> = issued.split(COMBINED_SERIALIZATION_FORMAT_SEPARATOR).collect(); + parts.remove(4); + parts.remove(3); + parts.remove(2); + parts.remove(1); + let expected = parts.join(COMBINED_SERIALIZATION_FORMAT_SEPARATOR); + assert_eq!(expected, presentation); + } +} \ No newline at end of file diff --git a/src/issuer.rs b/src/issuer.rs new file mode 100644 index 0000000..e3d5d09 --- /dev/null +++ b/src/issuer.rs @@ -0,0 +1,291 @@ +use std::collections::{HashMap, VecDeque}; +use std::str::FromStr; +use std::vec::Vec; + +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use rand::Rng; +use serde_json::{json, Map as SJMap, Map}; +use serde_json::Value; + +use crate::{COMBINED_SERIALIZATION_FORMAT_SEPARATOR, DEFAULT_DIGEST_ALG, DIGEST_ALG_KEY, + SD_DIGESTS_KEY, SD_LIST_PREFIX, DEFAULT_SIGNING_ALG, + SDJWTCommon, SDJWTHasSDClaimException}; +use crate::disclosure::SDJWTDisclosure; + +pub struct SDJWTIssuer { + // parameters + sign_alg: String, + add_decoy_claims: bool, + extra_header_parameters: Option>, + + // input data + issuer_key: EncodingKey, + holder_key: Option, + + // internal fields + inner: SDJWTCommon, + all_disclosures: Vec, + sd_jwt_payload: SJMap, + signed_sd_jwt: String, + pub(crate) serialized_sd_jwt: String, +} + +pub enum SDJWTClaimsStrategy<'a> { + // Full disclosure + No, + // Top-level selective disclosure, nested objects as full disclosure + Flat, + // Full recursive selective disclosure + Full, + //TODO gather JSONPaths to point the claims to be SD + Partial(Vec<&'a str>), +} + +// { +// let strategy = Partial(vec!["$.address", "$.address.street_address"]) +// } +impl<'a> SDJWTClaimsStrategy<'a> { + + fn finalize_input(&mut self) -> Result<(), SDJWTHasSDClaimException> { + match self { + SDJWTClaimsStrategy::Partial(keys) => { + for key in keys.iter_mut() { + if let Some(new_key) = key.strip_prefix("$.") { + *key = new_key; + } else { + return Err(SDJWTHasSDClaimException("Invalid JSONPath".to_owned())) + } + } + Ok(()) + } + _ => Ok(()) + } + } + + fn next_level(&self, key: &str) -> Self { + match self { + Self::No => Self::No, + Self::Flat => Self::No, + Self::Full => Self::Full, + Self::Partial(sd_keys) => { + let next_sd_keys = sd_keys.iter().filter_map(|str| { + str.strip_prefix(key).and_then(|str| str.strip_prefix('.')) + }).collect(); + Self::Partial(next_sd_keys) + } + } + } + + fn sd_for_key(&self, key: &str) -> bool { + match self { + Self::No => false, + Self::Flat => true, + Self::Full => true, + Self::Partial(sd_keys) => { + sd_keys.contains(&key) + } + } + } +} + +impl SDJWTIssuer { + const DECOY_MIN_ELEMENTS: u32 = 2; + const DECOY_MAX_ELEMENTS: u32 = 5; + + pub fn issue_sd_jwt( + user_claims: Value, + mut sd_strategy: SDJWTClaimsStrategy, + issuer_key: EncodingKey, + holder_key: Option, + sign_alg: Option, + add_decoy_claims: bool, + serialization_format: String, + // extra_header_parameters: Option>, + ) -> Self { + let sign_alg = sign_alg.unwrap_or_else(|| DEFAULT_SIGNING_ALG.to_string()); + + let inner = SDJWTCommon { + serialization_format, + ..Default::default() + }; + + sd_strategy.finalize_input().unwrap(); + + SDJWTCommon::check_for_sd_claim(&user_claims).unwrap(); + + let mut issuer = SDJWTIssuer { + issuer_key, + holder_key, + sign_alg, + add_decoy_claims, + extra_header_parameters: None, + inner, + all_disclosures: Vec::new(), + sd_jwt_payload: serde_json::Map::new(), + signed_sd_jwt: String::new(), + serialized_sd_jwt: String::new(), + }; + + issuer.assemble_sd_jwt_payload(user_claims, sd_strategy); + issuer.create_signed_jws(); + issuer.create_combined(); + + issuer + } + + fn assemble_sd_jwt_payload(&mut self, mut user_claims: Value, sd_strategy: SDJWTClaimsStrategy) { + let claims_obj_ref = user_claims.as_object_mut().unwrap(); + let always_revealed_root_keys = vec!["sub", "iss", "iat", "exp"]; + let mut always_revealed_claims: Map = always_revealed_root_keys.into_iter().filter_map(|key| claims_obj_ref.remove_entry(key)).collect(); + + self.sd_jwt_payload = self.create_sd_claims(&user_claims, sd_strategy).as_object().unwrap().clone(); + + self.sd_jwt_payload.insert(DIGEST_ALG_KEY.to_owned(), Value::String(DEFAULT_DIGEST_ALG.to_owned())); //TODO + self.sd_jwt_payload.append(&mut always_revealed_claims); + + if let Some(holder_key) = &self.holder_key { + let _ = holder_key; + unimplemented!("holder key is not supported for issuance"); + //TODO public? self.sd_jwt_payload.insert("cnf".to_string(), json!({"jwk": holder_key.export_public(true)})); + } + } + + fn create_sd_claims(&mut self, user_claims: &Value, sd_strategy: SDJWTClaimsStrategy) -> Value { + match user_claims { + Value::Array(list) => { + self.create_sd_claims_list(list, sd_strategy) + } + Value::Object(object) => { + self.create_sd_claims_object(object, sd_strategy) + } + _ => user_claims.to_owned() + } + } + + fn create_sd_claims_list(&mut self, list: &[Value], sd_strategy: SDJWTClaimsStrategy) -> Value { + let mut claims = Vec::new(); + for (idx, object) in list.iter().enumerate() { + let key = idx.to_string(); + let strategy_for_child = sd_strategy.next_level(&key); + let subtree = self.create_sd_claims(object, strategy_for_child); + + if sd_strategy.sd_for_key(&key) { + let disclosure = SDJWTDisclosure::new(Some(key.to_owned()), subtree, &self.inner); + claims.push(json!({ SD_LIST_PREFIX: disclosure.hash})); + self.all_disclosures.push(disclosure); + } else { + claims.push(subtree); + } + } + Value::Array(claims) + } + + fn create_sd_claims_object(&mut self, user_claims: &SJMap, sd_strategy: SDJWTClaimsStrategy) -> Value { + let mut claims = SJMap::new(); + let mut sd_claims = Vec::new(); + + for (key, value) in user_claims.iter() { + let strategy_for_child = sd_strategy.next_level(key); + let subtree_from_here = self.create_sd_claims(value, strategy_for_child); + + if sd_strategy.sd_for_key(key) { + let disclosure = SDJWTDisclosure::new(Some(key.to_owned()), subtree_from_here, &self.inner); + sd_claims.push(disclosure.hash.clone()); + self.all_disclosures.push(disclosure); + } else { + claims.insert(key.to_owned(), subtree_from_here); + } + } + + if self.add_decoy_claims { + let num_decoy_elements = rand::thread_rng().gen_range(Self::DECOY_MIN_ELEMENTS..Self::DECOY_MAX_ELEMENTS); + for _ in 0..num_decoy_elements { + sd_claims.push(self.create_decoy_claim_entry()); + } + } + + if !sd_claims.is_empty() { + sd_claims.sort(); + claims.insert(SD_DIGESTS_KEY.to_owned(), Value::Array(sd_claims.into_iter().map(Value::String).collect())); + } + + Value::Object(claims) + } + + fn create_signed_jws(&mut self) { + if let Some(extra_headers) = &self.extra_header_parameters { + let mut _protected_headers = extra_headers.clone(); + for (key, value) in extra_headers.iter() { + _protected_headers.insert(key.to_string(), value.to_string()); + } + unimplemented!("extra_headers are not supported for issuance"); + } + + let mut header = Header::new(Algorithm::from_str(&self.sign_alg).unwrap()); + header.typ = self.inner.typ.clone(); + self.signed_sd_jwt = jsonwebtoken::encode(&header, &self.sd_jwt_payload, &self.issuer_key).unwrap(); + + if self.inner.serialization_format == "json" { + unimplemented!("json serialization is not supported for issuance"); + // let jws_content = serde_json::from_str(&self.serialized_sd_jwt).unwrap(); + // jws_content.insert(JWS_KEY_DISCLOSURES.to_string(), self.ii_disclosures.iter().map(|d| d.b64.to_string()).collect()); + // self.serialized_sd_jwt = serde_json::to_string(&jws_content).unwrap(); + } + } + + fn create_combined(&mut self) { + if self.inner.serialization_format == "compact" { + let mut disclosures: VecDeque = self.all_disclosures.iter().map(|d| d.raw_b64.to_string()).collect(); + disclosures.push_front(self.signed_sd_jwt.clone()); + + let disclosures: Vec<&str> = disclosures.iter().map(|s| s.as_str()).collect(); + + self.serialized_sd_jwt = format!( + "{}{}", + disclosures.join(COMBINED_SERIALIZATION_FORMAT_SEPARATOR), + COMBINED_SERIALIZATION_FORMAT_SEPARATOR, + ); + } else if self.inner.serialization_format == "json" { + self.serialized_sd_jwt = self.signed_sd_jwt.clone(); + } else { + unimplemented!("Unexpected format, should be an error") + } + } + + fn create_decoy_claim_entry(&mut self) -> String { + let digest = self.inner.b64hash(SDJWTCommon::generate_salt(None).as_bytes()).to_string(); + digest + } +} + +#[cfg(test)] +mod tests { + use jsonwebtoken::EncodingKey; + use log::trace; + use serde_json::json; + + use crate::issuer::SDJWTClaimsStrategy; + use crate::SDJWTIssuer; + + const PRIVATE_ISSUER_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUr2bNKuBPOrAaxsR\nnbSH6hIhmNTxSGXshDSUD1a1y7ihRANCAARvbx3gzBkyPDz7TQIbjF+ef1IsxUwz\nX1KWpmlVv+421F7+c1sLqGk4HUuoVeN8iOoAcE547pJhUEJyf5Asc6pP\n-----END PRIVATE KEY-----\n"; + + #[test] + fn test_assembly_sd_full_recursive() { + let user_claims = json!({ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "address": { + "street_address": "Schulstr. 12", + "locality": "Schulpforta", + "region": "Sachsen-Anhalt", + "country": "DE" + } + }); + let private_issuer_bytes = PRIVATE_ISSUER_PEM.as_bytes(); + let issuer_key = EncodingKey::from_ec_pem(private_issuer_bytes).unwrap(); + let sd_jwt = SDJWTIssuer::issue_sd_jwt(user_claims, SDJWTClaimsStrategy::Full, issuer_key, None, None, false, "compact".to_owned()); + trace!("{:?}", sd_jwt.serialized_sd_jwt) + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..5312865 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use base64::{Engine, engine::general_purpose}; +use lazy_static::lazy_static; +use rand::prelude::ThreadRng; +use rand::RngCore; +use serde_json::{Map, Value}; +use sha2::Digest; + +pub use {holder::SDJWTHolder, issuer::SDJWTIssuer, verifier::SDJWTVerifier}; + +mod issuer; +mod verifier; +mod holder; +mod disclosure; + +const DEFAULT_SIGNING_ALG: &str = "ES256"; +const SD_DIGESTS_KEY: &str = "_sd"; +const DIGEST_ALG_KEY: &str = "_sd_alg"; +const DEFAULT_DIGEST_ALG: &str = "sha-256"; +const SD_LIST_PREFIX: &str = "..."; +const _SD_JWT_TYP_HEADER: &str = "sd+jwt"; +const KB_JWT_TYP_HEADER: &str = "kb+jwt"; +const JWS_KEY_DISCLOSURES: &str = "disclosures"; +const JWS_KEY_KB_JWT: &str = "kb_jwt"; +const COMBINED_SERIALIZATION_FORMAT_SEPARATOR: &str = "~"; +const JWT_SEPARATOR: &str = "."; + +#[derive(Debug)] +pub(crate) struct SDJWTHasSDClaimException(String); + +impl SDJWTHasSDClaimException {} + +#[derive(Default)] +pub(crate) struct SDJWTCommon { + typ: Option, + serialization_format: String, + unverified_input_key_binding_jwt: Option, + unverified_sd_jwt: Option, + unverified_input_sd_jwt_payload: Option>, + hash_to_decoded_disclosure: HashMap, + hash_to_disclosure: HashMap, + input_disclosures: Vec, +} + +// Define the SDJWTCommon struct to hold common properties. +impl SDJWTCommon { + fn b64hash(&self, data: &[u8]) -> String { + let mut hasher = sha2::Sha256::new(); // TODO dynamic type + hasher.update(data); + let hash = hasher.finalize(); + SDJWTCommon::base64url_encode(&hash) + } + + fn create_hash_mappings(&mut self) -> Result<(), String> { + self.hash_to_decoded_disclosure = HashMap::new(); + self.hash_to_disclosure = HashMap::new(); + + for disclosure in &self.input_disclosures { + let decoded_disclosure = SDJWTCommon::base64url_decode(disclosure).map_err( + |err| format!("Error decoding disclosure {}: {}", disclosure, err) + )?; + let decoded_disclosure: Value = serde_json::from_slice(&decoded_disclosure).map_err( + |err| format!("Error parsing disclosure {}: {}", disclosure, err) + )?; + + let hash = self.b64hash(disclosure.as_bytes()); + if self.hash_to_decoded_disclosure.contains_key(&hash) { + return Err(format!("Duplicate disclosure hash {} for disclosure {:?}", hash, decoded_disclosure)); + } + self.hash_to_decoded_disclosure.insert(hash.clone(), decoded_disclosure); + self.hash_to_disclosure.insert(hash.clone(), disclosure.to_owned()); + } + + Ok(()) + } + + fn check_for_sd_claim(the_object: &Value) -> Result<(), SDJWTHasSDClaimException> { + match the_object { + Value::Object(obj) => { + for (key, value) in obj.iter() { + if key == SD_DIGESTS_KEY { + let str_err = serde_json::to_string(obj).unwrap(); + return Err(SDJWTHasSDClaimException(str_err)); + } else { + Self::check_for_sd_claim(value)?; + } + } + } + Value::Array(arr) => { + for item in arr { + Self::check_for_sd_claim(item)?; + } + } + _ => {} + } + + Ok(()) + } + + fn parse_sd_jwt(&mut self, sd_jwt_with_disclosures: String) -> Result<(), String> { + if self.get_serialization_format() == "compact" { + let parts: Vec<&str> = sd_jwt_with_disclosures.split(COMBINED_SERIALIZATION_FORMAT_SEPARATOR).collect(); + if parts.len() < 3 { + unimplemented!() + } + let mut parts = parts.into_iter(); + let sd_jwt = parts.next().unwrap(); + self.unverified_input_key_binding_jwt = Some(parts.next_back().unwrap().to_owned()); + self.input_disclosures = parts.map(str::to_owned).collect(); + self.unverified_sd_jwt = Some(sd_jwt.to_owned()); + + let mut sd_jwt = sd_jwt.split(JWT_SEPARATOR); + sd_jwt.next(); + let jwt_body = sd_jwt.next().unwrap(); + self.unverified_input_sd_jwt_payload = Some(SDJWTCommon::jwt_payload_decode(jwt_body).unwrap()); + Ok(()) + } else { + // If the SD-JWT is in JSON format, parse the JSON and extract the disclosures. + let unverified_input_sd_jwt_parsed: Value = serde_json::from_str(&sd_jwt_with_disclosures).unwrap(); + self.unverified_input_key_binding_jwt = unverified_input_sd_jwt_parsed.get(JWS_KEY_KB_JWT).map(Value::to_string); + self.input_disclosures = unverified_input_sd_jwt_parsed[JWS_KEY_DISCLOSURES].as_array().unwrap().iter().map(Value::to_string).collect(); + let payload = unverified_input_sd_jwt_parsed["payload"].as_str().unwrap(); + self.unverified_input_sd_jwt_payload = Some(SDJWTCommon::jwt_payload_decode(payload).unwrap()); + Ok(()) + } + } + + fn get_serialization_format(&self) -> &str { + &self.serialization_format + } + + fn base64url_encode(data: &[u8]) -> String { + general_purpose::URL_SAFE_NO_PAD.encode(data) + } + + fn base64url_decode(b64data: &str) -> Result, base64::DecodeError> { + general_purpose::URL_SAFE_NO_PAD.decode(b64data) + } + + fn jwt_payload_decode(b64data: &str) -> Result, SDJWTHasSDClaimException> { + Ok(serde_json::from_str(&String::from_utf8(Self::base64url_decode(b64data).unwrap()).unwrap()).unwrap()) + } + + + fn generate_salt(key_for_predefined_salt: Option) -> String { + let map = SALTS.lock().unwrap(); + + if let Some(salt) = key_for_predefined_salt.and_then(|key|map.get(&key)) { //FIXME better mock approach + salt.clone() + } else { + let mut buf = [0u8; 16]; + ThreadRng::default().fill_bytes(&mut buf); + Self::base64url_encode(&buf) + } + } +} + +lazy_static! { + pub static ref SALTS: Mutex> = Mutex::new(HashMap::new()); +} + +#[cfg(test)] +mod tests { + //FIXME add tests +} diff --git a/src/verifier.rs b/src/verifier.rs new file mode 100644 index 0000000..ca8f69e --- /dev/null +++ b/src/verifier.rs @@ -0,0 +1,186 @@ +use std::collections::HashMap; +use std::option::Option; +use std::string::String; +use std::vec::Vec; + +use jsonwebtoken::{Algorithm, DecodingKey, Header, Validation}; +use log::debug; +use serde_json::{Map, Value}; + +use crate::{DEFAULT_DIGEST_ALG, DEFAULT_SIGNING_ALG, DIGEST_ALG_KEY, SD_DIGESTS_KEY, SDJWTCommon}; + +type KeyResolver = dyn Fn(&str, &Header) -> DecodingKey; + +pub struct SDJWTVerifier { + sd_jwt_engine: SDJWTCommon, + + sd_jwt_payload: Map, + _holder_public_key_payload: Option>, + duplicate_hash_check: Vec, + verified_claims: Value, + + cb_get_issuer_key: Box, +} + +impl SDJWTVerifier { + pub fn new( + sd_jwt_presentation: String, + cb_get_issuer_key: Box, + expected_aud: Option, + expected_nonce: Option, + serialization_format: String, + ) -> Result { + let mut verifier = SDJWTVerifier { + sd_jwt_payload: serde_json::Map::new(), + _holder_public_key_payload: None, + duplicate_hash_check: Vec::new(), + cb_get_issuer_key, + sd_jwt_engine: SDJWTCommon { + serialization_format, + ..Default::default() + }, + verified_claims: Value::Null, + }; + + verifier.sd_jwt_engine.parse_sd_jwt(sd_jwt_presentation)?; + verifier.sd_jwt_engine.create_hash_mappings()?; + verifier.verify_sd_jwt(Some(DEFAULT_SIGNING_ALG.to_owned()))?; + verifier.verified_claims = verifier.extract_sd_claims()?; + + if expected_aud.is_some() && expected_nonce.is_some() { + verifier.verify_key_binding_jwt(expected_aud.unwrap(), expected_nonce.unwrap(), Some(DEFAULT_SIGNING_ALG))?; + } else if expected_aud.is_some() || expected_nonce.is_some() { + return Err("Either both expected_aud and expected_nonce must be provided or both must be None".to_string()); + } + + Ok(verifier) + } + + fn verify_sd_jwt(&mut self, sign_alg: Option) -> Result<(), String> { + let sd_jwt = self.sd_jwt_engine.unverified_sd_jwt.as_ref().unwrap(); + let parsed_header_sd_jwt = jsonwebtoken::decode_header(sd_jwt).map_err(|err| { + err.to_string() + })?; + + let unverified_issuer = self.sd_jwt_engine.unverified_input_sd_jwt_payload.as_ref().unwrap()["iss"].as_str().unwrap(); + let issuer_public_key = (self.cb_get_issuer_key)(unverified_issuer, &parsed_header_sd_jwt); + + let claims = jsonwebtoken::decode(sd_jwt, + &issuer_public_key, + &Validation::new(Algorithm::ES256)).unwrap().claims; + + let _ = sign_alg; //FIXME check algo + + self.sd_jwt_payload = claims; + + Ok(()) + } + + fn verify_key_binding_jwt(&mut self, expected_aud: String, expected_nonce: String, sign_alg: Option<&str>) -> Result<(), String> { + let (_, _, _) = (expected_aud, expected_nonce, sign_alg); + unimplemented!("JWT KB"); + } + + fn extract_sd_claims(&mut self) -> Result { + if self.sd_jwt_payload.contains_key(DIGEST_ALG_KEY) && self.sd_jwt_payload[DIGEST_ALG_KEY] != DEFAULT_DIGEST_ALG { + return Err(format!("Invalid hash algorithm {}", self.sd_jwt_payload[DIGEST_ALG_KEY])); + } + + self.duplicate_hash_check = Vec::new(); + let claims: Value = self.sd_jwt_payload.clone().into_iter().collect(); + Ok(self.unpack_disclosed_claims(&claims).unwrap()) + } + + fn unpack_disclosed_claims(&mut self, sd_jwt_claims: &Value) -> Result { + let nested_sd_jwt_claims = match sd_jwt_claims { + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => { + return Ok(sd_jwt_claims.to_owned()); + } + Value::Array(_) => { unimplemented!() } + Value::Object(obj) => { obj } + }; + + let mut disclosed_claims: Map = serde_json::Map::new(); + + for (key, value) in nested_sd_jwt_claims { + if key != SD_DIGESTS_KEY && key != DIGEST_ALG_KEY { + disclosed_claims.insert(key.to_owned(), + self.unpack_disclosed_claims(value).unwrap()); + } + } + + if let Some(digest_of_disclosures) = nested_sd_jwt_claims[SD_DIGESTS_KEY].as_array() { + self.unpack_from_digests(&mut disclosed_claims, digest_of_disclosures)?; + } + + Ok(Value::Object(disclosed_claims)) + } + + fn unpack_from_digests(&mut self, pre_output: &mut Map, digests_of_disclosures: &Vec) -> Result<(), String> { + for digest in digests_of_disclosures { + let digest = digest.as_str().unwrap(); + if self.duplicate_hash_check.contains(&digest.to_string()) { + return Err("Duplicate hash found in SD-JWT".to_string()); + } + self.duplicate_hash_check.push(digest.to_string()); + + if let Some(value_for_digest) = self.sd_jwt_engine.hash_to_decoded_disclosure.get(digest) { + let disclosure = value_for_digest.as_array().unwrap(); + let key = disclosure[1].as_str().unwrap().to_owned(); + let value = disclosure[2].clone(); + if pre_output.contains_key(&key) { + return Err(format!( + "Duplicate key found when unpacking disclosed claim: '{}' in {:?}. This is not allowed.", + key, + pre_output + )); + } + let unpacked_value = self.unpack_disclosed_claims(&value)?; + pre_output.insert(key, unpacked_value); + } else { + debug!("Digest {:?} skipped as decoy", digest) + } + } + + Ok(()) + } +} + + +#[cfg(test)] +mod tests { + use jsonwebtoken::{DecodingKey, EncodingKey}; + use serde_json::json; + use crate::issuer::SDJWTClaimsStrategy; + use crate::{SDJWTHolder, SDJWTIssuer, SDJWTVerifier}; + + const PRIVATE_ISSUER_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgUr2bNKuBPOrAaxsR\nnbSH6hIhmNTxSGXshDSUD1a1y7ihRANCAARvbx3gzBkyPDz7TQIbjF+ef1IsxUwz\nX1KWpmlVv+421F7+c1sLqGk4HUuoVeN8iOoAcE547pJhUEJyf5Asc6pP\n-----END PRIVATE KEY-----\n"; + const PUBLIC_ISSUER_PEM: &str = "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb28d4MwZMjw8+00CG4xfnn9SLMVM\nM19SlqZpVb/uNtRe/nNbC6hpOB1LqFXjfIjqAHBOeO6SYVBCcn+QLHOqTw==\n-----END PUBLIC KEY-----\n"; + + + #[test] + fn verify_full_presentation() { + let user_claims = json!({ + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "address": { + "street_address": "Schulstr. 12", + "locality": "Schulpforta", + "region": "Sachsen-Anhalt", + "country": "DE" + } + }); + let private_issuer_bytes = PRIVATE_ISSUER_PEM.as_bytes(); + let issuer_key = EncodingKey::from_ec_pem(private_issuer_bytes).unwrap(); + let sd_jwt = SDJWTIssuer::issue_sd_jwt(user_claims.clone(), SDJWTClaimsStrategy::Full, issuer_key, None, None, false, "compact".to_owned()); + let presentation = SDJWTHolder::new(sd_jwt.serialized_sd_jwt.clone(), "compact".to_owned()).create_presentation(user_claims.as_object().unwrap().clone(), None, None, None, None); + assert_eq!(sd_jwt.serialized_sd_jwt, presentation); + let verified_claims = SDJWTVerifier::new(presentation,Box::new(|_, _| { + let public_issuer_bytes= PUBLIC_ISSUER_PEM.as_bytes(); + DecodingKey::from_ec_pem(public_issuer_bytes).unwrap() + }), None, None, "compact".to_owned()).unwrap().verified_claims; + assert_eq!(user_claims, verified_claims); + } +} \ No newline at end of file