diff --git a/Cargo.lock b/Cargo.lock index b4af8f210..fb38b3930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,7 +1316,7 @@ checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2" [[package]] name = "consumer" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did_iota", "did_jwk", @@ -1929,7 +1929,7 @@ dependencies = [ [[package]] name = "did_iota" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "bls12_381_plus 0.8.15", "identity_iota", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "did_jwk" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did-jwk", "identity_iota", @@ -1960,7 +1960,7 @@ dependencies = [ [[package]] name = "did_key" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did-method-key", "identity_iota", @@ -1978,7 +1978,7 @@ dependencies = [ [[package]] name = "did_manager" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "consumer", "producer", @@ -2006,7 +2006,7 @@ dependencies = [ [[package]] name = "did_web" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did-web", "identity_iota", @@ -4051,9 +4051,11 @@ dependencies = [ "dyn-clone", "futures", "icu", + "identity_core", "identity_credential", "identity_eddsa_verifier", "identity_iota", + "identity_jose", "iota_stronghold", "itertools 0.10.5", "jsonwebtoken", @@ -4065,10 +4067,11 @@ dependencies = [ "ring", "serde", "serde_json", + "serde_with 3.9.0", "serial_test", "sha256", "stronghold_engine", - "stronghold_ext", + "stronghold_ext 0.1.0 (git+https://github.com/tensor-programming/stronghold_ext)", "strum", "tauri", "tempfile", @@ -4281,7 +4284,7 @@ dependencies = [ [[package]] name = "identity_stronghold_ext" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "async-trait", "elliptic-curve 0.13.8", @@ -4293,7 +4296,7 @@ dependencies = [ "log", "p256 0.13.2", "serde_json", - "stronghold_ext", + "stronghold_ext 0.1.0 (git+https://github.com/impierce/stronghold_ext.git)", "tokio", ] @@ -6800,7 +6803,7 @@ dependencies = [ [[package]] name = "producer" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "did_iota", "did_jwk", @@ -8097,7 +8100,7 @@ dependencies = [ [[package]] name = "shared" version = "0.1.0" -source = "git+https://github.com/impierce/did-manager.git?rev=2b88f55#2b88f55b8f5e42aa794a701c5fe11bc962250139" +source = "git+https://git@github.com/impierce/did-manager.git?tag=v1.0.0-beta.3#3ad5e3dba7bc76df8d6cb4a4fd2df2238d88710b" dependencies = [ "identity_iota", "identity_storage", @@ -8687,6 +8690,24 @@ dependencies = [ "zeroize", ] +[[package]] +name = "stronghold_ext" +version = "0.1.0" +source = "git+https://github.com/impierce/stronghold_ext.git#cad0e5ac4d9011a38c88303b668e790e2f2f3a5e" +dependencies = [ + "ecdsa 0.16.9", + "iota_stronghold", + "k256", + "p256 0.13.2", + "rand 0.8.5", + "serde", + "sha2 0.10.8", + "stronghold-utils", + "stronghold_engine", + "thiserror", + "zeroize", +] + [[package]] name = "stronghold_ext" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 2c45aa796..b8338e929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,8 @@ tauri-runtime-wry = { version = "=2.0.0-beta.20" } tauri-utils = { version = "=2.0.0-beta.19", features = [ "resources" ] } tauri-winres = "=0.1" -did_manager = { git = "https://github.com/impierce/did-manager.git", rev = "2b88f55" } +agent_shared = { git = "https://git@github.com/impierce/ssi-agent.git", rev = "1823810" } +did_manager = { git = "https://git@github.com/impierce/did-manager.git", tag = "v1.0.0-beta.3" } jsonwebtoken = "9.3" log = "^0.4" oid4vc = { git = "https://git@github.com/impierce/openid4vc.git", rev = "d095db0" } @@ -45,4 +46,4 @@ keywords = ["identity", "did", "ssi", "wallet", "siopv2"] license = "Apache-2.0" repository = "https://github.com/impierce/identity-wallet" edition = "2021" -rust-version = "1.75.0" +rust-version = "1.76.0" diff --git a/identity-wallet/Cargo.toml b/identity-wallet/Cargo.toml index debb36e13..ff87d90b7 100644 --- a/identity-wallet/Cargo.toml +++ b/identity-wallet/Cargo.toml @@ -2,7 +2,7 @@ name = "identity-wallet" version = "0.6.8" edition = "2021" -rust-version = "1.75.0" +rust-version.workspace = true [dependencies] tauri.workspace = true @@ -23,8 +23,10 @@ identity_credential = { version = "1.3", default-features = false, features = [ "presentation", "validator", ] } +identity_core = { version = "1.3" } identity_eddsa_verifier = { version = "1.3" } identity_iota = { version = "1.3" } +identity_jose = { version = "1.3" } iota_stronghold = { version = "2.1" } itertools = "0.10.5" jsonwebtoken.workspace = true @@ -37,6 +39,7 @@ reqwest = { version = "0.11", default-features = false, features = [ "rustls-tls", ] } serde = { version = "1.0", features = ["derive"] } +serde_with = "3.8" serde_json.workspace = true sha256 = "1.4" stronghold_engine = { version = "2.0.1" } diff --git a/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts b/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts index fa82e0198..f7e7b9976 100644 --- a/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts +++ b/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LinkedVerifiableCredentialData } from "./LinkedVerifiableCredentialData"; import type { ValidationResult } from "./ValidationResult"; -export type CurrentUserPrompt = { "type": "redirect", target: string, } | { "type": "password-required" } | { "type": "accept-connection", client_name: string, logo_uri?: string, redirect_uri: string, previously_connected: boolean, domain_validation: ValidationResult, } | { "type": "credential-offer", issuer_name: string, logo_uri?: string, credential_configurations: Record, } | { "type": "share-credentials", client_name: string, logo_uri?: string, options: Array, }; \ No newline at end of file +export type CurrentUserPrompt = { "type": "redirect", target: string, } | { "type": "password-required" } | { "type": "accept-connection", client_name: string, logo_uri?: string, redirect_uri: string, previously_connected: boolean, domain_validation: ValidationResult, linked_verifiable_presentations: Array, } | { "type": "credential-offer", issuer_name: string, logo_uri?: string, credential_configurations: Record, } | { "type": "share-credentials", client_name: string, logo_uri?: string, options: Array, }; \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/LinkedVerifiableCredentialData.ts b/identity-wallet/bindings/user_prompt/LinkedVerifiableCredentialData.ts new file mode 100644 index 000000000..9203f2df2 --- /dev/null +++ b/identity-wallet/bindings/user_prompt/LinkedVerifiableCredentialData.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export interface LinkedVerifiableCredentialData { name: string | null, logo_uri: string | null, issuance_date: string, } \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/ValidationResult.ts b/identity-wallet/bindings/user_prompt/ValidationResult.ts index fcc211ac3..9a5206b0d 100644 --- a/identity-wallet/bindings/user_prompt/ValidationResult.ts +++ b/identity-wallet/bindings/user_prompt/ValidationResult.ts @@ -1,4 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ValidationStatus } from "./ValidationStatus"; -export interface ValidationResult { status: ValidationStatus, message: string | null, } \ No newline at end of file +export interface ValidationResult { status: ValidationStatus, name?: string, logo_uri?: string, issuance_date?: string, message?: string, } \ No newline at end of file diff --git a/identity-wallet/src/state/did/mod.rs b/identity-wallet/src/state/did/mod.rs index 6d99f468f..60bd8a5ee 100644 --- a/identity-wallet/src/state/did/mod.rs +++ b/identity-wallet/src/state/did/mod.rs @@ -1,3 +1,4 @@ pub mod actions; pub mod reducers; pub mod validate_domain_linkage; +pub mod validate_linked_verifiable_presentations; diff --git a/identity-wallet/src/state/did/validate_domain_linkage.rs b/identity-wallet/src/state/did/validate_domain_linkage.rs index 41d7ef0d3..6f406a5f0 100644 --- a/identity-wallet/src/state/did/validate_domain_linkage.rs +++ b/identity-wallet/src/state/did/validate_domain_linkage.rs @@ -14,12 +14,18 @@ use identity_iota::{ use jsonwebtoken::{crypto::verify, jwk::Jwk as JsonWebTokenJwk, Algorithm, DecodingKey, Validation}; use log::info; use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; use ts_rs::TS; +#[skip_serializing_none] #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, TS, Default)] #[ts(export, export_to = "bindings/user_prompt/ValidationResult.ts")] pub struct ValidationResult { pub(crate) status: ValidationStatus, + pub(crate) name: Option, + #[ts(type = "string", optional)] + pub(crate) logo_uri: Option, + pub(crate) issuance_date: Option, pub(crate) message: Option, } @@ -33,7 +39,7 @@ pub enum ValidationStatus { } /// This `Verifier` uses `jsonwebtoken` under the hood to verify verification input. -struct Verifier; +pub struct Verifier; impl JwsVerifier for Verifier { fn verify(&self, input: VerificationInput, public_key: &IotaIdentityJwk) -> Result<(), SignatureVerificationError> { use SignatureVerificationErrorKind::*; @@ -78,6 +84,7 @@ pub async fn validate_domain_linkage(url: url::Url, did: &str) -> ValidationResu return ValidationResult { status: ValidationStatus::Unknown, message: Some(e.to_string()), + ..Default::default() }; } }; @@ -92,10 +99,13 @@ pub async fn validate_domain_linkage(url: url::Url, did: &str) -> ValidationResu return ValidationResult { status: ValidationStatus::Unknown, message: Some(e.to_string()), + ..Default::default() }; } }; + info!("Resolved document: {:?}", document); + let url = identity_iota::core::Url::from(url); let res = validator.validate_linkage( @@ -108,12 +118,13 @@ pub async fn validate_domain_linkage(url: url::Url, did: &str) -> ValidationResu if res.is_ok() { ValidationResult { status: ValidationStatus::Success, - message: None, + ..Default::default() } } else { ValidationResult { status: ValidationStatus::Failure, message: res.err().map(|e| e.to_string()), + ..Default::default() } } } @@ -228,6 +239,7 @@ mod tests { ValidationResult { status: ValidationStatus::Unknown, message: Some("failed to decode JSON".to_string()), + ..Default::default() } ); } @@ -287,7 +299,8 @@ mod tests { result, ValidationResult { status: ValidationStatus::Failure, - message: Some("invalid issuer DID".to_string()) + message: Some("invalid issuer DID".to_string()), + ..Default::default() } ); } @@ -320,7 +333,8 @@ mod tests { result, ValidationResult { status: ValidationStatus::Failure, - message: Some("invalid issuer DID".to_string()) + message: Some("invalid issuer DID".to_string()), + ..Default::default() } ); } diff --git a/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs b/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs new file mode 100644 index 000000000..223d311ac --- /dev/null +++ b/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs @@ -0,0 +1,873 @@ +use crate::{ + persistence::{download_asset, hash}, + state::did::validate_domain_linkage::{validate_domain_linkage, ValidationStatus, Verifier}, +}; +use did_manager::Resolver; +use futures::{ + future::OptionFuture, + stream::{iter, FuturesUnordered}, + StreamExt, +}; +use identity_iota::{ + core::{OneOrMany, ToJson}, + credential::{DecodedJwtPresentation, FailFast, Jwt, JwtCredentialValidator, JwtPresentationValidator, Subject}, + document::{CoreDocument, Service}, + verification::jws::Decoder, +}; +use identity_jose::jwt::JwtClaims; +use log::{info, warn}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use ts_rs::TS; +use url::Url; + +#[cfg_attr(not(test), derive(PartialEq))] +#[derive(Clone, Serialize, Deserialize, Debug, TS, Default)] +#[ts(export, export_to = "bindings/user_prompt/LinkedVerifiableCredentialData.ts")] +pub struct LinkedVerifiableCredentialData { + pub name: Option, + pub logo_uri: Option, + pub issuance_date: String, + #[ts(skip)] + pub issuer_linked_domains: Vec, +} + +// 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(holder_did: &str) -> Vec> { + info!("Validating linked verifiable presentations for holder DID: {holder_did}"); + + let resolver = Resolver::new().await; + + let holder_document = match resolver.resolve(holder_did).await { + Ok(holder_document) => holder_document, + _ => { + warn!("Failed to resolve holder DID: {holder_did}"); + return vec![]; + } + }; + + info!("Holder document: {holder_document:#?}"); + + iter( + // Get all linked verifiable presentation URLs from the holder document + holder_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, &holder_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(|| { + info!("Found LinkedVerifiablePresentation service: {service:#?}"); + 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!("Failed to parse linked verifiable presentation URL: {}", 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!("Failed to parse linked verifiable presentation URL: {}", 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!("Failed to retrieve linked verifiable presentation: {}", err); + }) + .ok()?; + let status = response.status(); + + response + .text() + .await + .inspect_err(|err| { + warn!("Failed to read linked verifiable presentation response: {}", err); + }) + .ok() + .and_then(|presentation_jwt| { + status.is_success().then(|| { + let validator = JwtPresentationValidator::with_signature_verifier(Verifier); + validator + .validate(&presentation_jwt.into(), &holder_document, &Default::default()) + .inspect_err(|err| { + warn!("Failed to validate linked verifiable presentation: {:#?}", err); + }) + .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(); + + info!("Issuer document: {issuer_document:#?}"); + + // Resolve the issuer linked domains from the issuer document + let issuer_linked_domains = get_issuer_linked_domains(&issuer_document).await; + + info!("Issuer linked domains: {issuer_linked_domains:#?}"); + + // Only linked verifiable credentials with at least one successful domain linkage validation are considered + let validated_linked_domains = get_validated_linked_domains(&issuer_linked_domains, &issuer_did).await; + if !validated_linked_domains.is_empty() { + let validator = JwtCredentialValidator::with_signature_verifier(Verifier); + + // Decode the linked verifiable credential and validate it + if let Ok(linked_verifiable_credential) = validator.validate::<_, Value>( + &linked_verifiable_credential, + &issuer_document, + &Default::default(), + FailFast::FirstError, + ) { + info!("Validated linked verifiable credential: {linked_verifiable_credential:#?}"); + + let credential_subject = match &linked_verifiable_credential.credential.credential_subject { + OneOrMany::One(subject) => Some(subject), + // TODO: how to handle multiple credential subjects? + OneOrMany::Many(subjects) => subjects.first(), + }; + + OptionFuture::from(credential_subject.map(|credential_subject| async { + let name = get_name(credential_subject); + let logo_uri = get_logo_uri(credential_subject).await; + let issuance_date = linked_verifiable_credential.credential.issuance_date.to_rfc3339(); + + LinkedVerifiableCredentialData { + name, + logo_uri, + issuance_date, + issuer_linked_domains: validated_linked_domains, + } + })) + .await + } else { + warn!("Failed to validate linked verifiable credential: {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: {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(issuer_linked_domains: &[Url], issuer_did: &str) -> Vec { + FuturesUnordered::from_iter(issuer_linked_domains.iter().map(|issuer_linked_domain| async move { + let validation_status = validate_domain_linkage(issuer_linked_domain.clone(), issuer_did) + .await + .status; + + if validation_status == ValidationStatus::Success { + info!("Successfully validated domain linkage for issuer linked domain: {issuer_linked_domain}"); + Some(issuer_linked_domain.clone()) + } else { + warn!("Failed to validate domain linkage for issuer linked domain: {issuer_linked_domain}"); + None + } + })) + .filter_map(|result| async move { result }) + .collect() + .await +} + +/// This function uses the linked verifiable credential to resolve the issuer document. +async fn get_issuer_document(resolver: &Resolver, linked_verifiable_credential: &Jwt) -> Option { + let decoder = Decoder::new(); + + // Decode the linked verifiable credential. + let decoded_linked_verifiable_credential = decoder + .decode_compact_serialization(linked_verifiable_credential.as_str().as_bytes(), None) + .inspect_err(|err| warn!("Failed to decode linked verifiable credential: {:#?}", err)) + .ok()?; + + let claims: JwtClaims = serde_json::from_slice(decoded_linked_verifiable_credential.claims()) + .inspect_err(|err| warn!("Failed to parse linked verifiable credential claims: {:#?}", err)) + .ok()?; + + info!("Linked verifiable credential claims: {:#?}", claims); + + // Resolve the DID + resolver + .resolve(claims.iss()?) + .await + .inspect_err(|err| warn!("Failed to resolve issuer DID.: {:#?}", err)) + .ok() +} + +/// 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() +} + +fn get_name(credential_subject: &Subject) -> Option { + credential_subject + .properties + .get("name") + .and_then(Value::as_str) + .map(ToString::to_string) +} + +async fn get_logo_uri(credential_subject: &Subject) -> Option { + OptionFuture::from( + credential_subject + .properties + .get("image") + .and_then(Value::as_str) + .map(|image| async { + let _ = download_asset( + image + .parse() + .inspect_err(|err| warn!("Failed to parse logo URI: {:#?}", err)) + .ok()?, + &hash(image), + ) + .await; + Some(image.to_string()) + }), + ) + .await + .flatten() +} + +#[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(':').last().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) + } + } + + #[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; + + // 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; + + // 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 verifiable_credential_jwt = issuer_a + .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 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 verifiable_credential_jwt_2 = issuer_b + .issue_credential( + holder.did_document.id().to_string().as_str(), + "Webshop", + "https://webshop.com/logo.jpg".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; + + assert_eq!( + validate_linked_verifiable_presentations(holder.did_document.id().to_string().as_ref()).await, + vec![ + vec![LinkedVerifiableCredentialData { + name: Some("Webshop".to_string()), + logo_uri: Some("https://webshop.com/logo.jpg".to_string()), + issuer_linked_domains: vec![issuer_a.domain.clone()], + ..Default::default() + }], + vec![LinkedVerifiableCredentialData { + name: Some("Webshop".to_string()), + logo_uri: Some("https://webshop.com/logo.jpg".to_string()), + 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; + + assert_eq!( + validate_linked_verifiable_presentations(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_succesfully_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; + + let mut holder = TestEntity::new().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"; + 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("https://webshop.com/logo.jpg".to_string()), + issuer_linked_domains: vec![issuer.domain.clone()], + ..Default::default() + }]) + ); + } + + #[tokio::test] + async fn get_validated_linked_domains_returns_only_succesfully_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; + + // Succesfully validate the linked domain. + assert_eq!( + get_validated_linked_domains( + &[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( + &[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( + &[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( + &[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/identity-wallet/src/state/qr_code/reducers/read_authorization_request.rs b/identity-wallet/src/state/qr_code/reducers/read_authorization_request.rs index a4a0e4c30..3b3451413 100644 --- a/identity-wallet/src/state/qr_code/reducers/read_authorization_request.rs +++ b/identity-wallet/src/state/qr_code/reducers/read_authorization_request.rs @@ -8,7 +8,10 @@ use crate::{ credentials::reducers::handle_oid4vp_authorization_request::{ get_oid4vp_client_name_and_logo_uri, OID4VPClientMetadata, }, - did::validate_domain_linkage::validate_domain_linkage, + did::{ + validate_domain_linkage::validate_domain_linkage, + validate_linked_verifiable_presentations::validate_linked_verifiable_presentations, + }, qr_code::actions::qrcode_scanned::QrCodeScanned, user_prompt::CurrentUserPrompt, AppState, @@ -82,7 +85,13 @@ pub async fn read_authorization_request(state: AppState, action: Action) -> Resu let did = siopv2_authorization_request.body.client_id.as_str(); - let domain_validation = validate_domain_linkage(url, did).await; + let domain_validation = Box::new(validate_domain_linkage(url, did).await); + + let linked_verifiable_presentations = validate_linked_verifiable_presentations(did) + .await + .into_iter() + .flatten() + .collect(); drop(state_guard); @@ -97,6 +106,7 @@ pub async fn read_authorization_request(state: AppState, action: Action) -> Resu redirect_uri, previously_connected, domain_validation, + linked_verifiable_presentations, }), ..state }); diff --git a/identity-wallet/src/state/user_prompt.rs b/identity-wallet/src/state/user_prompt.rs index 2afe33670..ba5a59613 100644 --- a/identity-wallet/src/state/user_prompt.rs +++ b/identity-wallet/src/state/user_prompt.rs @@ -6,6 +6,8 @@ use ts_rs::TS; use crate::state::did::validate_domain_linkage::ValidationResult; +use super::did::validate_linked_verifiable_presentations::LinkedVerifiableCredentialData; + /// "User prompts" are a way for the backend to communicate a desired/required user interaction to the frontend. /// This application design leaves it up to the frontend how it wants to handle such "user prompts". /// Having too much frontend logic in the backend would pollute the loose coupling and make it a lot harder to maintain. @@ -28,7 +30,8 @@ pub enum CurrentUserPrompt { logo_uri: Option, redirect_uri: String, previously_connected: bool, - domain_validation: ValidationResult, + domain_validation: Box, + linked_verifiable_presentations: Vec, }, #[serde(rename = "credential-offer")] CredentialOffer { @@ -72,10 +75,11 @@ mod tests { redirect_uri: "https://example.com".to_string(), previously_connected: false, domain_validation: Default::default(), + linked_verifiable_presentations: Default::default(), }; assert_eq!( serde_json::to_string(&prompt).unwrap(), - r#"{"type":"accept-connection","client_name":"Test Client","logo_uri":null,"redirect_uri":"https://example.com","previously_connected":false,"domain_validation":{"status":"Unknown","message":null}}"# + r#"{"type":"accept-connection","client_name":"Test Client","logo_uri":null,"redirect_uri":"https://example.com","previously_connected":false,"domain_validation":{"status":"Unknown"},"linked_verifiable_presentations":[]}"# ); } } diff --git a/identity-wallet/src/subject.rs b/identity-wallet/src/subject.rs index 04becca2d..fcb59c59c 100644 --- a/identity-wallet/src/subject.rs +++ b/identity-wallet/src/subject.rs @@ -11,13 +11,14 @@ use identity_iota::{ use jsonwebtoken::Algorithm; use oid4vc::oid4vc_core::{authentication::sign::ExternalSign, Sign, Verify}; use std::sync::Arc; +use tokio::sync::Mutex; /// A `Subject` implements functions required for signatures and verification. /// In UniMe, it serves as the "binding link" between the protocol libraries (OID4VC) and the secret management (DID Manager). #[derive(Debug)] pub struct Subject { pub stronghold_manager: Arc, - pub secret_manager: SecretManager, + pub secret_manager: Arc>, } #[async_trait] @@ -25,7 +26,9 @@ impl Sign for Subject { async fn key_id(&self, subject_syntax_type: &str, algorithm: Algorithm) -> Option { let method: DidMethod = serde_json::from_str(&format!("{subject_syntax_type:?}")).ok()?; - self.secret_manager + let mut secret_manager = self.secret_manager.lock().await; + + secret_manager .produce_document(method, None, algorithm.into_jws_algorithm()) .await .ok() @@ -34,8 +37,9 @@ impl Sign for Subject { } async fn sign(&self, message: &str, _subject_syntax_type: &str, algorithm: Algorithm) -> anyhow::Result> { - Ok(self - .secret_manager + let secret_manager = self.secret_manager.lock().await; + + Ok(secret_manager .sign(message.as_bytes(), algorithm.into_jws_algorithm()) .await?) } @@ -49,9 +53,9 @@ impl Sign for Subject { impl oid4vc::oid4vc_core::Subject for Subject { async fn identifier(&self, subject_syntax_type: &str, algorithm: Algorithm) -> anyhow::Result { let method: DidMethod = serde_json::from_str(&format!("{subject_syntax_type:?}"))?; + let mut secret_manager = self.secret_manager.lock().await; - Ok(self - .secret_manager + Ok(secret_manager .produce_document(method, None, algorithm.into_jws_algorithm()) .await .map(|document| document.id().to_string())?) @@ -114,17 +118,16 @@ pub async fn subject(stronghold_manager: Arc, password: Strin Arc::new(Subject { stronghold_manager: stronghold_manager.clone(), - secret_manager: SecretManager::load( - client_path, - password, - Some("ed25519-0".to_owned()), - Some("es256-0".to_owned()), - Some("es256k-0".to_owned()), - None, - None, - ) - .await - .unwrap(), + secret_manager: Arc::new(Mutex::new( + SecretManager::builder() + .snapshot_path(&client_path) + .with_ed25519_key("ed25519-0") + .with_es256_key("es256-0") + .password(&password) + .build() + .await + .unwrap(), + )), }) } diff --git a/unime/src-tauri/Cargo.toml b/unime/src-tauri/Cargo.toml index d37b25e9c..14b1db718 100644 --- a/unime/src-tauri/Cargo.toml +++ b/unime/src-tauri/Cargo.toml @@ -8,7 +8,7 @@ keywords = ["identity", "did", "ssi", "wallet", "siopv2"] license = "Apache-2.0" repository = "https://github.com/impierce/identity-wallet" edition = "2021" -rust-version = "1.75.0" +rust-version.workspace = true [lib] name = "unime" diff --git a/unime/src-tauri/tauri.conf.json b/unime/src-tauri/tauri.conf.json index cd0dd41fe..f69e29d25 100644 --- a/unime/src-tauri/tauri.conf.json +++ b/unime/src-tauri/tauri.conf.json @@ -27,7 +27,7 @@ "build": { "beforeDevCommand": "pnpm dev", "beforeBuildCommand": "pnpm build", - "devUrl": "http://localhost:5173", + "devUrl": "http://localhost:4173", "frontendDist": "../build" }, "bundle": { diff --git a/unime/src-tauri/tests/common/mod.rs b/unime/src-tauri/tests/common/mod.rs index c1852cb1f..b4054eeee 100644 --- a/unime/src-tauri/tests/common/mod.rs +++ b/unime/src-tauri/tests/common/mod.rs @@ -12,6 +12,7 @@ use identity_wallet::{ state::core_utils::{IdentityManager, Managers}, stronghold::StrongholdManager, }; +use tokio::sync::Mutex; use self::assert_state_update::setup_stronghold; use serde::de::DeserializeOwned; @@ -54,17 +55,15 @@ pub async fn test_managers( let subject: Arc = Arc::new(Subject { stronghold_manager: stronghold_manager.clone(), - secret_manager: SecretManager::load( - stronghold_snapshot_path, - TEST_PASSWORD.to_string(), - Some(KEY_ID.to_string()), - None, - None, - None, - None, - ) - .await - .unwrap(), + secret_manager: Arc::new(Mutex::new( + SecretManager::builder() + .snapshot_path(&stronghold_snapshot_path) + .with_ed25519_key(KEY_ID) + .password(TEST_PASSWORD) + .build() + .await + .unwrap(), + )), }); let provider_manager = ProviderManager::new( diff --git a/unime/src-tauri/tests/fixtures/states/accept_connection.json b/unime/src-tauri/tests/fixtures/states/accept_connection.json index 7c2c4aac9..ad93e2cca 100644 --- a/unime/src-tauri/tests/fixtures/states/accept_connection.json +++ b/unime/src-tauri/tests/fixtures/states/accept_connection.json @@ -15,6 +15,7 @@ "domain_validation": { "status": "Unknown", "message": "error decoding response body: expected value at line 1 column 1" - } + }, + "linked_verifiable_presentations": [] } } diff --git a/unime/src/i18n/de-DE/index.ts b/unime/src/i18n/de-DE/index.ts index 15fb8320c..0c71e70d7 100644 --- a/unime/src/i18n/de-DE/index.ts +++ b/unime/src/i18n/de-DE/index.ts @@ -289,7 +289,7 @@ const de = { }, }, DOMAIN_LINKAGE: { - TITLE: 'Verifiziert', + TITLE: 'Verifizierte Website', SUCCESS: 'UniMe konnte die Identität erfolgreich verifizieren, um dir einen sicheren Login zu ermöglichen.', FAILURE: 'UniMe konnte die Verknüpfung der Identität mit der Domain nicht überprüfen.', UNKNOWN: 'UniMe konnte keinen Nachweis über die verbundene Identität der Domain finden.', diff --git a/unime/src/i18n/en/index.ts b/unime/src/i18n/en/index.ts index 98a0be5bc..7bed006c7 100644 --- a/unime/src/i18n/en/index.ts +++ b/unime/src/i18n/en/index.ts @@ -288,7 +288,7 @@ const en = { }, }, DOMAIN_LINKAGE: { - TITLE: 'Verified', + TITLE: 'Verified website', SUCCESS: 'UniMe successfully verified the identity to provide you with a secure login.', FAILURE: 'UniMe could not verify the linkage of the identity to the domain.', UNKNOWN: "UniMe could not find any proof of the domain's associated identity.", diff --git a/unime/src/i18n/nl-NL/index.ts b/unime/src/i18n/nl-NL/index.ts index 43303bc8d..9729220f3 100644 --- a/unime/src/i18n/nl-NL/index.ts +++ b/unime/src/i18n/nl-NL/index.ts @@ -288,7 +288,7 @@ const nl = { }, }, DOMAIN_LINKAGE: { - TITLE: 'Geverifieerd', + TITLE: 'Geverifieerde website', SUCCESS: 'UniMe heeft de identiteit met succes geverifieerd om u een veilige login te geven.', FAILURE: 'UniMe kon de koppeling van de identiteit aan het domein niet verifiëren.', UNKNOWN: 'UniMe kon geen bewijs vinden van de bijbehorende identiteit van het domein.', diff --git a/unime/src/lib/components/StatusIndicator.svelte b/unime/src/lib/components/StatusIndicator.svelte new file mode 100644 index 000000000..40a418294 --- /dev/null +++ b/unime/src/lib/components/StatusIndicator.svelte @@ -0,0 +1,60 @@ + + + + +
+
+

+ {title} +

+ {#if description} +

{description}

+ {/if} +
+ + {#if logoUrl} + + {/if} + + {#if status === 'Success'} + + {:else if status === 'Failure'} + + {:else} + + {/if} +
+ + +{#if $$slots.popover && $open} +
+
+ +
+{/if} diff --git a/unime/src/lib/components/index.ts b/unime/src/lib/components/index.ts index 11b5048d6..439e024b3 100644 --- a/unime/src/lib/components/index.ts +++ b/unime/src/lib/components/index.ts @@ -14,4 +14,5 @@ export { default as TopNavBar } from './navigation/TopNavBar.svelte'; export { default as PaddedIcon } from './PaddedIcon.svelte'; export { default as ProgressBar } from './ProgressBar.svelte'; export { default as SettingsEntry } from './SettingsEntry.svelte'; +export { default as StatusIndicator } from './StatusIndicator.svelte'; export { default as Switch } from './Switch.svelte'; diff --git a/unime/src/routes/prompt/accept-connection/+page.svelte b/unime/src/routes/prompt/accept-connection/+page.svelte index dd5b5eedb..b403ca041 100644 --- a/unime/src/routes/prompt/accept-connection/+page.svelte +++ b/unime/src/routes/prompt/accept-connection/+page.svelte @@ -3,27 +3,16 @@ import { goto } from '$app/navigation'; import LL from '$i18n/i18n-svelte'; - import { fade } from 'svelte/transition'; import type { CurrentUserPrompt } from '@bindings/user_prompt/CurrentUserPrompt'; - import { createPopover, melt } from '@melt-ui/svelte'; - import { Button, Image, PaddedIcon, TopNavBar } from '$lib/components'; + import { Button, Image, PaddedIcon, StatusIndicator, TopNavBar } from '$lib/components'; import { dispatch } from '$lib/dispatcher'; - import { - CheckBoldIcon, - PlugsConnectedFillIcon, - QuestionMarkBoldIcon, - WarningCircleFillIcon, - XRegularIcon, - } from '$lib/icons'; + import { PlugsConnectedFillIcon, WarningCircleFillIcon } from '$lib/icons'; import { error, state } from '$lib/stores'; - import { hash } from '$lib/utils'; + import { formatDate, hash } from '$lib/utils'; - const { - elements: { trigger, content, arrow }, - states: { open }, - } = createPopover(); + const profile_settings = $state.profile_settings; let loading = false; @@ -32,8 +21,14 @@ type IsAcceptConnectionPrompt = T extends { type: 'accept-connection' } ? T : never; type AcceptConnectionPrompt = IsAcceptConnectionPrompt; - const { client_name, domain_validation, logo_uri, previously_connected, redirect_uri } = - $state.current_user_prompt as AcceptConnectionPrompt; + const { + client_name, + domain_validation, + logo_uri, + previously_connected, + redirect_uri, + linked_verifiable_presentations, + } = $state.current_user_prompt as AcceptConnectionPrompt; $: ({ hostname } = new URL(redirect_uri)); $: imageId = logo_uri ? hash(logo_uri) : '_'; @@ -92,63 +87,51 @@
{/if} + -
-

- {$LL.SCAN.CONNECTION_REQUEST.CONNECTED_PREVIOUSLY()} -

- {#if previously_connected} - - {:else} - - {/if} -
+ + -
-
-

{$LL.DOMAIN_LINKAGE.TITLE()}

- {#if $open} -
-
-
- {#if domain_validation.status === 'Success'} - -

{$LL.DOMAIN_LINKAGE.SUCCESS()}

- {:else if domain_validation.status === 'Failure'} -

{$LL.DOMAIN_LINKAGE.FAILURE()}

- -

{$LL.DOMAIN_LINKAGE.CAUTION()}

- {:else} -

{$LL.DOMAIN_LINKAGE.UNKNOWN()}

- -

{$LL.DOMAIN_LINKAGE.CAUTION()}

- {/if} - - {#if $state.dev_mode !== 'Off' && domain_validation.message} - -

{domain_validation.message}

- {/if} -
-
+ +
+ {#if domain_validation.status === 'Success'} + +

{$LL.DOMAIN_LINKAGE.SUCCESS()}

+ {:else if domain_validation.status === 'Failure'} +

{$LL.DOMAIN_LINKAGE.FAILURE()}

+ +

{$LL.DOMAIN_LINKAGE.CAUTION()}

+ {:else} +

{$LL.DOMAIN_LINKAGE.UNKNOWN()}

+ +

{$LL.DOMAIN_LINKAGE.CAUTION()}

+ {/if} + + {#if $state.dev_mode !== 'Off' && domain_validation.message} + +

{domain_validation.message}

{/if}
- {#if domain_validation.status === 'Success'} - - {:else if domain_validation.status === 'Failure'} - - {:else} - +
+ + + {#each linked_verifiable_presentations as presentation} + {#if presentation.name} + {@const issuanceDate = + presentation.issuance_date && profile_settings.locale + ? formatDate(presentation.issuance_date, profile_settings.locale) + : undefined} + {/if} -
+ {/each}
diff --git a/unime/vite.config.ts b/unime/vite.config.ts index 2230c5af8..53e01e49d 100644 --- a/unime/vite.config.ts +++ b/unime/vite.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ clearScreen: false, server: { host: '0.0.0.0', - port: 5173, + port: 4173, strictPort: true, hmr: { protocol: 'ws',