diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/LinkedDomainsService.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/LinkedDomainsService.kt index 1411df79..68f118e1 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/LinkedDomainsService.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/LinkedDomainsService.kt @@ -9,6 +9,7 @@ import com.microsoft.walletlibrary.did.sdk.credential.service.models.linkedDomai import com.microsoft.walletlibrary.did.sdk.credential.service.validators.DomainLinkageCredentialValidator import com.microsoft.walletlibrary.did.sdk.datasource.network.apis.HttpAgentApiProvider import com.microsoft.walletlibrary.did.sdk.datasource.network.linkedDomainsOperations.FetchWellKnownConfigDocumentNetworkOperation +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument import com.microsoft.walletlibrary.did.sdk.identifier.resolvers.Resolver import com.microsoft.walletlibrary.did.sdk.identifier.resolvers.RootOfTrustResolver import com.microsoft.walletlibrary.did.sdk.util.Constants @@ -58,16 +59,22 @@ internal class LinkedDomainsService @Inject constructor( } } - private suspend fun verifyLinkedDomains(domainUrls: List, relyingPartyDid: String): Result { + internal suspend fun verifyLinkedDomains( + domainUrls: List, + relyingPartyDid: String + ): Result { if (domainUrls.isEmpty()) return Result.success(LinkedDomainMissing) val domainUrl = domainUrls.first() val hostname = URL(domainUrl).host return getWellKnownConfigDocument(domainUrl) - .map { - wellKnownConfigDocument -> + .map { wellKnownConfigDocument -> wellKnownConfigDocument.linkedDids.firstNotNullOf { linkedDidJwt -> - val isDomainLinked = jwtDomainLinkageCredentialValidator.validate(linkedDidJwt, relyingPartyDid, domainUrl) + val isDomainLinked = jwtDomainLinkageCredentialValidator.validate( + linkedDidJwt, + relyingPartyDid, + domainUrl + ) if (isDomainLinked) LinkedDomainVerified(hostname) else @@ -79,16 +86,30 @@ internal class LinkedDomainsService @Inject constructor( } private suspend fun getLinkedDomainsFromDid(relyingPartyDid: String): Result> { - val didDocumentResult = resolver.resolve(relyingPartyDid) + val didDocumentResult = resolveIdentifierDocument(relyingPartyDid) return didDocumentResult.map { didDocument -> - val linkedDomainsServices = - didDocument.service.filter { service -> service.type.equals(Constants.LINKED_DOMAINS_SERVICE_ENDPOINT_TYPE, true) } - linkedDomainsServices.map { it.serviceEndpoint }.flatten() + getLinkedDomainsFromDidDocument(didDocument) } } - private suspend fun getWellKnownConfigDocument(domainUrl: String) = FetchWellKnownConfigDocumentNetworkOperation( - domainUrl, - apiProvider - ).fire() + internal suspend fun resolveIdentifierDocument(relyingPartyDid: String): Result { + return resolver.resolve(relyingPartyDid) + } + + internal fun getLinkedDomainsFromDidDocument(didDocument: IdentifierDocument): List { + val linkedDomainsServices = + didDocument.service.filter { service -> + service.type.equals( + Constants.LINKED_DOMAINS_SERVICE_ENDPOINT_TYPE, + true + ) + } + return linkedDomainsServices.map { it.serviceEndpoint }.flatten() + } + + private suspend fun getWellKnownConfigDocument(domainUrl: String) = + FetchWellKnownConfigDocumentNetworkOperation( + domainUrl, + apiProvider + ).fire() } \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/VerifiableCredentialSdk.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/VerifiableCredentialSdk.kt index db96b90a..e4b72037 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/VerifiableCredentialSdk.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/did/sdk/VerifiableCredentialSdk.kt @@ -39,6 +39,9 @@ internal object VerifiableCredentialSdk { @JvmStatic internal lateinit var presentationService: PresentationService + @JvmStatic + internal lateinit var linkedDomainsService: LinkedDomainsService + @JvmStatic internal lateinit var revocationService: RevocationService @@ -90,6 +93,7 @@ internal object VerifiableCredentialSdk { issuanceService = sdkComponent.issuanceService() presentationService = sdkComponent.presentationService() + linkedDomainsService = sdkComponent.linkedDomainsService() revocationService = sdkComponent.revocationService() correlationVectorService = sdkComponent.correlationVectorService() identifierService = sdkComponent.identifierManager() diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/mappings/IdentifierDocumentMapping.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/mappings/IdentifierDocumentMapping.kt new file mode 100644 index 00000000..ad13fa7a --- /dev/null +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/mappings/IdentifierDocumentMapping.kt @@ -0,0 +1,14 @@ +package com.microsoft.walletlibrary.mappings + +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.nimbusds.jose.jwk.JWK + +internal fun IdentifierDocument.getJwk(id: String): JWK? { + if (verificationMethod.isNullOrEmpty()) return null + for (publicKey in verificationMethod) { + if (publicKey.id == id) { + return publicKey.publicKeyJwk + } + } + return null +} \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/mappings/LinkedDomainsServiceExtension.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/mappings/LinkedDomainsServiceExtension.kt new file mode 100644 index 00000000..d1d979ec --- /dev/null +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/mappings/LinkedDomainsServiceExtension.kt @@ -0,0 +1,14 @@ +package com.microsoft.walletlibrary.mappings + +import com.microsoft.walletlibrary.did.sdk.LinkedDomainsService +import com.microsoft.walletlibrary.did.sdk.credential.service.models.linkedDomains.LinkedDomainResult +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.did.sdk.util.controlflow.SdkException + +internal suspend fun LinkedDomainsService.fetchAndVerifyLinkedDomains(identifierDocument: IdentifierDocument): Result { + val linkedDomains = getLinkedDomainsFromDidDocument(identifierDocument) + verifyLinkedDomains(linkedDomains, identifierDocument.id) + .onSuccess { return Result.success(it) } + .onFailure { return Result.failure(it) } + return Result.failure(SdkException("Failed while verifying linked domains")) +} \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/CredentialMetadata.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/CredentialMetadata.kt index 450ae0c5..c23ade40 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/CredentialMetadata.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/CredentialMetadata.kt @@ -1,5 +1,12 @@ package com.microsoft.walletlibrary.networking.entities.openid4vci.credentialmetadata +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialoffer.CredentialOffer +import com.microsoft.walletlibrary.requests.styles.RequesterStyle +import com.microsoft.walletlibrary.requests.styles.VerifiedIdManifestIssuerStyle +import com.microsoft.walletlibrary.util.OpenId4VciValidationException +import com.microsoft.walletlibrary.util.VerifiedIdExceptions import kotlinx.serialization.Serializable /** @@ -27,4 +34,63 @@ internal data class CredentialMetadata( // Display information for the issuer. val display: List? = null, -) \ No newline at end of file +) { + + fun transformLocalizedIssuerDisplayDefinitionToRequesterStyle(): RequesterStyle { + val preferredLocalesList = + ConfigurationCompat.getLocales(Resources.getSystem().configuration) + for (index in 0 until preferredLocalesList.size()) { + val preferredLocale = preferredLocalesList[index] + display?.forEach { displayDefinition -> + displayDefinition.locale?.let { language -> + if (language == preferredLocale?.language) { + return VerifiedIdManifestIssuerStyle(displayDefinition.name ?: "") + } + } + } + } + return VerifiedIdManifestIssuerStyle(display?.first()?.name ?: "") + } + + fun validateAuthorizationServers(credentialOffer: CredentialOffer) { + if (authorization_servers.isNullOrEmpty()) { + throw OpenId4VciValidationException( + "Authorization servers property missing in credential metadata.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + credentialOffer.grants.forEach { + if (!authorization_servers.contains(it.value.authorization_server)) + throw OpenId4VciValidationException( + "Authorization server ${it.value.authorization_server} not found in Credential Metadata.", + VerifiedIdExceptions.MALFORMED_CREDENTIAL_METADATA_EXCEPTION.value + ) + } + } + + fun getSupportedCredentialConfigurations(credentialConfigurationIds: List): List { + val supportedConfigIds = mutableListOf() + if (credential_configurations_supported == null) + return supportedConfigIds + credentialConfigurationIds.forEach { id -> + credential_configurations_supported[id]?.let { supportedConfigIds.add(it) } + } + return supportedConfigIds + } + + fun verifyIfCredentialIssuerExist() { + if (credential_issuer == null) + throw OpenId4VciValidationException( + "Credential metadata does not contain credential_issuer.", + VerifiedIdExceptions.MALFORMED_CREDENTIAL_METADATA_EXCEPTION.value + ) + } + + fun verifyIfSignedMetadataExist() { + if (signed_metadata == null) + throw OpenId4VciValidationException( + "Credential metadata does not contain signed_metadata.", + VerifiedIdExceptions.MALFORMED_CREDENTIAL_METADATA_EXCEPTION.value + ) + } +} \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/SignedMetadataTokenClaims.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/SignedMetadataTokenClaims.kt new file mode 100644 index 00000000..4663e4f6 --- /dev/null +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/networking/entities/openid4vci/credentialmetadata/SignedMetadataTokenClaims.kt @@ -0,0 +1,73 @@ +package com.microsoft.walletlibrary.networking.entities.openid4vci.credentialmetadata + +import com.microsoft.walletlibrary.util.TokenValidationException +import com.microsoft.walletlibrary.util.VerifiedIdExceptions +import kotlinx.serialization.Serializable + +@Serializable +data class SignedMetadataTokenClaims( + val sub: String?, + val iat: String?, + val exp: String? = null, + val iss: String?, + val nbf: String? = null +) { + fun validateSignedMetadataTokenClaims(expectedSubject: String, expectedIssuer: String) { + validateSubject(expectedSubject) + validateIssuer(expectedIssuer) + validateIssuedAtTime() + validateExpiryTime() + } + + private fun validateIssuer(expectedIssuer: String) { + if (iss == null) { + throw TokenValidationException( + "Issuer property missing in signed metadata.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + if (iss != expectedIssuer) { + throw TokenValidationException( + "Invalid issuer property in signed metadata.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + } + + private fun validateSubject(expectedSubject: String) { + if (sub == null) { + throw TokenValidationException( + "Subject property missing in signed metadata.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + if (sub != expectedSubject) { + throw TokenValidationException( + "Invalid subject property in signed metadata.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + } + + private fun validateIssuedAtTime() { + if (iat != null && iat.toLong() >= getCurrentTimeInSecondsWithSkew()) { + throw TokenValidationException( + "Issued at time is in the future.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + } + + private fun validateExpiryTime() { + if (exp != null && exp.toLong() <= getCurrentTimeInSecondsWithSkew()) { + throw TokenValidationException( + "Token has expired.", + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + } + + private fun getCurrentTimeInSecondsWithSkew(skew: Long = 300): Long { + return (System.currentTimeMillis() / 1000) + skew + } +} \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandler.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandler.kt index 4cb3541c..264ef25f 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandler.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandler.kt @@ -5,10 +5,14 @@ import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialoffe import com.microsoft.walletlibrary.networking.operations.FetchCredentialMetadataNetworkOperation import com.microsoft.walletlibrary.requests.VerifiedIdRequest import com.microsoft.walletlibrary.util.LibraryConfiguration -import com.microsoft.walletlibrary.util.OpenId4VciException +import com.microsoft.walletlibrary.util.OpenId4VciRequestException +import com.microsoft.walletlibrary.util.OpenId4VciValidationException import com.microsoft.walletlibrary.util.VerifiedIdExceptions internal class OpenId4VCIRequestHandler(private val libraryConfiguration: LibraryConfiguration) : RequestHandler { + + private val signedMetadataProcessor = SignedMetadataProcessor(libraryConfiguration) + // Indicates whether the provided raw request can be handled by this handler. // This method checks if the raw request can be cast to CredentialOffer successfully, and if it contains the required fields. override fun canHandle(rawRequest: Any): Boolean { @@ -27,24 +31,47 @@ internal class OpenId4VCIRequestHandler(private val libraryConfiguration: Librar override suspend fun handleRequest(rawRequest: Any): VerifiedIdRequest<*> { val credentialOffer: CredentialOffer try { + // Deserialize the raw request to a CredentialOffer object. credentialOffer = libraryConfiguration.serializer.decodeFromString( CredentialOffer.serializer(), rawRequest as String ) } catch (exception: Exception) { - throw OpenId4VciException( + throw OpenId4VciValidationException( "Failed to decode CredentialOffer ${exception.message}", VerifiedIdExceptions.MALFORMED_CREDENTIAL_OFFER_EXCEPTION.value, exception ) } + + // Fetch the credential metadata from the credential issuer in credential offer object. fetchCredentialMetadata(credentialOffer.credential_issuer) - .onSuccess { } - .onFailure { } - TODO( - "Validate credential metadata, gather more inform, map data models and " + - "finally return VerifiedIdRequest." - ) + .onSuccess { credentialMetadata -> + // Validate Credential Metadata to verify if credential issuer and Signed Metadata exist. + credentialMetadata.verifyIfCredentialIssuerExist() + credentialMetadata.verifyIfSignedMetadataExist() + + // Get only the supported credential configuration ids from the credential metadata from the list in credential offer. + val configIds = credentialOffer.credential_configuration_ids + val supportedCredentialConfigurationIds = + credentialMetadata.getSupportedCredentialConfigurations(configIds) + + // Validate the authorization servers in the credential metadata. + credentialMetadata.validateAuthorizationServers(credentialOffer) + + // Get the root of trust from the signed metadata. + val rootOfTrust = credentialMetadata.signed_metadata?.let { + signedMetadataProcessor.process(it, credentialOffer.credential_issuer) + } + } + .onFailure { + throw OpenId4VciRequestException( + "Failed to fetch credential metadata ${it.message}", + VerifiedIdExceptions.CREDENTIAL_METADATA_FETCH_EXCEPTION.value, + it as Exception + ) + } + TODO("Map data models and finally return VerifiedIdRequest.") } private suspend fun fetchCredentialMetadata(metadataUrl: String): Result { @@ -56,6 +83,7 @@ internal class OpenId4VCIRequestHandler(private val libraryConfiguration: Librar ).fire() } + // Build the credential metadata url from the provided credential issuer. private fun buildCredentialMetadataUrl(credentialIssuer: String): String { val suffix = "/.well-known/openid-credential-issuer" if (!credentialIssuer.endsWith(suffix)) diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/SignedMetadataProcessor.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/SignedMetadataProcessor.kt new file mode 100644 index 00000000..38ee0f78 --- /dev/null +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/handlers/SignedMetadataProcessor.kt @@ -0,0 +1,88 @@ +package com.microsoft.walletlibrary.requests.handlers + +import com.microsoft.walletlibrary.did.sdk.crypto.protocols.jose.JwaCryptoHelper +import com.microsoft.walletlibrary.did.sdk.crypto.protocols.jose.jws.JwsToken +import com.microsoft.walletlibrary.mappings.getJwk +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialmetadata.SignedMetadataTokenClaims +import com.microsoft.walletlibrary.requests.RootOfTrust +import com.microsoft.walletlibrary.util.LibraryConfiguration +import com.microsoft.walletlibrary.util.OpenId4VciValidationException +import com.microsoft.walletlibrary.util.TokenValidationException +import com.microsoft.walletlibrary.util.VerifiedIdExceptions +import com.microsoft.walletlibrary.wrapper.IdentifierDocumentResolver +import com.microsoft.walletlibrary.wrapper.RootOfTrustResolver +import com.nimbusds.jose.jwk.JWK + +/** + * Validates and processes signed metadata in Credential Metadata. + */ +internal class SignedMetadataProcessor(private val libraryConfiguration: LibraryConfiguration) { + + // Deserializes the provided signed metadata from credential metadata, verifies its integrity + // validates it and processes it to return the root of trust. + internal suspend fun process(signedMetadata: String, credentialIssuer: String): RootOfTrust { + val jwsToken = deserializeSignedMetadata(signedMetadata) + + // Extract the DID and Key ID from the signed metadata token header. + val kid = jwsToken.keyId ?: throw OpenId4VciValidationException( + "JWS contains no key id", + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + val didKeyIdPair = JwaCryptoHelper.extractDidAndKeyId(kid) + val did = didKeyIdPair.first ?: throw OpenId4VciValidationException( + "JWS contains no DID", + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + val keyId = didKeyIdPair.second + + // Resolve the identifier document for the DID in the token and verify the integrity of the signed metadata. + val identifierDocument = IdentifierDocumentResolver.resolveIdentifierDocument(did) + val jwk = identifierDocument.getJwk(keyId) + ?: throw OpenId4VciValidationException( + "JWK with key id $kid not found in identifier document", + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + validateSignedMetadata(jwsToken, jwk, credentialIssuer, did) + + // Return the root of trust from the identifier document along with its verification status. + return RootOfTrustResolver.resolveRootOfTrust(identifierDocument) + } + + private fun deserializeSignedMetadata(signedMetadata: String): JwsToken { + return try { + JwsToken.deserialize(signedMetadata) + } catch (exception: Exception) { + throw OpenId4VciValidationException( + "Invalid signed metadata", + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value, + exception + ) + } + } + + private fun validateSignedMetadata(jwsToken: JwsToken, jwk: JWK, credentialIssuer: String, issuerDid:String) { + try { + verifySignature(jwsToken, jwk) + val signedMetadataTokenClaims = libraryConfiguration.serializer.decodeFromString( + SignedMetadataTokenClaims.serializer(), + jwsToken.content() + ) + signedMetadataTokenClaims.validateSignedMetadataTokenClaims(credentialIssuer, issuerDid) + } catch (exception: Exception) { + throw OpenId4VciValidationException( + "Invalid signed metadata", + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value, + exception + ) + } + } + + private fun verifySignature(jwsToken: JwsToken, jwk: JWK) { + if (!jwsToken.verify(listOf(jwk))) { + throw TokenValidationException( + "Signature is invalid on Signed metadata", + VerifiedIdExceptions.INVALID_SIGNATURE_EXCEPTION.value + ) + } + } +} \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/resolvers/OpenIdURLRequestResolver.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/resolvers/OpenIdURLRequestResolver.kt index 0092b966..b3ccf583 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/resolvers/OpenIdURLRequestResolver.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/requests/resolvers/OpenIdURLRequestResolver.kt @@ -17,9 +17,11 @@ import com.microsoft.walletlibrary.requests.input.VerifiedIdRequestURL import com.microsoft.walletlibrary.requests.rawrequests.OpenIdRawRequest import com.microsoft.walletlibrary.util.Constants import com.microsoft.walletlibrary.util.LibraryConfiguration +import com.microsoft.walletlibrary.util.OpenId4VciRequestException import com.microsoft.walletlibrary.util.PreviewFeatureFlags import com.microsoft.walletlibrary.util.RequestURIMissingException import com.microsoft.walletlibrary.util.UnSupportedVerifiedIdRequestInputException +import com.microsoft.walletlibrary.util.VerifiedIdExceptions import com.microsoft.walletlibrary.wrapper.OpenIdResolver import org.json.JSONObject diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/Exceptions.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/Exceptions.kt index 680485e3..836b51ab 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/Exceptions.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/Exceptions.kt @@ -53,6 +53,12 @@ class VerifiedIdRequestFetchException( retryable: Boolean = false ) : WalletLibraryException(message, cause, retryable) +class IdentifierDocumentResolutionException( + message: String = "", + cause: Throwable? = null, + retryable: Boolean = false +) : WalletLibraryException(message, cause, retryable) + class VerifiedIdResponseCompletionException( message: String = "", cause: Throwable? = null, diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdException.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdException.kt index e2221c9d..3b3be02e 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdException.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdException.kt @@ -45,7 +45,21 @@ class UnspecifiedVerifiedIdException( correlationId: String? = null ) : VerifiedIdException(message, code, correlationId) -class OpenId4VciException( +class OpenId4VciValidationException( + message: String, + code: String, + val innerError: Exception? = null, + correlationId: String? = null +) : VerifiedIdException(message, code, correlationId) + +class TokenValidationException( + message: String, + code: String, + val innerError: Exception? = null, + correlationId: String? = null +) : VerifiedIdException(message, code, correlationId) + +class OpenId4VciRequestException( message: String, code: String, val innerError: Exception? = null, diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdExceptions.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdExceptions.kt index b0ad7560..f03f120e 100644 --- a/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdExceptions.kt +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/util/VerifiedIdExceptions.kt @@ -6,5 +6,12 @@ enum class VerifiedIdExceptions(val value: String) { MALFORMED_INPUT_EXCEPTION("malformed_input"), USER_CANCELED_EXCEPTION("user_canceled"), UNSPECIFIED_EXCEPTION("unspecified_error"), - MALFORMED_CREDENTIAL_OFFER_EXCEPTION("malformed_credential_offer") + CREDENTIAL_OFFER_FETCH_EXCEPTION("credential_offer_fetch_error"), + CREDENTIAL_METADATA_FETCH_EXCEPTION("credential_metadata_fetch_error"), + MALFORMED_CREDENTIAL_OFFER_EXCEPTION("malformed_credential_offer"), + MALFORMED_CREDENTIAL_METADATA_EXCEPTION("malformed_credential_metadata"), + MALFORMED_SIGNED_METADATA_EXCEPTION("malformed_signed_metadata"), + INVALID_SIGNATURE_EXCEPTION("invalid_signature"), + INVALID_PROPERTY_EXCEPTION("invalid_property"), + DOCUMENT_RESOLUTION_EXCEPTION("document_resolution_error"), } \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/wrapper/IdentifierDocumentResolver.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/wrapper/IdentifierDocumentResolver.kt new file mode 100644 index 00000000..91cfe3f4 --- /dev/null +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/wrapper/IdentifierDocumentResolver.kt @@ -0,0 +1,33 @@ +package com.microsoft.walletlibrary.wrapper + +import com.microsoft.walletlibrary.did.sdk.VerifiableCredentialSdk +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.util.IdentifierDocumentResolutionException + +/** + * Wrapper class to wrap the resolve identifier from VC SDK and return identifier document. + */ +internal object IdentifierDocumentResolver { + + internal suspend fun resolveIdentifierDocument(did: String): IdentifierDocument { + val identifierDocumentResult = + VerifiableCredentialSdk.linkedDomainsService.resolveIdentifierDocument(did) + return handleResolutionResult(identifierDocumentResult) + } + + private fun handleResolutionResult(identifierDocumentResult: Result): IdentifierDocument { + identifierDocumentResult + .onSuccess { + return it + } + .onFailure { + throw IdentifierDocumentResolutionException( + "Unable to fetch identifier document", + it + ) + } + throw IdentifierDocumentResolutionException( + "Unable to fetch identifier document" + ) + } +} \ No newline at end of file diff --git a/walletlibrary/src/main/java/com/microsoft/walletlibrary/wrapper/RootOfTrustResolver.kt b/walletlibrary/src/main/java/com/microsoft/walletlibrary/wrapper/RootOfTrustResolver.kt new file mode 100644 index 00000000..1b353267 --- /dev/null +++ b/walletlibrary/src/main/java/com/microsoft/walletlibrary/wrapper/RootOfTrustResolver.kt @@ -0,0 +1,20 @@ +package com.microsoft.walletlibrary.wrapper + +import com.microsoft.walletlibrary.did.sdk.VerifiableCredentialSdk +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.mappings.fetchAndVerifyLinkedDomains +import com.microsoft.walletlibrary.mappings.toRootOfTrust +import com.microsoft.walletlibrary.requests.RootOfTrust + +/** + * Wrapper class to wrap the fetch linked domains from VC SDK and return RootOfTrust. + */ +internal object RootOfTrustResolver { + + internal suspend fun resolveRootOfTrust(identifierDocument: IdentifierDocument): RootOfTrust { + VerifiableCredentialSdk.linkedDomainsService.fetchAndVerifyLinkedDomains(identifierDocument) + .onSuccess { return it.toRootOfTrust() } + .onFailure { return RootOfTrust("", false) } + return RootOfTrust("", false) + } +} \ No newline at end of file diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/did/sdk/identifier/resolvers/ResolverTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/did/sdk/identifier/resolvers/ResolverTest.kt index 77d7b2d3..dec3acf0 100644 --- a/walletlibrary/src/test/java/com/microsoft/walletlibrary/did/sdk/identifier/resolvers/ResolverTest.kt +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/did/sdk/identifier/resolvers/ResolverTest.kt @@ -8,13 +8,13 @@ import com.microsoft.walletlibrary.did.sdk.util.controlflow.LocalNetworkExceptio import com.microsoft.walletlibrary.did.sdk.util.controlflow.NotFoundException import com.microsoft.walletlibrary.did.sdk.util.controlflow.ResolverException import com.microsoft.walletlibrary.did.sdk.util.controlflow.Result -import kotlin.Result as KotlinResult import com.microsoft.walletlibrary.did.sdk.util.defaultTestSerializer import io.mockk.coEvery import io.mockk.mockk import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import kotlin.Result as KotlinResult class ResolverTest { private val identifierRepository: IdentifierRepository = mockk() diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/mappings/IdentifierDocumentMappingTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/mappings/IdentifierDocumentMappingTest.kt new file mode 100644 index 00000000..23edf6e4 --- /dev/null +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/mappings/IdentifierDocumentMappingTest.kt @@ -0,0 +1,72 @@ +package com.microsoft.walletlibrary.mappings + +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocumentPublicKey +import com.nimbusds.jose.jwk.JWK +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class IdentifierDocumentMappingTest { + private val mockIdentifierDocumentPublicKey: IdentifierDocumentPublicKey = mockk() + private val mockPublicKeyJwk: JWK = mockk() + private val mockIdentifierDocument: IdentifierDocument = mockk() + + @Test + fun getJwkFromIdentifierDocument_KidHasMatchingKeyId_ReturnsJwk() { + // Arrange + val kid = "#signingKey-1" + every { mockIdentifierDocument.verificationMethod } returns listOf(mockIdentifierDocumentPublicKey) + every { mockIdentifierDocumentPublicKey.id } returns "#signingKey-1" + every { mockIdentifierDocumentPublicKey.publicKeyJwk } returns mockPublicKeyJwk + + // Act + val actualResult = mockIdentifierDocument.getJwk(kid) + + //Assert + assertThat(actualResult).isNotNull + assertThat(actualResult).isEqualTo(mockPublicKeyJwk) + } + + @Test + fun getJwkFromIdentifierDocument_KidHasNoMatchingKeyId_ReturnsNull() { + // Arrange + val kid = "#nonMatchingKey-1" + every { mockIdentifierDocument.verificationMethod } returns listOf(mockIdentifierDocumentPublicKey) + every { mockIdentifierDocumentPublicKey.id } returns "#signingKey-1" + every { mockIdentifierDocumentPublicKey.publicKeyJwk } returns mockPublicKeyJwk + + // Act + val actualResult = mockIdentifierDocument.getJwk(kid) + + //Assert + assertThat(actualResult).isNull() + } + + @Test + fun getJwkFromIdentifierDocument_DocumentHasEmptyKeyList_ReturnsNull() { + // Arrange + val kid = "#nonMatchingKey-1" + every { mockIdentifierDocument.verificationMethod } returns emptyList() + + // Act + val actualResult = mockIdentifierDocument.getJwk(kid) + + //Assert + assertThat(actualResult).isNull() + } + + @Test + fun getJwkFromIdentifierDocument_DocumentHasNullVerificationMethod_ReturnsNull() { + // Arrange + val kid = "#nonMatchingKey-1" + every { mockIdentifierDocument.verificationMethod } returns null + + // Act + val actualResult = mockIdentifierDocument.getJwk(kid) + + //Assert + assertThat(actualResult).isNull() + } +} \ No newline at end of file diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/networking/entities/openid4vci/CredentialMetadataTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/networking/entities/openid4vci/CredentialMetadataTest.kt new file mode 100644 index 00000000..147f8482 --- /dev/null +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/networking/entities/openid4vci/CredentialMetadataTest.kt @@ -0,0 +1,182 @@ +package com.microsoft.walletlibrary.networking.entities.openid4vci + +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialmetadata.CredentialConfiguration +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialmetadata.CredentialMetadata +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialoffer.CredentialOffer +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialoffer.CredentialOfferGrants +import com.microsoft.walletlibrary.util.OpenId4VciValidationException +import com.microsoft.walletlibrary.util.VerifiedIdExceptions +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatCode +import org.junit.Test + +class CredentialMetadataTest { + private val mockCredentialConfiguration1 = mockk() + private val credentialConfigurationId1 = "credential_id1" + private val mockCredentialConfiguration2 = mockk() + private val credentialConfigurationId2 = "credential_id2" + private val authorizationServer1 = "authorizationServer1" + private val credentialMetadata = CredentialMetadata( + authorization_servers = listOf(authorizationServer1), + credential_configurations_supported = mapOf( + credentialConfigurationId1 to mockCredentialConfiguration1, + credentialConfigurationId2 to mockCredentialConfiguration2 + ) + ) + private val credentialOffer = CredentialOffer( + credential_issuer = "metadata_url", + issuer_session = "request_state", + credential_configuration_ids = listOf(credentialConfigurationId1), + grants = mapOf( + "authorization_code" to CredentialOfferGrants( + authorizationServer1 + ) + ) + ) + + @Test + fun getSupportedCredentialConfigurations_HasMatchingConfig_ReturnsMatchingConfig() { + // Act + val actualSupportedCredentialConfigurations = + credentialMetadata.getSupportedCredentialConfigurations( + listOf(credentialConfigurationId1) + ) + + // Assert + assertThat(actualSupportedCredentialConfigurations).containsExactly( + mockCredentialConfiguration1 + ) + } + + @Test + fun getSupportedCredentialConfigurations_HasNoMatchingConfig_ReturnsEmptyList() { + // Act + val actualSupportedCredentialConfigurations = + credentialMetadata.getSupportedCredentialConfigurations( + listOf("non_matching_credential_id") + ) + + // Assert + assertThat(actualSupportedCredentialConfigurations).isEmpty() + } + + @Test + fun getSupportedCredentialConfigurations_ConfigInMetadataIsNull_ReturnsEmptyList() { + // Arrange + val credentialMetadata = CredentialMetadata() + + // Act + val actualSupportedCredentialConfigurations = + credentialMetadata.getSupportedCredentialConfigurations( + listOf(credentialConfigurationId1) + ) + + // Assert + assertThat(actualSupportedCredentialConfigurations).isEmpty() + } + + @Test + fun validateAuthorizationServers_AuthorizationServerInConfigAndMetadata_ValidationPasses() { + // Arrange + val credentialOffer = CredentialOffer( + credential_issuer = "metadata_url", + issuer_session = "request_state", + credential_configuration_ids = listOf(credentialConfigurationId1), + grants = mapOf( + "authorization_code" to CredentialOfferGrants( + authorizationServer1 + ) + ) + ) + + // Act + assertThatCode { credentialMetadata.validateAuthorizationServers(credentialOffer) }.doesNotThrowAnyException() + } + + @Test + fun validateAuthorizationServers_AuthorizationServerInConfigNotInMetadata_ThrowsException() { + // Arrange + val credentialMetadata = CredentialMetadata( + authorization_servers = listOf("non_matching_authorization_server"), + credential_configurations_supported = mapOf( + credentialConfigurationId1 to mockCredentialConfiguration1, + credentialConfigurationId2 to mockCredentialConfiguration2 + ) + ) + + // Act + val actualResult = runCatching { + credentialMetadata.validateAuthorizationServers(credentialOffer) + } + + // Assert + assertThat(actualResult.isSuccess).isFalse + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Authorization server $authorizationServer1 not found in Credential Metadata.") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_CREDENTIAL_METADATA_EXCEPTION.value + ) + } + + @Test + fun validateAuthorizationServers_NoAuthorizationServerInMetadata_ThrowsException() { + // Arrange + val credentialMetadata = CredentialMetadata() + + // Act + val actualResult = runCatching { + credentialMetadata.validateAuthorizationServers(credentialOffer) + } + + // Assert + assertThat(actualResult.isSuccess).isFalse + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Authorization servers property missing in credential metadata.") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + + @Test + fun verifyIfCredentialIssuerExist_NoCredentialIssuer_ThrowsException() { + // Arrange + val credentialMetadata = CredentialMetadata() + + // Act + val actualResult = runCatching { + credentialMetadata.verifyIfCredentialIssuerExist() + } + + // Assert + assertThat(actualResult.isSuccess).isFalse + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Credential metadata does not contain credential_issuer.") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_CREDENTIAL_METADATA_EXCEPTION.value + ) + } + + @Test + fun verifyIfSignedMetadataExist_NoSignedMetadata_ThrowsException() { + // Arrange + val credentialMetadata = CredentialMetadata(credential_issuer = "credential_issuer") + + // Act + val actualResult = runCatching { + credentialMetadata.verifyIfSignedMetadataExist() + } + + // Assert + assertThat(actualResult.isSuccess).isFalse + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Credential metadata does not contain signed_metadata.") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_CREDENTIAL_METADATA_EXCEPTION.value + ) + } +} \ No newline at end of file diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandlerTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandlerTest.kt index ff4913c4..6207116a 100644 --- a/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandlerTest.kt +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/OpenId4VCIRequestHandlerTest.kt @@ -1,15 +1,23 @@ package com.microsoft.walletlibrary.requests.handlers +import com.microsoft.walletlibrary.did.sdk.util.controlflow.NetworkException +import com.microsoft.walletlibrary.did.sdk.util.controlflow.SdkException import com.microsoft.walletlibrary.util.LibraryConfiguration +import com.microsoft.walletlibrary.util.OpenId4VciRequestException +import com.microsoft.walletlibrary.util.OpenId4VciValidationException import com.microsoft.walletlibrary.util.defaultTestSerializer +import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.spyk +import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import kotlin.Result as KotlinResult class OpenId4VCIRequestHandlerTest { private val mockLibraryConfiguration = mockk() - private val openId4VCIRequestHandler = OpenId4VCIRequestHandler(mockLibraryConfiguration) + private val openId4VCIRequestHandler = spyk(OpenId4VCIRequestHandler(mockLibraryConfiguration)) private val expectedCredentialOfferString = """ { "credential_issuer": "metadata_url", @@ -31,7 +39,8 @@ class OpenId4VCIRequestHandlerTest { every { mockLibraryConfiguration.serializer } returns defaultTestSerializer //Act - val actualCanHandleResult = openId4VCIRequestHandler.canHandle(expectedCredentialOfferString) + val actualCanHandleResult = + openId4VCIRequestHandler.canHandle(expectedCredentialOfferString) // Assert assertThat(actualCanHandleResult).isEqualTo(true) @@ -66,9 +75,48 @@ class OpenId4VCIRequestHandlerTest { @Test fun canHandleTest_AnyFailureWithSerializer_ReturnsFalse() { //Act - val actualCanHandleResult = openId4VCIRequestHandler.canHandle(expectedCredentialOfferString) + val actualCanHandleResult = + openId4VCIRequestHandler.canHandle(expectedCredentialOfferString) // Assert assertThat(actualCanHandleResult).isEqualTo(false) } + + @Test + fun handleRequestTest_AnyFailureWithSerializer_ThrowsException() { + runBlocking { + //Act + val actualHandleRequestResult = runCatching { + openId4VCIRequestHandler.handleRequest(expectedCredentialOfferString) + } + // Assert + assertThat(actualHandleRequestResult.isFailure).isTrue + val actualException = actualHandleRequestResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).contains("Failed to decode CredentialOffer") + assertThat((actualException as OpenId4VciValidationException).innerError).isNotNull + } + } + + @Test + fun handleRequestTest_AnyFailureWithFetchingMetadata_ThrowsException() { + // Arrange + every { mockLibraryConfiguration.serializer } returns defaultTestSerializer + coEvery { openId4VCIRequestHandler["fetchCredentialMetadata"]("metadata_url") } returns KotlinResult.failure( + NetworkException("Failed to fetch metadata", false) + ) + + runBlocking { + //Act + val actualHandleRequestResult = runCatching { + openId4VCIRequestHandler.handleRequest(expectedCredentialOfferString) + } + // Assert + assertThat(actualHandleRequestResult.isFailure).isTrue + val actualException = actualHandleRequestResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciRequestException::class.java) + assertThat(actualException?.message).contains("Failed to fetch credential metadata") + assertThat((actualException as OpenId4VciRequestException).innerError).isInstanceOf(NetworkException::class.java) + } + } } \ No newline at end of file diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/SignedMetadataProcessorTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/SignedMetadataProcessorTest.kt new file mode 100644 index 00000000..dc087946 --- /dev/null +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/requests/handlers/SignedMetadataProcessorTest.kt @@ -0,0 +1,304 @@ +package com.microsoft.walletlibrary.requests.handlers + +import com.microsoft.walletlibrary.did.sdk.LinkedDomainsService +import com.microsoft.walletlibrary.did.sdk.VerifiableCredentialSdk +import com.microsoft.walletlibrary.did.sdk.crypto.protocols.jose.jws.JwsToken +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.mappings.getJwk +import com.microsoft.walletlibrary.networking.entities.openid4vci.credentialmetadata.SignedMetadataTokenClaims +import com.microsoft.walletlibrary.requests.RootOfTrust +import com.microsoft.walletlibrary.util.IdentifierDocumentResolutionException +import com.microsoft.walletlibrary.util.LibraryConfiguration +import com.microsoft.walletlibrary.util.OpenId4VciValidationException +import com.microsoft.walletlibrary.util.TokenValidationException +import com.microsoft.walletlibrary.util.VerifiedIdExceptions +import com.microsoft.walletlibrary.util.defaultTestSerializer +import com.microsoft.walletlibrary.wrapper.IdentifierDocumentResolver +import com.microsoft.walletlibrary.wrapper.RootOfTrustResolver +import com.nimbusds.jose.jwk.JWK +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.spyk +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class SignedMetadataProcessorTest { + private val mockLibraryConfiguration: LibraryConfiguration = mockk() + private val signedMetadataProcessor = spyk(SignedMetadataProcessor(mockLibraryConfiguration)) + private val signedMetadataString = "testSignedMetadata" + private val credentialIssuer = "testCredentialIssuer" + private val mockIdentifierDocument = mockk() + private val mockJwsToken: JwsToken = mockk() + private val mockJwk = mockk() + private val mockLinedDomainsService: LinkedDomainsService = mockk() + + init { + mockkStatic(VerifiableCredentialSdk::class) + mockkStatic(IdentifierDocumentResolver::class) + mockkStatic(RootOfTrustResolver::class) + mockkStatic("com.microsoft.walletlibrary.mappings.IdentifierDocumentMappingKt") + mockkStatic("com.microsoft.walletlibrary.mappings.LinkedDomainMappingKt") + mockkStatic("com.microsoft.walletlibrary.mappings.LinkedDomainsServiceExtensionKt") + every { VerifiableCredentialSdk.linkedDomainsService } returns mockLinedDomainsService + every { mockLibraryConfiguration.serializer } returns defaultTestSerializer + every { signedMetadataProcessor["deSerializeSignedMetadata"](signedMetadataString) } returns mockJwsToken + } + + @Test + fun process_KeyIdMissing_ThrowsException() { + // Arrange + mockJwsToken(null) + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("JWS contains no key id") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + } + } + + @Test + fun process_DidMissingInKid_ThrowsException() { + // Arrange + mockJwsToken("#signingKey-1") + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("JWS contains no DID") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + } + } + + @Test + fun process_FailResolvingDocument_ThrowsException() { + // Arrange + mockJwsToken("did:web:test#signingKey-1") + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } throws IdentifierDocumentResolutionException( + "Unable to fetch identifier document" + ) + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(IdentifierDocumentResolutionException::class.java) + assertThat(actualException?.message).isEqualTo("Unable to fetch identifier document") + } + } + + @Test + fun process_FailGetJwkFromDocument_ThrowsException() { + // Arrange + mockIdentifierDocument(null) + mockJwsToken("did:web:test#signingKey-1") + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } returns mockIdentifierDocument + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("JWK with key id did:web:test#signingKey-1 not found in identifier document") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + } + } + + @Test + fun process_FailSignatureVerification_ThrowsException() { + // Arrange + mockIdentifierDocument() + mockJwsToken("did:web:test#signingKey-1", passSignatureVerification = false) + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } returns mockIdentifierDocument + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Invalid signed metadata") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + val actualInnerException = actualException.innerError + assertThat(actualInnerException).isInstanceOf(TokenValidationException::class.java) + assertThat(actualInnerException?.message).isEqualTo("Signature is invalid on Signed metadata") + assertThat((actualInnerException as TokenValidationException).code).isEqualTo( + VerifiedIdExceptions.INVALID_SIGNATURE_EXCEPTION.value + ) + } + } + + @Test + fun process_FailSignedMetadataTokenDeserialization_ThrowsException() { + // Arrange + mockIdentifierDocument() + val mockJwsTokenContent = "testContent" + mockJwsToken("did:web:test#signingKey-1", mockJwsTokenContent) + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } returns mockIdentifierDocument + every { + mockLibraryConfiguration.serializer.decodeFromString( + SignedMetadataTokenClaims.serializer(), + mockJwsTokenContent + ) + } throws SerializationException("Mock SerializationException") + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Invalid signed metadata") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + val actualInnerException = actualException.innerError + assertThat(actualInnerException).isInstanceOf(SerializationException::class.java) + assertThat(actualInnerException?.message).isEqualTo("Mock SerializationException") + } + } + + @Test + fun process_FailSignedMetadataTokenValidation_ThrowsException() { + // Arrange + mockIdentifierDocument() + val signedMetadataTokenClaimsString = + """{"sub":"","iss": "did:web:testissuer","iat": 1707859806}""".trimIndent() + mockJwsToken("did:web:test#signingKey-1", signedMetadataTokenClaimsString) + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } returns mockIdentifierDocument + + runBlocking { + // Act + val actualResult = runCatching { + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + } + + // Assert + assertThat(actualResult.isFailure).isTrue + val actualException = actualResult.exceptionOrNull() + assertThat(actualException).isInstanceOf(OpenId4VciValidationException::class.java) + assertThat(actualException?.message).isEqualTo("Invalid signed metadata") + assertThat((actualException as OpenId4VciValidationException).code).isEqualTo( + VerifiedIdExceptions.MALFORMED_SIGNED_METADATA_EXCEPTION.value + ) + val actualInnerException = actualException.innerError + assertThat(actualInnerException).isInstanceOf(TokenValidationException::class.java) + assertThat(actualInnerException?.message).isEqualTo("Invalid subject property in signed metadata.") + assertThat((actualInnerException as TokenValidationException).code).isEqualTo( + VerifiedIdExceptions.INVALID_PROPERTY_EXCEPTION.value + ) + } + } + + @Test + fun process_LinkedDomainsNotVerifiedByResolver_ReturnsUnverifiedRootOfTrust() { + // Arrange + mockIdentifierDocument() + val signedMetadataTokenClaimsString = + """{"sub":"testCredentialIssuer","iss": "did:web:test","iat": 1707859806}""".trimIndent() + mockJwsToken("did:web:test#signingKey-1", signedMetadataTokenClaimsString) + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } returns mockIdentifierDocument + coEvery { RootOfTrustResolver.resolveRootOfTrust(mockIdentifierDocument) } returns RootOfTrust( + "unverifiedDomain", + false + ) + + runBlocking { + // Act + val actualResult = + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + + // Assert + assertThat(actualResult).isInstanceOf(RootOfTrust::class.java) + assertThat(actualResult.source).isEqualTo("unverifiedDomain") + assertThat(actualResult.verified).isFalse + } + } + + @Test + fun process_LinkedDomainsVerifiedByResolver_ReturnsVerifiedRootOfTrust() { + // Arrange + mockIdentifierDocument() + val signedMetadataTokenClaimsString = + """{"sub":"testCredentialIssuer","iss": "did:web:test","iat": 1707859806}""".trimIndent() + mockJwsToken("did:web:test#signingKey-1", signedMetadataTokenClaimsString) + coEvery { IdentifierDocumentResolver.resolveIdentifierDocument("did:web:test") } returns mockIdentifierDocument + coEvery { RootOfTrustResolver.resolveRootOfTrust(mockIdentifierDocument) } returns RootOfTrust( + "verifiedDomain", + true + ) + + runBlocking { + // Act + val actualResult = + signedMetadataProcessor.process(signedMetadataString, credentialIssuer) + + // Assert + assertThat(actualResult).isInstanceOf(RootOfTrust::class.java) + assertThat(actualResult.source).isEqualTo("verifiedDomain") + assertThat(actualResult.verified).isTrue + } + } + + private fun mockJwsToken(kid: String?, content: String? = null, passSignatureVerification: Boolean = true) { + every { mockJwsToken.keyId } returns kid + if (content != null) { + every { mockJwsToken.content() } returns content + } + every { mockJwsToken.verify(listOf(mockJwk)) } returns passSignatureVerification + kid?.let { mockJwk(it) } + } + + private fun mockJwk(kid: String) { + every { mockJwk.keyID } returns kid + } + + private fun mockIdentifierDocument(jwk: JWK? = mockJwk) { + val jwkToReturn = if (jwk == null) jwk else mockJwk + every { mockIdentifierDocument.getJwk("signingKey-1") } returns jwkToReturn + } +} \ No newline at end of file diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/wrapper/IdentifierDocumentResolverTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/wrapper/IdentifierDocumentResolverTest.kt new file mode 100644 index 00000000..e5e8c47d --- /dev/null +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/wrapper/IdentifierDocumentResolverTest.kt @@ -0,0 +1,61 @@ +package com.microsoft.walletlibrary.wrapper + +import com.microsoft.walletlibrary.did.sdk.LinkedDomainsService +import com.microsoft.walletlibrary.did.sdk.VerifiableCredentialSdk +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.did.sdk.util.controlflow.SdkException +import com.microsoft.walletlibrary.util.IdentifierDocumentResolutionException +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test + +class IdentifierDocumentResolverTest { + private val mockLinedDomainsService: LinkedDomainsService = mockk() + private val mockIdentifierDocument: IdentifierDocument = mockk() + + init { + mockkStatic(VerifiableCredentialSdk::class) + every { VerifiableCredentialSdk.linkedDomainsService } returns mockLinedDomainsService + } + + @Test + fun resolveIdentifierDocument_SuccessfulFetch_ReturnsIdentifierDocument() { + // Arrange + coEvery { mockLinedDomainsService.resolveIdentifierDocument("") } returns Result.success( + mockIdentifierDocument + ) + + runBlocking { + // Act + val actualResult = IdentifierDocumentResolver.resolveIdentifierDocument("") + + // Assert + assertThat(actualResult).isEqualTo(mockIdentifierDocument) + } + } + + @Test + fun resolveIdentifierDocument_FailedFetch_ThrowsException() { + // Arrange + coEvery { mockLinedDomainsService.resolveIdentifierDocument("") } returns Result.failure( + SdkException() + ) + + runBlocking { + // Act + val actualResult = runCatching { + IdentifierDocumentResolver.resolveIdentifierDocument("") + } + + // Assert + assertThat(actualResult.isFailure).isTrue + assertThat(actualResult.exceptionOrNull()).isInstanceOf( + IdentifierDocumentResolutionException::class.java + ) + } + } +} \ No newline at end of file diff --git a/walletlibrary/src/test/java/com/microsoft/walletlibrary/wrapper/RootOfTrustResolverTest.kt b/walletlibrary/src/test/java/com/microsoft/walletlibrary/wrapper/RootOfTrustResolverTest.kt new file mode 100644 index 00000000..e0b211f4 --- /dev/null +++ b/walletlibrary/src/test/java/com/microsoft/walletlibrary/wrapper/RootOfTrustResolverTest.kt @@ -0,0 +1,61 @@ +package com.microsoft.walletlibrary.wrapper + +import com.microsoft.walletlibrary.did.sdk.LinkedDomainsService +import com.microsoft.walletlibrary.did.sdk.VerifiableCredentialSdk +import com.microsoft.walletlibrary.did.sdk.credential.service.models.linkedDomains.LinkedDomainVerified +import com.microsoft.walletlibrary.did.sdk.identifier.models.identifierdocument.IdentifierDocument +import com.microsoft.walletlibrary.did.sdk.util.controlflow.SdkException +import com.microsoft.walletlibrary.mappings.fetchAndVerifyLinkedDomains +import com.microsoft.walletlibrary.requests.RootOfTrust +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.assertj.core.api.Assertions +import org.junit.Test +import kotlin.Result as KotlinResult + +class RootOfTrustResolverTest { + private val mockLinedDomainsService: LinkedDomainsService = mockk() + private val mockIdentifierDocument: IdentifierDocument = mockk() + private val expectedDomain = "testdomain" + + init { + mockkStatic(VerifiableCredentialSdk::class) + mockkStatic("com.microsoft.walletlibrary.mappings.LinkedDomainsServiceExtensionKt") + every { VerifiableCredentialSdk.linkedDomainsService } returns mockLinedDomainsService + } + + @Test + fun resolveRootOfTrust_VerifiedLinkedDomainExists_ReturnsRootOfTrustWithVerifiedDomain() { + // Arrange + coEvery { mockLinedDomainsService.fetchAndVerifyLinkedDomains(mockIdentifierDocument) } returns KotlinResult.success(LinkedDomainVerified(expectedDomain)) + + runBlocking { + // Act + val actualResult = RootOfTrustResolver.resolveRootOfTrust(mockIdentifierDocument) + + // Assert + Assertions.assertThat(actualResult).isInstanceOf(RootOfTrust::class.java) + Assertions.assertThat(actualResult.verified).isTrue + Assertions.assertThat(actualResult.source).isEqualTo(expectedDomain) + } + } + + @Test + fun resolveRootOfTrust_FailedWhileFetchingOrVerifying_ReturnsRootOfTrustWithUnverifiedEmptyDomain() { + // Arrange + coEvery { mockLinedDomainsService.fetchAndVerifyLinkedDomains(mockIdentifierDocument) } returns KotlinResult.failure(SdkException()) + + runBlocking { + // Act + val actualResult = RootOfTrustResolver.resolveRootOfTrust(mockIdentifierDocument) + + // Assert + Assertions.assertThat(actualResult).isInstanceOf(RootOfTrust::class.java) + Assertions.assertThat(actualResult.verified).isFalse + Assertions.assertThat(actualResult.source).isEqualTo("") + } + } +} \ No newline at end of file