diff --git a/Cargo.lock b/Cargo.lock index 79f78037..afeb7c53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,8 +86,10 @@ dependencies = [ "axum 0.8.3", "axum-auth 0.8.1", "axum-macros", + "base64 0.22.1", "chrono", "cqrs-es", + "did_manager", "futures", "http 1.3.1", "http-api-problem", @@ -96,24 +98,28 @@ dependencies = [ "identity_core", "identity_credential", "identity_did", + "identity_iota", + "identity_jose", "jsonwebtoken", "lazy_static", "metrics", "metrics-exporter-prometheus", "mime", "oauth_tsl", - "oid4vc-core", - "oid4vc-manager", - "oid4vci", - "oid4vp", + "oid4vc", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vc-manager 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vp 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "rand 0.9.1", + "reqwest 0.12.20", "rstest", "serde", "serde_json", "serde_urlencoded", "serde_with 3.13.0", "serial_test", - "siopv2", + "siopv2 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "thiserror 1.0.69", "tokio", "tower 0.4.13", @@ -195,8 +201,8 @@ dependencies = [ "lazy_static", "mime", "names", - "oid4vc-core", - "oid4vci", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "rand 0.9.1", "reqwest 0.12.20", "rstest", @@ -247,7 +253,7 @@ dependencies = [ "lazy_static", "mime", "names", - "oid4vc-core", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "product_common", "rand 0.9.1", "reqwest 0.12.20", @@ -288,8 +294,8 @@ dependencies = [ "jsonwebtoken", "lazy_static", "oauth_tsl", - "oid4vc-core", - "oid4vci", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "once_cell", "rand 0.9.1", "reqwest 0.12.20", @@ -336,7 +342,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "log", - "oid4vc-core", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "p256 0.13.2", "ring", "serde", @@ -359,9 +365,9 @@ dependencies = [ "http-serde", "identity_iota", "jsonwebtoken", - "oid4vc-core", - "oid4vci", - "oid4vp", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vp 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "once_cell", "rand 0.9.1", "reqwest 0.12.20", @@ -414,16 +420,16 @@ dependencies = [ "identity_credential", "jsonwebtoken", "lazy_static", - "oid4vc-core", - "oid4vc-manager", - "oid4vci", - "oid4vp", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vc-manager 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vp 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "reqwest 0.12.20", "rstest", "serde", "serde_json", "serial_test", - "siopv2", + "siopv2 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "strum 0.26.3", "strum_macros 0.26.4", "thiserror 1.0.69", @@ -3041,6 +3047,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "did_manager" +version = "0.1.0" +source = "git+https://github.com/impierce/did-manager?tag=v1.0.0-beta.6#e8f68b575a3ee354ac4b08663939917977974556" +dependencies = [ + "consumer", + "producer", +] + [[package]] name = "did_url" version = "0.1.0" @@ -8185,6 +8200,43 @@ dependencies = [ "asn1-rs", ] +[[package]] +name = "oid4vc" +version = "0.1.0" +source = "git+https://github.com/impierce/openid4vc?rev=4cbf854#4cbf854a51a2b3b1e7441804addae9785091990c" +dependencies = [ + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "oid4vc-manager 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "oid4vp 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "siopv2 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", +] + +[[package]] +name = "oid4vc-core" +version = "0.1.0" +source = "git+https://github.com/impierce/openid4vc?rev=4cbf854#4cbf854a51a2b3b1e7441804addae9785091990c" +dependencies = [ + "anyhow", + "async-trait", + "base64-url", + "derivative", + "derive_more 0.99.20", + "did_url", + "ed25519-dalek 2.1.1", + "getset", + "is_empty", + "jsonwebtoken", + "lazy_static", + "nutype", + "rand 0.8.5", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with 2.3.3", + "url", +] + [[package]] name = "oid4vc-core" version = "0.1.0" @@ -8210,6 +8262,39 @@ dependencies = [ "url", ] +[[package]] +name = "oid4vc-manager" +version = "0.1.0" +source = "git+https://github.com/impierce/openid4vc?rev=4cbf854#4cbf854a51a2b3b1e7441804addae9785091990c" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.6.20", + "axum-auth 0.4.1", + "chrono", + "derivative", + "did-key", + "did_url", + "futures", + "getset", + "identity_credential", + "jsonwebtoken", + "lazy_static", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "oid4vp 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "paste", + "reqwest 0.11.27", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with 3.13.0", + "siopv2 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "tokio", + "tower-http 0.4.4", + "url", +] + [[package]] name = "oid4vc-manager" version = "0.1.0" @@ -8228,21 +8313,49 @@ dependencies = [ "identity_credential", "jsonwebtoken", "lazy_static", - "oid4vc-core", - "oid4vci", - "oid4vp", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vp 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "paste", "reqwest 0.11.27", "serde", "serde_json", "serde_urlencoded", "serde_with 3.13.0", - "siopv2", + "siopv2 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "tokio", "tower-http 0.4.4", "url", ] +[[package]] +name = "oid4vci" +version = "0.1.0" +source = "git+https://github.com/impierce/openid4vc?rev=4cbf854#4cbf854a51a2b3b1e7441804addae9785091990c" +dependencies = [ + "anyhow", + "chrono", + "derivative", + "getset", + "http 1.3.1", + "jsonwebtoken", + "lazy_static", + "nutype", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "paste", + "pkce", + "reqwest 0.11.27", + "reqwest-middleware", + "reqwest-retry", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with 3.13.0", + "thiserror 1.0.69", + "tokio", + "url", +] + [[package]] name = "oid4vci" version = "0.1.0" @@ -8256,7 +8369,7 @@ dependencies = [ "jsonwebtoken", "lazy_static", "nutype", - "oid4vc-core", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "paste", "pkce", "reqwest 0.11.27", @@ -8271,6 +8384,34 @@ dependencies = [ "url", ] +[[package]] +name = "oid4vp" +version = "0.1.0" +source = "git+https://github.com/impierce/openid4vc?rev=4cbf854#4cbf854a51a2b3b1e7441804addae9785091990c" +dependencies = [ + "anyhow", + "chrono", + "futures", + "getset", + "identity_credential", + "is_empty", + "jsonwebtoken", + "lazy_static", + "monostate", + "nutype", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "reqwest 0.11.27", + "reqwest-middleware", + "reqwest-retry", + "serde", + "serde_json", + "serde_with 3.13.0", + "tokio", + "url", + "validator", +] + [[package]] name = "oid4vp" version = "0.1.0" @@ -8286,8 +8427,8 @@ dependencies = [ "lazy_static", "monostate", "nutype", - "oid4vc-core", - "oid4vci", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", + "oid4vci 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "reqwest 0.11.27", "reqwest-middleware", "reqwest-retry", @@ -9175,6 +9316,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "producer" +version = "0.1.0" +source = "git+https://github.com/impierce/did-manager?tag=v1.0.0-beta.6#e8f68b575a3ee354ac4b08663939917977974556" +dependencies = [ + "did_iota", + "did_jwk", + "did_key", + "did_web", + "identity_iota", + "identity_storage", + "identity_stronghold", + "identity_stronghold_ext", + "iota-sdk 1.1.5", + "iota_stronghold", + "log", + "serde", + "serde_json", + "shared", + "url", +] + [[package]] name = "product_common" version = "0.8.2" @@ -11002,6 +11165,34 @@ dependencies = [ "time", ] +[[package]] +name = "siopv2" +version = "0.1.0" +source = "git+https://github.com/impierce/openid4vc?rev=4cbf854#4cbf854a51a2b3b1e7441804addae9785091990c" +dependencies = [ + "anyhow", + "async-trait", + "base64-url", + "chrono", + "derive_more 0.99.20", + "did_url", + "futures", + "getset", + "is_empty", + "jsonwebtoken", + "monostate", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=4cbf854)", + "reqwest 0.11.27", + "reqwest-middleware", + "reqwest-retry", + "serde", + "serde_json", + "serde_urlencoded", + "serde_with 3.13.0", + "tokio", + "url", +] + [[package]] name = "siopv2" version = "0.1.0" @@ -11018,7 +11209,7 @@ dependencies = [ "is_empty", "jsonwebtoken", "monostate", - "oid4vc-core", + "oid4vc-core 0.1.0 (git+https://github.com/impierce/openid4vc?rev=99fc7be)", "reqwest 0.11.27", "reqwest-middleware", "reqwest-retry", diff --git a/agent_api_rest/Cargo.toml b/agent_api_rest/Cargo.toml index c2c038a6..b47280e2 100644 --- a/agent_api_rest/Cargo.toml +++ b/agent_api_rest/Cargo.toml @@ -16,8 +16,11 @@ anyhow.workspace = true axum.workspace = true axum-auth = "0.8" axum-macros = "0.5" +base64.workspace = true chrono.workspace = true cqrs-es.workspace = true +did_manager = { git = "https://github.com/impierce/did-manager", tag = "v1.0.0-beta.6" } +futures.workspace = true http = "1.3" http-api-problem = { version = "0.60", features = ["axum", "api-error"] } http-body-util = "0.1" @@ -25,13 +28,18 @@ hyper = { version = "1.2" } identity_core.workspace = true identity_credential.workspace = true identity_did.workspace = true +identity_iota.workspace = true +identity_jose = { git = "https://github.com/iotaledger/identity", tag = "v1.6.0-beta.7" } +jsonwebtoken.workspace = true metrics = "0.24" metrics-exporter-prometheus = { version = "0.17", default-features = false } oauth_tsl.workspace = true +oid4vc = { git = "https://github.com/impierce/openid4vc", rev = "4cbf854" } oid4vc-core.workspace = true oid4vci.workspace = true oid4vp.workspace = true rand.workspace = true +reqwest.workspace = true serde.workspace = true serde_with.workspace = true serde_json.workspace = true @@ -61,7 +69,6 @@ agent_verification = { path = "../agent_verification", features = [ "test_utils", ] } -futures.workspace = true jsonwebtoken.workspace = true lazy_static.workspace = true mime.workspace = true diff --git a/agent_api_rest/src/issuance/credential_issuer/credential.rs b/agent_api_rest/src/issuance/credential_issuer/credential.rs index 152004bf..a79d1fd1 100644 --- a/agent_api_rest/src/issuance/credential_issuer/credential.rs +++ b/agent_api_rest/src/issuance/credential_issuer/credential.rs @@ -162,7 +162,7 @@ pub mod tests { Mock, MockServer, ResponseTemplate, }; - const CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2lpZXlvTE1TVnNKQVp2N0pqZTV3V1NrREV5bVVna3lGOGtiY3JqWnBYM3FkIiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtpaWV5b0xNU1ZzSkFadjdKamU1d1dTa0RFeW1VZ2t5RjhrYmNyalpwWDNxZCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsImNyZWRlbnRpYWxTdGF0dXMiOnsiaWQiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAiLCJ0eXBlIjoic3RhdHVzbGlzdCtqd3QiLCJpZHgiOjEyMywidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIn19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.LpNq8l-qqqCA-htsB8KZLaVoNCfxqTrsPxVmEj0dsPAGFhOqO8lXI7DU0FhNwzWedxJ1ySS_Vq7ChBW-TgY7Bw"; + const CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2lpZXlvTE1TVnNKQVp2N0pqZTV3V1NrREV5bVVna3lGOGtiY3JqWnBYM3FkIiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsImp0aSI6InRlc3QtY3JlZGVudGlhbC1pZC0xMjM0NSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtpaWV5b0xNU1ZzSkFadjdKamU1d1dTa0RFeW1VZ2t5RjhrYmNyalpwWDNxZCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsImNyZWRlbnRpYWxTdGF0dXMiOnsiaWQiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAiLCJ0eXBlIjoic3RhdHVzbGlzdCtqd3QiLCJpZHgiOjEyMywidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIn19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.jtRMtLPjz8ji3XiJVEkAu65LMO-UT8Mmbw4hoyXaOO4qHZwdcjbAf755Bs6LqMykN5iWxNrgoZ4-s3Aee335AQ"; const DEFAULT_EXTERNAL_SERVER_RESPONSE_TIMEOUT_MS: u64 = 1000; trait CredentialEventTrigger { diff --git a/agent_api_rest/src/issuance/mod.rs b/agent_api_rest/src/issuance/mod.rs index 84559d8b..e7ec239b 100644 --- a/agent_api_rest/src/issuance/mod.rs +++ b/agent_api_rest/src/issuance/mod.rs @@ -1,9 +1,9 @@ pub mod credential_configurations; pub mod credential_issuer; pub mod credentials; -pub mod offers; - pub mod error; +pub mod offers; +pub mod public_credential; use crate::issuance::{ credential_configurations::credential_configurations, @@ -19,6 +19,7 @@ use crate::issuance::{ }, credentials::{all_credentials, credentials, patch_credential}, offers::{all_offers, offer, offers, send::send}, + public_credential::public_credential, }; use crate::API_VERSION; use agent_issuance::state::IssuanceState; @@ -38,7 +39,8 @@ pub fn router(issuance_state: IssuanceState) -> Router { .route("/credential-configurations", post(credential_configurations)) .route("/offers", post(offers).get(all_offers)) .route("/offers/{offer_id}", get(offer)) - .route("/offers/send", post(send)), + .route("/offers/send", post(send)) + .route("/public-credential", get(public_credential)), ) .route( "/.well-known/oauth-authorization-server", diff --git a/agent_api_rest/src/issuance/public_credential.rs b/agent_api_rest/src/issuance/public_credential.rs new file mode 100644 index 00000000..9bfa7996 --- /dev/null +++ b/agent_api_rest/src/issuance/public_credential.rs @@ -0,0 +1,264 @@ +use crate::handlers::query_handler; +use agent_holder::credential::aggregate::get_unverified_jwt_claims; +use agent_issuance::state::IssuanceState; +use agent_secret_manager::subject::get_public_key_from_kid; +use agent_shared::config::{get_all_enabled_did_methods, get_all_enabled_signing_algorithms_supported}; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use http_api_problem::ApiError; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::Deserialize; +use tracing::info; + +#[derive(Deserialize)] +pub struct PublicLinkQuery { + #[serde(rename = "public-credential-token")] + public_credential_token: String, +} + +/// This endpoint receives a Public Credential Token as a query parameter and then perform several validation steps on the token. +/// When all validations pass, the requested credential is returned in the response. +/// When any validation fails, only the error is returned. +/// Both the verifier and the Issuer need to perform all these checks on the Public Credential Token, zero trust is assumed. +pub async fn public_credential( + State(state): State, + Query(parameter): Query, +) -> Result { + let jwt = parameter.public_credential_token; + + // Get unverified claims + let jwt_value = serde_json::Value::String(jwt.clone()); + let claims = get_unverified_jwt_claims(&jwt_value).map_err(|_| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid JWT") + .message("Failed to decode Public Credential Token") + .finish() + })?; + + // The subject of the Public Credential Token is the JTI (credential ID) of the issued credential which a Verifier is trying to access + let sub = claims.get("sub").and_then(|v| v.as_str()).ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to get `sub` claim from Public Credential Token") + .finish() + })?; + + // Because the JTI needs to be a valid URL, we appended the credential ID to the issuer URL of the Public Credential Token. + // This means only the last segment of the `sub` claim is the actual credential ID. + let credential_id = sub.rsplit('/').next().ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to parse credential ID from `sub` claim in Public Credential Token") + .finish() + })?; + + // Get the credential, if there is one for the given `sub` + let credential = query_handler(credential_id, &state.query.credential) + .await? + .ok_or_else(|| { + ApiError::builder(StatusCode::NOT_FOUND) + .title("Invalid Token") + .message("Public Credential Token `sub` claim does not hold a credential ID to a valid credential") + .finish() + })?; + + info!("credential data: {:#?}", credential.signed); + let signed_credential = credential.signed.ok_or( + ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) + .title("Invalid internal credential stored for given credential ID") + .message("Unable to read the JWT of the requested credential") + .finish(), + )?; + let data = get_unverified_jwt_claims(&signed_credential).map_err(|_| { + ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) + .title("Invalid internal credential stored for given credential ID") + .message("Unable to read the JWT of the requested credential") + .finish() + })?; + + // Extract credential subject ID (we need as_ref to avoid moving credential) + let credential_subject_id = data + .get("vc") + .and_then(|vc| vc.get("credentialSubject")) + .and_then(|cs| cs.get("id")) + .and_then(|id| id.as_str()) + .ok_or_else(|| { + ApiError::builder(StatusCode::UNPROCESSABLE_ENTITY) + .title("Invalid Credential") + .message("Requested credential is missing the credentialSubject.id field. Publicly sharing anonymous credentials is not supported.") + .finish() + })?; + + // Extract iss from claims and validate + let iss = claims.get("iss").and_then(|v| v.as_str()).ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to get `iss` claim from Public Credential Token") + .finish() + })?; + + info!("issuer of Public Credential Token: {}", iss); + info!( + "credential subject ID of requested credential: {}", + credential_subject_id + ); + + // Check whether the issuer of the Public Credential Token matches the subject of the requested credential + // TODO + // This check means we currently don't allow to publicly share anonymous credentials + // if iss != credential_subject_id { + // return Err(ApiError::builder(StatusCode::UNAUTHORIZED) + // .title("Invalid Token") + // .message("Public Credential Token issuer does not match requested credential subject") + // .finish()); + // } + + // TODO: validate status + + // TODO: skip `aud` validation for now + // // Extract the `aud` claim + // let aud = claims.get("aud").and_then(|v| v.as_str()).ok_or_else(|| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Token") + // .message("Failed to get `aud` claim from Public Credential Token") + // .finish() + // })?; + + // // Validate that the `aud` matches an enabled DID of this Unicorn instance + // let supported_signing_algorithms = get_all_enabled_signing_algorithms_supported(); + // let enabled_did_methods = get_all_enabled_did_methods(); + + // let mut dids = Vec::new(); + + // // TODO: this is ugly but for now the easiest way for me to get all did_methods for the hackathon + // // In fact i don't need the full identifier (kid), just the DID. + // for did_method in &enabled_did_methods { + // for alg in &supported_signing_algorithms { + // let did = state + // .subject + // .identifier(did_method.to_string().as_ref(), *alg) + // .await + // .map_err(|_| ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR).finish())?; + + // dids.push( + // did.split('#') + // .next() + // .ok_or(ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR))? + // .to_string(), + // ); + // } + // } + + // if !dids.contains(&aud.to_string()) { + // return Err(ApiError::builder(StatusCode::UNAUTHORIZED) + // .title("Invalid Token") + // .message("Public Credential Token audience does not match this holder's DID") + // .finish()); + // } + + // Decode header to get kid + let jwt_header = decode_header(&jwt).map_err(|e| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message(format!("Failed to decode Public Credential Token header: {e}")) + .finish() + })?; + + let kid = jwt_header.kid.ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to get `kid` from Public Credential Token header") + .finish() + })?; + + // Validate the kid belongs to the same DID as credential subject + // TODO + // let kid_did = kid.split('#').next().unwrap_or(&kid); + // if kid_did != credential_subject_id { + // return Err(ApiError::builder(StatusCode::UNAUTHORIZED) + // .title("Invalid Token") + // .message("Public Credential Token kid does not match requested credential subject DID") + // .finish()); + // } + + // Fetch the public key using the kid + let public_key = get_public_key_from_kid(&kid).await.map_err(|_| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to retrieve public key for kid") + .finish() + })?; + + // Create decoding key based on the algorithm + let decoding_key = match jwt_header.alg { + Algorithm::EdDSA => DecodingKey::from_ed_der(&public_key), + Algorithm::ES256 => DecodingKey::from_ec_der(&public_key), + _ => { + return Err(ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message(format!( + "Public Credential Token kid uses an unsupported algorithm: {:?}", + jwt_header.alg + )) + .finish()); + } + }; + + let mut validation = Validation::new(jwt_header.alg); + validation.validate_aud = false; // we are skipping aud validation for now + // TODO: more validation parameters should be set + + // Decode and verify the JWT signature + let _token_data = decode::(&jwt, &decoding_key, &validation).map_err(|e| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message(format!("JWT verification failed: {}", e)) + .finish() + })?; + + // Return the credential if all validations pass + Ok((StatusCode::OK, Json(signed_credential)).into_response()) +} + +#[cfg(test)] +pub mod tests { + use crate::issuance::router; + use crate::API_VERSION; + use agent_issuance::state::initialize; + use agent_secret_manager::service::Service; + use agent_store::in_memory; + use axum::{ + body::Body, + http::{self, Request}, + }; + use tower::Service as _; + + #[tokio::test] + #[tracing_test::traced_test] + async fn test_public_credential_endpoint_invalid_parameter() { + let issuance_state = in_memory::issuance_state(Service::default(), Default::default()).await; + initialize(&issuance_state).await.unwrap(); + + let mut app = router(issuance_state); + + let response = app + .call( + Request::builder() + .method(http::Method::GET) + .uri(format!( + "{API_VERSION}/public-credential?public_credential_token=invalid" + )) + .header(http::header::CONTENT_TYPE, mime::APPLICATION_JSON.as_ref()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); + } +} diff --git a/agent_api_rest/src/lib.rs b/agent_api_rest/src/lib.rs index 6c6dfc83..27840ef8 100644 --- a/agent_api_rest/src/lib.rs +++ b/agent_api_rest/src/lib.rs @@ -11,7 +11,7 @@ pub mod utils; use agent_holder::state::HolderState; use agent_identity::state::IdentityState; use agent_issuance::state::IssuanceState; -use agent_shared::config::config; +use agent_shared::config::{config, API_VERSION}; use agent_verification::state::VerificationState; use axum::{ body::{Body, Bytes}, @@ -28,8 +28,6 @@ use tower::ServiceBuilder; use tower_http::trace::TraceLayer; use tracing::{debug, info, info_span, Span}; -pub const API_VERSION: &str = "/v0"; - pub const DOCUMENTATION_URL: &str = "https://beta.docs.impierce.com/unicore/"; #[derive(Default)] diff --git a/agent_api_rest/src/verification/mod.rs b/agent_api_rest/src/verification/mod.rs index a1bede8b..f21c0267 100644 --- a/agent_api_rest/src/verification/mod.rs +++ b/agent_api_rest/src/verification/mod.rs @@ -1,5 +1,8 @@ pub mod authorization_requests; +pub mod public_verification; pub mod relying_party; +pub mod validate_domain_linkage; +pub mod validate_linked_verifiable_presentation; pub mod error; @@ -10,7 +13,7 @@ use axum::{routing::post, Router}; use crate::verification::{ authorization_requests::authorization_request, authorization_requests::authorization_requests, - relying_party::redirect::redirect, relying_party::request::request, + public_verification::public_verification, relying_party::redirect::redirect, relying_party::request::request, }; use crate::API_VERSION; @@ -26,7 +29,8 @@ pub fn router(verification_state: VerificationState) -> Router { .route( "/authorization_requests/{authorization_request_id}", get(authorization_request), - ), + ) + .route("/public-verification", get(public_verification)), ) .route("/request/{request_id}", get(request)) .route("/redirect", post(redirect)) diff --git a/agent_api_rest/src/verification/public_verification.rs b/agent_api_rest/src/verification/public_verification.rs new file mode 100644 index 00000000..3f270968 --- /dev/null +++ b/agent_api_rest/src/verification/public_verification.rs @@ -0,0 +1,431 @@ +use agent_holder::credential::aggregate::get_unverified_jwt_claims; +use agent_secret_manager::subject::get_public_key_from_kid; +use agent_shared::config::{get_all_enabled_did_methods, get_all_enabled_signing_algorithms_supported}; +use agent_verification::state::VerificationState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use base64::{engine::general_purpose, Engine as _}; +use did_manager::Resolver; +use http_api_problem::ApiError; +use identity_iota::document::{verifiable, ServiceEndpoint}; +use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use tracing::info; +use url::Url; + +use crate::{ + issuance::credential_issuer::credential, + verification::{ + validate_domain_linkage::{get_issuer_linked_domains, validate_domain_linkage, ValidationStatus}, + validate_linked_verifiable_presentation::validate_linked_verifiable_presentations, + }, +}; + +#[derive(Deserialize)] +pub struct PublicVerificationQuery { + #[serde(rename = "public-credential-token")] + public_credential_token: String, +} + +#[derive(Serialize, Default)] +pub struct ValidationResult { + status: ValidationStatus, + payload: Option, + data: Option, +} + +// TODO: make stronger typing then strings +#[derive(Serialize, Default)] +pub struct PublicVerificationResponse { + pub credential: Option, + pub proof: ValidationResult, + pub status: ValidationResult, + pub trust_relation: ValidationResult, + pub linked_vp: ValidationResult, + pub domain_linkage: ValidationResult, +} + +/// This endpoint receives a Public Credential Token as a query parameter and then performs several validation steps on the token. +/// When all validations pass, the requested credential is returned in the response along with the validation results. +/// When any validation fails, only the validation results are returned. +/// Both the Verifier and the Issuer need to perform all these checks on the Public Credential Token, zero trust is assumed. +pub async fn public_verification( + State(state): State, + Query(parameter): Query, +) -> Result { + let jwt = parameter.public_credential_token; + // Initialize response to "invalid" default, if a check passes the response is updated accordingly + let mut public_verification_response = PublicVerificationResponse::default(); + + // Get unverified claims + let jwt_value = serde_json::Value::String(jwt.clone()); + let public_credential_token_claims = get_unverified_jwt_claims(&jwt_value).map_err(|_| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid JWT") + .message("Failed to decode Public Credential Token") + .finish() + })?; + + // Extract the `aud` claim + let aud = public_credential_token_claims + .get("aud") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to get `aud` claim from Public Credential Token") + .finish() + })?; + + // Check and validate domain linkage + let resolver = Resolver::new().await; + let issuer_did_document = resolver + .resolve(aud) + .await + .inspect_err(|err| println!("Failed to resolve issuer DID.: {err:#?}")) + .map_err(|_| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to resolve issuer DID") + .finish() + })?; + + info!("Issuer DID Document: {:#?}", issuer_did_document); + + let mut linked_domains = get_issuer_linked_domains(&issuer_did_document).await; + for url in linked_domains.clone() { + let validation_result = validate_domain_linkage(&resolver, url.clone(), aud).await; + if validation_result.status == ValidationStatus::Success { + public_verification_response.domain_linkage = ValidationResult { + status: ValidationStatus::Success, + payload: Some(url.to_string()), + data: None, + }; + break; + } + } + + // Fallback for did:webs if no domain linkage is found + if linked_domains.is_empty() || aud.starts_with("did:web") { + let did_web_domain = extract_url_from_did_web(aud).ok_or( + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to resolve issuer DID") + .finish(), + )?; + info!("Extracted URL from did:web: {:#?}", did_web_domain); + public_verification_response.domain_linkage = ValidationResult { + status: ValidationStatus::Success, + payload: Some(did_web_domain.to_string()), + data: None, + }; + linked_domains.push(did_web_domain); + } + + info!("Linked Domains: {:#?}", linked_domains); + + // Validate the issuers linked verifiable presentations and then check if any of them were issued to this verifier to establish a trust relation. + + // Get this instance's DID's + // TODO: this is ugly but for now the easiest way for me to get all did_methods for the hackathon + let supported_signing_algorithms = get_all_enabled_signing_algorithms_supported(); + let enabled_did_methods = get_all_enabled_did_methods(); + + let mut dids = Vec::new(); + + // TODO: In fact i don't need the full identifier (kid), just the DID. + for did_method in &enabled_did_methods { + for alg in &supported_signing_algorithms { + let did = state + .subject + .identifier(did_method.to_string().as_ref(), *alg) + .await + .map_err(|_| ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR).finish())?; + + dids.push( + did.split('#') + .next() + .ok_or(ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR))? + .to_string(), + ); + } + } + + info!("DIDs to match against: {:#?}", dids); + let linked_verifiable_credentials: Vec<_> = + validate_linked_verifiable_presentations(&resolver, &issuer_did_document) + .await + .into_iter() + .flatten() + .filter(|linked_verifiable_credential| { + info!( + "Validating linked verifiable credential: {:#?}", + linked_verifiable_credential + ); + // Check if the issuer of the linked verifiable credential matches the DID of this verifier to establish a trust relation + let claims = match get_unverified_jwt_claims(&linked_verifiable_credential.data) { + Ok(claims) => claims, + Err(_) => return false, + }; + + info!("Linked VC claims: {:#?}", claims); + info!("DIDs to match against: {:#?}", dids); + + claims + .get("iss") + .and_then(|iss| iss.as_str()) + .and_then(|iss| match dids.contains(&iss.to_string()) { + true => Some(true), + false => None, + }) + .unwrap_or(false) + }) + .collect(); + + if !linked_verifiable_credentials.is_empty() { + // TODO: this is hardcoded logic for the hackathon demo + let data = get_unverified_jwt_claims(&linked_verifiable_credentials[0].data).map_err(|_| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Linked Verifiable Presentation") + .message("Failed to get the credential data from the linked verifiable presentation") + .finish() + })?["vc"] + .clone(); + + public_verification_response.linked_vp.status = ValidationStatus::Success; + public_verification_response.linked_vp.data = Some(data); + public_verification_response.trust_relation.status = ValidationStatus::Success; + } else { + public_verification_response.linked_vp = ValidationResult { + status: ValidationStatus::Failure, + // TODO: this is a hackathon specific message + payload: Some("No valid certifications found for the issuer".to_string()), + data: None, + }; + public_verification_response.trust_relation = ValidationResult { + status: ValidationStatus::Failure, + // TODO: this is a hackathon specific message + payload: Some("Trust relation between this verifier and the issuer could not be established".to_string()), + data: None, + }; + } + + // TODO: validate status of Public Credential Token + // Invalid = BAD_REQUEST + + // All primary checks have passed for the Public Credential Token at this point, to perform the remaining checks we need to fetch the Public Credential from the Issuer. + + // Discover public credential endpoint through DID resolution + let public_credential_endpoint = issuer_did_document + .service() + .iter() + .find(|service| service.type_().contains("PublicCredentialEndpoint")) + .and_then(|service| match service.service_endpoint() { + ServiceEndpoint::One(url) => Some(url.clone()), + // TODO: handle multiple endpoints? + ServiceEndpoint::Set(urls) => urls.first().cloned(), + ServiceEndpoint::Map(map) => map.values().next().and_then(|urls| urls.first().cloned()), + }) + .ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Public Credential Endpoint Not Found") + .message("Issuer DID Document is missing PublicCredentialEndpoint service") + .finish() + })?; + + // Fetch Public Credential from issuer endpoint + let public_credential_endpoint_url_with_parameter = + format!("{}?public-credential-token={}", public_credential_endpoint, jwt); + let response = reqwest::get(public_credential_endpoint_url_with_parameter) + .await + .map_err(|e| { + ApiError::builder(StatusCode::BAD_GATEWAY) + .title("Failed to Fetch Public Credential") + .message(format!( + "Failed to get response from Issuer Public Credential endpoint: {e}" + )) + .finish() + })?; + + let verifiable_credential = response.json::().await.map_err(|e| { + ApiError::builder(StatusCode::BAD_GATEWAY) + .title("Invalid Public Credential Response") + .message(format!("Failed to parse Issuer Public Credential response: {e}")) + .finish() + })?; + + // Validate all remaining checks + + // Get unverified claims + let verifiable_credential_claims = get_unverified_jwt_claims(&verifiable_credential).map_err(|_| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid JWT") + .message("Failed to decode Public Credential") + .finish() + })?; + + // The subject of the Public Credential Token is the JTI (credential ID) of the issued credential which a Verifier is trying to access + let sub = public_credential_token_claims + .get("sub") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Token") + .message("Failed to get `sub` claim from Public Credential Token") + .finish() + })?; + + let jti = verifiable_credential_claims + .get("jti") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + ApiError::builder(StatusCode::BAD_REQUEST) + .title("Invalid Credential") + .message("Failed to get `jti` claim from Public Credential") + .finish() + })?; + + if sub != jti { + return Err(ApiError::builder(StatusCode::UNPROCESSABLE_ENTITY) + .title("Invalid Token") + .message("Public Credential Token `sub` claim does not match Public Credential `jti` claim") + .finish()); + } + + // TODO: none of this works with sd-jwt yet + + // Extract credential subject ID + // let credential_subject_id = verifiable_credential_claims.get("vc") + // .and_then(|data| data.get("credentialSubject")) + // .and_then(|cred_subject| cred_subject.get("id")) + // .and_then(|id| id.as_str()) + // .ok_or_else(|| { + // ApiError::builder(StatusCode::UNPROCESSABLE_ENTITY) + // .title("Invalid Credential") + // .message("Requested credential is missing the credentialSubject.id field. Publicly sharing anonymous credentials is not supported.") + // .finish() + // })?; + + // Extract iss from claims and validate + // let iss = public_credential_token_claims + // .get("iss") + // .and_then(|v| v.as_str()) + // .ok_or_else(|| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Token") + // .message("Failed to get `iss` claim from Public Credential Token") + // .finish() + // })?; + + // Check whether the issuer of the Public Credential Token matches the subject of the requested credential + // This check means we currently don't allow to publicly share anonymous credentials + // if iss != credential_subject_id { + // return Err(ApiError::builder(StatusCode::UNAUTHORIZED) + // .title("Invalid Token") + // .message("Public Credential Token issuer does not match requested credential subject") + // .finish()); + // } + + // TODO: validate status + public_verification_response.status.status = ValidationStatus::Success; + + // let verifiable_credential_str = verifiable_credential + // .as_str() + // .ok_or_else(|| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Credential") + // .message("Public Credential is not a valid JWT") + // .finish() + // })?; + + // // Decode header to get kid + // let jwt_header = decode_header(&verifiable_credential_str).map_err(|e| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Token") + // .message(format!("Failed to decode Public Credential Token header: {e}")) + // .finish() + // })?; + + // let kid = jwt_header.kid.ok_or_else(|| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Token") + // .message("Failed to get `kid` from Public Credential Token header") + // .finish() + // })?; + + // Validate the kid belongs to the same DID as credential subject + // let kid_did = kid.split('#').next().unwrap_or(&kid); + // if kid_did != credential_subject_id { + // return Err(ApiError::builder(StatusCode::UNAUTHORIZED) + // .title("Invalid Token") + // .message("Public Credential Token kid does not match requested credential subject DID") + // .finish()); + // } + + // // Fetch the public key using the kid + // let public_key = get_public_key_from_kid(&kid).await.map_err(|_| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Token") + // .message("Failed to retrieve public key for kid") + // .finish() + // })?; + + // // TODO: more validation parameters should be set + // let validation = Validation::new(jwt_header.alg); + // validation.set_issuer(&[credential_subject_id]); + // validation.set_audience(&[aud]); + // validation.sub = Some(sub.to_string()); + + // Decode and verify the JWT signature + // let _token_data = decode::(&jwt, &decoding_key, &validation).map_err(|e| { + // ApiError::builder(StatusCode::BAD_REQUEST) + // .title("Invalid Token") + // .message(format!("JWT verification failed: {}", e)) + // .finish() + // })?; + + public_verification_response.proof.status = ValidationStatus::Success; + + // If all validations have passed, set the credential in the response + if public_verification_response.proof.status == ValidationStatus::Success + && public_verification_response.status.status == ValidationStatus::Success + && public_verification_response.trust_relation.status == ValidationStatus::Success + && public_verification_response.linked_vp.status == ValidationStatus::Success + && public_verification_response.domain_linkage.status == ValidationStatus::Success + { + let credential_data = verifiable_credential_claims.get("vc").cloned().ok_or_else(|| { + ApiError::builder(StatusCode::INTERNAL_SERVER_ERROR) + .title("Invalid Public Credential received") + .message("Public Credential data could not be extracted from the received response") + .finish() + })?; + public_verification_response.credential = Some(credential_data); + } + + // Return the credential if all validations pass + Ok((StatusCode::OK, Json(public_verification_response)).into_response()) +} + +// Helpers + +fn extract_url_from_did_web(did_web: &str) -> Option { + if let Some(did) = did_web.strip_prefix("did:web:") { + let url_str = if let Some(index_colon) = did.find(':') { + &did[..index_colon] + } else { + did + }; + + // TODO: quick hack to solve the percent-encoding issue in did:web:localhost%3A3033 (localhost:3033) + let url_decoded = url_str.replace("%3A", ":"); + + if let Ok(url) = Url::parse(&format!("https://{url_decoded}")) { + return Some(url); + } + } + None +} diff --git a/agent_api_rest/src/verification/validate_domain_linkage.rs b/agent_api_rest/src/verification/validate_domain_linkage.rs new file mode 100644 index 00000000..38c65526 --- /dev/null +++ b/agent_api_rest/src/verification/validate_domain_linkage.rs @@ -0,0 +1,189 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use did_manager::Resolver; +use identity_credential::domain_linkage::{DomainLinkageConfiguration, JwtDomainLinkageValidator}; +use identity_iota::{ + core::{FromJson, ToJson}, + credential::JwtCredentialValidationOptions, + document::CoreDocument, + verification::{ + jwk::Jwk as IotaIdentityJwk, + jws::{JwsVerifier, SignatureVerificationError, SignatureVerificationErrorKind, VerificationInput}, + }, +}; +use jsonwebtoken::{crypto::verify, jwk::Jwk as JsonWebTokenJwk, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::str::FromStr; +use url::Url; + +#[skip_serializing_none] +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Default)] +pub struct ValidationResult { + pub(crate) status: ValidationStatus, + pub(crate) name: Option, + pub(crate) logo_uri: Option, + pub(crate) issuance_date: Option, + pub(crate) message: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Default)] +pub enum ValidationStatus { + Success, + #[default] + Failure, + Unknown, +} + +/// This `Verifier` uses `jsonwebtoken` under the hood to verify verification input. +pub struct Verifier; +impl JwsVerifier for Verifier { + fn verify(&self, input: VerificationInput, public_key: &IotaIdentityJwk) -> Result<(), SignatureVerificationError> { + use SignatureVerificationErrorKind::*; + + let algorithm = + Algorithm::from_str(&input.alg.to_string()).map_err(|_| SignatureVerificationError::new(UnsupportedAlg))?; + + // Convert the `IotaIdentityJwk` first into a `JsonWebTokenJwk` and then into a `DecodingKey`. + let decoding_key = public_key + .to_json() + .ok() + .and_then(|public_key| JsonWebTokenJwk::from_json(&public_key).ok()) + .and_then(|jwk| DecodingKey::from_jwk(&jwk).ok()) + .ok_or(SignatureVerificationError::new(KeyDecodingFailure))?; + + let mut validation = Validation::new(algorithm); + validation.validate_aud = false; + validation.required_spec_claims.clear(); + + match verify( + &URL_SAFE_NO_PAD.encode(input.decoded_signature), + &input.signing_input, + &decoding_key, + algorithm, + ) { + Ok(true) => Ok(()), + Err(_) | Ok(false) => Err(SignatureVerificationError::new( + // TODO: more fine-grained error handling? + InvalidSignature, + )), + } + } +} + +/// https://wiki.iota.org/identity.rs/how-tos/domain-linkage/create-and-verify/#verifying-a-did-and-domain-linkage +pub async fn validate_domain_linkage(resolver: &Resolver, url: url::Url, did: &str) -> ValidationResult { + let did_configuration_result = fetch_configuration(url.clone()).await; + + let domain_linkage_configuration = match did_configuration_result { + Ok(did_config) => did_config, + Err(err) => { + return ValidationResult { + status: ValidationStatus::Unknown, + message: Some(format!("Error while fetching configuration: {err}")), + ..Default::default() + }; + } + }; + + let validator = JwtDomainLinkageValidator::with_signature_verifier(Verifier); + + let document = match resolver.resolve(did).await { + Ok(document) => document, + Err(e) => { + return ValidationResult { + status: ValidationStatus::Unknown, + message: Some(e.to_string()), + ..Default::default() + }; + } + }; + + let url = identity_iota::core::Url::from(url); + + let res = validator.validate_linkage( + &document, + &domain_linkage_configuration, + &url, + &JwtCredentialValidationOptions::default(), + ); + + if res.is_ok() { + ValidationResult { + status: ValidationStatus::Success, + ..Default::default() + } + } else { + ValidationResult { + status: ValidationStatus::Failure, + message: res.err().map(|e| e.to_string()), + ..Default::default() + } + } +} + +/// Acts as a replacement for `fetch_configuration()` from `identity_credential` which fails on JSON-LD inside `linked_dids`. +/// This implementation is also less strict (allows `http` scheme, does not fail on JSON-LD) +/// The resource at the `.well-known` endpoint is fetched and any non-string values from `linked_dids` before deserializing. +/// Returns a `DomainLinkageConfiguration` which can be verified using a verifier from `identity_credential`. +async fn fetch_configuration(mut url: url::Url) -> Result { + // 1. Prepare the URL + url.set_fragment(None); + url.set_query(None); + url.set_path(".well-known/did-configuration.json"); + + // 2. Fetch the resource + let response = reqwest::get(url.clone()) + .await + .map_err(|_| format!("failed to get response from resource url: {url}"))?; + + // 3. Parse to JSON value (mutable) + let mut json = response + .json::() + .await + .map_err(|_| "failed to parse response into JSON value".to_string())?; + + // 4. Remove all non-string values from `linked_dids` (JSON-LD) + if let serde_json::Value::Object(ref mut root) = json { + if let Some(serde_json::Value::Array(ref mut linked_dids)) = root.get_mut("linked_dids") { + linked_dids.retain(|did| matches!(did, serde_json::Value::String(_))); + } + } + + // 5. Deserialize to `DomainLinkageConfiguration` + let config = DomainLinkageConfiguration::from_json_value(json) + .map_err(|_| "failed to deserialize DomainLinkageConfiguration from JSON".to_string())?; + Ok(config) +} + +/// Get the linked domains from the issuer document. It returns a list of URLs if the service type is `LinkedDomains`. +pub async fn get_issuer_linked_domains(issuer_document: &CoreDocument) -> Vec { + issuer_document + .service() + .iter() + .filter_map(|service| { + service + .type_() + .contains("LinkedDomains") + .then(|| service.service_endpoint()) + .and_then(|service_endpoint| service_endpoint.to_json_value().ok()) + .and_then(|linked_domain| { + linked_domain.get("origins").and_then(|origins| { + origins.as_array().and_then(|origins| { + origins + .iter() + .map(|origin| { + origin.as_str().and_then(|origin| { + origin + .parse() + .inspect_err(|err| println!("Failed to parse linked domain: {err:#?}")) + .ok() + }) + }) + .collect::>>() + }) + }) + }) + }) + .flatten() + .collect() +} diff --git a/agent_api_rest/src/verification/validate_linked_verifiable_presentation.rs b/agent_api_rest/src/verification/validate_linked_verifiable_presentation.rs new file mode 100644 index 00000000..fdd8903f --- /dev/null +++ b/agent_api_rest/src/verification/validate_linked_verifiable_presentation.rs @@ -0,0 +1,1013 @@ +use did_manager::Resolver; +use futures::{ + future::OptionFuture, + stream::{iter, FuturesUnordered}, + StreamExt, +}; +use identity_iota::{ + core::{OneOrMany, ToJson}, + credential::{ + DecodedJwtCredential, DecodedJwtPresentation, FailFast, Jwt, JwtCredentialValidationOptions, + JwtCredentialValidator, JwtPresentationValidator, StatusCheck, Subject, + }, + document::{CoreDocument, Service}, +}; +use identity_jose::{jws::Decoder, jwt::JwtClaims}; +use oid4vc::oid4vci::credential_issuer::credential_issuer_metadata::CredentialIssuerMetadata; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::{info, warn}; +use url::Url; + +use crate::verification::validate_domain_linkage::{ValidationStatus, Verifier}; + +#[cfg_attr(not(test), derive(PartialEq))] +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct LinkedVerifiableCredentialData { + pub name: Option, + pub logo_uri: Option, + pub issuance_date: String, + pub issuer_linked_domains: Vec, + pub data: serde_json::Value, +} + +// Skip the partial equality check for `issuance_date` during testing. +#[cfg(test)] +impl PartialEq for LinkedVerifiableCredentialData { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.logo_uri == other.logo_uri + && self.issuer_linked_domains == other.issuer_linked_domains + } +} + +/// Validate the linked verifiable presentations for the given holder DID. Returns a list of linked verifiable +/// credential data. It starts by resolving the holder DID and then iterates over the linked verifiable presentation +/// URLs. For each linked verifiable presentation, it validates the presentation and then validates the linked +/// verifiable credentials. It only considers linked verifiable credentials with successful domain linkage validation. +pub async fn validate_linked_verifiable_presentations( + resolver: &Resolver, + did_document: &CoreDocument, +) -> Vec> { + iter( + // Get all linked verifiable presentation URLs from the holder document + did_document + .service() + .iter() + .filter_map(get_linked_verifiable_presentation_urls) + .flatten(), + ) + .filter_map(|linked_verifiable_presentation_url| { + // Validate the linked verifiable presentation and get the linked verifiable credential data + get_validated_linked_presentation_data(resolver, did_document, linked_verifiable_presentation_url) + }) + .collect::>() + .await +} + +/// Get the linked verifiable presentation URLs from the service. It returns a list of URLs if the service type is a +/// `LinkedVerifiablePresentation`. +fn get_linked_verifiable_presentation_urls(service: &Service) -> Option> { + service + .type_() + .contains("LinkedVerifiablePresentation") + .then(|| service.service_endpoint()) + .and_then(|service_endpoint| service_endpoint.to_json_value().ok()) + .and_then( + // Parse the linked verifiable presentation URLs from the service endpoint. The service endpoint must be + // either a string or an array of strings: https://identity.foundation/linked-vp/#linked-verifiable-presentation + |linked_verifiable_presentation_urls| match linked_verifiable_presentation_urls { + Value::String(url) => url + .parse() + .inspect_err(|err| warn!("TODO: {err}")) + .ok() + .map(|url| vec![url]), + Value::Array(array) => Some( + array + .into_iter() + .filter_map(|url| { + url.as_str() + .and_then(|url| url.parse().inspect_err(|err| warn!("TODO: {err}")).ok()) + }) + .collect(), + ), + _ => None, + }, + ) +} + +/// Validate the linked verifiable presentations for the given holder document and linked verifiable presentation URL. +/// It returns a list of linked verifiable credential data. +async fn get_validated_linked_presentation_data( + resolver: &Resolver, + holder_document: &CoreDocument, + linked_verifiable_presentation_url: Url, +) -> Option> { + OptionFuture::from( + validate_linked_verifiable_presentation(holder_document, linked_verifiable_presentation_url) + .await + .map(|linked_verifiable_presentation| { + get_validated_linked_credential_data(resolver, linked_verifiable_presentation) + }), + ) + .await +} + +/// Retrieves the linked verifiable presentation from the given URL and validates it against the holder document. +/// Returns the decoded linked verifiable presentation if successful. +async fn validate_linked_verifiable_presentation( + holder_document: &CoreDocument, + linked_verifiable_presentation_url: Url, +) -> Option> { + let response = reqwest::get(linked_verifiable_presentation_url) + .await + .inspect_err(|err| { + warn!("TODO: {err}"); + }) + .ok()?; + let status = response.status(); + + response + .text() + .await + .inspect_err(|err| { + warn!("TODO: {err}"); + }) + .ok() + .and_then(|presentation_jwt| { + status.is_success().then(|| { + info!("Validating linked verifiable presentation: {presentation_jwt}"); + let validator = JwtPresentationValidator::with_signature_verifier(Verifier); + let result = validator.validate(&presentation_jwt.into(), &holder_document, &Default::default()); + info!("Linked verifiable presentation validation result: {:#?}", result); + result.ok() + })? + }) +} + +/// Validate the linked verifiable credentials in the linked verifiable presentation. Skips invalid credentials or credentials with invalid domain linkage. +/// Since anyone can host a linked verifiable presentation, it is important to validate the linked verifiable +/// credentials. The `issuer` field in the linked verifiable credential is used to resolve the issuer document and which +/// is then used to retrieve the linked domains. The linked domains then are used to validate the domain linkage. +async fn get_validated_linked_credential_data( + resolver: &Resolver, + linked_verifiable_presentation: DecodedJwtPresentation, +) -> Vec { + iter(linked_verifiable_presentation.presentation.verifiable_credential) + .filter_map(|linked_verifiable_credential| async move { + // Resolve the issuer document and issuer DID + let issuer_document = get_issuer_document(resolver, &linked_verifiable_credential).await?; + let issuer_did = issuer_document.id().to_string(); + + // Resolve the issuer linked domains from the issuer document + let issuer_linked_domains = get_issuer_linked_domains(&issuer_document).await; + + // Only linked verifiable credentials with at least one successful domain linkage validation are considered + let mut validated_linked_domains = + get_validated_linked_domains(resolver, &issuer_linked_domains, &issuer_did).await; + + // TODO: This is a fallback to get the url from a did:web to validate domain linkage. This is useful for companies who haven't implemented domain linkage yet. + if validated_linked_domains.is_empty() { + info!( + "Falling back to extract url from did:web for issuer DID: {}", + issuer_did + ); + if let Some(did_web_url) = extract_url_from_did_web(&issuer_did) { + validated_linked_domains.insert(0, did_web_url); + info!("Extracted url from did:web: {}", validated_linked_domains[0]); + } + } + + info!("Validated linked domains: {:#?}", validated_linked_domains); + if !validated_linked_domains.is_empty() { + let validator = JwtCredentialValidator::with_signature_verifier(Verifier); + + // `SkipUnsupported` allows for custom credential types, such as the StatusList2021Entry (https://www.w3.org/TR/2023/WD-vc-status-list-20230427/#statuslist2021entry) + let options = JwtCredentialValidationOptions::new().status_check(StatusCheck::SkipUnsupported); + + // Decode the linked verifiable credential and validate the jwt_vc_json, checks the JWT and the Issuer DID + if let Ok(decoded_linked_verifiable_credential) = validator.validate::<_, Value>( + &linked_verifiable_credential, + &issuer_document, + &options, + FailFast::FirstError, + ) { + let credential_subject = match &decoded_linked_verifiable_credential.credential.credential_subject { + OneOrMany::One(subject) => Some(subject), + // TODO: how to handle multiple credential subjects? + OneOrMany::Many(subjects) => subjects.first(), + }; + + if let Some(credential_subject) = credential_subject { + let name = get_name(credential_subject); + let logo_uri = get_logo_uri( + credential_subject, + &decoded_linked_verifiable_credential, + &validated_linked_domains, + ) + .await; + let issuance_date = decoded_linked_verifiable_credential + .credential + .issuance_date + .to_rfc3339(); + + Some(LinkedVerifiableCredentialData { + name, + logo_uri, + issuance_date, + issuer_linked_domains: validated_linked_domains, + data: serde_json::Value::from(linked_verifiable_credential.as_str()), + }) + } else { + warn!("Failed to get credential_subject from linked_verifiable_credential"); + None + } + } else { + warn!("Failed to validate linked verifiable credential"); + // TODO: Should we add more fine-grained error handling? `None` here means that the linked verifiable credential is invalid. + None + } + } else { + warn!("No validated linked domains for issuer DID"); + // TODO: Should we add more fine-grained error handling? `None` here means that the domain linkage + // validation failed or is unknown. + None + } + }) + .collect::>() + .await +} + +/// Returns a Vec of successfully validated issuer linked domains. +async fn get_validated_linked_domains( + // TODO: make this conditional configuration more 'ergonomic'. + #[cfg(not(feature = "test_utils"))] resolver: &Resolver, + #[cfg(feature = "test_utils")] _resolver: &Resolver, + issuer_linked_domains: &[Url], + issuer_did: &str, +) -> Vec { + FuturesUnordered::from_iter(issuer_linked_domains.iter().map(|issuer_linked_domain| async move { + let validation_status: ValidationStatus = { + #[cfg(not(feature = "test_utils"))] + { + use crate::verification::validate_domain_linkage::validate_domain_linkage; + + validate_domain_linkage(resolver, issuer_linked_domain.clone(), issuer_did) + .await + .status + } + #[cfg(feature = "test_utils")] + { + // Silence unused variable warning + let _issuer_did = issuer_did; + // Skip validation during tests + Default::default() + } + }; + + if validation_status == ValidationStatus::Success { + Some(issuer_linked_domain.clone()) + } else { + // Fallback for did:webs if no domain linkage is found + if issuer_did.starts_with("did:web") { + extract_url_from_did_web(issuer_did) + } else { + // Failed to validate domain linkage for issuer linked domain + None + } + } + })) + .filter_map(|result| async move { result }) + .collect() + .await +} + +/// Get the linked domains from the issuer document. It returns a list of URLs if the service type is `LinkedDomains`. +async fn get_issuer_linked_domains(issuer_document: &CoreDocument) -> Vec { + issuer_document + .service() + .iter() + .filter_map(|service| { + service + .type_() + .contains("LinkedDomains") + .then(|| service.service_endpoint()) + .and_then(|service_endpoint| service_endpoint.to_json_value().ok()) + .and_then(|linked_domain| { + linked_domain.get("origins").and_then(|origins| { + origins.as_array().and_then(|origins| { + origins + .iter() + .map(|origin| { + origin.as_str().and_then(|origin| { + origin + .parse() + .inspect_err(|err| warn!("Failed to parse linked domain: {err:}")) + .ok() + }) + }) + .collect::>>() + }) + }) + }) + }) + .flatten() + .collect() +} + +/// This function uses the credential in jwt format from the jwt_vc_json to resolve the issuer document. +pub async fn get_issuer_document(resolver: &Resolver, credential_jwt: &Jwt) -> Option { + let decoder = Decoder::new(); + + // Decode the linked verifiable credential. + let decoded_credential_jwt = decoder + .decode_compact_serialization(credential_jwt.as_str().as_bytes(), None) + .inspect_err(|err| warn!("Failed to decode credential jwt: {err:#?}")) + .ok()?; + + let claims: JwtClaims = serde_json::from_slice(decoded_credential_jwt.claims()) + .inspect_err(|err| warn!("Failed to parse credential claims: {err:#?}")) + .ok()?; + + // Resolve the DID + resolver + .resolve(claims.iss()?) + .await + .inspect_err(|err| warn!("Failed to resolve issuer DID.: {err:#?}")) + .ok() +} + +// TODO: Validate credential types against their corresponding JSON Schema. + +fn get_name(credential_subject: &Subject) -> Option { + credential_subject + .properties + .get("name") + .or_else(|| credential_subject.properties.get("naam")) // TODO: "naam" is expected to be used in Dutch credentials + .or_else(|| credential_subject.properties.get("legal_person_name")) // This is another valid property name according to the following spec: + // EWC RFC005: Issue Legal Person Identification Data (LPID) - v1.0 + // https://github.com/EWC-consortium/eudi-wallet-rfcs/blob/49faa8b0ba5e5e79836e247fd07cc0447c1ae98b/ewc-rfc005-issue-legal-person-identification-data.md#51031-lpid-attributes-specification + .and_then(Value::as_str) + .map(ToString::to_string) +} + +/// First, try to get the logo URI from the credential subject. +/// If this doesn't succeed, iterate through the validated linked domains and try to fetch it from the well-known/openid-credential-issuer endpoint. +/// In this endpoint, first we look inside the Display field, at the root. +/// If we can't find a logo there, we look inside the Credential Configurations Supported field at the root. +/// We try to match keys inside the Credential Configurations Supported object against the credential `type` array of the linked verifiable credential, in reverse order. +/// At first success the loop breaks and we download the image. +/// Otherwise, we use a fallback icon. +async fn get_logo_uri( + credential_subject: &Subject, + linked_verifiable_credential: &DecodedJwtCredential, + validated_linked_domains: &[Url], +) -> Option { + let mut logo_uri = credential_subject + .properties + .get("image") + .and_then(Value::as_str) + .map(ToString::to_string); + + // Check if logo URI was retrieved, if not then attempt to retrieve from a well-known endpoint + if logo_uri.is_none() { + for domain in validated_linked_domains.iter() { + let well_known_endpoint = format!("{domain}.well-known/openid-credential-issuer"); + + // Trying to fetch image from {well_known_endpoint} endpoint + if let Ok(response) = reqwest::Client::new().get(&well_known_endpoint).send().await { + if let Ok(metadata) = response.json::().await { + logo_uri = metadata.display.as_deref().and_then(extract_logo_uri_from_display); + + if logo_uri.is_some() { + break; + } + } + } + // TODO: Due to mixing 2 specs here, the oid4vci and linked verifiable presentation spec, we lose the Credential Issuer Identifier (CII) during the linked vp flow. + // The CII tells us where exactly we can add "/.well-known/openid-credential-issuer" to fetch the Credential Issuer Metadata, in which we might find the logo. + // For now we assume it's the same domain as the linked domain. + // But this is no guarantee and the code below is one such workaround. + let well_known_endpoint = format!("{domain}oid4vci/.well-known/openid-credential-issuer"); + + // "Trying to fetch image from well_known_endpoint} endpoint + if let Ok(response) = reqwest::Client::new().get(&well_known_endpoint).send().await { + if let Ok(metadata) = response.json::().await { + logo_uri = linked_verifiable_credential.credential.types.iter().find_map(|type_| { + // Trying to fetch from Credential Configuration Supported: {type_} + metadata + .credential_configurations_supported + .get(type_) + .and_then(|credential_configuration| credential_configuration.display.first()) + .and_then(|display| display.logo.clone()) + .map(|logo| logo.uri.to_string()) + }); + + if logo_uri.is_some() { + break; + } + } + } + } + } + + if let Some(ref logo_uri_str) = logo_uri { + // Parse the logo URI + match logo_uri_str.parse::() { + Ok(_) => logo_uri, + Err(_) => { + // Log parse error if the URI is invalid + None + } + } + } else { + // Failed to extract logo URI from well-known endpoints nor credential subject + None + } +} + +fn extract_logo_uri_from_display(display: &[Value]) -> Option { + display + .first() + .and_then(|display| display.get("logo")) + .and_then(|logo| logo.get("uri").or(logo.get("url"))) + .and_then(|url| url.as_str()) + .map(ToString::to_string) +} + +fn extract_url_from_did_web(did_web: &str) -> Option { + if let Some(did) = did_web.strip_prefix("did:web:") { + let url_str = if let Some(index_colon) = did.find(':') { + &did[..index_colon] + } else { + did + }; + + // TODO: quick hack to solve the percent-encoding issue in did:web:localhost%3A3033 (localhost:3033) + let url_decoded = url_str.replace("%3A", ":"); + + if let Ok(url) = Url::parse(&format!("https://{url_decoded}")) { + return Some(url); + } + } + None +} + +#[cfg(not(feature = "test_utils"))] +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + use did_manager::SecretManager; + use identity_credential::domain_linkage::{DomainLinkageConfiguration, DomainLinkageCredentialBuilder}; + use identity_iota::{ + core::{Duration, FromJson as _, Object, OrderedSet, Timestamp, Url}, + credential::{Credential, CredentialBuilder, Presentation}, + document::{CoreDocument, Service, ServiceEndpoint}, + verification::jws::JwsAlgorithm, + }; + use jsonwebtoken::{Algorithm, Header}; + use serde_json::json; + use std::sync::Arc; + use tempfile::TempDir; + use tokio::sync::Mutex; + use wiremock::{ + matchers::{method, path}, + Mock, MockServer, ResponseTemplate, + }; + + // 'Entity' struct that represents a digital identity including a DID Document, a domain, and a secret manager. + struct TestEntity { + pub mock_server: MockServer, + pub domain: url::Url, + pub did_document: CoreDocument, + pub secret_manager: Arc>, + } + + impl TestEntity { + // Create a new 'Entity' with a DID Document, mock server, a domain, and a secret manager. + async fn new() -> Self { + engine::snapshot::try_set_encrypt_work_factor(0).unwrap(); + + let mock_server = MockServer::start().await; + + let uri = mock_server.uri(); + let port = uri.split(':').next_back().unwrap(); + let domain: url::Url = format!("http://localhost:{port}").parse().unwrap(); + + let temp_dir = TempDir::new().unwrap(); + let path = temp_dir.path().join("stronghold.stronghold"); + let snapshot_path = path.as_os_str().to_str().unwrap(); + + let mut secret_manager = SecretManager::builder() + .snapshot_path(snapshot_path) + .password("sup3rSecr3t") + .build() + .await + .unwrap(); + + let did_document = secret_manager + .produce_document( + did_manager::DidMethod::Web, + Some(did_manager::MethodSpecificParameters::Web { + origin: domain.origin(), + }), + identity_iota::verification::jws::JwsAlgorithm::ES256, + ) + .await + .unwrap(); + + TestEntity { + mock_server, + domain, + did_document, + secret_manager: Arc::new(Mutex::new(secret_manager)), + } + } + + // Add the `.well-known/did.json` endpoint to the mock server. + async fn add_well_known_did_json(&self) { + Mock::given(method("GET")) + .and(path(".well-known/did.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!(self.did_document))) + .mount(&self.mock_server) + .await; + } + + // Add the `.well-known/did-configuration.json` endpoint to the mock server. + async fn add_well_known_did_configuration_json(&mut self, service_id: &str, origins: &[Url]) { + let service = Service::builder(Default::default()) + .id(format!("{}#{service_id}", self.did_document.id()).parse().unwrap()) + .type_("LinkedDomains") + .service_endpoint( + serde_json::from_value::(serde_json::json!( + { + "origins": origins + } + )) + .unwrap(), + ) + .build() + .expect("Failed to create DID Configuration Resource"); + self.did_document + .insert_service(service) + .expect("Service already exists in DID Document"); + + let domain_linkage_configuration = { + let origin = Url::parse(self.domain.origin().ascii_serialization()).unwrap(); + let payload = DomainLinkageCredentialBuilder::new() + .issuer(self.did_document.id().clone()) + .origin(origin) + .issuance_date(Timestamp::now_utc()) + .expiration_date(Timestamp::now_utc().checked_add(Duration::seconds(60)).unwrap()) + .build() + .and_then(|credential| credential.serialize_jwt(Default::default())) + .unwrap(); + + DomainLinkageConfiguration::new(vec![self.generate_jwt(payload).await]) + }; + + Mock::given(method("GET")) + .and(path(".well-known/did-configuration.json")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!(domain_linkage_configuration))) + .mount(&self.mock_server) + .await; + } + + // Add a linked verifiable presentation to the DID Document and the mock server. + async fn add_linked_verifiable_presentation( + &mut self, + service_id: &str, + linked_verifiable_presentation_data: Vec<(String, Vec)>, + ) { + let mut urls: Vec = vec![]; + + for (linked_verifiable_presentation_endpoint, linked_verifiable_credential_jwts) in + linked_verifiable_presentation_data + { + let url = format!( + "{}/{linked_verifiable_presentation_endpoint}", + self.domain.origin().ascii_serialization() + ) + .parse() + .unwrap(); + urls.push(url); + + let linked_verifiable_presentation = { + let presentation = { + let mut builder = + Presentation::builder(self.did_document.id().to_string().parse().unwrap(), Object::new()); + for linked_verifiable_credential_jwt in linked_verifiable_credential_jwts { + builder = builder.credential(linked_verifiable_credential_jwt); + } + builder.build().unwrap() + }; + + self.generate_jwt(presentation.serialize_jwt(&Default::default()).unwrap()) + .await + }; + + Mock::given(method("GET")) + .and(path(format!("/{linked_verifiable_presentation_endpoint}"))) + .respond_with(ResponseTemplate::new(200).set_body_string(linked_verifiable_presentation.as_str())) + .mount(&self.mock_server) + .await; + } + + let service_endpoint = match urls.len() { + // Value::String + 1 => ServiceEndpoint::from(urls[0].clone()), + // Value::Array + _ => ServiceEndpoint::from(OrderedSet::from_iter(urls)), + }; + let service = Service::builder(Default::default()) + .id(format!("{}#{service_id}", self.did_document.id()).parse().unwrap()) + .type_("LinkedVerifiablePresentation") + .service_endpoint(service_endpoint) + .build() + .unwrap(); + + self.did_document + .insert_service(service) + .expect("Service already exists in DID Document"); + } + + // 'Issues' a Credential Jwt to a subject. + async fn issue_credential(&mut self, subject_id: &str, subject_name: &str, subject_image: Url) -> Jwt { + let subject = identity_credential::credential::Subject::from_json_value(json!({ + "id": subject_id, + "name": subject_name, + "image": subject_image + })) + .unwrap(); + + let issuer = identity_iota::credential::Issuer::Url(self.did_document.id().to_string().parse().unwrap()); + + let credential: Credential = CredentialBuilder::default() + .issuer(issuer) + .subject(subject) + .build() + .unwrap(); + + self.generate_jwt(credential.serialize_jwt(Default::default()).unwrap()) + .await + } + + // Generates a JWT with the given payload. + async fn generate_jwt(&mut self, payload: String) -> Jwt { + let subject_did = self.did_document.id().to_string(); + + // Compose JWT + let header = Header { + alg: Algorithm::ES256, + typ: Some("JWT".to_string()), + kid: Some(format!("{subject_did}#key-0")), + ..Default::default() + }; + + let message = [ + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap().as_slice()), + URL_SAFE_NO_PAD.encode(payload.as_bytes()), + ] + .join("."); + + let secret_manager = self.secret_manager.lock().await; + + let proof_value = secret_manager + .sign(message.as_bytes(), JwsAlgorithm::ES256) + .await + .unwrap(); + let signature = URL_SAFE_NO_PAD.encode(proof_value.as_slice()); + let message = [message, signature].join("."); + + Jwt::from(message) + } + + async fn add_logo_endpoint(&self) { + Mock::given(method("GET")) + .and(path("logo.png")) + .respond_with(ResponseTemplate::new(200).set_body_raw( + include_bytes!("../../../resources/images/impierce_white.png"), + "image/png", + )) + .mount(&self.mock_server) + .await; + } + } + + #[tokio::test] + async fn validate_linked_verifiable_presentations_successfully_validates_multiple_presentations() { + let mut holder = TestEntity::new().await; + + let mut issuer_a = TestEntity::new().await; + issuer_a.add_logo_endpoint().await; + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer A mock server. + issuer_a + .add_well_known_did_configuration_json("linked-domain", &[issuer_a.domain.clone().into()]) + .await; + issuer_a.add_well_known_did_json().await; + + let mut issuer_b = TestEntity::new().await; + issuer_b.add_logo_endpoint().await; + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer B mock server. + issuer_b + .add_well_known_did_configuration_json("linked-domain", &[issuer_b.domain.clone().into()]) + .await; + issuer_b.add_well_known_did_json().await; + + let logo_uri_a: String = format!("{}logo.png", issuer_a.domain.clone()); + + let verifiable_credential_jwt = issuer_a + .issue_credential( + holder.did_document.id().to_string().as_str(), + "Webshop", + logo_uri_a.parse().unwrap(), + ) + .await; + + let service_id = "linked-verifiable-presentation"; + let linked_verifiable_presentation_endpoint = "linked-verifiable-presentation.jwt"; + + // Add the first linked verifiable presentation endpoint and the service to the holder DID Document. + holder + .add_linked_verifiable_presentation( + service_id, + vec![( + linked_verifiable_presentation_endpoint.to_string(), + vec![verifiable_credential_jwt], + )], + ) + .await; + + let logo_uri_b: String = format!("{}logo.png", issuer_b.domain.clone()); + + let verifiable_credential_jwt_2 = issuer_b + .issue_credential( + holder.did_document.id().to_string().as_str(), + "Webshop", + logo_uri_b.parse().unwrap(), + ) + .await; + + let service_id2 = "linked-verifiable-presentation-2"; + + // Add the second linked verifiable presentation endpoint and the service to the holder DID Document. + let linked_verifiable_presentation_endpoint2 = "linked-verifiable-presentation2.jwt"; + holder + .add_linked_verifiable_presentation( + service_id2, + vec![( + linked_verifiable_presentation_endpoint2.to_string(), + vec![verifiable_credential_jwt_2], + )], + ) + .await; + + holder.add_well_known_did_json().await; + + let resolver = Resolver::new().await; + + assert_eq!( + validate_linked_verifiable_presentations(&resolver, holder.did_document.id().to_string().as_ref()).await, + vec![ + vec![LinkedVerifiableCredentialData { + name: Some("Webshop".to_string()), + logo_uri: Some(logo_uri_a), + issuer_linked_domains: vec![issuer_a.domain.clone()], + ..Default::default() + }], + vec![LinkedVerifiableCredentialData { + name: Some("Webshop".to_string()), + logo_uri: Some(logo_uri_b), + issuer_linked_domains: vec![issuer_b.domain.clone()], + ..Default::default() + }] + ] + ); + } + + #[tokio::test] + async fn validate_linked_verifiable_presentations_successfully_considers_missing_issuer_domain_linkage() { + let mut holder = TestEntity::new().await; + + let mut issuer = TestEntity::new().await; + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer mock server. + issuer + .add_well_known_did_configuration_json("linked-domain", &[issuer.domain.clone().into()]) + .await; + + // This time we do not add the `/did.json` endpoint to the issuer mock server, which makes it impossible to + // validate the domain linkage of the issuer. + // issuer.add_well_known_did_json().await; + + let verifiable_credential_jwt = issuer + .issue_credential( + holder.did_document.id().to_string().as_str(), + "Webshop", + "https://webshop.com/logo.jpg".parse().unwrap(), + ) + .await; + + let service_id = "linked-verifiable-presentation"; + let linked_verifiable_presentation_endpoint = "linked-verifiable-presentation.jwt"; + + // Add the linked verifiable presentation endpoint and the service to the holder DID Document. + holder + .add_linked_verifiable_presentation( + service_id, + vec![( + linked_verifiable_presentation_endpoint.to_string(), + vec![verifiable_credential_jwt], + )], + ) + .await; + + holder.add_well_known_did_json().await; + + let resolver = Resolver::new().await; + + assert_eq!( + validate_linked_verifiable_presentations(&resolver, holder.did_document.id().to_string().as_ref()).await, + // The domain linkage validation of the issuer failed, so the linked verifiable credential is not considered. + vec![vec![]] + ); + } + + #[tokio::test] + async fn get_linked_verifiable_presentation_urls_successfully_retrieves_urls() { + let mut holder = TestEntity::new().await; + + let service_id = "linked-verifiable-presentation"; + let linked_verifiable_presentation_endpoint = "linked-verifiable-presentation.jwt"; + let linked_verifiable_presentation_endpoint2 = "linked-verifiable-presentation2.jwt"; + holder + .add_linked_verifiable_presentation( + service_id, + vec![ + ( + linked_verifiable_presentation_endpoint.to_string(), + // Linked verifiable presentation can include multiple linked verifiable credentials. + vec![Jwt::from("test1".to_string()), Jwt::from("test2".to_string())], + ), + ( + linked_verifiable_presentation_endpoint2.to_string(), + vec![Jwt::from("test3".to_string())], + ), + ], + ) + .await; + + // Assert that the URLs of both linked verifiable presentations are retrieved. + assert!( + get_linked_verifiable_presentation_urls(&holder.did_document.service()[0]) + .unwrap() + .iter() + .all(|item| [ + format!("{}{}", holder.domain, linked_verifiable_presentation_endpoint) + .parse() + .unwrap(), + format!("{}{}", holder.domain, linked_verifiable_presentation_endpoint2) + .parse() + .unwrap() + ] + .contains(item)) + ); + } + + #[tokio::test] + async fn get_validated_linked_credential_data_successfully_returns_linked_verifiable_credential_data() { + let mut issuer = TestEntity::new().await; + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer mock server. + issuer + .add_well_known_did_configuration_json("linked-domain", &[issuer.domain.clone().into()]) + .await; + issuer.add_well_known_did_json().await; + issuer.add_logo_endpoint().await; + + let mut holder = TestEntity::new().await; + + let issuer_logo = format!("{}logo.png", issuer.domain.clone()); + + let verifiable_credential_jwt = issuer + .issue_credential( + holder.did_document.id().to_string().as_str(), + "Webshop", + issuer_logo.parse().unwrap(), + ) + .await; + + let service_id = "linked-verifiable-presentation"; + let linked_verifiable_presentation_endpoint = "linked-verifiable-presentation.jwt"; + holder + .add_linked_verifiable_presentation( + service_id, + vec![( + linked_verifiable_presentation_endpoint.to_string(), + vec![verifiable_credential_jwt], + )], + ) + .await; + + let resolver = Resolver::new().await; + + let linked_verifiable_presentation_url: url::Url = + format!("{}{linked_verifiable_presentation_endpoint}", holder.domain) + .parse() + .unwrap(); + + let validated_linked_presentation_data = + get_validated_linked_presentation_data(&resolver, &holder.did_document, linked_verifiable_presentation_url) + .await; + + assert_eq!( + validated_linked_presentation_data, + Some(vec![LinkedVerifiableCredentialData { + name: Some("Webshop".to_string()), + logo_uri: Some(issuer_logo), + issuer_linked_domains: vec![issuer.domain.clone()], + ..Default::default() + }]) + ); + } + + #[tokio::test] + async fn get_validated_linked_domains_returns_only_successfully_validated_linked_domains() { + let mut issuer1 = TestEntity::new().await; + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer mock server. + issuer1 + .add_well_known_did_configuration_json("linked-domain", &[issuer1.domain.clone().into()]) + .await; + issuer1.add_well_known_did_json().await; + + let resolver = Resolver::new().await; + + // Successfully validate the linked domain. + assert_eq!( + get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone()], + issuer1.did_document.id().to_string().as_ref() + ) + .await, + vec![issuer1.domain.clone()] + ); + + // Assert that only one domain was validated. + assert_eq!( + get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone(), "http://invalid-domain.org".parse().unwrap()], + issuer1.did_document.id().to_string().as_ref() + ) + .await, + vec![issuer1.domain.clone()] + ); + + let mut issuer2 = TestEntity::new().await; + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer mock server. + issuer2 + .add_well_known_did_configuration_json("linked-domain-2", &[issuer2.domain.clone().into()]) + .await; + issuer2.add_well_known_did_json().await; + + // Assert that only one domain was validated. The second domain cannot be validated because the issuer DID is different. + assert_eq!( + get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone(), issuer2.domain.clone()], + issuer1.did_document.id().to_string().as_ref() + ) + .await, + vec![issuer1.domain.clone()] + ); + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer mock server. Use the same issuer DID as + // issuer1, but a different domain. + let mut issuer2 = TestEntity::new().await; + issuer2.did_document = issuer1.did_document.clone(); + issuer2.secret_manager = issuer1.secret_manager.clone(); + + // Add the `/did_configuration.json` and `/did.json` endpoints to the issuer mock server. + issuer2 + .add_well_known_did_configuration_json("linked-domain-2", &[issuer2.domain.clone().into()]) + .await; + issuer2.add_well_known_did_json().await; + + // Assert that both domains were validated (regardless of the order). + assert!(get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone(), issuer2.domain.clone()], + issuer1.did_document.id().to_string().as_ref() + ) + .await + .iter() + .all(|item| [issuer1.domain.clone(), issuer2.domain.clone()].contains(item))); + } +} diff --git a/agent_application/example.config.yaml b/agent_application/example.config.yaml index d9622544..4452f801 100644 --- a/agent_application/example.config.yaml +++ b/agent_application/example.config.yaml @@ -15,6 +15,8 @@ did_methods: enabled: false domain_linkage_enabled: false +public_credential_endpoint_enabled: true +public_verification_endpoint_enabled: true signing_algorithms_supported: es256: diff --git a/agent_holder/src/offer/aggregate.rs b/agent_holder/src/offer/aggregate.rs index 0038df53..2067642e 100644 --- a/agent_holder/src/offer/aggregate.rs +++ b/agent_holder/src/offer/aggregate.rs @@ -315,13 +315,12 @@ pub mod tests { use super::test_utils::*; use super::*; use agent_api_rest::issuance; - use agent_api_rest::API_VERSION; use agent_issuance::offer::aggregate::test_utils::token_response; use agent_issuance::server_config::aggregate::test_utils::credential_configurations_supported; use agent_issuance::state::initialize; use agent_secret_manager::service::Service; - use agent_shared::config::config; use agent_shared::config::config_mut; + use agent_shared::config::{config, API_VERSION}; use agent_shared::generate_random_string; use agent_store::in_memory; use axum::{ @@ -556,6 +555,6 @@ pub mod test_utils { #[fixture] pub fn signed_credentials(holder_credential_id: String) -> Vec { - vec![OfferCredential { holder_credential_id, credential: Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ekRuYWVSd1Q0ZzZBWkNIenh2Tkw3RExqcVRhVDg4YW00WFI2VFVHcktyNkRYajZUeiIsIm5iZiI6MTI2MjMwNDAwMCwiaWF0IjoxMjYyMzA0MDAwLCJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6RG5hZVJ3VDRnNkFaQ0h6eHZOTDdETGpxVGFUODhhbTRYUjZUVUdyS3I2RFhqNlR6IiwiZGVncmVlIjp7InR5cGUiOiJNYXN0ZXJEZWdyZWUiLCJuYW1lIjoiTWFzdGVyIG9mIE9jZWFub2dyYXBoeSJ9LCJmaXJzdF9uYW1lIjoiRmVycmlzIiwibGFzdF9uYW1lIjoiUnVzdGFjZWFuIn0sImlzc3VlciI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiaXNzdWFuY2VEYXRlIjoiMjAxMC0wMS0wMVQwMDowMDowMFoiLCJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIiwidHlwZSI6InN0YXR1c2xpc3Qrand0IiwiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fSwic3RhdHVzIjp7InN0YXR1c19saXN0Ijp7ImlkeCI6MTIzLCJ1cmkiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAifX19.6oVjfN06dzQmd2oCVBm9lgOkhL0mLIHn-I8vUB0OX3n7MbjhIWjjfA_6SSvcofGGLj-BFt1fMCDUy1VriCviAA".to_string())}] + vec![OfferCredential { holder_credential_id, credential: Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ekRuYWVSd1Q0ZzZBWkNIenh2Tkw3RExqcVRhVDg4YW00WFI2VFVHcktyNkRYajZUeiIsIm5iZiI6MTI2MjMwNDAwMCwiaWF0IjoxMjYyMzA0MDAwLCJqdGkiOiJ0ZXN0LWNyZWRlbnRpYWwtaWQtMTIzNDUiLCJ2YyI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiZGlkOmtleTp6RG5hZVJ3VDRnNkFaQ0h6eHZOTDdETGpxVGFUODhhbTRYUjZUVUdyS3I2RFhqNlR6IiwiZGVncmVlIjp7InR5cGUiOiJNYXN0ZXJEZWdyZWUiLCJuYW1lIjoiTWFzdGVyIG9mIE9jZWFub2dyYXBoeSJ9LCJmaXJzdF9uYW1lIjoiRmVycmlzIiwibGFzdF9uYW1lIjoiUnVzdGFjZWFuIn0sImlzc3VlciI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiaXNzdWFuY2VEYXRlIjoiMjAxMC0wMS0wMVQwMDowMDowMFoiLCJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIiwidHlwZSI6InN0YXR1c2xpc3Qrand0IiwiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fSwic3RhdHVzIjp7InN0YXR1c19saXN0Ijp7ImlkeCI6MTIzLCJ1cmkiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAifX19.S_8ayJz5COdPQDMo9RfAAUp2uLZ2SmEc2lRvJJm2Exla8aJXnOKWurhDHS7xtyExme55X4k99OzvwyGNVtSjCQ".to_string())}] } } diff --git a/agent_holder/src/presentation/aggregate.rs b/agent_holder/src/presentation/aggregate.rs index 490aa267..74fb51a7 100644 --- a/agent_holder/src/presentation/aggregate.rs +++ b/agent_holder/src/presentation/aggregate.rs @@ -176,6 +176,6 @@ pub mod test_utils { #[fixture] pub fn signed_presentation() -> Jwt { - Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I2tleS0wIn0.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsIm5iZiI6MCwidnAiOnsiQGNvbnRleHQiOiJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsInR5cGUiOiJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIiwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOlsiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbVJwWkRwclpYazZlalpOYTJkRk9EUk9RMDF3VFdWQmVEbHFTemxqWmpWWE5FYzRaMk5hT1hoMWQwcDJSekZsTjNkT2F6aExRMmQwSTNvMlRXdG5SVGcwVGtOTmNFMWxRWGc1YWtzNVkyWTFWelJIT0dkaldqbDRkWGRLZGtjeFpUZDNUbXM0UzBObmRDSjkuZXlKcGMzTWlPaUprYVdRNmEyVjVPbm8yVFd0blJUZzBUa05OY0UxbFFYZzVha3M1WTJZMVZ6UkhPR2RqV2psNGRYZEtka2N4WlRkM1RtczRTME5uZENJc0luTjFZaUk2SW1ScFpEcHJaWGs2ZWtSdVlXVlNkMVEwWnpaQldrTkllbmgyVGt3M1JFeHFjVlJoVkRnNFlXMDBXRkkyVkZWSGNrdHlOa1JZYWpaVWVpSXNJbTVpWmlJNk1USTJNak13TkRBd01Dd2lhV0YwSWpveE1qWXlNekEwTURBd0xDSjJZeUk2ZXlKQVkyOXVkR1Y0ZENJNld5Sm9kSFJ3Y3pvdkwzZDNkeTUzTXk1dmNtY3ZNakF4T0M5amNtVmtaVzUwYVdGc2N5OTJNU0pkTENKMGVYQmxJanBiSWxabGNtbG1hV0ZpYkdWRGNtVmtaVzUwYVdGc0lsMHNJbU55WldSbGJuUnBZV3hUZFdKcVpXTjBJanA3SW1sa0lqb2laR2xrT210bGVUcDZSRzVoWlZKM1ZEUm5Oa0ZhUTBoNmVIWk9URGRFVEdweFZHRlVPRGhoYlRSWVVqWlVWVWR5UzNJMlJGaHFObFI2SWl3aVpHVm5jbVZsSWpwN0luUjVjR1VpT2lKTllYTjBaWEpFWldkeVpXVWlMQ0p1WVcxbElqb2lUV0Z6ZEdWeUlHOW1JRTlqWldGdWIyZHlZWEJvZVNKOUxDSm1hWEp6ZEY5dVlXMWxJam9pUm1WeWNtbHpJaXdpYkdGemRGOXVZVzFsSWpvaVVuVnpkR0ZqWldGdUluMHNJbWx6YzNWbGNpSTZJbVJwWkRwclpYazZlalpOYTJkRk9EUk9RMDF3VFdWQmVEbHFTemxqWmpWWE5FYzRaMk5hT1hoMWQwcDJSekZsTjNkT2F6aExRMmQwSWl3aWFYTnpkV0Z1WTJWRVlYUmxJam9pTWpBeE1DMHdNUzB3TVZRd01Eb3dNRG93TUZvaUxDSmpjbVZrWlc1MGFXRnNVM1JoZEhWeklqcDdJbWxrSWpvaWFIUjBjSE02THk5dGVTMWtiMjFoYVc0dVpYaGhiWEJzWlM1dmNtY3ZhV1YwWmkxdllYVjBhQzEwYjJ0bGJpMXpkR0YwZFhNdGJHbHpkQzh3SWl3aWRIbHdaU0k2SW5OMFlYUjFjMnhwYzNRcmFuZDBJaXdpYVdSNElqb3hNak1zSW5WeWFTSTZJbWgwZEhCek9pOHZiWGt0Wkc5dFlXbHVMbVY0WVcxd2JHVXViM0puTDJsbGRHWXRiMkYxZEdndGRHOXJaVzR0YzNSaGRIVnpMV3hwYzNRdk1DSjlmU3dpYzNSaGRIVnpJanA3SW5OMFlYUjFjMTlzYVhOMElqcDdJbWxrZUNJNk1USXpMQ0oxY21raU9pSm9kSFJ3Y3pvdkwyMTVMV1J2YldGcGJpNWxlR0Z0Y0d4bExtOXlaeTlwWlhSbUxXOWhkWFJvTFhSdmEyVnVMWE4wWVhSMWN5MXNhWE4wTHpBaWZYMTkuNm9WamZOMDZkelFtZDJvQ1ZCbTlsZ09raEwwbUxJSG4tSTh2VUIwT1gzbjdNYmpoSVdqamZBXzZTU3Zjb2ZHR0xqLUJGdDFmTUNEVXkxVnJpQ3ZpQUEiXX19.yC9j8CGnspTVL-wPBHaTw5s9wZ1d-bDyScPbXziAkPQkxHpjsbSkGe5bnWQp3yaZZkEs1EdKRb5wkx_3VMn2CA".to_string()) + Jwt::from("eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I2tleS0wIn0.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsIm5iZiI6MCwidnAiOnsiQGNvbnRleHQiOiJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsInR5cGUiOiJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIiwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOlsiZXlKMGVYQWlPaUpLVjFRaUxDSmhiR2NpT2lKRlpFUlRRU0lzSW10cFpDSTZJbVJwWkRwclpYazZlalpOYTJkRk9EUk9RMDF3VFdWQmVEbHFTemxqWmpWWE5FYzRaMk5hT1hoMWQwcDJSekZsTjNkT2F6aExRMmQwSTNvMlRXdG5SVGcwVGtOTmNFMWxRWGc1YWtzNVkyWTFWelJIT0dkaldqbDRkWGRLZGtjeFpUZDNUbXM0UzBObmRDSjkuZXlKcGMzTWlPaUprYVdRNmEyVjVPbm8yVFd0blJUZzBUa05OY0UxbFFYZzVha3M1WTJZMVZ6UkhPR2RqV2psNGRYZEtka2N4WlRkM1RtczRTME5uZENJc0luTjFZaUk2SW1ScFpEcHJaWGs2ZWtSdVlXVlNkMVEwWnpaQldrTkllbmgyVGt3M1JFeHFjVlJoVkRnNFlXMDBXRkkyVkZWSGNrdHlOa1JZYWpaVWVpSXNJbTVpWmlJNk1USTJNak13TkRBd01Dd2lhV0YwSWpveE1qWXlNekEwTURBd0xDSnFkR2tpT2lKMFpYTjBMV055WldSbGJuUnBZV3d0YVdRdE1USXpORFVpTENKMll5STZleUpBWTI5dWRHVjRkQ0k2V3lKb2RIUndjem92TDNkM2R5NTNNeTV2Y21jdk1qQXhPQzlqY21Wa1pXNTBhV0ZzY3k5Mk1TSmRMQ0owZVhCbElqcGJJbFpsY21sbWFXRmliR1ZEY21Wa1pXNTBhV0ZzSWwwc0ltTnlaV1JsYm5ScFlXeFRkV0pxWldOMElqcDdJbWxrSWpvaVpHbGtPbXRsZVRwNlJHNWhaVkozVkRSbk5rRmFRMGg2ZUhaT1REZEVUR3B4VkdGVU9EaGhiVFJZVWpaVVZVZHlTM0kyUkZocU5sUjZJaXdpWkdWbmNtVmxJanA3SW5SNWNHVWlPaUpOWVhOMFpYSkVaV2R5WldVaUxDSnVZVzFsSWpvaVRXRnpkR1Z5SUc5bUlFOWpaV0Z1YjJkeVlYQm9lU0o5TENKbWFYSnpkRjl1WVcxbElqb2lSbVZ5Y21seklpd2liR0Z6ZEY5dVlXMWxJam9pVW5WemRHRmpaV0Z1SW4wc0ltbHpjM1ZsY2lJNkltUnBaRHByWlhrNmVqWk5hMmRGT0RST1EwMXdUV1ZCZURscVN6bGpaalZYTkVjNFoyTmFPWGgxZDBwMlJ6RmxOM2RPYXpoTFEyZDBJaXdpYVhOemRXRnVZMlZFWVhSbElqb2lNakF4TUMwd01TMHdNVlF3TURvd01Eb3dNRm9pTENKamNtVmtaVzUwYVdGc1UzUmhkSFZ6SWpwN0ltbGtJam9pYUhSMGNITTZMeTl0ZVMxa2IyMWhhVzR1WlhoaGJYQnNaUzV2Y21jdmFXVjBaaTF2WVhWMGFDMTBiMnRsYmkxemRHRjBkWE10YkdsemRDOHdJaXdpZEhsd1pTSTZJbk4wWVhSMWMyeHBjM1FyYW5kMElpd2lhV1I0SWpveE1qTXNJblZ5YVNJNkltaDBkSEJ6T2k4dmJYa3RaRzl0WVdsdUxtVjRZVzF3YkdVdWIzSm5MMmxsZEdZdGIyRjFkR2d0ZEc5clpXNHRjM1JoZEhWekxXeHBjM1F2TUNKOWZTd2ljM1JoZEhWeklqcDdJbk4wWVhSMWMxOXNhWE4wSWpwN0ltbGtlQ0k2TVRJekxDSjFjbWtpT2lKb2RIUndjem92TDIxNUxXUnZiV0ZwYmk1bGVHRnRjR3hsTG05eVp5OXBaWFJtTFc5aGRYUm9MWFJ2YTJWdUxYTjBZWFIxY3kxc2FYTjBMekFpZlgxOS5TXzhheUp6NUNPZFBRRE1vOVJmQUFVcDJ1TFoyU21FYzJsUnZKSm0yRXhsYThhSlhuT0tXdXJoREhTN3h0eUV4bWU1NVg0azk5T3p2d3lHTlZ0U2pDUSJdfX0.75mHffStcj527yIKZUZ9JQIQNtlZbhvdYQM3MLn1L79XmCdUdDX6Kasfu9o3nrc0ahjvn0DMhybAupt1iNflAQ".to_string()) } } diff --git a/agent_identity/src/document/aggregate.rs b/agent_identity/src/document/aggregate.rs index 62d1ebd1..4c435e92 100644 --- a/agent_identity/src/document/aggregate.rs +++ b/agent_identity/src/document/aggregate.rs @@ -331,9 +331,9 @@ impl Aggregate for Document { let verification_method = VerificationMethod::new_from_jwk( did.clone(), public_key_jwk, - (did_method == SupportedDidMethod::Key) - .then_some(did.method_id()) - .or(did_method.fragment()), + Some("key-0"), // TODO: (did_method == SupportedDidMethod::Key) + // .then_some(did.method_id()) + // .or(did_method.fragment()), ) .map_err(|err| VerificationMethodBuilderError(err.to_string()))?; diff --git a/agent_identity/src/service/aggregate.rs b/agent_identity/src/service/aggregate.rs index 8d727ea0..09da3f35 100644 --- a/agent_identity/src/service/aggregate.rs +++ b/agent_identity/src/service/aggregate.rs @@ -1,6 +1,6 @@ use super::{command::ServiceCommand, error::ServiceError, event::ServiceEvent}; use crate::services::IdentityServices; -use agent_shared::config::config; +use agent_shared::config::{config, API_VERSION}; use async_trait::async_trait; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use cqrs_es::Aggregate; @@ -55,6 +55,76 @@ impl Aggregate for Service { info!("Handling command: {:?}", command); match command { + CreatePublicVerificationEndpointService { service_id } => { + let origin = identity_core::common::Url::parse(config().public_url.origin().ascii_serialization()) + .map_err(|err| InvalidUrlError(err.to_string()))?; + + let endpoint_url = + // TODO: the backend route is + API_VERSION + "/public-verification", but the public page is at "/verify". + // In the future it would be better if we can make this into 1 route. + origin.to_string().trim_end_matches('/').to_string() + "/verify"; + let endpoint_url = endpoint_url + .parse::() + .map_err(|err| InvalidUrlError(err.to_string()))?; + let service_endpoint = ServiceEndpoint::from(endpoint_url); + + // Create a new service. + let service = DocumentService::builder(Default::default()) + // This service is DID method-agnostic. When added to an enabled DID Document, + // its placeholder value is replaced with the appropriate DID method-specific identifier. + .id(format!("did:place:holder#{service_id}") + .parse::() + .map_err(|err| InvalidDidError(err.to_string()))?) + .type_("PublicVerificationEndpoint") + .service_endpoint(service_endpoint) + .build() + .map_err(|err| ServiceBuilderError(err.to_string()))?; + + Ok(vec![PublicVerificationServiceCreated { + service_id, + service, + is_deleted: false, + }]) + } + DeletePublicVerificationEndpointService { service_id } => Ok(vec![PublicVerificationServiceDeleted { + service_id, + service: None, + is_deleted: true, + }]), + CreatePublicCredentialEndpointService { service_id } => { + let origin = identity_core::common::Url::parse(config().public_url.origin().ascii_serialization()) + .map_err(|err| InvalidUrlError(err.to_string()))?; + + let endpoint_url = + origin.to_string().trim_end_matches('/').to_string() + API_VERSION + "/public-credential"; + let endpoint_url = endpoint_url + .parse::() + .map_err(|err| InvalidUrlError(err.to_string()))?; + let service_endpoint = ServiceEndpoint::from(endpoint_url); + + // Create a new service. + let service = DocumentService::builder(Default::default()) + // This service is DID method-agnostic. When added to an enabled DID Document, + // its placeholder value is replaced with the appropriate DID method-specific identifier. + .id(format!("did:place:holder#{service_id}") + .parse::() + .map_err(|err| InvalidDidError(err.to_string()))?) + .type_("PublicCredentialEndpoint") + .service_endpoint(service_endpoint) + .build() + .map_err(|err| ServiceBuilderError(err.to_string()))?; + + Ok(vec![PublicCredentialServiceCreated { + service_id, + service, + is_deleted: false, + }]) + } + DeletePublicCredentialEndpointService { service_id } => Ok(vec![PublicCredentialServiceDeleted { + service_id, + service: None, + is_deleted: true, + }]), CreateDomainLinkageService { service_id, verification_methods, @@ -253,6 +323,42 @@ impl Aggregate for Service { self.presentation_ids = presentation_ids; self.service.replace(service); } + PublicVerificationServiceCreated { + service_id, + service, + is_deleted, + } => { + self.service_id = service_id; + self.service.replace(service); + self.is_deleted = is_deleted; + } + PublicVerificationServiceDeleted { + service_id, + service, + is_deleted, + } => { + self.service_id = service_id; + self.service = service; + self.is_deleted = is_deleted; + } + PublicCredentialServiceCreated { + service_id, + service, + is_deleted, + } => { + self.service_id = service_id; + self.service.replace(service); + self.is_deleted = is_deleted; + } + PublicCredentialServiceDeleted { + service_id, + service, + is_deleted, + } => { + self.service_id = service_id; + self.service = service; + self.is_deleted = is_deleted; + } } } } diff --git a/agent_identity/src/service/command.rs b/agent_identity/src/service/command.rs index 69aaa675..4e0ca1a9 100644 --- a/agent_identity/src/service/command.rs +++ b/agent_identity/src/service/command.rs @@ -15,4 +15,16 @@ pub enum ServiceCommand { service_id: String, presentation_ids: Vec, }, + CreatePublicCredentialEndpointService { + service_id: String, + }, + DeletePublicCredentialEndpointService { + service_id: String, + }, + CreatePublicVerificationEndpointService { + service_id: String, + }, + DeletePublicVerificationEndpointService { + service_id: String, + }, } diff --git a/agent_identity/src/service/event.rs b/agent_identity/src/service/event.rs index f536bf77..88aaa78b 100644 --- a/agent_identity/src/service/event.rs +++ b/agent_identity/src/service/event.rs @@ -28,6 +28,26 @@ pub enum ServiceEvent { presentation_ids: Vec, service: DocumentService, }, + PublicCredentialServiceCreated { + service_id: String, + service: DocumentService, + is_deleted: bool, + }, + PublicCredentialServiceDeleted { + service_id: String, + service: Option, + is_deleted: bool, + }, + PublicVerificationServiceCreated { + service_id: String, + service: DocumentService, + is_deleted: bool, + }, + PublicVerificationServiceDeleted { + service_id: String, + service: Option, + is_deleted: bool, + }, } impl DomainEvent for ServiceEvent { diff --git a/agent_identity/src/service/views/mod.rs b/agent_identity/src/service/views/mod.rs index 0d310d3d..83677955 100644 --- a/agent_identity/src/service/views/mod.rs +++ b/agent_identity/src/service/views/mod.rs @@ -40,6 +40,42 @@ impl View for Service { self.presentation_ids.clone_from(presentation_ids); self.service.replace(service.clone()); } + PublicCredentialServiceCreated { + service_id, + service, + is_deleted, + } => { + self.service_id.clone_from(service_id); + self.service.replace(service.clone()); + self.is_deleted.clone_from(is_deleted); + } + PublicCredentialServiceDeleted { + service_id, + service, + is_deleted, + } => { + self.service_id.clone_from(service_id); + self.service.clone_from(service); + self.is_deleted.clone_from(is_deleted); + } + PublicVerificationServiceCreated { + service_id, + service, + is_deleted, + } => { + self.service_id.clone_from(service_id); + self.service.replace(service.clone()); + self.is_deleted.clone_from(is_deleted); + } + PublicVerificationServiceDeleted { + service_id, + service, + is_deleted, + } => { + self.service_id.clone_from(service_id); + self.service.clone_from(service); + self.is_deleted.clone_from(is_deleted); + } } } } diff --git a/agent_identity/src/state.rs b/agent_identity/src/state.rs index 53d6fb2f..a019b0c7 100644 --- a/agent_identity/src/state.rs +++ b/agent_identity/src/state.rs @@ -97,6 +97,12 @@ pub const DOMAIN_LINKAGE_SERVICE_ID: &str = "linked-domain-service"; /// The unique identifier for the linked verifiable presentation service. pub const LINKED_VERIFIABLE_PRESENTATION_SERVICE_ID: &str = "linked-verifiable-presentation-service"; +/// The unique identifier for the public credential endpoint service. +pub const PUBLIC_CREDENTIAL_SERVICE_ID: &str = "public-credential-endpoint-service"; + +/// The unique identifier for the public verification endpoint service. +pub const PUBLIC_VERIFICATION_SERVICE_ID: &str = "public-verification-endpoint-service"; + /// Initialize the identity state. pub async fn initialize(state: &IdentityState) -> anyhow::Result<()> { info!("Initializing the identity state ..."); @@ -105,8 +111,15 @@ pub async fn initialize(state: &IdentityState) -> anyhow::Result<()> { initialize_documents(state).await?; initialize_domain_linkage(state).await?; initialize_linked_verifiable_presentations(state).await?; + initialize_public_credential_endpoint(state).await?; + initialize_public_verification_endpoint(state).await?; publish_decentrally_hosted_documents(state).await?; + info!( + "All DID documents: {:#?}", + query_all_documents(state, |(_, _)| true).await? + ); + Ok(()) } @@ -515,6 +528,158 @@ pub async fn initialize_linked_verifiable_presentations(state: &IdentityState) - Ok(()) } +/// Initializes the Public Credential Endpoint service based on the current configuration and document state. +/// +/// This asynchronous function performs the following steps: +/// +/// 1. Query Documents: It retrieves all documents that are not disabled and whose DID methods support updates. +/// 2. Conditional Service Creation: +/// - If domain linkage is enabled in the configuration and there exists at least one update-supporting document, +/// it creates the Public Credential Endpoint service. +/// - It then queries for the created service. If found, it adds the service to all update-supporting documents. +/// 3. Service Deletion: +/// - If the Public Credential Endpoint service is disabled or no update-supporting documents exist, the function sends a command +/// to disable the Public Credential Endpoint service. +pub async fn initialize_public_credential_endpoint(state: &IdentityState) -> anyhow::Result<()> { + // Get all the Documents that are not disabled and support updates. + let update_supporting_documents = query_all_documents(state, |(_, document)| { + document.status != Status::Disabled + && document + .did_method + .as_ref() + .map(SupportedDidMethod::supports_update) + .unwrap_or_default() + && document + .iota_metadata + .as_ref() + .map(|iota_metadata| iota_metadata.is_funded) + .unwrap_or(true) + }) + .await?; + + // Check whether Public Credential Endpoint service is enabled and whether there are any enabled update-supporting Documents. + if config().public_credential_endpoint_enabled && !update_supporting_documents.is_empty() { + info!( + "Creating public credential endpoint service with documents: {:?}", + update_supporting_documents + ); + + // Create the Public Credential Endpoint service. + let command = ServiceCommand::CreatePublicCredentialEndpointService { + service_id: PUBLIC_CREDENTIAL_SERVICE_ID.to_string(), + }; + + command_handler(PUBLIC_CREDENTIAL_SERVICE_ID, &state.command.service, command).await?; + + info!("Created Public Credential Endpoint service"); + + match query_handler(PUBLIC_CREDENTIAL_SERVICE_ID, &state.query.service).await { + Ok(Some(Service { + service: Some(service), .. + })) => { + info!("Found Public Credential Endpoint service: {service}"); + + // Add the Public Credential Endpoint service to all the enabled update supporting Documents. + for document_id in update_supporting_documents.keys() { + let command = DocumentCommand::AddService { + service_id: PUBLIC_CREDENTIAL_SERVICE_ID.to_string(), + service: Box::new(service.clone()), + }; + + command_handler(document_id, &state.command.document, command).await?; + } + } + _ => anyhow::bail!("Failed to retrieve Public Credential Endpoint service"), + }; + } else { + // If Public Credential Endpoint service is disabled and/or there are no enabled update supporting Documents, then disable the Public Credential Endpoint service. + let command = ServiceCommand::DeletePublicCredentialEndpointService { + service_id: PUBLIC_CREDENTIAL_SERVICE_ID.to_string(), + }; + + command_handler(PUBLIC_CREDENTIAL_SERVICE_ID, &state.command.service, command).await?; + + info!("Disabled Public Credential Endpoint service"); + } + + Ok(()) +} + +/// Initializes the Public Verification Endpoint service based on the current configuration and document state. +/// +/// This asynchronous function performs the following steps: +/// 1. Query Documents: It retrieves all documents that are not disabled and whose DID methods support updates. +/// 2. Conditional Service Creation: +/// - If public verification endpoint is enabled in the configuration and there exists at least one update-supporting document, +/// it creates the Public Verification Endpoint service. +/// - It then queries for the created service. If found, it adds the service to all update-supporting documents. +/// 3. Service Deletion: +/// - If the Public Verification Endpoint service is disabled or no update-supporting documents exist, the function sends a command +/// to disable the Public Verification Endpoint service. +pub async fn initialize_public_verification_endpoint(state: &IdentityState) -> anyhow::Result<()> { + // Get all the Documents that are not disabled and support updates. + let update_supporting_documents = query_all_documents(state, |(_, document)| { + document.status != Status::Disabled + && document + .did_method + .as_ref() + .map(SupportedDidMethod::supports_update) + .unwrap_or_default() + && document + .iota_metadata + .as_ref() + .map(|iota_metadata| iota_metadata.is_funded) + .unwrap_or(true) + }) + .await?; + + // Check whether Public Verification Endpoint service is enabled and whether there are any enabled update-supporting Documents. + if config().public_credential_endpoint_enabled && !update_supporting_documents.is_empty() { + info!( + "Creating public credential endpoint service with documents: {:?}", + update_supporting_documents + ); + + // Create the Public Verification Endpoint service. + let command = ServiceCommand::CreatePublicVerificationEndpointService { + service_id: PUBLIC_VERIFICATION_SERVICE_ID.to_string(), + }; + + command_handler(PUBLIC_VERIFICATION_SERVICE_ID, &state.command.service, command).await?; + info!("Created Linked Domain service"); + + match query_handler(PUBLIC_VERIFICATION_SERVICE_ID, &state.query.service).await { + Ok(Some(Service { + service: Some(service), .. + })) => { + info!("Found Public Verification Endpoint service: {service}"); + + // Add the Public Verification Endpoint service to all the enabled update supporting Documents. + for document_id in update_supporting_documents.keys() { + let command = DocumentCommand::AddService { + service_id: PUBLIC_VERIFICATION_SERVICE_ID.to_string(), + service: Box::new(service.clone()), + }; + + command_handler(document_id, &state.command.document, command).await?; + } + } + _ => anyhow::bail!("Failed to retrieve Public Verification Endpoint service"), + }; + } else { + // If Public Verification Endpoint service is disabled and/or there are no enabled update supporting Documents, then disable the Public Verification Endpoint service. + let command = ServiceCommand::DeletePublicVerificationEndpointService { + service_id: PUBLIC_VERIFICATION_SERVICE_ID.to_string(), + }; + + command_handler(PUBLIC_VERIFICATION_SERVICE_ID, &state.command.service, command).await?; + + info!("Disabled Public Verification Endpoint service"); + } + + Ok(()) +} + /// Publishes all decentrally hosted documents. /// /// This asynchronous function performs the following steps: diff --git a/agent_issuance/src/credential/aggregate.rs b/agent_issuance/src/credential/aggregate.rs index 900015ee..85119709 100644 --- a/agent_issuance/src/credential/aggregate.rs +++ b/agent_issuance/src/credential/aggregate.rs @@ -406,12 +406,24 @@ impl Aggregate for Credential { .timestamp() }); + // The jti must be a URL which is the same as the credential ID, as per https://www.w3.org/TR/2023/WD-vc-jwt-20230427/#jwt-encoding + #[cfg(feature = "test_utils")] + let jti = "http://example.org/1234".to_string(); + #[cfg(not(feature = "test_utils"))] + let jti = if let Some(id) = id { + id.to_string() + } else { + config().public_url.to_string().trim_end_matches('/').to_string() + "/" + &credential_id + }; + // Add standard claims let vc_jwt_builder = VerifiableCredentialJwt::builder() .sub(subject_id) .iss(issuer_did) .iat(iat) - .nbf(iat); // TODO: setting the `nbf` to `iat` makes the JWT immediately usable + // TODO: setting the `nbf` to `iat` makes the JWT immediately usable + .nbf(iat) + .jti(jti); let vc_jwt_builder = if let Some(exp) = exp { vc_jwt_builder.exp(exp) @@ -419,12 +431,6 @@ impl Aggregate for Credential { vc_jwt_builder }; - let vc_jwt_builder = if let Some(id) = id { - vc_jwt_builder.jti(id.to_string()) - } else { - vc_jwt_builder - }; - let vc_jwt_built = vc_jwt_builder .verifiable_credential(credential.raw) .build() @@ -696,9 +702,9 @@ pub mod test_utils { "notification_id".to_string() } - pub const OPENBADGE_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsImp0aSI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY3JlZGVudGlhbHMvMzUyNyIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9vYi92M3AwL2NvbnRleHQtMy4wLjMuanNvbiJdLCJpZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY3JlZGVudGlhbHMvMzUyNyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJPcGVuQmFkZ2VDcmVkZW50aWFsIl0sImlzc3VlciI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiaXNzdWFuY2VEYXRlIjoiMjAxMC0wMS0wMVQwMDowMDowMFoiLCJuYW1lIjoiVGVhbXdvcmsgQmFkZ2UiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwidHlwZSI6WyJBY2hpZXZlbWVudFN1YmplY3QiXSwiYWNoaWV2ZW1lbnQiOnsiaWQiOiJodHRwczovL2V4YW1wbGUuY29tL2FjaGlldmVtZW50cy8yMXN0LWNlbnR1cnktc2tpbGxzL3RlYW13b3JrIiwidHlwZSI6IkFjaGlldmVtZW50IiwiY3JpdGVyaWEiOnsibmFycmF0aXZlIjoiVGVhbSBtZW1iZXJzIGFyZSBub21pbmF0ZWQgZm9yIHRoaXMgYmFkZ2UgYnkgdGhlaXIgcGVlcnMgYW5kIHJlY29nbml6ZWQgdXBvbiByZXZpZXcgYnkgRXhhbXBsZSBDb3JwIG1hbmFnZW1lbnQuIn0sImRlc2NyaXB0aW9uIjoiVGhpcyBiYWRnZSByZWNvZ25pemVzIHRoZSBkZXZlbG9wbWVudCBvZiB0aGUgY2FwYWNpdHkgdG8gY29sbGFib3JhdGUgd2l0aGluIGEgZ3JvdXAgZW52aXJvbm1lbnQuIiwibmFtZSI6IlRlYW13b3JrIn19LCJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIiwidHlwZSI6InN0YXR1c2xpc3Qrand0IiwidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIiwiaWR4IjowfX0sInN0YXR1cyI6eyJzdGF0dXNfbGlzdCI6eyJpZHgiOjAsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.FBmcIzSWi10Fvr_r6PLM18seqiavenyuSzryt-CToleTUuy5p4lLzWm1Cj5OmYrEWxwC4dMH46szxEt8YwqsBw"; + pub const OPENBADGE_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsImp0aSI6InRlc3QtY3JlZGVudGlhbC1pZC0xMjM0NSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIiwiaHR0cHM6Ly9wdXJsLmltc2dsb2JhbC5vcmcvc3BlYy9vYi92M3AwL2NvbnRleHQtMy4wLjMuanNvbiJdLCJpZCI6Imh0dHBzOi8vZXhhbXBsZS5jb20vY3JlZGVudGlhbHMvMzUyNyIsInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJPcGVuQmFkZ2VDcmVkZW50aWFsIl0sImlzc3VlciI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwiaXNzdWFuY2VEYXRlIjoiMjAxMC0wMS0wMVQwMDowMDowMFoiLCJuYW1lIjoiVGVhbXdvcmsgQmFkZ2UiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwidHlwZSI6WyJBY2hpZXZlbWVudFN1YmplY3QiXSwiYWNoaWV2ZW1lbnQiOnsiaWQiOiJodHRwczovL2V4YW1wbGUuY29tL2FjaGlldmVtZW50cy8yMXN0LWNlbnR1cnktc2tpbGxzL3RlYW13b3JrIiwidHlwZSI6IkFjaGlldmVtZW50IiwiY3JpdGVyaWEiOnsibmFycmF0aXZlIjoiVGVhbSBtZW1iZXJzIGFyZSBub21pbmF0ZWQgZm9yIHRoaXMgYmFkZ2UgYnkgdGhlaXIgcGVlcnMgYW5kIHJlY29nbml6ZWQgdXBvbiByZXZpZXcgYnkgRXhhbXBsZSBDb3JwIG1hbmFnZW1lbnQuIn0sImRlc2NyaXB0aW9uIjoiVGhpcyBiYWRnZSByZWNvZ25pemVzIHRoZSBkZXZlbG9wbWVudCBvZiB0aGUgY2FwYWNpdHkgdG8gY29sbGFib3JhdGUgd2l0aGluIGEgZ3JvdXAgZW52aXJvbm1lbnQuIiwibmFtZSI6IlRlYW13b3JrIn19LCJjcmVkZW50aWFsU3RhdHVzIjp7ImlkIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIiwidHlwZSI6InN0YXR1c2xpc3Qrand0IiwidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIiwiaWR4IjowfX0sInN0YXR1cyI6eyJzdGF0dXNfbGlzdCI6eyJpZHgiOjAsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.wBz4XKyHt1t5qjxHNbQge84ifK7eK_kBpsxqHtCQ_-X3gNU2XlCiyBluFhS3G90RP4hVrU-QieRKjsnSz12MBg"; - pub const W3C_VC_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4iLCJkZWdyZWUiOnsidHlwZSI6Ik1hc3RlckRlZ3JlZSIsIm5hbWUiOiJNYXN0ZXIgb2YgT2NlYW5vZ3JhcGh5In19LCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsImlzc3VhbmNlRGF0ZSI6IjIwMTAtMDEtMDFUMDA6MDA6MDBaIiwiY3JlZGVudGlhbFN0YXR1cyI6eyJpZCI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCIsInR5cGUiOiJzdGF0dXNsaXN0K2p3dCIsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCIsImlkeCI6MH19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjowLCJ1cmkiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAifX19.C-nr-XWFgxQsQTFTQ84d2u-88yL7MEalB_QXHdklfvwIeLL_vYWU4wsRpseB67z5l-3s4zb1nF76yXPjm58vCg"; + pub const W3C_VC_VERIFIABLE_CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0IiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsImp0aSI6InRlc3QtY3JlZGVudGlhbC1pZC0xMjM0NSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4iLCJkZWdyZWUiOnsidHlwZSI6Ik1hc3RlckRlZ3JlZSIsIm5hbWUiOiJNYXN0ZXIgb2YgT2NlYW5vZ3JhcGh5In19LCJpc3N1ZXIiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsImlzc3VhbmNlRGF0ZSI6IjIwMTAtMDEtMDFUMDA6MDA6MDBaIiwiY3JlZGVudGlhbFN0YXR1cyI6eyJpZCI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCIsInR5cGUiOiJzdGF0dXNsaXN0K2p3dCIsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCIsImlkeCI6MH19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjowLCJ1cmkiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAifX19.JUURhe6J3nV9Y0VtZjh1oh-L4hzkTfWJc-NTZ0sTNI8JpKZG5LbHc_CT3pG-xG-mwNSlAFYMdmfmKYwtQc9nCg"; #[fixture] pub fn credential_id() -> String { diff --git a/agent_issuance/src/state.rs b/agent_issuance/src/state.rs index 7a1d95cf..f7361a10 100644 --- a/agent_issuance/src/state.rs +++ b/agent_issuance/src/state.rs @@ -6,7 +6,7 @@ use agent_shared::handlers::{command_handler, query_handler}; use agent_shared::profile::ApplicationProfile; use agent_shared::UrlAppendHelpers; use cqrs_es::persist::ViewRepository; -use oid4vc_core::Sign; +use oid4vc_core::{Sign, Subject}; use oid4vci::credential_issuer::authorization_server_metadata::AuthorizationServerMetadata; use oid4vci::credential_issuer::credential_issuer_metadata::CredentialIssuerMetadata; use std::sync::Arc; @@ -29,6 +29,7 @@ pub struct IssuanceState { pub command: CommandHandlers, pub query: Queries, pub signer: Arc, + pub subject: Arc, } /// The command handlers are used to execute commands on the aggregates. diff --git a/agent_secret_manager/src/subject.rs b/agent_secret_manager/src/subject.rs index eb138440..25869787 100644 --- a/agent_secret_manager/src/subject.rs +++ b/agent_secret_manager/src/subject.rs @@ -223,6 +223,53 @@ impl StorageKey { } } +// Helpers + +pub async fn get_public_key_from_kid(did_url: &str) -> anyhow::Result> { + let did_url = + identity_iota::did::DIDUrl::parse(did_url).map_err(|err| anyhow!("Failed to parse DID URL: {err}"))?; + + let resolver = Resolver::new().await; + + let document = resolver + .resolve(did_url.did().as_str()) + .await + .map_err(|err| anyhow!("Failed to resolve DID Document for DID: `{did_url}`, error: {err}"))?; + + let verification_method = document + .resolve_method(DIDUrlQuery::from(&did_url), None) + .ok_or(anyhow!( + "Failed to resolve verification method for DID URL: `{did_url}`" + ))?; + + // Try decode from `MethodData` directly, else use public JWK params. + verification_method.data().try_decode().or_else(|_| { + verification_method + .data() + .public_key_jwk() + .and_then(|public_key_jwk| match public_key_jwk.params() { + JwkParams::Okp(okp_params) => URL_SAFE_NO_PAD.decode(&okp_params.x).ok(), + JwkParams::Ec(ec_params) => { + let x_bytes = URL_SAFE_NO_PAD.decode(&ec_params.x).ok()?; + let y_bytes = URL_SAFE_NO_PAD.decode(&ec_params.y).ok()?; + + let encoded_point = p256::EncodedPoint::from_affine_coordinates( + p256::FieldBytes::from_slice(&x_bytes), + p256::FieldBytes::from_slice(&y_bytes), + false, // false for uncompressed point + ); + + let verifying_key = p256::ecdsa::VerifyingKey::from_encoded_point(&encoded_point) + .expect("Failed to create verifying key from encoded point"); + + Some(verifying_key.to_encoded_point(false).as_bytes().to_vec()) + } + _ => None, + }) + .ok_or(anyhow!("Failed to decode public key for DID URL: `{did_url}`")) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/agent_shared/src/config/mod.rs b/agent_shared/src/config/mod.rs index 45f8923e..5b3381bd 100644 --- a/agent_shared/src/config/mod.rs +++ b/agent_shared/src/config/mod.rs @@ -35,6 +35,8 @@ pub use provisioned::load_provisioned_config; pub const BITS_PER_STATUS: u8 = 2; // Amount of bits per status pub const STATUS_LIST_BYTES_AMOUNT: usize = 2048; // Amount of bytes in the status list. Equates to 8192 statuses for BITS_PER_STATUS = 2. +pub const API_VERSION: &str = "/v0"; + static STRONGHOLD_PATH: &str = "./stronghold.dat"; // TODO: Once we have a proper state implementation for `agent_secret_manager` we can make use of randomly generated Key @@ -248,6 +250,10 @@ pub struct ApplicationConfiguration { pub external_server_response_timeout_ms: u64, #[config(default, production_default = "true")] pub domain_linkage_enabled: bool, + #[config(default = "true", production_default = "true")] + pub public_credential_endpoint_enabled: bool, + #[config(default = "true", production_default = "true")] + pub public_verification_endpoint_enabled: bool, #[config(default)] pub credential_offer_by_value_enabled: bool, #[config(development_default = "SecretManagerConfig::development_default()")] diff --git a/agent_store/src/in_memory.rs b/agent_store/src/in_memory.rs index 72e49d31..a3e16b0b 100644 --- a/agent_store/src/in_memory.rs +++ b/agent_store/src/in_memory.rs @@ -179,5 +179,6 @@ pub async fn issuance_state( all_offers, }, signer: issuance_services.issuer.clone(), + subject: issuance_services.issuer.clone(), } } diff --git a/agent_store/src/lib.rs b/agent_store/src/lib.rs index 7dcce139..75ff90f5 100644 --- a/agent_store/src/lib.rs +++ b/agent_store/src/lib.rs @@ -219,6 +219,7 @@ pub async fn verification_state( authorization_request, all_authorization_requests, }, + subject: services.verifier.clone(), } } diff --git a/agent_store/src/mongodb.rs b/agent_store/src/mongodb.rs index d2b0896f..2dee7a67 100644 --- a/agent_store/src/mongodb.rs +++ b/agent_store/src/mongodb.rs @@ -156,5 +156,6 @@ pub async fn issuance_state( all_offers, }, signer: issuance_services.issuer.clone(), + subject: issuance_services.issuer.clone(), } } diff --git a/agent_store/src/postgres.rs b/agent_store/src/postgres.rs index 57cb342a..8efd5f16 100644 --- a/agent_store/src/postgres.rs +++ b/agent_store/src/postgres.rs @@ -150,5 +150,6 @@ pub async fn issuance_state( all_offers, }, signer: issuance_services.issuer.clone(), + subject: issuance_services.issuer.clone(), } } diff --git a/agent_verification/src/state.rs b/agent_verification/src/state.rs index 09ed29be..1805e230 100644 --- a/agent_verification/src/state.rs +++ b/agent_verification/src/state.rs @@ -1,5 +1,6 @@ use agent_shared::application_state::CommandHandler; use cqrs_es::persist::ViewRepository; +use oid4vc_core::Subject; use std::sync::Arc; use crate::authorization_request::aggregate::AuthorizationRequest; @@ -10,6 +11,7 @@ use crate::authorization_request::views::AuthorizationRequestView; pub struct VerificationState { pub command: CommandHandlers, pub query: Queries, + pub subject: Arc, } /// The command handlers are used to execute commands on the aggregates.