diff --git a/Cargo.lock b/Cargo.lock index 266df0f0f..b4758274e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5586,6 +5586,7 @@ dependencies = [ "typetag", "unicode-normalization", "url", + "urlencoding", "uuid", "wiremock", ] diff --git a/identity-wallet/Cargo.toml b/identity-wallet/Cargo.toml index c7135f16d..1ff404141 100644 --- a/identity-wallet/Cargo.toml +++ b/identity-wallet/Cargo.toml @@ -62,6 +62,7 @@ typetag = "0.2" unicode-normalization = "0.1" url = "2.5" uuid = { version = "1.4", features = ["v4", "fast-rng", "serde"] } +urlencoding = "2.1.3" [dev-dependencies] serial_test.workspace = true diff --git a/identity-wallet/bindings/actions/Action.ts b/identity-wallet/bindings/actions/Action.ts index 7f28c1560..020a6fbe1 100644 --- a/identity-wallet/bindings/actions/Action.ts +++ b/identity-wallet/bindings/actions/Action.ts @@ -26,6 +26,7 @@ import type { ServiceHealthCheck } from "./ServiceHealthCheck"; import type { SetLocale } from "./SetLocale"; import type { SetPreferredDidMethod } from "./SetPreferredDidMethod"; import type { SetPreferredKeyType } from "./SetPreferredKeyType"; +import type { ShareToLinkedIn } from "./ShareToLinkedIn"; import type { ShowDevModeSetting } from "../dev_mode/ShowDevModeSetting"; import type { ToggleTrustListEntry } from "./ToggleTrustListEntry"; import type { UnlockStorage } from "./UnlockStorage"; @@ -33,4 +34,4 @@ import type { UpdateCredentialMetadata } from "./UpdateCredentialMetadata"; import type { UpdateProfileSettings } from "./UpdateProfileSettings"; import type { UpdateSortingPreference } from "./UpdateSortingPreference"; -export type Action = { "type": "[App] Get state" } | { "type": "[Storage] Unlock", payload: UnlockStorage, } | { "type": "[App] Reset" } | { "type": "[DID] Create new", payload: CreateNew, } | { "type": "[Settings] Set locale", payload: SetLocale, } | { "type": "[Settings] Update profile", payload: UpdateProfileSettings, } | { "type": "[QR Code] Scanned", payload: QrCodeScanned, } | { "type": "[Authenticate] Connection accepted" } | { "type": "[User Flow] Cancel", payload?: CancelUserFlow, } | { "type": "[DEV] Show DEV mode setting", payload: ShowDevModeSetting, } | { "type": "[DEV] Load DEV profile", payload: DevProfile, } | { "type": "[DEV] Toggle DEV mode" } | { "type": "[Authenticate] Credentials selected", payload: CredentialsSelected, } | { "type": "[Credential Offer] Selected", payload: CredentialOffersSelected, } | { "type": "[Credential Offer] Code received", payload: CodeReceived, } | { "type": "[Credential Metadata] Update", payload: UpdateCredentialMetadata, } | { "type": "[Credential] Delete", payload: DeleteCredential, } | { "type": "[Credential] Refresh status", payload: RefreshCredentialStatus, } | { "type": "[Credential] Refresh all statuses" } | { "type": "[User Journey] Cancel" } | { "type": "[Settings] Update sorting preference", payload: UpdateSortingPreference, } | { "type": "[Search] Query", payload: SearchQuery, } | { "type": "[Search] Add recent", payload: AddRecentSearch, } | { "type": "[Search] Delete recent", payload: DeleteRecentSearch, } | { "type": "[DID] Set preferred method", payload: SetPreferredDidMethod, } | { "type": "[Keys] Set preferred key type", payload: SetPreferredKeyType, } | { "type": "[Trust List] Add entry", payload: AddTrustListEntry, } | { "type": "[Trust List] Edit entry", payload: EditTrustListEntry, } | { "type": "[Trust List] Delete entry", payload: DeleteTrustListEntry, } | { "type": "[Trust List] Toggle entry", payload: ToggleTrustListEntry, } | { "type": "[Trust Lists] Add", payload: AddTrustList, } | { "type": "[Trust Lists] Edit", payload: EditTrustList, } | { "type": "[Trust Lists] Delete", payload: DeleteTrustList, } | { "type": "[Biometrics] Enable", payload: EnableBiometrics, } | { "type": "[Storage] Check password", payload: CheckPassword, } | { "type": "[Verified Data] Check service health", payload: ServiceHealthCheck, } | { "type": "[Verified Data] Send verification email", payload: SendVerificationEmail, } | { "type": "[Verified Data] Redeem code", payload: RedeemCode, } | { "type": "[Verified Data] Reset email verification" } | { "type": "[Credential] Self Issue", payload: SelfIssueCredential, }; \ No newline at end of file +export type Action = { "type": "[App] Get state" } | { "type": "[Storage] Unlock", payload: UnlockStorage, } | { "type": "[App] Reset" } | { "type": "[DID] Create new", payload: CreateNew, } | { "type": "[Settings] Set locale", payload: SetLocale, } | { "type": "[Settings] Update profile", payload: UpdateProfileSettings, } | { "type": "[QR Code] Scanned", payload: QrCodeScanned, } | { "type": "[Authenticate] Connection accepted" } | { "type": "[User Flow] Cancel", payload?: CancelUserFlow, } | { "type": "[DEV] Show DEV mode setting", payload: ShowDevModeSetting, } | { "type": "[DEV] Load DEV profile", payload: DevProfile, } | { "type": "[DEV] Toggle DEV mode" } | { "type": "[Authenticate] Credentials selected", payload: CredentialsSelected, } | { "type": "[Credential Offer] Selected", payload: CredentialOffersSelected, } | { "type": "[Credential Offer] Code received", payload: CodeReceived, } | { "type": "[Credential Metadata] Update", payload: UpdateCredentialMetadata, } | { "type": "[Credential] Delete", payload: DeleteCredential, } | { "type": "[Credential] Refresh status", payload: RefreshCredentialStatus, } | { "type": "[Credential] Refresh all statuses" } | { "type": "[User Journey] Cancel" } | { "type": "[Settings] Update sorting preference", payload: UpdateSortingPreference, } | { "type": "[Search] Query", payload: SearchQuery, } | { "type": "[Search] Add recent", payload: AddRecentSearch, } | { "type": "[Search] Delete recent", payload: DeleteRecentSearch, } | { "type": "[DID] Set preferred method", payload: SetPreferredDidMethod, } | { "type": "[Keys] Set preferred key type", payload: SetPreferredKeyType, } | { "type": "[Trust List] Add entry", payload: AddTrustListEntry, } | { "type": "[Trust List] Edit entry", payload: EditTrustListEntry, } | { "type": "[Trust List] Delete entry", payload: DeleteTrustListEntry, } | { "type": "[Trust List] Toggle entry", payload: ToggleTrustListEntry, } | { "type": "[Trust Lists] Add", payload: AddTrustList, } | { "type": "[Trust Lists] Edit", payload: EditTrustList, } | { "type": "[Trust Lists] Delete", payload: DeleteTrustList, } | { "type": "[Biometrics] Enable", payload: EnableBiometrics, } | { "type": "[Storage] Check password", payload: CheckPassword, } | { "type": "[Verified Data] Check service health", payload: ServiceHealthCheck, } | { "type": "[Verified Data] Send verification email", payload: SendVerificationEmail, } | { "type": "[Verified Data] Redeem code", payload: RedeemCode, } | { "type": "[Verified Data] Reset email verification" } | { "type": "[Credential] Self Issue", payload: SelfIssueCredential, } | { "type": "[Credential] Share to LinkedIn", payload: ShareToLinkedIn, }; \ No newline at end of file diff --git a/identity-wallet/bindings/actions/ShareToLinkedIn.ts b/identity-wallet/bindings/actions/ShareToLinkedIn.ts new file mode 100644 index 000000000..de49f59e6 --- /dev/null +++ b/identity-wallet/bindings/actions/ShareToLinkedIn.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 ShareToLinkedIn { id: string, } \ No newline at end of file diff --git a/identity-wallet/bindings/credentials/CredentialMetadata.ts b/identity-wallet/bindings/credentials/CredentialMetadata.ts index a0841f379..1c6e6bc4f 100644 --- a/identity-wallet/bindings/credentials/CredentialMetadata.ts +++ b/identity-wallet/bindings/credentials/CredentialMetadata.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 CredentialMetadata { is_favorite: boolean, date_added: string, date_issued: string, icon?: string, } \ No newline at end of file +export interface CredentialMetadata { is_favorite: boolean, date_added: string, date_issued: string, expiration_date: string | null, icon?: string, } \ No newline at end of file diff --git a/identity-wallet/bindings/credentials/DisplayCredential.ts b/identity-wallet/bindings/credentials/DisplayCredential.ts index 7f153b0da..9653956a1 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, } \ No newline at end of file +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 | null, } \ No newline at end of file diff --git a/identity-wallet/src/state/actions.rs b/identity-wallet/src/state/actions.rs index ebaa49979..dc8d51d1d 100644 --- a/identity-wallet/src/state/actions.rs +++ b/identity-wallet/src/state/actions.rs @@ -73,7 +73,7 @@ mod bindings { authorization_code_received::CodeReceived, credential_offers_selected::CredentialOffersSelected, credentials_selected::CredentialsSelected, delete_credential::DeleteCredential, refresh_credential_status::RefreshCredentialStatus, self_issue_credential::SelfIssueCredential, - update_credential_metadata::UpdateCredentialMetadata, + share_to_linkedin::ShareToLinkedIn, update_credential_metadata::UpdateCredentialMetadata, }, dev_mode::actions::{dev_profile::DevProfile, show_setting::ShowDevModeSetting}, did::actions::{set_preferred_keytype::SetPreferredKeyType, set_preferred_method::SetPreferredDidMethod}, @@ -181,5 +181,7 @@ mod bindings { ResetEmailVerification, #[serde(rename = "[Credential] Self Issue")] SelfIssueCredential { payload: SelfIssueCredential }, + #[serde(rename = "[Credential] Share to LinkedIn")] + ShareToLinkedIn { payload: ShareToLinkedIn }, } } diff --git a/identity-wallet/src/state/core_utils/helpers.rs b/identity-wallet/src/state/core_utils/helpers.rs index 25df5414d..e515631cf 100644 --- a/identity-wallet/src/state/core_utils/helpers.rs +++ b/identity-wallet/src/state/core_utils/helpers.rs @@ -65,8 +65,8 @@ pub async fn validate_jwt_vc_json( .ok_or(AppError::Error("Failed to resolve issuer DID".to_string()))?; validator - .validate::<_, Value>(&credential_jwt, &issuer_document, &options, FailFast::FirstError) - .map_err(|_| AppError::Error("Invalid jwt_vc_json".to_string())) + .validate::<_, Value>(&credential_jwt, &issuer_document, &options, FailFast::AllErrors) + .map_err(|e| AppError::Error(format!("Invalid jwt_vc_json: {e}"))) } /// Validate supported credential types against their corresponding JSON Schema. diff --git a/identity-wallet/src/state/credentials/actions/mod.rs b/identity-wallet/src/state/credentials/actions/mod.rs index 8be246eb4..67494760d 100644 --- a/identity-wallet/src/state/credentials/actions/mod.rs +++ b/identity-wallet/src/state/credentials/actions/mod.rs @@ -5,4 +5,5 @@ pub mod delete_credential; pub mod refresh_all_credential_statuses; pub mod refresh_credential_status; pub mod self_issue_credential; +pub mod share_to_linkedin; pub mod update_credential_metadata; diff --git a/identity-wallet/src/state/credentials/actions/share_to_linkedin.rs b/identity-wallet/src/state/credentials/actions/share_to_linkedin.rs new file mode 100644 index 000000000..0a9b6a8dd --- /dev/null +++ b/identity-wallet/src/state/credentials/actions/share_to_linkedin.rs @@ -0,0 +1,20 @@ +use crate::reducer; +use crate::state::credentials::reducers::share_to_linkedin::share_to_linkedin; +use crate::state::{actions::ActionTrait, Reducer}; + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, TS, Clone)] +#[ts(export, export_to = "bindings/actions/ShareToLinkedIn.ts")] +pub struct ShareToLinkedIn { + #[ts(type = "string")] + pub id: String, +} + +#[typetag::serde(name = "[Credential] Share to LinkedIn")] +impl ActionTrait for ShareToLinkedIn { + fn reducers<'a>(&self) -> Vec> { + vec![reducer!(share_to_linkedin)] + } +} diff --git a/identity-wallet/src/state/credentials/create_public_link.rs b/identity-wallet/src/state/credentials/create_public_link.rs new file mode 100644 index 000000000..af7fb68cd --- /dev/null +++ b/identity-wallet/src/state/credentials/create_public_link.rs @@ -0,0 +1,206 @@ +use std::str::FromStr; + +use crate::error::AppError::{self, *}; +use crate::state::core_utils::helpers::get_unverified_jwt_claims; +use crate::state::credentials::VerifiableCredentialRecord; +use crate::state::did::validate_linked_verifiable_presentations::validate_linked_verifiable_presentations; +use crate::state::AppState; +use chrono::{Duration, Utc}; +use did_manager::Resolver; +use identity_iota::core::ToJson; +use jsonwebtoken::Header; +use log::{info, warn}; +use oid4vc::oid4vc_core::jwt::encode; +use serde::Serialize; +use serde_json::{json, Value}; +use url::Url; +use uuid::Uuid; + +// TODO: this should actually be the reducer and the sharing to LinkedIn or whatever platform should be the helper, since that part doesnt update the AppState. +pub async fn create_public_link(state: &AppState, credential_id: &str) -> Result { + // Get the VerifiableCredentialRecord belonging to the Id from Stronghold + let id = Uuid::from_str(credential_id).map_err(|e| AppError::Error(e.to_string()))?; + let stronghold_manager = state + .core_utils + .managers + .lock() + .await + .stronghold_manager + .as_ref() + .ok_or(MissingManagerError("stronghold"))? + .clone(); + let vcr_bytes = stronghold_manager + .get(id) + .map_err(|e| AppError::Error(e.to_string()))? + .ok_or(AppError::Error( + "Failed to get VerifiableCredentialRecord bytes from Stronghold".to_string(), + ))?; + let vcr: VerifiableCredentialRecord = + serde_json::from_slice(&vcr_bytes).map_err(|e| AppError::Error(e.to_string()))?; + + // Get the UniMe did + let did_method = state + .profile_settings + .preferred_did_methods + .first() + .ok_or(AppError::Error("Failed to get a preferred did method".to_string()))?; + + let issuer_did = state.dids.get(did_method).ok_or(AppError::Error( + "Failed to get the did for the preferred did method".to_string(), + ))?; + + // Get preferred key type and convert it to jsonwebtoken::Algorithm + let key_type = state + .profile_settings + .preferred_key_types + .first() + .ok_or(AppError::Error("Failed to get a preferred key type".to_string()))? + .as_str(); + let algorithm = match key_type { + "EdDSA" => jsonwebtoken::Algorithm::EdDSA, + "ES256" => jsonwebtoken::Algorithm::ES256, + _ => return Err(AppError::Error("Unsupported key type".to_string())), + }; + + // Get kid + let managers = state.core_utils.managers.lock().await; + let subject = managers + .identity_manager + .as_ref() + .ok_or(MissingManagerError("identity"))? + .subject + .clone(); + + let kid = subject + .key_id(did_method, algorithm) + .await + .ok_or(AppError::Error("Failed to create a key id".to_string()))?; + + // Compose the JWT header + let header = Header { + alg: algorithm, + typ: Some("JWT".to_string()), + kid: Some(kid.clone()), + ..Default::default() + }; + + // Get the credential's issuer DID from the credential data + let jwt = &mut vcr.verifiable_credential.clone(); + let jwt_data = get_unverified_jwt_claims(jwt)?; + let credential_issuer_did = jwt_data + .get("iss") + .and_then(|v| v.as_str()) + .ok_or(AppError::Error("Issuer (iss) not found".to_string()))?; + + // Get the JTI claim from the credential data + let jti = jwt_data + .get("jti") + .and_then(|v| v.as_str()) + .ok_or(AppError::Error("JTI not found".to_string()))?; + + let now = Utc::now(); + let exp = now + Duration::days(365); + + let claims = PublicLinkTokenClaims { + iss: issuer_did.to_string(), + sub: jti.to_string(), + aud: credential_issuer_did.to_string(), + iat: now.timestamp(), + nbf: now.timestamp(), + exp: exp.timestamp(), + status: "active".to_string(), // TODO: implement TSL revocation + }; + + let public_link_jwt = encode(subject, header, claims, did_method) + .await + .map_err(|e| AppError::Error(e.to_string()))?; + + // Extract the Issuer DID from the `aud` claim of the token + let public_verifier_endpoint_url = + get_trusted_verifier_public_verification_endpoint(state, credential_issuer_did).await?; + + let public_link = format!( + "{}?public-credential-token={}", + public_verifier_endpoint_url, public_link_jwt + ); + + let public_link_url = + Url::parse(&public_link).map_err(|e| AppError::Error(format!("Invalid public link URL: {}", e)))?; + + info!("Succesfully generated public link URL: {}", public_link_url); + + Ok(public_link_url) +} + +// Through the issuer DID Document find the ecosystem leader and its public verification endpoint, who will be the trusted verifier. +// Step 1: get the issuer DID Document +// Step 2: find the Linked VP service +// Step 3: get the issuer DID Document of the Linked VP credential +// Step 4: find the Public Verification Endpoint service +pub async fn get_trusted_verifier_public_verification_endpoint( + _state: &AppState, + issuer_did: &str, +) -> Result { + let resolver = Resolver::new().await; + + // TODO: hardcoded logic that only selects the first Linked VP + let linked_vp = validate_linked_verifiable_presentations(&resolver, issuer_did) + .await + .iter() + .flatten() + .next() + .ok_or(AppError::Error( + "No Linked VP found for issuer of credential requested for public sharing".to_string(), + ))? + .clone(); + + info!("Linked VP: {linked_vp:#?}"); + + let linked_vp_claims = get_unverified_jwt_claims(&json!(&linked_vp.data))?; + let linked_vp_issuer_did = linked_vp_claims + .get("iss") + .and_then(|v| v.as_str()) + .ok_or(AppError::Error("Issuer (iss) not found in Linked VP".to_string()))?; + let linked_vp_issuer_document = resolver + .resolve(linked_vp_issuer_did) + .await + .map_err(|_| AppError::Error("Failed to resolve linked VP issuer did".to_string()))?; + + info!("Linked VP Issuer DID Document: {linked_vp_issuer_document:#?}"); + + let public_verification_endpoint = linked_vp_issuer_document.service().iter().find_map(|service| { + service + .type_() + .contains("PublicVerificationEndpoint") + .then(|| { + info!("Found Public Verification Endpoint: {service:#?}"); + service.service_endpoint() + }) + .and_then(|service_endpoint| service_endpoint.to_json_value().ok()) + .and_then(|endpoint_value| match endpoint_value { + Value::String(url) => Some(url), + Value::Object(obj) => obj + .get("url") + .or_else(|| obj.get("uri")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + _ => { + warn!("Unexpected service endpoint format: {endpoint_value:#?}"); + None + } + }) + }); + + public_verification_endpoint + .ok_or_else(|| AppError::Error("No public verification endpoint found in issuer DID document".to_string())) +} +#[derive(Serialize, Debug)] +struct PublicLinkTokenClaims { + iss: String, + sub: String, + aud: String, + iat: i64, + nbf: i64, + exp: i64, + status: String, // TODO: impl TSL revocation +} diff --git a/identity-wallet/src/state/credentials/mod.rs b/identity-wallet/src/state/credentials/mod.rs index acbd6941b..965bf1a05 100644 --- a/identity-wallet/src/state/credentials/mod.rs +++ b/identity-wallet/src/state/credentials/mod.rs @@ -1,8 +1,9 @@ pub mod actions; +pub mod create_public_link; pub mod reducers; - use super::{core_utils::helpers::get_unverified_jwt_claims, FeatTrait}; use crate::{error::AppError, state::core_utils::DateUtils}; +use chrono::{TimeZone, Utc}; use derivative::Derivative; use identity_credential::{sd_jwt_v2::Sha256Hasher, sd_jwt_vc::SdJwtVc}; use log::info; @@ -54,6 +55,7 @@ pub struct DisplayCredential { pub display_name: String, #[ts(optional)] pub credential_status: Option, + pub public_link: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Eq, Derivative, TS)] @@ -85,6 +87,8 @@ pub struct CredentialMetadata { pub date_added: String, #[derivative(PartialEq = "ignore")] pub date_issued: String, + #[derivative(PartialEq = "ignore")] + pub expiration_date: Option, #[ts(optional)] pub icon: Option, } @@ -102,9 +106,10 @@ impl VerifiableCredentialRecord { ) -> Result { let display_credential = { // Try to parse the Verifiable Credential as an SD-JWT credential. - let (id, format, data, issuance_date, display_claims) = if let Some(sd_jwt_vc) = verifiable_credential - .as_str() - .and_then(|verifiable_credential| verifiable_credential.parse::().ok()) + let (id, format, data, issuance_date, expiration_date, display_claims) = if let Some(sd_jwt_vc) = + verifiable_credential + .as_str() + .and_then(|verifiable_credential| verifiable_credential.parse::().ok()) { info!("Verifiable Credential parsed as a SD-JWT VC"); @@ -112,6 +117,7 @@ impl VerifiableCredentialRecord { let id = Uuid::new_v4().to_string(); let issuance_date = sd_jwt_vc.claims().iat.map(|iat| iat.to_rfc3339()).unwrap_or_default(); + let expiration_date = sd_jwt_vc.claims().exp.map(|exp| exp.to_rfc3339()); let mut credential_subject = serde_json::json!(sd_jwt_vc .clone() @@ -171,9 +177,13 @@ impl VerifiableCredentialRecord { "credentialSubject": credential_subject }); - (id, format, data, issuance_date, display_claims) + (id, format, data, issuance_date, expiration_date, display_claims) } else { - let credential_display = get_unverified_jwt_claims(&verifiable_credential)?["vc"].clone(); + let jwt_claims = get_unverified_jwt_claims(&verifiable_credential)?; + let credential_display = jwt_claims + .get("vc") + .ok_or(AppError::InvalidCredentialFormatError)? + .clone(); // TODO: We are using this hash as Credential ID so that we can prevent credential duplication in // demo situations. Now we can actually delete Credentials in UniMe we don't need to use the hash of the @@ -201,10 +211,51 @@ impl VerifiableCredentialRecord { ) }; - let issuance_date = credential_display["issuanceDate"] - .as_str() - .map(ToString::to_string) - .unwrap_or_default(); + // Try to get issuanceDate from multiple possible fields, in a JwtVcJson, the JWT claim takes precedence. + let issuance_date = verifiable_credential + // first, from the jwt 'iat' claim + .get("iat") + .and_then(|v| v.as_i64()) + .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()) + .map(|utc| utc.to_rfc3339()) + // secondly, from 'issuanceDate' field in the VC DM 1.1 + .or_else(|| { + credential_display + .get("issuanceDate") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + // thirdly, from 'validFrom' field in the VC DM 2.0 + .or_else(|| { + credential_display + .get("validFrom") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + .unwrap_or_default(); // If all attempts fail, return an empty string + + // Try to get expirationDate from multiple possible fields, in a JwtVcJson, the JWT claim takes precedence. + let expiration_date = verifiable_credential + // first, from the jwt 'exp' claim + .get("exp") + .and_then(|v| v.as_i64()) + .and_then(|timestamp| Utc.timestamp_opt(timestamp, 0).single()) + .map(|utc| utc.to_rfc3339()) + // secondly, from 'expirationDate' field in the VC DM 1.1 + .or_else(|| { + credential_display + .get("expirationDate") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }) + // thirdly, from 'validUntil' field in the VC DM 2.0 + .or_else(|| { + credential_display + .get("validUntil") + .and_then(|v| v.as_str()) + .map(ToString::to_string) + }); + let id = Uuid::from_slice(&hash.as_bytes()[..16])?.to_string(); let format = CredentialFormats::JwtVcJson(()); @@ -216,7 +267,7 @@ impl VerifiableCredentialRecord { let data = credential_display; - (id, format, data, issuance_date, display_claims) + (id, format, data, issuance_date, expiration_date, display_claims) }; DisplayCredential { @@ -228,6 +279,7 @@ impl VerifiableCredentialRecord { is_favorite: false, date_added: DateUtils::new_date_string(), date_issued: issuance_date, + expiration_date, icon: None, }, // The other fields will be filled in at a later stage. @@ -237,6 +289,7 @@ impl VerifiableCredentialRecord { // The credential status is None here but it will be set right after this function. // This initialization is separated since it requires async fetching. credential_status: None, + public_link: None, } }; diff --git a/identity-wallet/src/state/credentials/reducers/mod.rs b/identity-wallet/src/state/credentials/reducers/mod.rs index 142266790..0e07a8b44 100644 --- a/identity-wallet/src/state/credentials/reducers/mod.rs +++ b/identity-wallet/src/state/credentials/reducers/mod.rs @@ -5,4 +5,5 @@ pub mod refresh_credential_status; pub mod self_issue_credential; pub mod send_credential_request; pub mod send_token_request; +pub mod share_to_linkedin; pub mod update_credential_metadata; 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 83e10eb63..1f9a69457 100644 --- a/identity-wallet/src/state/credentials/reducers/refresh_credential_status.rs +++ b/identity-wallet/src/state/credentials/reducers/refresh_credential_status.rs @@ -53,7 +53,8 @@ pub async fn refresh_credential_status(state: AppState, action: Action) -> Resul 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(); - credential_status_data.status = status; + // TODO: Hardcoded to Valid for demo purpose until we can host status list on IOTA + credential_status_data.status = StatusType::VALID; // Update the credential in Stronghold { diff --git a/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs b/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs new file mode 100644 index 000000000..ac3a1adb2 --- /dev/null +++ b/identity-wallet/src/state/credentials/reducers/share_to_linkedin.rs @@ -0,0 +1,72 @@ +use crate::state::credentials::create_public_link::create_public_link; +use crate::{ + error::AppError, + state::{ + actions::{listen, Action}, + credentials::actions::share_to_linkedin::ShareToLinkedIn, + AppState, + }, +}; +use chrono::{DateTime, Datelike}; +use log::info; +use urlencoding::encode; + +pub async fn share_to_linkedin(state: AppState, action: Action) -> Result { + if let Some(share_to_linkedin) = listen::(action) { + let mut credentials = state.credentials.clone(); + let credential = credentials + .iter_mut() + .find(|cred| cred.id == share_to_linkedin.id) + .ok_or(AppError::NoCredentialWithIdError(share_to_linkedin.id))?; + + // Build LinkedIn URL, all parameters must be URL percent-encoded + let mut linkedin_url = "https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME".to_string(); + linkedin_url.push_str(format!("&name={}", encode(&credential.display_name)).as_str()); + linkedin_url.push_str(format!("&organizationName={}", encode(&credential.issuer_name)).as_str()); + + let issue_date = DateTime::parse_from_rfc3339(&credential.metadata.date_issued) + .map_err(|e| AppError::Error(e.to_string()))?; + linkedin_url.push_str(format!("&issueYear={}", issue_date.year()).as_str()); + linkedin_url.push_str(format!("&issueMonth={}", issue_date.month()).as_str()); + + if let Some(expiration_date_str) = &credential.metadata.expiration_date { + let expiration_date = + DateTime::parse_from_rfc3339(expiration_date_str).map_err(|e| AppError::Error(e.to_string()))?; + linkedin_url.push_str(format!("&expirationYear={}", expiration_date.year()).as_str()); + linkedin_url.push_str(format!("&expirationMonth={}", expiration_date.month()).as_str()); + } + + // Get or create public link to the credential + let public_link = if let Some(existing_link) = credential.public_link.clone() { + existing_link.clone() + } else { + create_public_link(&state, &credential.id).await?.to_string() + }; + + linkedin_url.push_str(format!("&certUrl={}", encode(&public_link)).as_str()); + linkedin_url.push_str(format!("&certId={}", encode(&credential.id)).as_str()); + + info!("Opening LinkedIn AddToProfile URL in browser: `{linkedin_url}`"); + + // When testing Tauri is often not initialized and the link doesn't actually need to be opened anyway. + #[cfg(not(feature = "test_utils"))] + { + use tauri_plugin_opener::OpenerExt; + + let app_handle = state + .core_utils + .app_handle + .clone() + .ok_or(AppError::Error("Tauri app handle is not available".to_string()))?; + app_handle + .opener() + .open_url(linkedin_url, None::<&str>) + .map_err(|err| AppError::Error(format!("Failed to open URL in browser: {err}")))?; + } + + credential.public_link = Some(public_link); + return Ok(AppState { credentials, ..state }); + } + + Ok(state) +} diff --git a/identity-wallet/src/state/did/validate_domain_linkage.rs b/identity-wallet/src/state/did/validate_domain_linkage.rs index 1f972e88f..95a1fef8e 100644 --- a/identity-wallet/src/state/did/validate_domain_linkage.rs +++ b/identity-wallet/src/state/did/validate_domain_linkage.rs @@ -43,7 +43,7 @@ impl JwsVerifier for Verifier { fn verify(&self, input: VerificationInput, public_key: &IotaIdentityJwk) -> Result<(), SignatureVerificationError> { use SignatureVerificationErrorKind::*; - info!("Verifying input"); + info!("Verifying input signature with alg: {}", input.alg); let algorithm = Algorithm::from_str(&input.alg.to_string()).map_err(|_| SignatureVerificationError::new(UnsupportedAlg))?; @@ -66,7 +66,10 @@ impl JwsVerifier for Verifier { &decoding_key, algorithm, ) { - Ok(true) => Ok(()), + Ok(true) => { + info!("Signature successfully verified"); + Ok(()) + } Err(_) | Ok(false) => Err(SignatureVerificationError::new( // TODO: more fine-grained error handling? InvalidSignature, 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 1a735c635..cfd3d3f55 100644 --- a/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs +++ b/identity-wallet/src/state/did/validate_linked_verifiable_presentations.rs @@ -35,6 +35,8 @@ pub struct LinkedVerifiableCredentialData { pub issuance_date: String, #[ts(skip)] pub issuer_linked_domains: Vec, + #[ts(skip)] + pub data: String, } // Skip the partial equality check for `issuance_date` during testing. @@ -76,6 +78,7 @@ pub async fn validate_linked_verifiable_presentations( .flatten(), ) .filter_map(|linked_verifiable_presentation_url| { + info!("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) }) @@ -162,6 +165,8 @@ async fn validate_linked_verifiable_presentation( .ok() .and_then(|presentation_jwt| { status.is_success().then(|| { + info!("Validating linked verifiable presentation JWT: {presentation_jwt}"); + let validator = JwtPresentationValidator::with_signature_verifier(Verifier); validator .validate(&presentation_jwt.into(), &holder_document, &Default::default()) @@ -182,9 +187,9 @@ async fn get_validated_linked_credential_data( linked_verifiable_presentation: DecodedJwtPresentation, ) -> Vec { iter(linked_verifiable_presentation.presentation.verifiable_credential) - .filter_map(|linked_verifiable_credential| async move { + .filter_map(|linked_verifiable_credential_jwt| async move { // Resolve the issuer document and issuer DID - let issuer_document = get_issuer_document(resolver, &linked_verifiable_credential).await?; + let issuer_document = get_issuer_document(resolver, &linked_verifiable_credential_jwt).await?; let issuer_did = issuer_document.id().to_string(); info!("Issuer document: {issuer_document:#?}"); @@ -200,6 +205,7 @@ async fn get_validated_linked_credential_data( // TODO: This is a fallback to get the url from a did:web to validate domain linkage. This is useful for companies who haven't implemented domain linkage yet. if validated_linked_domains.is_empty() { + info!("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); } @@ -213,7 +219,7 @@ async fn get_validated_linked_credential_data( // Decode the linked verifiable credential and validate the jwt_vc_json, checks the JWT and the Issuer DID if let Ok(linked_verifiable_credential) = validator.validate::<_, Value>( - &linked_verifiable_credential, + &linked_verifiable_credential_jwt, &issuer_document, &options, FailFast::FirstError, @@ -221,7 +227,8 @@ async fn get_validated_linked_credential_data( info!("Validated linked verifiable credential JWT: {linked_verifiable_credential:#?}"); // Validate the linked verifiable credential against its corresponding JSON Schema - validate_credential_types(&linked_verifiable_credential.credential.to_json_value().ok()?).ok()?; + // TODO + // 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), @@ -234,12 +241,15 @@ async fn get_validated_linked_credential_data( let logo_uri = get_logo_uri(credential_subject, &linked_verifiable_credential, &validated_linked_domains).await; let issuance_date = linked_verifiable_credential.credential.issuance_date.to_rfc3339(); - info!("LinkedVerifiableCredentialData: name: {name:?}, logo_uri: {logo_uri:?}, issuance_date: {issuance_date}"); + let data = linked_verifiable_credential_jwt.as_str().to_string(); + info!("LinkedVerifiableCredentialData: name: {name:?}, logo_uri: {logo_uri:?}, issuance_date: {issuance_date}, validated_linked_domains: {validated_linked_domains:#?}, data: {data}"); + Some(LinkedVerifiableCredentialData { name, logo_uri, issuance_date, issuer_linked_domains: validated_linked_domains, + data }) } else { @@ -247,7 +257,7 @@ async fn get_validated_linked_credential_data( None } } else { - warn!("Failed to validate linked verifiable credential: {linked_verifiable_credential:#?}"); + warn!("Failed to validate linked verifiable credential: {linked_verifiable_credential_jwt:#?}"); // TODO: Should we add more fine-grained error handling? `None` here means that the linked verifiable credential is invalid. None } @@ -447,7 +457,10 @@ fn extract_url_from_did_web(did_web: &str) -> Option { did }; - if let Ok(url) = Url::parse(&format!("https://{url_str}")) { + // TODO: quick hack to solve the percent-encoding issue in did:web:localhost%3A3033 (localhost:3033) + let url_decoded = url_str.replace("%3A", ":"); + + if let Ok(url) = Url::parse(&format!("https://{url_decoded}")) { return Some(url); } } diff --git a/identity-wallet/src/state/search/reducers/search_query.rs b/identity-wallet/src/state/search/reducers/search_query.rs index c1cf7a677..5dae51d11 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 { connection_id: None, display_name: "John".to_string(), credential_status: None, + public_link: None, }, DisplayCredential { id: "2".to_string(), @@ -136,6 +137,7 @@ mod tests { connection_id: None, display_name: "Jane".to_string(), credential_status: None, + public_link: None, }, DisplayCredential { id: "3".to_string(), @@ -151,6 +153,7 @@ mod tests { connection_id: None, display_name: "Jeff".to_string(), credential_status: None, + public_link: None, }, ], ..Default::default() diff --git a/unime/src-tauri/capabilities/mobile.json b/unime/src-tauri/capabilities/mobile.json index 72aae8969..fa810ba85 100644 --- a/unime/src-tauri/capabilities/mobile.json +++ b/unime/src-tauri/capabilities/mobile.json @@ -3,5 +3,5 @@ "identifier": "mobile-capability", "windows": ["main"], "platforms": ["iOS", "android"], - "permissions": ["barcode-scanner:default", "biometric:default", "keystore:default"] + "permissions": ["barcode-scanner:default", "biometric:default", "keystore:default", "allow-default-urls"] } diff --git a/unime/src-tauri/tests/fixtures/actions/share_to_linkedin.json b/unime/src-tauri/tests/fixtures/actions/share_to_linkedin.json new file mode 100644 index 000000000..4565f4157 --- /dev/null +++ b/unime/src-tauri/tests/fixtures/actions/share_to_linkedin.json @@ -0,0 +1,6 @@ +{ + "type": "[Credential] Share to LinkedIn", + "payload": { + "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479" + } +} diff --git a/unime/src-tauri/tests/tests/mod.rs b/unime/src-tauri/tests/tests/mod.rs index 690b56ede..7c06726a1 100644 --- a/unime/src-tauri/tests/tests/mod.rs +++ b/unime/src-tauri/tests/tests/mod.rs @@ -7,4 +7,5 @@ mod qr_code_scanned; mod refresh_credential_status; mod search_query; mod self_issue_credential; +mod share_public_link; mod sorting; diff --git a/unime/src-tauri/tests/tests/share_public_link.rs b/unime/src-tauri/tests/tests/share_public_link.rs new file mode 100644 index 000000000..1096921ab --- /dev/null +++ b/unime/src-tauri/tests/tests/share_public_link.rs @@ -0,0 +1,96 @@ +use identity_wallet::state::{ + actions::Action, + core_utils::CoreUtils, + credentials::{reducers::share_to_linkedin::share_to_linkedin, VerifiableCredentialRecord}, + profile_settings::{Profile, ProfileSettings}, + AppState, AppStateContainer, +}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::common::{ + assert_state_update::{assert_state_update, setup_state_file, setup_stronghold}, + json_example, test_managers, +}; + +const CREDENTIAL_JWT: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSIsImtpZCI6ImRpZDprZXk6ejZNa2dFODROQ01wTWVBeDlqSzljZjVXNEc4Z2NaOXh1d0p2RzFlN3dOazhLQ2d0I3o2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtnRTg0TkNNcE1lQXg5aks5Y2Y1VzRHOGdjWjl4dXdKdkcxZTd3Tms4S0NndCIsInN1YiI6ImRpZDprZXk6ejZNa2lpZXlvTE1TVnNKQVp2N0pqZTV3V1NrREV5bVVna3lGOGtiY3JqWnBYM3FkIiwianRpIjoiZjQ3YWMxMGItNThjYy00MzcyLWE1NjctMGUwMmIyYzNkNDc5IiwibmJmIjoxMjYyMzA0MDAwLCJpYXQiOjEyNjIzMDQwMDAsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiaWQiOiJkaWQ6a2V5Ono2TWtpaWV5b0xNU1ZzSkFadjdKamU1d1dTa0RFeW1VZ2t5RjhrYmNyalpwWDNxZCIsImZpcnN0X25hbWUiOiJGZXJyaXMiLCJsYXN0X25hbWUiOiJSdXN0YWNlYW4ifSwiaXNzdWVyIjoiZGlkOmtleTp6Nk1rZ0U4NE5DTXBNZUF4OWpLOWNmNVc0RzhnY1o5eHV3SnZHMWU3d05rOEtDZ3QiLCJpc3N1YW5jZURhdGUiOiIyMDEwLTAxLTAxVDAwOjAwOjAwWiIsImNyZWRlbnRpYWxTdGF0dXMiOnsiaWQiOiJodHRwczovL215LWRvbWFpbi5leGFtcGxlLm9yZy9pZXRmLW9hdXRoLXRva2VuLXN0YXR1cy1saXN0LzAiLCJ0eXBlIjoic3RhdHVzbGlzdCtqd3QiLCJpZHgiOjEyMywidXJpIjoiaHR0cHM6Ly9teS1kb21haW4uZXhhbXBsZS5vcmcvaWV0Zi1vYXV0aC10b2tlbi1zdGF0dXMtbGlzdC8wIn19LCJzdGF0dXMiOnsic3RhdHVzX2xpc3QiOnsiaWR4IjoxMjMsInVyaSI6Imh0dHBzOi8vbXktZG9tYWluLmV4YW1wbGUub3JnL2lldGYtb2F1dGgtdG9rZW4tc3RhdHVzLWxpc3QvMCJ9fX0.LpNq8l-qqqCA-htsB8KZLaVoNCfxqTrsPxVmEj0dsPAGFhOqO8lXI7DU0FhNwzWedxJ1ySS_Vq7ChBW-TgY7Bw"; + +#[tokio::test] +#[serial_test::serial] +async fn test_share_public_link() { + // Set up AppState + setup_state_file(); + + let mut app_state = AppState { + core_utils: CoreUtils { + managers: test_managers(vec![]).await, + ..Default::default() + }, + profile_settings: ProfileSettings { + profile: Some(Profile { + name: "Ferris".to_string(), + ..Default::default() + }), + ..Default::default() + }, + ..AppState::default() + }; + + app_state.dids.insert( + "did:jwk".to_string(), + "did:example:ebfeb1f712ebc6f1c276e12ec21".to_string(), + ); + + // Set up stronghold + setup_stronghold(); + let stronghold_manager = app_state + .core_utils + .managers + .lock() + .await + .stronghold_manager + .as_ref() + .unwrap() + .clone(); + + // Create the VerifiableCredentialRecord from the JWT + let credential_jwt_value = serde_json::to_value(CREDENTIAL_JWT).unwrap(); + let mut vrc = VerifiableCredentialRecord::try_new(credential_jwt_value, Vec::new()).unwrap(); + + // Replace the key/id with the test Uuid key from the action fixture + let key = Uuid::parse_str("f47ac10b-58cc-4372-a567-0e02b2c3d479").unwrap(); + vrc.display_credential.id = key.clone().to_string(); + + // Insert the VerifiableCredentialRecord into Stronghold + stronghold_manager + .insert(key, serde_json::json!(vrc).to_string().as_bytes().to_vec()) + .unwrap(); + + // Insert the DisplayCredential into the app state + app_state.credentials.push(vrc.display_credential); + + let action = json_example::("tests/fixtures/actions/share_to_linkedin.json"); + let result = share_to_linkedin(app_state.clone(), action.clone()).await.unwrap(); + + println!("Result: {:#?}", result); + + // TODO!! + + // let expected_state = json_example::("tests/fixtures/states/share_to_linkedin.json"); + + // assert_state_update( + // AppStateContainer(Mutex::new(app_state)), + // vec![action], + // vec![Some(expected_state.clone())], + // ) + // .await; + + // // Assert Stronghold + // let managers = result.core_utils.managers.lock().await; + // let stronghold_manager = managers.stronghold_manager.as_ref().unwrap(); + + // let stronghold_values = stronghold_manager.values().unwrap().unwrap(); + // let stronghold_value = stronghold_values.first().unwrap().clone(); + + // assert_eq!(stronghold_value.display_credential, result.credentials[0]); +} diff --git a/unime/src/lib/icons/index.ts b/unime/src/lib/icons/index.ts index beee182ac..9d982385c 100644 --- a/unime/src/lib/icons/index.ts +++ b/unime/src/lib/icons/index.ts @@ -1,4 +1,5 @@ // Phosphor icons: https://icon-sets.iconify.design/ph/ +export { default as LinkedinIcon } from '~icons/ph/linkedin-logo-fill'; export { default as ArrowCounterClockwiseBoldIcon } from '~icons/ph/arrow-counter-clockwise-bold'; export { default as ArrowLeftRegularIcon } from '~icons/ph/arrow-left'; export { default as ArrowSquareOutBoldIcon } from '~icons/ph/arrow-square-out-bold'; diff --git a/unime/src/routes/credentials/[id]/CredentialHeaderMenu.svelte b/unime/src/routes/credentials/[id]/CredentialHeaderMenu.svelte index e7553afa5..a0633873b 100644 --- a/unime/src/routes/credentials/[id]/CredentialHeaderMenu.svelte +++ b/unime/src/routes/credentials/[id]/CredentialHeaderMenu.svelte @@ -8,7 +8,7 @@ import { ActionSheet, Button } from '$lib/components'; import { dispatch } from '$lib/dispatcher'; - import { DotsThreeVerticalBoldIcon, PencilFillIcon, TrashFillIcon } from '$lib/icons'; + import { DotsThreeVerticalBoldIcon, LinkedinIcon, PencilFillIcon, TrashFillIcon } from '$lib/icons'; const dispatchEvent = createEventDispatcher(); @@ -36,6 +36,18 @@ use:melt={$menu} transition:fly={{ duration: 150, y: -10 }} > + + +