From 2d8e9c713afcf6ab7133bdaf584354fb325eb1a6 Mon Sep 17 00:00:00 2001 From: Enrico Marconi <31142849+UMR1352@users.noreply.github.com> Date: Mon, 20 Jan 2025 13:06:26 +0100 Subject: [PATCH] Upgrade to SD-JWT v12 & API rework (#14) --- Cargo.toml | 31 ++- README.md | 208 ++++++++++------- examples/sd_jwt.rs | 104 +++++---- src/builder.rs | 396 ++++++++++++++++++++++++++++++++ src/decoder.rs | 244 +++++--------------- src/disclosure.rs | 77 +++---- src/encoder.rs | 226 +++++------------- src/error.rs | 12 +- src/hasher.rs | 9 +- src/jwt.rs | 103 +++++++++ src/key_binding_jwt_claims.rs | 214 ++++++++++++++++-- src/lib.rs | 9 +- src/sd_jwt.rs | 415 ++++++++++++++++++++++++++++++++-- src/signer.rs | 19 ++ tests/api_test.rs | 294 +++++++++--------------- 15 files changed, 1591 insertions(+), 770 deletions(-) create mode 100644 src/builder.rs create mode 100644 src/jwt.rs create mode 100644 src/signer.rs diff --git a/Cargo.toml b/Cargo.toml index 17abaea..d4f8090 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,30 +1,43 @@ [package] name = "sd-jwt-payload" -version = "0.2.1" +version = "0.3.0" edition = "2021" authors = ["IOTA Stiftung"] homepage = "https://www.iota.org" license = "Apache-2.0" repository = "https://github.com/iotaledger/sd-jwt-payload" -rust-version = "1.65" readme = "./README.md" -description = "Rust implementation of the Selective Disclosure for JWTs (SD-JWT)" +description = "Rust implementation of Selective Disclosure JWTs (SD-JWT)" keywords = ["sd-jwt", "selective-disclosure", "disclosure"] [dependencies] multibase = { version = "0.9", default-features = false, features = ["std"] } -serde_json = { version = "1.0", default-features = false, features = ["std" ] } -rand = { version = "0.8.5", default-features = false, features = ["std", "std_rng"] } +serde_json = { version = "1.0", default-features = false, features = ["std"] } +rand = { version = "0.8.5", default-features = false, features = [ + "std", + "std_rng", +] } thiserror = { version = "1.0", default-features = false } -strum = { version = "0.26", default-features = false, features = ["std", "derive"] } -itertools = { version = "0.12", default-features = false, features = ["use_std"] } -iota-crypto = { version = "0.23", default-features = false, features = ["sha"], optional = true } +strum = { version = "0.26", default-features = false, features = [ + "std", + "derive", +] } +itertools = { version = "0.12", default-features = false, features = [ + "use_std", +] } +iota-crypto = { version = "0.23", default-features = false, features = [ + "sha", +], optional = true } serde = { version = "1.0", default-features = false, features = ["derive"] } json-pointer = "0.3.4" serde_with = "3.6.1" +async-trait = "0.1.80" +anyhow = "1" +indexmap = "2" [dev-dependencies] -josekit = "0.8.4" +tokio = { version = "1.38.1", features = ["macros", "rt-multi-thread"] } +josekit = { version = "0.8.4", features = ["vendored"] } [[example]] name = "sd_jwt" diff --git a/README.md b/README.md index f1d5197..f7d047a 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,20 @@ # SD-JWT Reference implementation -Rust implementation of the [Selective Disclosure for JWTs (SD-JWT) **version 07**](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html) +Rust implementation of the [Selective Disclosure for JWTs (SD-JWT) **version 12**](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-12.html) ## Overview This library supports -* **Encoding**: - - creating disclosers and replacing values in objects and arrays with the digest of their disclosure. - - Adding decoys to objects and arrays. -* **Decoding** +* **Issuing SD-JWTs**: + - Create a selectively disclosable JWT by choosing which properties can be concealed from a verifier. + Concealable claims are replaced with their disclosure's digest. + - Adding decoys to both JSON objects and arrays. + - Requiring an holder's key-bind. +* **Managing SD-JWTs** + - Conceal with ease any concealable property. + - Insert a key-bind. +* **Verifying SD-JWTs** - Recursively replace digests in objects and arrays with their corresponding disclosure value. `Sha-256` hash function is shipped by default, encoding/decoding with other hash functions is possible. @@ -54,7 +59,7 @@ Include the library in your `cargo.toml`. ```bash [dependencies] -sd-jwt-payload = { version = "0.2.1" } +sd-jwt-payload = { version = "0.3.0" } ``` ## Examples @@ -64,153 +69,198 @@ See [sd_jwt.rs](./examples/sd_jwt.rs) for a runnable example. ## Usage This library consists of the major structs: -1. [`SdObjectEncoder`](./src/encoder.rs): creates SD objects. -2. [`SdObjectDecoder`](./src/decoder.rs): decodes SD objects. -3. [`Disclosure`](./src/disclosure.rs): used by the `SdObjectEncoder` and `SdObjectDecoder` to represent a disclosure. -3. [`SdJwt`](./src/sd_jwt.rs): creates/parses SD-JWTs. -4. [`Hasher`](./src/hasher.rs): a trait to provide hash functions to the encoder/decoder. +1. [`SdJwtBuilder`](./src/builder.rs): creates SD-JWTs. +2. [`SdJwt`](./src/sd_jwt.rs): handles SD-JWTs. +3. [`Disclosure`](./src/disclosure.rs): used throughout the library to represent disclosure objects. +4. [`Hasher`](./src/hasher.rs): a trait to provide hash functions create and replace disclosures. 5. [`Sha256Hasher`](./src/hasher.rs): implements `Hasher` for the `Sha-256` hash function. +6. [`JwsSigner`](./src/signer.rs): a trait used to create JWS signatures. -### Encoding -Any JSON object can be encoded - +### Creation +Any JSON object can be used to create an SD-JWT: ```rust let object = json!({ + "sub": "user_42", "given_name": "John", "family_name": "Doe", + "email": "johndoe@example.com", + "phone_number": "+1-202-555-0101", + "phone_number_verified": true, "address": { "street_address": "123 Main St", + "locality": "Anytown", "region": "Anystate", + "country": "US" }, - "phone": [ - "+49 123456", - "+49 234567" + "birthdate": "1940-01-01", + "updated_at": 1570000000, + "nationalities": [ + "US", + "DE" ] }); ``` ```rust - let mut encoder: SdObjectEncoder = object.try_into()?; + let builder: SdJwtBuilder = SdJwtBuilder::new(object); ``` -This creates a stateful encoder with `Sha-256` hash function by default to create disclosure digests. +This creates a stateful builder with `Sha-256` hash function by default to create disclosure digests. -*Note: `SdObjectEncoder` is generic over `Hasher` which allows custom encoding with other hash functions.* +*Note: `SdJwtBuilder` is generic over `Hasher` which allows custom encoding with other hash functions.* -The encoder can encode any of the object's values or array elements, using the `conceal` method. Suppose the value of `street_address` should be selectively disclosed as well as the value of `address` and the first `phone` value. +The builder can encode any of the object's values or array elements, using the `make_concealable` method. Suppose the value of `street_address` in 'address' should be selectively disclosed as well as the entire value of `address` and the first `phone` value. ```rust - let disclosure1 = encoder.conceal("/address/street_address"], None)?; - let disclosure2 = encoder.conceal("/address", None)?; - let disclosure3 = encoder.conceal("/phone/0", None)?; + builder + .make_concealable("/email")? + .make_concealable("/phone_number")? + .make_concealable("/address/street_address")? + .make_concealable("/address")? + .make_concealable("/nationalities/0")? ``` -``` -"WyJHaGpUZVYwV2xlUHE1bUNrVUtPVTkzcXV4WURjTzIiLCAic3RyZWV0X2FkZHJlc3MiLCAiMTIzIE1haW4gU3QiXQ" -"WyJVVXVBelg5RDdFV1g0c0FRVVM5aURLYVp3cU13blUiLCAiYWRkcmVzcyIsIHsicmVnaW9uIjoiQW55c3RhdGUiLCJfc2QiOlsiaHdiX2d0eG01SnhVbzJmTTQySzc3Q194QTUxcmkwTXF0TVVLZmI0ZVByMCJdfV0" -"WyJHRDYzSTYwUFJjb3dvdXJUUmg4OG5aM1JNbW14YVMiLCAiKzQ5IDEyMzQ1NiJd" -``` -*Note: the `conceal` method takes a [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) to determine the element to conceal inside the JSON object.* +*Note: the `make_concealable` method takes a [JSON Pointer](https://datatracker.ietf.org/doc/html/rfc6901) to determine the element to conceal inside the JSON object.* -The encoder also supports adding decoys. For instance, the amount of phone numbers and the amount of claims need to be hidden. +The builder also supports adding decoys. For instance, the amount of phone numbers and the amount of claims need to be hidden. ```rust - encoder.add_decoys("/phone", 3).unwrap(); //Adds 3 decoys to the array `phone`. - encoder.add_decoys("", 6).unwrap(); // Adds 6 decoys to the top level object. + builder + .add_decoys("/nationalities", 1)? // Adds 1 decoys to the array `nationalities`. + .add_decoys("", 2)? // Adds 2 decoys to the top level object. ``` -Add the hash function claim. +Through the builder an issuer can require a specific key-binding that will be verified upon validation: + ```rust - encoder.add_sd_alg_property(); // This adds "_sd_alg": "sha-256" + builder + .require_key_binding(RequiredKeyBinding::Kid("key1".to_string())) ``` -Now `encoder.object()?` will return the encoded object. +Internally, builder's object now looks like: ```json { + "_sd": [ + "5P7JOl7w5kWrMDQ71U4ts1CHaPPNTKDqOt9OaOdGMOg", + "73rQnMSG1np-GjzaM-yHfcZAIqmeaIK9Dn9N0atxHms", + "s0UiQ41MTAPnjfKk4HEYet0ksuMo0VTArCwG5ALiC84", + "v-xRCoLxbDcL5NZGX9uRFI0hgH9gx3uX1Y1EMcWeC5k", + "z7SAFTHCOGF8vXbHyIPXH6TQvo750AdGXhvqgMTA8Mw" + ], + "_sd_alg": "sha-256", + "cnf": { + "kid": "key1" + }, + "sub": "user_42", "given_name": "John", "family_name": "Doe", - "phone": [ - { - "...": "eZVn0KkQm_T8x-x57VxYt-_MmNG91Sh34E-bZEnNfWY" - }, - "+49 234567", - { - "...": "KAiJIx0tktQRXBxZSBVVld9298bZIp2WkpkDYDa3CWQ" - }, + "nationalities": [ { - "...": "CBKARPh6sdTCJyliZ7pBOYzix7Z4Bb4yRh0EykHX2Uw" + "...": "xYpMTpfay0Rb77IWvbJU1C4JT3kvJUftZHxZuwfiS1M" }, + "DE", { - "...": "oi1KgsYXgqBFXUXvbVaHSGYYaWhkB5RL55T90Gl_5s0" + "...": "GqcdlPi6GUDcj9VVpm8kj29jfXCdyBx2GfWP34339hI" } ], - "_sd": [ - "Jj5jBeGEawY6vRvmHDg55EjeAIP8FVhWEV2FczhUXrY", - "8eqphBPJyCBgUJhNWNP7ci-Y79N615wpZQrxi5D4ju8", - "_hOU5puJjNzSBhK0bwh3h8_b6H6nN7vd_7I0uTp80Mo", - "G_tH70MrfCkVM0HhsH9REObIt1Ei19477y6CEsS0Zlo", - "zP56MeH0ryjzqh9Kadrb5C9Z2BE2FWg8nb3g0rR3LSA", - "dgfVW11ip9OOyVi8M4h1RjXK8akw7ICeMQkjUwSI6iU", - "Bx33mOyTF5-w8gRS5yL4YQ4dig44V3lmHxk1WRss_7U" - ], - "_sd_alg": "sha-256" + "phone_number_verified": true, + "updated_at": 1570000000, + "birthdate": "1940-01-01" } ``` *Note: no JWT claims like `exp` or `iat` are added. If necessary, these need to be added and validated manually.* -### Creating SD-JWT - -Since creating JWTs is outside the scope of this library, see [sd_jwt.rs example](./examples/sd_jwt.rs) where `josekit` is used to create `jwt` with the object above as the claim set. - -Create SD-JWT +To create the actual SD-JWT the `finish` method must be called on the builder: ```rust - let sd_jwt: SdJwt = SdJwt::new(jwt, disclosures.clone(), None); - let sd_jwt: String = sd_jwt.presentation(); + let signer = MyHS256Signer::new(); + let sd_jwt = builder + // ... + .finish(&signer, "ES256") + .await?; ``` ``` eyJ0eXAiOiJTRC1KV1QiLCJhbGciOiJIUzI1NiJ9.eyJnaXZlbl9uYW1lIjoiSm9obiIsImZhbWlseV9uYW1lIjoiRG9lIiwicGhvbmUiOlt7Ii4uLiI6ImVaVm4wS2tRbV9UOHgteDU3VnhZdC1fTW1ORzkxU2gzNEUtYlpFbk5mV1kifSwiKzQ5IDIzNDU2NyIseyIuLi4iOiJLQWlKSXgwdGt0UVJYQnhaU0JWVmxkOTI5OGJaSXAyV2twa0RZRGEzQ1dRIn0seyIuLi4iOiJDQktBUlBoNnNkVENKeWxpWjdwQk9Zeml4N1o0QmI0eVJoMEV5a0hYMlV3In0seyIuLi4iOiJvaTFLZ3NZWGdxQkZYVVh2YlZhSFNHWVlhV2hrQjVSTDU1VDkwR2xfNXMwIn1dLCJfc2QiOlsiSmo1akJlR0Vhd1k2dlJ2bUhEZzU1RWplQUlQOEZWaFdFVjJGY3poVVhyWSIsIjhlcXBoQlBKeUNCZ1VKaE5XTlA3Y2ktWTc5TjYxNXdwWlFyeGk1RDRqdTgiLCJfaE9VNXB1SmpOelNCaEswYndoM2g4X2I2SDZuTjd2ZF83STB1VHA4ME1vIiwiR190SDcwTXJmQ2tWTTBIaHNIOVJFT2JJdDFFaTE5NDc3eTZDRXNTMFpsbyIsInpQNTZNZUgwcnlqenFoOUthZHJiNUM5WjJCRTJGV2c4bmIzZzByUjNMU0EiLCJkZ2ZWVzExaXA5T095Vmk4TTRoMVJqWEs4YWt3N0lDZU1Ra2pVd1NJNmlVIiwiQngzM21PeVRGNS13OGdSUzV5TDRZUTRkaWc0NFYzbG1IeGsxV1Jzc183VSJdLCJfc2RfYWxnIjoic2hhLTI1NiJ9.knTqw4FMCplHoMu7mfiix7dv4lIjYgRIn-tmuemAhbY~WyJHaGpUZVYwV2xlUHE1bUNrVUtPVTkzcXV4WURjTzIiLCAic3RyZWV0X2FkZHJlc3MiLCAiMTIzIE1haW4gU3QiXQ~WyJVVXVBelg5RDdFV1g0c0FRVVM5aURLYVp3cU13blUiLCAiYWRkcmVzcyIsIHsicmVnaW9uIjoiQW55c3RhdGUiLCJfc2QiOlsiaHdiX2d0eG01SnhVbzJmTTQySzc3Q194QTUxcmkwTXF0TVVLZmI0ZVByMCJdfV0~WyJHRDYzSTYwUFJjb3dvdXJUUmg4OG5aM1JNbW14YVMiLCAiKzQ5IDEyMzQ1NiJd~ ``` -### Decoding +### Handling -Parse the SD-JWT string to extract the JWT and the disclosures in order to decode the claims and construct the disclosed values. +Once an SD-JWT is obtained, any concealable property can be omitted from it by creating a presentation and calling the +`conceal` method: -*Note: Validating the signature of the JWT and extracting the claim set is outside the scope of this library. +```rust + let mut sd_jwt = SdJwt::parse("...")?; + let hasher = Sha256Hasher::new(); + let (presented_sd_jwt, removed_disclosures) = sd_jwt + .into_presentation(&hasher)? + .conceal("/email")? + .conceal("/nationalities/0")? + .finish()?; +``` + +To attach a key-binding JWT (KB-JWT) the `KeyBindingJwtBuilder` struct can be used: + +```rust + let mut sd_jwt = SdJwt::parse("...")?; + // Can be used to check which key is required - if any. + let requird_kb: Option<&RequiredKeyBinding> = sd_jwt.required_key_binding(); + + let signer = MyJwkSigner::new(); + let hasher = Sha256Hasher::new(); + let kb_jwt = KeyBindingJwtBuilder::new() + .nonce("abcd-efgh-ijkl-mnop") + .iat(time::now()) + .finish(&sd_jwt, &hasher, "ES256", &signer) + .await?; + + let (sd_jwt, _) = sd_jwt.into_presentation(&hasher)? + .attach_key_binding_jwt(kb_jwt) + .finish()?; +``` + +### Verifying + +The SD-JWT can be turned into a JSON object of its disclosed values by calling the `into_disclosed_object` method: ```rust - let sd_jwt: SdJwt = SdJwt::parse(sd_jwt_string)?; - let claims_set: // extract claims from `sd_jwt.jwt`. - let decoder = SdObjectDecoder::new(); - let decoded_object = decoder.decode(claims_set, &sd_jwt.disclosures)?; + let mut sd_jwt = SdJwt::parse("...")?; + let disclosed_object = sd_jwt.into_disclosed_object(&hasher)?; ``` -`decoded_object`: +`disclosed_object`: ```json { - "given_name": "John", - "family_name": "Doe", - "phone": [ - "+49 123456", - "+49 234567" - ], "address": { + "country": "US", + "locality": "Anytown", "region": "Anystate", "street_address": "123 Main St" - } + }, + "phone_number": "+1-202-555-0101", + "cnf": { + "kid": "key1" + }, + "sub": "user_42", + "given_name": "John", + "family_name": "Doe", + "nationalities": [ + "DE" + ], + "phone_number_verified": true, + "updated_at": 1570000000, + "birthdate": "1940-01-01" } ``` Note: -* `street_address` and `address` are recursively decoded. * `_sd_alg` property was removed. diff --git a/examples/sd_jwt.rs b/examples/sd_jwt.rs index 31e3cb0..c850c08 100644 --- a/examples/sd_jwt.rs +++ b/examples/sd_jwt.rs @@ -3,18 +3,36 @@ use std::error::Error; +use async_trait::async_trait; +use josekit::jws::alg::hmac::HmacJwsSigner; use josekit::jws::JwsHeader; use josekit::jws::HS256; use josekit::jwt::JwtPayload; use josekit::jwt::{self}; -use sd_jwt_payload::Disclosure; +use sd_jwt_payload::JsonObject; +use sd_jwt_payload::JwsSigner; +use sd_jwt_payload::KeyBindingJwtBuilder; use sd_jwt_payload::SdJwt; -use sd_jwt_payload::SdObjectDecoder; -use sd_jwt_payload::SdObjectEncoder; -use sd_jwt_payload::HEADER_TYP; +use sd_jwt_payload::SdJwtBuilder; +use sd_jwt_payload::Sha256Hasher; use serde_json::json; -fn main() -> Result<(), Box> { +struct HmacSignerAdapter(HmacJwsSigner); + +#[async_trait] +impl JwsSigner for HmacSignerAdapter { + type Error = josekit::JoseError; + async fn sign(&self, header: &JsonObject, payload: &JsonObject) -> Result, Self::Error> { + let header = JwsHeader::from_map(header.clone())?; + let payload = JwtPayload::from_map(payload.clone())?; + let jws = jwt::encode_with_signer(&payload, &header, &self.0)?; + + Ok(jws.into_bytes()) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { let object = json!({ "sub": "user_42", "given_name": "John", @@ -36,50 +54,50 @@ fn main() -> Result<(), Box> { ] }); - let mut encoder: SdObjectEncoder = object.try_into()?; - let disclosures: Vec = vec![ - encoder.conceal("/email", None)?, - encoder.conceal("/phone_number", None)?, - encoder.conceal("/address/street_address", None)?, - encoder.conceal("/address", None)?, - encoder.conceal("/nationalities/0", None)?, - ]; - - encoder.add_decoys("/nationalities", 3)?; - encoder.add_decoys("", 4)?; // Add decoys to the top level. + let key = b"0123456789ABCDEF0123456789ABCDEF"; + let signer = HmacSignerAdapter(HS256.signer_from_bytes(key)?); + let sd_jwt = SdJwtBuilder::new(object)? + .make_concealable("/email")? + .make_concealable("/phone_number")? + .make_concealable("/address/street_address")? + .make_concealable("/address")? + .make_concealable("/nationalities/0")? + .add_decoys("/nationalities", 1)? + .add_decoys("", 2)? + .require_key_binding(sd_jwt_payload::RequiredKeyBinding::Kid("key1".to_string())) + .finish(&signer, "HS256") + .await?; - encoder.add_sd_alg_property(); + println!("raw object: {}", serde_json::to_string_pretty(sd_jwt.claims())?); - println!("encoded object: {}", serde_json::to_string_pretty(encoder.object()?)?); + // Issuer sends the SD-JWT with all its disclosures to its holder. + let received_sd_jwt = sd_jwt.presentation(); + let sd_jwt = received_sd_jwt.parse::()?; - // Create the JWT. - // Creating JWTs is outside the scope of this library, josekit is used here as an example. - let mut header = JwsHeader::new(); - header.set_token_type(HEADER_TYP); + let hasher = Sha256Hasher::new(); + let kb_jwt = KeyBindingJwtBuilder::new() + .aud("https://verifier.example.com") + .nonce("abcdefghi") + .iat(164389238943) + .finish(&sd_jwt, &hasher, "HS256", &signer) + .await?; - // Use the encoded object as a payload for the JWT. - let payload = JwtPayload::from_map(encoder.object()?.clone())?; - let key = b"0123456789ABCDEF0123456789ABCDEF"; - let signer = HS256.signer_from_bytes(key)?; - let jwt = jwt::encode_with_signer(&payload, &header, &signer)?; + // The holder can withhold from a verifier any concealable claim by calling `conceal`. + let (presented_sd_jwt, _removed_disclosures) = sd_jwt + .into_presentation(&hasher)? + .conceal("/email")? + .conceal("/nationalities/0")? + .attach_key_binding_jwt(kb_jwt) + .finish()?; - // Create an SD_JWT by collecting the disclosures and creating an `SdJwt` instance. - let disclosures: Vec = disclosures - .into_iter() - .map(|disclosure| disclosure.to_string()) - .collect(); - let sd_jwt: SdJwt = SdJwt::new(jwt, disclosures.clone(), None); - let sd_jwt: String = sd_jwt.presentation(); + // The holder send its token to a verifier. + let received_sd_jwt = presented_sd_jwt.presentation(); + let sd_jwt = received_sd_jwt.parse::()?; - // Decoding the SD-JWT - // Extract the payload from the JWT of the SD-JWT after verifying the signature. - let sd_jwt: SdJwt = SdJwt::parse(&sd_jwt)?; - let verifier = HS256.verifier_from_bytes(key)?; - let (payload, _header) = jwt::decode_with_verifier(&sd_jwt.jwt, &verifier)?; + println!( + "object to verify: {}", + serde_json::to_string_pretty(&sd_jwt.into_disclosed_object(&hasher)?)? + ); - // Decode the payload by providing the disclosures that were parsed from the SD-JWT. - let decoder = SdObjectDecoder::new_with_sha256(); - let decoded = decoder.decode(payload.claims_set(), &sd_jwt.disclosures)?; - println!("decoded object: {}", serde_json::to_string_pretty(&decoded)?); Ok(()) } diff --git a/src/builder.rs b/src/builder.rs new file mode 100644 index 0000000..ac7ba18 --- /dev/null +++ b/src/builder.rs @@ -0,0 +1,396 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::borrow::Cow; + +use anyhow::Context as _; +use itertools::Itertools; +use serde::Serialize; +use serde_json::Value; + +use crate::jwt::Jwt; +use crate::Disclosure; +use crate::Error; +use crate::Hasher; +use crate::JsonObject; +use crate::JwsSigner; +use crate::RequiredKeyBinding; +use crate::Result; +use crate::SdJwt; +use crate::SdJwtClaims; +use crate::SdObjectEncoder; +#[cfg(feature = "sha")] +use crate::Sha256Hasher; +use crate::DEFAULT_SALT_SIZE; +use crate::HEADER_TYP; + +/// Builder structure to create an issuable SD-JWT. +#[derive(Debug)] +pub struct SdJwtBuilder { + encoder: SdObjectEncoder, + header: JsonObject, + disclosures: Vec, + key_bind: Option, +} + +#[cfg(feature = "sha")] +impl SdJwtBuilder { + /// Creates a new [`SdJwtBuilder`] with `sha-256` hash function. + /// + /// ## Error + /// Returns [`Error::DataTypeMismatch`] if `object` is not a valid JSON object. + pub fn new(object: T) -> Result { + Self::new_with_hasher(object, Sha256Hasher::new()) + } +} + +impl SdJwtBuilder { + /// Creates a new [`SdJwtBuilder`] with custom hash function to create digests. + pub fn new_with_hasher(object: T, hasher: H) -> Result { + Self::new_with_hasher_and_salt_size(object, hasher, DEFAULT_SALT_SIZE) + } + + /// Creates a new [`SdJwtBuilder`] with custom hash function to create digests, and custom salt size. + pub fn new_with_hasher_and_salt_size(object: T, hasher: H, salt_size: usize) -> Result { + let object = serde_json::to_value(object).map_err(|e| Error::Unspecified(e.to_string()))?; + let encoder = SdObjectEncoder::with_custom_hasher_and_salt_size(object, hasher, salt_size)?; + Ok(Self { + encoder, + disclosures: vec![], + key_bind: None, + header: JsonObject::default(), + }) + } + + /// Substitutes a value with the digest of its disclosure. + /// + /// ## Notes + /// - `path` indicates the pointer to the value that will be concealed using the syntax of [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901). + /// + /// + /// ## Example + /// ```rust + /// use sd_jwt_payload::SdJwtBuilder; + /// use sd_jwt_payload::json; + /// + /// let obj = json!({ + /// "id": "did:value", + /// "claim1": { + /// "abc": true + /// }, + /// "claim2": ["val_1", "val_2"] + /// }); + /// let builder = SdJwtBuilder::new(obj) + /// .unwrap() + /// .make_concealable("/id").unwrap() //conceals "id": "did:value" + /// .make_concealable("/claim1/abc").unwrap() //"abc": true + /// .make_concealable("/claim2/0").unwrap(); //conceals "val_1" + /// ``` + /// + /// ## Error + /// * [`Error::InvalidPath`] if pointer is invalid. + /// * [`Error::DataTypeMismatch`] if existing SD format is invalid. + pub fn make_concealable(mut self, path: &str) -> Result { + let disclosure = self.encoder.conceal(path)?; + self.disclosures.push(disclosure); + + Ok(self) + } + + /// Sets the JWT header. + /// ## Notes + /// - if [`SdJwtBuilder::header`] is not called, the default header is used: ```json { "typ": "sd-jwt", "alg": + /// "" } ``` + /// - `alg` is always replaced with the value passed to [`SdJwtBuilder::finish`]. + pub fn header(mut self, header: JsonObject) -> Self { + self.header = header; + self + } + + /// Adds a new claim to the underlying object. + pub fn insert_claim<'a, K, V>(mut self, key: K, value: V) -> Result + where + K: Into>, + V: Serialize, + { + let key = key.into().into_owned(); + let value = serde_json::to_value(value).map_err(|e| Error::DeserializationError(e.to_string()))?; + self + .encoder + .object + .as_object_mut() + .expect("encoder::object is a JSON Object") + .insert(key, value); + + Ok(self) + } + + /// Adds a decoy digest to the specified path. + /// + /// `path` indicates the pointer to the value that will be concealed using the syntax of + /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901). + /// + /// Use `path` = "" to add decoys to the top level. + pub fn add_decoys(mut self, path: &str, number_of_decoys: usize) -> Result { + self.encoder.add_decoys(path, number_of_decoys)?; + + Ok(self) + } + + /// Require a proof of possession of a given key from the holder. + /// + /// This operation adds a JWT confirmation (`cnf`) claim as specified in + /// [RFC8300](https://www.rfc-editor.org/rfc/rfc7800.html#section-3). + pub fn require_key_binding(mut self, key_bind: RequiredKeyBinding) -> Self { + self.key_bind = Some(key_bind); + self + } + + /// Creates an SD-JWT with the provided data. + pub async fn finish(self, signer: &S, alg: &str) -> Result + where + S: JwsSigner, + { + let SdJwtBuilder { + mut encoder, + disclosures, + key_bind, + mut header, + } = self; + encoder.add_sd_alg_property(); + let mut object = encoder.object; + // Add key binding requirement as `cnf`. + if let Some(key_bind) = key_bind { + let key_bind = serde_json::to_value(key_bind).map_err(|e| Error::DeserializationError(e.to_string()))?; + object + .as_object_mut() + .expect("encoder::object is a JSON Object") + .insert("cnf".to_string(), key_bind); + } + + // Check mandatory header properties or insert them. + if let Some(Value::String(typ)) = header.get("typ") { + if !typ.split('+').contains(&HEADER_TYP) { + return Err(Error::DataTypeMismatch( + "invalid header: \"typ\" must contain type \"sd-jwt\"".to_string(), + )); + } + } else { + header.insert("typ".to_string(), Value::String(HEADER_TYP.to_string())); + } + header.insert("alg".to_string(), Value::String(alg.to_string())); + + let jws = signer + .sign(&header, object.as_object().expect("encoder::object is a JSON Object")) + .await + .map_err(|e| anyhow::anyhow!("jws failed: {e}")) + .and_then(|jws_bytes| String::from_utf8(jws_bytes).context("invalid JWS")) + .map_err(|e| Error::JwsSignerFailure(e.to_string()))?; + + let claims = serde_json::from_value::(object) + .map_err(|e| Error::DeserializationError(format!("invalid SD-JWT claims: {e}")))?; + let jwt = Jwt { header, claims, jws }; + + Ok(SdJwt::new(jwt, disclosures, None)) + } +} + +#[cfg(test)] +mod test { + use serde_json::json; + + use super::*; + + mod marking_properties_as_concealable { + use super::*; + + mod that_exist { + use super::*; + + mod on_top_level { + use super::*; + + #[test] + fn can_be_done_for_object_values() { + let result = SdJwtBuilder::new(json!({ "address": {} })) + .unwrap() + .make_concealable("/address"); + + assert!(result.is_ok()); + } + + #[test] + fn can_be_done_for_array_elements() { + let result = SdJwtBuilder::new(json!({ "nationalities": ["US", "DE"] })) + .unwrap() + .make_concealable("/nationalities"); + + assert!(result.is_ok()); + } + } + + mod as_subproperties { + use super::*; + + #[test] + fn can_be_done_for_object_values() { + let result = SdJwtBuilder::new(json!({ "address": { "country": "US" } })) + .unwrap() + .make_concealable("/address/country"); + + assert!(result.is_ok()); + } + + #[test] + fn can_be_done_for_array_elements() { + let result = SdJwtBuilder::new(json!({ + "address": { "contact_person": [ "Jane Dow", "John Doe" ] } + })) + .unwrap() + .make_concealable("/address/contact_person/0"); + + assert!(result.is_ok()); + } + } + } + + mod that_do_not_exist { + use super::*; + mod on_top_level { + use super::*; + + #[test] + fn returns_an_error_for_nonexistant_object_paths() { + let result = SdJwtBuilder::new(json!({})).unwrap().make_concealable("/email"); + + assert_eq!(result.unwrap_err(), Error::InvalidPath("/email".to_string()),); + } + + #[test] + fn returns_an_error_for_nonexistant_array_paths() { + let result = SdJwtBuilder::new(json!({})) + .unwrap() + .make_concealable("/nationalities/0"); + + assert_eq!(result.unwrap_err(), Error::InvalidPath("/nationalities/0".to_string()),); + } + + #[test] + fn returns_an_error_for_nonexistant_array_entries() { + let result = SdJwtBuilder::new(json!({ + "nationalities": ["US", "DE"] + })) + .unwrap() + .make_concealable("/nationalities/2"); + + assert_eq!(result.unwrap_err(), Error::InvalidPath("/nationalities/2".to_string()),); + } + } + + mod as_subproperties { + use super::*; + + #[test] + fn returns_an_error_for_nonexistant_object_paths() { + let result = SdJwtBuilder::new(json!({ + "address": {} + })) + .unwrap() + .make_concealable("/address/region"); + + assert_eq!(result.unwrap_err(), Error::InvalidPath("/address/region".to_string()),); + } + + #[test] + fn returns_an_error_for_nonexistant_array_paths() { + let result = SdJwtBuilder::new(json!({ + "address": {} + })) + .unwrap() + .make_concealable("/address/contact_person/2"); + + assert_eq!( + result.unwrap_err(), + Error::InvalidPath("/address/contact_person/2".to_string()), + ); + } + + #[test] + fn returns_an_error_for_nonexistant_array_entries() { + let result = SdJwtBuilder::new(json!({ + "address": { "contact_person": [ "Jane Dow", "John Doe" ] } + })) + .unwrap() + .make_concealable("/address/contact_person/2"); + + assert_eq!( + result.unwrap_err(), + Error::InvalidPath("/address/contact_person/2".to_string()), + ); + } + } + } + } + + mod adding_decoys { + use super::*; + + mod on_top_level { + use super::*; + + #[test] + fn can_add_zero_object_value_decoys_for_a_path() { + let result = SdJwtBuilder::new(json!({})).unwrap().add_decoys("", 0); + + assert!(result.is_ok()); + } + + #[test] + fn can_add_object_value_decoys_for_a_path() { + let result = SdJwtBuilder::new(json!({})).unwrap().add_decoys("", 2); + + assert!(result.is_ok()); + } + } + + mod for_subproperties { + use super::*; + + #[test] + fn can_add_zero_object_value_decoys_for_a_path() { + let result = SdJwtBuilder::new(json!({ "address": {} })) + .unwrap() + .add_decoys("/address", 0); + + assert!(result.is_ok()); + } + + #[test] + fn can_add_object_value_decoys_for_a_path() { + let result = SdJwtBuilder::new(json!({ "address": {} })) + .unwrap() + .add_decoys("/address", 2); + + assert!(result.is_ok()); + } + + #[test] + fn can_add_zero_array_element_decoys_for_a_path() { + let result = SdJwtBuilder::new(json!({ "nationalities": ["US", "DE"] })) + .unwrap() + .add_decoys("/nationalities", 0); + + assert!(result.is_ok()); + } + + #[test] + fn can_add_array_element_decoys_for_a_path() { + let result = SdJwtBuilder::new(json!({ "nationalities": ["US", "DE"] })) + .unwrap() + .add_decoys("/nationalities", 2); + + assert!(result.is_ok()); + } + } + } +} diff --git a/src/decoder.rs b/src/decoder.rs index 375462c..50826ee 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -4,85 +4,30 @@ use crate::ARRAY_DIGEST_KEY; use crate::DIGESTS_KEY; use crate::SD_ALG; -use crate::SHA_ALG_NAME; use super::Disclosure; -use super::Hasher; -#[cfg(feature = "sha")] -use super::Sha256Hasher; use crate::Error; use serde_json::Map; use serde_json::Value; -use std::collections::BTreeMap; +use std::collections::HashMap; /// Substitutes digests in an SD-JWT object by their corresponding plain text values provided by disclosures. -pub struct SdObjectDecoder { - hashers: BTreeMap>, -} +pub struct SdObjectDecoder; impl SdObjectDecoder { - /// Creates a new [`SdObjectDecoder`] with `sha-256` hasher. - #[cfg(feature = "sha")] - pub fn new_with_sha256() -> Self { - let hashers: BTreeMap> = BTreeMap::new(); - let mut hasher = Self { hashers }; - hasher.add_hasher(Box::new(Sha256Hasher::new())); - hasher - } - - /// Creates a new [`SdObjectDecoder`] without any hashers. - pub fn new() -> Self { - let hashers: BTreeMap> = BTreeMap::new(); - Self { hashers } - } - - /// Adds a hasher. - /// - /// If a hasher for the same algorithm [`Hasher::alg_name`] already exists, it will be replaced and - /// the existing hasher will be returned, otherwise `None`. - pub fn add_hasher(&mut self, hasher: Box) -> Option> { - let alg_name = hasher.as_ref().alg_name().to_string(); - - self.hashers.insert(alg_name.clone(), hasher) - } - - /// Removes a hasher. - /// - /// If the hasher for that algorithm exists, it will be removed and returned, otherwise `None`. - pub fn remove_hasher(&mut self, hash_alg: String) -> Option> { - self.hashers.remove(&hash_alg) - } - /// Decodes an SD-JWT `object` containing by Substituting the digests with their corresponding /// plain text values provided by `disclosures`. - /// - /// ## Notes - /// * The hasher is determined by the `_sd_alg` property. If none is set, the sha-256 hasher will - /// be used, if present. - /// * Claims like `exp` or `iat` are not validated in the process of decoding. - /// * `_sd_alg` property will be removed if present. pub fn decode( &self, object: &Map, - disclosures: &Vec, + disclosures: &HashMap, ) -> Result, crate::Error> { - // Determine hasher. - let hasher = self.determine_hasher(object)?; - - // Create a map of (disclosure digest) → (disclosure). - let mut disclosures_map: BTreeMap = BTreeMap::new(); - for disclosure in disclosures { - let parsed_disclosure = Disclosure::parse(disclosure.to_string())?; - let digest = hasher.encoded_digest(disclosure.as_str()); - disclosures_map.insert(digest, parsed_disclosure); - } - // `processed_digests` are kept track of in case one digest appears more than once which // renders the SD-JWT invalid. let mut processed_digests: Vec = vec![]; // Decode the object recursively. - let mut decoded = self.decode_object(object, &disclosures_map, &mut processed_digests)?; + let mut decoded = self.decode_object(object, disclosures, &mut processed_digests)?; if processed_digests.len() != disclosures.len() { return Err(crate::Error::UnusedDisclosures( @@ -92,77 +37,65 @@ impl SdObjectDecoder { // Remove `_sd_alg` in case it exists. decoded.remove(SD_ALG); - Ok(decoded) - } - pub fn determine_hasher(&self, object: &Map) -> Result<&dyn Hasher, Error> { - //If the _sd_alg claim is not present at the top level, a default value of sha-256 MUST be used. - let alg: &str = if let Some(alg) = object.get(SD_ALG) { - alg.as_str().ok_or(Error::DataTypeMismatch( - "the value of `_sd_alg` is not a string".to_string(), - ))? - } else { - SHA_ALG_NAME - }; - self - .hashers - .get(alg) - .map(AsRef::as_ref) - .ok_or(Error::MissingHasher(alg.to_string())) + Ok(decoded) } fn decode_object( &self, object: &Map, - disclosures: &BTreeMap, + disclosures: &HashMap, processed_digests: &mut Vec, ) -> Result, Error> { let mut output: Map = object.clone(); for (key, value) in object.iter() { - if key == DIGESTS_KEY { - let sd_array: &Vec = value - .as_array() - .ok_or(Error::DataTypeMismatch(format!("{} is not an array", DIGESTS_KEY)))?; - for digest in sd_array { - let digest_str = digest - .as_str() - .ok_or(Error::DataTypeMismatch(format!("{} is not a string", digest)))? - .to_string(); - - // Reject if any digests were found more than once. - if processed_digests.contains(&digest_str) { - return Err(Error::DuplicateDigestError(digest_str)); - } - - // Check if a disclosure of this digest is available - // and insert its claim name and value in the object. - if let Some(disclosure) = disclosures.get(&digest_str) { - let claim_name = disclosure.claim_name.clone().ok_or(Error::DataTypeMismatch(format!( - "disclosure type error: {}", - disclosure - )))?; + match value { + Value::Array(sd_array) if key == DIGESTS_KEY => { + for digest in sd_array { + let digest_str = digest + .as_str() + .ok_or(Error::DataTypeMismatch(format!("{} is not a string", digest)))? + .to_string(); - if output.contains_key(&claim_name) { - return Err(Error::ClaimCollisionError(claim_name)); + // Reject if any digests were found more than once. + if processed_digests.contains(&digest_str) { + return Err(Error::DuplicateDigestError(digest_str)); } - processed_digests.push(digest_str.clone()); - let recursively_decoded = match disclosure.claim_value { - Value::Array(ref sub_arr) => Value::Array(self.decode_array(sub_arr, disclosures, processed_digests)?), - Value::Object(ref sub_obj) => { - Value::Object(self.decode_object(sub_obj, disclosures, processed_digests)?) + // Check if a disclosure of this digest is available + // and insert its claim name and value in the object. + if let Some(disclosure) = disclosures.get(&digest_str) { + let claim_name = disclosure.claim_name.clone().ok_or(Error::DataTypeMismatch(format!( + "disclosure type error: {}", + disclosure + )))?; + + if output.contains_key(&claim_name) { + return Err(Error::ClaimCollisionError(claim_name)); } - _ => disclosure.claim_value.clone(), - }; + processed_digests.push(digest_str.clone()); + + let recursively_decoded = match disclosure.claim_value { + Value::Array(ref sub_arr) => { + Value::Array(self.decode_array(sub_arr, disclosures, processed_digests)?) + } + Value::Object(ref sub_obj) => { + Value::Object(self.decode_object(sub_obj, disclosures, processed_digests)?) + } + _ => disclosure.claim_value.clone(), + }; - output.insert(claim_name, recursively_decoded); + output.insert(claim_name, recursively_decoded); + } + } + if output + .get(DIGESTS_KEY) + .expect("output has a `DIGEST_KEY` property") + .is_array() + { + output.remove(DIGESTS_KEY); } } - output.remove(DIGESTS_KEY); - continue; - } - - match value { Value::Object(object) => { let decoded_object = self.decode_object(object, disclosures, processed_digests)?; if !decoded_object.is_empty() { @@ -185,7 +118,7 @@ impl SdObjectDecoder { fn decode_array( &self, array: &[Value], - disclosures: &BTreeMap, + disclosures: &HashMap, processed_digests: &mut Vec, ) -> Result, Error> { let mut output: Vec = vec![]; @@ -244,40 +177,13 @@ impl SdObjectDecoder { } } -#[cfg(feature = "sha")] -impl Default for SdObjectDecoder { - fn default() -> Self { - Self::new_with_sha256() - } -} - #[cfg(test)] mod test { - use crate::Disclosure; - use crate::Error; + use std::collections::HashMap; + use crate::SdObjectDecoder; use crate::SdObjectEncoder; use serde_json::json; - use serde_json::Value; - - #[test] - fn collision() { - let object = json!({ - "id": "did:value", - }); - let mut encoder = SdObjectEncoder::try_from(object).unwrap(); - let dis = encoder.conceal("/id", None).unwrap(); - encoder - .object - .as_object_mut() - .unwrap() - .insert("id".to_string(), Value::String("id-value".to_string())); - let decoder = SdObjectDecoder::new_with_sha256(); - let decoded = decoder - .decode(encoder.object().unwrap(), &vec![dis.to_string()]) - .unwrap_err(); - assert!(matches!(decoded, Error::ClaimCollisionError(_))); - } #[test] fn sd_alg() { @@ -289,53 +195,11 @@ mod test { }); let mut encoder = SdObjectEncoder::try_from(object).unwrap(); encoder.add_sd_alg_property(); - assert_eq!(encoder.object().unwrap().get("_sd_alg").unwrap(), "sha-256"); - let decoder = SdObjectDecoder::new_with_sha256(); - let decoded = decoder.decode(encoder.object().unwrap(), &vec![]).unwrap(); + assert_eq!(encoder.object.get("_sd_alg").unwrap(), "sha-256"); + let decoder = SdObjectDecoder; + let decoded = decoder + .decode(encoder.object.as_object().unwrap(), &HashMap::new()) + .unwrap(); assert!(decoded.get("_sd_alg").is_none()); } - - #[test] - fn duplicate_digest() { - let object = json!({ - "id": "did:value", - }); - let mut encoder = SdObjectEncoder::try_from(object).unwrap(); - let dislosure: Disclosure = encoder.conceal("/id", Some("test".to_string())).unwrap(); - // 'obj' contains digest of `id` twice. - let obj = json!({ - "_sd":[ - "mcKLMnXQdCM0gJ5l4Hb6ignpVgCw4SfienkI8vFgpjE", - "mcKLMnXQdCM0gJ5l4Hb6ignpVgCw4SfienkI8vFgpjE" - ] - } - ); - let decoder = SdObjectDecoder::new_with_sha256(); - let result = decoder.decode(obj.as_object().unwrap(), &vec![dislosure.to_string()]); - assert!(matches!(result.err().unwrap(), crate::Error::DuplicateDigestError(_))); - } - - #[test] - fn unused_disclosure() { - let object = json!({ - "id": "did:value", - "tst": "tst-value" - }); - let mut encoder = SdObjectEncoder::try_from(object).unwrap(); - let disclosure_1: Disclosure = encoder.conceal("/id", Some("test".to_string())).unwrap(); - let disclosure_2: Disclosure = encoder.conceal("/tst", Some("test".to_string())).unwrap(); - // 'obj' contains only the digest of `id`. - let obj = json!({ - "_sd":[ - "mcKLMnXQdCM0gJ5l4Hb6ignpVgCw4SfienkI8vFgpjE", - ] - } - ); - let decoder = SdObjectDecoder::new_with_sha256(); - let result = decoder.decode( - obj.as_object().unwrap(), - &vec![disclosure_1.to_string(), disclosure_2.to_string()], - ); - assert!(matches!(result.err().unwrap(), crate::Error::UnusedDisclosures(1))); - } } diff --git a/src/disclosure.rs b/src/disclosure.rs index ac325ad..2b29a55 100644 --- a/src/disclosure.rs +++ b/src/disclosure.rs @@ -2,16 +2,15 @@ // SPDX-License-Identifier: Apache-2.0 use crate::Error; -use serde::Deserialize; -use serde::Serialize; +use serde_json::json; use serde_json::Value; use std::fmt::Display; -/// Represents an elements constructing a disclosure. -/// Object properties and array elements disclosures are supported. +/// A disclosable value. +/// Both object properties and array elements disclosures are supported. /// /// See: https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-disclosures -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq)] pub struct Disclosure { /// The salt value. pub salt: String, @@ -19,27 +18,35 @@ pub struct Disclosure { pub claim_name: Option, /// The claim Value which can be of any type. pub claim_value: Value, - /// The base64url-encoded string. - pub disclosure: String, + /// Base64Url-encoded disclosure. + unparsed: String, +} + +impl AsRef for Disclosure { + fn as_ref(&self) -> &str { + &self.unparsed + } } impl Disclosure { /// Creates a new instance of [`Disclosure`]. /// /// Use `.to_string()` to get the actual disclosure. - pub fn new(salt: String, claim_name: Option, claim_value: Value) -> Self { - let input = if let Some(name) = &claim_name { - format!("[\"{}\", \"{}\", {}]", &salt, &name, &claim_value.to_string()) - } else { - format!("[\"{}\", {}]", &salt, &claim_value.to_string()) - }; + pub(crate) fn new(salt: String, claim_name: Option, claim_value: Value) -> Self { + let string_encoded = { + let json_input = if let Some(name) = claim_name.as_deref() { + json!([salt, name, claim_value]) + } else { + json!([salt, claim_value]) + }; - let encoded = multibase::Base::Base64Url.encode(input); + multibase::Base::Base64Url.encode(json_input.to_string()) + }; Self { salt, claim_name, claim_value, - disclosure: encoded, + unparsed: string_encoded, } } @@ -48,9 +55,9 @@ impl Disclosure { /// ## Error /// /// Returns an [`Error::InvalidDisclosure`] if input is not a valid disclosure. - pub fn parse(disclosure: String) -> Result { + pub fn parse(disclosure: &str) -> Result { let decoded: Vec = multibase::Base::Base64Url - .decode(&disclosure) + .decode(disclosure) .map_err(|_e| { Error::InvalidDisclosure(format!( "Base64 decoding of the disclosure was not possible {}", @@ -81,8 +88,7 @@ impl Disclosure { .get(1) .ok_or(Error::InvalidDisclosure("invalid claim name".to_string()))? .clone(), - - disclosure, + unparsed: disclosure.to_string(), }) } else if decoded.len() == 3 { Ok(Self { @@ -108,7 +114,7 @@ impl Disclosure { .get(2) .ok_or(Error::InvalidDisclosure("invalid claim name".to_string()))? .clone(), - disclosure, + unparsed: disclosure.to_string(), }) } else { Err(Error::InvalidDisclosure(format!( @@ -118,20 +124,20 @@ impl Disclosure { } } - /// Reference the actual disclosure. pub fn as_str(&self) -> &str { - &self.disclosure - } - - /// Convert this object into the actual disclosure. - pub fn into_string(self) -> String { - self.disclosure + self.as_ref() } } impl Display for Disclosure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&self.disclosure) + write!(f, "{}", self.unparsed) + } +} + +impl PartialEq for Disclosure { + fn eq(&self, other: &Self) -> bool { + self.claim_name == other.claim_name && self.salt == other.salt && self.claim_value == other.claim_value } } @@ -150,18 +156,7 @@ mod test { ); let parsed = - Disclosure::parse("WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgInRpbWUiLCAiMjAxMi0wNC0yM1QxODoyNVoiXQ".to_owned()); - assert_eq!(parsed.unwrap(), disclosure); - } - - // Test values from: - // https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-05.html#section-5.5-25 - #[test] - fn test_creating() { - let disclosure = Disclosure::new("lklxF5jMYlGTPUovMNIvCA".to_owned(), None, "US".to_owned().into()); - assert_eq!( - "WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgIlVTIl0".to_owned(), - disclosure.to_string() - ); + Disclosure::parse("WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgInRpbWUiLCAiMjAxMi0wNC0yM1QxODoyNVoiXQ").unwrap(); + assert_eq!(parsed, disclosure); } } diff --git a/src/encoder.rs b/src/encoder.rs index 66a88fd..4aea4f1 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -21,22 +21,8 @@ pub const HEADER_TYP: &str = "sd-jwt"; /// Transforms a JSON object into an SD-JWT object by substituting selected values /// with their corresponding disclosure digests. -#[cfg(not(feature = "sha"))] -pub struct SdObjectEncoder { - /// The object in JSON format. - pub(crate) object: Value, - /// Size of random data used to generate the salts for disclosures in bytes. - /// Constant length for readability considerations. - pub(crate) salt_size: usize, - /// The hash function used to create digests. - pub(crate) hasher: H, -} - -/// Transforms a JSON object into an SD-JWT object by substituting selected values -/// with their corresponding disclosure digests. -#[cfg(feature = "sha")] #[derive(Debug, Clone)] -pub struct SdObjectEncoder { +pub struct SdObjectEncoder { /// The object in JSON format. pub(crate) object: Value, /// Size of random data used to generate the salts for disclosures in bytes. @@ -47,116 +33,59 @@ pub struct SdObjectEncoder { } #[cfg(feature = "sha")] -impl SdObjectEncoder { - /// Creates a new [`SdObjectEncoder`] with `sha-256` hash function. - /// - /// ## Error - /// Returns [`Error::DeserializationError`] if `object` is not a valid JSON object. - pub fn new(object: &str) -> Result> { - let object: Value = serde_json::from_str(object).map_err(|e| Error::DeserializationError(e.to_string()))?; - if !object.is_object() { - return Err(Error::DataTypeMismatch("expected object".to_owned())); - } - - Ok(SdObjectEncoder { - object, - salt_size: DEFAULT_SALT_SIZE, - hasher: Sha256Hasher::new(), - }) - } - - /// Creates a new [`SdObjectEncoder`] with `sha-256` hash function from a serializable object. - /// - /// ## Error - /// Returns [`Error::DeserializationError`] if `object` can not be serialized into a valid JSON object. - pub fn try_from_serializable(object: T) -> std::result::Result { - let object: Value = serde_json::to_value(&object).map_err(|e| Error::DeserializationError(e.to_string()))?; - SdObjectEncoder::try_from(object) - } -} - -#[cfg(feature = "sha")] -impl TryFrom for SdObjectEncoder { +impl TryFrom for SdObjectEncoder { type Error = crate::Error; - fn try_from(value: Value) -> std::result::Result { - if !value.is_object() { - return Err(Error::DataTypeMismatch("expected object".to_owned())); - } - - Ok(SdObjectEncoder { - object: value, - salt_size: DEFAULT_SALT_SIZE, - hasher: Sha256Hasher::new(), - }) + fn try_from(value: Value) -> Result { + Self::with_custom_hasher_and_salt_size(value, Sha256Hasher::new(), DEFAULT_SALT_SIZE) } } impl SdObjectEncoder { - /// Creates a new [`SdObjectEncoder`] with custom hash function to create digests. - pub fn with_custom_hasher(object: &str, hasher: H) -> Result { - let object: Value = serde_json::to_value(object).map_err(|e| Error::DeserializationError(e.to_string()))?; + /// Creates a new [`SdObjectEncoder`] with custom hash function to create digests, and custom salt size. + pub fn with_custom_hasher_and_salt_size(object: Value, hasher: H, salt_size: usize) -> Result { if !object.is_object() { - return Err(Error::DataTypeMismatch("expected object".to_owned())); - } + return Err(Error::DataTypeMismatch( + "argument `object` must be a JSON Object".to_string(), + )); + }; + Ok(Self { object, - salt_size: DEFAULT_SALT_SIZE, + salt_size, hasher, }) } /// Substitutes a value with the digest of its disclosure. - /// If no salt is provided, the disclosure will be created with a random salt value. /// /// `path` indicates the pointer to the value that will be concealed using the syntax of /// [JSON pointer](https://datatracker.ietf.org/doc/html/rfc6901). /// - /// - /// ## Example - /// ``` - /// use sd_jwt_payload::SdObjectEncoder; - /// use sd_jwt_payload::json; - /// - /// let obj = json!({ - /// "id": "did:value", - /// "claim1": { - /// "abc": true - /// }, - /// "claim2": ["val_1", "val_2"] - /// }); - /// let mut encoder = SdObjectEncoder::try_from(obj).unwrap(); - /// encoder.conceal("/id", None).unwrap(); //conceals "id": "did:value" - /// encoder.conceal("/claim1/abc", None).unwrap(); //"abc": true - /// encoder.conceal("/claim2/0", None).unwrap(); //conceals "val_1" - /// ``` - /// /// ## Error /// * [`Error::InvalidPath`] if pointer is invalid. /// * [`Error::DataTypeMismatch`] if existing SD format is invalid. - pub fn conceal(&mut self, path: &str, salt: Option) -> Result { + pub fn conceal(&mut self, path: &str) -> Result { // Determine salt. - let salt = salt.unwrap_or(Self::gen_rand(self.salt_size)); + let salt = Self::gen_rand(self.salt_size); let element_pointer = path .parse::>() - .map_err(|err| Error::InvalidPath(format!("{:?}", err)))?; + .map_err(|_| Error::InvalidPath(path.to_string()))?; let mut parent_pointer = element_pointer.clone(); - let element_key = parent_pointer - .pop() - .ok_or(Error::InvalidPath("path does not contain any values".to_string()))?; + let element_key = parent_pointer.pop().ok_or(Error::InvalidPath(path.to_string()))?; let parent = parent_pointer .get(&self.object) - .map_err(|err| Error::InvalidPath(format!("{:?}", err)))?; + .map_err(|_| Error::InvalidPath(path.to_string()))?; match parent { Value::Object(_) => { let parent = parent_pointer .get_mut(&mut self.object) - .map_err(|err| Error::InvalidPath(format!("{:?}", err)))? + .map_err(|_| Error::InvalidPath(path.to_string()))? .as_object_mut() - .ok_or(Error::InvalidPath("path does not contain any values".to_string()))?; + .ok_or_else(|| Error::InvalidPath(path.to_string()))?; // Remove the value from the parent and create a disclosure for it. let disclosure = Disclosure::new( @@ -164,7 +93,7 @@ impl SdObjectEncoder { Some(element_key.to_owned()), parent .remove(&element_key) - .ok_or(Error::InvalidPath(format!("{} does not exist", element_key)))?, + .ok_or_else(|| Error::InvalidPath(path.to_string()))?, ); // Hash the disclosure. @@ -175,7 +104,9 @@ impl SdObjectEncoder { Ok(disclosure) } Value::Array(_) => { - let element = element_pointer.get_mut(&mut self.object).unwrap(); + let element = element_pointer + .get_mut(&mut self.object) + .map_err(|_| Error::InvalidPath(path.to_string()))?; let disclosure = Disclosure::new(salt, None, element.clone()); let hash = self.hasher.encoded_digest(disclosure.as_str()); let tripledot = json!({ARRAY_DIGEST_KEY: hash}); @@ -190,18 +121,13 @@ impl SdObjectEncoder { /// Adds the `_sd_alg` property to the top level of the object. /// The value is taken from the [`crate::Hasher::alg_name`] implementation. - pub fn add_sd_alg_property(&mut self) -> Option { - if let Some(object) = self.object.as_object_mut() { - object.insert(SD_ALG.to_string(), Value::String(self.hasher.alg_name().to_string())) - } else { - None // Should be unreachable since the `self.object` is checked to be an object on creation. - } - } - - /// Returns the modified object as a string. - pub fn try_to_string(&self) -> Result { - serde_json::to_string(&self.object) - .map_err(|_e| Error::Unspecified("error while serializing internal object".to_string())) + pub fn add_sd_alg_property(&mut self) { + self + .object + .as_object_mut() + // Safety: `object` is a JSON object. + .unwrap() + .insert(SD_ALG.to_string(), Value::String(self.hasher.alg_name().to_string())); } /// Adds a decoy digest to the specified path. @@ -217,28 +143,25 @@ impl SdObjectEncoder { Ok(()) } - fn add_decoy(&mut self, path: &str) -> Result { - let mut element_pointer = path + fn add_decoy(&mut self, path: &str) -> Result<()> { + let element_pointer = path .parse::>() - .map_err(|err| Error::InvalidPath(format!("{:?}", err)))?; + .map_err(|_| Error::InvalidPath(path.to_string()))?; let value = element_pointer .get_mut(&mut self.object) - .map_err(|err| Error::InvalidPath(format!("{:?}", err)))?; + .map_err(|_| Error::InvalidPath(path.to_string()))?; if let Some(object) = value.as_object_mut() { - let (disclosure, hash) = Self::random_digest(&self.hasher, self.salt_size, true); + let (_, hash) = Self::random_digest(&self.hasher, self.salt_size, false); Self::add_digest_to_object(object, hash)?; - Ok(disclosure) + Ok(()) } else if let Some(array) = value.as_array_mut() { - let (disclosure, hash) = Self::random_digest(&self.hasher, self.salt_size, true); + let (_, hash) = Self::random_digest(&self.hasher, self.salt_size, true); let tripledot = json!({ARRAY_DIGEST_KEY: hash}); array.push(tripledot); - Ok(disclosure) + Ok(()) } else { - Err(Error::InvalidPath(format!( - "{:?} is neither an object nor an array", - element_pointer.pop() - ))) + Err(Error::InvalidPath(path.to_string())) } } @@ -246,7 +169,14 @@ impl SdObjectEncoder { fn add_digest_to_object(object: &mut Map, digest: String) -> Result<()> { if let Some(sd_value) = object.get_mut(DIGESTS_KEY) { if let Value::Array(value) = sd_value { - value.push(Value::String(digest)) + // Make sure the digests are sorted. + let idx = value + .iter() + .enumerate() + .find(|(_, value)| value.as_str().is_some_and(|s| s > &digest)) + .map(|(pos, _)| pos) + .unwrap_or(value.len()); + value.insert(idx, Value::String(digest)); } else { return Err(Error::DataTypeMismatch( "invalid object: existing `_sd` type is not an array".to_string(), @@ -281,32 +211,6 @@ impl SdObjectEncoder { multibase::Base::Base64Url.encode(bytes) } - - /// Returns a reference to the internal object. - pub fn object(&self) -> Result<&Map> { - // Safety: encoder can be constructed from objects only. - self.object.as_object().ok_or(Error::DataTypeMismatch( - "encoder initialized with invalid JSON object".to_string(), - )) - } - - /// Returns the used salt length. - pub fn salt_size(&self) -> usize { - self.salt_size - } - - /// Sets size of random data used to generate the salts for disclosures in bytes. - /// - /// ## Warning - /// Salt size must be >= 16. - pub fn set_salt_size(&mut self, salt_size: usize) -> Result<()> { - if salt_size < 16 { - Err(Error::InvalidSaltSize) - } else { - self.salt_size = salt_size; - Ok(()) - } - } } #[cfg(test)] @@ -314,16 +218,9 @@ mod test { use super::SdObjectEncoder; use crate::Error; - use serde::Serialize; use serde_json::json; use serde_json::Value; - #[derive(Serialize)] - struct TestStruct { - id: String, - claim2: Vec, - } - fn object() -> Value { json!({ "id": "did:value", @@ -337,11 +234,11 @@ mod test { #[test] fn simple() { let mut encoder = SdObjectEncoder::try_from(object()).unwrap(); - encoder.conceal("/claim1/abc", None).unwrap(); - encoder.conceal("/id", None).unwrap(); + encoder.conceal("/claim1/abc").unwrap(); + encoder.conceal("/id").unwrap(); encoder.add_decoys("", 10).unwrap(); encoder.add_decoys("/claim2", 10).unwrap(); - assert!(encoder.object().unwrap().get("id").is_none()); + assert!(encoder.object.get("id").is_none()); assert_eq!(encoder.object.get("_sd").unwrap().as_array().unwrap().len(), 11); assert_eq!(encoder.object.get("claim2").unwrap().as_array().unwrap().len(), 12); } @@ -349,9 +246,9 @@ mod test { #[test] fn errors() { let mut encoder = SdObjectEncoder::try_from(object()).unwrap(); - encoder.conceal("/claim1/abc", None).unwrap(); + encoder.conceal("/claim1/abc").unwrap(); assert!(matches!( - encoder.conceal("claim2/2", None).unwrap_err(), + encoder.conceal("claim2/2").unwrap_err(), Error::InvalidPath(_) )); } @@ -360,27 +257,12 @@ mod test { fn test_wrong_path() { let mut encoder = SdObjectEncoder::try_from(object()).unwrap(); assert!(matches!( - encoder.conceal("/claim12", None).unwrap_err(), + encoder.conceal("/claim12").unwrap_err(), Error::InvalidPath(_) )); assert!(matches!( - encoder.conceal("/claim12/0", None).unwrap_err(), + encoder.conceal("/claim12/0").unwrap_err(), Error::InvalidPath(_) )); } - - #[test] - fn test_from_serializable() { - let test_value = TestStruct { - id: "did:value".to_string(), - claim2: vec!["arr-value1".to_string(), "arr-vlaue2".to_string()], - }; - let mut encoder = SdObjectEncoder::try_from_serializable(test_value).unwrap(); - encoder.conceal("/id", None).unwrap(); - encoder.add_decoys("", 10).unwrap(); - encoder.add_decoys("/claim2", 10).unwrap(); - assert!(encoder.object.get("id").is_none()); - assert_eq!(encoder.object.get("_sd").unwrap().as_array().unwrap().len(), 11); - assert_eq!(encoder.object.get("claim2").unwrap().as_array().unwrap().len(), 12); - } } diff --git a/src/error.rs b/src/error.rs index cf66bd2..45b17d9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -4,14 +4,14 @@ /// Alias for a `Result` with the error type [`Error`]. pub type Result = ::core::result::Result; -#[derive(Debug, thiserror::Error, strum::IntoStaticStr)] +#[derive(Debug, thiserror::Error, strum::IntoStaticStr, PartialEq)] #[non_exhaustive] pub enum Error { #[error("invalid input: {0}")] InvalidDisclosure(String), - #[error("no hasher can be specified for the hashing algorithm {0}")] - MissingHasher(String), + #[error("invalid hasher: {0}")] + InvalidHasher(String), #[error("data type is not expected: {0}")] DataTypeMismatch(String), @@ -39,4 +39,10 @@ pub enum Error { #[error("the validation ended with {0} unused disclosure(s)")] UnusedDisclosures(usize), + + #[error("JWS creation failure: {0}")] + JwsSignerFailure(String), + + #[error("Missing required KB-JWT")] + MissingKeyBindingJwt, } diff --git a/src/hasher.rs b/src/hasher.rs index 65c8807..b299c93 100644 --- a/src/hasher.rs +++ b/src/hasher.rs @@ -6,6 +6,7 @@ use crypto::hashes::sha::SHA256; #[cfg(feature = "sha")] use crypto::hashes::sha::SHA256_LEN; +use multibase::Base; pub const SHA_ALG_NAME: &str = "sha-256"; @@ -16,7 +17,7 @@ pub const SHA_ALG_NAME: &str = "sha-256"; /// Implementations of this trait are expected only for algorithms listed in /// the IANA "Named Information Hash Algorithm" registry. /// See [Hash Function Claim](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#name-hash-function-claim) -pub trait Hasher: Sync + Send { +pub trait Hasher { /// Digests input to produce unique fixed-size hash value in bytes. fn digest(&self, input: &[u8]) -> Vec; @@ -26,17 +27,17 @@ pub trait Hasher: Sync + Send { /// /// The hash algorithm identifier MUST be a hash algorithm value from the /// "Hash Name String" column in the IANA "Named Information Hash Algorithm" - fn alg_name(&self) -> &'static str; + fn alg_name(&self) -> &str; /// Returns the base64url-encoded digest of a `disclosure`. fn encoded_digest(&self, disclosure: &str) -> String { let hash = self.digest(disclosure.as_bytes()); - multibase::Base::Base64Url.encode(hash) + Base::Base64Url.encode(hash) } } /// An implementation of [`Hasher`] that uses the `sha-256` hash function. -#[derive(Default, Clone, Copy)] +#[derive(Default, Clone, Copy, Debug)] #[cfg(feature = "sha")] pub struct Sha256Hasher; diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 0000000..5934a5e --- /dev/null +++ b/src/jwt.rs @@ -0,0 +1,103 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; +use std::str::FromStr; + +use anyhow::Context; +use multibase::Base; +use serde::de::DeserializeOwned; +use serde::Serialize; + +use crate::Error; +use crate::JsonObject; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Jwt { + pub header: JsonObject, + pub claims: T, + pub jws: String, +} + +impl Display for Jwt +where + T: Serialize, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", &self.jws) + } +} + +impl FromStr for Jwt +where + T: DeserializeOwned, +{ + type Err = Error; + fn from_str(s: &str) -> Result { + let mut segments = s.split('.'); + let header = segments + .next() + .context("missing header segment") + .and_then(|b64| Base::Base64Url.decode(b64).context("not Base64Url-encoded")) + .and_then(|json_bytes| serde_json::from_slice::(&json_bytes).context("invalid JWT header properties")) + .map_err(|e| Error::DeserializationError(format!("invalid JWT: {e}")))?; + let claims = segments + .next() + .context("missing payload") + .and_then(|b64| Base::Base64Url.decode(b64).context("not Base64Url-encoded")) + .and_then(|json_bytes| { + serde_json::from_slice::(&json_bytes).map_err(|e| anyhow::anyhow!("invalid JWT claims: {e}")) + }) + .map_err(|e| Error::DeserializationError(format!("invalid JWT: {e}")))?; + let _signature = segments + .next() + .context("missing signature") + .and_then(|sig| Base::Base64Url.decode(sig).context("not base64url")) + .map_err(|e| Error::DeserializationError(format!("invalid JWT: {e}")))?; + if segments.next().is_some() { + return Err(Error::DeserializationError( + "invalid JWT: more than 3 segments".to_string(), + )); + } + + Ok(Self { + header, + claims, + jws: s.to_string(), + }) + } +} + +impl Jwt { + #[allow(dead_code)] + pub fn signature(&self) -> &str { + self + .jws + .split('.') + .next_back() + // Safety: jws is a valid JWS. + .unwrap() + } +} + +#[cfg(test)] +mod tests { + use super::Jwt; + use serde::Deserialize; + use serde::Serialize; + + const JWT: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + + #[derive(Debug, Serialize, Deserialize)] + struct TestClaims { + sub: String, + name: String, + iat: i64, + } + + #[test] + fn round_trip() { + let jwt = JWT.parse::>().unwrap(); + assert_eq!(&jwt.to_string(), JWT); + } +} diff --git a/src/key_binding_jwt_claims.rs b/src/key_binding_jwt_claims.rs index 2a10e66..3bdb40a 100644 --- a/src/key_binding_jwt_claims.rs +++ b/src/key_binding_jwt_claims.rs @@ -1,12 +1,179 @@ // Copyright 2020-2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +use crate::jwt::Jwt; +use crate::Error; use crate::Hasher; -use itertools::Itertools; +use crate::JsonObject; +use crate::JwsSigner; +use crate::SdJwt; +use crate::SHA_ALG_NAME; +use anyhow::Context as _; use serde::Deserialize; use serde::Serialize; use serde_json::Value; -use std::collections::BTreeMap; +use std::borrow::Cow; +use std::fmt::Display; +use std::ops::Deref; +use std::str::FromStr; + +pub const KB_JWT_HEADER_TYP: &str = "kb+jwt"; + +/// Representation of a [KB-JWT](https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-12.html#name-key-binding-jwt). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyBindingJwt(Jwt); + +impl Display for KeyBindingJwt { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", &self.0) + } +} + +impl FromStr for KeyBindingJwt { + type Err = Error; + fn from_str(s: &str) -> Result { + let jwt = Jwt::::from_str(s)?; + let valid_jwt_type = jwt.header.get("typ").is_some_and(|typ| typ == KB_JWT_HEADER_TYP); + if !valid_jwt_type { + return Err(Error::DeserializationError(format!( + "invalid KB-JWT: typ must be \"{KB_JWT_HEADER_TYP}\"" + ))); + } + let valid_alg = jwt.header.get("alg").is_some_and(|alg| alg != "none"); + if !valid_alg { + return Err(Error::DeserializationError( + "invalid KB-JWT: alg must be set and cannot be \"none\"".to_string(), + )); + } + + Ok(Self(jwt)) + } +} + +impl KeyBindingJwt { + /// Returns a [`KeyBindingJwtBuilder`] that allows the creation of a [`KeyBindingJwt`]. + pub fn builder() -> KeyBindingJwtBuilder { + KeyBindingJwtBuilder::default() + } + /// Returns a reference to this [`KeyBindingJwt`] claim set. + pub fn claims(&self) -> &KeyBindingJwtClaims { + &self.0.claims + } +} + +/// Builder-style struct to ease the creation of an [`KeyBindingJwt`]. +#[derive(Debug, Default, Clone)] +pub struct KeyBindingJwtBuilder { + header: JsonObject, + payload: JsonObject, +} + +impl KeyBindingJwtBuilder { + /// Creates a new [`KeyBindingJwtBuilder`]. + pub fn new() -> Self { + Self::default() + } + + /// Creates a new [`KeyBindingJwtBuilder`] using `object` as its payload. + pub fn from_object(object: JsonObject) -> Self { + Self { + header: JsonObject::default(), + payload: object, + } + } + + /// Sets the JWT's header. + pub fn header(mut self, header: JsonObject) -> Self { + self.header = header; + self + } + + /// Sets the [iat](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6) property. + pub fn iat(mut self, iat: i64) -> Self { + self.payload.insert("iat".to_string(), iat.into()); + self + } + + /// Sets the [aud](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3) property. + pub fn aud<'a, S>(mut self, aud: S) -> Self + where + S: Into>, + { + self.payload.insert("aud".to_string(), aud.into().into_owned().into()); + self + } + + /// Sets the `nonce` property. + pub fn nonce<'a, S>(mut self, nonce: S) -> Self + where + S: Into>, + { + self + .payload + .insert("nonce".to_string(), nonce.into().into_owned().into()); + self + } + + /// Inserts a given property with key `name` and value `value` in the payload. + pub fn insert_property(mut self, name: &str, value: Value) -> Self { + self.payload.insert(name.to_string(), value); + self + } + + /// Builds an [`KeyBindingJwt`] from the data provided to builder. + pub async fn finish( + self, + sd_jwt: &SdJwt, + hasher: &dyn Hasher, + alg: &str, + signer: &S, + ) -> Result + where + S: JwsSigner, + { + let mut claims = self.payload; + if alg == "none" { + return Err(Error::DataTypeMismatch( + "A KeyBindingJwt cannot use algorithm \"none\"".to_string(), + )); + } + if sd_jwt.key_binding_jwt().is_some() { + return Err(Error::DataTypeMismatch( + "the provided SD-JWT already has a KB-JWT attached".to_string(), + )); + } + if sd_jwt.claims()._sd_alg.as_deref().unwrap_or(SHA_ALG_NAME) != hasher.alg_name() { + return Err(Error::InvalidHasher(format!( + "invalid hashing algorithm \"{}\"", + hasher.alg_name() + ))); + } + let sd_hash = hasher.encoded_digest(&sd_jwt.to_string()); + claims.insert("sd_hash".to_string(), sd_hash.into()); + + let mut header = self.header; + header.insert("alg".to_string(), alg.to_owned().into()); + header + .entry("typ") + .or_insert_with(|| KB_JWT_HEADER_TYP.to_owned().into()); + + // Validate claims + let parsed_claims = serde_json::from_value::(claims.clone().into()) + .map_err(|e| Error::DeserializationError(format!("invalid KB-JWT claims: {e}")))?; + let jws = signer + .sign(&header, &claims) + .await + .map_err(|e| anyhow::anyhow!("{e}")) + .and_then(|jws_bytes| String::from_utf8(jws_bytes).context("invalid JWS")) + .map_err(|e| Error::JwsSignerFailure(e.to_string()))?; + + Ok(KeyBindingJwt(Jwt { + header, + claims: parsed_claims, + jws, + })) + } +} /// Claims set for key binding JWT. #[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] @@ -16,23 +183,34 @@ pub struct KeyBindingJwtClaims { pub nonce: String, pub sd_hash: String, #[serde(flatten)] - pub properties: BTreeMap, + properties: JsonObject, } -impl KeyBindingJwtClaims { - pub const KB_JWT_HEADER_TYP: &'static str = " kb+jwt"; - - /// Creates a new [`KeyBindingJwtClaims`]. - pub fn new(hasher: &dyn Hasher, jwt: String, disclosures: Vec, nonce: String, aud: String, iat: i64) -> Self { - let disclosures = disclosures.iter().join("~"); - let sd_jwt = format!("{}~{}~", jwt, disclosures); - let hash = hasher.encoded_digest(&sd_jwt); - Self { - iat, - aud, - nonce, - sd_hash: hash, - properties: BTreeMap::new(), - } +impl Deref for KeyBindingJwtClaims { + type Target = JsonObject; + fn deref(&self) -> &Self::Target { + &self.properties } } + +/// Proof of possession of a given key. See [RFC7800](https://www.rfc-editor.org/rfc/rfc7800.html#section-3) for more details. +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum RequiredKeyBinding { + /// Json Web Key (JWK). + Jwk(JsonObject), + /// Encoded JWK in its compact serialization form. + Jwe(String), + /// Key ID. + Kid(String), + /// JWK from a JWK set identified by `kid`. + Jwu { + /// URL of the JWK Set. + jwu: String, + /// kid of the referenced JWK. + kid: String, + }, + /// Non standard key-bind. + #[serde(untagged)] + Custom(Value), +} diff --git a/src/lib.rs b/src/lib.rs index f7686ac..029ec49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,21 @@ // Copyright 2020-2023 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 +mod builder; mod decoder; mod disclosure; mod encoder; mod error; mod hasher; +mod jwt; mod key_binding_jwt_claims; mod sd_jwt; +mod signer; -pub use decoder::*; +pub use builder::*; +pub(crate) use decoder::*; pub use disclosure::*; -pub use encoder::*; +pub(crate) use encoder::*; pub use error::*; pub use hasher::*; pub use key_binding_jwt_claims::*; @@ -19,3 +23,4 @@ pub use sd_jwt::*; pub use serde_json::json; pub use serde_json::Map; pub use serde_json::Value; +pub use signer::*; diff --git a/src/sd_jwt.rs b/src/sd_jwt.rs index 3f93870..cfba0d0 100644 --- a/src/sd_jwt.rs +++ b/src/sd_jwt.rs @@ -2,27 +2,73 @@ // SPDX-License-Identifier: Apache-2.0 use std::fmt::Display; +use std::iter::Peekable; +use std::ops::Deref; +use std::ops::DerefMut; use std::str::FromStr; +use crate::jwt::Jwt; +use crate::Disclosure; use crate::Error; +use crate::Hasher; +use crate::JsonObject; +use crate::KeyBindingJwt; +use crate::RequiredKeyBinding; use crate::Result; +use crate::SdObjectDecoder; +use crate::ARRAY_DIGEST_KEY; +use crate::DIGESTS_KEY; +use crate::SHA_ALG_NAME; +use indexmap::IndexMap; use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, Eq, PartialEq, Default)] +pub struct SdJwtClaims { + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub _sd: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub _sd_alg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cnf: Option, + #[serde(flatten)] + properties: JsonObject, +} + +impl Deref for SdJwtClaims { + type Target = JsonObject; + fn deref(&self) -> &Self::Target { + &self.properties + } +} + +impl DerefMut for SdJwtClaims { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.properties + } +} /// Representation of an SD-JWT of the format /// `~~~...~~`. #[derive(Debug, Clone, Eq, PartialEq)] pub struct SdJwt { /// The JWT part. - pub jwt: String, + jwt: Jwt, /// The disclosures part. - pub disclosures: Vec, + disclosures: Vec, /// The optional key binding JWT. - pub key_binding_jwt: Option, + key_binding_jwt: Option, } impl SdJwt { /// Creates a new [`SdJwt`] from its components. - pub fn new(jwt: String, disclosures: Vec, key_binding_jwt: Option) -> Self { + pub(crate) fn new( + jwt: Jwt, + disclosures: Vec, + key_binding_jwt: Option, + ) -> Self { Self { jwt, disclosures, @@ -30,14 +76,50 @@ impl SdJwt { } } + pub fn header(&self) -> &JsonObject { + &self.jwt.header + } + + pub fn claims(&self) -> &SdJwtClaims { + &self.jwt.claims + } + + /// Returns a mutable reference to this SD-JWT's claims. + /// ## Warning + /// Modifying the claims might invalidate the signature. + /// Use this method carefully. + pub fn claims_mut(&mut self) -> &mut SdJwtClaims { + &mut self.jwt.claims + } + + pub fn disclosures(&self) -> &[Disclosure] { + &self.disclosures + } + + pub fn required_key_bind(&self) -> Option<&RequiredKeyBinding> { + self.claims().cnf.as_ref() + } + + pub fn key_binding_jwt(&self) -> Option<&KeyBindingJwt> { + self.key_binding_jwt.as_ref() + } + /// Serializes the components into the final SD-JWT. /// /// ## Error /// Returns [`Error::DeserializationError`] if parsing fails. pub fn presentation(&self) -> String { - let disclosures = self.disclosures.iter().join("~"); - let key_bindings = self.key_binding_jwt.as_deref().unwrap_or(""); - format!("{}~{}~{}", self.jwt, disclosures, key_bindings) + let disclosures = self.disclosures.iter().map(ToString::to_string).join("~"); + let key_bindings = self + .key_binding_jwt + .as_ref() + .map(ToString::to_string) + .unwrap_or_default(); + if disclosures.is_empty() { + format!("{}~{}", self.jwt, key_bindings) + } else { + format!("{}~{}~{}", self.jwt, disclosures, key_bindings) + } } /// Parses an SD-JWT into its components as [`SdJwt`]. @@ -50,27 +132,48 @@ impl SdJwt { )); } - let includes_key_binding = sd_jwt.chars().next_back().is_some_and(|char| char != '~'); - if includes_key_binding && num_of_segments < 3 { - return Err(Error::DeserializationError( - "SD-JWT format is invalid, less than 3 segments with key binding jwt".to_string(), - )); - } + let jwt = sd_segments.first().unwrap().parse()?; - let jwt = sd_segments.first().unwrap().to_string(); - let disclosures: Vec = sd_segments[1..num_of_segments - 1] + let disclosures = sd_segments[1..num_of_segments - 1] .iter() - .map(|disclosure| disclosure.to_string()) - .collect(); + .map(|s| Disclosure::parse(s)) + .try_collect()?; - let key_binding = includes_key_binding.then(|| sd_segments[num_of_segments - 1].to_string()); + let key_binding_jwt = sd_segments + .last() + .filter(|segment| !segment.is_empty()) + .map(|segment| segment.parse()) + .transpose()?; Ok(Self { jwt, disclosures, - key_binding_jwt: key_binding, + key_binding_jwt, }) } + + /// Prepares this [`SdJwt`] for a presentation, returning an [`SdJwtPresentationBuilder`]. + /// ## Errors + /// - [`Error::InvalidHasher`] is returned if the provided `hasher`'s algorithm doesn't match the algorithm specified + /// by SD-JWT's `_sd_alg` claim. "sha-256" is used if the claim is missing. + pub fn into_presentation(self, hasher: &dyn Hasher) -> Result { + SdJwtPresentationBuilder::new(self, hasher) + } + + /// Returns the JSON object obtained by replacing all disclosures into their + /// corresponding JWT concealable claims. + pub fn into_disclosed_object(self, hasher: &dyn Hasher) -> Result { + let decoder = SdObjectDecoder; + let object = serde_json::to_value(self.claims()).unwrap(); + + let disclosure_map = self + .disclosures + .into_iter() + .map(|disclosure| (hasher.encoded_digest(disclosure.as_str()), disclosure)) + .collect(); + + decoder.decode(object.as_object().unwrap(), &disclosure_map) + } } impl Display for SdJwt { @@ -86,15 +189,281 @@ impl FromStr for SdJwt { } } +#[derive(Debug, Clone)] +pub struct SdJwtPresentationBuilder { + sd_jwt: SdJwt, + disclosures: IndexMap, + removed_disclosures: Vec, + object: Value, +} + +impl Deref for SdJwtPresentationBuilder { + type Target = SdJwt; + fn deref(&self) -> &Self::Target { + &self.sd_jwt + } +} + +impl SdJwtPresentationBuilder { + pub fn new(mut sd_jwt: SdJwt, hasher: &dyn Hasher) -> Result { + let required_hasher = sd_jwt.claims()._sd_alg.as_deref().unwrap_or(SHA_ALG_NAME); + if required_hasher != hasher.alg_name() { + return Err(Error::InvalidHasher(format!( + "hasher \"{}\" was provided, but \"{required_hasher} is required\"", + hasher.alg_name() + ))); + } + let disclosures = std::mem::take(&mut sd_jwt.disclosures) + .into_iter() + .map(|disclosure| (hasher.encoded_digest(disclosure.as_str()), disclosure)) + .collect(); + let object = { + let sd = std::mem::take(&mut sd_jwt.jwt.claims._sd) + .into_iter() + .map(Value::String) + .collect(); + let mut object = Value::Object(std::mem::take(&mut sd_jwt.jwt.claims.properties)); + object + .as_object_mut() + .unwrap() + .insert(DIGESTS_KEY.to_string(), Value::Array(sd)); + + object + }; + Ok(Self { + sd_jwt, + disclosures, + removed_disclosures: vec![], + object, + }) + } + + /// Removes the disclosure for the property at `path`, concealing it. + /// + /// ## Notes + /// - When concealing a claim more than one disclosure may be removed: the disclosure for the claim itself and the + /// disclosures for any concealable sub-claim. + pub fn conceal(mut self, path: &str) -> Result { + let path_segments = path.trim_start_matches('/').split('/').peekable(); + let digests_to_remove = conceal(&self.object, path_segments, &self.disclosures)? + .into_iter() + // needed, since some strings are borrowed for the lifetime of the borrow of `self.disclosures`. + .map(ToOwned::to_owned) + // needed, to drop borrow `self.disclosures`. + .collect_vec(); + + digests_to_remove + .into_iter() + .flat_map(|digest| self.disclosures.shift_remove(&digest)) + .for_each(|disclosure| self.removed_disclosures.push(disclosure)); + + Ok(self) + } + + /// Adds a [`KeyBindingJwt`] to this [`SdJwt`]'s presentation. + pub fn attach_key_binding_jwt(mut self, kb_jwt: KeyBindingJwt) -> Self { + self.sd_jwt.key_binding_jwt = Some(kb_jwt); + self + } + + /// Returns the resulting [`SdJwt`] together with all removed disclosures. + /// ## Errors + /// - Fails with [`Error::MissingKeyBindingJwt`] if this [`SdJwt`] requires a key binding but none was provided. + pub fn finish(self) -> Result<(SdJwt, Vec)> { + if self.sd_jwt.required_key_bind().is_some() && self.key_binding_jwt.is_none() { + return Err(Error::MissingKeyBindingJwt); + } + + // Put everything back in its place. + let SdJwtPresentationBuilder { + mut sd_jwt, + disclosures, + removed_disclosures, + object, + .. + } = self; + sd_jwt.disclosures = disclosures.into_values().collect_vec(); + + let Value::Object(mut obj) = object else { + unreachable!(); + }; + let Value::Array(sd) = obj.remove(DIGESTS_KEY).unwrap_or(Value::Array(vec![])) else { + unreachable!() + }; + sd_jwt.jwt.claims._sd = sd + .into_iter() + .map(|value| { + if let Value::String(s) = value { + s + } else { + unreachable!() + } + }) + .collect(); + sd_jwt.jwt.claims.properties = obj; + + Ok((sd_jwt, removed_disclosures)) + } +} + +fn conceal<'p, 'o, 'd, I>( + object: &'o Value, + mut path: Peekable, + disclosures: &'d IndexMap, +) -> Result> +where + I: Iterator, + 'd: 'o, +{ + let element_key = path + .next() + .ok_or_else(|| Error::InvalidPath("element at path doesn't exist or is not disclosable".to_string()))?; + let has_next = path.peek().is_some(); + match object { + // We are just traversing to a deeper part of the object. + Value::Object(object) if has_next => { + let next_object = object + .get(element_key) + .or_else(|| { + find_disclosure(object, element_key, disclosures) + .and_then(|digest| disclosures.get(digest)) + .map(|disclosure| &disclosure.claim_value) + }) + .ok_or_else(|| Error::InvalidPath("the referenced element doesn't exist or is not concealable".to_string()))?; + + conceal(next_object, path, disclosures) + } + // We reached the parent of the value we want to conceal. + // Make sure its concealable by finding its disclosure. + Value::Object(object) => { + let digest = find_disclosure(object, element_key, disclosures) + .ok_or_else(|| Error::InvalidPath("the referenced element doesn't exist or is not concealable".to_string()))?; + let disclosure = disclosures.get(digest).unwrap(); + let mut sub_disclosures: Vec<&str> = get_all_sub_disclosures(&disclosure.claim_value, disclosures).collect(); + sub_disclosures.push(digest); + Ok(sub_disclosures) + } + // Traversing an array + Value::Array(arr) if has_next => { + let index = element_key + .parse::() + .ok() + .filter(|idx| arr.len() > *idx) + .ok_or_else(|| Error::InvalidPath(String::default()))?; + let next_object = arr + .get(index) + .ok_or_else(|| Error::InvalidPath("the referenced element doesn't exist or is not concealable".to_string()))?; + + conceal(next_object, path, disclosures) + } + // Concealing an array's entry. + Value::Array(arr) => { + let index = element_key + .parse::() + .ok() + .filter(|idx| arr.len() > *idx) + .ok_or_else(|| Error::InvalidPath(String::default()))?; + let digest = arr + .get(index) + .unwrap() + .as_object() + .and_then(|entry| find_disclosure(entry, "", disclosures)) + .ok_or_else(|| Error::InvalidPath("the referenced element doesn't exist or is not concealable".to_string()))?; + let disclosure = disclosures.get(digest).unwrap(); + let mut sub_disclosures: Vec<&str> = get_all_sub_disclosures(&disclosure.claim_value, disclosures).collect(); + sub_disclosures.push(digest); + Ok(sub_disclosures) + } + _ => Err(Error::InvalidPath(String::default())), + } +} + +fn find_disclosure<'o>( + object: &'o JsonObject, + key: &str, + disclosures: &IndexMap, +) -> Option<&'o str> { + let maybe_disclosable_array_entry = || { + object + .get(ARRAY_DIGEST_KEY) + .and_then(|value| value.as_str()) + .filter(|_| object.len() == 1) + }; + // Try to find the digest for disclosable property `key` in + // the `_sd` field of `object`. + object + .get(DIGESTS_KEY) + .and_then(|value| value.as_array()) + .iter() + .flat_map(|values| values.iter()) + .flat_map(|value| value.as_str()) + .find(|digest| { + disclosures + .get(*digest) + .and_then(|disclosure| disclosure.claim_name.as_deref()) + .is_some_and(|name| name == key) + }) + // If no result is found try checking `object` as a disclosable array entry. + .or_else(maybe_disclosable_array_entry) +} + +fn get_all_sub_disclosures<'v, 'd>( + start: &'v Value, + disclosures: &'d IndexMap, +) -> Box + 'v> +where + 'd: 'v, +{ + match start { + // `start` is a JSON object, check if it has a "_sd" array + recursively + // check all its properties + Value::Object(object) => { + let direct_sds = object + .get(DIGESTS_KEY) + .and_then(|sd| sd.as_array()) + .map(|sd| sd.iter()) + .unwrap_or_default() + .flat_map(|value| value.as_str()) + .filter(|digest| disclosures.contains_key(*digest)); + let sub_sds = object + .values() + .flat_map(|value| get_all_sub_disclosures(value, disclosures)); + Box::new(itertools::chain!(direct_sds, sub_sds)) + } + // `start` is a JSON array, check for disclosable values `{"...", }` + + // recursively check all its values. + Value::Array(arr) => { + let mut digests = vec![]; + for value in arr { + if let Some(Value::String(digest)) = value.get(ARRAY_DIGEST_KEY) { + if disclosures.contains_key(digest) { + digests.push(digest.as_str()); + } + } else { + get_all_sub_disclosures(value, disclosures).for_each(|digest| digests.push(digest)); + } + } + Box::new(digests.into_iter()) + } + _ => Box::new(std::iter::empty()), + } +} + #[cfg(test)] mod test { use crate::SdJwt; + const SD_JWT: &str = "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1FyazFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZzZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZkx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1REw4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSLWFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzIiwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAiczBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTSjFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1cFJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZXd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2bmNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4anZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25VbGRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0MG9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.gR6rSL7urX79CNEvTQnP1MH5xthG11ucIV44SqKFZ4Pvlu_u16RfvXQd4k4CAIBZNKn2aTI18TfvFwV97gJFoA~WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ~"; + #[test] fn parse() { - let sd_jwt_str = "eyJhbGciOiAiRVMyNTYifQ.eyJAY29udGV4dCI6IFsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCAiaHR0cHM6Ly93M2lkLm9yZy92YWNjaW5hdGlvbi92MSJdLCAidHlwZSI6IFsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCAiVmFjY2luYXRpb25DZXJ0aWZpY2F0ZSJdLCAiaXNzdWVyIjogImh0dHBzOi8vZXhhbXBsZS5jb20vaXNzdWVyIiwgImlzc3VhbmNlRGF0ZSI6ICIyMDIzLTAyLTA5VDExOjAxOjU5WiIsICJleHBpcmF0aW9uRGF0ZSI6ICIyMDI4LTAyLTA4VDExOjAxOjU5WiIsICJuYW1lIjogIkNPVklELTE5IFZhY2NpbmF0aW9uIENlcnRpZmljYXRlIiwgImRlc2NyaXB0aW9uIjogIkNPVklELTE5IFZhY2NpbmF0aW9uIENlcnRpZmljYXRlIiwgImNyZWRlbnRpYWxTdWJqZWN0IjogeyJfc2QiOiBbIjFWX0stOGxEUThpRlhCRlhiWlk5ZWhxUjRIYWJXQ2k1VDB5Ykl6WlBld3ciLCAiSnpqTGd0UDI5ZFAtQjN0ZDEyUDY3NGdGbUsyenk4MUhNdEJnZjZDSk5XZyIsICJSMmZHYmZBMDdaX1lsa3FtTlp5bWExeHl5eDFYc3RJaVM2QjFZYmwySlo0IiwgIlRDbXpybDdLMmdldl9kdTdwY01JeXpSTEhwLVllZy1GbF9jeHRyVXZQeGciLCAiVjdrSkJMSzc4VG1WRE9tcmZKN1p1VVBIdUtfMmNjN3laUmE0cVYxdHh3TSIsICJiMGVVc3ZHUC1PRERkRm9ZNE5semxYYzN0RHNsV0p0Q0pGNzVOdzhPal9nIiwgInpKS19lU01YandNOGRYbU1aTG5JOEZHTTA4ekozX3ViR2VFTUotNVRCeTAiXSwgInZhY2NpbmUiOiB7Il9zZCI6IFsiMWNGNWhMd2toTU5JYXFmV0pyWEk3Tk1XZWRMLTlmNlkyUEE1MnlQalNaSSIsICJIaXk2V1d1ZUxENWJuMTYyOTh0UHY3R1hobWxkTURPVG5CaS1DWmJwaE5vIiwgIkxiMDI3cTY5MWpYWGwtakM3M3ZpOGViT2o5c214M0MtX29nN2dBNFRCUUUiXSwgInR5cGUiOiAiVmFjY2luZSJ9LCAicmVjaXBpZW50IjogeyJfc2QiOiBbIjFsU1FCTlkyNHEwVGg2T0d6dGhxLTctNGw2Y0FheHJZWE9HWnBlV19sbkEiLCAiM256THE4MU0yb04wNndkdjFzaEh2T0VKVnhaNUtMbWREa0hFREpBQldFSSIsICJQbjFzV2kwNkc0TEpybm4tX1JUMFJiTV9IVGR4blBKUXVYMmZ6V3ZfSk9VIiwgImxGOXV6ZHN3N0hwbEdMYzcxNFRyNFdPN01HSnphN3R0N1FGbGVDWDRJdHciXSwgInR5cGUiOiAiVmFjY2luZVJlY2lwaWVudCJ9LCAidHlwZSI6ICJWYWNjaW5hdGlvbkV2ZW50In0sICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICJUQ0FFUjE5WnZ1M09IRjRqNFc0dmZTVm9ISVAxSUxpbERsczd2Q2VHZW1jIiwgInkiOiAiWnhqaVdXYlpNUUdIVldLVlE0aGJTSWlyc1ZmdWVjQ0U2dDRqVDlGMkhaUSJ9fX0.l7byWDsTtDOjFbWS4lko-3mkeeZwzUYw6ZicrJurES_gzs6EK_svPiVwj5g6evb_nmLWpK2_cXQ_J0cjH0XnGw~WyJQYzMzSk0yTGNoY1VfbEhnZ3ZfdWZRIiwgIm9yZGVyIiwgIjMvMyJd~WyJBSngtMDk1VlBycFR0TjRRTU9xUk9BIiwgImRhdGVPZlZhY2NpbmF0aW9uIiwgIjIwMjEtMDYtMjNUMTM6NDA6MTJaIl0~WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImF0Y0NvZGUiLCAiSjA3QlgwMyJd~WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgIm1lZGljaW5hbFByb2R1Y3ROYW1lIiwgIkNPVklELTE5IFZhY2NpbmUgTW9kZXJuYSJd~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodHRwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwgImlhdCI6IDE2OTgwNzc3OTAsICJfc2RfaGFzaCI6ICJ1MXpzTkxGUXhlVkVGcFRmT1Z1NFRjSTNaYjdDX1UzYTFFNGVzQVlRLXpZIn0.LLaMyLVXmAC5YVj29d8T-QbyJaxORbMCuWtxnw8VLZHjz9kyyMMTFaOfGb3CZmytVWfwXIYXevyBfsR4Ir5EQA"; + let sd_jwt = SdJwt::parse(SD_JWT).unwrap(); + assert_eq!(sd_jwt.disclosures.len(), 2); + assert!(sd_jwt.key_binding_jwt.is_none()); + } - let sd_jwt = SdJwt::parse(sd_jwt_str).unwrap(); - assert_eq!(sd_jwt.disclosures.len(), 4); - assert_eq!(sd_jwt.key_binding_jwt.unwrap(), "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodHRwczovL3ZlcmlmaWVyLmV4YW1wbGUub3JnIiwgImlhdCI6IDE2OTgwNzc3OTAsICJfc2RfaGFzaCI6ICJ1MXpzTkxGUXhlVkVGcFRmT1Z1NFRjSTNaYjdDX1UzYTFFNGVzQVlRLXpZIn0.LLaMyLVXmAC5YVj29d8T-QbyJaxORbMCuWtxnw8VLZHjz9kyyMMTFaOfGb3CZmytVWfwXIYXevyBfsR4Ir5EQA"); + #[test] + fn round_trip_ser_des() { + let sd_jwt = SdJwt::parse(SD_JWT).unwrap(); + assert_eq!(&sd_jwt.to_string(), SD_JWT); } } diff --git a/src/signer.rs b/src/signer.rs new file mode 100644 index 0000000..e6048a2 --- /dev/null +++ b/src/signer.rs @@ -0,0 +1,19 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; + +use async_trait::async_trait; +use serde_json::Map; +use serde_json::Value; + +pub type JsonObject = Map; + +/// JSON Web Signature (JWS) Signer. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait JwsSigner { + type Error: Display; + /// Creates a JWS. The algorithm used for signed must be read from `header.alg` property. + async fn sign(&self, header: &JsonObject, payload: &JsonObject) -> Result, Self::Error>; +} diff --git a/tests/api_test.rs b/tests/api_test.rs index a48b7cc..29af608 100644 --- a/tests/api_test.rs +++ b/tests/api_test.rs @@ -1,182 +1,67 @@ // Copyright 2020-2024 IOTA Stiftung // SPDX-License-Identifier: Apache-2.0 -use josekit::jws::JwsAlgorithm; +use async_trait::async_trait; +use josekit::jws::alg::hmac::HmacJwsSigner; use josekit::jws::JwsHeader; -use josekit::jws::JwsVerifier; use josekit::jws::HS256; +use josekit::jwt; use josekit::jwt::JwtPayload; -use josekit::jwt::{self}; +use sd_jwt_payload::Hasher; +use sd_jwt_payload::JsonObject; +use sd_jwt_payload::JwsSigner; +use sd_jwt_payload::KeyBindingJwt; +use sd_jwt_payload::Sha256Hasher; use serde_json::json; -use serde_json::Map; use serde_json::Value; -use sd_jwt_payload::Disclosure; use sd_jwt_payload::SdJwt; -use sd_jwt_payload::SdObjectDecoder; -use sd_jwt_payload::SdObjectEncoder; +use sd_jwt_payload::SdJwtBuilder; -#[test] -fn test_complex_structure() { - // Values taken from https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-07.html#appendix-A.2 - let object = json!({ - "verified_claims": { - "verification": { - "trust_framework": "de_aml", - "time": "2012-04-23T18:25Z", - "verification_process": "f24c6f-6d3f-4ec5-973e-b0d8506f3bc7", - "evidence": [ - { - "type": "document", - "method": "pipp", - "time": "2012-04-22T11:30Z", - "document": { - "type": "idcard", - "issuer": { - "name": "Stadt Augsburg", - "country": "DE" - }, - "number": "53554554", - "date_of_issuance": "2010-03-23", - "date_of_expiry": "2020-03-22" - } - }, - "evidence2" - ] - }, - "claims": { - "given_name": "Max", - "family_name": "Müller", - "nationalities": [ - "DE" - ], - "birthdate": "1956-01-28", - "place_of_birth": { - "country": "IS", - "locality": "Þykkvabæjarklaustur" - }, - "address": { - "locality": "Maxstadt", - "postal_code": "12344", - "country": "DE", - "street_address": "Weidenstraße 22" - } - } - }, - "birth_middle_name": "Timotheus", - "salutation": "Dr.", - "msisdn": "49123456789" - }); - - let mut disclosures: Vec = vec![]; - let mut encoder = SdObjectEncoder::try_from(object.clone()).unwrap(); - let disclosure = encoder.conceal("/verified_claims/verification/time", None); - disclosures.push(disclosure.unwrap()); - - let disclosure = encoder.conceal("/verified_claims/verification/evidence/0/document/type", None); - disclosures.push(disclosure.unwrap()); +const HMAC_SECRET: &[u8; 32] = b"0123456789ABCDEF0123456789ABCDEF"; - let disclosure = encoder.conceal("/verified_claims/verification/evidence/1", None); - disclosures.push(disclosure.unwrap()); +struct HmacSignerAdapter(HmacJwsSigner); - let disclosure = encoder.conceal("/verified_claims/verification/evidence", None); - disclosures.push(disclosure.unwrap()); +#[async_trait] +impl JwsSigner for HmacSignerAdapter { + type Error = josekit::JoseError; + async fn sign(&self, header: &JsonObject, payload: &JsonObject) -> Result, Self::Error> { + let header = JwsHeader::from_map(header.clone())?; + let payload = JwtPayload::from_map(payload.clone())?; - let disclosure = encoder.conceal("/verified_claims/claims/place_of_birth/locality", None); - disclosures.push(disclosure.unwrap()); - - let disclosure = encoder.conceal("/verified_claims/claims", None); - disclosures.push(disclosure.unwrap()); + jwt::encode_with_signer(&payload, &header, &self.0).map(String::into_bytes) + } +} - println!( - "encoded object: {}", - serde_json::to_string_pretty(&encoder.object().unwrap()).unwrap() - ); - // Create the JWT. - // Creating JWTs is out of the scope of this library, josekit is used here as an example - let mut header = JwsHeader::new(); - header.set_token_type("SD-JWT"); - - // Use the encoded object as a payload for the JWT. - let payload = JwtPayload::from_map(encoder.object().unwrap().clone()).unwrap(); - let key = b"0123456789ABCDEF0123456789ABCDEF"; - let signer = HS256.signer_from_bytes(key).unwrap(); - let jwt = jwt::encode_with_signer(&payload, &header, &signer).unwrap(); - - // Create an SD_JWT by collecting the disclosures and creating an `SdJwt` instance. - let disclosures: Vec = disclosures +async fn make_sd_jwt(object: Value, disclosable_values: impl IntoIterator) -> SdJwt { + let signer = HmacSignerAdapter(HS256.signer_from_bytes(HMAC_SECRET).unwrap()); + disclosable_values .into_iter() - .map(|disclosure| disclosure.to_string()) - .collect(); - let sd_jwt: SdJwt = SdJwt::new(jwt, disclosures.clone(), None); - let sd_jwt: String = sd_jwt.presentation(); - - // Decoding the SD-JWT - // Extract the payload from the JWT of the SD-JWT after verifying the signature. - let sd_jwt: SdJwt = SdJwt::parse(&sd_jwt).unwrap(); - let verifier = HS256.verifier_from_bytes(key).unwrap(); - let (payload, _header) = jwt::decode_with_verifier(&sd_jwt.jwt, &verifier).unwrap(); - - // Decode the payload by providing the disclosures that were parsed from the SD-JWT. - let decoder = SdObjectDecoder::new_with_sha256(); - - println!( - "claims: {}", - serde_json::to_string_pretty(payload.claims_set()).unwrap() - ); - let decoded = decoder.decode(payload.claims_set(), &sd_jwt.disclosures).unwrap(); - println!("decoded object: {}", serde_json::to_string_pretty(&decoded).unwrap()); - assert_eq!(Value::Object(decoded), object); + .fold(SdJwtBuilder::new(object).unwrap(), |builder, path| { + builder.make_concealable(path).unwrap() + }) + .finish(&signer, "HS256") + .await + .unwrap() } -#[test] -fn concealed_object_in_array() { - let mut disclosures: Vec = vec![]; - let nested_object = json!({ - "test1": 123, - }); - let mut encoder = SdObjectEncoder::try_from(nested_object.clone()).unwrap(); - let disclosure = encoder.conceal("/test1", None); - disclosures.push(disclosure.unwrap()); - - let object = json!({ - "test2": [ - "value1", - encoder.object().unwrap() - ] - }); - - let expected = json!({ - "test2": [ - "value1", - { - "test1": 123, - } - ] - }); - let mut encoder = SdObjectEncoder::try_from(object.clone()).unwrap(); - let disclosure = encoder.conceal("/test2/0", None); - disclosures.push(disclosure.unwrap()); - let disclosure = encoder.conceal("/test2", None); - disclosures.push(disclosure.unwrap()); - - let disclosures: Vec = disclosures - .into_iter() - .map(|disclosure| disclosure.to_string()) - .collect(); - let decoder = SdObjectDecoder::new_with_sha256(); - let decoded = decoder.decode(encoder.object().unwrap(), &disclosures).unwrap(); - assert_eq!(Value::Object(decoded), expected); +async fn make_kb_jwt(sd_jwt: &SdJwt, hasher: &dyn Hasher) -> KeyBindingJwt { + let signer = HmacSignerAdapter(HS256.signer_from_bytes(HMAC_SECRET).unwrap()); + KeyBindingJwt::builder() + .nonce("abcdefghi") + .aud("https://example.com") + .iat(1458304832) + .finish(sd_jwt, hasher, "HS256", &signer) + .await + .unwrap() } #[test] -fn decode() { +fn simple_sd_jwt() { // Values taken from https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-06.html#name-example-2-handling-structur - let sd_jwt = "eyJhbGciOiAiRVMyNTYifQ.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1FyazFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZzZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZkx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1REw4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSLWFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzIiwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAiczBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTSjFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1cFJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZXd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2bmNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4anZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25VbGRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0MG9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.IjE4EfnYu1RZ1uz6yqtFh5Lppq36VC4VeSr-hLDFpZ9zqBNmMrT5JHLLXTuMJqKQp3NIzDsLaft4GK5bYyfqhg~WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ~"; + let sd_jwt = "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1FyazFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZzZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZkx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1REw4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSLWFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzIiwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAiczBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTSjFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1cFJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZXd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2bmNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4anZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25VbGRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0MG9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.gR6rSL7urX79CNEvTQnP1MH5xthG11ucIV44SqKFZ4Pvlu_u16RfvXQd4k4CAIBZNKn2aTI18TfvFwV97gJFoA~WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ~"; let sd_jwt: SdJwt = SdJwt::parse(sd_jwt).unwrap(); - let (payload, _header) = jwt::decode_with_verifier(&sd_jwt.jwt, &DecoyJwsVerifier {}).unwrap(); - let decoder = SdObjectDecoder::new_with_sha256(); - let decoded: Map = decoder.decode(payload.claims_set(), &sd_jwt.disclosures).unwrap(); + let disclosed = sd_jwt.into_disclosed_object(&Sha256Hasher::new()).unwrap(); let expected_object = json!({ "address": { "country": "JP", @@ -186,42 +71,79 @@ fn decode() { "iat": 1683000000, "exp": 1883000000 } - ) - .as_object() - .unwrap() - .clone(); - assert_eq!(expected_object, decoded); + ); + assert_eq!(expected_object.as_object().unwrap(), &disclosed); } -// Boilerplate to allow extracting JWS payload without verifying the signature. -#[derive(Debug, Clone)] -struct DecoyJwsAlgorithm; -impl JwsAlgorithm for DecoyJwsAlgorithm { - fn name(&self) -> &str { - "ES256" - } +#[tokio::test] +async fn concealing_parent_also_removes_all_sub_disclosures() -> anyhow::Result<()> { + let hasher = Sha256Hasher::new(); + let sd_jwt = make_sd_jwt( + json!({"parent": {"property1": "value1", "property2": [1, 2, 3]}}), + ["/parent/property1", "/parent/property2/0", "/parent"], + ) + .await; - fn box_clone(&self) -> Box { - Box::new(self.clone()) - } + let removed_disclosures = sd_jwt.into_presentation(&hasher)?.conceal("/parent")?.finish()?.1; + assert_eq!(removed_disclosures.len(), 3); + + Ok(()) } -#[derive(Debug, Clone)] -struct DecoyJwsVerifier; -impl JwsVerifier for DecoyJwsVerifier { - fn algorithm(&self) -> &dyn josekit::jws::JwsAlgorithm { - &DecoyJwsAlgorithm {} - } +#[tokio::test] +async fn concealing_property_of_concealable_value_works() -> anyhow::Result<()> { + let hasher = Sha256Hasher::new(); + let sd_jwt = make_sd_jwt( + json!({"parent": {"property1": "value1", "property2": [1, 2, 3]}}), + ["/parent/property1", "/parent/property2/0", "/parent"], + ) + .await; - fn key_id(&self) -> Option<&str> { - None - } + sd_jwt + .into_presentation(&hasher)? + .conceal("/parent/property2/0")? + .finish()?; - fn verify(&self, _message: &[u8], _signature: &[u8]) -> Result<(), josekit::JoseError> { - Ok(()) - } + Ok(()) +} - fn box_clone(&self) -> Box { - Box::new(self.clone()) - } +#[tokio::test] +async fn sd_jwt_is_verifiable() -> anyhow::Result<()> { + let sd_jwt = make_sd_jwt(json!({"key": "value"}), []).await; + let jwt = sd_jwt.presentation().split_once('~').unwrap().0.to_string(); + let verifier = HS256.verifier_from_bytes(HMAC_SECRET)?; + + josekit::jwt::decode_with_verifier(&jwt, &verifier)?; + Ok(()) +} + +#[tokio::test] +async fn sd_jwt_without_disclosures_works() -> anyhow::Result<()> { + let hasher = Sha256Hasher::new(); + let sd_jwt = make_sd_jwt(json!({"parent": {"property1": "value1", "property2": [1, 2, 3]}}), []).await; + // Try to serialize & deserialize `sd_jwt`. + let sd_jwt = { + let s = sd_jwt.to_string(); + s.parse::()? + }; + + assert!(sd_jwt.disclosures().is_empty()); + assert!(sd_jwt.key_binding_jwt().is_none()); + + let with_kb = sd_jwt + .clone() + .into_presentation(&hasher)? + .attach_key_binding_jwt(make_kb_jwt(&sd_jwt, &hasher).await) + .finish()? + .0; + // Try to serialize & deserialize `with_kb`. + let with_kb = { + let s = with_kb.to_string(); + s.parse::()? + }; + + assert!(with_kb.disclosures().is_empty()); + assert!(with_kb.key_binding_jwt().is_some()); + + Ok(()) }