diff --git a/docs/adr/0001-did-based-client-identification.md b/docs/adr/0001-did-based-client-identification.md new file mode 100644 index 000000000..80264abe6 --- /dev/null +++ b/docs/adr/0001-did-based-client-identification.md @@ -0,0 +1,37 @@ +# ADR 0001: DID-Based Client Identification and OID4VCI Issuer DID Discovery + +## Status + +Accepted + +## Context + +UniMe needs a verifiable identifier for every party shown on the connection acceptance screen, so it can validate domain linkage, discover linked verifiable presentations, and store connections against a stable identity. + +SIOPv2 and OID4VP authorization requests already carry a client identifier that can be parsed as a DID after removing any OpenID4VP client identifier prefix. + +OID4VCI credential offers do not. They only provide a credential issuer URL, so the wallet has no issuer DID before the user is asked to accept the connection. + +## Decision + +All connections are identified by a DID. A client identifier that cannot be parsed as a DID is rejected. + +For OID4VCI, the issuer DID is discovered by fetching: + +```text +{credential_issuer_url}/.well-known/did.json +``` + +The DID document's `id` becomes the issuer DID in `ClientMetadata`. This makes `/.well-known/did.json` mandatory for every OID4VCI credential issuer accepted by UniMe. + +We accept reduced OID4VCI interoperability for now in exchange for a clear, verifiable trust model. DID-based identification is the industry direction, and `did:web` is currently the most generic practical method for business wallets. `did:jwk` and `did:key` support neither service endpoints nor key rotation, which makes them suitable only for identity wallets rather than issuers and verifiers. + +## Consequences + +OID4VCI issuers that do not publish `/.well-known/did.json` cannot be accepted as connections. + +In practice this will limit OID4VCI issuers to `did:web`, since this endpoint is defined only in the `did:web` specification and probably doesnt combine with other dids suitable for Issuers and Verifiers. However, support for additional DID methods can be added later. + +The connection model uses DIDs consistently across SIOPv2, OID4VP, and OID4VCI. + +Domain linkage and linked verifiable presentation validation can run before the user accepts an OID4VCI issuer. diff --git a/docs/adr/0002-logging-sensitive-information.md b/docs/adr/0002-logging-sensitive-information.md new file mode 100644 index 000000000..75e0a7a5e --- /dev/null +++ b/docs/adr/0002-logging-sensitive-information.md @@ -0,0 +1,54 @@ +# ADR 0002: Logging Sensitive Information + +## Status + +Accepted + +## Context + +UniMe logs protocol payloads and state transitions for development and diagnostics. Every dispatched action is logged at INFO in `identity-wallet/src/command.rs`, and the `identity_wallet` and `oid4vc*` crates are set to DEBUG in `unime/src-tauri/src/lib.rs`. These logs contain credential contents, authorization request details, and other wallet data. + +The configured targets are `Stdout` and `Webview`. There is no file target, so UniMe itself never writes a log file into the app container. That removes app-private log files from device backups and file-level extraction, but it does not mean the logs are ephemeral: on mobile, `tauri-plugin-log` does not write to real stdout, it hands records to the platform logging system, and the OS retains them. + +On Android, `TargetKind::Stdout` maps to `android_logger::log`, so records go to logcat and are retained in `logd`'s in-memory ring buffers. Those buffers are exactly what a bug report exports. A bug report can be produced on-device through Developer options, the Quick Settings tile, or the power-menu shortcut, and shared through the normal share sheet. It requires no root, no USB cable, and no attacker-side access; on fully managed devices an MDM can request one remotely. `adb` itself is also not USB-bound, since Android 11 supports wireless debugging. Logcat applies no privacy redaction. + +On iOS the same records go through `os_log`. Two details of Tauri's Swift `Logger` matter. Its `enabled` flag is `true` only under `#if DEBUG` and otherwise defaults to `false`, and no code in `tauri` or `tauri-plugin-log` sets it, so release builds currently emit nothing. When logging is enabled, INFO and DEBUG map to `OSLogType.info` and `OSLogType.debug`, which are memory-backed and normally absent from the on-disk store that `sysdiagnose` collects. However, messages are emitted as `%{public}@`, so anything that is captured is captured unredacted. + +Exposure is therefore platform-asymmetric, and the Android side dominates the threat model. The iOS behaviour is a side effect of a third-party dependency's defaults rather than a property UniMe controls. + +Credentials are encrypted at rest by Stronghold. Values written to logs in plaintext bypass that protection, so logs can disclose data that device access alone would not yield. + +Shipped release artifacts are Android `.aab` and iOS `.ipa` only (`scripts/copy-release-artifacts.sh`). Desktop code paths exist for development. + +## Decision + +Credential contents and protocol payloads may be logged. + +Secrets that grant access must never be logged and are redacted with a manual `Debug` implementation, the pattern already used for `CheckPassword`, `UnlockStorage`, and `CreateNew`. This covers at least: + +- profile and Stronghold passwords +- transaction codes (`tx_code`) +- authorization codes and pre-authorized codes +- PKCE `code_verifier` +- access and refresh tokens +- private key material + +The distinction is deliberate. Logged credential data is a disclosure risk, whereas a logged bearer secret enables active theft for as long as it remains valid. + +The following constraints are part of this decision, not incidental implementation details: + +- no file log target, so UniMe writes no log file into the app container +- no remote or network log target +- full-state dumps stay behind `cfg!(debug_assertions)` or the `LOG_STATE_UPDATES_TO_CONSOLE` environment variable + +## Consequences + +Diagnostics stay detailed enough to debug protocol flows against real issuers and verifiers. + +On Android release builds, credential contents reach logcat and can leave the device whenever a user is asked to share a bug report, which is a normal and actively encouraged support workflow. This is accepted, and it partially defeats Stronghold's at-rest protection for whatever is logged. + +iOS release builds are currently silent, but this depends on a dependency default and may change on any upgrade. It must not be treated as a guarantee. + +Existing call sites that log access-granting secrets have to be brought in line with this decision, in particular the `code` and `tx_code` fields on `CodeReceived` and `CredentialOffersSelected`, and the token request and response logging in `send_token_request.rs`. + +This decision must be revisited if a file or remote log target is introduced, if UniMe ships desktop, web, or server-side builds, if shared devices are supported, or if the iOS logging default changes. diff --git a/identity-wallet/bindings/connections/Connection.ts b/identity-wallet/bindings/connections/Connection.ts index 4553ef5ba..d724bf10f 100644 --- a/identity-wallet/bindings/connections/Connection.ts +++ b/identity-wallet/bindings/connections/Connection.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface Connection { id: string, name: string, url: string, did?: string, verified: boolean, first_interacted: string, last_interacted: string, } \ No newline at end of file +export interface Connection { id: string, name: string, url: string, did: string, verified: boolean, first_interacted: string, last_interacted: string, } \ No newline at end of file diff --git a/identity-wallet/bindings/credentials/DisplayCredential.ts b/identity-wallet/bindings/credentials/DisplayCredential.ts index a2f16ffc0..7a48b8095 100644 --- a/identity-wallet/bindings/credentials/DisplayCredential.ts +++ b/identity-wallet/bindings/credentials/DisplayCredential.ts @@ -3,4 +3,4 @@ import type { CredentialMetadata } from "./CredentialMetadata"; import type { CredentialStatus } from "./CredentialStatus"; import type { DisplayClaim } from "./DisplayClaim"; -export interface DisplayCredential { id: string, format: { format: string }, issuer_name: string, data: any, display_claims: Array, metadata: CredentialMetadata, connection_id?: string, display_name: string, credential_status?: CredentialStatus, public_link?: string, } \ No newline at end of file +export interface DisplayCredential { id: string, format: { format: string }, issuer_name: string, issuer_logo_uri: string | null, data: any, display_claims: Array, metadata: CredentialMetadata, connection_id?: string, display_name: string, credential_status?: CredentialStatus, public_link?: string, } \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/ClientMetadata.ts b/identity-wallet/bindings/user_prompt/ClientMetadata.ts new file mode 100644 index 000000000..e65f1ab4b --- /dev/null +++ b/identity-wallet/bindings/user_prompt/ClientMetadata.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 ClientMetadata { client_name: string, logo_uri: string | null, connection_url: string, redirect_uri: string | null, client_id: string, } \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/ConnectionData.ts b/identity-wallet/bindings/user_prompt/ConnectionData.ts new file mode 100644 index 000000000..c9056bd48 --- /dev/null +++ b/identity-wallet/bindings/user_prompt/ConnectionData.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HistoryEvent } from "../history/HistoryEvent"; + +export interface ConnectionData { first_interacted_at: string, last_interacted_at: string, interactions: Array, } \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts b/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts index 487dad3df..d5b9c2a73 100644 --- a/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts +++ b/identity-wallet/bindings/user_prompt/CurrentUserPrompt.ts @@ -1,5 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientMetadata } from "./ClientMetadata"; +import type { ConnectionData } from "./ConnectionData"; +import type { EcosystemProfile } from "./EcosystemProfile"; 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, linked_verifiable_presentations: Array, } | { "type": "credential-offer", issuer_name: string, logo_uri?: string, credential_configurations: Record, tx_code?: { input_mode?: 'numeric' | 'text'; length?: number }, } | { "type": "share-credentials", client_name: string, logo_uri?: string, options: Array, is_interactive: boolean, }; \ No newline at end of file +export type CurrentUserPrompt = { "type": "redirect", target: string, } | { "type": "password-required" } | { "type": "accept-connection", client_metadata: ClientMetadata, connection_data?: ConnectionData, domain_validation: ValidationResult, linked_verifiable_presentations?: Array, ecosystems?: Array, } | { "type": "credential-offer", issuer_name: string, logo_uri?: string, credential_configurations: Record, tx_code?: { input_mode?: 'numeric' | 'text'; length?: number }, } | { "type": "share-credentials", client_name: string, logo_uri?: string, options: Array, is_interactive: boolean, }; \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/EcosystemProfile.ts b/identity-wallet/bindings/user_prompt/EcosystemProfile.ts new file mode 100644 index 000000000..151e11825 --- /dev/null +++ b/identity-wallet/bindings/user_prompt/EcosystemProfile.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Member } from "./Member"; + +export interface EcosystemProfile { logo_uri: string | null, name: string, description: string | null, ecosystem_leader: Member, member_count: number, members: 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 index 9203f2df2..492b3e1b5 100644 --- a/identity-wallet/bindings/user_prompt/LinkedVerifiableCredentialData.ts +++ b/identity-wallet/bindings/user_prompt/LinkedVerifiableCredentialData.ts @@ -1,3 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DisplayCredential } from "../credentials/DisplayCredential"; +import type { ValidationResult } from "./ValidationResult"; -export interface LinkedVerifiableCredentialData { name: string | null, logo_uri: string | null, issuance_date: string, } \ No newline at end of file +export interface LinkedVerifiableCredentialData { credential: DisplayCredential, issuer_domain_validations: Array, } \ No newline at end of file diff --git a/identity-wallet/bindings/user_prompt/Member.ts b/identity-wallet/bindings/user_prompt/Member.ts new file mode 100644 index 000000000..4838ab22b --- /dev/null +++ b/identity-wallet/bindings/user_prompt/Member.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 Member { logo_uri: string | null, name: string, description: string | null, domain: 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 9a5206b0d..82c1d39b8 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, name?: string, logo_uri?: string, issuance_date?: string, message?: string, } \ No newline at end of file +export interface ValidationResult { status: ValidationStatus, url: string, name?: string, logo_uri?: string, issuance_date?: string, message?: string, } \ No newline at end of file diff --git a/identity-wallet/src/state/connections/actions/connection_accepted.rs b/identity-wallet/src/state/connections/actions/connection_accepted.rs index b885d104d..24e9bb30f 100644 --- a/identity-wallet/src/state/connections/actions/connection_accepted.rs +++ b/identity-wallet/src/state/connections/actions/connection_accepted.rs @@ -3,7 +3,9 @@ use crate::{ state::{ actions::ActionTrait, connections::reducers::handle_siopv2_authorization_request::handle_siopv2_authorization_request, - profile_settings::reducers::update_sorting_preference::sort_connections, Reducer, + profile_settings::reducers::update_sorting_preference::sort_connections, + qr_code::reducers::read_authorization_request::read_oid4vp_authorization_request, + qr_code::reducers::read_credential_offer::read_credential_offer, Reducer, }, }; @@ -13,11 +15,14 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectionAccepted; +// The first 3 reducers are executed in an OR/OR/OR manner, matching against the active flow, which is set after the QrCodeScanned action. #[typetag::serde(name = "[Authenticate] Connection accepted")] impl ActionTrait for ConnectionAccepted { fn reducers<'a>(&self) -> Vec> { vec![ reducer!(handle_siopv2_authorization_request), + reducer!(read_oid4vp_authorization_request), + reducer!(read_credential_offer), reducer!(sort_connections), ] } diff --git a/identity-wallet/src/state/connections/mod.rs b/identity-wallet/src/state/connections/mod.rs index 61db0c259..152b788c3 100644 --- a/identity-wallet/src/state/connections/mod.rs +++ b/identity-wallet/src/state/connections/mod.rs @@ -3,7 +3,7 @@ pub mod reducers; use super::{core_utils::DateUtils, FeatTrait}; -use identity_iota::did::CoreDID; +use identity_iota::did::{CoreDID, DID}; use log::info; use serde::{Deserialize, Serialize}; use std::ops::Not; @@ -18,16 +18,14 @@ impl Connections { Self(Vec::new()) } - pub fn contains(&self, url: &str, name: &str) -> bool { - self.0 - .iter() - .any(|connection| connection.url == url && connection.name == name) + pub fn contains(&self, did: &str) -> bool { + self.0.iter().any(|connection| connection.did == did) } /// Inserts a new connection into the list of connections. /// Modelled after the `std::collections::HashMap::insert` method. fn insert(&mut self, connection: Connection) -> Option<&Connection> { - self.contains(&connection.url, &connection.name) + self.contains(&connection.did) .not() .then(|| { self.0.push(connection); @@ -38,31 +36,23 @@ impl Connections { /// Returns a mutable reference to the connection with the given `url` and `name`. /// Modelled after the `std::collections::HashMap::get_mut` method. - fn get_mut(&mut self, url: &str, name: &str) -> Option<&mut Connection> { - self.0 - .iter_mut() - .find(|connection| connection.url == url && connection.name == name) + fn get_mut(&mut self, did: &str) -> Option<&mut Connection> { + self.0.iter_mut().find(|connection| connection.did == did) } /// Inserts a new connection into the list of connections if it does not already exist. If it does exist, updates /// the last interaction time and returns a reference to the connection. - pub fn update_or_insert(&mut self, url: &str, name: &str, did: Option) -> &Connection { - if self.contains(url, name) { - info!("Updating existing connection: {name} {url}"); - self.get_mut(url, name).map(|connection| { - if let Some(core_did) = did { - connection.did = Some(core_did.to_string()); - } + pub fn update_or_insert(&mut self, url: &str, name: &str, did: CoreDID) -> &Connection { + if self.contains(did.as_str()) { + info!("Updating existing connection: {name}, {url}, {did}"); + self.get_mut(did.as_str()).map(|connection| { + // TODO: what to do here when any information besides the DID has changed? connection.update_last_interaction_time(); &*connection }) } else { - info!("Inserting new connection: {name} {url}"); - self.insert(Connection::new( - name.to_string(), - url.to_string(), - did.map(|d| d.to_string()), - )) + info!("Inserting new connection: {name}, {url}, {did}"); + self.insert(Connection::new(name.to_string(), url.to_string(), did.to_string())) } .expect("Failed to update or insert connection") } @@ -84,15 +74,14 @@ pub struct Connection { pub id: String, pub name: String, pub url: String, - #[ts(optional)] - pub did: Option, + pub did: String, pub verified: bool, pub first_interacted: String, pub last_interacted: String, } impl Connection { - pub fn new(name: String, url: String, did: Option) -> Self { + pub fn new(name: String, url: String, did: String) -> Self { // TODO(ngdil): Temporary solution to support NGDIL demo, replace with different unique identifier to distinguish connection let id = sha256::digest([name.as_bytes(), url.as_bytes()].concat()).to_string(); let current_datetime = DateUtils::new_date_string(); @@ -122,6 +111,10 @@ impl PartialEq for Connection { #[cfg(test)] mod tests { + use std::str::FromStr; + + use identity_iota::did::DID; + use super::*; #[test] @@ -129,14 +122,15 @@ mod tests { let mut connections = Connections::new(); let url = "https://example.com"; let name = "Example"; - let connection = connections.update_or_insert(url, name, None); + let did = CoreDID::from_str("did:example:123").unwrap(); + let connection = connections.update_or_insert(url, name, did.clone()); assert_eq!(connection.url, url); assert_eq!(connection.name, name); assert_eq!(connection.first_interacted, connection.last_interacted); assert_eq!(connections.0.len(), 1); - assert!(connections.contains(url, name)); + assert!(connections.contains(did.as_str())); - let connection = connections.update_or_insert(url, name, None); + let connection = connections.update_or_insert(url, name, did.clone()); assert_eq!(connection.url, url); assert_eq!(connection.name, name); // The last interaction time should have been updated. @@ -145,46 +139,27 @@ mod tests { } #[test] - fn test_update_or_insert_with_duplicate_names() { - let mut connections = Connections::new(); - let url = "https://example.com"; - let name = "Example"; - let connection = connections.update_or_insert(url, name, None); - assert_eq!(connection.url, url); - assert_eq!(connection.name, name); - assert_eq!(connection.first_interacted, connection.last_interacted); - assert_eq!(connections.0.len(), 1); - assert!(connections.contains(url, name)); - - // A different server with the same name is treated as a different connection. - let url = "https://example2.com"; - let connection = connections.update_or_insert(url, name, None); - assert_eq!(connection.url, url); - assert_eq!(connection.name, name); - assert_eq!(connection.first_interacted, connection.last_interacted); - assert_eq!(connections.0.len(), 2); - assert!(connections.contains(url, name)); - } - - #[test] - fn test_update_or_insert_with_duplicate_urls() { + fn test_update_or_insert_distinguishes_connections_by_did() { let mut connections = Connections::new(); + let did = CoreDID::from_str("did:example:123").unwrap(); let url = "https://example.com"; let name = "Example"; - let connection = connections.update_or_insert(url, name, None); + let connection = connections.update_or_insert(url, name, did.clone()); assert_eq!(connection.url, url); assert_eq!(connection.name, name); assert_eq!(connection.first_interacted, connection.last_interacted); assert_eq!(connections.0.len(), 1); - assert!(connections.contains(url, name)); + assert!(connections.contains(did.as_str())); - // The same server is used with a different name. - let name = "Example2"; - let connection = connections.update_or_insert(url, name, None); - assert_eq!(connection.url, url); + // A different DID is a different connection, even when the display name is identical. + let other_did = CoreDID::from_str("did:example:456").unwrap(); + let other_url = "https://example2.com"; + let connection = connections.update_or_insert(other_url, name, other_did.clone()); + assert_eq!(connection.url, other_url); assert_eq!(connection.name, name); assert_eq!(connection.first_interacted, connection.last_interacted); assert_eq!(connections.0.len(), 2); - assert!(connections.contains(url, name)); + assert!(connections.contains(did.as_str())); + assert!(connections.contains(other_did.as_str())); } } diff --git a/identity-wallet/src/state/connections/reducers/handle_siopv2_authorization_request.rs b/identity-wallet/src/state/connections/reducers/handle_siopv2_authorization_request.rs index 0aeed580d..c36193800 100644 --- a/identity-wallet/src/state/connections/reducers/handle_siopv2_authorization_request.rs +++ b/identity-wallet/src/state/connections/reducers/handle_siopv2_authorization_request.rs @@ -12,17 +12,26 @@ use crate::{ }, }; -use identity_iota::did::CoreDID; -use log::{debug, info, warn}; -use oid4vc::oid4vc_core::{ - authorization_request::{AuthorizationRequest, Object}, - client_metadata::ClientMetadataResource, -}; -use oid4vc::siopv2::siopv2::SIOPv2; +use log::{debug, info}; -// Sends the authorization response. +/// Handles the `ConnectionAccepted` action for the SIOPv2 active flow, triggered by accepting `AcceptConnection` prompt and persists the connection. +/// Sends the SIOPv2 authorization response. #[tracing::instrument(skip_all, err)] pub async fn handle_siopv2_authorization_request(state: AppState, _action: Action) -> Result { + let siopv2_authorization_request = match state.core_utils.active_flow.clone() { + Some(ActiveFlow::Siopv2 { authorization_request }) => authorization_request, + // Not a SIOPv2 flow, let other reducers handle this action. + _ => return Ok(state), + }; + + let client_metadata = match &state.current_user_prompt { + Some(CurrentUserPrompt::AcceptConnection { client_metadata, .. }) => client_metadata.clone(), + _ => return Err(Error( + "Unexpected state: No CurrentUserPrompt::AcceptConnection found when reading SIOPv2 authorization request" + .to_string(), + )), + }; + let state_guard = state.core_utils.managers.lock().await; let provider_manager = &state_guard @@ -31,15 +40,7 @@ pub async fn handle_siopv2_authorization_request(state: AppState, _action: Actio .ok_or(MissingManagerError("identity"))? .provider_manager; - let siopv2_authorization_request = match state.core_utils.active_flow.clone() { - Some(ActiveFlow::Siopv2 { authorization_request }) => authorization_request, - _ => return Err(AppError::Error("Expected SIOPv2 authorization request".to_string())), - }; - - info!( - "Generating SIOPv2 authorization response for client: {}", - siopv2_authorization_request.body.client_id - ); + info!("generating response"); let response = provider_manager .generate_response(&*siopv2_authorization_request, Default::default()) @@ -49,28 +50,21 @@ pub async fn handle_siopv2_authorization_request(state: AppState, _action: Actio #[cfg(not(feature = "test_utils"))] if provider_manager.send_response(&response).await.is_err() { - warn!("Failed to send SIOPv2 authorization response to redirect_uri"); + log::warn!("Failed to send SIOPv2 authorization response to redirect_uri"); return Err(SendAuthorizationResponseError); } info!("SIOPv2 response successfully sent"); - let (client_name, logo_uri, connection_url, client_id) = - get_siopv2_client_name_and_logo_uri(&siopv2_authorization_request); - - if logo_uri.is_some() { - warn!("Skipping download of client logo as it should have already been downloaded in `read_authorization_request()` and be present in /assets/tmp folder"); - } - - let did = CoreDID::parse(client_id).ok(); - let mut connections = state.connections; - let connection = connections.update_or_insert(&connection_url, &client_name, did); + let connection = connections.update_or_insert( + &client_metadata.connection_url, + &client_metadata.client_name, + client_metadata.client_id, + ); - let file_name = match logo_uri { - Some(logo_uri) => hash(logo_uri.as_str()), - None => "_".to_string(), - }; - persist_asset(&file_name, &connection.id).ok(); + if let Some(logo_uri) = client_metadata.logo_uri { + persist_asset(&hash(logo_uri.as_str()), &connection.id).ok(); + } // History let mut history = state.history; @@ -92,33 +86,3 @@ pub async fn handle_siopv2_authorization_request(state: AppState, _action: Actio ..state }) } - -// Helper - -// TODO: move this functionality to the oid4vc-manager crate. -/// Returns (client_name, logo_uri, connection_url, client_id) -pub fn get_siopv2_client_name_and_logo_uri( - siopv2_authorization_request: &AuthorizationRequest>, -) -> (String, Option, String, String) { - // Get the connection url from the redirect url host (or use the redirect url if it does not - // contain a host). - let redirect_uri = siopv2_authorization_request.body.uri.uri().clone(); - let connection_url = redirect_uri.host_str().unwrap_or(redirect_uri.as_str()); - - let client_id = siopv2_authorization_request.body.client_id.clone(); - - // Get the client_name and logo_uri from the client_metadata if it exists. - match &siopv2_authorization_request.body.extension.client_metadata { - ClientMetadataResource::ClientMetadata { - client_name, logo_uri, .. - } => { - let client_name = client_name.as_ref().cloned().unwrap_or(connection_url.to_string()); - let logo_uri = logo_uri.as_ref().map(|logo_uri| logo_uri.to_string()); - Some((client_name, logo_uri, connection_url.to_string(), client_id.clone())) - } - // TODO: support `client_metadata_uri` - ClientMetadataResource::ClientMetadataUri(_) => None, - } - // Otherwise use the connection_url as the client_name. - .unwrap_or((connection_url.to_string(), None, connection_url.to_string(), client_id)) -} diff --git a/identity-wallet/src/state/core_utils/helpers.rs b/identity-wallet/src/state/core_utils/helpers.rs index 1eb78f6ad..3345903ee 100644 --- a/identity-wallet/src/state/core_utils/helpers.rs +++ b/identity-wallet/src/state/core_utils/helpers.rs @@ -12,6 +12,18 @@ use oid4vc::oid4vc_core::Verify; use serde_json::Value; use std::fs::File; +/// Authorization requests and redirect_uris can reach UniMe as complex URL's with many query parameters or paths. +/// This function normalizes a party's URL to its origin, the single format used to store and display connection URLs. +/// Opaque origins serialize to `"null"`, in which case the full URL is kept. +pub fn normalize_connection_url(url: &url::Url) -> String { + let origin = url.origin().ascii_serialization(); + if origin == "null" { + url.to_string() + } else { + origin + } +} + /// Downloads the logo from the given logo URI and stores it in the assets folder, returns None if it errors. pub async fn download_logo(logo_uri_str: &str) -> Option { match logo_uri_str.parse() { @@ -231,6 +243,7 @@ impl CredentialType { Ok(()) } _ => { + // TODO: make use of `app_handle.path().data_dir()` to make this work on mobile. let json_schema_path = format!("resources/jsonschemas/{version}.json"); validate_credential_against_schema(json_schema_path, data)?; diff --git a/identity-wallet/src/state/credentials/actions/credential_offers_selected.rs b/identity-wallet/src/state/credentials/actions/credential_offers_selected.rs index 6583ebb62..992e1a694 100644 --- a/identity-wallet/src/state/credentials/actions/credential_offers_selected.rs +++ b/identity-wallet/src/state/credentials/actions/credential_offers_selected.rs @@ -1,5 +1,5 @@ use crate::reducer; -use crate::state::credentials::reducers::send_credential_request::send_credential_request; +use crate::state::credentials::reducers::send_credential_request::handle_credential_offer; use crate::state::profile_settings::reducers::update_sorting_preference::{sort_connections, sort_credentials}; use crate::state::{actions::ActionTrait, Reducer}; @@ -19,9 +19,9 @@ pub struct CredentialOffersSelected { impl ActionTrait for CredentialOffersSelected { fn reducers<'a>(&self) -> Vec> { vec![ - reducer!(send_credential_request), + reducer!(handle_credential_offer), reducer!(sort_credentials), - reducer!(sort_connections), + reducer!(sort_connections), // TODO: remove this sort_connections, only after trust_connection ] } } diff --git a/identity-wallet/src/state/credentials/mod.rs b/identity-wallet/src/state/credentials/mod.rs index 793d72c81..3659ae1c4 100644 --- a/identity-wallet/src/state/credentials/mod.rs +++ b/identity-wallet/src/state/credentials/mod.rs @@ -46,6 +46,8 @@ pub struct DisplayCredential { #[ts(type = "{ format: string }")] pub format: CredentialFormats, pub issuer_name: String, + #[serde(default)] + pub issuer_logo_uri: Option, // TODO: Remove this field once we fully implemented `display_claims` for all credential formats. #[ts(type = "any")] pub data: serde_json::Value, @@ -200,14 +202,12 @@ impl VerifiableCredentialRecord { (id, data, issuance_date, expiration_date, display_claims) } CredentialFormats::JwtVcJson(()) => { - let credential_display = get_unverified_jwt_claims(&verifiable_credential) - .map_err(|e| AppError::Error(e.to_string()))? - .get("vc") - .cloned() - .ok_or(AppError::Error( - "Failed to create a VerifiableCredentialRecord: 'vc' claim is missing in the JWT VC" - .to_string(), - ))?; + let claims = get_unverified_jwt_claims(&verifiable_credential) + .map_err(|e| AppError::Error(e.to_string()))?; + let credential_display = claims.get("vc").cloned().ok_or(AppError::Error( + "Failed to create a VerifiableCredentialRecord: 'vc' claim is missing in the JWT VC" + .to_string(), + ))?; // TODO: do not use a hash to generate the credential ID. Currently we still do this so that our tests in `unime/src-tauri/tests` don't break. let hash = { sha256::digest(json!(credential_display).to_string()) }; @@ -217,6 +217,15 @@ impl VerifiableCredentialRecord { .get("issuanceDate") .or_else(|| credential_display.get("validFrom")) .and_then(|value| value.as_str().map(ToString::to_string)) + .or_else(|| { + claims.get("nbf").or_else(|| claims.get("iat")).and_then(|v| { + if let Some(secs) = v.as_i64() { + chrono::DateTime::from_timestamp(secs, 0).map(|dt| dt.to_rfc3339()) + } else { + v.as_str().map(|s| s.to_string()) + } + }) + }) .ok_or(AppError::Error( "Failed to create a VerifiableCredentialRecord: 'issuanceDate' or 'validFrom' is missing" .to_string(), @@ -224,7 +233,16 @@ impl VerifiableCredentialRecord { let expiration_date = credential_display .get("expirationDate") .or_else(|| credential_display.get("validUntil")) - .and_then(|valid_until| valid_until.as_str().map(ToString::to_string)); // TODO: import this from UniCore + .and_then(|valid_until| valid_until.as_str().map(ToString::to_string)) + .or_else(|| { + claims.get("exp").and_then(|v| { + if let Some(secs) = v.as_i64() { + chrono::DateTime::from_timestamp(secs, 0).map(|dt| dt.to_rfc3339()) + } else { + v.as_str().map(|s| s.to_string()) + } + }) + }); // TODO: Use the claims to rename the keys in the Credential according to the display hints provided by // the Issuer. Before we do this we need to make sure that UniCore supports Claims Description for @@ -258,6 +276,7 @@ impl VerifiableCredentialRecord { }, // The other fields will be filled in at a later stage. issuer_name: String::new(), + issuer_logo_uri: None, connection_id: None, display_name: String::new(), // The credential status is None here but it will be set right after this function. diff --git a/identity-wallet/src/state/credentials/reducers/handle_oid4vp_authorization_request.rs b/identity-wallet/src/state/credentials/reducers/handle_oid4vp_authorization_request.rs index 8a63a16f0..cae85609f 100644 --- a/identity-wallet/src/state/credentials/reducers/handle_oid4vp_authorization_request.rs +++ b/identity-wallet/src/state/credentials/reducers/handle_oid4vp_authorization_request.rs @@ -2,6 +2,8 @@ use crate::state::connections::Connections; use crate::state::core_utils::IdentityManager; use crate::state::credentials::reducers::self_issue_credential::SubjectWrapper; use crate::state::credentials::Sha256Hasher; +use crate::state::qr_code::reducers::accept_connection::get_oid4vp_client_metadata; +use crate::state::user_prompt::ClientMetadata; use crate::stronghold::StrongholdManager; use crate::subject::Subject; use crate::{ @@ -22,14 +24,11 @@ use chrono::{Duration, Utc}; use identity_core::common::Object as IotaObject; use identity_credential::sd_jwt_vc::SdJwtVc; use identity_iota::credential::{EnvelopedVc, VcDataUrl}; -use identity_iota::did::CoreDID; +use identity_iota::did::DID; use log::{debug, info, warn}; +use oid4vc::oid4vc_core::authorization_request::{AuthorizationRequest, Object}; use oid4vc::oid4vc_core::types::string_or_object::StringOrObject; use oid4vc::oid4vc_core::utils::jwt::get_unverified_jwt_claims; -use oid4vc::oid4vc_core::{ - authorization_request::{AuthorizationRequest, Object}, - client_metadata::ClientMetadataResource, -}; use oid4vc::oid4vc_core::{jwt, Sign, Subject as _}; use oid4vc::oid4vci::credential_format_profiles::CredentialFormats; use oid4vc::oid4vp::token::vp_token::Presentations; @@ -52,7 +51,8 @@ use std::str::FromStr as _; use std::sync::Arc; use uuid::Uuid; -// Sends the authorization response including the verifiable credentials. +/// Handles the non-interactive `CredentialsSelected` action, which is triggered by accepting the `ShareCredentials` prompt set by `read_oid4vp_authorization_request`. +/// Sends the authorization response including the verifiable credentials. #[tracing::instrument(skip_all, err)] pub async fn handle_oid4vp_authorization_request(state: AppState, action: Action) -> Result { if let Some(credential_uuids) = listen::(action) @@ -108,13 +108,10 @@ pub async fn handle_oid4vp_authorization_request(state: AppState, action: Action let mut connections = state.connections; let mut history = state.history; - update_history_and_connections( - &oid4vp_authorization_request, - history_credentials, - &mut connections, - &mut history, - ) - .await; + // TODO: this is kinda duplicate, we should probably refactor to pass on the ClientMetadata retrieved in fn `accept_connection` to avoid re-fetching it here, but for now this works. + let client_metadata = get_oid4vp_client_metadata(&oid4vp_authorization_request).await?; + + update_history_and_connections(history_credentials, &client_metadata, &mut connections, &mut history).await?; drop(state_guard); return Ok(AppState { @@ -130,55 +127,6 @@ pub async fn handle_oid4vp_authorization_request(state: AppState, action: Action Ok(state) } -pub struct OID4VPClientMetadata { - pub client_name: String, - pub logo_uri: Option, - pub connection_url: String, - pub client_id: String, -} - -// TODO: move this functionality to the oid4vc-manager crate. -/// Returns (client_name, logo_uri, connection_url, client_id) -pub fn get_oid4vp_client_name_and_logo_uri( - oid4vp_authorization_request: &AuthorizationRequest>, -) -> OID4VPClientMetadata { - // Get the connection url from the redirect url host (or use the redirect url if it does not - // contain a host). - let redirect_uri = oid4vp_authorization_request.body.uri.uri().clone(); - let connection_url = redirect_uri.host_str().unwrap_or(redirect_uri.as_str()); - - let client_id = oid4vp_authorization_request.body.client_id.clone(); - - // Get the client_name and logo_uri from the client_metadata if it exists. - match &oid4vp_authorization_request.body.extension.client_metadata { - ClientMetadataResource::ClientMetadata { - client_name, - logo_uri, - extension: _, - other: _, - } => { - let client_name = client_name.as_ref().cloned().unwrap_or(connection_url.to_string()); - let logo_uri = logo_uri.as_ref().map(|logo_uri| logo_uri.to_string()); - - Some(OID4VPClientMetadata { - client_name, - logo_uri, - connection_url: connection_url.to_string(), - client_id: client_id.clone(), - }) - } - // TODO: support `client_metadata_uri` - ClientMetadataResource::ClientMetadataUri(_) => None, - } - // Otherwise use the connection_url as the client_name. - .unwrap_or(OID4VPClientMetadata { - client_name: connection_url.to_string(), - logo_uri: None, - connection_url: connection_url.to_string(), - client_id, - }) -} - #[tracing::instrument(skip_all, err)] pub async fn build_oid4vp_vp_token_and_history_credentials( state: &AppState, @@ -343,28 +291,21 @@ pub async fn build_oid4vp_vp_token_and_history_credentials( #[tracing::instrument(skip_all)] pub async fn update_history_and_connections( - oid4vp_authorization_request: &AuthorizationRequest>, history_credentials: Vec, + client_metadata: &ClientMetadata, connections: &mut Connections, history: &mut Vec, -) { - let OID4VPClientMetadata { - client_name, - logo_uri, - connection_url, - client_id, - } = get_oid4vp_client_name_and_logo_uri(oid4vp_authorization_request); - - let did = CoreDID::parse(client_id).ok(); - - let previously_connected = connections.contains(connection_url.as_str(), &client_name); - let connection = connections.update_or_insert(&connection_url, &client_name, did); - - let file_name = match logo_uri { - Some(logo_uri) => hash(logo_uri.as_str()), - None => "_".to_string(), - }; - persist_asset(&file_name, &connection.id).ok(); +) -> Result<(), AppError> { + let previously_connected = connections.contains(client_metadata.client_id.as_str()); + let connection = connections.update_or_insert( + &client_metadata.connection_url, + &client_metadata.client_name, + client_metadata.client_id.clone(), + ); + + if let Some(logo_uri) = client_metadata.logo_uri.clone() { + persist_asset(&hash(logo_uri.as_str()), &connection.id).ok(); + } // History if !previously_connected { @@ -384,6 +325,8 @@ pub async fn update_history_and_connections( date: connection.last_interacted.clone(), credentials: history_credentials, }); + + Ok(()) } async fn get_vp_token( diff --git a/identity-wallet/src/state/credentials/reducers/refresh_credential_status.rs b/identity-wallet/src/state/credentials/reducers/refresh_credential_status.rs index e4b09bd0c..597f1cd06 100644 --- a/identity-wallet/src/state/credentials/reducers/refresh_credential_status.rs +++ b/identity-wallet/src/state/credentials/reducers/refresh_credential_status.rs @@ -5,12 +5,13 @@ use crate::{ http_client::get_http_client_builder, state::{ actions::{listen, Action}, - core_utils::{DateUtils, IdentityManager}, + core_utils::DateUtils, credentials::{ actions::refresh_credential_status::RefreshCredentialStatus, CredentialStatus, VerifiableCredentialRecord, }, AppState, }, + subject::Subject, }; use jsonwebtoken::{decode_header, Algorithm, DecodingKey}; use log::{info, warn}; @@ -49,16 +50,18 @@ pub async fn refresh_credential_status(state: AppState, action: Action) -> Resul } }; - let identity_manager = state_guard + let subject = state_guard .identity_manager .as_ref() - .ok_or(AppError::MissingManagerError("identity"))?; + .ok_or(AppError::MissingManagerError("identity"))? + .subject + .clone(); let debug_timestamp_before = chrono::Local::now(); let display_name = credential.display_name.clone(); - match fetch_credential_status(credential_status_data, identity_manager).await { + match fetch_credential_status(credential_status_data, &subject).await { Ok(status) => { info!("Successfully fetched credential status for credential with id: `{credential_id}`: `{status:?}` (previous status: `{:?}`)", credential_status_data.status); credential_status_data.last_checked = DateUtils::new_date_string(); @@ -155,7 +158,7 @@ pub async fn refresh_credential_status(state: AppState, action: Action) -> Resul #[tracing::instrument(skip_all, err)] pub async fn fetch_credential_status( credential_status_data: &CredentialStatus, - identity_manager: &IdentityManager, + subject: &Subject, ) -> Result { let status_list_jwt = fetch_status_list( credential_status_data.uri.as_str(), @@ -168,8 +171,7 @@ pub async fn fetch_credential_status( let key_id = extract_normalized_did_kid_from_jwt(&status_list_jwt).map_err(|_| AppError::GetCredentialStatusError)?; - let public_key = identity_manager - .subject + let public_key = subject .public_key(&key_id) .await .map_err(|_| AppError::GetCredentialStatusError)?; diff --git a/identity-wallet/src/state/credentials/reducers/send_credential_request.rs b/identity-wallet/src/state/credentials/reducers/send_credential_request.rs index 38b98ba3f..71c3a1ac5 100644 --- a/identity-wallet/src/state/credentials/reducers/send_credential_request.rs +++ b/identity-wallet/src/state/credentials/reducers/send_credential_request.rs @@ -1,11 +1,9 @@ use crate::oid4vci::authorization_request::CodeChallengeMethod; use crate::state::core_utils::helpers::download_logo; use crate::state::core_utils::{ActiveFlow, Oid4vciStage}; -use crate::state::credentials::reducers::handle_oid4vp_authorization_request::{ - get_oid4vp_client_name_and_logo_uri, OID4VPClientMetadata, -}; use crate::state::credentials::reducers::send_token_request::send_token_request; -use crate::state::user_prompt::CurrentUserPrompt; +use crate::state::qr_code::reducers::accept_connection::get_oid4vp_client_metadata; +use crate::state::user_prompt::{ClientMetadata, CurrentUserPrompt}; use crate::state::{UNIME_CLIENT_ID, UNIME_REDIRECT_URI}; use crate::{ error::AppError::{self, *}, @@ -36,10 +34,16 @@ use sd_jwt::Sha256Hasher; use tauri_plugin_opener::OpenerExt; use uuid::Uuid; -// TODO: rename this reducer to `handle_credential_offer` or similar. This should be done in an isolated PR in order to prevent -// confusing git diffs. +/// Handles the `CredentialOffersSelected` action, which is triggered by accepting the `CredentialOffer` prompt set by `read_credential_offer`. +/// Sends the credential request to the credential issuer in 3 possible flows: +/// 1. Pre-authorized code flow: this also handles the response immediately by chaining the `send_token_request` reducer in the return. +/// 2. Authorization code flow with interactive_authorization_endpoint: this requires the user to complete an interactive authorization request. This is an intermediary OID4VP flow +/// prompting the user to share the requested credentials necessary to authenticate/authorize the user to receive the credentials in the `CredentialOffer`. +/// The OID4VP flow will obtain an authorization code, which is then exchanged for the credential(s). +/// 3. Authorization code flow with pushed_authorization_request_endpoint: this sends the user to an external authorization server to complete the authorization request, +/// which should send the user back to UniMe after authenticating there with the right authorization code to retrieve the credentials. #[tracing::instrument(skip_all, err)] -pub async fn send_credential_request(state: AppState, action: Action) -> Result { +pub async fn handle_credential_offer(state: AppState, action: Action) -> Result { if let Some(selected_offer) = listen::(action.clone()) { let credential_configuration_ids = selected_offer.credential_configuration_ids; @@ -332,12 +336,10 @@ pub async fn send_credential_request(state: AppState, action: Action) -> Result< info!("Evaluated {} VCs matching interactive OID4VP request", uuids.len()); debug!("Matched VC UUIDs for interactive authorization: {uuids:?}"); - let OID4VPClientMetadata { - client_name, - logo_uri, - connection_url: _, - client_id: _, - } = get_oid4vp_client_name_and_logo_uri(&oid4vp_authorization_request); + // TODO: this is kinda duplicate, we should probably refactor to pass on the ClientMetadata retrieved in fn `accept_connection` to avoid re-fetching it here, but for now this works. + let ClientMetadata { + client_name, logo_uri, .. + } = get_oid4vp_client_metadata(&oid4vp_authorization_request).await?; info!("Interactive OID4VP client metadata: client_name={client_name:?}, logo_uri={logo_uri:?}"); diff --git a/identity-wallet/src/state/credentials/reducers/send_interactive_authorization_request_follow_up.rs b/identity-wallet/src/state/credentials/reducers/send_interactive_authorization_request_follow_up.rs index 63a7dc00b..6fa94d449 100644 --- a/identity-wallet/src/state/credentials/reducers/send_interactive_authorization_request_follow_up.rs +++ b/identity-wallet/src/state/credentials/reducers/send_interactive_authorization_request_follow_up.rs @@ -12,6 +12,7 @@ use crate::{ send_token_request::send_token_request, }, }, + qr_code::reducers::accept_connection::get_oid4vp_client_metadata, AppState, }, }; @@ -19,7 +20,8 @@ use log::{debug, info}; use oid4vc::oid4vci::InteractiveAuthorizationFollowUpRequest; use std::sync::Arc; -/// NOTE: the happy path of this reducer is directly chained to the `send_token_request` reducer via the return +/// Handles the interactive `CredentialsSelected` action, which is triggered after accepting the `ShareCredentials` prompt set by `handle_credential_offer`. +/// This reducer is directly chained to the `send_token_request` reducer via the return, retrieving the credentials. #[tracing::instrument(skip_all, err)] pub async fn send_interactive_authorization_request_follow_up( state: AppState, @@ -122,16 +124,12 @@ pub async fn send_interactive_authorization_request_follow_up( "Authorization code is missing in the response".to_string(), ))?; + // TODO: this is kinda duplicate, we should probably refactor to pass on the ClientMetadata retrieved in fn `accept_connection` to avoid re-fetching it here, but for now this works. + let client_metadata = get_oid4vp_client_metadata(&oid4vp_authorization_request).await?; let mut connections = state.connections; let mut history = state.history; - update_history_and_connections( - &oid4vp_authorization_request, - history_credentials, - &mut connections, - &mut history, - ) - .await; + update_history_and_connections(history_credentials, &client_metadata, &mut connections, &mut history).await?; drop(state_guard); let state = AppState { diff --git a/identity-wallet/src/state/credentials/reducers/send_token_request.rs b/identity-wallet/src/state/credentials/reducers/send_token_request.rs index b250f49af..2c3b03eef 100644 --- a/identity-wallet/src/state/credentials/reducers/send_token_request.rs +++ b/identity-wallet/src/state/credentials/reducers/send_token_request.rs @@ -1,12 +1,13 @@ use crate::{ error::AppError::{self, *}, + http_client::get_http_client, persistence::{hash, persist_asset}, state::{ actions::{listen, Action}, core_utils::{ - helpers::{validate_credential_types, validate_jwt_vc_json}, + helpers::{normalize_connection_url, validate_credential_types, validate_jwt_vc_json}, history_event::{EventType, HistoryCredential, HistoryEvent}, - ActiveFlow, CoreUtils, DateUtils, IdentityManager, Oid4vciStage, + ActiveFlow, CoreUtils, DateUtils, Oid4vciStage, }, credentials::{ actions::authorization_code_received::CodeReceived, @@ -16,7 +17,9 @@ use crate::{ user_prompt::CurrentUserPrompt, AppState, UNIME_CLIENT_ID, UNIME_REDIRECT_URI, }, + subject::Subject, }; +use identity_iota::did::{CoreDID, DID}; use log::{debug, info, warn}; use oauth_tsl::{status_list::StatusType, tokens::referenced_token::StatusClaim}; use oid4vc::{ @@ -27,7 +30,7 @@ use oid4vc::{ credential_response::CredentialResponseType, token_request::TokenRequest, }, }; -use serde_json::json; +use serde_json::{json, Value}; use std::collections::HashMap; use uuid::Uuid; @@ -199,24 +202,19 @@ pub async fn send_token_request(state: AppState, action: Action) -> Result Result() + .await?; + + let did_str = did_doc + .get("id") + .and_then(|id| id.as_str()) + .ok_or(AppError::DidParseError)? + .to_string(); + + let did = CoreDID::parse(did_str).map_err(|e| AppError::Error(format!("Failed to parse DID: {e}")))?; + // Create or update the connection. - let previously_connected = state.connections.contains(connection_url, &issuer_name); + let previously_connected = state.connections.contains(did.as_str()); let mut connections = state.connections; - let connection = connections.update_or_insert(connection_url, &issuer_name, None); + let connection = connections.update_or_insert(&connection_url, &issuer_name, did); let mut history_credentials = vec![]; @@ -335,7 +355,7 @@ pub async fn send_token_request(state: AppState, action: Action) -> Result Result hash(logo_uri.as_str()), - None => "_".to_string(), - }; - persist_asset(&file_name, &connection.id).ok(); + if let Some(logo_uri) = logo_uri { + persist_asset(&hash(logo_uri.as_str()), &connection.id).ok(); + } // History let mut history = state.history; @@ -479,9 +497,9 @@ fn get_credential_display_name( /// An error is returned when: /// 1. The credential does not contain a status claim in the JWT root or a credentialStatus property in the VC. /// 2. The status claim/property does not use the OAuth Token Status List mechanism. -async fn get_credential_status( +pub async fn get_credential_status( verifiable_credential_record: &VerifiableCredentialRecord, - identity_manager: &IdentityManager, + subject: &Subject, ) -> Option { let status_value = get_unverified_jwt_claims(&verifiable_credential_record.verifiable_credential) .ok() // convert Result → Option @@ -519,7 +537,7 @@ async fn get_credential_status( last_checked: String::new(), }; - let status = match fetch_credential_status(&credential_status_data, identity_manager).await { + let status = match fetch_credential_status(&credential_status_data, subject).await { Ok(status) => status, Err(_) => { warn!("Failed to fetch credential status"); diff --git a/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs b/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs index 27c6e5e23..88cf0a0d4 100644 --- a/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs +++ b/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs @@ -249,7 +249,7 @@ pub async fn get_trusted_verifier_public_verification_endpoint(issuer_did: &str) // This test feature is added to avoid the need to set up an entire trust ecosystem to create a unit test for this file. // This .env variable is managed programmatically by the unit test in this file, and is only used for testing purposes. It is not used in production. #[cfg(test)] - if let Ok(endpoint) = std::env::var("TEST_PUBLIC_VERIFIER_ENDPOINT") { + if let Ok(endpoint) = std::env::var("UNIME_TEST_PUBLIC_VERIFIER_ENDPOINT") { return Ok(endpoint); } diff --git a/identity-wallet/src/state/dev_mode/reducers/ferris_static_profile.rs b/identity-wallet/src/state/dev_mode/reducers/ferris_static_profile.rs index b61c796ae..2205e6170 100644 --- a/identity-wallet/src/state/dev_mode/reducers/ferris_static_profile.rs +++ b/identity-wallet/src/state/dev_mode/reducers/ferris_static_profile.rs @@ -225,7 +225,7 @@ pub async fn load_ferris_profile() -> Result { id: "352eaaf022a32cc315b4ac46bfa14bcad91e901bdf3aff3925d3a5a4c13bd611".to_string(), name: "NGDIL Demo".to_string(), url: "api.ngdil-demo.tanglelabs.io".to_string(), - did: None, + did: "did:example:123".to_string(), verified: false, first_interacted: "2023-09-11T19:53:53.937981+00:00".to_string(), last_interacted: "2023-09-11T19:53:53.937981+00:00".to_string(), @@ -234,7 +234,7 @@ pub async fn load_ferris_profile() -> Result { id: "424313e61e35ca4eeca44aac85dc4764c32d7cf9def83ba15f428c308bf1d181".to_string(), name: "Impierce Demo Portal".to_string(), url: "https://demo.impierce.com".to_string(), - did: Some("did:iota:rms:0x42ad588322e58b3c07aa39e4948d021ee17ecb5747915e9e1f35f028d7ecaf90".to_string()), + did: "did:iota:rms:0x42ad588322e58b3c07aa39e4948d021ee17ecb5747915e9e1f35f028d7ecaf90".to_string(), verified: true, first_interacted: "2024-01-09T07:36:41.382948+00:00".to_string(), last_interacted: "2024-01-09T07:36:41.382948+00:00".to_string(), @@ -243,7 +243,7 @@ pub async fn load_ferris_profile() -> Result { id: "e36236d8d7117ed6c6a5d4e99167a2ee1ccb455e75d5b71cee50b08adcf11ba1".to_string(), name: "my-webshop.com".to_string(), url: "https://shop.example.com".to_string(), - did: Some("did:key:z6Mkk7yqnGF3YwTrLpqrW6PGsKci7dNqh1CjnvMbzrMerSeL".to_string()), + did: "did:key:z6Mkk7yqnGF3YwTrLpqrW6PGsKci7dNqh1CjnvMbzrMerSeL".to_string(), verified: false, first_interacted: "2022-02-03T12:33:54.191824+00:00".to_string(), last_interacted: "2023-11-13T19:26:40.049239+00:00".to_string(), @@ -252,7 +252,7 @@ pub async fn load_ferris_profile() -> Result { id: "a81a51b8ad26bdd333abd791a112bf0e0823d559cadc580218a240238a86c292".to_string(), name: "IOTA".to_string(), url: "https://www.iota.org".to_string(), - did: Some("did:iota:0xe4edef97da1257e83cbeb49159cfdd2da6ac971ac447f233f8439cf29376ebfe".to_string()), + did: "did:iota:0xe4edef97da1257e83cbeb49159cfdd2da6ac971ac447f233f8439cf29376ebfe".to_string(), verified: true, first_interacted: "2024-01-09T08:45:44.217Z".to_string(), last_interacted: "2024-01-09T08:45:44.217Z".to_string(), diff --git a/identity-wallet/src/state/did/validate_domain_linkage.rs b/identity-wallet/src/state/did/validate_domain_linkage.rs index 340600e3d..a0c511d95 100644 --- a/identity-wallet/src/state/did/validate_domain_linkage.rs +++ b/identity-wallet/src/state/did/validate_domain_linkage.rs @@ -19,10 +19,11 @@ use ts_rs::TS; use crate::http_client::get_http_client; #[skip_serializing_none] -#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, TS, Default)] +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, TS)] #[ts(export, export_to = "bindings/user_prompt/ValidationResult.ts")] pub struct ValidationResult { pub(crate) status: ValidationStatus, + pub(crate) url: url::Url, pub(crate) name: Option, #[ts(type = "string", optional)] pub(crate) logo_uri: Option, @@ -89,8 +90,11 @@ pub async fn validate_domain_linkage(resolver: &Resolver, url: url::Url, did: &s Err(err) => { return ValidationResult { status: ValidationStatus::Unknown, + url, + name: None, + logo_uri: None, + issuance_date: None, message: Some(format!("Error while fetching configuration: {err}")), - ..Default::default() }; } }; @@ -102,33 +106,41 @@ pub async fn validate_domain_linkage(resolver: &Resolver, url: url::Url, did: &s Err(e) => { return ValidationResult { status: ValidationStatus::Unknown, + url, + name: None, + logo_uri: None, + issuance_date: None, message: Some(e.to_string()), - ..Default::default() }; } }; debug!("Resolved document: {document:?}"); - let url = identity_iota::core::Url::from(url); - let res = validator.validate_linkage( &document, &domain_linkage_configuration, - &url, + &identity_iota::core::Url::from(url.clone()), &JwtCredentialValidationOptions::default(), ); if res.is_ok() { ValidationResult { status: ValidationStatus::Success, - ..Default::default() + url, + name: None, + logo_uri: None, + issuance_date: None, + message: None, } } else { ValidationResult { status: ValidationStatus::Failure, + url, + name: None, + logo_uri: None, + issuance_date: None, message: res.err().map(|e| e.to_string()), - ..Default::default() } } } @@ -252,18 +264,22 @@ mod tests { let resolver = Resolver::new(); - let result = - validate_domain_linkage(&resolver, url::Url::parse(&mock_server.uri()).unwrap(), "did:foo:bar").await; + let url = url::Url::parse(&mock_server.uri()).unwrap(); + + let result = validate_domain_linkage(&resolver, url.clone(), "did:foo:bar").await; assert_eq!( result, ValidationResult { status: ValidationStatus::Unknown, + url, + name: None, + logo_uri: None, + issuance_date: None, message: Some( "Error while fetching configuration: failed to deserialize DomainLinkageConfiguration from JSON" .to_string() ), - ..Default::default() } ); } @@ -315,9 +331,11 @@ mod tests { let resolver = Resolver::new(); + let url = url::Url::parse(&mock_server.uri()).unwrap(); + let result = validate_domain_linkage( &resolver, - url::Url::parse(&mock_server.uri()).unwrap(), + url.clone(), "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp", ) .await; @@ -326,8 +344,11 @@ mod tests { result, ValidationResult { status: ValidationStatus::Failure, + url, + name: None, + logo_uri: None, + issuance_date: None, message: Some("invalid semantic structure of the domain linkage configuration".to_string()), - ..Default::default() } ); } @@ -358,7 +379,7 @@ mod tests { let result = validate_domain_linkage( &resolver, - url, + url.clone(), "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp", ) .await; @@ -367,8 +388,11 @@ mod tests { result, ValidationResult { status: ValidationStatus::Failure, + url, + name: None, + logo_uri: None, + issuance_date: None, message: Some("invalid semantic structure of the domain linkage configuration".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 index 9bed2cfc8..19812e523 100644 --- a/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs +++ b/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs @@ -1,12 +1,13 @@ use crate::{ http_client::get_http_client, state::{ - core_utils::helpers::{download_logo, get_issuer_document, validate_credential_types}, - did::{ - extract_url_from_did_web, - validate_domain_linkage::{ValidationStatus, Verifier}, + core_utils::helpers::{download_logo, get_issuer_document}, + credentials::{ + reducers::send_token_request::get_credential_status, DisplayCredential, VerifiableCredentialRecord, }, + did::validate_domain_linkage::{ValidationResult, ValidationStatus, Verifier}, }, + subject::Subject, }; use did_manager::Resolver; use futures::{ @@ -18,36 +19,25 @@ use identity_iota::{ core::{OneOrMany, ToJson}, credential::{ DecodedJwtCredential, DecodedJwtPresentation, FailFast, Jwt, JwtCredentialValidationOptions, - JwtCredentialValidator, JwtPresentationValidator, StatusCheck, Subject, + JwtCredentialValidator, JwtPresentationValidator, StatusCheck, }, document::{CoreDocument, Service}, }; use log::{debug, info, warn}; -use oid4vc::oid4vci::credential_issuer::credential_issuer_metadata::CredentialIssuerMetadata; +use oid4vc::oid4vci::{ + credential_format_profiles::CredentialFormats, + credential_issuer::credential_issuer_metadata::CredentialIssuerMetadata, +}; 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)] +#[derive(Clone, Serialize, Deserialize, Debug, TS, Default, PartialEq)] #[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 - } + pub credential: DisplayCredential, + pub issuer_domain_validations: Vec, } /// Validate the linked verifiable presentations for the given holder DID. Returns a list of linked verifiable @@ -55,11 +45,13 @@ impl PartialEq for LinkedVerifiableCredentialData { /// 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, + subject: &Subject, holder_did: &str, ) -> Vec> { info!("Validating linked verifiable presentations for holder DID: {holder_did}"); + let resolver = subject.resolver().await; + let holder_document = match resolver.resolve(holder_did).await { Ok(holder_document) => holder_document, _ => { @@ -81,7 +73,7 @@ pub async fn validate_linked_verifiable_presentations( .filter_map(|linked_verifiable_presentation_url| { debug!("Processing linked verifiable presentation URL: {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) + get_validated_linked_presentation_data(subject, &holder_document, linked_verifiable_presentation_url) }) .collect::>() .await @@ -129,7 +121,7 @@ fn get_linked_verifiable_presentation_urls(service: &Service) -> Option /// 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, + subject: &Subject, holder_document: &CoreDocument, linked_verifiable_presentation_url: Url, ) -> Option> { @@ -137,7 +129,7 @@ async fn get_validated_linked_presentation_data( validate_linked_verifiable_presentation(holder_document, linked_verifiable_presentation_url) .await .map(|linked_verifiable_presentation| { - get_validated_linked_credential_data(resolver, linked_verifiable_presentation) + get_validated_linked_credential_data(subject, linked_verifiable_presentation) }), ) .await @@ -187,9 +179,10 @@ async fn validate_linked_verifiable_presentation( /// 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, + subject: &Subject, linked_verifiable_presentation: DecodedJwtPresentation, ) -> Vec { + let resolver = &subject.resolver().await; iter(linked_verifiable_presentation.presentation.verifiable_credential) .filter_map(|linked_verifiable_credential_jwt| async move { // Resolve the issuer document and issuer DID @@ -204,16 +197,7 @@ async fn get_validated_linked_credential_data( debug!("Issuer linked domains: {issuer_linked_domains:#?}"); // 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() { - debug!("No validated linked domains found, attempting to extract URL from DID Web: {issuer_did}"); - if let Some(did_web_url) = extract_url_from_did_web(&issuer_did) { - validated_linked_domains.insert(0, did_web_url); - } - } + let validated_linked_domains = get_validated_linked_domains(resolver, &issuer_linked_domains, &issuer_did).await; if !validated_linked_domains.is_empty() { let validator = JwtCredentialValidator::with_signature_verifier(Verifier); @@ -230,8 +214,9 @@ async fn get_validated_linked_credential_data( ) { debug!("Validated linked verifiable credential JWT: {linked_verifiable_credential:#?}"); + // TODO: Uncomment this once json schema validation works on mobile. // Validate the linked verifiable credential against its corresponding JSON Schema - validate_credential_types(&linked_verifiable_credential.credential.to_json_value().ok()?).ok()?; + // validate_credential_types(&linked_verifiable_credential.credential.to_json_value().ok()?).ok()?; let credential_subject = match &linked_verifiable_credential.credential.credential_subject { OneOrMany::One(subject) => Some(subject), @@ -239,18 +224,34 @@ async fn get_validated_linked_credential_data( 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, &linked_verifiable_credential, &validated_linked_domains).await; + if credential_subject.is_some() { + let credential_name = get_credential_name(&linked_verifiable_credential); + let credential_logo_uri = get_credential_logo_uri(&linked_verifiable_credential).await; + + let linked_domains = validated_linked_domains.iter().map(|result| result.url.clone()).collect::>(); + let (issuer_name, issuer_logo_uri) = get_issuer_info(&linked_domains).await; let issuance_date = linked_verifiable_credential.credential.issuance_date.to_rfc3339(); - debug!("LinkedVerifiableCredentialData: name: {name:?}, logo_uri: {logo_uri:?}, issuance_date: {issuance_date}, validated_linked_domains: {validated_linked_domains:#?}"); + debug!("LinkedVerifiableCredentialData: name: {credential_name:?}, credential_logo_uri: {credential_logo_uri:?}, issuer_name: {issuer_name:?}, issuer_logo_uri: {issuer_logo_uri:?}, issuance_date: {issuance_date}, validated_linked_domains: {linked_domains:#?}"); + + let Ok(mut verifiable_credential_record) = VerifiableCredentialRecord::try_new( + CredentialFormats::JwtVcJson(()), + serde_json::json!(linked_verifiable_credential_jwt), + vec![], + ) else { + warn!("Failed to create `verifiable_credential_record` for linked verifiable credential"); + return None; + }; + + verifiable_credential_record.display_credential.credential_status = get_credential_status(&verifiable_credential_record, subject).await; + verifiable_credential_record.display_credential.display_name = credential_name.unwrap_or_default(); + verifiable_credential_record.display_credential.metadata.icon = credential_logo_uri; + verifiable_credential_record.display_credential.issuer_name = issuer_name.unwrap_or_default(); + verifiable_credential_record.display_credential.issuer_logo_uri = issuer_logo_uri; Some(LinkedVerifiableCredentialData { - name, - logo_uri, - issuance_date, - issuer_linked_domains: validated_linked_domains, + credential: verifiable_credential_record.display_credential, + issuer_domain_validations: validated_linked_domains, }) } else { @@ -280,33 +281,33 @@ async fn get_validated_linked_domains( #[cfg(feature = "test_utils")] _resolver: &Resolver, issuer_linked_domains: &[Url], issuer_did: &str, -) -> Vec { +) -> Vec { FuturesUnordered::from_iter(issuer_linked_domains.iter().map(|issuer_linked_domain| async move { - let validation_status: ValidationStatus = { + let validation_result: ValidationResult = { #[cfg(not(feature = "test_utils"))] { use crate::state::did::validate_domain_linkage::validate_domain_linkage; - validate_domain_linkage(resolver, issuer_linked_domain.clone(), issuer_did) - .await - .status + validate_domain_linkage(resolver, issuer_linked_domain.clone(), issuer_did).await } #[cfg(feature = "test_utils")] { // Silence unused variable warning let _issuer_did = issuer_did; // Skip validation during tests - Default::default() + ValidationResult { + status: ValidationStatus::default(), + url: issuer_linked_domain.clone(), + name: None, + logo_uri: None, + issuance_date: None, + message: None, + } } }; - 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 - } + info!("Validation of domain linkage for issuer linked domain '{issuer_linked_domain}' resulted in: {validation_result:?}"); + Some(validation_result) })) .filter_map(|result| async move { result }) .collect() @@ -346,100 +347,97 @@ async fn get_issuer_linked_domains(issuer_document: &CoreDocument) -> Vec { .collect() } -fn get_name(credential_subject: &Subject) -> Option { - credential_subject +fn get_credential_name(linked_verifiable_credential: &DecodedJwtCredential) -> Option { + linked_verifiable_credential + .credential .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 { - debug!("Trying to fetch image uri from credential subject"); - let mut logo_uri = credential_subject +/// Try to get the credential's own logo URI from the `logo` property in the root of the credential. +async fn get_credential_logo_uri(linked_verifiable_credential: &DecodedJwtCredential) -> Option { + debug!("Trying to fetch credential logo uri from credential root"); + let logo_uri = linked_verifiable_credential + .credential .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() { - debug!("Failed to fetch image uri from credential subject"); - for domain in validated_linked_domains.iter() { - let well_known_endpoint = format!("{domain}.well-known/openid-credential-issuer"); - debug!("Trying to fetch image uri from {well_known_endpoint} endpoint"); - if let Ok(response) = get_http_client().await.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; - } - } + .get("logo") + .and_then(|logo| { + if let Some(uri) = logo.get("uri").and_then(Value::as_str) { + Some(uri.to_string()) + } else { + logo.as_str().map(ToString::to_string) } - // 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"); - debug!("Trying to fetch image uri from {well_known_endpoint} endpoint"); + }); + + if let Some(ref logo_uri_str) = logo_uri { + download_logo(logo_uri_str).await + } else { + None + } +} + +/// Retrieve the issuer's name and issuer logo URI from .well-known/openid-credential-issuer metadata. +async fn get_issuer_info(validated_linked_domains: &[Url]) -> (Option, Option) { + let mut issuer_name = None; + let mut issuer_logo_uri = None; + + for domain in validated_linked_domains.iter() { + let well_known_endpoints = [ + format!("{domain}.well-known/openid-credential-issuer"), + format!("{domain}oid4vci/.well-known/openid-credential-issuer"), + ]; + + for well_known_endpoint in well_known_endpoints { + debug!("Trying to fetch issuer info from {well_known_endpoint} endpoint"); if let Ok(response) = get_http_client().await.get(&well_known_endpoint).send().await { + debug!("Response from {well_known_endpoint}: {response:#?}"); if let Ok(metadata) = response.json::().await { - logo_uri = linked_verifiable_credential.credential.types.iter().find_map(|type_| { - debug!("Trying to fetch image uri from Credential Configuration Supported: {type_}"); - metadata - .credential_configurations_supported - .get(type_) - .and_then(|credential_configuration| { - credential_configuration - .credential_metadata - .as_ref()? - .display - .as_ref()? - .first() - }) - .and_then(|display| display.logo.clone()) - .map(|logo| logo.uri.to_string()) - }); - - if logo_uri.is_some() { + debug!("Metadata from {well_known_endpoint}: {metadata:#?}"); + if let Some(display) = metadata.display.as_deref().and_then(|d| d.first()) { + if issuer_name.is_none() { + issuer_name = display.get("name").and_then(Value::as_str).map(ToString::to_string); + } + if issuer_logo_uri.is_none() { + if let Some(logo_uri_str) = extract_logo_uri_from_display(std::slice::from_ref(display)) { + issuer_logo_uri = download_logo(&logo_uri_str).await; + } + } + } + if issuer_name.is_some() && issuer_logo_uri.is_some() { break; } } } } + if issuer_name.is_some() && issuer_logo_uri.is_some() { + break; + } } - if let Some(logo_uri_str) = logo_uri { - download_logo(&logo_uri_str).await - } else { - warn!("No logo URI found"); - None + if issuer_name.is_none() { + if let Some(domain) = validated_linked_domains.first() { + if let Some(host) = domain.host_str() { + issuer_name = Some(host.to_string()); + } + } } + + (issuer_name, issuer_logo_uri) } 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) + .and_then(|logo| { + if let Some(uri) = logo.get("uri").or_else(|| logo.get("url")).and_then(|url| url.as_str()) { + Some(uri.to_string()) + } else { + logo.as_str().map(ToString::to_string) + } + }) } #[cfg(not(feature = "test_utils"))] @@ -472,6 +470,7 @@ mod tests { pub domain: url::Url, pub did_document: CoreDocument, pub secret_manager: Arc>, + pub subject: Arc, } impl TestEntity { @@ -507,11 +506,21 @@ mod tests { .await .unwrap(); + *crate::persistence::STRONGHOLD.lock().unwrap() = path.clone(); + let stronghold_manager = Arc::new(crate::stronghold::StrongholdManager::create("sup3rSecr3t").unwrap()); + let secret_manager = Arc::new(Mutex::new(secret_manager)); + let subject = Arc::new(Subject { + stronghold_manager, + secret_manager: secret_manager.clone(), + resolver: tokio::sync::OnceCell::new(), + }); + TestEntity { mock_server, domain, did_document, - secret_manager: Arc::new(Mutex::new(secret_manager)), + secret_manager, + subject, } } @@ -623,19 +632,25 @@ mod tests { } // 'Issues' a Credential Jwt to a subject. - async fn issue_credential(&mut self, subject_id: &str, subject_name: &str, subject_image: Url) -> Jwt { + async fn issue_credential(&mut self, subject_id: &str, credential_name: &str, credential_logo: 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 issuance_date = Timestamp::parse("2020-01-01T00:00:00Z").unwrap(); + + let mut properties = identity_iota::core::Object::new(); + properties.insert("name".to_string(), json!(credential_name)); + properties.insert("logo".to_string(), json!(credential_logo)); + let credential: Credential = CredentialBuilder::default() .issuer(issuer) .subject(subject) + .issuance_date(issuance_date) + .properties(properties) .build() .unwrap(); @@ -757,25 +772,21 @@ mod tests { holder.add_well_known_did_json().await; - let resolver = Resolver::new(); + let validated = + validate_linked_verifiable_presentations(&holder.subject, holder.did_document.id().to_string().as_ref()) + .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() - }] - ] - ); + assert_eq!(validated.len(), 2); + assert_eq!(validated[0].len(), 1); + assert_eq!(validated[1].len(), 1); + assert_eq!(validated[0][0].credential.display_name, "Webshop"); + assert_eq!(validated[0][0].credential.metadata.icon, Some(logo_uri_a)); + assert_eq!(validated[0][0].issuer_domain_validations.len(), 1); + assert_eq!(validated[0][0].issuer_domain_validations[0].url, issuer_a.domain); + assert_eq!(validated[1][0].credential.display_name, "Webshop"); + assert_eq!(validated[1][0].credential.metadata.icon, Some(logo_uri_b)); + assert_eq!(validated[1][0].issuer_domain_validations.len(), 1); + assert_eq!(validated[1][0].issuer_domain_validations[0].url, issuer_b.domain); } #[tokio::test] @@ -817,10 +828,9 @@ mod tests { holder.add_well_known_did_json().await; - let resolver = Resolver::new(); - assert_eq!( - validate_linked_verifiable_presentations(&resolver, holder.did_document.id().to_string().as_ref()).await, + validate_linked_verifiable_presentations(&holder.subject, 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![]] ); @@ -902,26 +912,25 @@ mod tests { ) .await; - let resolver = Resolver::new(); - 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() - }]) - ); + let validated_linked_presentation_data = get_validated_linked_presentation_data( + &holder.subject, + &holder.did_document, + linked_verifiable_presentation_url, + ) + .await + .unwrap(); + + assert_eq!(validated_linked_presentation_data.len(), 1); + let item = &validated_linked_presentation_data[0]; + assert_eq!(item.credential.display_name, "Webshop"); + assert_eq!(item.credential.metadata.icon, Some(issuer_logo)); + assert_eq!(item.issuer_domain_validations.len(), 1); + assert_eq!(item.issuer_domain_validations[0].url, issuer.domain); } #[tokio::test] @@ -937,24 +946,26 @@ mod tests { let resolver = Resolver::new(); // Successfully validate the linked domain. + let results = get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone()], + issuer1.did_document.id().to_string().as_ref(), + ) + .await; assert_eq!( - get_validated_linked_domains( - &resolver, - &[issuer1.domain.clone()], - issuer1.did_document.id().to_string().as_ref() - ) - .await, + results.into_iter().map(|r| r.url).collect::>(), vec![issuer1.domain.clone()] ); // Assert that only one domain was validated. + let results = get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone(), "http://invalid-domain.org".parse().unwrap()], + issuer1.did_document.id().to_string().as_ref(), + ) + .await; 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, + results.into_iter().map(|r| r.url).collect::>(), vec![issuer1.domain.clone()] ); @@ -967,13 +978,14 @@ mod tests { 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. + let results = get_validated_linked_domains( + &resolver, + &[issuer1.domain.clone(), issuer2.domain.clone()], + issuer1.did_document.id().to_string().as_ref(), + ) + .await; assert_eq!( - get_validated_linked_domains( - &resolver, - &[issuer1.domain.clone(), issuer2.domain.clone()], - issuer1.did_document.id().to_string().as_ref() - ) - .await, + results.into_iter().map(|r| r.url).collect::>(), vec![issuer1.domain.clone()] ); @@ -990,13 +1002,15 @@ mod tests { issuer2.add_well_known_did_json().await; // Assert that both domains were validated (regardless of the order). - assert!(get_validated_linked_domains( + let results = get_validated_linked_domains( &resolver, &[issuer1.domain.clone(), issuer2.domain.clone()], - issuer1.did_document.id().to_string().as_ref() + issuer1.did_document.id().to_string().as_ref(), ) - .await - .iter() - .all(|item| [issuer1.domain.clone(), issuer2.domain.clone()].contains(item))); + .await; + assert_eq!(results.len(), 2); + assert!(results + .iter() + .all(|item| [issuer1.domain.clone(), issuer2.domain.clone()].contains(&item.url))); } } diff --git a/identity-wallet/src/state/qr_code/actions/qrcode_scanned.rs b/identity-wallet/src/state/qr_code/actions/qrcode_scanned.rs index 5db2781d7..0070cec60 100644 --- a/identity-wallet/src/state/qr_code/actions/qrcode_scanned.rs +++ b/identity-wallet/src/state/qr_code/actions/qrcode_scanned.rs @@ -1,6 +1,5 @@ use crate::state::actions::ActionTrait; -use crate::state::qr_code::reducers::read_authorization_request::read_authorization_request; -use crate::state::qr_code::reducers::read_credential_offer::read_credential_offer; +use crate::state::qr_code::reducers::accept_connection::accept_connection; use crate::{reducer, state::Reducer}; use serde::{Deserialize, Serialize}; @@ -16,6 +15,6 @@ pub struct QrCodeScanned { #[typetag::serde(name = "[QR Code] Scanned")] impl ActionTrait for QrCodeScanned { fn reducers<'a>(&self) -> Vec> { - vec![reducer!(read_authorization_request), reducer!(read_credential_offer)] + vec![reducer!(accept_connection)] } } diff --git a/identity-wallet/src/state/qr_code/reducers/accept_connection.rs b/identity-wallet/src/state/qr_code/reducers/accept_connection.rs new file mode 100644 index 000000000..a124b06cc --- /dev/null +++ b/identity-wallet/src/state/qr_code/reducers/accept_connection.rs @@ -0,0 +1,405 @@ +use crate::{ + error::AppError::{self, *}, + http_client::get_http_client, + state::{ + actions::{listen, Action}, + core_utils::{ + helpers::{download_logo, normalize_connection_url}, + ActiveFlow, CoreUtils, Oid4vciStage, + }, + did::validate_linked_verifiable_presentations::{ + validate_linked_verifiable_presentations, LinkedVerifiableCredentialData, + }, + qr_code::actions::qrcode_scanned::QrCodeScanned, + user_prompt::{ClientMetadata, ConnectionData, CurrentUserPrompt}, + AppState, + }, +}; +use identity_iota::did::CoreDID; +use log::{info, warn}; +use oid4vc::siopv2::siopv2::SIOPv2; +use oid4vc::{ + oid4vc_core::{ + authorization_request::{AuthorizationRequest, Object}, + client_metadata::ClientMetadataResource, + }, + oid4vci::credential_offer::CredentialOffer, +}; +use oid4vc::{oid4vci::credential_offer::CredentialOfferParameters, oid4vp::oid4vp::OID4VP}; +use serde_json::Value; + +/// The kind of request encoded in a scanned QR-code. +/// +/// SIOPv2 and OID4VP requests are classified through `AuthorizationRequest::from_generic`, while +/// OID4VCI credential offers use their own URL scheme and are parsed directly from the raw string. +#[derive(Debug, Clone)] +enum ParsedQrCode { + Siopv2(Box>>), + Oid4vp(Box>>), + Oid4vci(Box), +} + +/// Sets the `AcceptConnection` prompt; the following `ConnectionAccepted` action routes to the next reducer depending on the `ActiveFlow` set here. +/// 1. Read and parse the QR-code to a URL. +/// 2. Retrieve the connection data to display on the "Accept connection" screen. +/// 3. Init the `ActiveFlow` enum with the rest of the retrieved data. +pub async fn accept_connection(state: AppState, action: Action) -> Result { + if let Some(qr_code_scanned) = listen::(action).map(|payload| payload.form_urlencoded) { + let parsed_qr_code = parse_qr_code(&state, qr_code_scanned).await?; + info!("QR code parsed as: {parsed_qr_code:?}"); + + let (client_metadata, active_flow) = + get_client_metadata_init_active_flow(&state, parsed_qr_code.clone()).await?; + info!("Retrieved client metadata: {client_metadata:?}"); + info!("Initializing active flow: {active_flow:?}"); + + let did = client_metadata.client_id.to_string(); + let connection_data = state + .connections + .0 + .iter() + // TODO: currently we only match against the DID, but if any display info changes with what we stored we plan to notify the user of the diffs. + .find(|conn| conn.did == did) + .map(|connection| { + let interactions = state + .history + .iter() + .filter(|event| event.connection_id == connection.id) + .cloned() + .collect(); + ConnectionData { + first_interacted_at: connection.first_interacted.clone(), + last_interacted_at: connection.last_interacted.clone(), + interactions, + } + }); + + let url = url::Url::parse(&client_metadata.connection_url).map_err(|_| { + Error(format!( + "`connection_url` could not be parsed to URL: `{:?}`", + client_metadata.connection_url.clone() + )) + })?; + + let state_guard = state.core_utils.managers.lock().await; + let subject = state_guard + .identity_manager + .as_ref() + .ok_or(AppError::MissingManagerError("identity"))? + .subject + .clone(); + + info!( + "Checking domain linkage for DID: {did} and URL: {}", + client_metadata.connection_url + ); + + let domain_validation = { + #[cfg(not(feature = "test_utils"))] + { + use crate::state::did::validate_domain_linkage::validate_domain_linkage; + + let resolver = subject.resolver().await; + + Box::new(validate_domain_linkage(resolver.as_ref(), url, &did).await) + } + #[cfg(feature = "test_utils")] + { + // Skip validation during tests + + use crate::state::did::validate_domain_linkage::{ValidationResult, ValidationStatus}; + Box::new(ValidationResult { + status: ValidationStatus::default(), + url, + name: None, + logo_uri: None, + issuance_date: None, + message: None, + }) + } + }; + + info!("Domain validation result: {domain_validation:?}"); + + let linked_verifiable_presentations = match validate_linked_verifiable_presentations(&subject, &did) + .await + .into_iter() + .flatten() + .collect::>() + { + vec if !vec.is_empty() => Some(vec), + _ => None, + }; + + info!("Linked verifiable presentations: {linked_verifiable_presentations:?}"); + + drop(state_guard); + + let current_user_prompt = Some(CurrentUserPrompt::AcceptConnection { + client_metadata, + connection_data, + domain_validation, + linked_verifiable_presentations, + ecosystems: None, // TODO: impl this + }); + + info!("Setting current user prompt to: {current_user_prompt:?}"); + + Ok(AppState { + current_user_prompt, + core_utils: CoreUtils { + active_flow: Some(active_flow), + ..state.core_utils + }, + ..state + }) + } else { + Ok(state) + } +} + +// Helpers + +// OID4VCI credential offers are handled by a dedicated reducer, so they're +// parsed directly here rather than through `provider_manager.validate_request`. +async fn parse_qr_code(state: &AppState, qr_code_scanned: String) -> Result { + let state_guard = state.core_utils.managers.lock().await; + let wallet = &state_guard + .identity_manager + .as_ref() + .ok_or(MissingManagerError("identity"))? + .wallet; + + if let Ok(credential_offer) = qr_code_scanned.parse::() { + let credential_offer: CredentialOfferParameters = match credential_offer { + CredentialOffer::CredentialOffer(credential_offer) => *credential_offer, + CredentialOffer::CredentialOfferUri(credential_offer_uri) => wallet + .get_credential_offer(credential_offer_uri) + .await + .map_err(GetCredentialOfferError)?, + }; + + return Ok(ParsedQrCode::Oid4vci(Box::new(credential_offer))); + } + + let provider_manager = &state_guard + .identity_manager + .as_ref() + .ok_or(MissingManagerError("identity"))? + .provider_manager; + + let generic_authorization_request = provider_manager + .validate_request(qr_code_scanned.clone()) + .await + .map_err(|_| InvalidQRCodeError(qr_code_scanned.clone()))?; + + if let Result::Ok(siopv2_authorization_request) = + AuthorizationRequest::>::from_generic(&generic_authorization_request) + { + Ok(ParsedQrCode::Siopv2(Box::new(siopv2_authorization_request))) + } else if let Result::Ok(oid4vp_authorization_request) = + AuthorizationRequest::>::from_generic(&generic_authorization_request) + { + Ok(ParsedQrCode::Oid4vp(Box::new(oid4vp_authorization_request))) + } else { + Err(InvalidAuthorizationRequest(Box::new(generic_authorization_request))) + } +} + +/// This function retrieves the client metadata and initializes the active flow based on the parsed QR code. +/// For SIOPv2 the next reducer will be `handle_siopv2_authorization_request`. +/// For OID4VP the next reducer will be `read_oid4vp_authorization_request`. +/// For OID4VCI the next reducer will be `read_credential_offer`. +async fn get_client_metadata_init_active_flow( + state: &AppState, + parsed_qr_code: ParsedQrCode, +) -> Result<(ClientMetadata, ActiveFlow), AppError> { + match parsed_qr_code { + ParsedQrCode::Siopv2(siopv2_authorization_request) => { + let client_metadata = get_siopv2_client_metadata(&siopv2_authorization_request).await?; + let active_flow = ActiveFlow::Siopv2 { + authorization_request: siopv2_authorization_request, + }; + Ok((client_metadata, active_flow)) + } + ParsedQrCode::Oid4vp(oid4vp_authorization_request) => { + let client_metadata = get_oid4vp_client_metadata(&oid4vp_authorization_request).await?; + let active_flow = ActiveFlow::Oid4vp { + authorization_request: oid4vp_authorization_request, + is_interactive: false, + }; + Ok((client_metadata, active_flow)) + } + ParsedQrCode::Oid4vci(credential_offer) => { + let client_metadata = get_oid4vci_client_metadata(state, &credential_offer).await?; + let active_flow = ActiveFlow::Oid4vciOffer { + stage: Oid4vciStage::OfferReceived, + logo_uri: client_metadata.logo_uri.clone(), + credential_offer, + }; + Ok((client_metadata, active_flow)) + } + } +} + +async fn get_siopv2_client_metadata( + siopv2_authorization_request: &AuthorizationRequest>, +) -> Result { + let redirect_uri = siopv2_authorization_request.body.uri.uri().clone(); + let connection_url = normalize_connection_url(&redirect_uri); + + let client_id = strip_client_id_prefix(&siopv2_authorization_request.body.client_id); + let client_id = + CoreDID::parse(&client_id).map_err(|e| AppError::Error(format!("Failed to parse client_id as DID: {e}")))?; + + Ok(match &siopv2_authorization_request.body.extension.client_metadata { + ClientMetadataResource::ClientMetadata { + client_name, logo_uri, .. + } => { + let client_name = client_name.as_ref().cloned().unwrap_or_else(|| connection_url.clone()); + let mut logo_uri = logo_uri.as_ref().map(ToString::to_string); + + if let Some(logo_uri_str) = &logo_uri { + if download_logo(logo_uri_str).await.is_none() { + logo_uri = None; + } + } else { + warn!("No logo URI found"); + } + + ClientMetadata { + client_name, + logo_uri, + connection_url: connection_url.clone(), + client_id: client_id.clone(), + redirect_uri: Some(redirect_uri.to_string()), + } + } + ClientMetadataResource::ClientMetadataUri(_) => { + return Err(Error("Client metadata URI not supported".to_string())); + } + }) +} + +pub(crate) fn strip_client_id_prefix(client_id: &str) -> String { + use oid4vc::oid4vp::authorization_request::ClientId; + use std::str::FromStr as _; + + ClientId::from_str(client_id) + .map(|client_id| client_id.identifier().to_string()) + .unwrap_or_else(|_| client_id.to_string()) +} + +pub(crate) async fn get_oid4vp_client_metadata( + oid4vp_authorization_request: &AuthorizationRequest>, +) -> Result { + let redirect_uri = oid4vp_authorization_request.body.uri.uri().clone(); + let connection_url = normalize_connection_url(&redirect_uri); + let client_id = CoreDID::parse(strip_client_id_prefix(&oid4vp_authorization_request.body.client_id)) + .map_err(|error| AppError::Error(format!("Failed to parse client_id as DID: {error}")))?; + + Ok(match &oid4vp_authorization_request.body.extension.client_metadata { + ClientMetadataResource::ClientMetadata { + client_name, logo_uri, .. + } => { + let client_name = client_name.as_ref().cloned().unwrap_or_else(|| connection_url.clone()); + let mut logo_uri = logo_uri.as_ref().map(ToString::to_string); + + if let Some(logo_uri_str) = &logo_uri { + if download_logo(logo_uri_str).await.is_none() { + logo_uri = None; + } + } else { + warn!("No logo URI found"); + } + + ClientMetadata { + client_name, + logo_uri, + connection_url: connection_url.clone(), + client_id, + redirect_uri: Some(redirect_uri.to_string()), + } + } + ClientMetadataResource::ClientMetadataUri(_) => { + return Err(Error("Client metadata URI not supported".to_string())); + } + }) +} + +async fn get_oid4vci_client_metadata( + state: &AppState, + credential_offer: &CredentialOfferParameters, +) -> Result { + let state_guard = state.core_utils.managers.lock().await; + let wallet = &state_guard + .identity_manager + .as_ref() + .ok_or(MissingManagerError("identity"))? + .wallet; + + let credential_issuer_url = credential_offer.credential_issuer.clone(); + let connection_url = normalize_connection_url(&credential_issuer_url); + + info!("credential issuer url: {credential_issuer_url:?}"); + info!("connection url: {connection_url:?}"); + + let credential_issuer_metadata = wallet + .get_credential_issuer_metadata(credential_issuer_url.clone()) + .await + .ok(); + + let display = credential_issuer_metadata + .as_ref() + .and_then(|metadata| metadata.display.as_ref()?.first().cloned()); + + let (issuer_name, logo_uri) = match display { + Some(display) => { + let issuer_name = display["name"] + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| connection_url.clone()); + let mut logo_uri = display["logo"]["uri"].as_str().map(ToString::to_string); + + if let Some(logo_uri_str) = &logo_uri { + if download_logo(logo_uri_str).await.is_none() { + logo_uri = None; + } + } else { + warn!("No logo URI found"); + } + + (issuer_name, logo_uri) + } + None => (connection_url.clone(), None), + }; + + // This fetching of the DID document means that our OID4VCI implementation only accepts did:web's as client IDs. + // Read more about this design decision in ADR 0001. + let did_doc = get_http_client() + .await + .get(format!( + "{}/.well-known/did.json", + credential_issuer_url.to_string().trim_end_matches('/') + )) + .send() + .await? + .json::() + .await?; + + let client_id = did_doc + .get("id") + .and_then(Value::as_str) + .ok_or(AppError::DidParseError)? + .to_string(); + let client_id = + CoreDID::parse(&client_id).map_err(|e| AppError::Error(format!("Failed to parse client_id as DID: {e}")))?; + + Ok(ClientMetadata { + client_name: issuer_name, + redirect_uri: Some(credential_issuer_url.to_string()), + connection_url, + logo_uri, + client_id, + }) +} diff --git a/identity-wallet/src/state/qr_code/reducers/mod.rs b/identity-wallet/src/state/qr_code/reducers/mod.rs index 228ea3fd2..cb2341b17 100644 --- a/identity-wallet/src/state/qr_code/reducers/mod.rs +++ b/identity-wallet/src/state/qr_code/reducers/mod.rs @@ -1,2 +1,3 @@ +pub mod accept_connection; pub mod read_authorization_request; pub mod read_credential_offer; 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 7c2c3d205..15e86ca7d 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 @@ -1,14 +1,8 @@ use crate::{ error::AppError::{self, *}, state::{ - actions::{listen, Action}, - connections::reducers::handle_siopv2_authorization_request::get_siopv2_client_name_and_logo_uri, - core_utils::{helpers::download_logo, ActiveFlow, CoreUtils}, - credentials::reducers::handle_oid4vp_authorization_request::{ - get_oid4vp_client_name_and_logo_uri, OID4VPClientMetadata, - }, - did::validate_linked_verifiable_presentations::validate_linked_verifiable_presentations, - qr_code::actions::qrcode_scanned::QrCodeScanned, + actions::Action, + core_utils::{ActiveFlow, CoreUtils}, user_prompt::CurrentUserPrompt, AppState, }, @@ -19,271 +13,135 @@ use serde_json::Value; use identity_credential::sd_jwt_vc::SdJwtVc; use log::{debug, info, warn}; use oid4vc::oid4vc_core::utils::jwt::get_unverified_jwt_claims; -use oid4vc::oid4vp::{dcql::dcql_query::Format, oid4vp::OID4VP, token::vp_token_validator::DecodedPresentations}; -use oid4vc::siopv2::siopv2::SIOPv2; +use oid4vc::oid4vp::{dcql::dcql_query::Format, token::vp_token_validator::DecodedPresentations}; use oid4vc::{ - oid4vc_core::authorization_request::{AuthorizationRequest, Object}, - oid4vci::credential_format_profiles::CredentialFormats, - oid4vp::dcql_evaluation::evaluate_credential_query, + oid4vci::credential_format_profiles::CredentialFormats, oid4vp::dcql_evaluation::evaluate_credential_query, }; -// Reads the request url from the payload and validates it. -#[tracing::instrument(skip_all, err)] -pub async fn read_authorization_request(state: AppState, action: Action) -> Result { - if let Some(qr_code_scanned) = listen::(action) - .map(|payload| payload.form_urlencoded) - .filter(|s| !s.starts_with("openid-credential-offer")) - { - let state_guard = state.core_utils.managers.lock().await; - let stronghold_manager = state_guard - .stronghold_manager - .as_ref() - .ok_or(MissingManagerError("stronghold"))?; - let provider_manager = &state_guard - .identity_manager - .as_ref() - .ok_or(MissingManagerError("identity"))? - .provider_manager; - - let generic_authorization_request = provider_manager - .validate_request(qr_code_scanned.clone()) - .await - .map_err(|_| InvalidQRCodeError(qr_code_scanned))?; - - if let Result::Ok(siopv2_authorization_request) = - AuthorizationRequest::>::from_generic(&generic_authorization_request) - { - let redirect_uri = siopv2_authorization_request.body.uri.uri().to_string(); - - let (client_name, logo_uri, connection_url, _) = - get_siopv2_client_name_and_logo_uri(&siopv2_authorization_request); - - info!("SIOPv2 authorization request display metadata: client_name={client_name:?}, logo_uri={logo_uri:?}"); - - if let Some(logo_uri_str) = logo_uri.clone() { - download_logo(&logo_uri_str).await; - } else { - warn!("No logo URI found"); - } - - let previously_connected = state.connections.contains(&connection_url, &client_name); - - let did = siopv2_authorization_request.body.client_id.as_str(); - - let domain_validation = { - #[cfg(not(feature = "test_utils"))] +/// Reads the active OID4VP request from the active flow after the `AcceptConnection` prompt is accepted and the `ConnectionAccepted` action is send back from the Frontend. +/// This function validates the request, and sets the next CurrentUserPrompt to non-interactive `ShareCredentials`. +/// Non-interactive `CredentialsSelected` is handled by `handle_oid4vp_authorization_request`. +pub async fn read_oid4vp_authorization_request(state: AppState, _action: Action) -> Result { + info!("read_authorization_request"); + + let oid4vp_authorization_request = match state.core_utils.active_flow.clone() { + Some(ActiveFlow::Oid4vp { + authorization_request, .. + }) => authorization_request, + // Not a OID4VP flow, let other reducers handle this action. + _ => return Ok(state), + }; + + let state_guard = state.core_utils.managers.lock().await; + let stronghold_manager = state_guard + .stronghold_manager + .as_ref() + .ok_or(MissingManagerError("stronghold"))?; + + let verifiable_credentials = stronghold_manager.values().map_err(StrongholdValuesError)?.unwrap(); + info!("verifiable credentials: {verifiable_credentials:?}"); + + // TODO: Move most of this logic to `openid4vc` crates. + let dcql_query = &oid4vp_authorization_request.body.extension.dcql_query; + let uuids: Vec = dcql_query + .credentials + .iter() + .filter_map(|credential_query_from_request| { + verifiable_credentials.iter().find_map(|verifiable_credential_record| { + let credential_data: Value = if credential_query_from_request.format == Format::DcSdJwt + && verifiable_credential_record.display_credential.format == CredentialFormats::DcSdJwt(()) { - use crate::state::did::validate_domain_linkage::validate_domain_linkage; - - let url = url::Url::parse(&redirect_uri).map_err(|_| { - Error(format!( - "`redirect_uri` could not be parsed to url::Url: `{:?}`", - redirect_uri.clone() - )) - })?; - - let resolver = &state_guard - .identity_manager - .as_ref() - .ok_or(MissingManagerError("identity"))? - .subject - .resolver() - .await; - - Box::new(validate_domain_linkage(resolver, url, did).await) - } - #[cfg(feature = "test_utils")] + serde_json::json!(verifiable_credential_record + .verifiable_credential + .as_str()? + .parse::() + .ok()? + .into_disclosed_object(&Sha256Hasher::new()) + .ok()?) + } else if credential_query_from_request.format == Format::VcSdJwt + && verifiable_credential_record.display_credential.format == CredentialFormats::VcSdJwt(()) { - // Skip validation during tests - Default::default() - } - }; - - let trusted_domains: Vec = state - .trust_lists - .0 - .iter() - .flat_map(|trust_list| { - trust_list - .entries - .iter() - .filter_map(|(domain, trusted)| trusted.then_some(domain.clone())) - .collect::>() - }) - .collect(); - - debug!("Resolved trusted domains for SIOPv2 request: {trusted_domains:?}"); - - let resolver = state_guard - .identity_manager - .as_ref() - .ok_or(MissingManagerError("identity"))? - .subject - .resolver() - .await; + serde_json::json!(verifiable_credential_record + .verifiable_credential + .as_str()? + .parse::() + .ok()? + .into_disclosed_object(&Sha256Hasher::new()) + .ok()?) + } else if credential_query_from_request.format == Format::JwtVcJson + && verifiable_credential_record.display_credential.format + == CredentialFormats::JwtVcJson(()) + { + let full_jwt_payload = + get_unverified_jwt_claims(&verifiable_credential_record.verifiable_credential) + .unwrap_or_default(); + // JWT_VC_JSON must be accessed from the vc values. + full_jwt_payload.get("vc").cloned().unwrap_or_else(|| { + debug!( + "JWT-VC-JSON is missing `vc` claims or is not a valid JSON value: {:?}", + full_jwt_payload + ); + serde_json::json!({}) + }) + } else { + debug!( + "Unhandled credential format: {:?}", + verifiable_credential_record.display_credential.format + ); + get_unverified_jwt_claims(&verifiable_credential_record.verifiable_credential) + .unwrap_or_default() + }; + + let credential_object = credential_data.as_object()?.clone(); + let decoded_presentations = + match DecodedPresentations::try_new(vec![credential_object]) { + Ok(decoded) => decoded, + Err(e) => { + debug!( + "Failed to decode credential into DecodedPresentations; id: {:?}, format: {:?}, error: {:?}", + verifiable_credential_record.display_credential.id, + verifiable_credential_record.display_credential.format, + e + ); + return None; + } + }; - let linked_verifiable_presentations: Vec<_> = validate_linked_verifiable_presentations(&resolver, did) - .await - .into_iter() - .flatten() - .filter(|linked_verifiable_credential| { - linked_verifiable_credential - .issuer_linked_domains - .iter() - .any(|domain| trusted_domains.contains(domain)) - }) - .collect(); + let credential_query_satisfied = + evaluate_credential_query(credential_query_from_request, &decoded_presentations); + credential_query_satisfied.then_some(verifiable_credential_record.display_credential.id.clone()) + }) + }) + .collect(); - debug!( - "Validated {} linked verifiable presentations", - linked_verifiable_presentations.len() - ); + info!("uuids of VCs that can fulfill the request: {uuids:?}"); - drop(state_guard); + drop(state_guard); - return Ok(AppState { + if let Some(CurrentUserPrompt::AcceptConnection { client_metadata, .. }) = &state.current_user_prompt { + // TODO: communicate when no credentials are available. + if !uuids.is_empty() { + Ok(AppState { core_utils: CoreUtils { - active_flow: Some(ActiveFlow::Siopv2 { - authorization_request: siopv2_authorization_request.clone().into(), + active_flow: Some(ActiveFlow::Oid4vp { + authorization_request: oid4vp_authorization_request.clone(), + is_interactive: false, }), ..state.core_utils }, - current_user_prompt: Some(CurrentUserPrompt::AcceptConnection { - client_name, - logo_uri, - redirect_uri, - previously_connected, - domain_validation, - linked_verifiable_presentations, + current_user_prompt: Some(CurrentUserPrompt::ShareCredentials { + client_name: client_metadata.client_name.clone(), + logo_uri: client_metadata.logo_uri.clone(), + options: uuids, + is_interactive: false, }), ..state - }); - } else if let Result::Ok(oid4vp_authorization_request) = - AuthorizationRequest::>::from_generic(&generic_authorization_request) - { - let verifiable_credentials = stronghold_manager.values().map_err(StrongholdValuesError)?.unwrap(); - debug!( - "Retrieved {} credentials from stronghold for OID4VP query", - verifiable_credentials.len() - ); - - // TODO: Move most of this logic to `openid4vc` crates. - let dcql_query = &oid4vp_authorization_request.body.extension.dcql_query; - let uuids: Vec = dcql_query - .credentials - .iter() - .filter_map(|credential_query_from_request| { - verifiable_credentials.iter().find_map(|verifiable_credential_record| { - let credential_data: Value = if credential_query_from_request.format == Format::DcSdJwt - && verifiable_credential_record.display_credential.format == CredentialFormats::DcSdJwt(()) - { - serde_json::json!(verifiable_credential_record - .verifiable_credential - .as_str()? - .parse::() - .ok()? - .into_disclosed_object(&Sha256Hasher::new()) - .ok()?) - } else if credential_query_from_request.format == Format::VcSdJwt - && verifiable_credential_record.display_credential.format == CredentialFormats::VcSdJwt(()) - { - serde_json::json!(verifiable_credential_record - .verifiable_credential - .as_str()? - .parse::() - .ok()? - .into_disclosed_object(&Sha256Hasher::new()) - .ok()?) - } else if credential_query_from_request.format == Format::JwtVcJson - && verifiable_credential_record.display_credential.format - == CredentialFormats::JwtVcJson(()) - { - let full_jwt_payload = - get_unverified_jwt_claims(&verifiable_credential_record.verifiable_credential) - .unwrap_or_default(); - // JWT_VC_JSON must be accessed from the vc values. - full_jwt_payload.get("vc").cloned().unwrap_or_else(|| { - debug!( - "JWT-VC-JSON is missing `vc` claims or is not a valid JSON value: {:?}", - full_jwt_payload - ); - serde_json::json!({}) - }) - } else { - debug!( - "Unhandled credential format: {:?}", - verifiable_credential_record.display_credential.format - ); - get_unverified_jwt_claims(&verifiable_credential_record.verifiable_credential) - .unwrap_or_default() - }; - - let credential_object = credential_data.as_object()?.clone(); - let decoded_presentations = - match DecodedPresentations::try_new(vec![credential_object]) { - Ok(decoded) => decoded, - Err(e) => { - debug!( - "Failed to decode credential into DecodedPresentations; id: {:?}, format: {:?}, error: {:?}", - verifiable_credential_record.display_credential.id, - verifiable_credential_record.display_credential.format, - e - ); - return None; - } - }; - - let credential_query_satisfied = - evaluate_credential_query(credential_query_from_request, &decoded_presentations); - credential_query_satisfied.then_some(verifiable_credential_record.display_credential.id.clone()) - }) - }) - .collect(); - - info!("Evaluated {} VCs matching OID4VP request", uuids.len()); - debug!("Matched VC UUIDs: {uuids:?}"); - - let OID4VPClientMetadata { - client_name, - logo_uri, - connection_url: _, - client_id: _, - } = get_oid4vp_client_name_and_logo_uri(&oid4vp_authorization_request); - - info!("OID4VP client metadata parsed: client_name={client_name:?}, logo_uri={logo_uri:?}"); - - if let Some(logo_uri_str) = logo_uri.clone() { - download_logo(&logo_uri_str).await; - } else { - warn!("No logo URI found"); - } - - // TODO: communicate when no credentials are available. - if !uuids.is_empty() { - drop(state_guard); - return Ok(AppState { - core_utils: CoreUtils { - active_flow: Some(ActiveFlow::Oid4vp { - authorization_request: oid4vp_authorization_request.clone().into(), - is_interactive: false, - }), - ..state.core_utils - }, - current_user_prompt: Some(CurrentUserPrompt::ShareCredentials { - client_name, - logo_uri, - options: uuids, - is_interactive: false, - }), - ..state - }); - } else { - return Err(NoMatchingCredentialError); - } + }) } else { - return Err(InvalidAuthorizationRequest(Box::new(generic_authorization_request))); - }; + Err(NoMatchingCredentialError) + } + } else { + warn!("Unexpected state: No CurrentUserPrompt::AcceptConnection found when reading authorization request"); + Ok(state) } - - Ok(state) } diff --git a/identity-wallet/src/state/qr_code/reducers/read_credential_offer.rs b/identity-wallet/src/state/qr_code/reducers/read_credential_offer.rs index 3598e3d6c..50e64ed8f 100644 --- a/identity-wallet/src/state/qr_code/reducers/read_credential_offer.rs +++ b/identity-wallet/src/state/qr_code/reducers/read_credential_offer.rs @@ -3,143 +3,90 @@ use std::collections::HashMap; use crate::{ error::AppError::{self, *}, state::{ - actions::{listen, Action}, - core_utils::{helpers::download_logo, ActiveFlow, CoreUtils, Oid4vciStage}, - qr_code::actions::qrcode_scanned::QrCodeScanned, + actions::Action, + core_utils::{helpers::download_logo, ActiveFlow}, user_prompt::CurrentUserPrompt, AppState, }, }; use log::{debug, info, warn}; -use oid4vc::oid4vci::{ - credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject, - credential_offer::{CredentialOffer, CredentialOfferParameters}, -}; +use oid4vc::oid4vci::credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject; + +/// Sets the `CredentialOffer` prompt after the `AcceptConnetion` prompt was accepted, triggering the `ConnectionAccepted` action. +/// Accepting the prompt set in this reducer would result in the `CredentialOffersSelected` action, which is handled by `handle_credential_offer`. +pub async fn read_credential_offer(state: AppState, _action: Action) -> Result { + info!("read_credential_offer"); -#[tracing::instrument(skip_all, err)] -pub async fn read_credential_offer(state: AppState, action: Action) -> Result { // Sometimes reducers are connected to actions that they shouldn't execute // Therefore its also checked if it can parse to credential offer query // TODO find a better way to connect to the right reducer - if let Some(credential_offer_uri) = - listen::(action).and_then(|payload| payload.form_urlencoded.parse::().ok()) - { - let state_guard = state.core_utils.managers.lock().await; - let wallet = &state_guard - .identity_manager - .as_ref() - .ok_or(MissingManagerError("identity"))? - .wallet; - - let credential_offer: CredentialOfferParameters = match credential_offer_uri { - CredentialOffer::CredentialOffer(credential_offer) => *credential_offer, - CredentialOffer::CredentialOfferUri(credential_offer_uri) => wallet - .get_credential_offer(credential_offer_uri) - .await - .map_err(GetCredentialOfferError)?, - }; - - // The credential offer contains a credential issuer url. - let credential_issuer_url = credential_offer.credential_issuer.clone(); - debug!("Parsed credential offer parameters: {credential_offer:?}"); - - let credential_issuer_metadata = wallet - .get_credential_issuer_metadata(credential_issuer_url.clone()) - .await - .ok(); - - debug!("Fetched credential issuer metadata: {credential_issuer_metadata:?}"); - - let credential_configurations: HashMap = credential_offer - .credential_configuration_ids - .iter() - .filter_map(|credential_configuration_id| { - credential_issuer_metadata - .as_ref() - .and_then(|credential_issuer_metadata| { - credential_issuer_metadata - .credential_configurations_supported - .get(credential_configuration_id) - .map(|credential_configuration| { - (credential_configuration_id.clone(), credential_configuration.clone()) - }) - }) - }) - .collect(); - - // Get the credential issuer display if present. - let display = credential_issuer_metadata - .as_ref() - .and_then(|credential_issuer_metadata| { - credential_issuer_metadata - .display - .as_ref() - .map(|display| display.first().cloned()) - }) - .flatten(); - - let tx_code = credential_offer - .grants - .as_ref() - .and_then(|grants| grants.pre_authorized_code.clone()) - .and_then(|pre_authorized_code| pre_authorized_code.tx_code); - - // Get the credential issuer name and logo uri or use the credential issuer url. - let (issuer_name, logo_uri) = display - .map(|display| { - let issuer_name = display["name"] - .as_str() - // TODO(NGDIL): remove this NGDIL specific logic once: https://staging.api.ngdil.com/.well-known/openid-credential-issuer is fixed. - .or_else(|| display["client_name"].as_str()) - .map(ToString::to_string) - .unwrap_or(credential_issuer_url.to_string()); - - let logo_uri = display["logo"]["uri"] - .as_str() - // TODO(NGDIL): remove this NGDIL specific logic once: https://staging.api.ngdil.com/.well-known/openid-credential-issuer is fixed. - .or_else(|| display["logo_uri"].as_str()) - .map(ToString::to_string); - - (issuer_name, logo_uri) - }) - .unwrap_or((credential_issuer_url.to_string(), None)); - - info!( - "Processed credential offer for `{issuer_name}` ({credential_issuer_url}) with {} configurations (has_tx_code: {})", - credential_configurations.len(), - tx_code.is_some() - ); - - download_credential_logos(&credential_configurations).await; - - if let Some(logo_uri_str) = &logo_uri { - download_logo(logo_uri_str).await; - } else { - warn!("No logo URI found"); - } - - drop(state_guard); - return Ok(AppState { + let credential_offer = match state.core_utils.active_flow.clone() { + Some(ActiveFlow::Oid4vciOffer { credential_offer, .. }) => credential_offer, + // Not a OID4VCI flow, let other reducers handle this action. + _ => return Ok(state), + }; + + let state_guard = state.core_utils.managers.lock().await; + let wallet = &state_guard + .identity_manager + .as_ref() + .ok_or(MissingManagerError("identity"))? + .wallet; + + // The credential offer contains a credential issuer url. + let credential_issuer_url = credential_offer.credential_issuer.clone(); + + info!("credential issuer url: {credential_issuer_url:?}"); + + let credential_issuer_metadata = wallet + .get_credential_issuer_metadata(credential_issuer_url.clone()) + .await + .ok(); + + info!("credential issuer metadata: {credential_issuer_metadata:?}"); + + let credential_configurations: HashMap = credential_offer + .credential_configuration_ids + .iter() + .filter_map(|credential_configuration_id| { + credential_issuer_metadata + .as_ref() + .and_then(|credential_issuer_metadata| { + credential_issuer_metadata + .credential_configurations_supported + .get(credential_configuration_id) + .map(|credential_configuration| { + (credential_configuration_id.clone(), credential_configuration.clone()) + }) + }) + }) + .collect(); + + let tx_code = credential_offer + .grants + .as_ref() + .and_then(|grants| grants.pre_authorized_code.clone()) + .and_then(|pre_authorized_code| pre_authorized_code.tx_code); + + download_credential_logos(&credential_configurations).await; + + drop(state_guard); + + if let Some(CurrentUserPrompt::AcceptConnection { client_metadata, .. }) = &state.current_user_prompt { + Ok(AppState { current_user_prompt: Some(CurrentUserPrompt::CredentialOffer { - issuer_name, - logo_uri: logo_uri.clone(), + issuer_name: client_metadata.client_name.clone(), + logo_uri: client_metadata.logo_uri.clone(), credential_configurations, tx_code, }), - core_utils: CoreUtils { - active_flow: Some(ActiveFlow::Oid4vciOffer { - stage: Oid4vciStage::OfferReceived, - credential_offer: Box::new(credential_offer), - logo_uri, - }), - ..state.core_utils - }, ..state - }); + }) + } else { + warn!("Unexpected state: No current user prompt found when reading credential offer"); + Ok(state) } - - Ok(state) } /// Downloads all the Credential logos. diff --git a/identity-wallet/src/state/search/reducers/search_query.rs b/identity-wallet/src/state/search/reducers/search_query.rs index 0cb2860e6..c789b7f3b 100644 --- a/identity-wallet/src/state/search/reducers/search_query.rs +++ b/identity-wallet/src/state/search/reducers/search_query.rs @@ -121,6 +121,7 @@ mod tests { id: "1".to_string(), format: CredentialFormats::default(), issuer_name: "Example Organization".to_string(), + issuer_logo_uri: None, data: serde_json::json!({"last_name": "Ferris"}), display_claims: vec![], metadata: CredentialMetadata { @@ -137,6 +138,7 @@ mod tests { id: "2".to_string(), format: CredentialFormats::default(), issuer_name: "Example Organization".to_string(), + issuer_logo_uri: None, data: serde_json::json!({"last_name": "John"}), display_claims: vec![], metadata: CredentialMetadata { @@ -153,6 +155,7 @@ mod tests { id: "3".to_string(), format: CredentialFormats::default(), issuer_name: "John Organization".to_string(), + issuer_logo_uri: None, data: serde_json::json!({"last_name": "Ferris"}), display_claims: vec![], metadata: CredentialMetadata { diff --git a/identity-wallet/src/state/user_prompt.rs b/identity-wallet/src/state/user_prompt.rs index 0cc27b323..b3a47fbd7 100644 --- a/identity-wallet/src/state/user_prompt.rs +++ b/identity-wallet/src/state/user_prompt.rs @@ -1,10 +1,11 @@ +use identity_iota::did::CoreDID; use oid4vc::oid4vci::credential_issuer::credential_configurations_supported::CredentialConfigurationsSupportedObject; use oid4vc::oid4vci::credential_offer::TxCodeConstraints; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use ts_rs::TS; -use crate::state::did::validate_domain_linkage::ValidationResult; +use crate::state::{core_utils::history_event::HistoryEvent, did::validate_domain_linkage::ValidationResult}; use super::did::validate_linked_verifiable_presentations::LinkedVerifiableCredentialData; @@ -25,13 +26,18 @@ pub enum CurrentUserPrompt { PasswordRequired, #[serde(rename = "accept-connection")] AcceptConnection { - client_name: String, + client_metadata: ClientMetadata, + // The connection_data field is optional, None means that the user has never interacted with this connection before. #[ts(optional)] - logo_uri: Option, - redirect_uri: String, - previously_connected: bool, + #[serde(skip_serializing_if = "Option::is_none")] + connection_data: Option, domain_validation: Box, - linked_verifiable_presentations: Vec, + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + linked_verifiable_presentations: Option>, + #[ts(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + ecosystems: Option>, }, #[serde(rename = "credential-offer")] CredentialOffer { @@ -56,9 +62,49 @@ pub enum CurrentUserPrompt { }, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, TS)] +#[ts(export, export_to = "bindings/user_prompt/ClientMetadata.ts")] +pub struct ClientMetadata { + pub client_name: String, + pub logo_uri: Option, + pub connection_url: String, + pub redirect_uri: Option, + #[ts(type = "string")] + pub client_id: CoreDID, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, TS)] +#[ts(export, export_to = "bindings/user_prompt/ConnectionData.ts")] +pub struct ConnectionData { + pub first_interacted_at: String, + pub last_interacted_at: String, + pub interactions: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, TS)] +#[ts(export, export_to = "bindings/user_prompt/EcosystemProfile.ts")] +pub struct EcosystemProfile { + pub logo_uri: Option, + pub name: String, + pub description: Option, + pub ecosystem_leader: Member, + pub member_count: usize, + pub members: Vec, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, TS)] +#[ts(export, export_to = "bindings/user_prompt/Member.ts")] +pub struct Member { + pub logo_uri: Option, + pub name: String, + pub description: Option, + pub domain: String, +} + #[cfg(test)] mod tests { use super::*; + use crate::state::did::validate_domain_linkage::ValidationStatus; #[test] fn test_serialize_current_user_prompt() { @@ -76,16 +122,28 @@ mod tests { ); let prompt = CurrentUserPrompt::AcceptConnection { - client_name: "Test Client".to_string(), - logo_uri: None, - redirect_uri: "https://example.com".to_string(), - previously_connected: false, - domain_validation: Default::default(), + client_metadata: ClientMetadata { + client_name: "Test Client".to_string(), + logo_uri: None, + connection_url: "https://example.com".to_string(), + redirect_uri: Some("https://example.com".to_string()), + client_id: "did:example:123".parse().unwrap(), + }, + connection_data: None, + domain_validation: Box::new(ValidationResult { + status: ValidationStatus::default(), + url: "https://example.com".parse().unwrap(), + name: None, + logo_uri: None, + issuance_date: None, + message: None, + }), linked_verifiable_presentations: Default::default(), + ecosystems: None, }; 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"},"linked_verifiable_presentations":[]}"# + r#"{"type":"accept-connection","client_metadata":{"client_name":"Test Client","logo_uri":null,"connection_url":"https://example.com","redirect_uri":"https://example.com","client_id":"did:example:123"},"domain_validation":{"status":"Unknown","url":"https://example.com/"}}"# ); } } diff --git a/identity-wallet/src/state/verified_data/reducers/mod.rs b/identity-wallet/src/state/verified_data/reducers/mod.rs index 9957ca641..f937b9ffc 100644 --- a/identity-wallet/src/state/verified_data/reducers/mod.rs +++ b/identity-wallet/src/state/verified_data/reducers/mod.rs @@ -8,7 +8,7 @@ use crate::{ http_client::get_http_client, state::{ actions::{listen, Action}, - qr_code::{actions::qrcode_scanned::QrCodeScanned, reducers::read_credential_offer::read_credential_offer}, + qr_code::{actions::qrcode_scanned::QrCodeScanned, reducers::accept_connection::accept_connection}, verified_data::{ actions::{RedeemCode, ResetEmailVerification, SendVerificationEmail, ServiceHealthCheck}, EmailVerification, @@ -135,7 +135,7 @@ pub async fn redeem_code(state: AppState, action: Action) -> Result + /** + * L​a​s​t​ ​i​n​t​e​r​a​c​t​i​o​n​:​ ​{​d​u​r​a​t​i​o​n​} + * @param {string} duration + */ + LAST_INTERACTION: RequiredParams<'duration'> + /** + * I​n​t​e​r​a​c​t​i​o​n​s + */ + INTERACTIONS: string + /** + * S​h​a​r​e​d​ ​D​a​t​a + */ + SHARED_DATA: string + /** + * R​e​c​e​i​v​e​d​ ​D​a​t​a + */ + RECEIVED_DATA: string /** * A​c​c​e​p​t​ ​c​o​n​n​e​c​t​i​o​n */ ACCEPT: string + /** + * C​e​r​t​i​f​i​c​a​t​i​o​n​s + */ + CERTIFICATIONS: string + /** + * C​e​r​t​i​f​i​c​a​t​i​o​n + */ + CERTIFICATION: string + /** + * {​c​o​u​n​t​}​ ​{​{​C​e​r​t​i​f​i​c​a​t​i​o​n​|​C​e​r​t​i​f​i​c​a​t​i​o​n​s​}​} + * @param {number} count + */ + CERTIFICATION_COUNT: RequiredParams<'count'> + /** + * S​h​o​w​ ​m​o​r​e + */ + SHOW_MORE: string + /** + * S​h​o​w​ ​l​e​s​s + */ + SHOW_LESS: string } SHARE_CREDENTIALS: { /** @@ -1494,25 +1537,17 @@ type RootTranslation = { } DOMAIN_LINKAGE: { /** - * V​e​r​i​f​i​e​d​ ​w​e​b​s​i​t​e - */ - TITLE: string - /** - * U​n​i​M​e​ ​s​u​c​c​e​s​s​f​u​l​l​y​ ​v​e​r​i​f​i​e​d​ ​t​h​e​ ​i​d​e​n​t​i​t​y​ ​t​o​ ​p​r​o​v​i​d​e​ ​y​o​u​ ​w​i​t​h​ ​a​ ​s​e​c​u​r​e​ ​l​o​g​i​n​. - */ - SUCCESS: string - /** - * U​n​i​M​e​ ​c​o​u​l​d​ ​n​o​t​ ​v​e​r​i​f​y​ ​t​h​e​ ​l​i​n​k​a​g​e​ ​o​f​ ​t​h​e​ ​i​d​e​n​t​i​t​y​ ​t​o​ ​t​h​e​ ​d​o​m​a​i​n​. + * V​e​r​i​f​i​e​d​ ​D​o​m​a​i​n */ - FAILURE: string + PILL_VERIFIED: string /** - * U​n​i​M​e​ ​c​o​u​l​d​ ​n​o​t​ ​f​i​n​d​ ​a​n​y​ ​p​r​o​o​f​ ​o​f​ ​t​h​e​ ​d​o​m​a​i​n​'​s​ ​a​s​s​o​c​i​a​t​e​d​ ​i​d​e​n​t​i​t​y​. + * U​n​t​r​u​s​t​e​d​ ​D​o​m​a​i​n */ - UNKNOWN: string + PILL_UNTRUSTED: string /** - * P​r​o​c​e​e​d​ ​w​i​t​h​ ​c​a​u​t​i​o​n​! + * U​n​v​e​r​i​f​i​e​d​ ​D​o​m​a​i​n */ - CAUTION: string + PILL_UNVERIFIED: string } ERROR: { /** @@ -2410,13 +2445,53 @@ export type TranslationFunctions = { */ DESCRIPTION: () => LocalizedString /** - * Connected previously + * Known connection */ - CONNECTED_PREVIOUSLY: () => LocalizedString + KNOWN_CONNECTION: () => LocalizedString + /** + * First interaction: {duration} + */ + FIRST_INTERACTION: (arg: { duration: string }) => LocalizedString + /** + * Last interaction: {duration} + */ + LAST_INTERACTION: (arg: { duration: string }) => LocalizedString + /** + * Interactions + */ + INTERACTIONS: () => LocalizedString + /** + * Shared Data + */ + SHARED_DATA: () => LocalizedString + /** + * Received Data + */ + RECEIVED_DATA: () => LocalizedString /** * Accept connection */ ACCEPT: () => LocalizedString + /** + * Certifications + */ + CERTIFICATIONS: () => LocalizedString + /** + * Certification + */ + CERTIFICATION: () => LocalizedString + /** + * {count} {{Certification|Certifications}} + */ + CERTIFICATION_COUNT: (arg: { count: number }) => LocalizedString + /** + * Show more + */ + SHOW_MORE: () => LocalizedString + /** + * Show less + */ + SHOW_LESS: () => LocalizedString } SHARE_CREDENTIALS: { /** @@ -3031,25 +3106,17 @@ export type TranslationFunctions = { } DOMAIN_LINKAGE: { /** - * Verified website - */ - TITLE: () => LocalizedString - /** - * UniMe successfully verified the identity to provide you with a secure login. - */ - SUCCESS: () => LocalizedString - /** - * UniMe could not verify the linkage of the identity to the domain. + * Verified Domain */ - FAILURE: () => LocalizedString + PILL_VERIFIED: () => LocalizedString /** - * UniMe could not find any proof of the domain's associated identity. + * Untrusted Domain */ - UNKNOWN: () => LocalizedString + PILL_UNTRUSTED: () => LocalizedString /** - * Proceed with caution! + * Unverified Domain */ - CAUTION: () => LocalizedString + PILL_UNVERIFIED: () => LocalizedString } ERROR: { /** diff --git a/unime/src/i18n/nl-NL/index.ts b/unime/src/i18n/nl-NL/index.ts index 362daf53c..bb55d4926 100644 --- a/unime/src/i18n/nl-NL/index.ts +++ b/unime/src/i18n/nl-NL/index.ts @@ -333,8 +333,18 @@ const nl_NL = { NAVBAR_TITLE: 'Credential Aanvraag', TITLE: 'Nieuwe connectie', DESCRIPTION: 'Accepteer alleen nieuwe connecties die je herkent en vertrouwt', - CONNECTED_PREVIOUSLY: 'Eerder verbonden', + KNOWN_CONNECTION: 'Bekende connectie', + FIRST_INTERACTION: 'Eerste interactie: {duration}', + LAST_INTERACTION: 'Laatste interactie: {duration}', + INTERACTIONS: 'Interacties', + SHARED_DATA: 'Gedeelde gegevens', + RECEIVED_DATA: 'Ontvangen gegevens', ACCEPT: 'Accepteer connectie', + CERTIFICATIONS: 'Certificeringen', + CERTIFICATION: 'Certificering', + CERTIFICATION_COUNT: '{count} {{count:Certificering|Certificeringen}}', + SHOW_MORE: 'Meer tonen', + SHOW_LESS: 'Minder tonen', }, SHARE_CREDENTIALS: { NAVBAR_TITLE: 'Gegevens Delen', @@ -559,11 +569,9 @@ const nl_NL = { }, }, DOMAIN_LINKAGE: { - 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.', - CAUTION: 'Ga voorzichtig te werk!', + PILL_VERIFIED: 'Geverifieerd domein', + PILL_UNTRUSTED: 'Niet-vertrouwd domein', + PILL_UNVERIFIED: 'Niet-geverifieerd domein', }, ERROR: { TITLE: 'Oeps!', diff --git a/unime/src/i18n/sv-FI/index.ts b/unime/src/i18n/sv-FI/index.ts index 0b1c15c4e..385dd1ee3 100644 --- a/unime/src/i18n/sv-FI/index.ts +++ b/unime/src/i18n/sv-FI/index.ts @@ -333,8 +333,18 @@ const sv_FI = { NAVBAR_TITLE: 'Anslutningsförfrågan', TITLE: 'Ny anslutning', DESCRIPTION: 'Acceptera bara anslutningar du känner igen och litar på', - CONNECTED_PREVIOUSLY: 'Tidigare ansluten', + KNOWN_CONNECTION: 'Känd anslutning', + FIRST_INTERACTION: 'Första interaktionen: {duration}', + LAST_INTERACTION: 'Senaste interaktionen: {duration}', + INTERACTIONS: 'Interaktioner', + SHARED_DATA: 'Delade data', + RECEIVED_DATA: 'Mottagna data', ACCEPT: 'Acceptera anslutning', + CERTIFICATIONS: 'Certifieringar', + CERTIFICATION: 'Certifiering', + CERTIFICATION_COUNT: '{count} {{count:Certifiering|Certifieringar}}', + SHOW_MORE: 'Visa mer', + SHOW_LESS: 'Visa mindre', }, SHARE_CREDENTIALS: { NAVBAR_TITLE: 'Dela data', @@ -558,11 +568,9 @@ const sv_FI = { }, }, DOMAIN_LINKAGE: { - TITLE: 'Verifierad webbplats', - SUCCESS: 'UniMe verifierade identiteten för säker inloggning.', - FAILURE: 'UniMe kunde inte verifiera kopplingen mellan identitet och domän.', - UNKNOWN: 'UniMe hittade inget bevis på domänens identitet.', - CAUTION: 'Var försiktig!', + PILL_VERIFIED: 'Verifierad domän', + PILL_UNTRUSTED: 'Ej betrodd domän', + PILL_UNVERIFIED: 'Overifierad domän', }, ERROR: { TITLE: 'Hoppsan!', diff --git a/unime/src/lib/components/StatusIndicator.svelte b/unime/src/lib/components/StatusIndicator.svelte deleted file mode 100644 index 9373b8967..000000000 --- a/unime/src/lib/components/StatusIndicator.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - -
-
-

