From 8f5b573886e8006b09594db9f73b918498b2422c Mon Sep 17 00:00:00 2001 From: Jacob Date: Thu, 1 Aug 2024 14:16:16 +0100 Subject: [PATCH] Support for draft 20 and refactor API Broad refactor of the API and support for draft 20. --------- Co-authored-by: Arjen van Veen --- .github/workflows/ci.yml | 5 +- .gitmodules | 4 +- Cargo.toml | 39 +- src/core/authorization_request/mod.rs | 358 +++++++++++++ src/core/authorization_request/parameters.rs | 496 ++++++++++++++++++ .../authorization_request/verification/did.rs | 75 +++ .../authorization_request/verification/mod.rs | 187 +++++++ .../verification/verifier.rs | 35 ++ .../verification/x509_san.rs | 125 +++++ src/core/credential_format/mod.rs | 17 + src/core/metadata/mod.rs | 157 ++++++ src/core/metadata/parameters/mod.rs | 4 + src/core/metadata/parameters/verifier.rs | 196 +++++++ src/core/metadata/parameters/wallet.rs | 300 +++++++++++ src/core/mod.rs | 6 + src/core/object/mod.rs | 105 ++++ src/core/response/mod.rs | 125 +++++ src/core/response/parameters.rs | 93 ++++ src/core/util/mod.rs | 78 +++ src/lib.rs | 3 + src/presentation_exchange.rs | 159 +++--- src/utils.rs | 13 +- src/verifier/by_reference.rs | 10 + src/verifier/client.rs | 201 +++++++ src/verifier/mod.rs | 223 ++++++++ src/verifier/request_builder.rs | 169 ++++++ src/verifier/request_signer.rs | 50 ++ src/verifier/session.rs | 123 +++++ src/wallet.rs | 88 ++++ tests/e2e.rs | 96 ++++ tests/examples/issuer.jwk | 7 + tests/examples/subject.jwk | 7 + tests/examples/vc.jwt | 1 + tests/examples/verifier.jwk | 7 + tests/jwt_vc.rs | 164 ++++++ {test => tests}/presentation-exchange | 0 36 files changed, 3619 insertions(+), 107 deletions(-) create mode 100644 src/core/authorization_request/mod.rs create mode 100644 src/core/authorization_request/parameters.rs create mode 100644 src/core/authorization_request/verification/did.rs create mode 100644 src/core/authorization_request/verification/mod.rs create mode 100644 src/core/authorization_request/verification/verifier.rs create mode 100644 src/core/authorization_request/verification/x509_san.rs create mode 100644 src/core/credential_format/mod.rs create mode 100644 src/core/metadata/mod.rs create mode 100644 src/core/metadata/parameters/mod.rs create mode 100644 src/core/metadata/parameters/verifier.rs create mode 100644 src/core/metadata/parameters/wallet.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/object/mod.rs create mode 100644 src/core/response/mod.rs create mode 100644 src/core/response/parameters.rs create mode 100644 src/core/util/mod.rs create mode 100644 src/verifier/by_reference.rs create mode 100644 src/verifier/client.rs create mode 100644 src/verifier/mod.rs create mode 100644 src/verifier/request_builder.rs create mode 100644 src/verifier/request_signer.rs create mode 100644 src/verifier/session.rs create mode 100644 src/wallet.rs create mode 100644 tests/e2e.rs create mode 100644 tests/examples/issuer.jwk create mode 100644 tests/examples/subject.jwk create mode 100644 tests/examples/vc.jwt create mode 100644 tests/examples/verifier.jwk create mode 100644 tests/jwt_vc.rs rename {test => tests}/presentation-exchange (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61bb994..b0bb227 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: [push, pull_request] env: CARGO_TERM_COLOR: always + RUSTFLAGS: "-Dwarnings" jobs: build: @@ -18,5 +19,5 @@ jobs: run: cargo test - name: Fmt run: cargo fmt -- --check - # - name: Clippy - # run: RUSTFLAGS="-Dwarnings" cargo clippy + - name: Clippy + run: cargo clippy diff --git a/.gitmodules b/.gitmodules index a775da0..1dd6abe 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "test/presentation-exchange"] - path = test/presentation-exchange +[submodule "presentation-exchange"] + path = tests/presentation-exchange url = https://github.com/decentralized-identity/presentation-exchange diff --git a/Cargo.toml b/Cargo.toml index 57687ad..e1b1a51 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,42 @@ [package] -name = "oidc4vp" +name = "oid4vp" version = "0.1.0" edition = "2021" authors = ["Spruce Systems, Inc."] license = "MIT OR Apache-2.0" description = "OpenID Connect for Verifiable Presentations" repository = "https://github.com/spruceid/oidc4vp-rs/" -documentation = "https://docs.rs/oidc4vp/" +documentation = "https://docs.rs/oid4vp/" + +[features] +reqwest = ["dep:reqwest"] +p256 = ["dep:p256"] [dependencies] -# openidconnect = { version = "2.4.0", default-features = false } -jsonpath-rust = "0.2.0" -serde = { version = "1.0.147", features = ["derive"] } -serde_json = "1.0.87" -ssi = { version = "0.6.0", default-features = false } -thiserror = "1.0.37" -# jsonschema = { version = "0.16.1", default-features = false } -lazy_static = "1.4.0" -# schemars = { version = "0.8.11", default-features = false } +anyhow = "1.0.75" +async-trait = "0.1.73" +base64 = "0.21.4" +did-web = "0.2.2" +http = "1.1.0" +p256 = { version = "0.13.2", features = ["jwk"], optional = true } +reqwest = { version = "0.12.5", features = ["rustls-tls"], optional = true } +serde = "1.0.188" +serde_cbor = "0.11.2" +serde_json = "1.0.107" +serde_qs = "0.12.0" +serde_urlencoded = "0.7.1" +ssi = "0.7.0" +thiserror = "1.0.49" +tokio = "1.32.0" +tracing = "0.1.37" +url = { version = "2.4.1", features = ["serde"] } +x509-cert = "0.2.4" [dev-dependencies] serde_path_to_error = "0.1.8" - +tokio = { version = "1.32.0", features = ["macros"] } +did-method-key = "0.2" +oid4vp = { path = ".", features = ["p256"] } [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { version = "1.2", features = ["v4", "serde", "js"] } diff --git a/src/core/authorization_request/mod.rs b/src/core/authorization_request/mod.rs new file mode 100644 index 0000000..53d2850 --- /dev/null +++ b/src/core/authorization_request/mod.rs @@ -0,0 +1,358 @@ +use std::ops::{Deref, DerefMut}; + +use anyhow::{anyhow, bail, Context, Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use url::Url; + +use crate::wallet::Wallet; + +use self::{ + parameters::{ + ClientId, ClientIdScheme, Nonce, PresentationDefinition, PresentationDefinitionUri, + RedirectUri, ResponseMode, ResponseType, ResponseUri, + }, + verification::verify_request, +}; + +use super::{ + object::{ParsingErrorContext, UntypedObject}, + util::{base_request, AsyncHttpClient}, +}; + +pub mod parameters; +pub mod verification; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "UntypedObject", into = "UntypedObject")] +pub struct AuthorizationRequestObject( + UntypedObject, + ClientId, + ClientIdScheme, + ResponseMode, + ResponseType, + PresentationDefinitionIndirection, + Url, + Nonce, +); + +/// An Authorization Request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthorizationRequest { + pub client_id: String, + #[serde(flatten)] + pub request_indirection: RequestIndirection, +} + +/// A RequestObject, passed by value or by reference. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RequestIndirection { + #[serde(rename = "request")] + ByValue(String), + #[serde(rename = "request_uri")] + ByReference(Url), +} + +/// A PresentationDefinition, passed by value or by reference +#[derive(Debug, Clone)] +pub enum PresentationDefinitionIndirection { + ByValue(PresentationDefinition), + ByReference(Url), +} + +impl AuthorizationRequest { + /// Validate the [AuthorizationRequest] according to the client_id scheme and return the parsed + /// [RequestObject]. + /// + /// Custom wallet metadata can be provided, otherwise the default metadata for this profile is used. + pub async fn validate( + self, + wallet: &W, + ) -> Result { + let jwt = match self.request_indirection { + RequestIndirection::ByValue(jwt) => jwt, + RequestIndirection::ByReference(url) => { + let request = base_request() + .method("GET") + .uri(url.to_string()) + .body(vec![]) + .context("failed to build authorization request request")?; + + let response = wallet + .http_client() + .execute(request) + .await + .context(format!( + "failed to make authorization request request at {url}" + ))?; + + let status = response.status(); + let Ok(body) = String::from_utf8(response.into_body()) else { + bail!("failed to parse authorization request response as UTF-8 from {url} (status: {status})") + }; + + if !status.is_success() { + bail!( + "authorization request request was unsuccessful (status: {status}): {body}" + ) + } + + body + } + }; + let aro = verify_request(wallet, jwt) + .await + .context("unable to validate Authorization Request")?; + if self.client_id.as_str() != aro.client_id().0.as_str() { + bail!( + "Authorization Request and Request Object have different client ids: '{}' vs. '{}'", + self.client_id, + aro.client_id().0 + ); + } + Ok(aro) + } + + /// Encode as [Url], using the `authorization_endpoint` as a base. + /// ``` + /// # use oid4vp::core::authorization_request::AuthorizationRequest; + /// # use oid4vp::core::authorization_request::RequestIndirection; + /// # use url::Url; + /// let authorization_endpoint: Url = "example://".parse().unwrap(); + /// let authorization_request = AuthorizationRequest { + /// client_id: "xyz".to_string(), + /// request_indirection: RequestIndirection::ByValue("test".to_string()), + /// }; + /// + /// let authorization_request_url = authorization_request.to_url(authorization_endpoint).unwrap(); + /// + /// assert_eq!(authorization_request_url.as_str(), "example://?client_id=xyz&request=test"); + /// ``` + pub fn to_url(self, mut authorization_endpoint: Url) -> Result { + let query = serde_urlencoded::to_string(self)?; + authorization_endpoint.set_query(Some(&query)); + Ok(authorization_endpoint) + } + + /// Parse from [Url], validating the authorization_endpoint. + /// ``` + /// # use oid4vp::core::authorization_request::AuthorizationRequest; + /// # use oid4vp::core::authorization_request::RequestIndirection; + /// # use url::Url; + /// let url: Url = "example://?client_id=xyz&request=test".parse().unwrap(); + /// let authorization_endpoint: Url = "example://".parse().unwrap(); + /// + /// let authorization_request = AuthorizationRequest::from_url( + /// url, + /// &authorization_endpoint + /// ).unwrap(); + /// + /// assert_eq!(authorization_request.client_id, "xyz"); + /// + /// let RequestIndirection::ByValue(request_object) = + /// authorization_request.request_indirection + /// else { + /// panic!("expected request-by-value") + /// }; + /// + /// assert_eq!(request_object, "test"); + /// ``` + pub fn from_url(url: Url, authorization_endpoint: &Url) -> Result { + let query = url + .query() + .ok_or(anyhow!("missing query params in Authorization Request uri"))? + .to_string(); + let fnd = url.authority(); + let exp = authorization_endpoint.authority(); + if fnd != exp { + bail!("unexpected authorization_endpoint authority, expected '{exp}', received '{fnd}'") + } + let fnd = url.path(); + let exp = authorization_endpoint.path(); + if fnd != exp { + bail!("unexpected authorization_endpoint path, expected '{exp}', received '{fnd}'") + } + Self::from_query_params(&query) + } + + /// Parse from urlencoded query parameters. + /// ``` + /// # use oid4vp::core::authorization_request::AuthorizationRequest; + /// # use oid4vp::core::authorization_request::RequestIndirection; + /// let query = "client_id=xyz&request=test"; + /// + /// let authorization_request = AuthorizationRequest::from_query_params(query).unwrap(); + /// + /// assert_eq!(authorization_request.client_id, "xyz"); + /// + /// let RequestIndirection::ByValue(request_object) = authorization_request.request_indirection + /// else { panic!("expected request-by-value") }; + /// assert_eq!(request_object, "test"); + /// ``` + pub fn from_query_params(query_params: &str) -> Result { + serde_urlencoded::from_str(query_params) + .context("unable to parse Authorization Request from query params") + } +} + +impl AuthorizationRequestObject { + pub fn client_id(&self) -> &ClientId { + &self.1 + } + + pub fn client_id_scheme(&self) -> &ClientIdScheme { + &self.2 + } + + pub async fn resolve_presentation_definition( + &self, + http_client: &H, + ) -> Result { + match &self.5 { + PresentationDefinitionIndirection::ByValue(by_value) => Ok(by_value.clone()), + PresentationDefinitionIndirection::ByReference(by_reference) => { + let request = base_request() + .method("GET") + .uri(by_reference.to_string()) + .body(vec![]) + .context("failed to build presentation definition request")?; + + let response = http_client.execute(request).await.context(format!( + "failed to make presentation definition request at {by_reference}" + ))?; + + let status = response.status(); + + if !status.is_success() { + bail!("presentation definition request was unsuccessful (status: {status})") + } + + serde_json::from_slice::(response.body()) + .context(format!( + "failed to parse presentation definition response as JSON from {by_reference} (status: {status})" + ))? + .try_into() + .context("failed to parse presentation definition from JSON") + } + } + } + + pub fn is_id_token_requested(&self) -> Option { + match self.4 { + ResponseType::VpToken => Some(false), + ResponseType::VpTokenIdToken => Some(true), + ResponseType::Unsupported(_) => None, + } + } + + pub fn response_mode(&self) -> &ResponseMode { + &self.3 + } + + pub fn response_type(&self) -> &ResponseType { + &self.4 + } + + /// Uri to submit the response at. + /// + /// AKA [ResponseUri] or [RedirectUri] depending on [ResponseMode]. + pub fn return_uri(&self) -> &Url { + &self.6 + } + + pub fn nonce(&self) -> &Nonce { + &self.7 + } +} + +impl From for UntypedObject { + fn from(value: AuthorizationRequestObject) -> Self { + let mut inner = value.0; + inner.insert(value.1); + inner.insert(value.2); + inner + } +} + +impl TryFrom for AuthorizationRequestObject { + type Error = Error; + + fn try_from(value: UntypedObject) -> std::result::Result { + let client_id = value.get().parsing_error()?; + let client_id_scheme = value + .get() + .parsing_error() + .context("this library cannot handle requests that omit client_id_scheme")?; + + let redirect_uri = value.get::(); + let response_uri = value.get::(); + + let (return_uri, response_mode) = match ( + redirect_uri, + response_uri, + value.get_or_default::().parsing_error()?, + ) { + (_, _, ResponseMode::Unsupported(m)) => { + bail!("this 'response_mode' ({m}) is not currently supported") + } + (Some(_), Some(_), _) => { + bail!("'response_uri' and 'redirect_uri' are mutually exclusive") + } + (_, None, response_mode @ ResponseMode::DirectPost) + | (_, None, response_mode @ ResponseMode::DirectPostJwt) => { + bail!("'response_uri' is required for this 'response_mode' ({response_mode})") + } + (_, Some(uri), response_mode @ ResponseMode::DirectPost) + | (_, Some(uri), response_mode @ ResponseMode::DirectPostJwt) => { + (uri.parsing_error()?.0, response_mode) + } + }; + + let response_type: ResponseType = value.get().parsing_error()?; + + let pd_indirection = match ( + value.get::(), + value.get::(), + ) { + (None, None) => bail!( + "one of 'presentation_definition' and 'presentation_definition_uri' are required" + ), + (Some(_), Some(_)) => { + bail!("'presentation_definition' and 'presentation_definition_uri' are mutually exclusive") + } + (Some(by_value), None) => { + PresentationDefinitionIndirection::ByValue(by_value.parsing_error()?) + } + (None, Some(by_reference)) => { + PresentationDefinitionIndirection::ByReference(by_reference.parsing_error()?.0) + } + }; + + let nonce = value.get().parsing_error()?; + + Ok(Self( + value, + client_id, + client_id_scheme, + response_mode, + response_type, + pd_indirection, + return_uri, + nonce, + )) + } +} + +impl Deref for AuthorizationRequestObject { + type Target = UntypedObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for AuthorizationRequestObject { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} diff --git a/src/core/authorization_request/parameters.rs b/src/core/authorization_request/parameters.rs new file mode 100644 index 0000000..80633f2 --- /dev/null +++ b/src/core/authorization_request/parameters.rs @@ -0,0 +1,496 @@ +use std::fmt; + +use crate::core::{ + object::{ParsingErrorContext, TypedParameter, UntypedObject}, + util::{base_request, AsyncHttpClient}, +}; +use anyhow::{bail, Context, Error, Ok}; +use serde::{Deserialize, Serialize}; +use serde_json::Value as Json; +use url::Url; + +use super::AuthorizationRequestObject; + +const DID: &str = "did"; +const ENTITY_ID: &str = "entity_id"; +const PREREGISTERED: &str = "pre-registered"; +const REDIRECT_URI: &str = "redirect_uri"; +const VERIFIER_ATTESTATION: &str = "verifier_attestation"; +const X509_SAN_DNS: &str = "x509_san_dns"; +const X509_SAN_URI: &str = "x509_san_uri"; + +#[derive(Debug, Clone)] +pub struct ClientId(pub String); + +#[derive(Debug, Clone, PartialEq)] +pub enum ClientIdScheme { + Did, + EntityId, + PreRegistered, + RedirectUri, + VerifierAttestation, + X509SanDns, + X509SanUri, + Other(String), +} + +impl TypedParameter for ClientId { + const KEY: &'static str = "client_id"; +} + +impl TryFrom for ClientId { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: ClientId) -> Self { + Json::String(value.0) + } +} + +impl TypedParameter for ClientIdScheme { + const KEY: &'static str = "client_id_scheme"; +} + +impl From for ClientIdScheme { + fn from(s: String) -> Self { + match s.as_str() { + DID => ClientIdScheme::Did, + ENTITY_ID => ClientIdScheme::EntityId, + PREREGISTERED => ClientIdScheme::PreRegistered, + REDIRECT_URI => ClientIdScheme::RedirectUri, + VERIFIER_ATTESTATION => ClientIdScheme::VerifierAttestation, + X509_SAN_DNS => ClientIdScheme::X509SanDns, + X509_SAN_URI => ClientIdScheme::X509SanUri, + _ => ClientIdScheme::Other(s), + } + } +} + +impl TryFrom for ClientIdScheme { + type Error = Error; + + fn try_from(value: Json) -> Result { + serde_json::from_value(value) + .map(String::into) + .map_err(Error::from) + } +} + +impl From for Json { + fn from(value: ClientIdScheme) -> Self { + Json::String(value.to_string()) + } +} + +impl fmt::Display for ClientIdScheme { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ClientIdScheme::Did => DID, + ClientIdScheme::EntityId => ENTITY_ID, + ClientIdScheme::PreRegistered => PREREGISTERED, + ClientIdScheme::RedirectUri => REDIRECT_URI, + ClientIdScheme::VerifierAttestation => VERIFIER_ATTESTATION, + ClientIdScheme::X509SanDns => X509_SAN_DNS, + ClientIdScheme::X509SanUri => X509_SAN_URI, + ClientIdScheme::Other(o) => o, + } + .fmt(f) + } +} + +/// `client_metadata` field in the Authorization Request. +#[derive(Debug, Clone)] +pub struct ClientMetadata(pub UntypedObject); + +impl TypedParameter for ClientMetadata { + const KEY: &'static str = "client_metadata"; +} + +impl From for Json { + fn from(cm: ClientMetadata) -> Self { + cm.0 .0.into() + } +} + +impl TryFrom for ClientMetadata { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(serde_json::from_value(value).map(ClientMetadata)?) + } +} + +impl ClientMetadata { + /// Resolves the client metadata from the Authorization Request Object. + /// + /// If the client metadata is not passed by reference or value if the Authorization Request Object, + /// then this function will return an error. + pub async fn resolve( + request: &AuthorizationRequestObject, + http_client: &H, + ) -> Result { + if let Some(metadata) = request.get() { + return metadata; + } + + if let Some(metadata_uri) = request.get::() { + let uri = metadata_uri.parsing_error()?.0; + let request = base_request() + .method("GET") + .uri(uri.to_string()) + .body(vec![]) + .context("failed to build client metadata request")?; + + let response = http_client + .execute(request) + .await + .context(format!("failed to make client metadata request at {uri}"))?; + + let status = response.status(); + + if !status.is_success() { + bail!("client metadata request was unsuccessful (status: {status})") + } + + return serde_json::from_slice::(response.body()) + .context(format!( + "failed to parse client metadata response as JSON from {uri} (status: {status})" + ))? + .try_into() + .context("failed to parse client metadata from JSON"); + } + + bail!("the client metadata was not passed by reference or value") + } +} + +/// `client_metadata_uri` field in the Authorization Request. +#[derive(Debug, Clone)] +pub struct ClientMetadataUri(pub Url); + +impl TypedParameter for ClientMetadataUri { + const KEY: &'static str = "client_metadata_uri"; +} + +impl From for Json { + fn from(cmu: ClientMetadataUri) -> Self { + cmu.0.to_string().into() + } +} + +impl TryFrom for ClientMetadataUri { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(serde_json::from_value(value).map(ClientMetadataUri)?) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Nonce(pub String); + +impl TypedParameter for Nonce { + const KEY: &'static str = "nonce"; +} + +impl TryFrom for Nonce { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: Nonce) -> Self { + Json::String(value.0) + } +} + +#[derive(Debug, Clone)] +pub struct Audience(pub String); + +impl TypedParameter for Audience { + const KEY: &'static str = "aud"; +} + +impl TryFrom for Audience { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: Audience) -> Json { + Json::String(value.0) + } +} + +/// `redirect_uri` field in the Authorization Request. +#[derive(Debug, Clone)] +pub struct RedirectUri(pub Url); + +impl TypedParameter for RedirectUri { + const KEY: &'static str = "redirect_uri"; +} + +impl From for Json { + fn from(cmu: RedirectUri) -> Self { + cmu.0.to_string().into() + } +} + +impl TryFrom for RedirectUri { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(serde_json::from_value(value).map(RedirectUri)?) + } +} + +/// `response_uri` field in the Authorization Request. +#[derive(Debug, Clone)] +pub struct ResponseUri(pub Url); + +impl TypedParameter for ResponseUri { + const KEY: &'static str = "response_uri"; +} + +impl From for Json { + fn from(cmu: ResponseUri) -> Self { + cmu.0.to_string().into() + } +} + +impl TryFrom for ResponseUri { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(serde_json::from_value(value).map(ResponseUri)?) + } +} + +const DIRECT_POST: &str = "direct_post"; +const DIRECT_POST_JWT: &str = "direct_post.jwt"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(into = "String", from = "String")] +pub enum ResponseMode { + /// The `direct_post` response mode as defined in OID4VP. + DirectPost, + /// The `direct_post.jwt` response mode as defined in OID4VP. + DirectPostJwt, + /// A ResponseMode that is unsupported by this library. + Unsupported(String), +} + +impl TypedParameter for ResponseMode { + const KEY: &'static str = "response_mode"; +} + +impl From for ResponseMode { + fn from(s: String) -> Self { + match s.as_str() { + DIRECT_POST => ResponseMode::DirectPost, + DIRECT_POST_JWT => ResponseMode::DirectPostJwt, + _ => ResponseMode::Unsupported(s), + } + } +} + +impl From for String { + fn from(s: ResponseMode) -> Self { + match s { + ResponseMode::DirectPost => DIRECT_POST.into(), + ResponseMode::DirectPostJwt => DIRECT_POST_JWT.into(), + ResponseMode::Unsupported(u) => u, + } + } +} + +impl TryFrom for ResponseMode { + type Error = Error; + + fn try_from(value: Json) -> Result { + let s: String = serde_json::from_value(value)?; + Ok(s.into()) + } +} + +impl From for Json { + fn from(rm: ResponseMode) -> Self { + String::from(rm).into() + } +} + +impl fmt::Display for ResponseMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ResponseMode::DirectPost => DIRECT_POST, + ResponseMode::DirectPostJwt => DIRECT_POST_JWT, + ResponseMode::Unsupported(u) => u, + } + .fmt(f) + } +} + +impl Default for ResponseMode { + fn default() -> Self { + Self::Unsupported("fragment".into()) + } +} + +impl ResponseMode { + pub fn is_jarm(&self) -> Result { + match self { + ResponseMode::DirectPost => Ok(false), + ResponseMode::DirectPostJwt => Ok(true), + ResponseMode::Unsupported(rm) => bail!("unsupported response_mode: {rm}"), + } + } +} + +const VP_TOKEN: &str = "vp_token"; +const VP_TOKEN_ID_TOKEN: &str = "vp_token id_token"; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde(into = "String", from = "String")] +pub enum ResponseType { + VpToken, + VpTokenIdToken, + Unsupported(String), +} + +impl From for String { + fn from(rt: ResponseType) -> Self { + match rt { + ResponseType::VpToken => VP_TOKEN.into(), + ResponseType::VpTokenIdToken => VP_TOKEN_ID_TOKEN.into(), + ResponseType::Unsupported(s) => s, + } + } +} + +impl From for ResponseType { + fn from(s: String) -> Self { + match s.as_str() { + VP_TOKEN => ResponseType::VpToken, + VP_TOKEN_ID_TOKEN => ResponseType::VpTokenIdToken, + _ => ResponseType::Unsupported(s), + } + } +} + +impl TypedParameter for ResponseType { + const KEY: &'static str = "response_type"; +} + +impl TryFrom for ResponseType { + type Error = Error; + + fn try_from(value: Json) -> Result { + let s: String = serde_json::from_value(value)?; + Ok(s.into()) + } +} + +impl From for Json { + fn from(rt: ResponseType) -> Self { + Json::String(rt.into()) + } +} + +#[derive(Debug, Clone)] +pub struct State(pub String); + +impl TypedParameter for State { + const KEY: &'static str = "state"; +} + +impl TryFrom for State { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: State) -> Self { + Json::String(value.0) + } +} + +#[derive(Debug, Clone)] +pub struct PresentationDefinition { + raw: Json, + parsed: crate::presentation_exchange::PresentationDefinition, +} + +impl PresentationDefinition { + pub fn into_parsed(self) -> crate::presentation_exchange::PresentationDefinition { + self.parsed + } + + pub fn parsed(&self) -> &crate::presentation_exchange::PresentationDefinition { + &self.parsed + } +} + +impl TryFrom for PresentationDefinition { + type Error = Error; + + fn try_from( + parsed: crate::presentation_exchange::PresentationDefinition, + ) -> Result { + let raw = serde_json::to_value(parsed.clone())?; + Ok(Self { raw, parsed }) + } +} + +impl TypedParameter for PresentationDefinition { + const KEY: &'static str = "presentation_definition"; +} + +impl TryFrom for PresentationDefinition { + type Error = Error; + + fn try_from(value: Json) -> Result { + let parsed = serde_json::from_value(value.clone())?; + Ok(Self { raw: value, parsed }) + } +} + +impl From for Json { + fn from(value: PresentationDefinition) -> Self { + value.raw + } +} + +#[derive(Debug, Clone)] +pub struct PresentationDefinitionUri(pub Url); + +impl TypedParameter for PresentationDefinitionUri { + const KEY: &'static str = "presentation_definition_uri"; +} + +impl TryFrom for PresentationDefinitionUri { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(serde_json::from_value(value).map(Self)?) + } +} + +impl From for Json { + fn from(value: PresentationDefinitionUri) -> Self { + value.0.to_string().into() + } +} diff --git a/src/core/authorization_request/verification/did.rs b/src/core/authorization_request/verification/did.rs new file mode 100644 index 0000000..8b14607 --- /dev/null +++ b/src/core/authorization_request/verification/did.rs @@ -0,0 +1,75 @@ +use crate::core::{ + authorization_request::AuthorizationRequestObject, + metadata::{parameters::wallet::RequestObjectSigningAlgValuesSupported, WalletMetadata}, + object::ParsingErrorContext, +}; +use anyhow::{bail, Context, Result}; +use base64::prelude::*; +use serde_json::{Map, Value as Json}; +use ssi::did_resolve::{resolve_key, DIDResolver}; + +/// Default implementation of request validation for `client_id_scheme` `did`. +pub async fn verify_with_resolver( + wallet_metadata: &WalletMetadata, + request_object: &AuthorizationRequestObject, + request_jwt: String, + trusted_dids: Option<&[String]>, + resolver: &dyn DIDResolver, +) -> Result<()> { + let (headers_b64, _, _) = ssi::jws::split_jws(&request_jwt)?; + + let headers_json_bytes = BASE64_URL_SAFE_NO_PAD + .decode(headers_b64) + .context("jwt headers were not valid base64url")?; + + let mut headers = serde_json::from_slice::>(&headers_json_bytes) + .context("jwt headers were not valid json")?; + + let Json::String(alg) = headers + .remove("alg") + .context("'alg' was missing from jwt headers")? + else { + bail!("'alg' header was not a string") + }; + + let supported_algs: RequestObjectSigningAlgValuesSupported = + wallet_metadata.get().parsing_error()?; + + if !supported_algs.0.contains(&alg) { + bail!("request was signed with unsupported algorithm: {alg}") + } + + let Json::String(kid) = headers + .remove("kid") + .context("'kid' was missing from jwt headers")? + else { + bail!("'kid' header was not a string") + }; + + let client_id = request_object.client_id(); + let (did, _f) = kid.split_once('#').context(format!( + "expected a DID verification method in 'kid' header, received '{kid}'" + ))?; + + if client_id.0 != did { + bail!( + "DIDs from 'kid' ({did}) and 'client_id' ({}) do not match", + client_id.0 + ) + } + + if let Some(dids) = trusted_dids { + if !dids.iter().any(|trusted_did| trusted_did == did) { + bail!("'client_id' ({did}) is not in the list of trusted dids") + } + } + + let jwk = resolve_key(&kid, resolver) + .await + .context("unable to resolve verification method from 'kid' header")?; + + let _: Json = ssi::jwt::decode_verify(&request_jwt, &jwk) + .context("request signature could not be verified")?; + + Ok(()) +} diff --git a/src/core/authorization_request/verification/mod.rs b/src/core/authorization_request/verification/mod.rs new file mode 100644 index 0000000..01faab7 --- /dev/null +++ b/src/core/authorization_request/verification/mod.rs @@ -0,0 +1,187 @@ +use crate::{ + core::{ + metadata::parameters::{ + verifier::{AuthorizationEncryptedResponseAlg, AuthorizationEncryptedResponseEnc}, + wallet::{ + AuthorizationEncryptionAlgValuesSupported, + AuthorizationEncryptionEncValuesSupported, ClientIdSchemesSupported, + }, + }, + object::{ParsingErrorContext, TypedParameter, UntypedObject}, + }, + wallet::Wallet, +}; +use anyhow::{bail, Context, Error, Result}; +use async_trait::async_trait; + +use super::{ + parameters::{ClientIdScheme, ClientMetadata, ResponseMode}, + AuthorizationRequestObject, +}; + +pub mod did; +pub mod verifier; +pub mod x509_san; + +/// Verifies Authorization Request Objects. +#[allow(unused_variables)] +#[async_trait] +pub trait RequestVerifier { + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `did`. + async fn did( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'did' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `entity_id`. + async fn entity_id( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'entity' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `pre-registered`. + async fn preregistered( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'pre-registered' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `redirect_uri`. + async fn redirect_uri( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'redirect_uri' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `verifier_attestation`. + async fn verifier_attestation( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'verifier_attestation' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `x509_san_dns`. + async fn x509_san_dns( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'x509_san_dns' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is `x509_san_uri`. + async fn x509_san_uri( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'x509_san_uri' client verification not implemented") + } + + /// Performs verification on Authorization Request Objects when `client_id_scheme` is any other value. + async fn other( + &self, + client_id_scheme: &str, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<(), Error> { + bail!("'{client_id_scheme}' client verification not implemented") + } +} + +pub(crate) async fn verify_request( + wallet: &W, + jwt: String, +) -> Result { + let request: AuthorizationRequestObject = ssi::jwt::decode_unverified::(&jwt) + .context("unable to decode Authorization Request Object JWT")? + .try_into()?; + + validate_request_against_metadata(wallet, &request).await?; + + let client_id_scheme = request.client_id_scheme(); + + match client_id_scheme { + ClientIdScheme::Did => wallet.did(&request, jwt).await?, + ClientIdScheme::EntityId => wallet.entity_id(&request, jwt).await?, + ClientIdScheme::PreRegistered => wallet.preregistered(&request, jwt).await?, + ClientIdScheme::RedirectUri => wallet.redirect_uri(&request, jwt).await?, + ClientIdScheme::VerifierAttestation => wallet.verifier_attestation(&request, jwt).await?, + ClientIdScheme::X509SanDns => wallet.x509_san_dns(&request, jwt).await?, + ClientIdScheme::X509SanUri => wallet.x509_san_uri(&request, jwt).await?, + ClientIdScheme::Other(scheme) => wallet.other(scheme, &request, jwt).await?, + }; + + Ok(request) +} + +pub(crate) async fn validate_request_against_metadata( + wallet: &W, + request: &AuthorizationRequestObject, +) -> Result<(), Error> { + let wallet_metadata = wallet.metadata(); + + let client_id_scheme = request.client_id_scheme(); + if !wallet_metadata + .get_or_default::()? + .0 + .contains(client_id_scheme) + { + bail!( + "wallet does not support client_id_scheme '{}'", + client_id_scheme + ) + } + + let client_metadata = ClientMetadata::resolve(request, wallet.http_client()) + .await? + .0; + + let response_mode = request.get::().parsing_error()?; + + if response_mode.is_jarm()? { + let alg = client_metadata + .get::() + .parsing_error()?; + let enc = client_metadata + .get::() + .parsing_error()?; + + if let Some(supported_algs) = + wallet_metadata.get::() + { + if !supported_algs?.0.contains(&alg.0) { + bail!( + "unsupported {} '{}'", + AuthorizationEncryptedResponseAlg::KEY, + alg.0 + ) + } + } + if let Some(supported_encs) = + wallet_metadata.get::() + { + if !supported_encs?.0.contains(&enc.0) { + bail!( + "unsupported {} '{}'", + AuthorizationEncryptedResponseEnc::KEY, + enc.0 + ) + } + } + } + + Ok(()) +} diff --git a/src/core/authorization_request/verification/verifier.rs b/src/core/authorization_request/verification/verifier.rs new file mode 100644 index 0000000..7c5698c --- /dev/null +++ b/src/core/authorization_request/verification/verifier.rs @@ -0,0 +1,35 @@ +use anyhow::Result; +#[cfg(feature = "p256")] +use anyhow::{bail, Error}; +#[cfg(feature = "p256")] +use p256::ecdsa::signature::Verifier as _; +use x509_cert::spki::SubjectPublicKeyInfoRef; + +pub trait Verifier: Sized { + /// Construct a [Verifier] from [SubjectPublicKeyInfoRef]. + /// + /// ## Params + /// * `spki` - the public key information necessary to construct a [Verifier]. + /// * `algorithm` - the value taken from the `alg` header of the request, to hint at what curve should be used by the [Verifier]. + fn from_spki(spki: SubjectPublicKeyInfoRef<'_>, algorithm: String) -> Result; + fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<()>; +} + +#[cfg(feature = "p256")] +#[derive(Debug, Clone)] +pub struct P256Verifier(p256::ecdsa::VerifyingKey); + +#[cfg(feature = "p256")] +impl Verifier for P256Verifier { + fn from_spki(spki: SubjectPublicKeyInfoRef<'_>, algorithm: String) -> Result { + if algorithm != "ES256" { + bail!("P256Verifier cannot verify requests signed with '{algorithm}'") + } + spki.try_into().map(Self).map_err(Error::from) + } + + fn verify(&self, payload: &[u8], signature: &[u8]) -> Result<()> { + let signature = p256::ecdsa::Signature::from_slice(signature)?; + self.0.verify(payload, &signature).map_err(Error::from) + } +} diff --git a/src/core/authorization_request/verification/x509_san.rs b/src/core/authorization_request/verification/x509_san.rs new file mode 100644 index 0000000..ffff434 --- /dev/null +++ b/src/core/authorization_request/verification/x509_san.rs @@ -0,0 +1,125 @@ +use anyhow::{bail, Context, Result}; +use base64::prelude::*; +use serde_json::{Map, Value as Json}; +use tracing::debug; +use x509_cert::{ + der::{referenced::OwnedToRef, Decode}, + ext::pkix::{name::GeneralName, SubjectAltName}, + Certificate, +}; + +use crate::{ + core::{ + authorization_request::AuthorizationRequestObject, + metadata::{parameters::wallet::RequestObjectSigningAlgValuesSupported, WalletMetadata}, + object::ParsingErrorContext, + }, + verifier::client::X509SanVariant, +}; + +use super::verifier::Verifier; + +/// Default implementation of request validation for `client_id_scheme` `x509_san_dns`. +pub fn validate( + x509_san_variant: X509SanVariant, + wallet_metadata: &WalletMetadata, + request_object: &AuthorizationRequestObject, + request_jwt: String, + trusted_roots: Option<&[Certificate]>, +) -> Result<()> { + let client_id = request_object.client_id().0.as_str(); + let (headers_b64, body_b64, sig_b64) = ssi::jws::split_jws(&request_jwt)?; + + let headers_json_bytes = BASE64_URL_SAFE_NO_PAD + .decode(headers_b64) + .context("jwt headers were not valid base64url")?; + + let mut headers = serde_json::from_slice::>(&headers_json_bytes) + .context("jwt headers were not valid json")?; + + let Json::String(alg) = headers + .remove("alg") + .context("'alg' was missing from jwt headers")? + else { + bail!("'alg' header was not a string") + }; + + let supported_algs: RequestObjectSigningAlgValuesSupported = + wallet_metadata.get().parsing_error()?; + + if !supported_algs.0.contains(&alg) { + bail!("request was signed with unsupported algorithm: {alg}") + } + + let Json::Array(x5chain) = headers + .remove("x5c") + .context("'x5c' was missing from jwt headers")? + else { + bail!("'x5c' header was not an array") + }; + + let Json::String(b64_x509) = x5chain.first().context("'x5c' was an empty array")? else { + bail!("'x5c' header was not an array of strings"); + }; + + let leaf_cert_der = BASE64_STANDARD_NO_PAD + .decode(b64_x509.trim_end_matches('=')) + .context("leaf certificate in 'x5c' was not valid base64")?; + + let leaf_cert = Certificate::from_der(&leaf_cert_der) + .context("leaf certificate in 'x5c' was not valid DER")?; + + if !leaf_cert + .tbs_certificate + .filter::() + .filter_map(|r| match r { + Ok((_crit, san)) => Some(san.0.into_iter()), + Err(e) => { + debug!("unable to parse SubjectAlternativeName from DER: {e}"); + None + } + }) + .flatten() + .filter_map(|gn| match (gn, x509_san_variant) { + (GeneralName::DnsName(uri), X509SanVariant::Dns) => Some(uri.to_string()), + (gn, X509SanVariant::Dns) => { + debug!("found non-DNS SAN: {gn:?}"); + None + } + (GeneralName::UniformResourceIdentifier(uri), X509SanVariant::Uri) => { + Some(uri.to_string()) + } + (gn, X509SanVariant::Uri) => { + debug!("found non-URI SAN: {gn:?}"); + None + } + }) + .any(|uri| uri == client_id) + { + bail!("client_id does not match any Subject Alternative Name") + } + + if let Some(_trusted_roots) = trusted_roots { + // TODO: Verify chain to root. + } + + let verifier = V::from_spki( + leaf_cert + .tbs_certificate + .subject_public_key_info + .owned_to_ref(), + alg, + ) + .context("unable to parse SPKI")?; + + let payload = [headers_b64.as_bytes(), b".", body_b64.as_bytes()].concat(); + let signature = BASE64_URL_SAFE_NO_PAD + .decode(sig_b64) + .context("could not decode base64url encoded jwt signature")?; + + verifier + .verify(&payload, &signature) + .context("request signature could not be verified")?; + + Ok(()) +} diff --git a/src/core/credential_format/mod.rs b/src/core/credential_format/mod.rs new file mode 100644 index 0000000..863258e --- /dev/null +++ b/src/core/credential_format/mod.rs @@ -0,0 +1,17 @@ +/// A credential format that can be transmitted using OID4VP. +pub trait CredentialFormat { + /// The ID of the credential format. + const ID: &'static str; +} + +pub struct MsoMdoc; + +impl CredentialFormat for MsoMdoc { + const ID: &'static str = "mso_mdoc"; +} + +pub struct JwtVc; + +impl CredentialFormat for JwtVc { + const ID: &'static str = "jwt_vc"; +} diff --git a/src/core/metadata/mod.rs b/src/core/metadata/mod.rs new file mode 100644 index 0000000..bf2da29 --- /dev/null +++ b/src/core/metadata/mod.rs @@ -0,0 +1,157 @@ +use std::ops::{Deref, DerefMut}; + +use anyhow::Error; +use parameters::wallet::{RequestObjectSigningAlgValuesSupported, ResponseTypesSupported}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; + +use self::parameters::wallet::{AuthorizationEndpoint, VpFormatsSupported}; + +use super::{ + authorization_request::parameters::ResponseType, + object::{ParsingErrorContext, UntypedObject}, +}; + +pub mod parameters; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(try_from = "UntypedObject", into = "UntypedObject")] +pub struct WalletMetadata(UntypedObject, AuthorizationEndpoint, VpFormatsSupported); + +impl WalletMetadata { + pub fn new( + authorization_endpoint: AuthorizationEndpoint, + vp_formats_supported: VpFormatsSupported, + other: Option, + ) -> Self { + Self( + other.unwrap_or_default(), + authorization_endpoint, + vp_formats_supported, + ) + } + + pub fn authorization_endpoint(&self) -> &AuthorizationEndpoint { + &self.1 + } + + pub fn vp_formats_supported(&self) -> &VpFormatsSupported { + &self.2 + } + + /// The static wallet metadata bound to `openid4vp:`: + /// ```json + /// { + /// "authorization_endpoint": "openid4vp:", + /// "response_types_supported": [ + /// "vp_token" + /// ], + /// "vp_formats_supported": { + /// "jwt_vp_json": { + /// "alg_values_supported": ["ES256"] + /// }, + /// "jwt_vc_json": { + /// "alg_values_supported": ["ES256"] + /// } + /// }, + /// "request_object_signing_alg_values_supported": [ + /// "ES256" + /// ] + /// } + /// ``` + pub fn openid4vp_scheme_static() -> Self { + // Unwrap safety: unit tested. + let authorization_endpoint = AuthorizationEndpoint("openid4vp:".parse().unwrap()); + + let response_types_supported = ResponseTypesSupported(vec![ResponseType::VpToken]); + + let mut format_definition = Map::new(); + format_definition.insert( + "alg_values_supported".to_owned(), + Json::Array(vec![Json::String("ES256".to_owned())]), + ); + let format_definition = Json::Object(format_definition); + let mut vp_formats_supported = Map::new(); + vp_formats_supported.insert("jwt_vp_json".to_owned(), format_definition.clone()); + vp_formats_supported.insert("jwt_vc_json".to_owned(), format_definition.clone()); + let vp_formats_supported = VpFormatsSupported(vp_formats_supported); + + let request_object_signing_alg_values_supported = + RequestObjectSigningAlgValuesSupported(vec!["ES256".to_owned()]); + + let mut object = UntypedObject::default(); + + object.insert(authorization_endpoint); + object.insert(response_types_supported); + object.insert(vp_formats_supported); + object.insert(request_object_signing_alg_values_supported); + + // Unwrap safety: unit tested. + object.try_into().unwrap() + } +} + +impl From for UntypedObject { + fn from(value: WalletMetadata) -> Self { + let mut inner = value.0; + inner.insert(value.1); + inner.insert(value.2); + inner + } +} + +impl TryFrom for WalletMetadata { + type Error = Error; + + fn try_from(value: UntypedObject) -> Result { + let authorization_endpoint = value.get().parsing_error()?; + let vp_formats_supported = value.get().parsing_error()?; + Ok(Self(value, authorization_endpoint, vp_formats_supported)) + } +} + +impl Deref for WalletMetadata { + type Target = UntypedObject; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for WalletMetadata { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod test { + use super::WalletMetadata; + + #[test] + fn openid4vp_scheme_static() { + let expected = serde_json::json!( + { + "authorization_endpoint": "openid4vp:", + "response_types_supported": [ + "vp_token" + ], + "vp_formats_supported": { + "jwt_vp_json": { + "alg_values_supported": ["ES256"] + }, + "jwt_vc_json": { + "alg_values_supported": ["ES256"] + } + }, + "request_object_signing_alg_values_supported": [ + "ES256" + ] + } + ); + + let wallet_metadata = WalletMetadata::openid4vp_scheme_static(); + + assert_eq!(expected, serde_json::to_value(wallet_metadata).unwrap()) + } +} diff --git a/src/core/metadata/parameters/mod.rs b/src/core/metadata/parameters/mod.rs new file mode 100644 index 0000000..8aeb810 --- /dev/null +++ b/src/core/metadata/parameters/mod.rs @@ -0,0 +1,4 @@ +/// Metadata supplied by the verifier, also known as the Client Metadata. +pub mod verifier; +/// Metadata supplied by the wallet, also known as the Authorization Server Metadata. +pub mod wallet; diff --git a/src/core/metadata/parameters/verifier.rs b/src/core/metadata/parameters/verifier.rs new file mode 100644 index 0000000..3681521 --- /dev/null +++ b/src/core/metadata/parameters/verifier.rs @@ -0,0 +1,196 @@ +use anyhow::Error; +use serde::Deserialize; +use serde_json::{Map, Value as Json}; + +use crate::core::object::TypedParameter; + +#[derive(Debug, Clone, Deserialize)] +pub struct VpFormats(pub Map); + +impl TypedParameter for VpFormats { + const KEY: &'static str = "vp_formats"; +} + +impl TryFrom for VpFormats { + type Error = Error; + + fn try_from(value: Json) -> Result { + serde_json::from_value(value).map(Self).map_err(Into::into) + } +} + +impl From for Json { + fn from(value: VpFormats) -> Json { + value.0.into() + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JWKs { + pub keys: Vec>, +} + +impl TypedParameter for JWKs { + const KEY: &'static str = "jwks"; +} + +impl TryFrom for JWKs { + type Error = Error; + + fn try_from(value: Json) -> Result { + serde_json::from_value(value).map_err(Into::into) + } +} + +impl From for Json { + fn from(value: JWKs) -> Json { + let keys = value.keys.into_iter().map(Json::Object).collect(); + let mut obj = Map::default(); + obj.insert("keys".into(), Json::Array(keys)); + obj.into() + } +} + +#[derive(Debug, Clone)] +pub struct RequireSignedRequestObject(pub bool); + +impl TypedParameter for RequireSignedRequestObject { + const KEY: &'static str = "require_signed_request_object"; +} + +impl TryFrom for RequireSignedRequestObject { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: RequireSignedRequestObject) -> Json { + Json::Bool(value.0) + } +} + +#[derive(Debug, Clone)] +pub struct AuthorizationEncryptedResponseAlg(pub String); + +impl TypedParameter for AuthorizationEncryptedResponseAlg { + const KEY: &'static str = "authorization_encrypted_response_alg"; +} + +impl TryFrom for AuthorizationEncryptedResponseAlg { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: AuthorizationEncryptedResponseAlg) -> Json { + Json::String(value.0) + } +} + +#[derive(Debug, Clone)] +pub struct AuthorizationEncryptedResponseEnc(pub String); + +impl TypedParameter for AuthorizationEncryptedResponseEnc { + const KEY: &'static str = "authorization_encrypted_response_enc"; +} + +impl TryFrom for AuthorizationEncryptedResponseEnc { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: AuthorizationEncryptedResponseEnc) -> Json { + Json::String(value.0) + } +} + +#[cfg(test)] +mod test { + use serde_json::json; + + use crate::core::object::UntypedObject; + + use super::*; + + fn metadata() -> UntypedObject { + serde_json::from_value(json!( + { + "jwks":{ + "keys":[ + { + "kty":"EC", + "crv":"P-256", + "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "use":"enc", + "kid":"1" + } + ] + }, + "authorization_encrypted_response_alg":"ECDH-ES", + "authorization_encrypted_response_enc":"A256GCM", + "require_signed_request_object":true, + "vp_formats":{ "mso_mdoc":{} } + } + )) + .unwrap() + } + + #[test] + fn vp_formats() { + let VpFormats(fnd) = metadata().get().unwrap().unwrap(); + let exp = json!({"mso_mdoc": {}}).as_object().unwrap().clone(); + assert_eq!(fnd, exp) + } + + #[test] + fn jwks() { + let JWKs { keys } = metadata().get().unwrap().unwrap(); + assert_eq!(keys.len(), 1); + + let jwk = &keys[0]; + assert_eq!(jwk.get("kty").unwrap(), "EC"); + assert_eq!(jwk.get("crv").unwrap(), "P-256"); + assert_eq!( + jwk.get("x").unwrap(), + "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4" + ); + assert_eq!( + jwk.get("y").unwrap(), + "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ); + assert_eq!(jwk.get("use").unwrap(), "enc"); + assert_eq!(jwk.get("kid").unwrap(), "1"); + } + + #[test] + fn require_signed_request_object() { + let exp = true; + let RequireSignedRequestObject(b) = metadata().get().unwrap().unwrap(); + assert_eq!(b, exp); + } + + #[test] + fn authorization_encrypted_response_alg() { + let exp = "ECDH-ES"; + let AuthorizationEncryptedResponseAlg(s) = metadata().get().unwrap().unwrap(); + assert_eq!(s, exp); + } + + #[test] + fn authorization_encrypted_response_enc() { + let exp = "A256GCM"; + let AuthorizationEncryptedResponseEnc(s) = metadata().get().unwrap().unwrap(); + assert_eq!(s, exp); + } +} diff --git a/src/core/metadata/parameters/wallet.rs b/src/core/metadata/parameters/wallet.rs new file mode 100644 index 0000000..3454a01 --- /dev/null +++ b/src/core/metadata/parameters/wallet.rs @@ -0,0 +1,300 @@ +use crate::core::{ + authorization_request::parameters::{ClientIdScheme, ResponseType}, + object::TypedParameter, +}; +use anyhow::{bail, Error, Result}; +use serde_json::{Map, Value as Json}; +use url::Url; + +#[derive(Debug, Clone)] +pub struct Issuer(pub String); + +impl TypedParameter for Issuer { + const KEY: &'static str = "issuer"; +} + +impl TryFrom for Issuer { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: Issuer) -> Json { + Json::String(value.0) + } +} + +#[derive(Debug, Clone)] +pub struct AuthorizationEndpoint(pub Url); + +impl TypedParameter for AuthorizationEndpoint { + const KEY: &'static str = "authorization_endpoint"; +} + +impl TryFrom for AuthorizationEndpoint { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: AuthorizationEndpoint) -> Json { + Json::String(value.0.to_string()) + } +} + +#[derive(Debug, Clone)] +pub struct ResponseTypesSupported(pub Vec); + +impl TypedParameter for ResponseTypesSupported { + const KEY: &'static str = "response_types_supported"; +} + +impl TryFrom for ResponseTypesSupported { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: ResponseTypesSupported) -> Json { + Json::Array( + value + .0 + .iter() + .cloned() + .map(String::from) + .map(Json::from) + .collect(), + ) + } +} + +// TODO: Client ID scheme types? +#[derive(Debug, Clone)] +pub struct ClientIdSchemesSupported(pub Vec); + +impl TypedParameter for ClientIdSchemesSupported { + const KEY: &'static str = "client_id_schemes_supported"; +} + +impl TryFrom for ClientIdSchemesSupported { + type Error = Error; + + fn try_from(value: Json) -> Result { + let Json::Array(xs) = value else { + bail!("expected JSON array") + }; + xs.into_iter() + .map(Json::try_into) + .collect::>>() + .map(Self) + } +} + +impl From for Json { + fn from(value: ClientIdSchemesSupported) -> Json { + Json::Array(value.0.into_iter().map(Json::from).collect()) + } +} + +impl Default for ClientIdSchemesSupported { + fn default() -> Self { + Self(vec![ClientIdScheme::PreRegistered]) + } +} + +#[derive(Debug, Clone)] +pub struct RequestObjectSigningAlgValuesSupported(pub Vec); + +impl TypedParameter for RequestObjectSigningAlgValuesSupported { + const KEY: &'static str = "request_object_signing_alg_values_supported"; +} + +impl TryFrom for RequestObjectSigningAlgValuesSupported { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: RequestObjectSigningAlgValuesSupported) -> Json { + Json::Array(value.0.into_iter().map(Json::from).collect()) + } +} + +// TODO: Better types +#[derive(Debug, Clone)] +pub struct VpFormatsSupported(pub Map); + +impl TypedParameter for VpFormatsSupported { + const KEY: &'static str = "vp_formats_supported"; +} + +impl TryFrom for VpFormatsSupported { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: VpFormatsSupported) -> Json { + Json::Object(value.0) + } +} + +#[derive(Debug, Clone)] +pub struct AuthorizationEncryptionAlgValuesSupported(pub Vec); + +impl TypedParameter for AuthorizationEncryptionAlgValuesSupported { + const KEY: &'static str = "authorization_encryption_alg_values_supported"; +} + +impl TryFrom for AuthorizationEncryptionAlgValuesSupported { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: AuthorizationEncryptionAlgValuesSupported) -> Json { + Json::Array(value.0.into_iter().map(Json::from).collect()) + } +} + +#[derive(Debug, Clone)] +pub struct AuthorizationEncryptionEncValuesSupported(pub Vec); + +impl TypedParameter for AuthorizationEncryptionEncValuesSupported { + const KEY: &'static str = "authorization_encryption_enc_values_supported"; +} + +impl TryFrom for AuthorizationEncryptionEncValuesSupported { + type Error = Error; + + fn try_from(value: Json) -> Result { + Ok(Self(serde_json::from_value(value)?)) + } +} + +impl From for Json { + fn from(value: AuthorizationEncryptionEncValuesSupported) -> Json { + Json::Array(value.0.into_iter().map(Json::from).collect()) + } +} + +#[cfg(test)] +mod test { + use serde_json::json; + + use crate::core::object::UntypedObject; + + use super::*; + + fn metadata() -> UntypedObject { + serde_json::from_value(json!({ + "issuer": "https://self-issued.me/v2", + "authorization_endpoint": "mdoc-openid4vp://", + "response_types_supported": [ + "vp_token" + ], + "vp_formats_supported": + { + "mso_mdoc": { + } + }, + "client_id_schemes_supported": [ + "redirect_uri", + "x509_san_uri" + ], + "request_object_signing_alg_values_supported": [ + "ES256" + ], + "authorization_encryption_alg_values_supported": [ + "ECDH-ES" + ], + "authorization_encryption_enc_values_supported": [ + "A256GCM" + ] + } + )) + .unwrap() + } + + #[test] + fn issuer() { + let exp = "https://self-issued.me/v2"; + let Issuer(s) = metadata().get().unwrap().unwrap(); + assert_eq!(s, exp); + } + + #[test] + fn authorization_endpoint() { + let exp = "mdoc-openid4vp://".parse().unwrap(); + let AuthorizationEndpoint(s) = metadata().get().unwrap().unwrap(); + assert_eq!(s, exp); + } + + #[test] + fn response_types_supported() { + let exp = [ResponseType::VpToken]; + let ResponseTypesSupported(v) = metadata().get().unwrap().unwrap(); + assert!(exp.iter().all(|x| v.contains(x))); + assert!(v.iter().all(|x| exp.contains(x))); + } + + #[test] + fn client_id_schemes_supported() { + let exp = [ClientIdScheme::RedirectUri, ClientIdScheme::X509SanUri]; + let ClientIdSchemesSupported(v) = metadata().get().unwrap().unwrap(); + assert!(exp.iter().all(|x| v.contains(x))); + assert!(v.iter().all(|x| exp.contains(x))); + } + + #[test] + fn request_object_signing_alg_values_supported() { + let exp = ["ES256".to_string()]; + let RequestObjectSigningAlgValuesSupported(v) = metadata().get().unwrap().unwrap(); + assert!(exp.iter().all(|x| v.contains(x))); + assert!(v.iter().all(|x| exp.contains(x))); + } + + #[test] + fn vp_formats_supported() { + let VpFormatsSupported(mut m) = metadata().get().unwrap().unwrap(); + assert_eq!(m.len(), 1); + assert_eq!( + m.remove("mso_mdoc").unwrap(), + Json::Object(Default::default()) + ); + } + + #[test] + fn authorization_encryption_alg_values_supported() { + let exp = ["ECDH-ES".to_string()]; + let AuthorizationEncryptionAlgValuesSupported(v) = metadata().get().unwrap().unwrap(); + assert!(exp.iter().all(|x| v.contains(x))); + assert!(v.iter().all(|x| exp.contains(x))); + } + + #[test] + fn authorization_encryption_enc_values_supported() { + let exp = ["A256GCM".to_string()]; + let AuthorizationEncryptionEncValuesSupported(v) = metadata().get().unwrap().unwrap(); + assert!(exp.iter().all(|x| v.contains(x))); + assert!(v.iter().all(|x| exp.contains(x))); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..a4979fa --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,6 @@ +pub mod authorization_request; +pub mod credential_format; +pub mod metadata; +pub mod object; +pub mod response; +pub mod util; diff --git a/src/core/object/mod.rs b/src/core/object/mod.rs new file mode 100644 index 0000000..ac56ed7 --- /dev/null +++ b/src/core/object/mod.rs @@ -0,0 +1,105 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; + +/// An untyped (JSON) Object from which [TypedParameters](TypedParameter) can be parsed. +/// +/// Can represent metadata or request objects. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct UntypedObject(pub(crate) Map); + +// TODO: Replace anyhow error type. +/// A strongly typed parameter that can represent metadata entries or request parameters. +pub trait TypedParameter: + TryFrom + Into + Clone + std::fmt::Debug +{ + const KEY: &'static str; +} + +impl UntypedObject { + /// Get a [TypedParameter] from the Object or return the default value. + /// + /// Note that this method clones the underlying data. + pub fn get_or_default(&self) -> Result { + Ok(self + .0 + .get(T::KEY) + .cloned() + .map(TryInto::try_into) + .transpose()? + .unwrap_or_default()) + } + + /// Get a [TypedParameter] from the Object. + /// + /// Note that this method clones the underlying data. + pub fn get(&self) -> Option> { + Some(self.0.get(T::KEY)?.clone().try_into().map_err(Into::into)) + } + + /// Remove a [TypedParameter] from the Object. + pub fn remove(&mut self) -> Option> { + Some(self.0.remove(T::KEY)?.try_into().map_err(Into::into)) + } + + /// Insert a [TypedParameter]. + /// + /// Returns the existing [TypedParameter] if one already exists. + /// + /// # Errors + /// Returns an error if there was already an entry in the Object, but it could not be parsed from JSON. + pub fn insert(&mut self, t: T) -> Option> { + Some( + self.0 + .insert(T::KEY.to_owned(), t.into())? + .try_into() + .map_err(Into::into), + ) + } + + /// Flatten the structure for posting as a form. + pub(crate) fn flatten_for_form(self) -> Result> { + self.0 + .into_iter() + .map(|(k, v)| { + if let Json::String(s) = v { + return Ok((k, s)); + } + serde_json::to_string(&v) + .map(|v| (k, v)) + .map_err(Error::from) + }) + .collect() + } +} + +impl From for Json { + fn from(value: UntypedObject) -> Self { + value.0.into() + } +} + +pub trait ParsingErrorContext { + type T: TypedParameter; + + fn parsing_error(self) -> Result; +} + +impl ParsingErrorContext for Option> { + type T = T; + + fn parsing_error(self) -> Result { + self.context(format!("'{}' is missing", T::KEY))? + .context(format!("'{}' could not be parsed", T::KEY)) + } +} + +impl ParsingErrorContext for Result { + type T = T; + + fn parsing_error(self) -> Result { + self.context(format!("'{}' could not be parsed", T::KEY)) + } +} diff --git a/src/core/response/mod.rs b/src/core/response/mod.rs new file mode 100644 index 0000000..30fff9a --- /dev/null +++ b/src/core/response/mod.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use url::Url; + +use self::parameters::{PresentationSubmission, VpToken}; + +use super::object::{ParsingErrorContext, UntypedObject}; + +pub mod parameters; + +#[derive(Debug, Clone)] +pub enum AuthorizationResponse { + Unencoded(UnencodedAuthorizationResponse), + Jwt(JwtAuthorizationResponse), +} + +impl AuthorizationResponse { + pub fn from_x_www_form_urlencoded(bytes: &[u8]) -> Result { + if let Ok(jwt) = serde_urlencoded::from_bytes(bytes) { + return Ok(Self::Jwt(jwt)); + } + + let flattened = serde_urlencoded::from_bytes::>(bytes) + .context("failed to construct flat map")?; + let map = flattened + .into_iter() + .map(|(k, v)| { + let v = serde_json::from_str::(&v).unwrap_or(Value::String(v)); + (k, v) + }) + .collect(); + + Ok(Self::Unencoded(UntypedObject(map).try_into()?)) + } +} + +#[derive(Debug, Clone)] +pub struct UnencodedAuthorizationResponse( + pub UntypedObject, + pub VpToken, + pub PresentationSubmission, +); + +impl UnencodedAuthorizationResponse { + /// Encode the Authorization Response as 'application/x-www-form-urlencoded'. + pub fn into_x_www_form_urlencoded(self) -> Result { + let mut inner = self.0; + inner.insert(self.1); + inner.insert(self.2); + serde_urlencoded::to_string(inner.flatten_for_form()?) + .context("failed to encode response as 'application/x-www-form-urlencoded'") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JwtAuthorizationResponse { + /// Can be JWT or JWE. + pub response: String, +} + +impl JwtAuthorizationResponse { + /// Encode the Authorization Response as 'application/x-www-form-urlencoded'. + pub fn into_x_www_form_urlencoded(self) -> Result { + serde_urlencoded::to_string(self) + .context("failed to encode response as 'application/x-www-form-urlencoded'") + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostRedirection { + pub redirect_uri: Url, +} + +impl TryFrom for UnencodedAuthorizationResponse { + type Error = Error; + + fn try_from(value: UntypedObject) -> Result { + let vp_token = value.get().parsing_error()?; + let presentation_submission = value.get().parsing_error()?; + Ok(Self(value, vp_token, presentation_submission)) + } +} + +#[cfg(test)] +mod test { + use serde_json::json; + + use crate::core::object::UntypedObject; + + use super::{JwtAuthorizationResponse, UnencodedAuthorizationResponse}; + + #[test] + fn jwt_authorization_response_to_form_urlencoded() { + let response = JwtAuthorizationResponse { + response: "header.body.signature".into(), + }; + assert_eq!( + response.into_x_www_form_urlencoded().unwrap(), + "response=header.body.signature", + ) + } + + #[test] + fn unencoded_authorization_response_to_form_urlencoded() { + let object: UntypedObject = serde_json::from_value(json!( + { + "presentation_submission": { + "id": "id", + "definition_id": "definition_id", + "descriptor_map": [] + }, + "vp_token": "string" + } + )) + .unwrap(); + let response = UnencodedAuthorizationResponse::try_from(object).unwrap(); + assert_eq!( + response.into_x_www_form_urlencoded().unwrap(), + "presentation_submission=%7B%22definition_id%22%3A%22definition_id%22%2C%22descriptor_map%22%3A%5B%5D%2C%22id%22%3A%22id%22%7D&vp_token=string", + ) + } +} diff --git a/src/core/response/parameters.rs b/src/core/response/parameters.rs new file mode 100644 index 0000000..b9d6b8b --- /dev/null +++ b/src/core/response/parameters.rs @@ -0,0 +1,93 @@ +use anyhow::Error; +use serde_json::Value as Json; + +pub use crate::core::authorization_request::parameters::State; +use crate::core::object::TypedParameter; + +#[derive(Debug, Clone)] +pub struct IdToken(pub String); + +impl TypedParameter for IdToken { + const KEY: &'static str = "id_token"; +} + +impl TryFrom for IdToken { + type Error = Error; + + fn try_from(value: Json) -> Result { + serde_json::from_value(value).map(Self).map_err(Into::into) + } +} + +impl From for Json { + fn from(value: IdToken) -> Self { + value.0.into() + } +} + +#[derive(Debug, Clone)] +pub struct VpToken(pub String); + +impl TypedParameter for VpToken { + const KEY: &'static str = "vp_token"; +} + +impl TryFrom for VpToken { + type Error = Error; + + fn try_from(value: Json) -> Result { + serde_json::from_value(value).map(Self).map_err(Into::into) + } +} + +impl From for Json { + fn from(value: VpToken) -> Self { + value.0.into() + } +} + +#[derive(Debug, Clone)] +pub struct PresentationSubmission { + raw: Json, + parsed: crate::presentation_exchange::PresentationSubmission, +} + +impl PresentationSubmission { + pub fn into_parsed(self) -> crate::presentation_exchange::PresentationSubmission { + self.parsed + } + + pub fn parsed(&self) -> &crate::presentation_exchange::PresentationSubmission { + &self.parsed + } +} + +impl TryFrom for PresentationSubmission { + type Error = Error; + + fn try_from( + parsed: crate::presentation_exchange::PresentationSubmission, + ) -> Result { + let raw = serde_json::to_value(parsed.clone())?; + Ok(Self { raw, parsed }) + } +} + +impl TypedParameter for PresentationSubmission { + const KEY: &'static str = "presentation_submission"; +} + +impl TryFrom for PresentationSubmission { + type Error = Error; + + fn try_from(raw: Json) -> Result { + let parsed = serde_json::from_value(raw.clone())?; + Ok(Self { raw, parsed }) + } +} + +impl From for Json { + fn from(value: PresentationSubmission) -> Self { + value.raw + } +} diff --git a/src/core/util/mod.rs b/src/core/util/mod.rs new file mode 100644 index 0000000..0fb529d --- /dev/null +++ b/src/core/util/mod.rs @@ -0,0 +1,78 @@ +#[cfg(feature = "reqwest")] +use anyhow::Context; +use anyhow::Result; +use async_trait::async_trait; +use http::{Request, Response}; + +/// Generic HTTP client. +/// +/// A trait is used here so to facilitate native HTTP/TLS when compiled for mobile applications. +#[async_trait] +pub trait AsyncHttpClient { + async fn execute(&self, request: Request>) -> Result>>; +} + +pub(crate) fn base_request() -> http::request::Builder { + Request::builder().header("Prefer", "OID4VP-0.0.20") +} + +#[cfg(feature = "reqwest")] +pub struct ReqwestClient(reqwest::Client); + +#[cfg(feature = "reqwest")] +impl ReqwestClient { + pub fn new() -> Result { + reqwest::Client::builder() + .use_rustls_tls() + .build() + .context("unable to build http_client") + .map(Self) + } +} + +#[cfg(feature = "reqwest")] +#[async_trait] +impl AsyncHttpClient for ReqwestClient { + async fn execute(&self, request: Request>) -> Result>> { + let response = self + .0 + .execute(request.try_into().context("unable to convert request")?) + .await + .context("http request failed")?; + + let mut builder = Response::builder() + .status(response.status()) + .version(response.version()); + + builder + .extensions_mut() + .context("unable to set extensions")? + .extend(response.extensions().clone()); + + builder + .headers_mut() + .context("unable to set headers")? + .extend(response.headers().clone()); + + builder + .body( + response + .bytes() + .await + .context("failed to extract response body")? + .to_vec(), + ) + .context("unable to construct response") + } +} + +#[cfg(test)] +mod test { + use http::Response; + + #[test] + fn debug() { + Response::builder().extensions_mut().unwrap(); + Response::builder().headers_mut().unwrap(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7fa2eb1..551dda8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,5 @@ +pub mod core; pub mod presentation_exchange; mod utils; +pub mod verifier; +pub mod wallet; diff --git a/src/presentation_exchange.rs b/src/presentation_exchange.rs index 83e106a..df3c7cc 100644 --- a/src/presentation_exchange.rs +++ b/src/presentation_exchange.rs @@ -1,25 +1,7 @@ +pub use crate::utils::NonEmptyVec; use serde::{Deserialize, Serialize}; use serde_json::Map; -use crate::utils::NonEmptyVec; - -// TODO does openidconnect have a Request type? -#[derive(Debug, Deserialize)] -pub struct ResponseRequest { - id_token: serde_json::Value, // IdTokenSIOP, // CoreIdTokenClaims, - vp_token: VpToken, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct VpTokenIdToken { - pub presentation_submission: PresentationSubmission, -} - -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] -pub struct VpToken { - pub presentation_definition: PresentationDefinition, -} - #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct PresentationDefinition { pub id: String, // Uuid, @@ -43,8 +25,6 @@ pub struct InputDescriptor { pub format: Option, // TODO #[serde(skip_serializing_if = "Option::is_none")] pub constraints: Option, // TODO shouldn't be optional - #[serde(skip_serializing_if = "Option::is_none")] - pub schema: Option, // TODO shouldn't exist anymore } // TODO must have at least one @@ -69,6 +49,32 @@ pub struct ConstraintsField { pub filter: Option, // TODO JSONSchema validation at deserialization time #[serde(skip_serializing_if = "Option::is_none")] pub optional: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub intent_to_retain: Option, +} + +pub type ConstraintsFields = Vec; + +impl ConstraintsField { + pub fn new( + path: NonEmptyVec, + id: Option, + purpose: Option, + name: Option, + filter: Option, + optional: Option, + intent_to_retain: Option, + ) -> ConstraintsField { + ConstraintsField { + path, + id, + purpose, + name, + filter, + optional, + intent_to_retain, + } + } } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -80,25 +86,25 @@ pub enum ConstraintsLimitDisclosure { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct PresentationSubmission { - id: String, - definition_id: String, - descriptor_map: Vec, + pub id: String, + pub definition_id: String, + pub descriptor_map: Vec, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct DescriptorMap { - id: String, - format: String, // TODO should be enum of supported formats - path: String, - path_nested: Option>, + pub id: String, + pub format: String, // TODO should be enum of supported formats + pub path: String, + //pub path_nested: Option>, } #[derive(Deserialize)] pub struct SubmissionRequirementBaseBase { - name: Option, - purpose: Option, + pub name: Option, + pub purpose: Option, #[serde(flatten)] - property_set: Option>, + pub property_set: Option>, } #[derive(Deserialize)] @@ -126,10 +132,10 @@ pub enum SubmissionRequirement { #[derive(Deserialize)] pub struct SubmissionRequirementPick { #[serde(flatten)] - submission_requirement: SubmissionRequirementBase, - count: Option, - min: Option, - max: Option, + pub submission_requirement: SubmissionRequirementBase, + pub count: Option, + pub min: Option, + pub max: Option, } #[cfg(test)] @@ -143,62 +149,54 @@ pub(crate) mod tests { #[test] fn request_example() { - let value = json!({ - "id_token": { - "email": null - }, - "vp_token": { - "presentation_definition": { - "id": "vp token example", - "input_descriptors": [ - { - "id": "id card credential", - "format": { - "ldp_vc": { - "proof_type": [ - "Ed25519Signature2018" - ] + let value = json!( + { + "id": "vp token example", + "input_descriptors": [ + { + "id": "id card credential", + "format": { + "ldp_vc": { + "proof_type": [ + "Ed25519Signature2018" + ] + } + }, + "constraints": { + "fields": [ + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "pattern": "IDCardCredential" } - }, - "constraints": { - "fields": [ - { - "path": [ - "$.type" - ], - "filter": { - "type": "string", - "pattern": "IDCardCredential" - } - } - ] } - } - ] + ] + } } - } + ] } ); - let _: ResponseRequest = serde_path_to_error::deserialize(value) - .map_err(|e| e.path().to_string()) - .unwrap(); - // assert_eq!(serde_json::to_value(res).unwrap(), value); + let _: PresentationDefinition = serde_json::from_value(value).unwrap(); } #[derive(Deserialize)] pub struct PresentationDefinitionTest { - presentation_definition: PresentationDefinition, + #[serde(alias = "presentation_definition")] + _pd: PresentationDefinition, } #[test] fn presentation_definition_suite() { let paths = - fs::read_dir("test/presentation-exchange/test/presentation-definition").unwrap(); + fs::read_dir("tests/presentation-exchange/test/presentation-definition").unwrap(); for path in paths { let path = path.unwrap().path(); if let Some(ext) = path.extension() { if ext != OsStr::new("json") - || vec!["VC_expiration_example.json", "VC_revocation_example.json"] // TODO bad format + || ["VC_expiration_example.json", "VC_revocation_example.json"] // TODO bad format .contains(&path.file_name().unwrap().to_str().unwrap()) { continue; @@ -214,21 +212,21 @@ pub(crate) mod tests { } } - // TODO use VP type? #[derive(Deserialize)] pub struct PresentationSubmissionTest { - presentation_submission: PresentationSubmission, + #[serde(alias = "presentation_submission")] + _ps: PresentationSubmission, } #[test] fn presentation_submission_suite() { let paths = - fs::read_dir("test/presentation-exchange/test/presentation-submission").unwrap(); + fs::read_dir("tests/presentation-exchange/test/presentation-submission").unwrap(); for path in paths { let path = path.unwrap().path(); if let Some(ext) = path.extension() { if ext != OsStr::new("json") - || vec![ + || [ "appendix_DIDComm_example.json", "appendix_CHAPI_example.json", ] @@ -249,18 +247,19 @@ pub(crate) mod tests { #[derive(Deserialize)] pub struct SubmissionRequirementsTest { - submission_requirements: Vec, + #[serde(alias = "submission_requirements")] + _sr: Vec, } #[test] fn submission_requirements_suite() { let paths = - fs::read_dir("test/presentation-exchange/test/submission-requirements").unwrap(); + fs::read_dir("tests/presentation-exchange/test/submission-requirements").unwrap(); for path in paths { let path = path.unwrap().path(); if let Some(ext) = path.extension() { if ext != OsStr::new("json") - || vec!["schema.json"].contains(&path.file_name().unwrap().to_str().unwrap()) + || ["schema.json"].contains(&path.file_name().unwrap().to_str().unwrap()) { continue; } diff --git a/src/utils.rs b/src/utils.rs index 179b436..91ab96d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,20 +1,11 @@ -use jsonpath_rust::JsonPathInst; +use anyhow::{bail, Error}; use serde::{Deserialize, Serialize}; use std::ops::Deref; -// #[derive(Clone)] -// pub struct JsonPath(JsonPathInst); - #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(try_from = "Vec", into = "Vec")] pub struct NonEmptyVec(Vec); -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("cannot construct a non-empty vec from an empty vec")] - Empty, -} - impl NonEmptyVec { pub fn new(t: T) -> Self { Self(vec![t]) @@ -38,7 +29,7 @@ impl TryFrom> for NonEmptyVec { fn try_from(v: Vec) -> Result, Error> { if v.is_empty() { - return Err(Error::Empty); + bail!("cannot create a NonEmptyVec from an empty Vec") } Ok(NonEmptyVec(v)) } diff --git a/src/verifier/by_reference.rs b/src/verifier/by_reference.rs new file mode 100644 index 0000000..9131408 --- /dev/null +++ b/src/verifier/by_reference.rs @@ -0,0 +1,10 @@ +use url::Url; + +#[derive(Debug, Clone, Default)] +pub enum ByReference { + #[default] + False, + True { + at: Url, + }, +} diff --git a/src/verifier/client.rs b/src/verifier/client.rs new file mode 100644 index 0000000..8e45186 --- /dev/null +++ b/src/verifier/client.rs @@ -0,0 +1,201 @@ +use std::{fmt::Debug, sync::Arc}; + +use anyhow::{bail, Context as _, Result}; +use async_trait::async_trait; +use base64::prelude::*; +use serde_json::{json, Value as Json}; +use ssi::did_resolve::DIDResolver; +use tracing::debug; +use x509_cert::{ + der::Encode, + ext::pkix::{name::GeneralName, SubjectAltName}, + Certificate, +}; + +use crate::core::authorization_request::{ + parameters::{ClientId, ClientIdScheme}, + AuthorizationRequestObject, +}; + +use super::request_signer::RequestSigner; + +#[async_trait] +pub trait Client: Debug { + fn id(&self) -> &ClientId; + + fn scheme(&self) -> &ClientIdScheme; + + async fn generate_request_object_jwt( + &self, + body: &AuthorizationRequestObject, + ) -> Result; +} + +/// A [Client] with the `did` Client Identifier. +#[derive(Debug, Clone)] +pub struct DIDClient { + id: ClientId, + vm: String, + signer: Arc, +} + +impl DIDClient { + pub async fn new( + vm: String, + signer: Arc, + resolver: &dyn DIDResolver, + ) -> Result { + let (id, _f) = vm.rsplit_once('#').context(format!( + "expected a DID verification method, received '{vm}'" + ))?; + + let key = ssi::did_resolve::resolve_key(&vm, resolver) + .await + .context("unable to resolve key from verification method")?; + + if &key != signer.jwk() { + bail!( + "verification method resolved from DID document did not match public key of signer" + ) + } + + Ok(Self { + id: ClientId(id.to_string()), + vm, + signer, + }) + } +} + +/// A [Client] with the `x509_san_dns` or `x509_san_uri` Client Identifier. +#[derive(Debug, Clone)] +pub struct X509SanClient { + id: ClientId, + x5c: Vec, + signer: Arc, + variant: X509SanVariant, +} + +impl X509SanClient { + pub fn new( + x5c: Vec, + signer: Arc, + variant: X509SanVariant, + ) -> Result { + let leaf = &x5c[0]; + let id = if let Some(san) = leaf + .tbs_certificate + .filter::() + .filter_map(|r| match r { + Ok((_crit, san)) => Some(san.0.into_iter()), + Err(e) => { + debug!("unable to parse SubjectAlternativeName from DER: {e}"); + None + } + }) + .flatten() + .filter_map(|general_name| match (general_name, variant) { + (GeneralName::DnsName(uri), X509SanVariant::Dns) => Some(uri.to_string()), + (gn, X509SanVariant::Dns) => { + debug!("found non-DNS SAN: {gn:?}"); + None + } + (GeneralName::UniformResourceIdentifier(uri), X509SanVariant::Uri) => { + Some(uri.to_string()) + } + (gn, X509SanVariant::Uri) => { + debug!("found non-URI SAN: {gn:?}"); + None + } + }) + .next() + { + san + } else { + bail!("x509 certificate does not contain Subject Alternative Name"); + }; + Ok(X509SanClient { + id: ClientId(id), + x5c, + signer, + variant, + }) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum X509SanVariant { + Uri, + Dns, +} + +#[async_trait] +impl Client for DIDClient { + fn id(&self) -> &ClientId { + &self.id + } + + fn scheme(&self) -> &ClientIdScheme { + &ClientIdScheme::Did + } + + async fn generate_request_object_jwt( + &self, + body: &AuthorizationRequestObject, + ) -> Result { + let algorithm = self.signer.alg(); + let header = json!({ + "alg": algorithm, + "kid": self.vm, + "typ": "JWT" + }); + make_jwt(header, body, self.signer.as_ref()).await + } +} + +#[async_trait] +impl Client for X509SanClient { + fn id(&self) -> &ClientId { + &self.id + } + + fn scheme(&self) -> &ClientIdScheme { + match self.variant { + X509SanVariant::Dns => &ClientIdScheme::X509SanDns, + X509SanVariant::Uri => &ClientIdScheme::X509SanUri, + } + } + + async fn generate_request_object_jwt( + &self, + body: &AuthorizationRequestObject, + ) -> Result { + let algorithm = self.signer.alg(); + let x5c: Vec = self + .x5c + .iter() + .map(|x509| x509.to_der()) + .map(|der| Ok(BASE64_STANDARD.encode(der?))) + .collect::>()?; + let header = json!({ + "alg": algorithm, + "x5c": x5c, + "typ": "JWT" + }); + make_jwt(header, body, self.signer.as_ref()).await + } +} + +async fn make_jwt( + header: Json, + body: &AuthorizationRequestObject, + signer: &S, +) -> Result { + let header_b64: String = + serde_json::to_vec(&header).map(|b| BASE64_URL_SAFE_NO_PAD.encode(b))?; + let body_b64 = serde_json::to_vec(body).map(|b| BASE64_URL_SAFE_NO_PAD.encode(b))?; + let payload = [header_b64.as_bytes(), b".", body_b64.as_bytes()].concat(); + let signature = signer.sign(&payload).await; + let signature_b64 = BASE64_URL_SAFE_NO_PAD.encode(signature); + Ok(format!("{header_b64}.{body_b64}.{signature_b64}")) +} diff --git a/src/verifier/mod.rs b/src/verifier/mod.rs new file mode 100644 index 0000000..d179856 --- /dev/null +++ b/src/verifier/mod.rs @@ -0,0 +1,223 @@ +use std::{fmt::Debug, future::Future, pin::Pin, sync::Arc}; + +use anyhow::{bail, Context, Result}; +use client::Client; +use request_builder::RequestBuilder; +use session::{Outcome, Session, SessionStore, Status}; +use url::Url; +use uuid::Uuid; + +use crate::core::{ + object::{TypedParameter, UntypedObject}, + response::AuthorizationResponse, +}; + +use by_reference::ByReference; + +mod by_reference; +pub mod client; +pub mod request_builder; +pub mod request_signer; +pub mod session; + +/// An OpenID4VP verifier, also known as the client. +#[derive(Debug, Clone)] +pub struct Verifier { + client: Arc, + default_request_params: UntypedObject, + pass_by_reference: ByReference, + session_store: Arc, + submission_endpoint: Url, +} + +impl Verifier { + /// Build a new verifier. + pub fn builder() -> VerifierBuilder { + VerifierBuilder::default() + } + + /// Begin building a new authorization request (credential presentation). + pub fn build_authorization_request(&self) -> RequestBuilder<'_> { + RequestBuilder::new(self) + } + + /// Retrieve the current status of an authorization request. + /// + /// This should be triggered by a request from the application frontend. + /// + /// ## Returns + /// The status of the authorization request. + pub async fn poll_status(&self, uuid: Uuid) -> Result { + self.session_store + .get_session(uuid) + .await + .map(|session| session.status) + } + + /// Retrieve an authorization request that was passed by-reference. + /// + /// This should be triggered by a request from the wallet when the verifier is configured to + /// pass the authorization request by reference using [VerifierBuilder::by_reference]. The + /// wallet will make a request to `/`. For example: + /// + /// ```ignore + /// let url: Url = "https://verifier.example.com/some/sub/path".parse()?; + /// let verifier = Verifier::builder() + /// .by_reference(url) + /// ... + /// .build() + /// .await?; + /// ``` + /// + /// The wallet will request the authorization request from + /// `GET https://verifier.example.com/some/sub/path/`. + /// + /// This will update the presentation status. + /// + /// ## Returns + /// The signed authorization request as a JWT. + pub async fn retrieve_authorization_request(&self, reference: Uuid) -> Result { + let session = self + .session_store + .get_session(reference) + .await + .context("failed to retrieve session")?; + if session.status < Status::SentRequest { + self.session_store + .update_status(reference, Status::SentRequest) + .await + .context("failed to update session status")?; + } + Ok(session.authorization_request_jwt) + } + + /// Verify an authorization response. + /// + /// This should be triggered by a request from the wallet. The wallet will submit the + /// authorization response to `/`. For example: + /// + /// ```ignore + /// let url: Url = "https://verifier.example.com/some/sub/path".parse()?; + /// let verifier = Verifier::builder() + /// .with_submission_endpoint(url) + /// ... + /// .build() + /// .await?; + /// ``` + /// + /// If using the `fragment` response mode, the wallet will submit the authorization response to + /// `GET https://verifier.example.com/some/sub/path/#`. + /// + /// If using the `direct_post` response mode, the wallet will submit the authorization response + /// to `POST https://verifier.example.com/some/sub/path/`. + /// + /// This will update the presentation status. + pub async fn verify_response( + &self, + reference: Uuid, + authorization_response: AuthorizationResponse, + validator_function: F, + ) -> Result<()> + where + F: FnOnce(Session, AuthorizationResponse) -> Pin>, + Fut: Future, + { + let session = self.session_store.get_session(reference).await?; + + let outcome = validator_function(session, authorization_response).await; + + self.session_store + .update_status(reference, Status::Complete(outcome)) + .await + } +} + +/// Builder struct for [Verifier]. +#[derive(Debug, Clone, Default)] +pub struct VerifierBuilder { + client: Option>, + default_request_params: UntypedObject, + pass_by_reference: ByReference, + session_store: Option>, + submission_endpoint: Option, +} + +impl VerifierBuilder { + /// Build the verifier. + pub async fn build(self) -> Result { + let Self { + client, + default_request_params, + pass_by_reference, + session_store, + submission_endpoint, + } = self; + + let Some(client) = client else { + bail!("client is required, see `with_client`") + }; + + let Some(session_store) = session_store else { + bail!("session store is required, see `with_session_store`") + }; + + let Some(submission_endpoint) = submission_endpoint else { + bail!("submission endpoint is required, see `with_submission_endpoint`") + }; + + Ok(Verifier { + client, + default_request_params, + pass_by_reference, + session_store, + submission_endpoint, + }) + } + + /// Encode the Authorization Request directly in the `request` parameter. + pub fn by_value(mut self) -> Self { + self.pass_by_reference = ByReference::False; + self + } + + /// Pass the Authorization Request by reference in the `request_uri` parameter. + pub fn by_reference(mut self, at: Url) -> Self { + self.pass_by_reference = ByReference::True { at }; + self + } + + /// Set default parameters that every + /// [AuthorizationRequest](crate::core::authorization_request::AuthorizationRequest) will + /// contain. + /// + /// 'client_id' and 'client_id_scheme' are always overridden by the + /// [Client](crate::verifier::client::Client). + pub fn with_default_request_parameter(mut self, t: T) -> Self { + self.default_request_params.insert(t); + self + } + + /// Set the [Client](crate::verifier::client::Client) that the [Verifier] will use to identify + /// itself to the Wallet. + pub fn with_client(mut self, client: Arc) -> Self { + self.client = Some(client); + self + } + + /// Set the [SessionStore](crate::verifier::session_store::SessionStore) that the [Verifier] + /// will use to maintain session state across transactions. + pub fn with_session_store( + mut self, + session_store: Arc, + ) -> Self { + self.session_store = Some(session_store); + self + } + + /// Set the [Url] that the [Verifier] will listen at to receive the presentation submission + /// from the Wallet. + pub fn with_submission_endpoint(mut self, endpoint: Url) -> Self { + self.submission_endpoint = Some(endpoint); + self + } +} diff --git a/src/verifier/request_builder.rs b/src/verifier/request_builder.rs new file mode 100644 index 0000000..0e5b50f --- /dev/null +++ b/src/verifier/request_builder.rs @@ -0,0 +1,169 @@ +use anyhow::{bail, Context, Result}; +use url::Url; +use uuid::Uuid; + +use crate::{ + core::{ + authorization_request::{ + self, + parameters::{ResponseMode, ResponseType, ResponseUri}, + AuthorizationRequest, AuthorizationRequestObject, RequestIndirection, + }, + metadata::{ + parameters::wallet::{AuthorizationEndpoint, ClientIdSchemesSupported}, + WalletMetadata, + }, + object::{ParsingErrorContext, TypedParameter, UntypedObject}, + }, + presentation_exchange::PresentationDefinition, + verifier::{by_reference::ByReference, session::Status}, +}; + +use super::{session::Session, Verifier}; + +#[derive(Debug, Clone)] +#[must_use] +pub struct RequestBuilder<'a> { + presentation_definition: Option, + request_parameters: UntypedObject, + verifier: &'a Verifier, +} + +impl<'a> RequestBuilder<'a> { + pub(crate) fn new(verifier: &'a Verifier) -> Self { + Self { + presentation_definition: None, + request_parameters: verifier.default_request_params.clone(), + verifier, + } + } + + /// Set the presentation definition. + pub fn with_presentation_definition( + mut self, + presentation_definition: PresentationDefinition, + ) -> Self { + self.presentation_definition = Some(presentation_definition); + self + } + + /// Set or override the default authorization request parameters. + pub fn with_request_parameter(mut self, t: T) -> Self { + self.request_parameters.insert(t); + self + } + + /// Build the request. + /// + /// ## Returns + /// - UUID that can be used by the application frontend to poll for the status of this request. + /// - URL that the application frontend should use to drive the user to their wallet application. + pub async fn build(mut self, wallet_metadata: WalletMetadata) -> Result<(Uuid, Url)> { + let uuid = Uuid::new_v4(); + + let client_id = self.verifier.client.id(); + let client_id_scheme = self.verifier.client.scheme(); + + let _ = self.request_parameters.insert(client_id.clone()); + let _ = self.request_parameters.insert(client_id_scheme.clone()); + + let Some(presentation_definition) = self.presentation_definition else { + bail!("presentation definition is required, see `with_presentation_definition`") + }; + + let _ = self.request_parameters.insert( + authorization_request::parameters::PresentationDefinition::try_from( + presentation_definition.clone(), + ) + .context("failed to construct PresentationDefinition request parameter")?, + ); + + let _ = self + .request_parameters + .get::() + .context("response type is required, see `with_request_parameter`")? + .context("error occurred when retrieving response type")?; + + match self + .request_parameters + .get::() + .context("response mode is required, see `with_request_parameter`")? + .context("error occurred when retrieving response mode")? + { + ResponseMode::DirectPost | ResponseMode::DirectPostJwt => { + let mut uri = self.verifier.submission_endpoint.clone(); + { + let Ok(mut path) = uri.path_segments_mut() else { + bail!("invalid base URL for the submission endpoint") + }; + path.push(&uuid.to_string()); + } + self.request_parameters.insert(ResponseUri(uri)); + } + ResponseMode::Unsupported(r) => bail!("unsupported response_mode: {r}"), + } + + if !wallet_metadata + .get_or_default::()? + .0 + .contains(client_id_scheme) + { + bail!("the wallet does not support the client_id_scheme '{client_id_scheme}'") + } + + let authorization_request_object: AuthorizationRequestObject = + self.request_parameters.try_into().context( + "unable to construct the Authorization Request from provided request parameters", + )?; + + let authorization_request_jwt = self + .verifier + .client + .generate_request_object_jwt(&authorization_request_object) + .await?; + + let mut initial_status = Status::SentRequest; + + let request_indirection = match self.verifier.pass_by_reference.clone() { + ByReference::False => RequestIndirection::ByValue(authorization_request_jwt.clone()), + ByReference::True { mut at } => { + { + let Ok(mut path) = at.path_segments_mut() else { + bail!("invalid base URL for Authorization Request by reference") + }; + path.push(&uuid.to_string()); + } + initial_status = Status::SentRequestByReference; + RequestIndirection::ByReference(at) + } + }; + + let authorization_endpoint = wallet_metadata + .get::() + .parsing_error()? + .0; + + let authorization_request_url = AuthorizationRequest { + client_id: client_id.0.clone(), + request_indirection, + } + .to_url(authorization_endpoint) + .context("unable to generate authorization request URL")?; + + let session = Session { + uuid, + status: initial_status, + authorization_request_jwt, + authorization_request_object, + presentation_definition, + }; + + self.verifier + .session_store + .initiate(session) + .await + .context("failed to store the session in the session store")?; + + Ok((uuid, authorization_request_url)) + } +} diff --git a/src/verifier/request_signer.rs b/src/verifier/request_signer.rs new file mode 100644 index 0000000..67cb33c --- /dev/null +++ b/src/verifier/request_signer.rs @@ -0,0 +1,50 @@ +#[cfg(feature = "p256")] +use anyhow::Result; +use async_trait::async_trait; +#[cfg(feature = "p256")] +use p256::ecdsa::{signature::Signer, Signature, SigningKey}; +use ssi::jwk::JWK; + +use std::fmt::Debug; + +#[async_trait] +pub trait RequestSigner: Debug { + /// The algorithm that will be used to sign. + fn alg(&self) -> &str; + /// The public JWK of the signer. + fn jwk(&self) -> &JWK; + async fn sign(&self, payload: &[u8]) -> Vec; +} + +#[cfg(feature = "p256")] +#[derive(Debug)] +pub struct P256Signer { + key: SigningKey, + jwk: JWK, +} + +#[cfg(feature = "p256")] +impl P256Signer { + pub fn new(key: SigningKey) -> Result { + let pk: p256::PublicKey = key.verifying_key().into(); + let jwk = serde_json::from_str(&pk.to_jwk_string())?; + Ok(Self { key, jwk }) + } +} + +#[cfg(feature = "p256")] +#[async_trait] +impl RequestSigner for P256Signer { + fn alg(&self) -> &str { + "ES256" + } + + fn jwk(&self) -> &JWK { + &self.jwk + } + + async fn sign(&self, payload: &[u8]) -> Vec { + let sig: Signature = self.key.sign(payload); + sig.to_vec() + } +} diff --git a/src/verifier/session.rs b/src/verifier/session.rs new file mode 100644 index 0000000..0380b95 --- /dev/null +++ b/src/verifier/session.rs @@ -0,0 +1,123 @@ +use std::{collections::BTreeMap, fmt::Debug, sync::Arc}; + +use anyhow::{bail, Error, Ok, Result}; +use async_trait::async_trait; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::{ + core::authorization_request::AuthorizationRequestObject, + presentation_exchange::PresentationDefinition, +}; + +#[derive(Debug, Clone)] +pub struct Session { + pub uuid: Uuid, + pub status: Status, + pub authorization_request_jwt: String, + pub authorization_request_object: AuthorizationRequestObject, + pub presentation_definition: PresentationDefinition, +} + +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub enum Status { + /// Wallet has been sent the request by reference, waiting for the wallet to request the request. + SentRequestByReference, + /// Wallet has received the request, waiting on the wallet to process the request. + SentRequest, + /// Verifier has received the response and is now processing it. + ReceivedResponse, + /// Verifier has finished processing the response. + Complete(Outcome), +} + +#[derive(Debug, Clone)] +pub enum Outcome { + /// An error occurred during response processing. + Error { cause: Arc }, + /// The authorization response did not pass verification. + Failure { reason: String }, + /// The authorization response is verified. + Success, +} + +/// Storage interface for session information. +#[async_trait] +pub trait SessionStore: Debug { + /// Store a new authorization request session. + async fn initiate(&self, session: Session) -> Result<()>; + + /// Update the status of a session. + async fn update_status(&self, uuid: Uuid, status: Status) -> Result<()>; + + /// Get a session from the store. + async fn get_session(&self, uuid: Uuid) -> Result; + + /// Remove a session from the store. + async fn remove_session(&self, uuid: Uuid) -> Result<()>; +} + +/// A local in-memory store. Not for production use! +/// +/// # Warning +/// This in-memory store should only be used for test purposes, it will not work for a distributed +/// deployment. +#[derive(Debug, Clone, Default)] +pub struct MemoryStore { + store: Arc>>, +} + +#[async_trait] +impl SessionStore for MemoryStore { + async fn initiate(&self, session: Session) -> Result<()> { + self.store.try_lock()?.insert(session.uuid, session); + + Ok(()) + } + + async fn update_status(&self, uuid: Uuid, status: Status) -> Result<()> { + if let Some(session) = self.store.try_lock()?.get_mut(&uuid) { + session.status = status; + return Ok(()); + } + bail!("session not found") + } + + async fn get_session(&self, uuid: Uuid) -> Result { + if let Some(session) = self.store.try_lock()?.get(&uuid) { + return Ok(session.clone()); + } + + bail!("session not found") + } + + async fn remove_session(&self, uuid: Uuid) -> Result<()> { + if self.store.try_lock()?.remove(&uuid).is_some() { + return Ok(()); + } + + bail!("session not found") + } +} + +impl PartialEq for Outcome { + fn eq(&self, other: &Self) -> bool { + core::mem::discriminant(self) == core::mem::discriminant(other) + } +} + +impl Outcome { + fn ordering(&self) -> u8 { + match self { + Outcome::Error { .. } => 0, + Outcome::Failure { .. } => 1, + Outcome::Success => 2, + } + } +} + +impl PartialOrd for Outcome { + fn partial_cmp(&self, other: &Self) -> Option { + self.ordering().partial_cmp(&other.ordering()) + } +} diff --git a/src/wallet.rs b/src/wallet.rs new file mode 100644 index 0000000..074d0a4 --- /dev/null +++ b/src/wallet.rs @@ -0,0 +1,88 @@ +use anyhow::{bail, Context, Result}; +use async_trait::async_trait; +use http::header::CONTENT_TYPE; +use tracing::warn; +use url::Url; + +use crate::core::{ + authorization_request::{ + parameters::ResponseMode, verification::RequestVerifier, AuthorizationRequest, + AuthorizationRequestObject, + }, + metadata::WalletMetadata, + response::{AuthorizationResponse, PostRedirection}, + util::{base_request, AsyncHttpClient}, +}; + +#[async_trait] +pub trait Wallet: RequestVerifier + Sync { + type HttpClient: AsyncHttpClient + Send + Sync; + + fn metadata(&self) -> &WalletMetadata; + fn http_client(&self) -> &Self::HttpClient; + + async fn validate_request(&self, url: Url) -> Result { + let ar = AuthorizationRequest::from_url(url, &self.metadata().authorization_endpoint().0) + .context("unable to parse authorization request")?; + ar.validate(self) + .await + .context("unable to validate authorization request") + } + + async fn submit_response( + &self, + request: AuthorizationRequestObject, + response: AuthorizationResponse, + ) -> Result> { + let mut http_request_builder = base_request().uri(request.return_uri().as_str()); + + let http_request_body = match request.response_mode() { + ResponseMode::DirectPost => { + http_request_builder = http_request_builder + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .method("POST"); + + let AuthorizationResponse::Unencoded(unencoded) = response else { + bail!("unexpected AuthorizationResponse format") + }; + + unencoded.into_x_www_form_urlencoded()?.into_bytes() + } + ResponseMode::DirectPostJwt => { + http_request_builder = http_request_builder + .header(CONTENT_TYPE, "application/x-www-form-urlencoded") + .method("POST"); + + let AuthorizationResponse::Jwt(jwt) = response else { + bail!("unexpected AuthorizationResponse format") + }; + + jwt.into_x_www_form_urlencoded()?.into_bytes() + } + ResponseMode::Unsupported(rm) => bail!("unsupported response_mode {rm}"), + }; + + let http_request = http_request_builder + .body(http_request_body) + .context("failed to construct presentation submission request")?; + let http_response = self + .http_client() + .execute(http_request) + .await + .context("failed to make authorization response request")?; + + let status = http_response.status(); + let Ok(body) = String::from_utf8(http_response.into_body()) else { + bail!("failed to parse authorization response response as UTF-8 (status: {status})") + }; + + if !status.is_success() { + bail!("authorization response request was unsuccessful (status: {status}): {body}") + } + + Ok(serde_json::from_str(&body) + .map_err(|e| warn!("response did not contain a redirect: {e}")) + .ok() + .map(|PostRedirection { redirect_uri }| redirect_uri)) + } +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..a9f8b22 --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,96 @@ +use oid4vp::{ + core::{ + authorization_request::parameters::{ClientMetadata, Nonce, ResponseMode, ResponseType}, + object::UntypedObject, + response::{parameters::VpToken, AuthorizationResponse, UnencodedAuthorizationResponse}, + }, + presentation_exchange::{PresentationDefinition, PresentationSubmission}, + verifier::session::{Outcome, Status}, + wallet::Wallet, +}; +use serde_json::json; + +mod jwt_vc; + +#[tokio::test] +async fn w3c_vc_did_client_direct_post() { + let (wallet, verifier) = jwt_vc::wallet_verifier().await; + + let presentation_definition: PresentationDefinition = serde_json::from_value(json!({ + "id": "0b4dd017-efa6-4a05-a269-9790fa3c22c2", + "input_descriptors": [ + { + "id": "vc", + "format": { + "jwt_vc_json": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + } + } + ] + })) + .unwrap(); + + let client_metadata = UntypedObject::default(); + + let (id, request) = verifier + .build_authorization_request() + .with_presentation_definition(presentation_definition.clone()) + .with_request_parameter(ResponseMode::DirectPost) + .with_request_parameter(ResponseType::VpToken) + .with_request_parameter(Nonce("random123".to_owned())) + .with_request_parameter(ClientMetadata(client_metadata)) + .build(wallet.metadata().clone()) + .await + .unwrap(); + + let request = wallet.validate_request(request).await.unwrap(); + + assert_eq!( + &presentation_definition, + request + .resolve_presentation_definition(wallet.http_client()) + .await + .unwrap() + .parsed() + ); + + assert_eq!(&ResponseType::VpToken, request.response_type()); + + assert_eq!(&ResponseMode::DirectPost, request.response_mode()); + + // TODO: Response with a VP. + let presentation_submission: PresentationSubmission = serde_json::from_value(json!( + { + "id": "39881a17-e454-4d98-87ba-e3073d1014d6", + "definition_id": "0b4dd017-efa6-4a05-a269-9790fa3c22c2", + "descriptor_map": [ + { + "id": "vc", + "path": "$", + "format": "jwt_vc_json" + } + ] + } + + )) + .unwrap(); + + let response = AuthorizationResponse::Unencoded(UnencodedAuthorizationResponse( + Default::default(), + VpToken(include_str!("examples/vc.jwt").to_owned()), + presentation_submission.try_into().unwrap(), + )); + + let status = verifier.poll_status(id).await.unwrap(); + assert_eq!(Status::SentRequest, status); + + let redirect = wallet.submit_response(request, response).await.unwrap(); + + assert_eq!(None, redirect); + + let status = verifier.poll_status(id).await.unwrap(); + assert_eq!(Status::Complete(Outcome::Success), status); +} diff --git a/tests/examples/issuer.jwk b/tests/examples/issuer.jwk new file mode 100644 index 0000000..e074505 --- /dev/null +++ b/tests/examples/issuer.jwk @@ -0,0 +1,7 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "01cl8hOEBQ_cNy9I4Xhj17bepo8WmRwNzfLRxMm8V8Q", + "y": "7qM2BgesrjeSmY2TiH6foLgxIypR5XAx5uN4uEo2VA4", + "d": "_tBnQlvK_oRlUI-LW5R68vF1bkOROkfjq_VIW04wYGI" +} \ No newline at end of file diff --git a/tests/examples/subject.jwk b/tests/examples/subject.jwk new file mode 100644 index 0000000..34c22b1 --- /dev/null +++ b/tests/examples/subject.jwk @@ -0,0 +1,7 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "5ONGSVuRFE0zAhtfytQwq4EqX8kaJo0a7i7cpCEs47k", + "y": "tlZ00_TwKOen96XsxTXYbnSgH4M91JAzR742vYeKE9o", + "d": "6KaVgPBkYYex79CnRPtErB0i-4pB2xGvTEQkPM6JLoo" +} \ No newline at end of file diff --git a/tests/examples/vc.jwt b/tests/examples/vc.jwt new file mode 100644 index 0000000..e7354c1 --- /dev/null +++ b/tests/examples/vc.jwt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDprZXk6ekRuYWVlZXg5TUFWYmhvV2VEY2JiR1pkek0xenhxWnFwQzM4N2pXb0xoVXIxQmRTVCN6RG5hZWVleDlNQVZiaG9XZURjYmJHWmR6TTF6eHFacXBDMzg3aldvTGhVcjFCZFNUIn0.eyJpc3MiOiJkaWQ6a2V5OnpEbmFlZWV4OU1BVmJob1dlRGNiYkdaZHpNMXp4cVpxcEMzODdqV29MaFVyMUJkU1QiLCJuYmYiOjE3MDQwNjcyMDAuMCwianRpIjoiMmMyODJlYmEtZDE0Ny00MjJjLTljZTUtOTkxZjE5OTgwMGM1Iiwic3ViIjoiZGlkOmtleTp6RG5hZWZxVDFCckdHc0pFWkd3QWl1ZW91cU1oNk1xc1poYUwxbWQ1aGtIZ3RmemIyIiwidmMiOnsiQGNvbnRleHQiOiJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImlkIjoiMmMyODJlYmEtZDE0Ny00MjJjLTljZTUtOTkxZjE5OTgwMGM1IiwidHlwZSI6IlZlcmlmaWFibGVDcmVkZW50aWFsIiwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5OnpEbmFlZnFUMUJyR0dzSkVaR3dBaXVlb3VxTWg2TXFzWmhhTDFtZDVoa0hndGZ6YjIifSwiaXNzdWVyIjoiZGlkOmtleTp6RG5hZWVleDlNQVZiaG9XZURjYmJHWmR6TTF6eHFacXBDMzg3aldvTGhVcjFCZFNUIiwiaXNzdWFuY2VEYXRlIjoiMjAyNC0wMS0wMVQwMDowMDowMCswMDowMCJ9fQ.0M9jKe1x00s306nqTxO5kssomwlI5aTJxGAogpF2f6ed0DdPwsJG_8jqqvag5r0JWijLvZBznEW6KkEPXTePGA \ No newline at end of file diff --git a/tests/examples/verifier.jwk b/tests/examples/verifier.jwk new file mode 100644 index 0000000..054d072 --- /dev/null +++ b/tests/examples/verifier.jwk @@ -0,0 +1,7 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "kXIRbpKsO6WeRubwgugR1g6DcaOsAnikUruYu6A-GUc", + "y": "0nVuD6NHPyAD8av9gs3Xz4J1Og5dAMd93u15kDpfINo", + "d": "oswdMrk9rGbL4-RqvfFtcT-oGVh8xJR3DaJ8CiV9cHw" +} \ No newline at end of file diff --git a/tests/jwt_vc.rs b/tests/jwt_vc.rs new file mode 100644 index 0000000..0c218b7 --- /dev/null +++ b/tests/jwt_vc.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use async_trait::async_trait; +use did_method_key::DIDKey; +use http::{Request, Response}; +use oid4vp::{ + core::{ + authorization_request::{ + verification::{did, RequestVerifier}, + AuthorizationRequestObject, + }, + metadata::WalletMetadata, + response::AuthorizationResponse, + util::AsyncHttpClient, + }, + verifier::{ + request_signer::P256Signer, + session::{MemoryStore, Outcome}, + Verifier, + }, + wallet::Wallet, +}; +use serde_json::json; +use ssi::did::DIDMethod; + +pub async fn wallet_verifier() -> (JwtVcWallet, Arc) { + let verifier_did = "did:key:zDnaeaDj3YpPR4JXos2kCCNPS86hdELeN5PZh97KGkoFzUtGn".to_owned(); + let verifier_did_vm = + "did:key:zDnaeaDj3YpPR4JXos2kCCNPS86hdELeN5PZh97KGkoFzUtGn#zDnaeaDj3YpPR4JXos2kCCNPS86hdELeN5PZh97KGkoFzUtGn".to_owned(); + let signer = Arc::new( + P256Signer::new( + p256::SecretKey::from_jwk_str(include_str!("examples/verifier.jwk")) + .unwrap() + .into(), + ) + .unwrap(), + ); + let client = Arc::new( + oid4vp::verifier::client::DIDClient::new( + verifier_did_vm.clone(), + signer.clone(), + DIDKey.to_resolver(), + ) + .await + .unwrap(), + ); + let verifier = Arc::new( + Verifier::builder() + .with_client(client) + .with_submission_endpoint("http://example.com/submission".parse().unwrap()) + .with_session_store(Arc::new(MemoryStore::default())) + .build() + .await + .unwrap(), + ); + + let http_client = MockHttpClient { + verifier: verifier.clone(), + }; + + let metadata = serde_json::from_value(json!( + { + "authorization_endpoint": "openid4vp:", + "client_id_schemes_supported": [ + "did" + ], + "request_object_signing_alg_values_supported": [ + "ES256" + ], + "response_types_supported": [ + "vp_token" + ], + "vp_formats_supported": { + "jwt_vc_json": { + "alg_values_supported": ["ES256"] + } + } + } + )) + .unwrap(); + + ( + JwtVcWallet { + http_client, + metadata, + trusted_dids: vec![verifier_did], + }, + verifier, + ) +} + +pub struct JwtVcWallet { + http_client: MockHttpClient, + metadata: WalletMetadata, + trusted_dids: Vec, +} + +pub struct MockHttpClient { + verifier: Arc, +} + +impl JwtVcWallet { + fn trusted_dids(&self) -> &[String] { + &self.trusted_dids + } +} + +#[async_trait] +impl Wallet for JwtVcWallet { + type HttpClient = MockHttpClient; + + fn http_client(&self) -> &Self::HttpClient { + &self.http_client + } + fn metadata(&self) -> &WalletMetadata { + &self.metadata + } +} + +#[async_trait] +impl RequestVerifier for JwtVcWallet { + async fn did( + &self, + decoded_request: &AuthorizationRequestObject, + request_jwt: String, + ) -> Result<()> { + did::verify_with_resolver( + self.metadata(), + decoded_request, + request_jwt, + Some(self.trusted_dids()), + DIDKey.to_resolver(), + ) + .await + } +} + +#[async_trait] +impl AsyncHttpClient for MockHttpClient { + async fn execute(&self, request: Request>) -> Result>> { + // Only expect submission. + let body = request.body(); + let uri = request.uri(); + let id = uri + .path() + .strip_prefix("/submission/") + .context("failed to extract id from path")?; + + self.verifier + .verify_response( + id.parse().context("failed to parse id")?, + AuthorizationResponse::from_x_www_form_urlencoded(body) + .context("failed to parse authorization response request")?, + |_, _| Box::pin(async { Outcome::Success }), + ) + .await?; + + Response::builder() + .status(200) + .body(vec![]) + .context("failed to build response") + } +} diff --git a/test/presentation-exchange b/tests/presentation-exchange similarity index 100% rename from test/presentation-exchange rename to tests/presentation-exchange