diff --git a/Cargo.toml b/Cargo.toml index 7dfcbcadd1..6dc4d7dae2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,6 @@ edition = "2021" homepage = "https://www.iota.org" license = "Apache-2.0" repository = "https://github.com/iotaledger/identity.rs" -rust-version = "1.65" [workspace.lints.clippy] result_large_err = "allow" diff --git a/bindings/wasm/src/common/imported_document_lock.rs b/bindings/wasm/src/common/imported_document_lock.rs index 4852ab216e..4452ce6dd2 100644 --- a/bindings/wasm/src/common/imported_document_lock.rs +++ b/bindings/wasm/src/common/imported_document_lock.rs @@ -79,7 +79,7 @@ impl From<&ArrayIToCoreDocument> for Vec { pub(crate) struct ImportedDocumentReadGuard<'a>(tokio::sync::RwLockReadGuard<'a, CoreDocument>); -impl<'a> AsRef for ImportedDocumentReadGuard<'a> { +impl AsRef for ImportedDocumentReadGuard<'_> { fn as_ref(&self) -> &CoreDocument { self.0.as_ref() } diff --git a/bindings/wasm/src/error.rs b/bindings/wasm/src/error.rs index 035e7838bf..34d4c98d8b 100644 --- a/bindings/wasm/src/error.rs +++ b/bindings/wasm/src/error.rs @@ -151,7 +151,7 @@ fn error_chain_fmt(e: &impl std::error::Error, f: &mut std::fmt::Formatter<'_>) struct ErrorMessage<'a, E: std::error::Error>(&'a E); -impl<'a, E: std::error::Error> Display for ErrorMessage<'a, E> { +impl Display for ErrorMessage<'_, E> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { error_chain_fmt(self.0, f) } diff --git a/identity_core/Cargo.toml b/identity_core/Cargo.toml index fcdd263cc7..619745cb85 100644 --- a/identity_core/Cargo.toml +++ b/identity_core/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "tangle", "identity"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "The core traits and types for the identity-rs library." [dependencies] diff --git a/identity_core/src/common/mod.rs b/identity_core/src/common/mod.rs index 8d6be52251..04568e05b5 100644 --- a/identity_core/src/common/mod.rs +++ b/identity_core/src/common/mod.rs @@ -14,6 +14,7 @@ pub use self::single_struct_error::*; pub use self::timestamp::Duration; pub use self::timestamp::Timestamp; pub use self::url::Url; +pub use string_or_url::StringOrUrl; mod context; mod key_comparable; @@ -22,5 +23,6 @@ mod one_or_many; mod one_or_set; mod ordered_set; mod single_struct_error; +mod string_or_url; mod timestamp; mod url; diff --git a/identity_core/src/common/string_or_url.rs b/identity_core/src/common/string_or_url.rs new file mode 100644 index 0000000000..11e4e5dff2 --- /dev/null +++ b/identity_core/src/common/string_or_url.rs @@ -0,0 +1,151 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::convert::Infallible; +use std::fmt::Display; +use std::str::FromStr; + +use serde::Deserialize; +use serde::Serialize; + +use super::Url; + +/// A type that represents either an arbitrary string or a URL. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(untagged)] +pub enum StringOrUrl { + /// A well-formed URL. + Url(Url), + /// An arbitrary UTF-8 string. + String(String), +} + +impl StringOrUrl { + /// Parses a [`StringOrUrl`] from a string. + pub fn parse(s: &str) -> Result { + s.parse() + } + /// Returns a [`Url`] reference if `self` is [`StringOrUrl::Url`]. + pub fn as_url(&self) -> Option<&Url> { + match self { + Self::Url(url) => Some(url), + _ => None, + } + } + + /// Returns a [`str`] reference if `self` is [`StringOrUrl::String`]. + pub fn as_string(&self) -> Option<&str> { + match self { + Self::String(s) => Some(s), + _ => None, + } + } + + /// Returns whether `self` is a [`StringOrUrl::Url`]. + pub fn is_url(&self) -> bool { + matches!(self, Self::Url(_)) + } + + /// Returns whether `self` is a [`StringOrUrl::String`]. + pub fn is_string(&self) -> bool { + matches!(self, Self::String(_)) + } +} + +impl Default for StringOrUrl { + fn default() -> Self { + StringOrUrl::String(String::default()) + } +} + +impl Display for StringOrUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Url(url) => write!(f, "{url}"), + Self::String(s) => write!(f, "{s}"), + } + } +} + +impl FromStr for StringOrUrl { + // Cannot fail. + type Err = Infallible; + fn from_str(s: &str) -> Result { + Ok( + s.parse::() + .map(Self::Url) + .unwrap_or_else(|_| Self::String(s.to_string())), + ) + } +} + +impl AsRef for StringOrUrl { + fn as_ref(&self) -> &str { + match self { + Self::String(s) => s, + Self::Url(url) => url.as_str(), + } + } +} + +impl From for StringOrUrl { + fn from(value: Url) -> Self { + Self::Url(value) + } +} + +impl From for StringOrUrl { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From for String { + fn from(value: StringOrUrl) -> Self { + match value { + StringOrUrl::String(s) => s, + StringOrUrl::Url(url) => url.into_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, Serialize, Deserialize)] + struct TestData { + string_or_url: StringOrUrl, + } + + impl Default for TestData { + fn default() -> Self { + Self { + string_or_url: StringOrUrl::Url(TEST_URL.parse().unwrap()), + } + } + } + + const TEST_URL: &str = "file:///tmp/file.txt"; + + #[test] + fn deserialization_works() { + let test_data: TestData = serde_json::from_value(serde_json::json!({ "string_or_url": TEST_URL })).unwrap(); + let target_url: Url = TEST_URL.parse().unwrap(); + assert_eq!(test_data.string_or_url.as_url(), Some(&target_url)); + } + + #[test] + fn serialization_works() { + assert_eq!( + serde_json::to_value(TestData::default()).unwrap(), + serde_json::json!({ "string_or_url": TEST_URL }) + ) + } + + #[test] + fn parsing_works() { + assert!(TEST_URL.parse::().unwrap().is_url()); + assert!("I'm a random string :)".parse::().unwrap().is_string()); + } +} diff --git a/identity_credential/Cargo.toml b/identity_credential/Cargo.toml index 62cb6d0a41..1562305623 100644 --- a/identity_credential/Cargo.toml +++ b/identity_credential/Cargo.toml @@ -8,14 +8,14 @@ keywords = ["iota", "tangle", "identity"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "An implementation of the Verifiable Credentials standard." [dependencies] +anyhow = { version = "1" } async-trait = { version = "0.1.64", default-features = false } bls12_381_plus = { workspace = true, optional = true } flate2 = { version = "1.0.28", default-features = false, features = ["rust_backend"], optional = true } -futures = { version = "0.3", default-features = false, optional = true } +futures = { version = "0.3", default-features = false, features = ["alloc"], optional = true } identity_core = { version = "=1.4.0", path = "../identity_core", default-features = false } identity_did = { version = "=1.4.0", path = "../identity_did", default-features = false } identity_document = { version = "=1.4.0", path = "../identity_document", default-features = false } @@ -23,10 +23,12 @@ identity_verification = { version = "=1.4.0", path = "../identity_verification", indexmap = { version = "2.0", default-features = false, features = ["std", "serde"] } itertools = { version = "0.11", default-features = false, features = ["use_std"], optional = true } json-proof-token = { workspace = true, optional = true } +jsonschema = { version = "0.19", optional = true, default-features = false } once_cell = { version = "1.18", default-features = false, features = ["std"] } reqwest = { version = "0.11", default-features = false, features = ["default-tls", "json", "stream"], optional = true } roaring = { version = "0.10.2", default-features = false, features = ["serde"], optional = true } sd-jwt-payload = { version = "0.2.1", default-features = false, features = ["sha"], optional = true } +sd-jwt-payload-rework = { package = "sd-jwt-payload", git = "https://github.com/iotaledger/sd-jwt-payload.git", branch = "feat/sd-jwt-v11", default-features = false, features = ["sha"], optional = true } serde.workspace = true serde-aux = { version = "4.3.1", default-features = false } serde_json.workspace = true @@ -40,6 +42,7 @@ zkryptium = { workspace = true, optional = true } anyhow = "1.0.62" identity_eddsa_verifier = { path = "../identity_eddsa_verifier", default-features = false, features = ["ed25519"] } iota-crypto = { version = "0.23.2", default-features = false, features = ["ed25519", "std", "random"] } +josekit = "0.8" proptest = { version = "1.4.0", default-features = false, features = ["std"] } tokio = { version = "1.35.0", default-features = false, features = ["rt-multi-thread", "macros"] } @@ -50,7 +53,15 @@ all-features = true rustdoc-args = ["--cfg", "docsrs"] [features] -default = ["revocation-bitmap", "validator", "credential", "presentation", "domain-linkage-fetch", "sd-jwt"] +default = [ + "revocation-bitmap", + "validator", + "credential", + "presentation", + "domain-linkage-fetch", + "sd-jwt", + "sd-jwt-vc", +] credential = [] presentation = ["credential"] revocation-bitmap = ["dep:flate2", "dep:roaring"] @@ -59,7 +70,15 @@ validator = ["dep:itertools", "dep:serde_repr", "credential", "presentation"] domain-linkage = ["validator"] domain-linkage-fetch = ["domain-linkage", "dep:reqwest", "dep:futures"] sd-jwt = ["credential", "validator", "dep:sd-jwt-payload"] -jpt-bbs-plus = ["credential", "validator", "dep:zkryptium", "dep:bls12_381_plus", "dep:json-proof-token"] +sd-jwt-vc = ["sd-jwt", "dep:sd-jwt-payload-rework", "dep:jsonschema", "dep:futures"] +jpt-bbs-plus = [ + "credential", + "validator", + "dep:zkryptium", + "dep:bls12_381_plus", + "dep:json-proof-token", + "dep:futures", +] [lints] workspace = true diff --git a/identity_credential/src/credential/jwt_serialization.rs b/identity_credential/src/credential/jwt_serialization.rs index 3f2a33f0a7..feb3de531d 100644 --- a/identity_credential/src/credential/jwt_serialization.rs +++ b/identity_credential/src/credential/jwt_serialization.rs @@ -67,7 +67,7 @@ impl<'credential, T> CredentialJwtClaims<'credential, T> where T: ToOwned + Serialize + DeserializeOwned, { - pub(super) fn new(credential: &'credential Credential, custom: Option) -> Result { + pub(crate) fn new(credential: &'credential Credential, custom: Option) -> Result { let Credential { context, id, @@ -118,7 +118,7 @@ where } #[cfg(feature = "validator")] -impl<'credential, T> CredentialJwtClaims<'credential, T> +impl CredentialJwtClaims<'_, T> where T: ToOwned + Serialize + DeserializeOwned, { diff --git a/identity_credential/src/domain_linkage/domain_linkage_validator.rs b/identity_credential/src/domain_linkage/domain_linkage_validator.rs index be67c96832..746a9b2f5a 100644 --- a/identity_credential/src/domain_linkage/domain_linkage_validator.rs +++ b/identity_credential/src/domain_linkage/domain_linkage_validator.rs @@ -21,7 +21,6 @@ use super::DomainLinkageValidationResult; use crate::utils::url_only_includes_origin; /// A validator for a Domain Linkage Configuration and Credentials. - pub struct JwtDomainLinkageValidator { validator: JwtCredentialValidator, } diff --git a/identity_credential/src/error.rs b/identity_credential/src/error.rs index 1c814c3899..18b31d69c3 100644 --- a/identity_credential/src/error.rs +++ b/identity_credential/src/error.rs @@ -79,4 +79,9 @@ pub enum Error { /// Cause by an invalid attribute path #[error("Attribute Not found")] SelectiveDisclosureError, + + /// Failure of an SD-JWT VC operation. + #[cfg(feature = "sd-jwt-vc")] + #[error(transparent)] + SdJwtVc(#[from] crate::sd_jwt_vc::Error), } diff --git a/identity_credential/src/lib.rs b/identity_credential/src/lib.rs index 3111b72e0a..236329ab4c 100644 --- a/identity_credential/src/lib.rs +++ b/identity_credential/src/lib.rs @@ -27,8 +27,15 @@ mod utils; #[cfg(feature = "validator")] pub mod validator; +/// Implementation of the SD-JWT VC token specification. +#[cfg(feature = "sd-jwt-vc")] +pub mod sd_jwt_vc; + pub use error::Error; pub use error::Result; #[cfg(feature = "sd-jwt")] pub use sd_jwt_payload; + +#[cfg(feature = "sd-jwt-vc")] +pub use sd_jwt_payload_rework as sd_jwt_v2; diff --git a/identity_credential/src/presentation/jwt_serialization.rs b/identity_credential/src/presentation/jwt_serialization.rs index d8bb18c238..50aab3d428 100644 --- a/identity_credential/src/presentation/jwt_serialization.rs +++ b/identity_credential/src/presentation/jwt_serialization.rs @@ -136,7 +136,7 @@ where } #[cfg(feature = "validator")] -impl<'presentation, CRED, T> PresentationJwtClaims<'presentation, CRED, T> +impl PresentationJwtClaims<'_, CRED, T> where CRED: ToOwned + Serialize + DeserializeOwned + Clone, T: ToOwned + Serialize + DeserializeOwned, diff --git a/identity_credential/src/revocation/status_list_2021/entry.rs b/identity_credential/src/revocation/status_list_2021/entry.rs index 92415d06b7..1108b5e7c1 100644 --- a/identity_credential/src/revocation/status_list_2021/entry.rs +++ b/identity_credential/src/revocation/status_list_2021/entry.rs @@ -18,7 +18,7 @@ where D: serde::Deserializer<'de>, { struct ExactStrVisitor(&'static str); - impl<'a> Visitor<'a> for ExactStrVisitor { + impl Visitor<'_> for ExactStrVisitor { type Value = &'static str; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(formatter, "the exact string \"{}\"", self.0) diff --git a/identity_credential/src/revocation/validity_timeframe_2024/revocation_timeframe_status.rs b/identity_credential/src/revocation/validity_timeframe_2024/revocation_timeframe_status.rs index 0a70589112..6ae6ea74f8 100644 --- a/identity_credential/src/revocation/validity_timeframe_2024/revocation_timeframe_status.rs +++ b/identity_credential/src/revocation/validity_timeframe_2024/revocation_timeframe_status.rs @@ -18,7 +18,7 @@ where D: serde::Deserializer<'de>, { struct ExactStrVisitor(&'static str); - impl<'a> Visitor<'a> for ExactStrVisitor { + impl Visitor<'_> for ExactStrVisitor { type Value = &'static str; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(formatter, "the exact string \"{}\"", self.0) diff --git a/identity_credential/src/sd_jwt_vc/builder.rs b/identity_credential/src/sd_jwt_vc/builder.rs new file mode 100644 index 0000000000..46b28bc9b2 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/builder.rs @@ -0,0 +1,386 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![allow(clippy::vec_init_then_push)] +use std::sync::LazyLock; + +use identity_core::common::StringOrUrl; +use identity_core::common::Timestamp; +use identity_core::common::Url; +use identity_core::convert::ToJson; +use sd_jwt_payload_rework::Hasher; +use sd_jwt_payload_rework::JsonObject; +use sd_jwt_payload_rework::JwsSigner; +use sd_jwt_payload_rework::RequiredKeyBinding; +use sd_jwt_payload_rework::SdJwtBuilder; +use sd_jwt_payload_rework::Sha256Hasher; +use serde::Serialize; +use serde_json::json; +use serde_json::Value; + +use crate::credential::Credential; +use crate::credential::CredentialJwtClaims; + +use super::Error; +use super::Result; +use super::SdJwtVc; +use super::Status; +use super::SD_JWT_VC_TYP; + +static DEFAULT_HEADER: LazyLock = LazyLock::new(|| { + let mut object = JsonObject::default(); + object.insert("typ".to_string(), SD_JWT_VC_TYP.into()); + object +}); + +macro_rules! claim_to_key_value_pair { + ( $( $claim:ident ),+ ) => { + { + let mut claim_list = Vec::<(&'static str, serde_json::Value)>::new(); + $( + claim_list.push((stringify!($claim), serde_json::to_value($claim).unwrap())); + )* + claim_list + } + }; +} + +/// A structure to ease the creation of an [`SdJwtVc`]. +#[derive(Debug)] +pub struct SdJwtVcBuilder { + inner_builder: SdJwtBuilder, + header: JsonObject, + iss: Option, + nbf: Option, + exp: Option, + iat: Option, + vct: Option, + sub: Option, + status: Option, +} + +impl Default for SdJwtVcBuilder { + fn default() -> Self { + Self { + inner_builder: SdJwtBuilder::::new(json!({})).unwrap(), + header: DEFAULT_HEADER.clone(), + iss: None, + nbf: None, + exp: None, + iat: None, + vct: None, + sub: None, + status: None, + } + } +} + +impl SdJwtVcBuilder { + /// Creates a new [`SdJwtVcBuilder`] using `object` JSON representation and default + /// `sha-256` hasher. + pub fn new(object: T) -> Result { + let inner_builder = SdJwtBuilder::::new(object)?; + Ok(Self { + header: DEFAULT_HEADER.clone(), + inner_builder, + ..Default::default() + }) + } +} + +impl SdJwtVcBuilder { + /// Creates a new [`SdJwtVcBuilder`] using `object` JSON representation and a given + /// hasher `hasher`. + pub fn new_with_hasher(object: T, hasher: H) -> Result { + let inner_builder = SdJwtBuilder::new_with_hasher(object, hasher)?; + Ok(Self { + inner_builder, + header: DEFAULT_HEADER.clone(), + iss: None, + nbf: None, + exp: None, + iat: None, + vct: None, + sub: None, + status: None, + }) + } + + /// Creates a new [`SdJwtVcBuilder`] starting from a [`Credential`] that is converted to a JWT claim set. + pub fn new_from_credential(credential: Credential, hasher: H) -> std::result::Result { + let mut vc_jwt_claims = CredentialJwtClaims::new(&credential, None)? + .to_json_value() + .map_err(|e| crate::Error::JwtClaimsSetSerializationError(Box::new(e)))?; + // When converting a VC to its JWT claims representation, some VC specific claims are putted into a `vc` object + // property. Flatten out `vc`, keeping the other JWT claims intact. + { + let claims = vc_jwt_claims.as_object_mut().expect("serialized VC is a JSON object"); + let Value::Object(vc_properties) = claims.remove("vc").expect("serialized VC has `vc` property") else { + unreachable!("`vc` property's value is a JSON object"); + }; + for (key, value) in vc_properties { + claims.insert(key, value); + } + } + Ok(Self::new_with_hasher(vc_jwt_claims, hasher)?) + } + + /// 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 serde_json::json; + /// use identity_credential::sd_jwt_vc::SdJwtVcBuilder; + /// + /// let obj = json!({ + /// "id": "did:value", + /// "claim1": { + /// "abc": true + /// }, + /// "claim2": ["val_1", "val_2"] + /// }); + /// let builder = SdJwtVcBuilder::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" + /// ``` + pub fn make_concealable(mut self, path: &str) -> Result { + self.inner_builder = self.inner_builder.make_concealable(path)?; + Ok(self) + } + + /// Sets the JWT header. + /// ## Notes + /// - if [`SdJwtVcBuilder::header`] is not called, the default header is used: ```json { "typ": "sd-jwt", "alg": + /// "" } ``` + /// - `alg` is always replaced with the value passed to [`SdJwtVcBuilder::finish`]. + pub fn header(mut self, header: JsonObject) -> Self { + self.header = header; + 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.inner_builder = self.inner_builder.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.inner_builder = self.inner_builder.require_key_binding(key_bind); + self + } + + /// Inserts an `iss` claim. See [`super::SdJwtVcClaims::iss`]. + pub fn iss(mut self, issuer: Url) -> Self { + self.iss = Some(issuer); + self + } + + /// Inserts a `nbf` claim. See [`super::SdJwtVcClaims::nbf`]. + pub fn nbf(mut self, nbf: Timestamp) -> Self { + self.nbf = Some(nbf.to_unix()); + self + } + + /// Inserts a `exp` claim. See [`super::SdJwtVcClaims::exp`]. + pub fn exp(mut self, exp: Timestamp) -> Self { + self.exp = Some(exp.to_unix()); + self + } + + /// Inserts a `iat` claim. See [`super::SdJwtVcClaims::iat`]. + pub fn iat(mut self, iat: Timestamp) -> Self { + self.iat = Some(iat.to_unix()); + self + } + + /// Inserts a `vct` claim. See [`super::SdJwtVcClaims::vct`]. + pub fn vct(mut self, vct: impl Into) -> Self { + self.vct = Some(vct.into()); + self + } + + /// Inserts a `sub` claim. See [`super::SdJwtVcClaims::sub`]. + #[allow(clippy::should_implement_trait)] + pub fn sub(mut self, sub: impl Into) -> Self { + self.sub = Some(sub.into()); + self + } + + /// Inserts a `status` claim. See [`super::SdJwtVcClaims::status`]. + pub fn status(mut self, status: Status) -> Self { + self.status = Some(status); + self + } + + /// Creates an [`SdJwtVc`] with the provided data. + pub async fn finish(self, signer: &S, alg: &str) -> Result + where + S: JwsSigner, + { + let Self { + inner_builder, + mut header, + iss, + nbf, + exp, + iat, + vct, + sub, + status, + } = self; + // Check header. + header + .entry("typ") + .or_insert_with(|| SD_JWT_VC_TYP.to_owned().into()) + .as_str() + .filter(|typ| typ.contains(SD_JWT_VC_TYP)) + .ok_or_else(|| Error::InvalidJoseType(String::default()))?; + + let builder = inner_builder.header(header); + + // Insert SD-JWT VC claims into object. + let builder = claim_to_key_value_pair![iss, nbf, exp, iat, vct, sub, status] + .into_iter() + .filter(|(_, value)| !value.is_null()) + .fold(builder, |builder, (key, value)| { + builder.insert_claim(key, value).expect("value is a JSON Value") + }); + + let sd_jwt = builder.finish(signer, alg).await?; + SdJwtVc::try_from(sd_jwt) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + use crate::credential::CredentialBuilder; + use crate::credential::Subject; + use crate::sd_jwt_vc::tests::TestSigner; + + #[tokio::test] + async fn building_valid_vc_works() -> anyhow::Result<()> { + let credential = json!({ + "name": "John Doe", + "birthdate": "1970-01-01" + }); + + SdJwtVcBuilder::new(credential)? + .vct("https://bmi.bund.example/credential/pid/1.0".parse::()?) + .iat(Timestamp::now_utc()) + .iss("https://example.com/".parse()?) + .make_concealable("/birthdate")? + .finish(&TestSigner, "HS256") + .await?; + + Ok(()) + } + + #[tokio::test] + async fn building_vc_with_missing_mandatory_claims_fails() -> anyhow::Result<()> { + let credential = json!({ + "name": "John Doe", + "birthdate": "1970-01-01" + }); + + let err = SdJwtVcBuilder::new(credential)? + .vct("https://bmi.bund.example/credential/pid/1.0".parse::()?) + .iat(Timestamp::now_utc()) + // issuer is missing. + .make_concealable("/birthdate")? + .finish(&TestSigner, "HS256") + .await + .unwrap_err(); + assert!(matches!(err, Error::MissingClaim("iss"))); + + Ok(()) + } + + #[tokio::test] + async fn building_vc_with_invalid_mandatory_claims_fails() -> anyhow::Result<()> { + let credential = json!({ + "name": "John Doe", + "birthdate": "1970-01-01", + "vct": { "id": 1234567890 } + }); + + let err = SdJwtVcBuilder::new(credential)? + .iat(Timestamp::now_utc()) + .iss("https://example.com".parse()?) + .make_concealable("/birthdate")? + .finish(&TestSigner, "HS256") + .await + .unwrap_err(); + + assert!(matches!(err, Error::InvalidClaimValue { name: "vct", .. })); + + Ok(()) + } + + #[tokio::test] + async fn building_vc_with_disclosed_mandatory_claim_fails() -> anyhow::Result<()> { + let credential = json!({ + "name": "John Doe", + "birthdate": "1970-01-01", + "vct": { "id": 1234567890 } + }); + + let err = SdJwtVcBuilder::new(credential)? + .iat(Timestamp::now_utc()) + .iss("https://example.com".parse()?) + .make_concealable("/birthdate")? + .make_concealable("/vct")? + .finish(&TestSigner, "HS256") + .await + .unwrap_err(); + + assert!(matches!(err, Error::DisclosedClaim("vct"))); + + Ok(()) + } + + #[tokio::test] + async fn building_sd_jwt_vc_from_credential_works() -> anyhow::Result<()> { + let credential = CredentialBuilder::default() + .id(Url::parse("https://example.com/credentials/42")?) + .issuance_date(Timestamp::now_utc()) + .issuer(Url::parse("https://example.com/issuers/42")?) + .subject(Subject::with_id(Url::parse("https://example.com/subjects/42")?)) + .build()?; + + let sd_jwt_vc = SdJwtVcBuilder::new_from_credential(credential.clone(), Sha256Hasher)? + .vct(Url::parse("https://example.com/types/0")?) + .finish(&TestSigner, "HS256") + .await?; + + assert_eq!(sd_jwt_vc.claims().nbf.as_ref().unwrap(), &credential.issuance_date); + assert_eq!(&sd_jwt_vc.claims().iss, credential.issuer.url()); + assert_eq!( + sd_jwt_vc.claims().sub.as_ref().unwrap().as_url(), + credential.credential_subject.first().unwrap().id.as_ref() + ); + assert_eq!( + sd_jwt_vc.claims().get("jti"), + Some(&json!(credential.id.as_ref().unwrap())) + ); + assert_eq!(sd_jwt_vc.claims().get("type"), Some(&json!("VerifiableCredential"))); + + Ok(()) + } +} diff --git a/identity_credential/src/sd_jwt_vc/claims.rs b/identity_credential/src/sd_jwt_vc/claims.rs new file mode 100644 index 0000000000..9fcec11ce3 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/claims.rs @@ -0,0 +1,217 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::ops::Deref; +use std::ops::DerefMut; + +use identity_core::common::StringOrUrl; +use identity_core::common::Timestamp; +use identity_core::common::Url; +use sd_jwt_payload_rework::Disclosure; +use sd_jwt_payload_rework::SdJwtClaims; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use super::Error; +use super::Result; +use super::Status; + +/// JOSE payload claims for SD-JWT VC. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct SdJwtVcClaims { + /// Issuer. + pub iss: Url, + /// Not before. + /// See [RFC7519 section 4.1.5](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.5) for more information. + pub nbf: Option, + /// Expiration. + /// See [RFC7519 section 4.1.4](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4) for more information. + pub exp: Option, + /// Verifiable credential type. + /// See [SD-JWT VC specification](https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-04.html#type-claim) + /// for more information. + pub vct: StringOrUrl, + /// Token's status. + /// See [OAuth status list specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-status-list-02) + /// for more information. + pub status: Option, + /// Issued at. + /// See [RFC7519 section 4.1.6](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6) for more information. + pub iat: Option, + /// Subject. + /// See [RFC7519 section 4.1.2](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2) for more information. + pub sub: Option, + #[serde(flatten)] + pub(crate) sd_jwt_claims: SdJwtClaims, +} + +impl Deref for SdJwtVcClaims { + type Target = SdJwtClaims; + fn deref(&self) -> &Self::Target { + &self.sd_jwt_claims + } +} + +impl DerefMut for SdJwtVcClaims { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.sd_jwt_claims + } +} + +impl SdJwtVcClaims { + pub(crate) fn try_from_sd_jwt_claims(mut claims: SdJwtClaims, disclosures: &[Disclosure]) -> Result { + let check_disclosed = |claim_name: &'static str| { + disclosures + .iter() + .any(|disclosure| disclosure.claim_name.as_deref() == Some(claim_name)) + .then_some(Error::DisclosedClaim(claim_name)) + }; + let iss = claims + .remove("iss") + .ok_or(Error::MissingClaim("iss")) + .map_err(|e| check_disclosed("iss").unwrap_or(e)) + .and_then(|value| { + value + .as_str() + .and_then(|s| Url::parse(s).ok()) + .ok_or_else(|| Error::InvalidClaimValue { + name: "iss", + expected: "URL", + found: value, + }) + })?; + let nbf = { + if let Some(value) = claims.remove("nbf") { + value + .as_number() + .and_then(|t| t.as_i64()) + .and_then(|t| Timestamp::from_unix(t).ok()) + .ok_or_else(|| Error::InvalidClaimValue { + name: "nbf", + expected: "unix timestamp", + found: value, + }) + .map(Some)? + } else { + if let Some(err) = check_disclosed("nbf") { + return Err(err); + } + None + } + }; + let exp = { + if let Some(value) = claims.remove("exp") { + value + .as_number() + .and_then(|t| t.as_i64()) + .and_then(|t| Timestamp::from_unix(t).ok()) + .ok_or_else(|| Error::InvalidClaimValue { + name: "exp", + expected: "unix timestamp", + found: value, + }) + .map(Some)? + } else { + if let Some(err) = check_disclosed("exp") { + return Err(err); + } + None + } + }; + let vct = claims + .remove("vct") + .ok_or(Error::MissingClaim("vct")) + .map_err(|e| check_disclosed("vct").unwrap_or(e)) + .and_then(|value| { + value + .as_str() + .and_then(|s| StringOrUrl::parse(s).ok()) + .ok_or_else(|| Error::InvalidClaimValue { + name: "vct", + expected: "String or URL", + found: value, + }) + })?; + let status = { + if let Some(value) = claims.remove("status") { + serde_json::from_value::(value.clone()) + .map_err(|_| Error::InvalidClaimValue { + name: "status", + expected: "credential's status object", + found: value, + }) + .map(Some)? + } else { + if let Some(err) = check_disclosed("status") { + return Err(err); + } + None + } + }; + let sub = claims + .remove("sub") + .map(|value| { + value + .as_str() + .and_then(|s| StringOrUrl::parse(s).ok()) + .ok_or_else(|| Error::InvalidClaimValue { + name: "sub", + expected: "String or URL", + found: value, + }) + }) + .transpose()?; + let iat = claims + .remove("iat") + .map(|value| { + value + .as_number() + .and_then(|t| t.as_i64()) + .and_then(|t| Timestamp::from_unix(t).ok()) + .ok_or_else(|| Error::InvalidClaimValue { + name: "iat", + expected: "unix timestamp", + found: value, + }) + }) + .transpose()?; + + Ok(Self { + iss, + nbf, + exp, + vct, + status, + iat, + sub, + sd_jwt_claims: claims, + }) + } +} + +impl From for SdJwtClaims { + fn from(claims: SdJwtVcClaims) -> Self { + let SdJwtVcClaims { + iss, + nbf, + exp, + vct, + status, + iat, + sub, + mut sd_jwt_claims, + } = claims; + + sd_jwt_claims.insert("iss".to_string(), Value::String(iss.into_string())); + nbf.and_then(|t| sd_jwt_claims.insert("nbf".to_string(), Value::Number(t.to_unix().into()))); + exp.and_then(|t| sd_jwt_claims.insert("exp".to_string(), Value::Number(t.to_unix().into()))); + sd_jwt_claims.insert("vct".to_string(), Value::String(vct.into())); + status.and_then(|status| sd_jwt_claims.insert("status".to_string(), serde_json::to_value(status).unwrap())); + iat.and_then(|t| sd_jwt_claims.insert("iat".to_string(), Value::Number(t.to_unix().into()))); + sub.and_then(|sub| sd_jwt_claims.insert("sub".to_string(), Value::String(sub.into()))); + + sd_jwt_claims + } +} diff --git a/identity_credential/src/sd_jwt_vc/error.rs b/identity_credential/src/sd_jwt_vc/error.rs new file mode 100644 index 0000000000..13af8911a3 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/error.rs @@ -0,0 +1,57 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use serde_json::Value; +use thiserror::Error; + +/// Error type that represents failures that might arise when dealing +/// with `SdJwtVc`s. +#[derive(Error, Debug)] +pub enum Error { + /// A JWT claim required for an operation is missing. + #[error("missing required claim \"{0}\"")] + MissingClaim(&'static str), + /// A JWT claim that must not be disclosed was found among the disclosed values. + #[error("claim \"{0}\" cannot be disclosed")] + DisclosedClaim(&'static str), + /// Invalid value for a given JWT claim. + #[error("invalid value for claim \"{name}\"; expected value of type {expected}, but {found} was found")] + InvalidClaimValue { + /// Name of the invalid claim. + name: &'static str, + /// Type expected for the claim's value. + expected: &'static str, + /// The claim's value. + found: Value, + }, + /// A low level SD-JWT error. + #[error(transparent)] + SdJwt(#[from] sd_jwt_payload_rework::Error), + /// Value of header parameter `typ` is not valid. + #[error("invalid \"typ\" value; expected \"vc+sd-jwt\" (or a superset) but found \"{0}\"")] + InvalidJoseType(String), + /// Resolution error. + #[error("failed to resolve \"{input}\"")] + Resolution { + /// The resource's identifier. + input: String, + /// Low level error. + #[source] + source: super::resolver::Error, + }, + /// Invalid issuer Metadata object. + #[error("invalid Issuer Metadata: {0}")] + InvalidIssuerMetadata(#[source] anyhow::Error), + /// Invalid credential type metadata object. + #[error("invalid Type Metadata: {0}")] + InvalidTypeMetadata(#[source] anyhow::Error), + /// Credential validation failed. + #[error("credential validation failed: {0}")] + Validation(#[source] anyhow::Error), + /// SD-JWT VC signature verification failed. + #[error("verification failed: {0}")] + Verification(#[source] anyhow::Error), +} + +/// Either a value of type `T` or an [`Error`]. +pub type Result = std::result::Result; diff --git a/identity_credential/src/sd_jwt_vc/metadata/claim.rs b/identity_credential/src/sd_jwt_vc/metadata/claim.rs new file mode 100644 index 0000000000..8dcfdde0c1 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/metadata/claim.rs @@ -0,0 +1,286 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; +use std::ops::Deref; + +use anyhow::anyhow; +use anyhow::Context; +use itertools::Itertools; +use serde::Deserialize; +use serde::Serialize; +use serde::Serializer; +use serde_json::Value; + +use crate::sd_jwt_vc::Error; + +/// Information about a particular claim for displaying and validation purposes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaimMetadata { + /// [`ClaimPath`] of the claim or claims that are being addressed. + pub path: ClaimPath, + /// Object containing display information for the claim. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub display: Vec, + /// A string indicating whether the claim is selectively disclosable. + pub sd: Option, + /// A string defining the ID of the claim for reference in the SVG template. + pub svg_id: Option, +} + +impl ClaimMetadata { + /// Checks wheter `value` is compliant with the disclosability policy imposed by this [`ClaimMetadata`]. + pub fn check_value_disclosability(&self, value: &Value) -> Result<(), Error> { + if self.sd.unwrap_or_default() == ClaimDisclosability::Allowed { + return Ok(()); + } + + let interested_claims = self.path.reverse_index(value); + if self.sd.unwrap_or_default() == ClaimDisclosability::Always && interested_claims.is_ok() { + return Err(Error::Validation(anyhow!( + "claim or claims with path {} must always be disclosable", + &self.path + ))); + } + + if self.sd.unwrap_or_default() == ClaimDisclosability::Never && interested_claims.is_err() { + return Err(Error::Validation(anyhow!( + "claim or claims with path {} must never be disclosable", + &self.path + ))); + } + + Ok(()) + } +} + +/// A non-empty list of string, `null` values, or non-negative integers. +/// It is used to select a particular claim in the credential or a +/// set of claims. See [Claim Path](https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-05.html#name-claim-path) for more information. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "Vec")] +pub struct ClaimPath(Vec); + +impl ClaimPath { + fn reverse_index<'v>(&self, value: &'v Value) -> anyhow::Result> { + let mut segments = self.iter(); + let first_segment = segments.next().context("empty claim path")?; + segments.try_fold(index_value(value, first_segment)?, |values, segment| { + values.get(segment) + }) + } +} + +impl TryFrom> for ClaimPath { + type Error = anyhow::Error; + fn try_from(value: Vec) -> Result { + if value.is_empty() { + Err(anyhow::anyhow!("`ClaimPath` cannot be empty")) + } else { + Ok(Self(value)) + } + } +} + +impl Display for ClaimPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let segments = self.iter().join(", "); + write!(f, "[{segments}]") + } +} + +impl Deref for ClaimPath { + type Target = [ClaimPathSegment]; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +/// A single segment of a [`ClaimPath`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged, try_from = "Value")] +pub enum ClaimPathSegment { + /// JSON object property. + Name(String), + /// JSON array entry. + Position(usize), + /// All properties or entries. + #[serde(serialize_with = "serialize_all_variant")] + All, +} + +impl Display for ClaimPathSegment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::All => write!(f, "null"), + Self::Name(name) => write!(f, "\"{name}\""), + Self::Position(i) => write!(f, "{i}"), + } + } +} + +impl TryFrom for ClaimPathSegment { + type Error = anyhow::Error; + fn try_from(value: Value) -> Result { + match value { + Value::Null => Ok(ClaimPathSegment::All), + Value::String(s) => Ok(ClaimPathSegment::Name(s)), + Value::Number(n) => n + .as_u64() + .ok_or_else(|| anyhow::anyhow!("expected number greater or equal to 0")) + .map(|n| ClaimPathSegment::Position(n as usize)), + _ => Err(anyhow::anyhow!("expected either a string, number, or null")), + } + } +} + +fn serialize_all_variant(serializer: S) -> Result +where + S: Serializer, +{ + serializer.serialize_none() +} + +/// Information about whether a given claim is selectively disclosable. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ClaimDisclosability { + /// The issuer **must** make the claim selectively disclosable. + Always, + /// The issuer **may** make the claim selectively disclosable. + #[default] + Allowed, + /// The issuer **must not** make the claim selectively disclosable. + Never, +} + +/// Display information for a given claim. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClaimDisplay { + /// A language tag as defined in [RFC5646](https://www.rfc-editor.org/rfc/rfc5646.txt). + pub lang: String, + /// A human-readable label for the claim. + pub label: String, + /// A human-readable description for the claim. + pub description: Option, +} + +enum OneOrManyValue<'v> { + One(&'v Value), + Many(Box + 'v>), +} + +impl<'v> OneOrManyValue<'v> { + fn get(self, segment: &ClaimPathSegment) -> anyhow::Result> { + match self { + Self::One(value) => index_value(value, segment), + Self::Many(values) => { + let new_values = values + .map(|value| index_value(value, segment)) + .collect::>>()? + .into_iter() + .flatten(); + + Ok(OneOrManyValue::Many(Box::new(new_values))) + } + } + } +} + +struct OneOrManyValueIter<'v>(Option>); + +impl<'v> OneOrManyValueIter<'v> { + fn new(value: OneOrManyValue<'v>) -> Self { + Self(Some(value)) + } +} + +impl<'v> IntoIterator for OneOrManyValue<'v> { + type IntoIter = OneOrManyValueIter<'v>; + type Item = &'v Value; + fn into_iter(self) -> Self::IntoIter { + OneOrManyValueIter::new(self) + } +} + +impl<'v> Iterator for OneOrManyValueIter<'v> { + type Item = &'v Value; + fn next(&mut self) -> Option { + match self.0.take()? { + OneOrManyValue::One(v) => Some(v), + OneOrManyValue::Many(mut values) => { + let value = values.next(); + self.0 = Some(OneOrManyValue::Many(values)); + + value + } + } + } +} + +fn index_value<'v>(value: &'v Value, segment: &ClaimPathSegment) -> anyhow::Result> { + match segment { + ClaimPathSegment::Name(name) => value.get(name).map(OneOrManyValue::One), + ClaimPathSegment::Position(i) => value.get(i).map(OneOrManyValue::One), + ClaimPathSegment::All => value + .as_array() + .map(|values| OneOrManyValue::Many(Box::new(values.iter()))), + } + .ok_or_else(|| anyhow::anyhow!("value {value:#} has no element {segment}")) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn sample_obj() -> Value { + json!({ + "vct": "https://betelgeuse.example.com/education_credential", + "name": "Arthur Dent", + "address": { + "street_address": "42 Market Street", + "city": "Milliways", + "postal_code": "12345" + }, + "degrees": [ + { + "type": "Bachelor of Science", + "university": "University of Betelgeuse" + }, + { + "type": "Master of Science", + "university": "University of Betelgeuse" + } + ], + "nationalities": ["British", "Betelgeusian"] + }) + } + + #[test] + fn claim_path_works() { + let name_path = serde_json::from_value::(json!(["name"])).unwrap(); + let city_path = serde_json::from_value::(json!(["address", "city"])).unwrap(); + let first_degree_path = serde_json::from_value::(json!(["degrees", 0])).unwrap(); + let degrees_types_path = serde_json::from_value::(json!(["degrees", null, "type"])).unwrap(); + + assert!(matches!( + name_path.reverse_index(&sample_obj()).unwrap(), + OneOrManyValue::One(&Value::String(_)) + )); + assert!(matches!( + city_path.reverse_index(&sample_obj()).unwrap(), + OneOrManyValue::One(&Value::String(_)) + )); + assert!(matches!( + first_degree_path.reverse_index(&sample_obj()).unwrap(), + OneOrManyValue::One(&Value::Object(_)) + )); + let obj = &sample_obj(); + let mut degree_types = degrees_types_path.reverse_index(obj).unwrap().into_iter(); + assert_eq!(degree_types.next().unwrap().as_str(), Some("Bachelor of Science")); + assert_eq!(degree_types.next().unwrap().as_str(), Some("Master of Science")); + assert_eq!(degree_types.next(), None); + } +} diff --git a/identity_credential/src/sd_jwt_vc/metadata/display.rs b/identity_credential/src/sd_jwt_vc/metadata/display.rs new file mode 100644 index 0000000000..dade4816b5 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/metadata/display.rs @@ -0,0 +1,24 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +/// Credential type's display information of a given languange. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct DisplayMetadata { + /// Language tag as defined in [RFC5646](https://www.rfc-editor.org/rfc/rfc5646.txt). + pub lang: String, + /// VC type's human-readable name. + pub name: String, + /// VC type's human-readable description. + pub description: Option, + /// Optional rendering information. + pub rendering: Option>, +} + +/// Information on how to render a given credential type. +// TODO: model the actual object properties. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +pub struct RenderingMetadata(serde_json::Map); diff --git a/identity_credential/src/sd_jwt_vc/metadata/integrity.rs b/identity_credential/src/sd_jwt_vc/metadata/integrity.rs new file mode 100644 index 0000000000..d41ca1f097 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/metadata/integrity.rs @@ -0,0 +1,121 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; +use std::str::FromStr; + +use anyhow::anyhow; +use identity_core::convert::Base; +use identity_core::convert::BaseEncoding; +use serde::Deserialize; +use serde::Serialize; + +/// An integrity metadata string as defined in [W3C SRI](https://www.w3.org/TR/SRI/#integrity-metadata). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub struct IntegrityMetadata(String); + +impl IntegrityMetadata { + /// Parses an [`IntegrityMetadata`] from a string. + /// ## Example + /// ```rust + /// use identity_credential::sd_jwt_vc::metadata::IntegrityMetadata; + /// + /// let integrity_data = IntegrityMetadata::parse( + /// "sha384-dOTZf16X8p34q2/kYyEFm0jh89uTjikhnzjeLeF0FHsEaYKb1A1cv+Lyv4Hk8vHd", + /// ) + /// .unwrap(); + /// ``` + pub fn parse(s: &str) -> Result { + s.parse() + } + + /// Returns the digest algorithm's identifier string. + /// ## Example + /// ```rust + /// use identity_credential::sd_jwt_vc::metadata::IntegrityMetadata; + /// + /// let integrity_data: IntegrityMetadata = + /// "sha384-dOTZf16X8p34q2/kYyEFm0jh89uTjikhnzjeLeF0FHsEaYKb1A1cv+Lyv4Hk8vHd" + /// .parse() + /// .unwrap(); + /// assert_eq!(integrity_data.alg(), "sha384"); + /// ``` + pub fn alg(&self) -> &str { + self.0.split_once('-').unwrap().0 + } + + /// Returns the base64 encoded digest part. + /// ## Example + /// ```rust + /// use identity_credential::sd_jwt_vc::metadata::IntegrityMetadata; + /// + /// let integrity_data: IntegrityMetadata = + /// "sha384-dOTZf16X8p34q2/kYyEFm0jh89uTjikhnzjeLeF0FHsEaYKb1A1cv+Lyv4Hk8vHd" + /// .parse() + /// .unwrap(); + /// assert_eq!( + /// integrity_data.digest(), + /// "dOTZf16X8p34q2/kYyEFm0jh89uTjikhnzjeLeF0FHsEaYKb1A1cv+Lyv4Hk8vHd" + /// ); + /// ``` + pub fn digest(&self) -> &str { + self.0.split('-').nth(1).unwrap() + } + + /// Returns the digest's bytes. + pub fn digest_bytes(&self) -> Vec { + BaseEncoding::decode(self.digest(), Base::Base64).unwrap() + } + + /// Returns the option part. + /// ## Example + /// ```rust + /// use identity_credential::sd_jwt_vc::metadata::IntegrityMetadata; + /// + /// let integrity_data: IntegrityMetadata = + /// "sha384-dOTZf16X8p34q2/kYyEFm0jh89uTjikhnzjeLeF0FHsEaYKb1A1cv+Lyv4Hk8vHd" + /// .parse() + /// .unwrap(); + /// assert!(integrity_data.options().is_none()); + /// ``` + pub fn options(&self) -> Option<&str> { + self.0.splitn(3, '-').nth(2) + } +} + +impl AsRef for IntegrityMetadata { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + +impl Display for IntegrityMetadata { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", &self.0) + } +} + +impl FromStr for IntegrityMetadata { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + Self::try_from(s.to_owned()) + } +} + +impl TryFrom for IntegrityMetadata { + type Error = anyhow::Error; + fn try_from(value: String) -> Result { + let mut metadata_parts = value.splitn(3, '-'); + let _alg = metadata_parts + .next() + .ok_or_else(|| anyhow!("invalid integrity metadata"))?; + let _digest = metadata_parts + .next() + .and_then(|digest| BaseEncoding::decode(digest, Base::Base64).ok()) + .ok_or_else(|| anyhow!("invalid integrity metadata"))?; + let _options = metadata_parts.next(); + + Ok(Self(value)) + } +} diff --git a/identity_credential/src/sd_jwt_vc/metadata/issuer.rs b/identity_credential/src/sd_jwt_vc/metadata/issuer.rs new file mode 100644 index 0000000000..7af0effd6c --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/metadata/issuer.rs @@ -0,0 +1,94 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_core::common::Url; +use identity_verification::jwk::JwkSet; +use serde::Deserialize; +use serde::Serialize; + +use crate::sd_jwt_vc::Error; +use crate::sd_jwt_vc::SdJwtVc; +#[allow(unused_imports)] +use crate::sd_jwt_vc::SdJwtVcClaims; + +/// Path used to query [`IssuerMetadata`] for a given JWT VC issuer. +pub const WELL_KNOWN_VC_ISSUER: &str = "/.well-known/jwt-vc-issuer"; + +/// SD-JWT VC issuer's metadata. Contains information about one issuer's +/// public keys, either as an embedded JWK Set or a reference to one. +/// ## Notes +/// - [`IssuerMetadata::issuer`] must exactly match [`SdJwtVcClaims::iss`] in order to be considered valid. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub struct IssuerMetadata { + /// Issuer URI. + pub issuer: Url, + /// JWK Set containing the issuer's public keys. + #[serde(flatten)] + pub jwks: Jwks, +} + +impl IssuerMetadata { + /// Checks the validity of this [`IssuerMetadata`]. + /// [`IssuerMetadata::issuer`] must match `sd_jwt_vc`'s iss claim's value. + pub fn validate(&self, sd_jwt_vc: &SdJwtVc) -> Result<(), Error> { + let expected_issuer = &sd_jwt_vc.claims().iss; + let actual_issuer = &self.issuer; + if actual_issuer != expected_issuer { + Err(Error::InvalidIssuerMetadata(anyhow::anyhow!( + "expected issuer \"{expected_issuer}\", but found \"{actual_issuer}\"" + ))) + } else { + Ok(()) + } + } +} + +/// A JWK Set used for [`IssuerMetadata`]. +#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)] +pub enum Jwks { + /// Reference to a JWK set. + #[serde(rename = "jwks_uri")] + Uri(Url), + /// An embedded JWK set. + #[serde(rename = "jwks")] + Object(JwkSet), +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXAMPLE_URI_ISSUER_METADATA: &str = r#" +{ + "issuer":"https://example.com", + "jwks_uri":"https://jwt-vc-issuer.example.org/my_public_keys.jwks" +} + "#; + const EXAMPLE_JWKS_ISSUER_METADATA: &str = r#" +{ + "issuer":"https://example.com", + "jwks":{ + "keys":[ + { + "kid":"doc-signer-05-25-2022", + "e":"AQAB", + "n":"nj3YJwsLUFl9BmpAbkOswCNVx17Eh9wMO-_AReZwBqfaWFcfGHrZXsIV2VMCNVNU8Tpb4obUaSXcRcQ-VMsfQPJm9IzgtRdAY8NN8Xb7PEcYyklBjvTtuPbpzIaqyiUepzUXNDFuAOOkrIol3WmflPUUgMKULBN0EUd1fpOD70pRM0rlp_gg_WNUKoW1V-3keYUJoXH9NztEDm_D2MQXj9eGOJJ8yPgGL8PAZMLe2R7jb9TxOCPDED7tY_TU4nFPlxptw59A42mldEmViXsKQt60s1SLboazxFKveqXC_jpLUt22OC6GUG63p-REw-ZOr3r845z50wMuzifQrMI9bQ", + "kty":"RSA" + } + ] + } +} + "#; + + #[test] + fn deserializing_uri_metadata_works() { + let issuer_metadata: IssuerMetadata = serde_json::from_str(EXAMPLE_URI_ISSUER_METADATA).unwrap(); + assert!(matches!(issuer_metadata.jwks, Jwks::Uri(_))); + } + + #[test] + fn deserializing_jwks_metadata_works() { + let issuer_metadata: IssuerMetadata = serde_json::from_str(EXAMPLE_JWKS_ISSUER_METADATA).unwrap(); + assert!(matches!(issuer_metadata.jwks, Jwks::Object { .. })); + } +} diff --git a/identity_credential/src/sd_jwt_vc/metadata/mod.rs b/identity_credential/src/sd_jwt_vc/metadata/mod.rs new file mode 100644 index 0000000000..662c42032f --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/metadata/mod.rs @@ -0,0 +1,14 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod claim; +mod display; +mod integrity; +mod issuer; +mod vc_type; + +pub use claim::*; +pub use display::*; +pub use integrity::*; +pub use issuer::*; +pub use vc_type::*; diff --git a/identity_credential/src/sd_jwt_vc/metadata/vc_type.rs b/identity_credential/src/sd_jwt_vc/metadata/vc_type.rs new file mode 100644 index 0000000000..97be651392 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/metadata/vc_type.rs @@ -0,0 +1,268 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use futures::future::BoxFuture; +use futures::future::FutureExt; +use identity_core::common::Url; +use itertools::Itertools as _; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +use crate::sd_jwt_vc::Error; +use crate::sd_jwt_vc::Resolver; +use crate::sd_jwt_vc::Result; + +use super::ClaimMetadata; +use super::DisplayMetadata; +use super::IntegrityMetadata; + +/// Path used to retrieve VC Type Metadata. +pub const WELL_KNOWN_VCT: &str = "/.well-known/vct"; + +/// SD-JWT VC's credential type. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TypeMetadata { + /// A human-readable name for the type, intended for developers reading the JSON document. + pub name: Option, + /// A human-readable description for the type, intended for developers reading the JSON document. + pub description: Option, + /// A URI of another type that this type extends. + pub extends: Option, + /// Integrity metadata for the extended type. + #[serde(rename = "extends#integrity")] + pub extends_integrity: Option, + /// Either an embedded schema or a reference to one. + #[serde(flatten)] + pub schema: Option, + /// A list containing display information for the type. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub display: Vec, + /// A list of [`ClaimMetadata`] containing information about particular claims. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub claims: Vec, +} + +impl TypeMetadata { + /// Returns the name of this VC type, if any. + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + /// Returns the description of this VC type, if any. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + /// Returns the URI or string of the type this VC type extends, if any. + pub fn extends(&self) -> Option<&Url> { + self.extends.as_ref() + } + /// Returns the integrity string of the extended type object, if any. + pub fn extends_integrity(&self) -> Option<&str> { + self.extends_integrity.as_ref().map(|meta| meta.as_ref()) + } + /// Returns the [`ClaimMetadata`]s associated with this credential type. + pub fn claim_metadata(&self) -> &[ClaimMetadata] { + &self.claims + } + /// Returns the [`DisplayMetadata`]s associated with this credential type. + pub fn display_metadata(&self) -> &[DisplayMetadata] { + &self.display + } + /// Uses this [`TypeMetadata`] to validate JSON object `credential`. This method fails + /// if the schema is referenced instead of embedded. + /// Use [`TypeMetadata::validate_credential_with_resolver`] for such cases. + /// ## Notes + /// This method ignores type extensions. + pub fn validate_credential(&self, credential: &Value) -> Result<()> { + match &self.schema { + Some(TypeSchema::Object { schema, .. }) => validate_credential_with_schema(schema, credential), + Some(_) => Err(Error::Validation(anyhow::anyhow!( + "this credential type references a schema; resolution is required" + ))), + None => Ok(()), + } + } + + /// Similar to [`TypeMetadata::validate_credential`], but accepts a [`Resolver`] + /// [`StringOrUrl`] -> [`Value`] that is used to resolve any reference to either + /// another type or JSON schema. + pub async fn validate_credential_with_resolver(&self, credential: &Value, resolver: &R) -> Result<()> + where + R: Resolver + Sync, + { + validate_credential_impl(self.clone(), credential, resolver, vec![]).await + } +} + +/// Does this method signature look weird? Turns out having recursive async functions is not that ez :'(. +fn validate_credential_impl<'c, 'r, R>( + current_type: TypeMetadata, + credential: &'c Value, + resolver: &'r R, + mut passed_types: Vec, +) -> BoxFuture<'c, Result<()>> +where + R: Resolver + Sync, + 'r: 'c, +{ + async move { + // Check if current type has already been checked. + let is_type_already_checked = passed_types.contains(¤t_type); + if is_type_already_checked { + // This is a dependency cycle! + return Err(Error::Validation(anyhow::anyhow!("dependency cycle detected"))); + } + + // Check if `validate_credential` should have been called instead. + let has_extend = current_type.extends.is_none(); + let is_immediate = current_type + .schema + .as_ref() + .map(|schema| matches!(schema, &TypeSchema::Object { .. })) + .unwrap_or(true); + + if is_immediate && !has_extend { + return current_type.validate_credential(credential); + } + + if !is_immediate { + // Fetch schema and validate `current_type`. + let TypeSchema::Uri { schema_uri, .. } = current_type.schema.as_ref().unwrap() else { + unreachable!("schema is provided through `schema_uri` as checked by `validate_credential`"); + }; + let schema = resolver.resolve(schema_uri).await.map_err(|e| Error::Resolution { + input: schema_uri.to_string(), + source: e, + })?; + validate_credential_with_schema(&schema, credential)?; + } + + // Check for extends. + if let Some(extends_uri) = current_type.extends() { + // Fetch the extended type metadata and parse it. + let raw_type_metadata = resolver.resolve(extends_uri).await.map_err(|e| Error::Resolution { + input: extends_uri.to_string(), + source: e, + })?; + let type_metadata = + serde_json::from_value(raw_type_metadata).map_err(|e| Error::InvalidTypeMetadata(e.into()))?; + // Forward validation of new type. + passed_types.push(current_type); + validate_credential_impl(type_metadata, credential, resolver, passed_types).await + } else { + Ok(()) + } + } + .boxed() +} + +fn validate_credential_with_schema(schema: &Value, credential: &Value) -> Result<()> { + let schema = jsonschema::compile(schema).map_err(|e| Error::Validation(anyhow::anyhow!(e.to_string())))?; + schema.validate(credential).map_err(|errors| { + let error_msg = errors.map(|e| e.to_string()).join("; "); + Error::Validation(anyhow::anyhow!(error_msg)) + }) +} + +/// Either a reference to or an embedded JSON Schema. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +#[serde(untagged)] +pub enum TypeSchema { + /// URI reference to a JSON schema. + Uri { + /// URI of the referenced JSON schema. + schema_uri: Url, + /// Integrity string for the referenced schema. + #[serde(rename = "schema_uri#integrity")] + schema_uri_integrity: Option, + }, + /// An embedded JSON schema. + Object { + /// The JSON schema. + schema: Value, + /// Integrity of the JSON schema. + #[serde(rename = "schema#integrity")] + schema_integrity: Option, + }, +} + +#[cfg(test)] +mod tests { + use std::sync::LazyLock; + + use async_trait::async_trait; + use serde_json::json; + + use crate::sd_jwt_vc::resolver; + + use super::*; + + static IMMEDIATE_TYPE_METADATA: LazyLock = LazyLock::new(|| TypeMetadata { + name: Some("immediate credential".to_string()), + description: None, + extends: None, + extends_integrity: None, + display: vec![], + claims: vec![], + schema: Some(TypeSchema::Object { + schema: json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "age": { + "type": "number" + } + }, + "required": ["name", "age"] + }), + schema_integrity: None, + }), + }); + static REFERENCED_TYPE_METADATA: LazyLock = LazyLock::new(|| TypeMetadata { + name: Some("immediate credential".to_string()), + description: None, + extends: None, + extends_integrity: None, + display: vec![], + claims: vec![], + schema: Some(TypeSchema::Uri { + schema_uri: Url::parse("https://example.com/vc_types/1").unwrap(), + schema_uri_integrity: None, + }), + }); + + struct SchemaResolver; + #[async_trait] + impl Resolver for SchemaResolver { + async fn resolve(&self, _input: &Url) -> resolver::Result { + Ok(serde_json::to_value(IMMEDIATE_TYPE_METADATA.clone().schema).unwrap()) + } + } + + #[test] + fn validation_of_immediate_type_metadata_works() { + IMMEDIATE_TYPE_METADATA + .validate_credential(&json!({ + "name": "John Doe", + "age": 42 + })) + .unwrap(); + } + + #[tokio::test] + async fn validation_of_referenced_type_metadata_works() { + REFERENCED_TYPE_METADATA + .validate_credential_with_resolver( + &json!({ + "name": "Aristide Zantedeschi", + "age": 90, + }), + &SchemaResolver, + ) + .await + .unwrap(); + } +} diff --git a/identity_credential/src/sd_jwt_vc/mod.rs b/identity_credential/src/sd_jwt_vc/mod.rs new file mode 100644 index 0000000000..f3173264bf --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/mod.rs @@ -0,0 +1,24 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +mod builder; +mod claims; +mod error; +/// Additional metadata defined by the SD-JWT VC specification +/// such as issuer's metadata and credential type metadata. +pub mod metadata; +mod presentation; +mod resolver; +mod status; +#[cfg(test)] +pub(crate) mod tests; +mod token; + +pub use builder::*; +pub use claims::*; +pub use error::Error; +pub use error::Result; +pub use presentation::*; +pub use resolver::Resolver; +pub use status::*; +pub use token::*; diff --git a/identity_credential/src/sd_jwt_vc/presentation.rs b/identity_credential/src/sd_jwt_vc/presentation.rs new file mode 100644 index 0000000000..06a2d2feac --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/presentation.rs @@ -0,0 +1,54 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use super::Error; +use super::Result; +use super::SdJwtVc; +use super::SdJwtVcClaims; + +use sd_jwt_payload_rework::Disclosure; +use sd_jwt_payload_rework::Hasher; +use sd_jwt_payload_rework::KeyBindingJwt; +use sd_jwt_payload_rework::SdJwtPresentationBuilder; + +/// Builder structure to create an SD-JWT VC presentation. +/// It allows users to conceal claims and attach a key binding JWT. +#[derive(Debug, Clone)] +pub struct SdJwtVcPresentationBuilder { + vc_claims: SdJwtVcClaims, + builder: SdJwtPresentationBuilder, +} + +impl SdJwtVcPresentationBuilder { + /// Prepare a presentation for a given [`SdJwtVc`]. + pub fn new(token: SdJwtVc, hasher: &dyn Hasher) -> Result { + let SdJwtVc { + sd_jwt, + parsed_claims: vc_claims, + } = token; + let builder = sd_jwt.into_presentation(hasher).map_err(Error::SdJwt)?; + + Ok(Self { vc_claims, builder }) + } + /// Removes the disclosure for the property at `path`, conceiling 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 { + self.builder = self.builder.conceal(path).map_err(Error::SdJwt)?; + Ok(self) + } + + /// Adds a [`KeyBindingJwt`] to this [`SdJwtVc`]'s presentation. + pub fn attach_key_binding_jwt(mut self, kb_jwt: KeyBindingJwt) -> Self { + self.builder = self.builder.attach_key_binding_jwt(kb_jwt); + self + } + + /// Returns the resulting [`SdJwtVc`] together with all removed disclosures. + pub fn finish(self) -> Result<(SdJwtVc, Vec)> { + let (sd_jwt, disclosures) = self.builder.finish()?; + Ok((SdJwtVc::new(sd_jwt, self.vc_claims), disclosures)) + } +} diff --git a/identity_credential/src/sd_jwt_vc/resolver.rs b/identity_credential/src/sd_jwt_vc/resolver.rs new file mode 100644 index 0000000000..5e0993a90a --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/resolver.rs @@ -0,0 +1,26 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +#![allow(async_fn_in_trait)] + +use async_trait::async_trait; +use thiserror::Error; + +pub(crate) type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum Error { + #[error("The requested item \"{0}\" was not found.")] + NotFound(String), + #[error("Failed to parse the provided input into a resolvable type: {0}")] + ParsingFailure(#[source] anyhow::Error), + #[error(transparent)] + Generic(#[from] anyhow::Error), +} + +/// A type capable of asynchronously producing values of type `T` from inputs of type `I`. +#[async_trait] +pub trait Resolver { + /// Fetch the resource of type [`Resolver::Target`] using `input`. + async fn resolve(&self, input: &I) -> Result; +} diff --git a/identity_credential/src/sd_jwt_vc/status.rs b/identity_credential/src/sd_jwt_vc/status.rs new file mode 100644 index 0000000000..1c68db6d4c --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/status.rs @@ -0,0 +1,52 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_core::common::Url; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// SD-JWT VC's `status` claim value. Used to retrieve the status of the token. +pub struct Status(StatusMechanism); + +/// Mechanism used for representing the status of an SD-JWT VC token. +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum StatusMechanism { + /// Reference to a status list containing this token's status. + #[serde(rename = "status_list")] + StatusList(StatusListRef), + /// A non-standard status mechanism. + #[serde(untagged)] + Custom(serde_json::Value), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// A reference to an OAuth status list. +/// See [OAuth StatusList specification](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-status-list-02) +/// for more information. +pub struct StatusListRef { + /// URI of the status list. + pub uri: Url, + /// Index of the entry containing this token's status. + pub idx: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + #[test] + fn round_trip() { + let status_value = json!({ + "status_list": { + "idx": 420, + "uri": "https://example.com/statuslists/1" + } + }); + let status: Status = serde_json::from_value(status_value.clone()).unwrap(); + assert_eq!(serde_json::to_value(status).unwrap(), status_value); + } +} diff --git a/identity_credential/src/sd_jwt_vc/tests/mod.rs b/identity_credential/src/sd_jwt_vc/tests/mod.rs new file mode 100644 index 0000000000..f93fe20784 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/tests/mod.rs @@ -0,0 +1,113 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; + +use async_trait::async_trait; +use identity_core::convert::Base; +use identity_core::convert::BaseEncoding; +use identity_verification::jwk::Jwk; +use identity_verification::jwk::JwkParamsOct; +use identity_verification::jws::JwsVerifier; +use josekit::jws::JwsHeader; +use josekit::jws::HS256; +use josekit::jwt::JwtPayload; +use josekit::jwt::{self}; +use sd_jwt_payload_rework::JsonObject; +use sd_jwt_payload_rework::JwsSigner; +use serde::Serialize; +use serde_json::Value; + +use super::resolver; +use super::Resolver; + +mod validation; + +pub(crate) const ISSUER_SECRET: &[u8] = b"0123456789ABCDEF0123456789ABCDEF"; + +/// A JWS signer that uses HS256 with a static secret string. +pub(crate) struct TestSigner; + +pub(crate) fn signer_secret_jwk() -> Jwk { + let mut params = JwkParamsOct::new(); + params.k = BaseEncoding::encode(ISSUER_SECRET, Base::Base64Url); + let mut jwk = Jwk::from_params(params); + jwk.set_kid("key1"); + + jwk +} + +#[async_trait] +impl JwsSigner for TestSigner { + type Error = josekit::JoseError; + async fn sign(&self, header: &JsonObject, payload: &JsonObject) -> std::result::Result, Self::Error> { + let signer = HS256.signer_from_bytes(ISSUER_SECRET)?; + let header = JwsHeader::from_map(header.clone())?; + let payload = JwtPayload::from_map(payload.clone())?; + let jws = jwt::encode_with_signer(&payload, &header, &signer)?; + + Ok(jws.into_bytes()) + } +} + +#[derive(Default, Debug, Clone)] +pub(crate) struct TestResolver(HashMap>); + +impl TestResolver { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn insert_resource(&mut self, id: K, value: V) + where + K: ToString, + V: Serialize, + { + let value = serde_json::to_vec(&value).unwrap(); + self.0.insert(id.to_string(), value); + } +} + +#[async_trait] +impl Resolver> for TestResolver +where + I: ToString + Sync, +{ + async fn resolve(&self, id: &I) -> Result, resolver::Error> { + let id = id.to_string(); + self.0.get(&id).cloned().ok_or_else(|| resolver::Error::NotFound(id)) + } +} + +#[async_trait] +impl Resolver for TestResolver +where + I: ToString + Sync, +{ + async fn resolve(&self, id: &I) -> Result { + let id = id.to_string(); + self + .0 + .get(&id) + .ok_or_else(|| resolver::Error::NotFound(id)) + .and_then(|bytes| serde_json::from_slice(bytes).map_err(|e| resolver::Error::ParsingFailure(e.into()))) + } +} + +pub(crate) struct TestJwsVerifier; + +impl JwsVerifier for TestJwsVerifier { + fn verify( + &self, + input: identity_verification::jws::VerificationInput, + public_key: &Jwk, + ) -> Result<(), identity_verification::jws::SignatureVerificationError> { + let key = serde_json::to_value(public_key.clone()) + .and_then(serde_json::from_value) + .unwrap(); + let verifier = HS256.verifier_from_jwk(&key).unwrap(); + verifier.verify(&input.signing_input, &input.decoded_signature).unwrap(); + + Ok(()) + } +} diff --git a/identity_credential/src/sd_jwt_vc/tests/validation.rs b/identity_credential/src/sd_jwt_vc/tests/validation.rs new file mode 100644 index 0000000000..bc17b33952 --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/tests/validation.rs @@ -0,0 +1,172 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use identity_core::common::Timestamp; +use identity_core::common::Url; +use identity_verification::jwk::JwkSet; +use sd_jwt_payload_rework::Sha256Hasher; +use serde_json::json; + +use crate::sd_jwt_vc::metadata::IssuerMetadata; +use crate::sd_jwt_vc::metadata::Jwks; +use crate::sd_jwt_vc::metadata::TypeMetadata; +use crate::sd_jwt_vc::tests::TestJwsVerifier; +use crate::sd_jwt_vc::Error; +use crate::sd_jwt_vc::SdJwtVcBuilder; + +use super::TestResolver; +use super::TestSigner; + +fn issuer_metadata() -> IssuerMetadata { + let mut jwk_set = JwkSet::new(); + jwk_set.add(super::signer_secret_jwk()); + + IssuerMetadata { + issuer: "https://example.com".parse().unwrap(), + jwks: Jwks::Object(jwk_set), + } +} + +fn test_resolver() -> TestResolver { + let mut test_resolver = TestResolver::new(); + test_resolver.insert_resource("https://example.com/.well-known/jwt-vc-issuer/", issuer_metadata()); + test_resolver.insert_resource( + "https://example.com/.well-known/vct/education_credential", + vc_metadata(), + ); + + test_resolver +} + +#[tokio::test] +async fn validation_of_valid_token_works() -> anyhow::Result<()> { + let sd_jwt_credential = SdJwtVcBuilder::new(json!({ + "name": "John Doe", + "address": { + "street_address": "A random street", + "number": "3a" + }, + "degree": [] + }))? + .header(std::iter::once(("kid".to_string(), serde_json::Value::String("key1".to_string()))).collect()) + .vct("https://example.com/education_credential".parse::()?) + .iat(Timestamp::now_utc()) + .iss("https://example.com".parse()?) + .make_concealable("/address/street_address")? + .make_concealable("/address")? + .finish(&TestSigner, "HS256") + .await?; + + let resolver = test_resolver(); + sd_jwt_credential + .validate(&resolver, &TestJwsVerifier, &Sha256Hasher::new()) + .await?; + Ok(()) +} + +#[tokio::test] +async fn validation_of_invalid_token_fails() -> anyhow::Result<()> { + let sd_jwt_credential = SdJwtVcBuilder::new(json!({ + "name": "John Doe", + "address": { + "street_address": "A random street", + "number": "3a" + }, + "degree": [] + }))? + .header(std::iter::once(("kid".to_string(), serde_json::Value::String("invalid_key".to_string()))).collect()) + .vct("https://example.com/education_credential".parse::()?) + .iat(Timestamp::now_utc()) + .iss("https://example.com".parse()?) + .make_concealable("/address/street_address")? + .make_concealable("/address")? + .finish(&TestSigner, "HS256") + .await?; + + let resolver = test_resolver(); + let error = sd_jwt_credential + .validate(&resolver, &TestJwsVerifier, &Sha256Hasher::new()) + .await + .unwrap_err(); + assert!(matches!(error, Error::Verification(_))); + + Ok(()) +} + +fn vc_metadata() -> TypeMetadata { + serde_json::from_str( + r#"{ + "vct": "https://example.com/education_credential", + "name": "Betelgeuse Education Credential - Preliminary Version", + "description": "This is our development version of the education credential. Don't panic.", + "claims": [ + { + "path": ["name"], + "display": [ + { + "lang": "de-DE", + "label": "Vor- und Nachname", + "description": "Der Name des Studenten" + }, + { + "lang": "en-US", + "label": "Name", + "description": "The name of the student" + } + ], + "sd": "allowed" + }, + { + "path": ["address"], + "display": [ + { + "lang": "de-DE", + "label": "Adresse", + "description": "Adresse zum Zeitpunkt des Abschlusses" + }, + { + "lang": "en-US", + "label": "Address", + "description": "Address at the time of graduation" + } + ], + "sd": "always" + }, + { + "path": ["address", "street_address"], + "display": [ + { + "lang": "de-DE", + "label": "Straße" + }, + { + "lang": "en-US", + "label": "Street Address" + } + ], + "sd": "always", + "svg_id": "address_street_address" + }, + { + "path": ["degrees", null], + "display": [ + { + "lang": "de-DE", + "label": "Abschluss", + "description": "Der Abschluss des Studenten" + }, + { + "lang": "en-US", + "label": "Degree", + "description": "Degree earned by the student" + } + ], + "sd": "allowed" + } + ], + "schema_url": "https://example.com/credential-schema", + "schema_url#integrity": "sha256-o984vn819a48ui1llkwPmKjZ5t0WRL5ca_xGgX3c1VLmXfh" +}"#, + ) + .unwrap() +} diff --git a/identity_credential/src/sd_jwt_vc/token.rs b/identity_credential/src/sd_jwt_vc/token.rs new file mode 100644 index 0000000000..f2635c7f7a --- /dev/null +++ b/identity_credential/src/sd_jwt_vc/token.rs @@ -0,0 +1,476 @@ +// Copyright 2020-2024 IOTA Stiftung +// SPDX-License-Identifier: Apache-2.0 + +use std::fmt::Display; +use std::ops::Deref; +use std::str::FromStr; + +use super::claims::SdJwtVcClaims; +use super::metadata::ClaimMetadata; +use super::metadata::IssuerMetadata; +use super::metadata::Jwks; +use super::metadata::TypeMetadata; +#[allow(unused_imports)] +use super::metadata::WELL_KNOWN_VCT; +use super::metadata::WELL_KNOWN_VC_ISSUER; +use super::resolver::Error as ResolverErr; +use super::Error; +use super::Resolver; +use super::Result; +use super::SdJwtVcPresentationBuilder; +use crate::validator::JwtCredentialValidator as JwsUtils; +use crate::validator::KeyBindingJWTValidationOptions; +use anyhow::anyhow; +use identity_core::common::StringOrUrl; +use identity_core::common::Timestamp; +use identity_core::common::Url; +use identity_core::convert::ToJson as _; +use identity_verification::jwk::Jwk; +use identity_verification::jwk::JwkSet; +use identity_verification::jws::JwsVerifier; +use sd_jwt_payload_rework::Hasher; +use sd_jwt_payload_rework::JsonObject; +use sd_jwt_payload_rework::RequiredKeyBinding; +use sd_jwt_payload_rework::SdJwt; +use sd_jwt_payload_rework::SHA_ALG_NAME; +use serde_json::Value; + +/// SD-JWT VC's JOSE header `typ`'s value. +pub const SD_JWT_VC_TYP: &str = "vc+sd-jwt"; + +#[derive(Debug, Clone, PartialEq, Eq)] +/// An SD-JWT carrying a verifiable credential as described in +/// [SD-JWT VC specification](https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-04.html). +pub struct SdJwtVc { + pub(crate) sd_jwt: SdJwt, + pub(crate) parsed_claims: SdJwtVcClaims, +} + +impl Deref for SdJwtVc { + type Target = SdJwt; + fn deref(&self) -> &Self::Target { + &self.sd_jwt + } +} + +impl SdJwtVc { + pub(crate) fn new(sd_jwt: SdJwt, claims: SdJwtVcClaims) -> Self { + Self { + sd_jwt, + parsed_claims: claims, + } + } + + /// Parses a string into an [`SdJwtVc`]. + pub fn parse(s: &str) -> Result { + s.parse() + } + + /// Returns a reference to this [`SdJwtVc`]'s JWT claims. + pub fn claims(&self) -> &SdJwtVcClaims { + &self.parsed_claims + } + + /// Prepares this [`SdJwtVc`] for a presentation, returning an [`SdJwtVcPresentationBuilder`]. + /// ## Errors + /// - [`Error::SdJwt`] 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 { + SdJwtVcPresentationBuilder::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 { + SdJwt::from(self).into_disclosed_object(hasher).map_err(Error::SdJwt) + } + + /// Retrieves this SD-JWT VC's issuer's metadata by querying its default location. + /// ## Notes + /// This method doesn't perform any validation of the retrieved [`IssuerMetadata`] + /// besides its syntactical validity. + /// To check if the retrieved [`IssuerMetadata`] is valid use [`IssuerMetadata::validate`]. + pub async fn issuer_metadata(&self, resolver: &R) -> Result> + where + R: Resolver>, + { + let metadata_url = { + let origin = self.claims().iss.origin().ascii_serialization(); + let path = self.claims().iss.path(); + format!("{origin}{WELL_KNOWN_VC_ISSUER}{path}").parse().unwrap() + }; + match resolver.resolve(&metadata_url).await { + Err(ResolverErr::NotFound(_)) => Ok(None), + Err(e) => Err(Error::Resolution { + input: metadata_url.to_string(), + source: e, + }), + Ok(json_res) => serde_json::from_slice(&json_res) + .map_err(|e| Error::InvalidIssuerMetadata(e.into())) + .map(Some), + } + } + + /// Retrieve this SD-JWT VC credential's type metadata [`TypeMetadata`]. + /// ## Notes + /// `resolver` is fed with whatever value [`SdJwtVc`]'s `vct` might have. + /// If `vct` is a URI with scheme `https`, `resolver` must fetch the [`TypeMetadata`] + /// resource by combining `vct`'s value with [`WELL_KNOWN_VCT`]. To simplify this process + /// the utility function [`vct_to_url`] is provided. + /// + /// Returns the parsed [`TypeMetadata`] along with the raw [`Resolver`]'s response. + /// The latter can be used to validate the `vct#integrity` claim if present. + pub async fn type_metadata(&self, resolver: &R) -> Result<(TypeMetadata, Vec)> + where + R: Resolver>, + { + let vct = match self.claims().vct.clone() { + StringOrUrl::Url(url) => StringOrUrl::Url(vct_to_url(&url).unwrap_or(url)), + s => s, + }; + let raw = resolver.resolve(&vct).await.map_err(|e| Error::Resolution { + input: vct.to_string(), + source: e, + })?; + let metadata = serde_json::from_slice(&raw).map_err(|e| Error::InvalidTypeMetadata(e.into()))?; + + Ok((metadata, raw)) + } + + /// Resolves the issuer's public key in JWK format. + /// The issuer's JWK is first fetched through the issuer's metadata, + /// if this attempt fails `resolver` is used to query the key directly + /// through `kid`'s value. + pub async fn issuer_jwk(&self, resolver: &R) -> Result + where + R: Resolver>, + { + let kid = self + .header() + .get("kid") + .and_then(|value| value.as_str()) + .ok_or_else(|| Error::Verification(anyhow!("missing header claim `kid`")))?; + + // Try to find the key among issuer metadata jwk set. + if let jwk @ Ok(_) = self.issuer_jwk_from_iss_metadata(resolver, kid).await { + jwk + } else { + // Issuer has no metadata that can lead to its JWK. Let's see if it can be resolved directly. + let jwk_uri = kid.parse::().map_err(|e| Error::Verification(e.into()))?; + resolver + .resolve(&jwk_uri) + .await + .map_err(|e| Error::Resolution { + input: jwk_uri.to_string(), + source: e, + }) + .and_then(|bytes| { + serde_json::from_slice(&bytes).map_err(|e| Error::Verification(anyhow!("invalid JWK: {}", e))) + }) + } + } + + async fn issuer_jwk_from_iss_metadata(&self, resolver: &R, kid: &str) -> Result + where + R: Resolver>, + { + let metadata = self + .issuer_metadata(resolver) + .await? + .ok_or_else(|| Error::Verification(anyhow!("missing issuer metadata")))?; + metadata.validate(self)?; + + let jwks = match metadata.jwks { + Jwks::Object(jwks) => jwks, + Jwks::Uri(jwks_uri) => resolver + .resolve(&jwks_uri) + .await + .map_err(|e| Error::Resolution { + input: jwks_uri.into_string(), + source: e, + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).map_err(|e| Error::Verification(e.into())))?, + }; + jwks + .iter() + .find(|jwk| jwk.kid() == Some(kid)) + .cloned() + .ok_or_else(|| Error::Verification(anyhow!("missing key \"{kid}\" in issuer JWK set"))) + } + + /// Verifies this [`SdJwtVc`] JWT's signature. + pub fn verify_signature(&self, jws_verifier: &V, jwk: &Jwk) -> Result<()> + where + V: JwsVerifier, + { + let sd_jwt_str = self.sd_jwt.to_string(); + let jws_input = { + let jwt_str = sd_jwt_str.split_once('~').unwrap().0; + JwsUtils::::decode(jwt_str).map_err(|e| Error::Verification(e.into()))? + }; + + JwsUtils::::verify_signature_raw(jws_input, jwk, jws_verifier) + .map_err(|e| Error::Verification(e.into())) + .and(Ok(())) + } + + /// Checks the disclosability of this [`SdJwtVc`]'s claims against a list of [`ClaimMetadata`]. + /// ## Notes + /// This check should be performed by the token's holder in order to assert the issuer's compliance with + /// the credential's type. + pub fn validate_claims_disclosability(&self, claims_metadata: &[ClaimMetadata]) -> Result<()> { + let claims = Value::Object(self.parsed_claims.sd_jwt_claims.deref().clone()); + claims_metadata + .iter() + .try_fold((), |_, meta| meta.check_value_disclosability(&claims)) + } + + /// Check whether this [`SdJwtVc`] is valid. + /// + /// This method checks: + /// - JWS signature + /// - credential's type + /// - claims' disclosability + pub async fn validate(&self, resolver: &R, jws_verifier: &V, hasher: &dyn Hasher) -> Result<()> + where + R: Resolver>, + R: Resolver>, + R: Resolver, + R: Sync, + V: JwsVerifier, + { + // Signature verification. + // Fetch issuer's JWK. + let jwk = self.issuer_jwk(resolver).await?; + self.verify_signature(jws_verifier, &jwk)?; + + // Credential type. + // Fetch type metadata. Skip integrity check. + let fully_disclosed_token = self.clone().into_disclosed_object(hasher).map(Value::Object)?; + let (type_metadata, _) = self.type_metadata(resolver).await?; + type_metadata + .validate_credential_with_resolver(&fully_disclosed_token, resolver) + .await?; + + // Claims' disclosability. + self.validate_claims_disclosability(type_metadata.claim_metadata())?; + + // + + Ok(()) + } + + /// Verify the signature of this [`SdJwtVc`]'s [sd_jwt_payload_rework::KeyBindingJwt]. + pub fn verify_key_binding(&self, jws_verifier: &V, jwk: &Jwk) -> Result<()> { + let Some(kb_jwt) = self.key_binding_jwt() else { + return Ok(()); + }; + let kb_jwt_str = kb_jwt.to_string(); + let jws_input = JwsUtils::::decode(&kb_jwt_str).map_err(|e| Error::Verification(e.into()))?; + + JwsUtils::::verify_signature_raw(jws_input, jwk, jws_verifier) + .map_err(|e| Error::Verification(e.into())) + .and(Ok(())) + } + + /// Check the validity of this [`SdJwtVc`]'s [sd_jwt_payload_rework::KeyBindingJwt]. + /// # Notes + /// Validation of the required key binding (specified through the `cnf` JWT's claim) + /// is only partially validated - custom and "jwe" requirement are not checked. + pub fn validate_key_binding( + &self, + jws_verifier: &V, + jwk: &Jwk, + hasher: &dyn Hasher, + options: &KeyBindingJWTValidationOptions, + ) -> Result<()> { + self.verify_key_binding(jws_verifier, jwk)?; + + if let Some(requirement) = self.required_key_bind() { + if self.key_binding_jwt().is_none() { + return Err(Error::Validation(anyhow!( + "a key binding was required but none was provided" + ))); + } + match requirement { + RequiredKeyBinding::Jwk(json_jwk) => { + if jwk.to_json_value().unwrap().as_object().unwrap() != json_jwk { + return Err(Error::Validation(anyhow!( + "key used for signing KB-JWT does not match the key required in this SD-JWT" + ))); + } + } + RequiredKeyBinding::Kid(kid) | RequiredKeyBinding::Jwu { kid, .. } => jwk + .kid() + .filter(|id| id == kid) + .ok_or_else(|| { + Error::Validation(anyhow::anyhow!( + "the provided JWK doesn't have required `kid` \"{kid}\"" + )) + }) + .map(|_| ())?, + _ => (), + } + } + + let Some(kb_jwt) = self.key_binding_jwt() else { + return Ok(()); + }; + let KeyBindingJWTValidationOptions { + nonce, + aud, + earliest_issuance_date, + latest_issuance_date, + .. + } = options; + + let issuance_date = + Timestamp::from_unix(kb_jwt.claims().iat).map_err(|_| Error::Validation(anyhow!("invalid `iat` value")))?; + + if let Some(earliest_issuance_date) = earliest_issuance_date { + if issuance_date < *earliest_issuance_date { + return Err(Error::Validation(anyhow!( + "this KB-JWT has been created earlier than `earliest_issuance_date`" + ))); + } + } + + if let Some(latest_issuance_date) = latest_issuance_date { + if issuance_date > *latest_issuance_date { + return Err(Error::Validation(anyhow!( + "this KB-JWT has been created later than `latest_issuance_date`" + ))); + } + } else if issuance_date > Timestamp::now_utc() { + return Err(Error::Validation(anyhow!("this KB-JWT has been created in the future"))); + } + + if let Some(nonce) = nonce { + if nonce != &kb_jwt.claims().nonce { + return Err(Error::Validation(anyhow!("invalid KB-JWT's nonce: expected {nonce}"))); + } + } + + if let Some(aud) = aud { + if aud != &kb_jwt.claims().aud { + return Err(Error::Validation(anyhow!("invalid KB-JWT's `aud`: expected \"{aud}\""))); + } + } + + // Validate SD-JWT digest. + if self.claims()._sd_alg.as_deref().unwrap_or(SHA_ALG_NAME) != hasher.alg_name() { + return Err(Error::Validation(anyhow!("invalid hasher"))); + } + let encoded_sd_jwt = self.to_string(); + let digest = { + let last_tilde_idx = encoded_sd_jwt.rfind('~').expect("SD-JWT has a '~'"); + let sd_jwt_no_kb = &encoded_sd_jwt[..=last_tilde_idx]; + + hasher.encoded_digest(sd_jwt_no_kb) + }; + if kb_jwt.claims().sd_hash != digest { + return Err(Error::Validation(anyhow!("invalid KB-JWT's `sd_hash`"))); + } + + Ok(()) + } +} + +/// Converts `vct` claim's URI value into the appropriate well-known URL. +/// ## Warnings +/// Returns an [`Option::None`] if the URI's scheme is not `https`. +pub fn vct_to_url(resource: &Url) -> Option { + if resource.scheme() != "https" { + None + } else { + let origin = resource.origin().ascii_serialization(); + let path = resource.path(); + Some(format!("{origin}{WELL_KNOWN_VCT}{path}").parse().unwrap()) + } +} + +impl TryFrom for SdJwtVc { + type Error = Error; + fn try_from(mut sd_jwt: SdJwt) -> std::result::Result { + // Validate claims. + let claims = { + let claims = std::mem::take(sd_jwt.claims_mut()); + SdJwtVcClaims::try_from_sd_jwt_claims(claims, sd_jwt.disclosures())? + }; + + // Validate Header's typ. + let typ = sd_jwt + .header() + .get("typ") + .and_then(Value::as_str) + .ok_or_else(|| Error::InvalidJoseType("null".to_string()))?; + if !typ.contains(SD_JWT_VC_TYP) { + return Err(Error::InvalidJoseType(typ.to_string())); + } + + Ok(Self { + sd_jwt, + parsed_claims: claims, + }) + } +} + +impl FromStr for SdJwtVc { + type Err = Error; + fn from_str(s: &str) -> std::result::Result { + s.parse::().map_err(Error::SdJwt).and_then(TryInto::try_into) + } +} + +impl Display for SdJwtVc { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.sd_jwt) + } +} + +impl From for SdJwt { + fn from(value: SdJwtVc) -> Self { + let SdJwtVc { + mut sd_jwt, + parsed_claims, + } = value; + // Put back `parsed_claims`. + *sd_jwt.claims_mut() = parsed_claims.into(); + + sd_jwt + } +} + +#[cfg(test)] +mod tests { + use std::sync::LazyLock; + + use identity_core::common::StringOrUrl; + use identity_core::common::Url; + + use super::*; + + const EXAMPLE_SD_JWT_VC: &str = "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogInZjK3NkLWp3dCJ9.eyJfc2QiOiBbIjBIWm1uU0lQejMzN2tTV2U3QzM0bC0tODhnekppLWVCSjJWel9ISndBVGciLCAiOVpicGxDN1RkRVc3cWFsNkJCWmxNdHFKZG1lRU9pWGV2ZEpsb1hWSmRSUSIsICJJMDBmY0ZVb0RYQ3VjcDV5eTJ1anFQc3NEVkdhV05pVWxpTnpfYXdEMGdjIiwgIklFQllTSkdOaFhJbHJRbzU4eWtYbTJaeDN5bGw5WmxUdFRvUG8xN1FRaVkiLCAiTGFpNklVNmQ3R1FhZ1hSN0F2R1RyblhnU2xkM3o4RUlnX2Z2M2ZPWjFXZyIsICJodkRYaHdtR2NKUXNCQ0EyT3RqdUxBY3dBTXBEc2FVMG5rb3ZjS09xV05FIiwgImlrdXVyOFE0azhxM1ZjeUE3ZEMtbU5qWkJrUmVEVFUtQ0c0bmlURTdPVFUiLCAicXZ6TkxqMnZoOW80U0VYT2ZNaVlEdXZUeWtkc1dDTmcwd1RkbHIwQUVJTSIsICJ3elcxNWJoQ2t2a3N4VnZ1SjhSRjN4aThpNjRsbjFqb183NkJDMm9hMXVnIiwgInpPZUJYaHh2SVM0WnptUWNMbHhLdUVBT0dHQnlqT3FhMXoySW9WeF9ZRFEiXSwgImlzcyI6ICJodHRwczovL2V4YW1wbGUuY29tL2lzc3VlciIsICJpYXQiOiAxNjgzMDAwMDAwLCAiZXhwIjogMTg4MzAwMDAwMCwgInZjdCI6ICJodHRwczovL2JtaS5idW5kLmV4YW1wbGUvY3JlZGVudGlhbC9waWQvMS4wIiwgImFnZV9lcXVhbF9vcl9vdmVyIjogeyJfc2QiOiBbIkZjOElfMDdMT2NnUHdyREpLUXlJR085N3dWc09wbE1Makh2UkM0UjQtV2ciLCAiWEx0TGphZFVXYzl6Tl85aE1KUm9xeTQ2VXNDS2IxSXNoWnV1cVVGS1NDQSIsICJhb0NDenNDN3A0cWhaSUFoX2lkUkNTQ2E2NDF1eWNuYzh6UGZOV3o4bngwIiwgImYxLVAwQTJkS1dhdnYxdUZuTVgyQTctRVh4dmhveHY1YUhodUVJTi1XNjQiLCAiazVoeTJyMDE4dnJzSmpvLVZqZDZnNnl0N0Fhb25Lb25uaXVKOXplbDNqbyIsICJxcDdaX0t5MVlpcDBzWWdETzN6VnVnMk1GdVBOakh4a3NCRG5KWjRhSS1jIl19LCAiX3NkX2FsZyI6ICJzaGEtMjU2IiwgImNuZiI6IHsiandrIjogeyJrdHkiOiAiRUMiLCAiY3J2IjogIlAtMjU2IiwgIngiOiAiVENBRVIxOVp2dTNPSEY0ajRXNHZmU1ZvSElQMUlMaWxEbHM3dkNlR2VtYyIsICJ5IjogIlp4amlXV2JaTVFHSFZXS1ZRNGhiU0lpcnNWZnVlY0NFNnQ0alQ5RjJIWlEifX19.CaXec2NNooWAy4eTxYbGWI--UeUL0jpC7Zb84PP_09Z655BYcXUTvfj6GPk4mrNqZUU5GT6QntYR8J9rvcBjvA~WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwgIm5hdGlvbmFsaXRpZXMiLCBbIkRFIl1d~WyJNMEpiNTd0NDF1YnJrU3V5ckRUM3hBIiwgIjE4IiwgdHJ1ZV0~eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImtiK2p3dCJ9.eyJub25jZSI6ICIxMjM0NTY3ODkwIiwgImF1ZCI6ICJodHRwczovL2V4YW1wbGUuY29tL3ZlcmlmaWVyIiwgImlhdCI6IDE3MjA0NTQyOTUsICJzZF9oYXNoIjogIlZFejN0bEtqOVY0UzU3TTZoRWhvVjRIc19SdmpXZWgzVHN1OTFDbmxuZUkifQ.GqtiTKNe3O95GLpdxFK_2FZULFk6KUscFe7RPk8OeVLiJiHsGvtPyq89e_grBplvGmnDGHoy8JAt1wQqiwktSg"; + static EXAMPLE_ISSUER: LazyLock = LazyLock::new(|| "https://example.com/issuer".parse().unwrap()); + static EXAMPLE_VCT: LazyLock = LazyLock::new(|| { + "https://bmi.bund.example/credential/pid/1.0" + .parse::() + .unwrap() + .into() + }); + + #[test] + fn simple_sd_jwt_is_not_a_valid_sd_jwt_vc() { + let sd_jwt: SdJwt = "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBbIkM5aW5wNllvUmFFWFI0Mjd6WUpQN1FyazFXSF84YmR3T0FfWVVyVW5HUVUiLCAiS3VldDF5QWEwSElRdlluT1ZkNTloY1ZpTzlVZzZKMmtTZnFZUkJlb3d2RSIsICJNTWxkT0ZGekIyZDB1bWxtcFRJYUdlcmhXZFVfUHBZZkx2S2hoX2ZfOWFZIiwgIlg2WkFZT0lJMnZQTjQwVjd4RXhad1Z3ejd5Um1MTmNWd3Q1REw4Ukx2NGciLCAiWTM0em1JbzBRTExPdGRNcFhHd2pCZ0x2cjE3eUVoaFlUMEZHb2ZSLWFJRSIsICJmeUdwMFdUd3dQdjJKRFFsbjFsU2lhZW9iWnNNV0ExMGJRNTk4OS05RFRzIiwgIm9tbUZBaWNWVDhMR0hDQjB1eXd4N2ZZdW8zTUhZS08xNWN6LVJaRVlNNVEiLCAiczBCS1lzTFd4UVFlVTh0VmxsdE03TUtzSVJUckVJYTFQa0ptcXhCQmY1VSJdLCAiaXNzIjogImh0dHBzOi8vaXNzdWVyLmV4YW1wbGUuY29tIiwgImlhdCI6IDE2ODMwMDAwMDAsICJleHAiOiAxODgzMDAwMDAwLCAiYWRkcmVzcyI6IHsiX3NkIjogWyI2YVVoelloWjdTSjFrVm1hZ1FBTzN1MkVUTjJDQzFhSGhlWnBLbmFGMF9FIiwgIkF6TGxGb2JrSjJ4aWF1cFJFUHlvSnotOS1OU2xkQjZDZ2pyN2ZVeW9IemciLCAiUHp6Y1Z1MHFiTXVCR1NqdWxmZXd6a2VzRDl6dXRPRXhuNUVXTndrclEtayIsICJiMkRrdzBqY0lGOXJHZzhfUEY4WmN2bmNXN3p3Wmo1cnlCV3ZYZnJwemVrIiwgImNQWUpISVo4VnUtZjlDQ3lWdWIyVWZnRWs4anZ2WGV6d0sxcF9KbmVlWFEiLCAiZ2xUM2hyU1U3ZlNXZ3dGNVVEWm1Xd0JUdzMyZ25VbGRJaGk4aEdWQ2FWNCIsICJydkpkNmlxNlQ1ZWptc0JNb0d3dU5YaDlxQUFGQVRBY2k0MG9pZEVlVnNBIiwgInVOSG9XWWhYc1poVkpDTkUyRHF5LXpxdDd0NjlnSkt5NVFhRnY3R3JNWDQiXX0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.gR6rSL7urX79CNEvTQnP1MH5xthG11ucIV44SqKFZ4Pvlu_u16RfvXQd4k4CAIBZNKn2aTI18TfvFwV97gJFoA~WyJHMDJOU3JRZmpGWFE3SW8wOXN5YWpBIiwgInJlZ2lvbiIsICJcdTZlMmZcdTUzM2EiXQ~WyJsa2x4RjVqTVlsR1RQVW92TU5JdkNBIiwgImNvdW50cnkiLCAiSlAiXQ~" + .parse().unwrap(); + let err = SdJwtVc::try_from(sd_jwt).unwrap_err(); + assert!(matches!(err, Error::MissingClaim("vct"))) + } + + #[test] + fn parsing_a_valid_sd_jwt_vc_works() { + let sd_jwt_vc: SdJwtVc = EXAMPLE_SD_JWT_VC.parse().unwrap(); + assert_eq!(sd_jwt_vc.claims().iss, *EXAMPLE_ISSUER); + assert_eq!(sd_jwt_vc.claims().vct, *EXAMPLE_VCT); + } +} diff --git a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs index c099d763ab..acaa991e45 100644 --- a/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs +++ b/identity_credential/src/validator/jwt_credential_validation/jwt_credential_validator.rs @@ -297,7 +297,7 @@ impl JwtCredentialValidator { } /// Verify the signature using the given `public_key` and `signature_verifier`. - fn verify_decoded_signature( + pub(crate) fn verify_decoded_signature( decoded: JwsValidationItem<'_>, public_key: &Jwk, signature_verifier: &S, diff --git a/identity_document/Cargo.toml b/identity_document/Cargo.toml index 4bb50dd09d..cf212716ba 100644 --- a/identity_document/Cargo.toml +++ b/identity_document/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "tangle", "identity", "did"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "Method-agnostic implementation of the Decentralized Identifiers (DID) standard." [dependencies] diff --git a/identity_document/src/document/core_document.rs b/identity_document/src/document/core_document.rs index 2747f7fae6..1e1a340bb4 100644 --- a/identity_document/src/document/core_document.rs +++ b/identity_document/src/document/core_document.rs @@ -690,7 +690,7 @@ impl CoreDocument { &'me self, method_query: Q, scope: Option, - ) -> Option<&VerificationMethod> + ) -> Option<&'me VerificationMethod> where Q: Into>, { @@ -773,7 +773,7 @@ impl CoreDocument { /// Returns the first [`Service`] with an `id` property matching the provided `service_query`, if present. // NOTE: This method demonstrates unexpected behavior in the edge cases where the document contains // services whose ids are of the form #. - pub fn resolve_service<'query, 'me, Q>(&'me self, service_query: Q) -> Option<&Service> + pub fn resolve_service<'query, 'me, Q>(&'me self, service_query: Q) -> Option<&'me Service> where Q: Into>, { diff --git a/identity_document/src/utils/did_url_query.rs b/identity_document/src/utils/did_url_query.rs index 1af2b80b4c..d9399457e3 100644 --- a/identity_document/src/utils/did_url_query.rs +++ b/identity_document/src/utils/did_url_query.rs @@ -13,7 +13,7 @@ use identity_did::DID; #[repr(transparent)] pub struct DIDUrlQuery<'query>(Cow<'query, str>); -impl<'query> DIDUrlQuery<'query> { +impl DIDUrlQuery<'_> { /// Returns whether this query matches the given DIDUrl. pub(crate) fn matches(&self, did_url: &DIDUrl) -> bool { // Ensure the DID matches if included in the query. @@ -81,7 +81,7 @@ impl<'query> From<&'query DIDUrl> for DIDUrlQuery<'query> { } } -impl<'query> From for DIDUrlQuery<'query> { +impl From for DIDUrlQuery<'_> { fn from(other: DIDUrl) -> Self { Self(Cow::Owned(other.to_string())) } diff --git a/identity_ecdsa_verifier/Cargo.toml b/identity_ecdsa_verifier/Cargo.toml index 6829d41ae0..6c7e70a954 100644 --- a/identity_ecdsa_verifier/Cargo.toml +++ b/identity_ecdsa_verifier/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "identity", "jose", "jwk", "jws"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "JWS ECDSA signature verification for IOTA Identity" [lints] diff --git a/identity_eddsa_verifier/Cargo.toml b/identity_eddsa_verifier/Cargo.toml index b7da49295a..745f9b6b0d 100644 --- a/identity_eddsa_verifier/Cargo.toml +++ b/identity_eddsa_verifier/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "identity", "jose", "jwk", "jws"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "JWS EdDSA signature verification for IOTA Identity" [dependencies] diff --git a/identity_iota/Cargo.toml b/identity_iota/Cargo.toml index 67933e0634..cdddbcebc9 100644 --- a/identity_iota/Cargo.toml +++ b/identity_iota/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "tangle", "identity", "did", "ssi"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "Framework for Self-Sovereign Identity with IOTA DID." [dependencies] @@ -34,13 +33,12 @@ default = ["revocation-bitmap", "client", "iota-client", "resolver"] client = ["identity_iota_core/client"] # Enables the iota-client integration, the client trait implementations for it, and the `IotaClientExt` trait. -iota-client = ["identity_iota_core/iota-client", "identity_resolver?/iota"] +iota-client = ["identity_iota_core/iota-client", "identity_resolver/iota"] # Enables revocation with `RevocationBitmap2022`. revocation-bitmap = [ "identity_credential/revocation-bitmap", "identity_iota_core/revocation-bitmap", - "identity_resolver?/revocation-bitmap", ] # Enables revocation with `StatusList2021`. diff --git a/identity_iota/src/lib.rs b/identity_iota/src/lib.rs index 9ab2e53805..0a16a7e819 100644 --- a/identity_iota/src/lib.rs +++ b/identity_iota/src/lib.rs @@ -94,7 +94,6 @@ pub mod prelude { #[cfg_attr(docsrs, doc(cfg(feature = "resolver")))] pub mod resolver { //! DID resolution utilities - pub use identity_resolver::*; } diff --git a/identity_iota_core/Cargo.toml b/identity_iota_core/Cargo.toml index 73dcc4190e..0303e351a3 100644 --- a/identity_iota_core/Cargo.toml +++ b/identity_iota_core/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "tangle", "utxo", "shimmer", "identity"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "An IOTA Ledger integration for the IOTA DID Method." [dependencies] diff --git a/identity_iota_core/src/document/iota_document.rs b/identity_iota_core/src/document/iota_document.rs index bd3404045c..5c0813f28c 100644 --- a/identity_iota_core/src/document/iota_document.rs +++ b/identity_iota_core/src/document/iota_document.rs @@ -332,7 +332,7 @@ impl IotaDocument { /// Returns the first [`Service`] with an `id` property matching the provided `service_query`, if present. // NOTE: This method demonstrates unexpected behaviour in the edge cases where the document contains // services whose ids are of the form #. - pub fn resolve_service<'query, 'me, Q>(&'me self, service_query: Q) -> Option<&Service> + pub fn resolve_service<'query, 'me, Q>(&'me self, service_query: Q) -> Option<&'me Service> where Q: Into>, { @@ -347,7 +347,7 @@ impl IotaDocument { &'me self, method_query: Q, scope: Option, - ) -> Option<&VerificationMethod> + ) -> Option<&'me VerificationMethod> where Q: Into>, { diff --git a/identity_jose/Cargo.toml b/identity_jose/Cargo.toml index 73a7fa3cdb..e5449c30a6 100644 --- a/identity_jose/Cargo.toml +++ b/identity_jose/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "identity", "jose", "jwk", "jws"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "A library for JOSE (JSON Object Signing and Encryption)" [dependencies] diff --git a/identity_jose/src/jws/decoder.rs b/identity_jose/src/jws/decoder.rs index 6b93488acf..c1635c86d3 100644 --- a/identity_jose/src/jws/decoder.rs +++ b/identity_jose/src/jws/decoder.rs @@ -322,7 +322,7 @@ pub struct JwsValidationIter<'decoder, 'payload, 'signatures> { payload: &'payload [u8], } -impl<'decoder, 'payload, 'signatures> Iterator for JwsValidationIter<'decoder, 'payload, 'signatures> { +impl<'payload> Iterator for JwsValidationIter<'_, 'payload, '_> { type Item = Result>; fn next(&mut self) -> Option { diff --git a/identity_jose/src/jws/encoding/utils.rs b/identity_jose/src/jws/encoding/utils.rs index b1d903e612..2be2703488 100644 --- a/identity_jose/src/jws/encoding/utils.rs +++ b/identity_jose/src/jws/encoding/utils.rs @@ -86,7 +86,7 @@ pub(super) struct Flatten<'payload, 'unprotected> { pub(super) signature: JwsSignature<'unprotected>, } -impl<'payload, 'unprotected> Flatten<'payload, 'unprotected> { +impl Flatten<'_, '_> { pub(super) fn to_json(&self) -> Result { serde_json::to_string(&self).map_err(Error::InvalidJson) } @@ -99,7 +99,7 @@ pub(super) struct General<'payload, 'unprotected> { pub(super) signatures: Vec>, } -impl<'payload, 'unprotected> General<'payload, 'unprotected> { +impl General<'_, '_> { pub(super) fn to_json(&self) -> Result { serde_json::to_string(&self).map_err(Error::InvalidJson) } diff --git a/identity_jose/src/jws/recipient.rs b/identity_jose/src/jws/recipient.rs index 602f1e6f3f..96dd410fa0 100644 --- a/identity_jose/src/jws/recipient.rs +++ b/identity_jose/src/jws/recipient.rs @@ -15,7 +15,7 @@ pub struct Recipient<'a> { pub unprotected: Option<&'a JwsHeader>, } -impl<'a> Default for Recipient<'a> { +impl Default for Recipient<'_> { fn default() -> Self { Self::new() } diff --git a/identity_resolver/Cargo.toml b/identity_resolver/Cargo.toml index d99158835d..fd8ffd7a0b 100644 --- a/identity_resolver/Cargo.toml +++ b/identity_resolver/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "did", "identity", "resolver", "resolution"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "DID Resolution utilities for the identity.rs library." [dependencies] diff --git a/identity_storage/Cargo.toml b/identity_storage/Cargo.toml index 5331dc725f..2e07548630 100644 --- a/identity_storage/Cargo.toml +++ b/identity_storage/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "storage", "identity", "kms"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "Abstractions over storage for cryptographic keys used in DID Documents" [dependencies] diff --git a/identity_stronghold/Cargo.toml b/identity_stronghold/Cargo.toml index b7c61a998f..693dfa271e 100644 --- a/identity_stronghold/Cargo.toml +++ b/identity_stronghold/Cargo.toml @@ -8,7 +8,6 @@ keywords = ["iota", "storage", "identity", "kms", "stronghold"] license.workspace = true readme = "./README.md" repository.workspace = true -rust-version.workspace = true description = "Secure JWK storage with Stronghold for IOTA Identity" [dependencies] diff --git a/identity_verification/Cargo.toml b/identity_verification/Cargo.toml index 46fcc5ac24..9c122cec93 100644 --- a/identity_verification/Cargo.toml +++ b/identity_verification/Cargo.toml @@ -6,7 +6,6 @@ edition.workspace = true homepage.workspace = true license.workspace = true repository.workspace = true -rust-version.workspace = true description = "Verification data types and functionality for identity.rs" [dependencies]