- {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 506518c43..6d66144ab 100644 --- a/unime/src/lib/components/index.ts +++ b/unime/src/lib/components/index.ts @@ -18,7 +18,6 @@ export { default as SelectCountry } from './forms/SelectCountry.svelte'; export { default as SettingsCaretLink } from './SettingsCaretLink.svelte'; export { default as SettingsSwitch } from './SettingsSwitch.svelte'; export { default as SettingsValueLink } from './SettingsValueLink.svelte'; -export { default as StatusIndicator } from './StatusIndicator.svelte'; export { default as Switch } from './Switch.svelte'; export { default as Tabs } from './navigation/Tabs.svelte'; export { default as TextInput } from './forms/TextInput.svelte'; diff --git a/unime/src/lib/dev/mocks/accept-connection.ts b/unime/src/lib/dev/mocks/accept-connection.ts new file mode 100644 index 000000000..19ffc3031 --- /dev/null +++ b/unime/src/lib/dev/mocks/accept-connection.ts @@ -0,0 +1,246 @@ +import type { CredentialStatus } from '@bindings/credentials/CredentialStatus'; +import type { EventType } from '@bindings/history/EventType'; +import type { HistoryCredential } from '@bindings/history/HistoryCredential'; +import type { HistoryEvent } from '@bindings/history/HistoryEvent'; +import type { ClientMetadata } from '@bindings/user_prompt/ClientMetadata'; +import type { LinkedVerifiableCredentialData } from '@bindings/user_prompt/LinkedVerifiableCredentialData'; +import type { ValidationStatus } from '@bindings/user_prompt/ValidationStatus'; + +import type { AcceptConnectionPrompt } from './resolve'; + +const base: AcceptConnectionPrompt = { + type: 'accept-connection', + client_metadata: { + client_name: 'BestDex', + logo_uri: 'https://bestdex.com/logo.png', + connection_url: 'https://www.bestdex.com', + redirect_uri: 'https://www.bestdex.com/callback', + // Always a DID: the backend rejects a client_id it cannot parse as one. + client_id: 'did:web:bestdex.com', + }, + domain_validation: { status: 'Success', url: 'https://www.bestdex.com/' }, + linked_verifiable_presentations: [], + ecosystems: [], +}; + +/** Overrides a single `client_metadata` field without flattening the rest of the prompt. */ +const withClientMetadata = (overrides: Partial): AcceptConnectionPrompt => ({ + ...base, + client_metadata: { ...base.client_metadata, ...overrides }, +}); + +/** Readable, stable ids: they end up in the detail route's URL. */ +const slug = (name: string) => + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + +const defaultClaims = (name: string, issuer?: string) => ({ + id: 'did:web:bestdex.com', + certificationName: name, + ...(issuer ? { certifyingBody: issuer } : {}), + validFrom: '2025-03-12T00:00:00Z', + validUntil: '2028-03-11T00:00:00Z', +}); + +const certification = ( + name: string, + issuer?: string, + domain?: string, + status: ValidationStatus = 'Success', + // `unknown` rather than a claims type: `data` is `any` on the wire, and some fixtures + // deliberately pass a malformed subject. + credentialSubject: unknown = undefined, + credential_status: CredentialStatus | undefined = undefined, +): LinkedVerifiableCredentialData => ({ + credential: { + id: slug(name), + format: { format: 'jwt_vc_json' }, + issuer_name: issuer ?? '', + issuer_logo_uri: null, + ...(credential_status ? { credential_status } : {}), + data: { + type: ['VerifiableCredential'], + issuer: 'did:web:iso.org', + credentialSubject: credentialSubject === undefined ? defaultClaims(name, issuer) : credentialSubject, + }, + // Empty for `jwt_vc_json`: display claims come from issuer metadata in a credential + // offer, which a linked verifiable presentation never has. `DefaultRenderer` falls + // back to iterating `credentialSubject`, which is the path this whole page relies on. + display_claims: [], + metadata: { is_favorite: false, date_added: '', date_issued: '2025-03-12T00:00:00Z' }, + display_name: name, + }, + issuer_domain_validations: domain ? [{ status, url: `https://${domain}/`, ...(issuer ? { name: issuer } : {}) }] : [], +}); + +/** Marks a certification as having an issuer logo the backend downloaded. */ +const withIssuerLogo = ( + certification: LinkedVerifiableCredentialData, + url: string, +): LinkedVerifiableCredentialData => ({ + ...certification, + credential: { ...certification.credential, issuer_logo_uri: url }, +}); + +const historyCredential = (title: string): HistoryCredential => ({ + title, + issuer_name: 'BestDex', + id: slug(title), +}); + +const interaction = (event_type: EventType, date: string, credentials: HistoryCredential[] = []): HistoryEvent => ({ + connection_id: 'did:web:bestdex.com', + connection_name: 'BestDex', + event_type, + date, + credentials, +}); + +/** + * A connection we established, then received one credential from and shared data with twice. + * Four interactions, of which `ConnectionAdded` counts towards neither direction tile. + */ +const interactions: HistoryEvent[] = [ + interaction('ConnectionAdded', '2023-04-28T10:12:00Z'), + interaction('CredentialsAdded', '2023-05-02T14:05:00Z', [historyCredential('Loyalty Card')]), + interaction('CredentialsShared', '2023-06-14T11:48:00Z', [historyCredential('National ID')]), + // One exchange carrying several credentials: still a single interaction. + interaction('CredentialsShared', '2023-07-28T09:30:00Z', [ + historyCredential('National ID'), + historyCredential('Proof of Address'), + ]), +]; + +const connected = { + first_interacted_at: '2023-04-28T10:12:00Z', + last_interacted_at: '2023-07-28T09:30:00Z', + interactions, +}; + +const certifications: LinkedVerifiableCredentialData[] = [ + certification('ISO 27001 Certified', 'Intl. Organization for Standardization', 'iso.org', 'Failure'), + certification('SOC 2 Type II', 'AICPA', 'aicpa.com'), + certification('eIDAS Qualified Trust Service Provider', 'European Commission', 'ec.europa.eu'), + certification('PCI DSS Level 1', 'PCI Security Standards Council', 'pcisecuritystandards.org', 'Unknown'), + certification('ISO 9001 Quality Management', 'Intl. Organization for Standardization', 'iso.org'), + certification('GDPR Compliance Attestation', 'European Data Protection Board', 'edpb.europa.eu'), + certification('NEN 7510 Information Security', 'Koninklijk Nederlands Normalisatie-instituut', 'nen.nl'), + certification('CSA STAR Level 2', 'Cloud Security Alliance', 'cloudsecurityalliance.org'), + certification('WebTrust for CAs', 'Chartered Professional Accountants of Canada', 'cpacanada.ca'), + certification('ETSI EN 319 401', 'European Telecommunications Standards Institute', 'etsi.org'), +]; + +export const mocks = { + // M1 + new: base, + known: { ...base, connection_data: connected }, + // Connected, but no data has moved either way: both direction tiles read zero. + 'known-no-data': { ...base, connection_data: { ...connected, interactions: interactions.slice(0, 1) } }, + untrusted: { + ...base, + domain_validation: { + status: 'Failure', + url: 'https://www.bestdex.com/', + message: 'No did-configuration.json found', + }, + }, + 'unknown-domain': { ...base, domain_validation: { status: 'Unknown', url: 'https://www.bestdex.com/' } }, + 'long-name': withClientMetadata({ + client_name: 'Stichting Nederlandse Organisatie voor Wetenschappelijk Onderzoek', + }), + 'no-logo': withClientMetadata({ logo_uri: null }), + 'no-domain': withClientMetadata({ connection_url: 'not a url' }), + + // M2 — certifications + 'certs-one': { ...base, linked_verifiable_presentations: certifications.slice(0, 1) }, + // Exactly PREVIEW_COUNT: the section fills up but shows no "Show more" link. + 'certs-preview': { ...base, linked_verifiable_presentations: certifications.slice(0, 3) }, + // Over PREVIEW_COUNT: the "Show more" link appears and the sub-route lists all ten. + 'certs-many': { ...base, linked_verifiable_presentations: certifications }, + // Revoked certification: the detail page's status tile turns red. + 'certs-revoked': { + ...base, + linked_verifiable_presentations: [ + certification('ISO 27001 Certified', 'Intl. Organization for Standardization', 'iso.org', 'Success', undefined, { + status: 'INVALID', + last_checked: '2026-08-24T09:30:00Z', + }), + ], + }, + // Known connection with certifications: the section starts collapsed behind a count, + // and "Show More" expands it into the section the other `certs-*` fixtures show. + 'known-certs': { + ...base, + connection_data: connected, + linked_verifiable_presentations: certifications.slice(0, 3), + }, + // Collapsed label in the singular. + 'known-certs-one': { + ...base, + connection_data: connected, + linked_verifiable_presentations: certifications.slice(0, 1), + }, + // Issuer name and domain both missing: the card must degrade to just the title. + 'certs-bare': { + ...base, + linked_verifiable_presentations: [certification('Unattributed Certification')], + }, + 'known-with-certs': { + ...base, + connection_data: connected, + linked_verifiable_presentations: certifications, + }, + + // M2 — certification detail pages + // Claims covering every `ClaimRenderer` branch: a country code, two timestamps, and + // plain text. `id` and `type` are in `DefaultRenderer`'s hide list and must not show up. + 'cert-claims-rich': { + ...base, + linked_verifiable_presentations: [ + certification('ISO 27001 Certified', 'Intl. Organization for Standardization', 'iso.org', 'Success', { + id: 'did:web:bestdex.com', + type: ['VerifiableCredential', 'CertificationCredential'], + legalName: 'BestDex B.V.', + certificationScope: 'Information Security Management System', + registrationNumber: 'NL-ISO-27001-88213', + country: 'NL', + validFrom: '2025-03-12T00:00:00Z', + validUntil: '2028-03-11T00:00:00Z', + }), + ], + }, + // A single claim beyond the hidden `id`: the detail page must not look broken. + 'cert-claims-sparse': { + ...base, + linked_verifiable_presentations: [ + certification('Minimal Certification', 'Some Authority', 'authority.example', 'Success', { + id: 'did:web:bestdex.com', + legalName: 'BestDex B.V.', + }), + ], + }, + // No `credentialSubject` at all. `DefaultRenderer` dereferences it unguarded, so the + // detail page has to stop before reaching it rather than white-screen the prompt. + 'cert-claims-missing': { + ...base, + linked_verifiable_presentations: [ + certification('Malformed Certification', 'Some Authority', 'authority.example', 'Success', null), + ], + }, + // An issuer logo the backend has resolved and downloaded. This still renders the fallback + // badge in DEV: `` looks for `assets/tmp/`, which only exists once the + // backend has written the file. Kept so the shape is represented. + 'cert-logo': { + ...base, + linked_verifiable_presentations: [ + withIssuerLogo( + certification('ISO 27001 Certified', 'Intl. Organization for Standardization', 'iso.org'), + 'https://iso.org/badge.png', + ), + ], + }, +} satisfies Record; + +export type MockName = keyof typeof mocks; diff --git a/unime/src/lib/dev/mocks/resolve.ts b/unime/src/lib/dev/mocks/resolve.ts new file mode 100644 index 000000000..18b4cbfbd --- /dev/null +++ b/unime/src/lib/dev/mocks/resolve.ts @@ -0,0 +1,45 @@ +import type { AppState } from '@bindings/AppState'; +import type { CurrentUserPrompt } from '@bindings/user_prompt/CurrentUserPrompt'; + +import { mocks } from './accept-connection'; + +export type AcceptConnectionPrompt = Extract; + +/** + * Returns the fixture named by `?mock=`, or `null` when the page is showing a real prompt. + */ +function selectMock(url: URL, appState: AppState): AcceptConnectionPrompt | null { + // `import.meta.env.DEV` is replaced with `false` at build time, making this branch + // unreachable in production. Note the fixtures are still present in the bundle: + // Rollup does not tree-shake them out, verified against `vite build` output. + if (import.meta.env.DEV) { + const name = url.searchParams.get('mock'); + if (appState.dev_mode !== 'Off' && name && name in mocks) { + return mocks[name as keyof typeof mocks]; + } + } + return null; +} + +/** + * True when the page is rendering a fixture rather than a real prompt. + * + * Gates the backend dispatches: a mocked page has no prompt for the backend to act on, + * so accepting or cancelling one must stay client-side. + */ +export function isMockPrompt(url: URL, appState: AppState): boolean { + return selectMock(url, appState) !== null; +} + +/** + * Returns the mock prompt named by `?mock=` when dev mode is on. + * + * Returns `null` when there is no active prompt, which happens after the user + * accepts or cancels and the backend clears it. + */ +export function resolveAcceptConnectionPrompt(url: URL, appState: AppState): AcceptConnectionPrompt | null { + const mock = selectMock(url, appState); + if (mock) return mock; + const prompt = appState.current_user_prompt; + return prompt?.type === 'accept-connection' ? prompt : null; +} diff --git a/unime/src/lib/icons/index.ts b/unime/src/lib/icons/index.ts index 9d982385c..efb369a57 100644 --- a/unime/src/lib/icons/index.ts +++ b/unime/src/lib/icons/index.ts @@ -68,6 +68,7 @@ export { default as SealCheckFillIcon } from '~icons/ph/seal-check-fill'; export { default as SealQuestionRegularIcon } from '~icons/ph/seal-question'; export { default as SealWarningDuotoneIcon } from '~icons/ph/seal-warning-duotone'; export { default as ShareFatFillIcon } from '~icons/ph/share-fat-fill'; +export { default as ShieldCheckRegularIcon } from '~icons/ph/shield-check'; export { default as ShieldCheckFillIcon } from '~icons/ph/shield-check-fill'; export { default as ShieldFillIcon } from '~icons/ph/shield-fill'; export { default as SignOutFillIcon } from '~icons/ph/sign-out-fill'; diff --git a/unime/src/lib/utils.test.ts b/unime/src/lib/utils.test.ts index 155b47094..9faee563a 100644 --- a/unime/src/lib/utils.test.ts +++ b/unime/src/lib/utils.test.ts @@ -117,6 +117,13 @@ describe('formatRelativeDateTime function', () => { expect(formatRelativeDateTime(twoDaysAgo.toISOString(), 'de-DE')).toEqual('Vorgestern'); }); + // The word-valued results are the ones a capital letter would spoil mid-sentence. + test('1 day ago en-GB, uncapitalized', () => { + const now = new Date(); + const oneDayAgo = new Date(now.setDate(now.getDate() - 1)); + expect(formatRelativeDateTime(oneDayAgo.toISOString(), 'en-GB', { capitalize: false })).toEqual('yesterday'); + }); + test('3 days ago de-DE', () => { const now = new Date(); const threeDaysAgo = new Date(now.setDate(now.getDate() - 3)); diff --git a/unime/src/lib/utils.ts b/unime/src/lib/utils.ts index 1f9bd1212..7e81923ef 100644 --- a/unime/src/lib/utils.ts +++ b/unime/src/lib/utils.ts @@ -88,7 +88,7 @@ export function formatDateTime(isoDate: string, locale: Locale, test = false) { }).format(new Date(isoDate)); } -export function formatRelativeDateTime(isoDate: string, locale: Locale) { +export function formatRelativeDateTime(isoDate: string, locale: Locale, { capitalize = true } = {}) { const date = new Date(isoDate); const now = new Date(); @@ -110,8 +110,9 @@ export function formatRelativeDateTime(isoDate: string, locale: Locale) { // Use Math.round for more accurate relative time. const relativeDateTime = relativeFormatter.format(Math.round(diffInSeconds / divisor), units[index]); - // Capitalize the first character. - return relativeDateTime.charAt(0).toUpperCase() + relativeDateTime.slice(1); + // Capitalize the first character. Never lower-case: languages that capitalize the word + // themselves (German nouns, for one) would come out wrong. + return capitalize ? relativeDateTime.charAt(0).toUpperCase() + relativeDateTime.slice(1) : relativeDateTime; } /** diff --git a/unime/src/lib/utils/history.test.ts b/unime/src/lib/utils/history.test.ts new file mode 100644 index 000000000..941300bdd --- /dev/null +++ b/unime/src/lib/utils/history.test.ts @@ -0,0 +1,71 @@ +import type { EventType } from '@bindings/history/EventType'; +import type { HistoryCredential } from '@bindings/history/HistoryCredential'; +import type { HistoryEvent } from '@bindings/history/HistoryEvent'; + +import { countInteractions, interactionDirection } from './history'; + +const credential = (title: string): HistoryCredential => ({ + title, + issuer_name: 'BestDex', + id: title.toLowerCase().replace(/\s+/g, '-'), +}); + +const event = (event_type: EventType, credentials: HistoryCredential[] = []): HistoryEvent => ({ + connection_id: 'did:web:bestdex.com', + connection_name: 'BestDex', + event_type, + date: '2023-07-28T09:30:00Z', + credentials, +}); + +describe('interactionDirection', () => { + test('classifies credentials we received as incoming', () => { + expect(interactionDirection('CredentialsAdded')).toBe('incoming'); + }); + + test('classifies credentials we shared as outgoing', () => { + expect(interactionDirection('CredentialsShared')).toBe('outgoing'); + }); + + test('gives establishing the connection no direction, since no data moved', () => { + expect(interactionDirection('ConnectionAdded')).toBe('none'); + }); +}); + +describe('countInteractions', () => { + test('counts no interactions', () => { + expect(countInteractions([])).toEqual({ total: 0, shared: 0, received: 0 }); + }); + + test('counts each direction, with the connection event only in the total', () => { + const counts = countInteractions([ + event('ConnectionAdded'), + event('CredentialsAdded', [credential('Diploma')]), + event('CredentialsShared', [credential('Diploma')]), + event('CredentialsShared', [credential('Diploma')]), + ]); + + expect(counts).toEqual({ total: 4, shared: 2, received: 1 }); + }); + + test('counts an exchange carrying several credentials as one interaction', () => { + const counts = countInteractions([ + event('CredentialsShared', [credential('Diploma'), credential('Passport'), credential('Drivers License')]), + ]); + + // Not 3: the tiles count exchanges, so `shared` can never exceed `total`. + expect(counts).toEqual({ total: 1, shared: 1, received: 0 }); + }); + + test('keeps shared and received within the total', () => { + const interactions = [ + event('ConnectionAdded'), + event('CredentialsShared', [credential('Diploma')]), + event('CredentialsAdded', [credential('Passport')]), + ]; + + const { total, shared, received } = countInteractions(interactions); + + expect(shared + received).toBeLessThanOrEqual(total); + }); +}); diff --git a/unime/src/lib/utils/history.ts b/unime/src/lib/utils/history.ts new file mode 100644 index 000000000..52864cc93 --- /dev/null +++ b/unime/src/lib/utils/history.ts @@ -0,0 +1,65 @@ +import type { EventType } from '@bindings/history/EventType'; +import type { HistoryEvent } from '@bindings/history/HistoryEvent'; + +/** + * Which way data moved during an interaction. + * + * `ConnectionAdded` only establishes the connection — the backend always pushes it with an empty + * `credentials` array — so it has no direction. + */ +export type InteractionDirection = 'incoming' | 'outgoing' | 'none'; + +/** + * The direction of a single history event. + * + * The declared return type keeps this exhaustive: if `EventType` ever gains a variant, this stops + * compiling ("function lacks ending return statement") rather than silently classifying the new + * variant as `none`. + */ +export function interactionDirection(eventType: EventType): InteractionDirection { + switch (eventType) { + case 'CredentialsAdded': + return 'incoming'; + case 'CredentialsShared': + return 'outgoing'; + case 'ConnectionAdded': + return 'none'; + } +} + +export interface InteractionCounts { + /** Every interaction, `ConnectionAdded` included. */ + total: number; + /** Interactions in which we sent credentials to the other party. */ + shared: number; + /** Interactions in which we received credentials from the other party. */ + received: number; +} + +/** + * Counts interactions per direction, for the summary tiles on the connection request prompt. + * + * These are counts of *events*, not of credentials. A single exchange can carry several credentials + * (the backend pushes one event with the whole set), so counting credentials could make `shared` and + * `received` exceed `total`. Counting events preserves `shared + received <= total`, with the + * difference being the `ConnectionAdded` events. + */ +export function countInteractions(interactions: HistoryEvent[]): InteractionCounts { + let shared = 0; + let received = 0; + + for (const interaction of interactions) { + switch (interactionDirection(interaction.event_type)) { + case 'outgoing': + shared += 1; + break; + case 'incoming': + received += 1; + break; + case 'none': + break; + } + } + + return { total: interactions.length, shared, received }; +} diff --git a/unime/src/lib/utils/url.ts b/unime/src/lib/utils/url.ts index 61d46cc3b..50c0cf488 100644 --- a/unime/src/lib/utils/url.ts +++ b/unime/src/lib/utils/url.ts @@ -8,3 +8,17 @@ export function isUrl(text: string): boolean { return false; } } + +/** + * The hostname of `text` (e.g. `iso.org`), or `undefined` when it does not parse as a URL. + * + * Backend fields typed `url::Url` serialize as absolute URLs (`https://iso.org/`), but the + * designs show a bare hostname. + */ +export function hostname(text: string): string | undefined { + try { + return new URL(text).hostname; + } catch { + return undefined; + } +} diff --git a/unime/src/routes/(app)/activity/utils.test.ts b/unime/src/routes/(app)/activity/utils.test.ts index 9ddec09be..e7c90a11d 100644 --- a/unime/src/routes/(app)/activity/utils.test.ts +++ b/unime/src/routes/(app)/activity/utils.test.ts @@ -6,6 +6,7 @@ const connection: Connection = { id: '0', url: '', name: '', + did: '', verified: false, first_interacted: '', last_interacted: '', diff --git a/unime/src/routes/+layout.svelte b/unime/src/routes/+layout.svelte index 6fa4dfa11..a95d787c6 100644 --- a/unime/src/routes/+layout.svelte +++ b/unime/src/routes/+layout.svelte @@ -112,7 +112,7 @@ redirectPath = `/${$appState.current_user_prompt.target}`; } // Prompt redirect. - else { + else if (!page.url.pathname.startsWith(`/prompt/${$appState.current_user_prompt.type}`)) { redirectPath = `/prompt/${$appState.current_user_prompt.type}`; } } @@ -199,7 +199,9 @@ // User prompt let type = $appState?.current_user_prompt?.type; - if (type && type !== 'redirect') { + // This runs on every state push, so skip it when already inside the prompt's + // subtree — otherwise sub-routes get bounced back to the prompt's root page. + if (type && type !== 'redirect' && !page.url.pathname.startsWith(`/prompt/${type}`)) { goto(`/prompt/${type}`); } } diff --git a/unime/src/routes/prompt/accept-connection/+layout.svelte b/unime/src/routes/prompt/accept-connection/+layout.svelte new file mode 100644 index 000000000..03f44c9fd --- /dev/null +++ b/unime/src/routes/prompt/accept-connection/+layout.svelte @@ -0,0 +1,30 @@ + + + diff --git a/unime/src/routes/prompt/accept-connection/+page.svelte b/unime/src/routes/prompt/accept-connection/+page.svelte index 6b352d9a9..ab72451bd 100644 --- a/unime/src/routes/prompt/accept-connection/+page.svelte +++ b/unime/src/routes/prompt/accept-connection/+page.svelte @@ -1,50 +1,72 @@
@@ -55,12 +77,15 @@ class="sticky top-0 z-10" /> -
+
{#if logo_uri} -
- +
+
{:else} @@ -69,17 +94,21 @@

{client_name}

-

- - {hostname} -

+ {#if domain} +

+ {domain} +

+ {/if} +
+ +
- -
- - {#if !previously_connected} -
+
+ {#if !connection_data} +
@@ -94,58 +123,82 @@
{/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 connection_data} +
+ + + +
+

+ {$LL.SCAN.CONNECTION_REQUEST.KNOWN_CONNECTION()} +

+

+ {$LL.SCAN.CONNECTION_REQUEST.FIRST_INTERACTION({ + duration: formatRelativeDateTime(connection_data.first_interacted_at, profile_settings.locale, { + capitalize: false, + }), + })} +

+

+ {$LL.SCAN.CONNECTION_REQUEST.LAST_INTERACTION({ + duration: formatRelativeDateTime(connection_data.last_interacted_at, profile_settings.locale, { + capitalize: false, + }), + })} +

+
- - - - {#each linked_verifiable_presentations as presentation} - {#if presentation.name} - {@const issuanceDate = - presentation.issuance_date && profile_settings.locale - ? formatDate(presentation.issuance_date, profile_settings.locale) + + + {/if} +
+ + + {#if certifications.length > 0} +
+ {#if collapsible} + + (certificationsExpanded = !certificationsExpanded)} + /> + {:else} + PREVIEW_COUNT + ? `/prompt/accept-connection/certifications${page.url.search}` : undefined} - {/if} - {/each} -
+ + {#if collapsible && !certificationsExpanded} + + {:else} +
+ + {#each collapsible ? certifications : certifications.slice(0, PREVIEW_COUNT) as certification} + + {/each} +
+ {/if} + + {/if}
-
+ {:else if href} + + {$LL.SCAN.CONNECTION_REQUEST.SHOW_MORE()} + + {/if} +
diff --git a/unime/src/routes/prompt/accept-connection/certifications/+page.svelte b/unime/src/routes/prompt/accept-connection/certifications/+page.svelte new file mode 100644 index 000000000..1e5639e91 --- /dev/null +++ b/unime/src/routes/prompt/accept-connection/certifications/+page.svelte @@ -0,0 +1,36 @@ + + +
+ history.back()} + class="sticky top-0 z-10" + /> + +
+ {#each certifications as certification} + + {/each} +
+
+ + diff --git a/unime/src/routes/prompt/accept-connection/certifications/+page.ts b/unime/src/routes/prompt/accept-connection/certifications/+page.ts new file mode 100644 index 000000000..7b546d3cb --- /dev/null +++ b/unime/src/routes/prompt/accept-connection/certifications/+page.ts @@ -0,0 +1,6 @@ +import type { PageLoad } from './$types'; + +// The list page has no bottom button bar, unlike the prompt page it comes from. +export const load = (async () => { + return { bgAltBottom: false }; +}) satisfies PageLoad; diff --git a/unime/src/routes/prompt/accept-connection/certifications/[id]/+page.svelte b/unime/src/routes/prompt/accept-connection/certifications/[id]/+page.svelte new file mode 100644 index 000000000..25442c94f --- /dev/null +++ b/unime/src/routes/prompt/accept-connection/certifications/[id]/+page.svelte @@ -0,0 +1,117 @@ + + +
+ history.back()} + class="sticky top-0 z-10" + /> + + {#if certification} +
+
+
+ {#if imageId} + + + + {:else} + + {/if} +
+ +
+

+ {certification.credential.display_name} +

+ {#if issuer} +

+ {$LL.CREDENTIAL.DETAILS.ISSUED_BY()} + {issuer} +

+ {/if} + {#if validation && domain} +
+

{domain}

+ + +
+ {/if} +
+
+ +
+ +
+ + {#if hasClaims} +
+ +
+ {/if} +
+ {/if} +
+ + diff --git a/unime/src/routes/prompt/accept-connection/certifications/[id]/+page.ts b/unime/src/routes/prompt/accept-connection/certifications/[id]/+page.ts new file mode 100644 index 000000000..b3c34deb6 --- /dev/null +++ b/unime/src/routes/prompt/accept-connection/certifications/[id]/+page.ts @@ -0,0 +1,9 @@ +import type { PageLoad } from './$types'; + +// Dynamic IDs are not known at build time. +export const prerender = false; + +// The detail page has no bottom button bar, unlike the prompt page it comes from. +export const load = (async () => { + return { bgAltBottom: false }; +}) satisfies PageLoad; diff --git a/unime/src/routes/prompt/accept-connection/certifications/[id]/CertificationOverview.svelte b/unime/src/routes/prompt/accept-connection/certifications/[id]/CertificationOverview.svelte new file mode 100644 index 000000000..2618cbc06 --- /dev/null +++ b/unime/src/routes/prompt/accept-connection/certifications/[id]/CertificationOverview.svelte @@ -0,0 +1,86 @@ + + + +
+
+ {#if credential.credential_status?.status === 'INVALID'} +

{$LL.CREDENTIAL.DETAILS.INVALID()}

+
+ +
+ {:else} + {$LL.CREDENTIAL.DETAILS.VALID()} +
+ +
+ {#if credential.metadata.date_issued} +
+ {formatDate(credential.metadata.date_issued, $appState.profile_settings.locale)} +
+ {/if} + {/if} +
+
+
{$LL.CREDENTIAL.DETAILS.ISSUED_BY()}
+
+ {#if issuerLogoUrl} + + Issuer logo + {:else} + + {/if} +
+ +
{determineIssuerName()}
+
+
diff --git a/unime/src/routes/prompt/credential-offer/+page.svelte b/unime/src/routes/prompt/credential-offer/+page.svelte index 7f094692e..8e093f638 100644 --- a/unime/src/routes/prompt/credential-offer/+page.svelte +++ b/unime/src/routes/prompt/credential-offer/+page.svelte @@ -66,13 +66,8 @@
{#if logo_uri} -
- +
+
{:else} diff --git a/unime/src/routes/prompt/share-credentials/+page.svelte b/unime/src/routes/prompt/share-credentials/+page.svelte index 8357b5154..fb07b6103 100644 --- a/unime/src/routes/prompt/share-credentials/+page.svelte +++ b/unime/src/routes/prompt/share-credentials/+page.svelte @@ -51,8 +51,8 @@
{#if logo_uri} -
- +
+
{:else}