From 862296b377176757207a91e62307673e34eaa651 Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Thu, 9 Jul 2026 06:05:33 +0200 Subject: [PATCH 1/2] Extract code to verify VP token response --- CHANGELOG.md | 1 + .../wallet/lib/openid/DcApiVerifier.kt | 168 +------------ .../wallet/lib/openid/OpenId4VpVerifier.kt | 214 +--------------- .../wallet/lib/openid/VpTokenValidator.kt | 230 ++++++++++++++++++ 4 files changed, 256 insertions(+), 357 deletions(-) create mode 100644 vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/VpTokenValidator.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index daa19b4f3..0f4349273 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Release 7.0.0 (unreleased): - Move OpenID4VP request nonce handling out of `VerifierAgent` and consume nonces after successful response validation to prevent replay. - Deprecate abstract base class `AbstractMdocVerifier` - Extract `MdocDeviceSignatureVerifier` from `AbstractMdocVerifier` + - Extract `VpTokenValidator` from common code in `OpenId4VpVerifier` and `DcApiVerifier` - JVM interoperability: - Add `@JvmOverloads` to public API constructors with default parameters across the published modules. - Refactorings: diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt index 5e55a95ca..95470f078 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt @@ -15,7 +15,6 @@ import at.asitplus.dcapi.request.verifier.CredentialRequestOptions import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest.* import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest.OpenId4Vp.SignedDataElement -import at.asitplus.dif.ClaimFormat import at.asitplus.iso.DeviceResponse import at.asitplus.iso.EncryptionInfo import at.asitplus.iso.EncryptionParameters @@ -23,18 +22,13 @@ import at.asitplus.iso.SessionTranscript import at.asitplus.iso.serializeOrigin import at.asitplus.iso.sha256 import at.asitplus.openid.AuthenticationRequestParameters -import at.asitplus.openid.CredentialFormatEnum import at.asitplus.openid.OpenIdConstants import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.openid.ResponseParametersFrom import at.asitplus.openid.SupportedAlgorithmsContainerIso import at.asitplus.openid.SupportedAlgorithmsContainerJwt import at.asitplus.openid.SupportedAlgorithmsContainerSdJwt -import at.asitplus.openid.TransactionDataBase64Url import at.asitplus.openid.VpFormatsSupported -import at.asitplus.openid.dcql.DCQLCredentialQueryIdentifier -import at.asitplus.openid.dcql.DCQLQuery -import at.asitplus.openid.dcql.DCQLQueryResponse import at.asitplus.openid.dcql.toIso180137AnnexCDeviceRequest import at.asitplus.rfc6749OAuth2AuthorizationFramework.ResponseType import at.asitplus.signum.indispensable.CryptoPrivateKey @@ -43,7 +37,6 @@ import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm import at.asitplus.signum.indispensable.cosef.toCoseKey -import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.josef.JsonWebKey import at.asitplus.signum.indispensable.josef.JsonWebKeySet import at.asitplus.signum.indispensable.josef.JweAlgorithm @@ -64,32 +57,20 @@ import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKey import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKeyFun import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest -import at.asitplus.wallet.lib.data.VerifiablePresentationJws import at.asitplus.wallet.lib.data.toBase64UrlJsonString import at.asitplus.wallet.lib.jws.DecryptJwe import at.asitplus.wallet.lib.jws.DecryptJweFun import at.asitplus.wallet.lib.jws.JwsContentTypeConstants -import at.asitplus.wallet.lib.jws.SdJwtSigned import at.asitplus.wallet.lib.jws.SignJwt import at.asitplus.wallet.lib.jws.SignJwtFun import at.asitplus.wallet.lib.jws.VerifyJwsObject import at.asitplus.wallet.lib.jws.VerifyJwsObjectFun -import at.asitplus.wallet.lib.procedures.dcql.DCQLQueryAdapter import at.asitplus.wallet.lib.utils.DefaultMapStore import at.asitplus.wallet.lib.utils.MapStore import io.github.aakira.napier.Napier import io.ktor.utils.io.core.* -import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import kotlinx.serialization.decodeFromByteArray import kotlinx.serialization.encodeToByteArray -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.cancellation.CancellationException import kotlin.jvm.JvmOverloads @@ -143,6 +124,11 @@ class DcApiVerifier @JvmOverloads constructor( verifier = verifier, nonceService = nonceService, ) + private val vpTokenValidator = VpTokenValidator( + nonceAwareVerifier = nonceAwareVerifier, + mdocDeviceSignatureVerifier = mdocDeviceSignatureVerifier, + createSessionTranscript = DcApiSessionTranscriptCalculator(decryptionKeyMaterial), + ) private val supportedJwsAlgorithms = supportedAlgorithms .mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } private val supportedCoseAlgorithms = supportedAlgorithms @@ -482,146 +468,24 @@ class DcApiVerifier @JvmOverloads constructor( } /** - * Extract and verifies verifiable presentations, according to format defined in - * [OpenID for VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), - * as referenced by [OpenID for VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). + * Validates the `vp_token` of the response with the shared [VpTokenValidator], + * enforcing this verifier's transport: the Digital Credentials API. */ @Throws(IllegalArgumentException::class, CancellationException::class) private suspend fun validateVpToken( authnRequest: AuthenticationRequestParameters, responseParameters: ResponseParametersFrom, ): KmmResult = catching { - val expectedNonce = authnRequest.nonce - ?: throw IllegalArgumentException("nonce not present in $authnRequest") - val vpToken = responseParameters.parameters.vpToken - ?: throw IllegalArgumentException("vp_token not present in ${responseParameters.parameters}") - val clientIdRequired = responseParameters.clientIdRequired val originalResponseParameters = responseParameters.originalResponseParameters require(originalResponseParameters is ResponseParametersFrom.DcApi) { "Unsupported response parameters: $originalResponseParameters" } authnRequest.verifyExpectedOrigin(originalResponseParameters.origin) - - authnRequest.dcqlQuery?.let { query -> - val presentation = vpToken.jsonObject.mapKeys { - DCQLCredentialQueryIdentifier(it.key) - }.mapValues { (credentialQueryId, relatedPresentation) -> - val credentialQuery = query.credentialQuery(credentialQueryId) - ?: throw IllegalArgumentException("Unknown credential query identifier.") - - relatedPresentation.jsonArray.map { - verifyPresentationResult( - claimFormat = credentialQuery.format.toClaimFormat(), - relatedPresentation = it.jsonPrimitive, - expectedNonce = expectedNonce, - input = responseParameters, - clientId = authnRequest.clientId, - responseUrl = authnRequest.responseUrl - ?: authnRequest.redirectUrlExtracted, - transactionData = authnRequest.transactionData, - clientIdRequired = clientIdRequired, - origin = originalResponseParameters.origin, - requireCryptographicHolderBinding = query.credentialQuery(credentialQueryId)?.requireCryptographicHolderBinding, - ) - } - } - val submissionRequirementsValidationResult = catching { - val queryResponse = presentation.mapValues { - it.value.map { - it.getOrThrow() - } - } - DCQLQueryAdapter(query).checkSubmissionRequirements( - DCQLQueryResponse(queryResponse) - ).getOrThrow() - } - - // TODO: Validation errors are (sometimes) put into a VerifiableDCQLPresentationValidationResults which means that the success page is shown - // However, if we return a ValidationError, a BadRequest is sent, which is not shown to the user in the UI - VpTokenValidationResultDCQL( - credentialQueryResponseValidations = presentation, - submissionRequirementsValidationResult = submissionRequirementsValidationResult, - ) - } ?: throw IllegalArgumentException("Unsupported presentation mechanism") - } - - private fun DCQLQuery.credentialQuery(id: DCQLCredentialQueryIdentifier) = - credentials.associateBy { it.id }[id] - - private fun CredentialFormatEnum.toClaimFormat(): ClaimFormat = when (this) { - CredentialFormatEnum.JWT_VC -> ClaimFormat.JWT_VP - CredentialFormatEnum.DC_SD_JWT -> ClaimFormat.SD_JWT - CredentialFormatEnum.MSO_MDOC -> ClaimFormat.MSO_MDOC - CredentialFormatEnum.NONE, - CredentialFormatEnum.JWT_VC_JSON_LD, - CredentialFormatEnum.JSON_LD, - -> throw IllegalStateException("Unsupported credential format") - } - - /** - * Extract and verifies verifiable presentations, according to format defined in - * [OpenID for VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), - * as referenced by [OpenID for VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). - */ - private suspend fun verifyPresentationResult( - claimFormat: ClaimFormat, - relatedPresentation: JsonElement, - expectedNonce: String, - input: ResponseParametersFrom, - clientId: String?, - responseUrl: String?, - transactionData: List?, - clientIdRequired: Boolean, - origin: String?, - requireCryptographicHolderBinding: Boolean? = null, - ): KmmResult = catching { - when (claimFormat) { - ClaimFormat.SD_JWT -> { - val sdJwt = SdJwtSigned.parseCatching(relatedPresentation.extractContent()).getOrElse { - throw IllegalArgumentException("relatedPresentation") - } - nonceAwareVerifier.verifyPresentationSdJwt( - input = sdJwt, - challenge = expectedNonce, - transactionData = transactionData, - requireCryptographicHolderBinding = requireCryptographicHolderBinding != false, - audience = origin?.let { "origin:$it" }, - ) - } - - ClaimFormat.JWT_VP -> if (requireCryptographicHolderBinding != false) { - nonceAwareVerifier.verifyPresentationVcJwt( - input = JwsCompactTyped( - relatedPresentation.extractContent() - ), - challenge = expectedNonce - ) - } else { - nonceAwareVerifier.verifyUnsignedVcJws( - input = relatedPresentation.extractContent() - ).map { - Verifier.VerifyPresentationResult.SuccessUnsigned(it.vc) - } - } - - ClaimFormat.MSO_MDOC -> nonceAwareVerifier.verifyPresentationIsoMdoc( - input = relatedPresentation.extractContent().decodeToByteArray(Base64UrlStrict) - .let { coseCompliantSerializer.decodeFromByteArray(it) }, - verifyDocument = mdocDeviceSignatureVerifier.verifyDocument( - sessionTranscript = DcApiSessionTranscriptCalculator(decryptionKeyMaterial)( - input = input, - clientId = clientId, - expectedNonce = expectedNonce, - hasBeenEncrypted = input.hasBeenEncrypted, - responseUrl = responseUrl, - clientIdRequired = clientIdRequired, - origin = origin - ) - ) - ) - - else -> throw IllegalArgumentException("descriptor.format: $claimFormat") - }.getOrThrow() + vpTokenValidator.validateVpToken( + authnRequest = authnRequest, + responseParameters = responseParameters, + origin = originalResponseParameters.origin, + ).getOrThrow() } fun createDcApiSessionTranscriptAnnexC( @@ -635,14 +499,6 @@ class DcApiVerifier @JvmOverloads constructor( ) ) - // To be reconsidered when supporting [DCQLCredentialQueryInstance.multiple] - private fun JsonElement.extractContent(): String = when (this) { - is JsonArray -> first().extractContent() - is JsonObject -> toString() - is JsonPrimitive -> content - JsonNull -> throw IllegalArgumentException("Can't extract string from JsonNull") - } - // should always be ecdh-es for encryption private fun JsonWebKey.withAlgorithm(): JsonWebKey = this.copy(algorithm = JweAlgorithm.ECDH_ES) } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt index e249b9a9b..d60e57bab 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt @@ -3,15 +3,10 @@ package at.asitplus.wallet.lib.openid import at.asitplus.KmmResult import at.asitplus.catching import at.asitplus.dcapi.OpenId4VpResponse -import at.asitplus.dif.ClaimFormat import at.asitplus.dif.DifInputDescriptor import at.asitplus.dif.FormatContainerJwt import at.asitplus.dif.FormatContainerSdJwt -import at.asitplus.dif.PresentationSubmissionDescriptor -import at.asitplus.iso.DeviceResponse -import at.asitplus.jsonpath.JsonPath import at.asitplus.openid.AuthenticationRequestParameters -import at.asitplus.openid.CredentialFormatEnum import at.asitplus.openid.IdToken import at.asitplus.openid.IdTokenType import at.asitplus.openid.JarRequestParameters @@ -22,16 +17,10 @@ import at.asitplus.openid.ResponseParametersFrom import at.asitplus.openid.SupportedAlgorithmsContainerIso import at.asitplus.openid.SupportedAlgorithmsContainerJwt import at.asitplus.openid.SupportedAlgorithmsContainerSdJwt -import at.asitplus.openid.TransactionDataBase64Url import at.asitplus.openid.VpFormatsSupported -import at.asitplus.openid.dcql.DCQLCredentialQueryIdentifier -import at.asitplus.openid.dcql.DCQLQuery -import at.asitplus.openid.dcql.DCQLQueryResponse import at.asitplus.rfc6749OAuth2AuthorizationFramework.ResponseType import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm -import at.asitplus.signum.indispensable.io.Base64UrlStrict import at.asitplus.signum.indispensable.josef.JsonWebKey import at.asitplus.signum.indispensable.josef.JsonWebKeySet import at.asitplus.signum.indispensable.josef.JweAlgorithm @@ -47,38 +36,24 @@ import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.NonceChallengeVerifier import at.asitplus.wallet.lib.agent.Verifier -import at.asitplus.wallet.lib.agent.Verifier.VerifyPresentationResult import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKey import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKeyFun import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest import at.asitplus.wallet.lib.data.CredentialPresentationRequest.PresentationExchangeRequest -import at.asitplus.wallet.lib.data.VerifiablePresentationJws import at.asitplus.wallet.lib.data.toBase64UrlJsonString import at.asitplus.wallet.lib.jws.DecryptJwe import at.asitplus.wallet.lib.jws.DecryptJweFun import at.asitplus.wallet.lib.jws.JwsContentTypeConstants -import at.asitplus.wallet.lib.jws.SdJwtSigned import at.asitplus.wallet.lib.jws.SignJwt import at.asitplus.wallet.lib.jws.SignJwtFun import at.asitplus.wallet.lib.jws.VerifyJwsObject import at.asitplus.wallet.lib.jws.VerifyJwsObjectFun import at.asitplus.wallet.lib.oidvci.encodeToParameters -import at.asitplus.wallet.lib.procedures.dcql.DCQLQueryAdapter import at.asitplus.wallet.lib.utils.DefaultMapStore import at.asitplus.wallet.lib.utils.MapStore import io.github.aakira.napier.Napier import io.ktor.http.* -import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray -import kotlinx.serialization.decodeFromByteArray -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlin.coroutines.cancellation.CancellationException import kotlin.jvm.JvmOverloads import kotlin.time.Clock @@ -126,13 +101,16 @@ class OpenId4VpVerifier @JvmOverloads constructor( private val supportedJweEncryptionAlgorithms: Set = JweEncryption.entries.toSet(), ) { - private val mdocDeviceSignatureVerifier = MdocDeviceSignatureVerifier(verifyCoseSignature = verifyCoseSignature) - private val nonceAwareVerifier = NonceChallengeVerifier( verifierId = clientIdScheme.clientId, verifier = verifier, nonceService = nonceService, ) + private val vpTokenValidator = VpTokenValidator( + nonceAwareVerifier = nonceAwareVerifier, + mdocDeviceSignatureVerifier = MdocDeviceSignatureVerifier(verifyCoseSignature = verifyCoseSignature), + createSessionTranscript = UrlSessionTranscriptCalculator(decryptionKeyMaterial), + ) private val supportedJwsAlgorithms = supportedAlgorithms .mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } private val supportedCoseAlgorithms = supportedAlgorithms @@ -512,192 +490,26 @@ class OpenId4VpVerifier @JvmOverloads constructor( } /** - * Extract and verifies verifiable presentations, according to format defined in - * [OpenID for VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), - * as referenced by [OpenID for VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). + * Validates the `vp_token` of the response with the shared [VpTokenValidator], + * enforcing this verifier's transport: URL/QR, i.e. anything but the Digital Credentials API. */ @Throws(IllegalArgumentException::class, CancellationException::class) private suspend fun validateVpToken( authnRequest: AuthenticationRequestParameters, responseParameters: ResponseParametersFrom, ): KmmResult = catching { - val expectedNonce = authnRequest.nonce - ?: throw IllegalArgumentException("nonce not present in $authnRequest") - val vpToken = responseParameters.parameters.vpToken - ?: throw IllegalArgumentException("vp_token not present in ${responseParameters.parameters}") - val clientIdRequired = responseParameters.clientIdRequired - val originalResponseParameters = responseParameters.originalResponseParameters - require(originalResponseParameters !is ResponseParametersFrom.DcApi) { + require(responseParameters.originalResponseParameters !is ResponseParametersFrom.DcApi) { "DCAPI verification is not supported, use DcApiVerifier" } - - authnRequest.presentationDefinition?.let { - val presentationSubmission = responseParameters.parameters.presentationSubmission?.descriptorMap - ?: throw IllegalArgumentException("Presentation Exchange need to present a presentation submission.") - - val presentation = presentationSubmission.associate { descriptor -> - descriptor.id to verifyPresentationResult( - claimFormat = descriptor.format, - relatedPresentation = descriptor.relatedPresentation(vpToken), - expectedNonce = expectedNonce, - input = responseParameters, - clientId = authnRequest.clientId, - responseUrl = authnRequest.responseUrl ?: authnRequest.redirectUrlExtracted, - transactionData = authnRequest.transactionData, - clientIdRequired = clientIdRequired, - origin = null, - ) - } - - VpTokenValidationResultPresentationExchange( - inputDescriptorResponseValidations = presentation, - ) - } ?: authnRequest.dcqlQuery?.let { query -> - val presentation = vpToken.jsonObject.mapKeys { - DCQLCredentialQueryIdentifier(it.key) - }.mapValues { (credentialQueryId, relatedPresentation) -> - val credentialQuery = query.credentialQuery(credentialQueryId) - ?: throw IllegalArgumentException("Unknown credential query identifier.") - - relatedPresentation.jsonArray.map { - verifyPresentationResult( - claimFormat = credentialQuery.format.toClaimFormat(), - relatedPresentation = it.jsonPrimitive, - expectedNonce = expectedNonce, - input = responseParameters, - clientId = authnRequest.clientId, - responseUrl = authnRequest.responseUrl - ?: authnRequest.redirectUrlExtracted, - transactionData = authnRequest.transactionData, - clientIdRequired = clientIdRequired, - origin = null, - requireCryptographicHolderBinding = query.credentialQuery(credentialQueryId)?.requireCryptographicHolderBinding, - ) - } - } - val submissionRequirementsValidationResult = catching { - val queryResponse = presentation.mapValues { - it.value.map { - it.getOrThrow() - } - } - DCQLQueryAdapter(query).checkSubmissionRequirements( - DCQLQueryResponse(queryResponse) - ).getOrThrow() - } - - // TODO: Validation errors are (sometimes) put into a VerifiableDCQLPresentationValidationResults which means that the success page is shown - // However, if we return a ValidationError, a BadRequest is sent, which is not shown to the user in the UI - VpTokenValidationResultDCQL( - credentialQueryResponseValidations = presentation, - submissionRequirementsValidationResult = submissionRequirementsValidationResult, - ) - } ?: throw IllegalArgumentException("Unsupported presentation mechanism") - } - - private fun DCQLQuery.credentialQuery(id: DCQLCredentialQueryIdentifier) = - credentials.associateBy { it.id }[id] - - private fun PresentationSubmissionDescriptor.relatedPresentation(vpToken: JsonElement) = - JsonPath(cumulativeJsonPath).query(vpToken).first().value - - private fun CredentialFormatEnum.toClaimFormat(): ClaimFormat = when (this) { - CredentialFormatEnum.JWT_VC -> ClaimFormat.JWT_VP - CredentialFormatEnum.DC_SD_JWT -> ClaimFormat.SD_JWT - CredentialFormatEnum.MSO_MDOC -> ClaimFormat.MSO_MDOC - CredentialFormatEnum.NONE, - CredentialFormatEnum.JWT_VC_JSON_LD, - CredentialFormatEnum.JSON_LD, - -> throw IllegalStateException("Unsupported credential format") - } - - /** - * Extract and verifies verifiable presentations, according to format defined in - * [OpenID for VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), - * as referenced by [OpenID for VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). - */ - private suspend fun verifyPresentationResult( - claimFormat: ClaimFormat, - relatedPresentation: JsonElement, - expectedNonce: String, - input: ResponseParametersFrom, - clientId: String?, - responseUrl: String?, - transactionData: List?, - clientIdRequired: Boolean, - origin: String?, - requireCryptographicHolderBinding: Boolean? = null, - ): KmmResult = catching { - when (claimFormat) { - ClaimFormat.SD_JWT -> { - val sdJwt = SdJwtSigned.parseCatching(relatedPresentation.extractContent()).getOrElse { - throw IllegalArgumentException("relatedPresentation") - } - nonceAwareVerifier.verifyPresentationSdJwt( - input = sdJwt, - challenge = expectedNonce, - transactionData = transactionData, - requireCryptographicHolderBinding = requireCryptographicHolderBinding != false, - audience = origin?.let { "origin:$it" }, - ) - } - - ClaimFormat.JWT_VP -> if (requireCryptographicHolderBinding != false) { - nonceAwareVerifier.verifyPresentationVcJwt( - input = JwsCompactTyped( - relatedPresentation.extractContent() - ), - challenge = expectedNonce - ) - } else { - nonceAwareVerifier.verifyUnsignedVcJws( - input = relatedPresentation.extractContent() - ).map { - VerifyPresentationResult.SuccessUnsigned(it.vc) - } - } - - ClaimFormat.MSO_MDOC -> nonceAwareVerifier.verifyPresentationIsoMdoc( - input = relatedPresentation.extractContent().decodeToByteArray(Base64UrlStrict) - .let { coseCompliantSerializer.decodeFromByteArray(it) }, - verifyDocument = mdocDeviceSignatureVerifier.verifyDocument( - sessionTranscript = UrlSessionTranscriptCalculator(decryptionKeyMaterial)( - input = input, - clientId = clientId, - expectedNonce = expectedNonce, - hasBeenEncrypted = input.hasBeenEncrypted, - responseUrl = responseUrl, - clientIdRequired = clientIdRequired, - origin = origin - ) - ) - ) - - else -> throw IllegalArgumentException("descriptor.format: $claimFormat") - }.getOrThrow() - } - - // To be reconsidered when supporting [DCQLCredentialQueryInstance.multiple] - private fun JsonElement.extractContent(): String = when (this) { - is JsonArray -> first().extractContent() - is JsonObject -> toString() - is JsonPrimitive -> content - JsonNull -> throw IllegalArgumentException("Can't extract string from JsonNull") + vpTokenValidator.validateVpToken( + authnRequest = authnRequest, + responseParameters = responseParameters, + origin = null, + ).getOrThrow() } // should always be ecdh-es for encryption private fun JsonWebKey.withAlgorithm(): JsonWebKey = this.copy(algorithm = JweAlgorithm.ECDH_ES) } -private val PresentationSubmissionDescriptor.cumulativeJsonPath: String - get() { - var cummulativeJsonPath = this.path - var descriptorIterator = this.nestedPath - while (descriptorIterator != null) { - cummulativeJsonPath += descriptorIterator.path.substring(1) - descriptorIterator = descriptorIterator.nestedPath - } - return cummulativeJsonPath - } - diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/VpTokenValidator.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/VpTokenValidator.kt new file mode 100644 index 000000000..39df0a3ae --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/VpTokenValidator.kt @@ -0,0 +1,230 @@ +package at.asitplus.wallet.lib.openid + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.dif.ClaimFormat +import at.asitplus.dif.PresentationSubmissionDescriptor +import at.asitplus.iso.DeviceResponse +import at.asitplus.jsonpath.JsonPath +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.CredentialFormatEnum +import at.asitplus.openid.ResponseParametersFrom +import at.asitplus.openid.TransactionDataBase64Url +import at.asitplus.openid.dcql.DCQLCredentialQueryIdentifier +import at.asitplus.openid.dcql.DCQLQuery +import at.asitplus.openid.dcql.DCQLQueryResponse +import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer +import at.asitplus.signum.indispensable.io.Base64UrlStrict +import at.asitplus.signum.indispensable.josef.JwsCompactTyped +import at.asitplus.wallet.lib.MdocDeviceSignatureVerifier +import at.asitplus.wallet.lib.agent.Verifier +import at.asitplus.wallet.lib.agent.Verifier.VerifyPresentationResult +import at.asitplus.wallet.lib.data.VerifiablePresentationJws +import at.asitplus.wallet.lib.jws.SdJwtSigned +import at.asitplus.wallet.lib.procedures.dcql.DCQLQueryAdapter +import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.coroutines.cancellation.CancellationException + +/** + * Validates the `vp_token` of an OpenID4VP authentication response, independently of the transport + * that delivered it: URL/QR (see [OpenId4VpVerifier]) or the W3C Digital Credentials API (see [DcApiVerifier]). + * The only transport-specific step, calculation of the ISO session transcript, is delegated to + * the injected [SessionTranscriptCalculator]. + */ +internal class VpTokenValidator( + /** Verifies the presentations in the response; the verifiers pass their [at.asitplus.wallet.lib.agent.NonceChallengeVerifier]. */ + private val nonceAwareVerifier: Verifier, + /** Verifies the mdoc device signature against the session transcript. */ + private val mdocDeviceSignatureVerifier: MdocDeviceSignatureVerifier, + /** Calculates the ISO session transcript for the transport the response was received over. */ + private val createSessionTranscript: SessionTranscriptCalculator, +) { + + /** + * Extract and verifies verifiable presentations, according to format defined in + * [OpenID for VCI](https://openid.net/specs/openid-4-verifiable-credential-issuance-1_0.html), + * as referenced by [OpenID for VP](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). + * + * [origin] is the calling origin from the W3C Digital Credentials API, or `null` for URL/QR transport. + */ + @Throws(IllegalArgumentException::class, CancellationException::class) + suspend fun validateVpToken( + authnRequest: AuthenticationRequestParameters, + responseParameters: ResponseParametersFrom, + origin: String?, + ): KmmResult = catching { + val expectedNonce = authnRequest.nonce + ?: throw IllegalArgumentException("nonce not present in $authnRequest") + val vpToken = responseParameters.parameters.vpToken + ?: throw IllegalArgumentException("vp_token not present in ${responseParameters.parameters}") + val clientIdRequired = responseParameters.clientIdRequired + + authnRequest.presentationDefinition?.let { + val presentationSubmission = responseParameters.parameters.presentationSubmission?.descriptorMap + ?: throw IllegalArgumentException("Presentation Exchange need to present a presentation submission.") + + val presentation = presentationSubmission.associate { descriptor -> + descriptor.id to verifyPresentationResult( + claimFormat = descriptor.format, + relatedPresentation = descriptor.relatedPresentation(vpToken), + expectedNonce = expectedNonce, + input = responseParameters, + clientId = authnRequest.clientId, + responseUrl = authnRequest.responseUrl ?: authnRequest.redirectUrlExtracted, + transactionData = authnRequest.transactionData, + clientIdRequired = clientIdRequired, + origin = origin, + ) + } + + VpTokenValidationResultPresentationExchange( + inputDescriptorResponseValidations = presentation, + ) + } ?: authnRequest.dcqlQuery?.let { query -> + val presentation = vpToken.jsonObject.mapKeys { + DCQLCredentialQueryIdentifier(it.key) + }.mapValues { (credentialQueryId, relatedPresentation) -> + val credentialQuery = query.credentialQuery(credentialQueryId) + ?: throw IllegalArgumentException("Unknown credential query identifier.") + + relatedPresentation.jsonArray.map { + verifyPresentationResult( + claimFormat = credentialQuery.format.toClaimFormat(), + relatedPresentation = it.jsonPrimitive, + expectedNonce = expectedNonce, + input = responseParameters, + clientId = authnRequest.clientId, + responseUrl = authnRequest.responseUrl + ?: authnRequest.redirectUrlExtracted, + transactionData = authnRequest.transactionData, + clientIdRequired = clientIdRequired, + origin = origin, + requireCryptographicHolderBinding = query.credentialQuery(credentialQueryId)?.requireCryptographicHolderBinding, + ) + } + } + val submissionRequirementsValidationResult = catching { + val queryResponse = presentation.mapValues { + it.value.map { + it.getOrThrow() + } + } + DCQLQueryAdapter(query).checkSubmissionRequirements( + DCQLQueryResponse(queryResponse) + ).getOrThrow() + } + + // TODO: Validation errors are (sometimes) put into a VerifiableDCQLPresentationValidationResults which means that the success page is shown + // However, if we return a ValidationError, a BadRequest is sent, which is not shown to the user in the UI + VpTokenValidationResultDCQL( + credentialQueryResponseValidations = presentation, + submissionRequirementsValidationResult = submissionRequirementsValidationResult, + ) + } ?: throw IllegalArgumentException("Unsupported presentation mechanism") + } + + private fun DCQLQuery.credentialQuery(id: DCQLCredentialQueryIdentifier) = + credentials.associateBy { it.id }[id] + + private fun PresentationSubmissionDescriptor.relatedPresentation(vpToken: JsonElement) = + JsonPath(cumulativeJsonPath).query(vpToken).first().value + + private fun CredentialFormatEnum.toClaimFormat(): ClaimFormat = when (this) { + CredentialFormatEnum.JWT_VC -> ClaimFormat.JWT_VP + CredentialFormatEnum.DC_SD_JWT -> ClaimFormat.SD_JWT + CredentialFormatEnum.MSO_MDOC -> ClaimFormat.MSO_MDOC + CredentialFormatEnum.NONE, + CredentialFormatEnum.JWT_VC_JSON_LD, + CredentialFormatEnum.JSON_LD, + -> throw IllegalStateException("Unsupported credential format") + } + + private suspend fun verifyPresentationResult( + claimFormat: ClaimFormat, + relatedPresentation: JsonElement, + expectedNonce: String, + input: ResponseParametersFrom, + clientId: String?, + responseUrl: String?, + transactionData: List?, + clientIdRequired: Boolean, + origin: String?, + requireCryptographicHolderBinding: Boolean? = null, + ): KmmResult = catching { + when (claimFormat) { + ClaimFormat.SD_JWT -> { + val sdJwt = SdJwtSigned.parseCatching(relatedPresentation.extractContent()).getOrElse { + throw IllegalArgumentException("relatedPresentation") + } + nonceAwareVerifier.verifyPresentationSdJwt( + input = sdJwt, + challenge = expectedNonce, + transactionData = transactionData, + requireCryptographicHolderBinding = requireCryptographicHolderBinding != false, + audience = origin?.let { "origin:$it" }, + ) + } + + ClaimFormat.JWT_VP -> if (requireCryptographicHolderBinding != false) { + nonceAwareVerifier.verifyPresentationVcJwt( + input = JwsCompactTyped( + relatedPresentation.extractContent() + ), + challenge = expectedNonce + ) + } else { + nonceAwareVerifier.verifyUnsignedVcJws( + input = relatedPresentation.extractContent() + ).map { + VerifyPresentationResult.SuccessUnsigned(it.vc) + } + } + + ClaimFormat.MSO_MDOC -> nonceAwareVerifier.verifyPresentationIsoMdoc( + input = relatedPresentation.extractContent().decodeToByteArray(Base64UrlStrict) + .let { coseCompliantSerializer.decodeFromByteArray(it) }, + verifyDocument = mdocDeviceSignatureVerifier.verifyDocument( + sessionTranscript = createSessionTranscript( + input = input, + clientId = clientId, + expectedNonce = expectedNonce, + hasBeenEncrypted = input.hasBeenEncrypted, + responseUrl = responseUrl, + clientIdRequired = clientIdRequired, + origin = origin + ) + ) + ) + + else -> throw IllegalArgumentException("descriptor.format: $claimFormat") + }.getOrThrow() + } + + // To be reconsidered when supporting [DCQLCredentialQueryInstance.multiple] + private fun JsonElement.extractContent(): String = when (this) { + is JsonArray -> first().extractContent() + is JsonObject -> toString() + is JsonPrimitive -> content + JsonNull -> throw IllegalArgumentException("Can't extract string from JsonNull") + } +} + +private val PresentationSubmissionDescriptor.cumulativeJsonPath: String + get() { + var cummulativeJsonPath = this.path + var descriptorIterator = this.nestedPath + while (descriptorIterator != null) { + cummulativeJsonPath += descriptorIterator.path.substring(1) + descriptorIterator = descriptorIterator.nestedPath + } + return cummulativeJsonPath + } From 9fd4bc551534fa238a7778bbf7915d1ba808c63a Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Thu, 9 Jul 2026 06:05:37 +0200 Subject: [PATCH 2/2] Extract OpenId4VpRequestFactory shared by both verifiers --- .../wallet/lib/openid/DcApiVerifier.kt | 172 ++---------- .../lib/openid/OpenId4VpRequestFactory.kt | 256 ++++++++++++++++++ .../lib/openid/OpenId4VpRequestOptions.kt | 1 + .../wallet/lib/openid/OpenId4VpVerifier.kt | 174 ++---------- 4 files changed, 293 insertions(+), 310 deletions(-) create mode 100644 vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestFactory.kt diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt index 95470f078..f0f955222 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/DcApiVerifier.kt @@ -25,26 +25,15 @@ import at.asitplus.openid.AuthenticationRequestParameters import at.asitplus.openid.OpenIdConstants import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.openid.ResponseParametersFrom -import at.asitplus.openid.SupportedAlgorithmsContainerIso -import at.asitplus.openid.SupportedAlgorithmsContainerJwt -import at.asitplus.openid.SupportedAlgorithmsContainerSdJwt -import at.asitplus.openid.VpFormatsSupported import at.asitplus.openid.dcql.toIso180137AnnexCDeviceRequest import at.asitplus.rfc6749OAuth2AuthorizationFramework.ResponseType import at.asitplus.signum.indispensable.CryptoPrivateKey import at.asitplus.signum.indispensable.SecretExposure import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer -import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm import at.asitplus.signum.indispensable.cosef.toCoseKey -import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.signum.indispensable.josef.JsonWebKeySet -import at.asitplus.signum.indispensable.josef.JweAlgorithm import at.asitplus.signum.indispensable.josef.JweEncryption -import at.asitplus.signum.indispensable.josef.JwsCompactTyped import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer -import at.asitplus.signum.indispensable.josef.toJsonWebKey -import at.asitplus.signum.indispensable.josef.toJwsAlgorithm import at.asitplus.signum.supreme.asymmetric.HPKE import at.asitplus.wallet.lib.DefaultNonceService import at.asitplus.wallet.lib.MdocDeviceSignatureVerifier @@ -57,10 +46,8 @@ import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKey import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKeyFun import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest -import at.asitplus.wallet.lib.data.toBase64UrlJsonString import at.asitplus.wallet.lib.jws.DecryptJwe import at.asitplus.wallet.lib.jws.DecryptJweFun -import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.SignJwt import at.asitplus.wallet.lib.jws.SignJwtFun import at.asitplus.wallet.lib.jws.VerifyJwsObject @@ -95,7 +82,7 @@ class DcApiVerifier @JvmOverloads constructor( private val decryptionKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert(), /** Decrypts encrypted responses from holders. */ private val decryptJwe: DecryptJweFun = DecryptJwe(decryptionKeyMaterial), - /** Signs authentication requests in [createSignedRequestObject]. */ + /** Signs authentication requests for signed DC API requests. */ private val signAuthnRequest: SignJwtFun = SignJwt(keyMaterial, JwsHeaderClientIdScheme(clientIdScheme)), /** Validates signed responses from holders. */ @@ -124,56 +111,32 @@ class DcApiVerifier @JvmOverloads constructor( verifier = verifier, nonceService = nonceService, ) + private val requestFactory = OpenId4VpRequestFactory( + clientIdScheme = clientIdScheme, + decryptionKeyMaterial = decryptionKeyMaterial, + signAuthnRequest = signAuthnRequest, + nonceService = nonceService, + supportedAlgorithms = supportedAlgorithms, + stateToAuthnRequestStore = stateToAuthnRequestStore, + supportedJweEncryptionAlgorithms = supportedJweEncryptionAlgorithms, + ) private val vpTokenValidator = VpTokenValidator( nonceAwareVerifier = nonceAwareVerifier, mdocDeviceSignatureVerifier = mdocDeviceSignatureVerifier, createSessionTranscript = DcApiSessionTranscriptCalculator(decryptionKeyMaterial), ) - private val supportedJwsAlgorithms = supportedAlgorithms - .mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } - private val supportedCoseAlgorithms = supportedAlgorithms - .mapNotNull { it.toCoseAlgorithm().getOrNull()?.coseValue } private val responseParser = ResponseParser(decryptJwe, verifyJwsObject) /** * Creates the [at.asitplus.openid.RelyingPartyMetadata], without encryption (see [metadataWithEncryption]) */ - val metadata by lazy { - RelyingPartyMetadata( - redirectUris = listOfNotNull((clientIdScheme as? ClientIdScheme.RedirectUri)?.redirectUri), - jsonWebKeySet = JsonWebKeySet( - listOf( - decryptionKeyMaterial.publicKey.toJsonWebKey(decryptionKeyMaterial.identifier).withAlgorithm() - ) - ), - vpFormatsSupported = VpFormatsSupported( - vcJwt = SupportedAlgorithmsContainerJwt( - algorithmStrings = supportedJwsAlgorithms.toSet() - ), - dcSdJwt = SupportedAlgorithmsContainerSdJwt( - sdJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), - kbJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), - ), - msoMdoc = SupportedAlgorithmsContainerIso( - issuerAuthAlgorithmInts = supportedCoseAlgorithms.toSet(), - deviceAuthAlgorithmInts = supportedCoseAlgorithms.toSet(), - ), - ) - ) - } + val metadata get() = requestFactory.metadata /** * Creates the [RelyingPartyMetadata], but with parameters set to request encryption of pushed authentication * responses, see [RelyingPartyMetadata.encryptedResponseEncValues]. */ - val metadataWithEncryption by lazy { - metadata.copy( - encryptedResponseEncValuesSupportedString = supportedJweEncryptionAlgorithms.map { it.identifier }.toSet(), - jsonWebKeySet = metadata.jsonWebKeySet?.let { - JsonWebKeySet(it.keys.map { it.copy(publicKeyUse = "enc") }) - } - ) - } + val metadataWithEncryption get() = requestFactory.metadataWithEncryption /** * Creates a new authentication request for the W3C Digital Credentials API, i.e. the object that the @@ -206,12 +169,17 @@ class DcApiVerifier @JvmOverloads constructor( ): DigitalCredentialGetRequest = when (this) { is DcApiCreationOptions.OpenId4VpUnsigned -> OpenId4VpUnsigned( // client_id MUST be omitted in unsigned requests, per OpenID4VP 1.0 Appendix A.3.1 - createPlainAuthnRequest(requestOptions.requireEncryptionKeyConveyed().copy(populateClientId = false)) + requestFactory.createPlainAuthnRequest( + requestFactory.requireEncryptionKeyConveyed(requestOptions).copy(populateClientId = false) + ) ) is DcApiCreationOptions.OpenId4VpSigned -> OpenId4VpSigned( SignedDataElement( - createSignedRequestObject(requestOptions.requireEncryptionKeyConveyed()).getOrThrow().jws + requestFactory.createSignedRequestObject( + requestFactory.requireEncryptionKeyConveyed(requestOptions), + RequestObjectSigning.DcApi, + ).getOrThrow().jws ) ) @@ -220,31 +188,6 @@ class DcApiVerifier @JvmOverloads constructor( ) } - private suspend fun createSignedRequestObject( - requestOptions: OpenId4VpRequestOptions, - ): KmmResult> = catching { - val requestObject = createPlainAuthnRequest(requestOptions) - signAuthnRequest( - JwsContentTypeConstants.OAUTH_AUTHZ_REQUEST, - requestObject.copy( - // per RFC 9101, `iss` is the client identifier; wallets identify us via - // client_id and the request signature, an audience cannot be known upfront - issuer = when (clientIdScheme) { - is ClientIdScheme.PreRegistered -> clientIdScheme.issuerUri ?: clientIdScheme.clientId - else -> clientIdScheme.clientId - }, - ), - AuthenticationRequestParameters.serializer(), - ).getOrThrow() - } - - private suspend fun createPlainAuthnRequest( - requestOptions: OpenId4VpRequestOptions, - ) = requestOptions.toAuthnRequest() - .also { - storeAuthnRequest(it, requestOptions.state) - } - private suspend fun createIsoMdocRequest( requestOptions: OpenId4VpRequestOptions, ): IsoMdocRequest { @@ -260,61 +203,6 @@ class DcApiVerifier @JvmOverloads constructor( .also { stateToIsoMdocRequestStore.put(requestOptions.state, it) } } - private suspend fun OpenId4VpRequestOptions.toAuthnRequest(): AuthenticationRequestParameters = - AuthenticationRequestParameters( - responseType = responseType, - clientId = if (populateClientId) clientIdScheme.clientId else null, - redirectUrl = if (!isAnyDirectPost) clientIdScheme.redirectUri else null, - responseUrl = responseUrl, - // Using scope as an alias for a well-defined Presentation Exchange or DCQL is not supported - scope = null, - nonce = nonceAwareVerifier.provideNonce(), - clientMetadata = clientMetadata(), - idTokenType = null, - responseMode = responseMode, - state = null, - // Presentation Exchange is not available for the DC API, only DCQL - dcqlQuery = (presentationRequest as? DCQLRequest)?.dcqlQuery, - transactionData = transactionData?.map { it.toBase64UrlJsonString() }, - expectedOrigins = expectedOrigins, - ) - - private suspend fun storeAuthnRequest( - authenticationRequestParameters: AuthenticationRequestParameters, - externalId: String? = null, - ) = stateToAuthnRequestStore.put( - key = externalId - ?: authenticationRequestParameters.state - ?: throw IllegalArgumentException("Neither externalId nor state has been provided"), - value = authenticationRequestParameters, - ) - - /** - * The DC API has no other channel to convey the verifier's encryption key: wallets can only encrypt responses - * with a key from [at.asitplus.openid.AuthenticationRequestParameters.clientMetadata] in the request itself. - */ - private fun OpenId4VpRequestOptions.requireEncryptionKeyConveyed(): OpenId4VpRequestOptions = also { - if (responseMode.requiresEncryption) { - requireNotNull(clientMetadata()?.jsonWebKeySet) { - "Encrypted responses require client metadata with a JSON Web Key Set in the request, " + - "which is not populated for this client identifier scheme" - } - } - } - - private fun OpenId4VpRequestOptions.clientMetadata(): RelyingPartyMetadata? = when (verifierMetadataMode) { - VerifierMetadataMode.OMIT_IF_OUT_OF_BAND -> null - VerifierMetadataMode.AUTO -> when (clientIdScheme) { - is ClientIdScheme.RedirectUri, - is ClientIdScheme.VerifierAttestation, - is ClientIdScheme.CertificateSanDns, - is ClientIdScheme.CertificateHash, - -> if (responseMode.requiresEncryption) metadataWithEncryption else metadata - - else -> null - } - } - /** * Validates an Authentication Response from the Wallet, where [input] is a signed or unsigned DC API response. * @@ -365,7 +253,7 @@ class DcApiVerifier @JvmOverloads constructor( externalId: String ): KmmResult = catching { Napier.d("validateAuthnResponse: $input") - val authnRequest = loadAuthnRequest(input, externalId) + val authnRequest = requestFactory.loadAuthnRequest(input, externalId) val responseType = authnRequest.responseType?.let { ResponseType.Companion(it) } require(responseType != null) { @@ -450,23 +338,6 @@ class DcApiVerifier @JvmOverloads constructor( presentationResults.all { it.isSuccess } && (this !is VpTokenValidationResultDCQL || submissionRequirementsValidationResult.isSuccess) - @Throws(IllegalArgumentException::class, CancellationException::class) - private suspend fun loadAuthnRequest( - input: ResponseParametersFrom, - externalId: String?, - ): AuthenticationRequestParameters { - val storedId = externalId - ?: input.parameters.state - ?: throw IllegalArgumentException("Neither externalId nor state given") - val authnRequest = stateToAuthnRequestStore.get(storedId) - ?: throw IllegalArgumentException("No authn request found for $storedId") - if (authnRequest.responseMode?.requiresEncryption == true) - require(input.hasBeenEncrypted) { - "response_mode requires encryption, but no encrypted response was given" - } - return authnRequest - } - /** * Validates the `vp_token` of the response with the shared [VpTokenValidator], * enforcing this verifier's transport: the Digital Credentials API. @@ -498,8 +369,5 @@ class DcApiVerifier @JvmOverloads constructor( ).sha256(), ) ) - - // should always be ecdh-es for encryption - private fun JsonWebKey.withAlgorithm(): JsonWebKey = this.copy(algorithm = JweAlgorithm.ECDH_ES) } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestFactory.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestFactory.kt new file mode 100644 index 000000000..04666b914 --- /dev/null +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestFactory.kt @@ -0,0 +1,256 @@ +package at.asitplus.wallet.lib.openid + +import at.asitplus.KmmResult +import at.asitplus.catching +import at.asitplus.dif.DifInputDescriptor +import at.asitplus.dif.FormatContainerJwt +import at.asitplus.dif.FormatContainerSdJwt +import at.asitplus.openid.AuthenticationRequestParameters +import at.asitplus.openid.IdTokenType +import at.asitplus.openid.RelyingPartyMetadata +import at.asitplus.openid.RequestObjectParameters +import at.asitplus.openid.ResponseParametersFrom +import at.asitplus.openid.SupportedAlgorithmsContainerIso +import at.asitplus.openid.SupportedAlgorithmsContainerJwt +import at.asitplus.openid.SupportedAlgorithmsContainerSdJwt +import at.asitplus.openid.VpFormatsSupported +import at.asitplus.signum.indispensable.SignatureAlgorithm +import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm +import at.asitplus.signum.indispensable.josef.JsonWebKey +import at.asitplus.signum.indispensable.josef.JsonWebKeySet +import at.asitplus.signum.indispensable.josef.JweAlgorithm +import at.asitplus.signum.indispensable.josef.JweEncryption +import at.asitplus.signum.indispensable.josef.JwsCompactTyped +import at.asitplus.signum.indispensable.josef.toJsonWebKey +import at.asitplus.signum.indispensable.josef.toJwsAlgorithm +import at.asitplus.wallet.lib.NonceService +import at.asitplus.wallet.lib.agent.KeyMaterial +import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest +import at.asitplus.wallet.lib.data.CredentialPresentationRequest.PresentationExchangeRequest +import at.asitplus.wallet.lib.data.toBase64UrlJsonString +import at.asitplus.wallet.lib.jws.JwsContentTypeConstants +import at.asitplus.wallet.lib.jws.SignJwtFun +import at.asitplus.wallet.lib.utils.MapStore +import kotlin.coroutines.cancellation.CancellationException + +/** How to populate `iss`/`aud` when signing an OpenID4VP request object. */ +internal sealed interface RequestObjectSigning { + /** SIOPv2 JAR: `aud` is the SIOPv2 identifier, `iss` likewise unless pre-registered. */ + data object SiopJar : RequestObjectSigning + + /** OpenID4VP over the DC API: `iss` is the client identifier (RFC 9101), no `aud`. */ + data object DcApi : RequestObjectSigning +} + +/** + * Builds and stores OpenID4VP authentication requests, independently of the transport that will carry them: + * URL/QR (see [OpenId4VpVerifier]) or the W3C Digital Credentials API (see [DcApiVerifier]). + * + * The request content is derived entirely from [OpenId4VpRequestOptions]; the transport only decides how the + * resulting [AuthenticationRequestParameters] is delivered and, for signed requests, how `iss`/`aud` are set + * (see [RequestObjectSigning]). + */ +internal class OpenId4VpRequestFactory( + /** Scheme to use for our client identifier. */ + private val clientIdScheme: ClientIdScheme, + /** Advertised in [metadata] so that holders can encrypt responses. */ + private val decryptionKeyMaterial: KeyMaterial, + /** Signs authentication requests in [createSignedRequestObject]. */ + private val signAuthnRequest: SignJwtFun, + /** Creates OpenID4VP request nonces. */ + private val nonceService: NonceService, + /** Advertised in [metadata]. */ + supportedAlgorithms: Set, + /** Used to store issued authn requests to verify the authn response to it. */ + private val stateToAuthnRequestStore: MapStore, + /** Algorithms supported to decrypt responses from wallets, for [metadataWithEncryption]. */ + private val supportedJweEncryptionAlgorithms: Set, +) { + + private val supportedJwsAlgorithms = supportedAlgorithms + .mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } + private val supportedCoseAlgorithms = supportedAlgorithms + .mapNotNull { it.toCoseAlgorithm().getOrNull()?.coseValue } + private val containerJwt = FormatContainerJwt(algorithmStrings = supportedJwsAlgorithms) + private val containerSdJwt = FormatContainerSdJwt( + sdJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), + kbJwtAlgorithmStrings = supportedJwsAlgorithms.toSet() + ) + + /** + * Creates the [at.asitplus.openid.RelyingPartyMetadata], without encryption (see [metadataWithEncryption]) + */ + val metadata by lazy { + RelyingPartyMetadata( + redirectUris = listOfNotNull((clientIdScheme as? ClientIdScheme.RedirectUri)?.redirectUri), + jsonWebKeySet = JsonWebKeySet( + listOf( + decryptionKeyMaterial.publicKey.toJsonWebKey(decryptionKeyMaterial.identifier).withAlgorithm() + ) + ), + vpFormatsSupported = VpFormatsSupported( + vcJwt = SupportedAlgorithmsContainerJwt( + algorithmStrings = supportedJwsAlgorithms.toSet() + ), + dcSdJwt = SupportedAlgorithmsContainerSdJwt( + sdJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), + kbJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), + ), + msoMdoc = SupportedAlgorithmsContainerIso( + issuerAuthAlgorithmInts = supportedCoseAlgorithms.toSet(), + deviceAuthAlgorithmInts = supportedCoseAlgorithms.toSet(), + ), + ) + ) + } + + /** + * Creates the [RelyingPartyMetadata], but with parameters set to request encryption of pushed authentication + * responses, see [RelyingPartyMetadata.encryptedResponseEncValues]. + */ + val metadataWithEncryption by lazy { + metadata.copy( + encryptedResponseEncValuesSupportedString = supportedJweEncryptionAlgorithms.map { it.identifier }.toSet(), + jsonWebKeySet = metadata.jsonWebKeySet?.let { + JsonWebKeySet(it.keys.map { it.copy(publicKeyUse = "enc") }) + } + ) + } + + suspend fun createPlainAuthnRequest( + requestOptions: OpenId4VpRequestOptions, + requestObjectParameters: RequestObjectParameters? = null, + ): AuthenticationRequestParameters = requestOptions.toAuthnRequest(requestObjectParameters) + .also { storeAuthnRequest(it, requestOptions.state) } + + suspend fun createSignedRequestObject( + requestOptions: OpenId4VpRequestOptions, + signing: RequestObjectSigning, + requestObjectParameters: RequestObjectParameters? = null, + ): KmmResult> = catching { + val requestObject = createPlainAuthnRequest(requestOptions, requestObjectParameters) + val preRegisteredIssuer = (clientIdScheme as? ClientIdScheme.PreRegistered) + ?.let { it.issuerUri ?: it.clientId } + val signedRequestObject = when (signing) { + RequestObjectSigning.SiopJar -> requestObject.copy( + audience = SIOP_V2_ISSUER, + issuer = preRegisteredIssuer ?: SIOP_V2_ISSUER, + ) + + RequestObjectSigning.DcApi -> requestObject.copy( + // per RFC 9101, `iss` is the client identifier; wallets identify us via + // client_id and the request signature, an audience cannot be known upfront + issuer = preRegisteredIssuer ?: clientIdScheme.clientId, + ) + } + signAuthnRequest( + JwsContentTypeConstants.OAUTH_AUTHZ_REQUEST, + signedRequestObject, + AuthenticationRequestParameters.serializer(), + ).getOrThrow() + } + + suspend fun storeAuthnRequest( + authenticationRequestParameters: AuthenticationRequestParameters, + externalId: String? = null, + ) = stateToAuthnRequestStore.put( + key = externalId + ?: authenticationRequestParameters.state + ?: throw IllegalArgumentException("Neither externalId nor state has been provided"), + value = authenticationRequestParameters, + ) + + @Throws(IllegalArgumentException::class, CancellationException::class) + suspend fun loadAuthnRequest( + input: ResponseParametersFrom, + externalId: String? = null, + ): AuthenticationRequestParameters { + val storedId = externalId + ?: input.parameters.state + ?: throw IllegalArgumentException("Neither externalId nor state given") + val authnRequest = stateToAuthnRequestStore.get(storedId) + ?: throw IllegalArgumentException("No authn request found for $storedId") + if (authnRequest.responseMode?.requiresEncryption == true) + require(input.hasBeenEncrypted) { + "response_mode requires encryption, but no encrypted response was given" + } + return authnRequest + } + + /** + * The DC API has no other channel to convey the verifier's encryption key: wallets can only encrypt responses + * with a key from [at.asitplus.openid.AuthenticationRequestParameters.clientMetadata] in the request itself. + */ + fun requireEncryptionKeyConveyed(requestOptions: OpenId4VpRequestOptions): OpenId4VpRequestOptions = + requestOptions.also { + if (it.responseMode.requiresEncryption) { + requireNotNull(it.clientMetadata()?.jsonWebKeySet) { + "Encrypted responses require client metadata with a JSON Web Key Set in the request, " + + "which is not populated for this client identifier scheme" + } + } + } + + private suspend fun OpenId4VpRequestOptions.toAuthnRequest( + requestObjectParameters: RequestObjectParameters?, + ): AuthenticationRequestParameters = AuthenticationRequestParameters( + responseType = responseType, + clientId = if (populateClientId) clientIdScheme.clientId else null, + redirectUrl = if (!isAnyDirectPost) clientIdScheme.redirectUri else null, + responseUrl = responseUrl, + // Using scope as an alias for a well-defined Presentation Exchange or DCQL is not supported + scope = if (isSiop) buildScope() else null, + nonce = nonceService.provideNonce(), + walletNonce = requestObjectParameters?.walletNonce, + clientMetadata = clientMetadata(), + idTokenType = if (isSiop) IdTokenType.SUBJECT_SIGNED.text else null, + responseMode = responseMode, + // the DC API binds request and response through the browser, not through a `state` + state = if (isAnyDcApi) null else state, + dcqlQuery = (presentationRequest as? DCQLRequest)?.dcqlQuery, + // Presentation Exchange is not available for the DC API, only DCQL + presentationDefinition = (presentationRequest as? PresentationExchangeRequest)?.presentationDefinition?.run { + copy( + inputDescriptors = inputDescriptors.map { + when (it) { + is DifInputDescriptor -> it.replaceAvailableFormatHolders() + } + } + ) + }, + transactionData = transactionData?.map { it.toBase64UrlJsonString() }, + expectedOrigins = expectedOrigins, + ) + + /** + * Defining *some* non-null format container is our way of specifying the allowed credential representations, + * but provided values are overridden here + */ + private fun DifInputDescriptor.replaceAvailableFormatHolders() = copy( + format = format?.copy( + jwtVp = format?.jwtVp?.let { containerJwt }, + sdJwt = format?.sdJwt?.let { containerSdJwt }, + msoMdoc = format?.msoMdoc?.let { containerJwt }, + ) + ) + + private fun OpenId4VpRequestOptions.clientMetadata(): RelyingPartyMetadata? = when (verifierMetadataMode) { + VerifierMetadataMode.OMIT_IF_OUT_OF_BAND -> null + VerifierMetadataMode.AUTO -> when (clientIdScheme) { + is ClientIdScheme.RedirectUri, + is ClientIdScheme.VerifierAttestation, + is ClientIdScheme.CertificateSanDns, + is ClientIdScheme.CertificateHash, + -> if (responseMode.requiresEncryption) metadataWithEncryption else metadata + + else -> null + } + } + + // should always be ecdh-es for encryption + private fun JsonWebKey.withAlgorithm(): JsonWebKey = this.copy(algorithm = JweAlgorithm.ECDH_ES) + + companion object { + private const val SIOP_V2_ISSUER = "https://self-issued.me/v2" + } +} diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestOptions.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestOptions.kt index 1c3c922ec..4685c4f99 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestOptions.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpRequestOptions.kt @@ -100,6 +100,7 @@ data class OpenId4VpRequestOptions( } if (isAnyDcApi) { require(isDcql) { "DC API only supports DCQL" } + require(!isSiop) { "DC API does not support SIOP (id_token)" } requireNotNull(expectedOrigins) { "Expected origins must be set for DC API" } } else { require(populateClientId) { "client_id should be set for anything but (unsigned) DC API requests" } diff --git a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt index d60e57bab..dc9b77b24 100644 --- a/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt +++ b/vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpVerifier.kt @@ -3,32 +3,18 @@ package at.asitplus.wallet.lib.openid import at.asitplus.KmmResult import at.asitplus.catching import at.asitplus.dcapi.OpenId4VpResponse -import at.asitplus.dif.DifInputDescriptor -import at.asitplus.dif.FormatContainerJwt -import at.asitplus.dif.FormatContainerSdJwt import at.asitplus.openid.AuthenticationRequestParameters import at.asitplus.openid.IdToken -import at.asitplus.openid.IdTokenType import at.asitplus.openid.JarRequestParameters import at.asitplus.openid.OpenIdConstants import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.openid.RequestObjectParameters import at.asitplus.openid.ResponseParametersFrom -import at.asitplus.openid.SupportedAlgorithmsContainerIso -import at.asitplus.openid.SupportedAlgorithmsContainerJwt -import at.asitplus.openid.SupportedAlgorithmsContainerSdJwt -import at.asitplus.openid.VpFormatsSupported import at.asitplus.rfc6749OAuth2AuthorizationFramework.ResponseType import at.asitplus.signum.indispensable.SignatureAlgorithm -import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm -import at.asitplus.signum.indispensable.josef.JsonWebKey -import at.asitplus.signum.indispensable.josef.JsonWebKeySet -import at.asitplus.signum.indispensable.josef.JweAlgorithm import at.asitplus.signum.indispensable.josef.JweEncryption import at.asitplus.signum.indispensable.josef.JwsCompactTyped import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer -import at.asitplus.signum.indispensable.josef.toJsonWebKey -import at.asitplus.signum.indispensable.josef.toJwsAlgorithm import at.asitplus.wallet.lib.DefaultNonceService import at.asitplus.wallet.lib.MdocDeviceSignatureVerifier import at.asitplus.wallet.lib.NonceService @@ -39,12 +25,8 @@ import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKey import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKeyFun -import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest -import at.asitplus.wallet.lib.data.CredentialPresentationRequest.PresentationExchangeRequest -import at.asitplus.wallet.lib.data.toBase64UrlJsonString import at.asitplus.wallet.lib.jws.DecryptJwe import at.asitplus.wallet.lib.jws.DecryptJweFun -import at.asitplus.wallet.lib.jws.JwsContentTypeConstants import at.asitplus.wallet.lib.jws.SignJwt import at.asitplus.wallet.lib.jws.SignJwtFun import at.asitplus.wallet.lib.jws.VerifyJwsObject @@ -106,22 +88,22 @@ class OpenId4VpVerifier @JvmOverloads constructor( verifier = verifier, nonceService = nonceService, ) + private val requestFactory = OpenId4VpRequestFactory( + clientIdScheme = clientIdScheme, + decryptionKeyMaterial = decryptionKeyMaterial, + signAuthnRequest = signAuthnRequest, + nonceService = nonceService, + supportedAlgorithms = supportedAlgorithms, + stateToAuthnRequestStore = stateToAuthnRequestStore, + supportedJweEncryptionAlgorithms = supportedJweEncryptionAlgorithms, + ) private val vpTokenValidator = VpTokenValidator( nonceAwareVerifier = nonceAwareVerifier, mdocDeviceSignatureVerifier = MdocDeviceSignatureVerifier(verifyCoseSignature = verifyCoseSignature), createSessionTranscript = UrlSessionTranscriptCalculator(decryptionKeyMaterial), ) - private val supportedJwsAlgorithms = supportedAlgorithms - .mapNotNull { it.toJwsAlgorithm().getOrNull()?.identifier } - private val supportedCoseAlgorithms = supportedAlgorithms - .mapNotNull { it.toCoseAlgorithm().getOrNull()?.coseValue } private val responseParser = ResponseParser(decryptJwe, verifyJwsObject) private val timeLeeway = timeLeewaySeconds.toDuration(DurationUnit.SECONDS) - private val containerJwt = FormatContainerJwt(algorithmStrings = supportedJwsAlgorithms) - private val containerSdJwt = FormatContainerSdJwt( - sdJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), - kbJwtAlgorithmStrings = supportedJwsAlgorithms.toSet() - ) @Deprecated("Moved to upper level", ReplaceWith("CreationOptions", "at.asitplus.wallet.lib.openid.CreationOptions")) typealias CreationOptions = at.asitplus.wallet.lib.openid.CreationOptions @@ -132,42 +114,13 @@ class OpenId4VpVerifier @JvmOverloads constructor( /** * Creates the [at.asitplus.openid.RelyingPartyMetadata], without encryption (see [metadataWithEncryption]) */ - val metadata by lazy { - RelyingPartyMetadata( - redirectUris = listOfNotNull((clientIdScheme as? ClientIdScheme.RedirectUri)?.redirectUri), - jsonWebKeySet = JsonWebKeySet( - listOf( - decryptionKeyMaterial.publicKey.toJsonWebKey(decryptionKeyMaterial.identifier).withAlgorithm() - ) - ), - vpFormatsSupported = VpFormatsSupported( - vcJwt = SupportedAlgorithmsContainerJwt( - algorithmStrings = supportedJwsAlgorithms.toSet() - ), - dcSdJwt = SupportedAlgorithmsContainerSdJwt( - sdJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), - kbJwtAlgorithmStrings = supportedJwsAlgorithms.toSet(), - ), - msoMdoc = SupportedAlgorithmsContainerIso( - issuerAuthAlgorithmInts = supportedCoseAlgorithms.toSet(), - deviceAuthAlgorithmInts = supportedCoseAlgorithms.toSet(), - ), - ) - ) - } + val metadata get() = requestFactory.metadata /** * Creates the [RelyingPartyMetadata], but with parameters set to request encryption of pushed authentication * responses, see [RelyingPartyMetadata.encryptedResponseEncValues]. */ - val metadataWithEncryption by lazy { - metadata.copy( - encryptedResponseEncValuesSupportedString = supportedJweEncryptionAlgorithms.map { it.identifier }.toSet(), - jsonWebKeySet = metadata.jsonWebKeySet?.let { - JsonWebKeySet(it.keys.map { it.copy(publicKeyUse = "enc") }) - } - ) - } + val metadataWithEncryption get() = requestFactory.metadataWithEncryption /** * Creates a new authentication request conforming to OpenID4VP. @@ -246,22 +199,8 @@ class OpenId4VpVerifier @JvmOverloads constructor( internal suspend fun createSignedRequestObject( requestOptions: OpenId4VpRequestOptions, requestObjectParameters: RequestObjectParameters? = null, - ): KmmResult> = catching { - val requestObject = createPlainAuthnRequest(requestOptions, requestObjectParameters) - val siopClientId = "https://self-issued.me/v2" - val issuer = when (clientIdScheme) { - is ClientIdScheme.PreRegistered -> clientIdScheme.issuerUri ?: clientIdScheme.clientId - else -> siopClientId - } - signAuthnRequest( - JwsContentTypeConstants.OAUTH_AUTHZ_REQUEST, - requestObject.copy( - audience = siopClientId, - issuer = issuer, - ), - AuthenticationRequestParameters.serializer(), - ).getOrThrow() - } + ): KmmResult> = + requestFactory.createSignedRequestObject(requestOptions, RequestObjectSigning.SiopJar, requestObjectParameters) @Deprecated("Use createAuthnRequest instead with CreationOptions.SignedRequestByValue") suspend fun createAuthnRequest( @@ -272,76 +211,13 @@ class OpenId4VpVerifier @JvmOverloads constructor( internal suspend fun createPlainAuthnRequest( requestOptions: OpenId4VpRequestOptions, requestObjectParameters: RequestObjectParameters? = null, - ) = requestOptions.toAuthnRequest(requestObjectParameters) - .also { storeAuthnRequest(it) } - - private suspend fun OpenId4VpRequestOptions.toAuthnRequest( - requestObjectParameters: RequestObjectParameters?, - ): AuthenticationRequestParameters = AuthenticationRequestParameters( - responseType = responseType, - clientId = if (populateClientId) clientIdScheme.clientId else null, - redirectUrl = if (!isAnyDirectPost) clientIdScheme.redirectUri else null, - responseUrl = responseUrl, - // Using scope as an alias for a well-defined Presentation Exchange or DCQL is not supported - scope = if (isSiop) buildScope() else null, - nonce = nonceAwareVerifier.provideNonce(), - walletNonce = requestObjectParameters?.walletNonce, - clientMetadata = clientMetadata(), - idTokenType = if (isSiop) IdTokenType.SUBJECT_SIGNED.text else null, - responseMode = responseMode, - state = state, - dcqlQuery = (presentationRequest as? DCQLRequest)?.dcqlQuery, - presentationDefinition = (presentationRequest as? PresentationExchangeRequest)?.presentationDefinition?.run { - copy( - inputDescriptors = inputDescriptors.map { - when (it) { - is DifInputDescriptor -> it.replaceAvailableFormatHolders() - } - } - ) - }, - transactionData = transactionData?.map { it.toBase64UrlJsonString() }, - expectedOrigins = expectedOrigins, - ) - - /** - * Defining *some* non-null format container is our way of specifying the allowed credential representations, - * but provided values are overridden here - */ - private fun DifInputDescriptor.replaceAvailableFormatHolders() = copy( - format = format?.copy( - jwtVp = format?.jwtVp?.let { containerJwt }, - sdJwt = format?.sdJwt?.let { containerSdJwt }, - msoMdoc = format?.msoMdoc?.let { containerJwt }, - ) - ) + ) = requestFactory.createPlainAuthnRequest(requestOptions, requestObjectParameters) @Deprecated("Should not be necessary at all, simply call [createAuthnRequest]") suspend fun submitAuthnRequest( authenticationRequestParameters: AuthenticationRequestParameters, externalId: String? = null, - ) = storeAuthnRequest(authenticationRequestParameters) - - internal suspend fun storeAuthnRequest( - authenticationRequestParameters: AuthenticationRequestParameters, - ) = stateToAuthnRequestStore.put( - key = authenticationRequestParameters.state - ?: throw IllegalArgumentException("No state has been provided"), - value = authenticationRequestParameters, - ) - - private fun OpenId4VpRequestOptions.clientMetadata(): RelyingPartyMetadata? = when (verifierMetadataMode) { - VerifierMetadataMode.OMIT_IF_OUT_OF_BAND -> null - VerifierMetadataMode.AUTO -> when (clientIdScheme) { - is ClientIdScheme.RedirectUri, - is ClientIdScheme.VerifierAttestation, - is ClientIdScheme.CertificateSanDns, - is ClientIdScheme.CertificateHash, - -> if (responseMode.requiresEncryption) metadataWithEncryption else metadata - - else -> null - } - } + ) = requestFactory.storeAuthnRequest(authenticationRequestParameters) /** * Validates an Authentication Response from the Wallet, where [input] is either: @@ -384,7 +260,7 @@ class OpenId4VpVerifier @JvmOverloads constructor( input: ResponseParametersFrom, ) = catching { Napier.d("validateAuthnResponse: $input") - val authnRequest = loadAuthnRequest(input) + val authnRequest = requestFactory.loadAuthnRequest(input) val responseType = authnRequest.responseType?.let { ResponseType(it) } require(responseType != null) { @@ -436,21 +312,6 @@ class OpenId4VpVerifier @JvmOverloads constructor( presentationResults.all { it.isSuccess } && (this !is VpTokenValidationResultDCQL || submissionRequirementsValidationResult.isSuccess) - @Throws(IllegalArgumentException::class, CancellationException::class) - private suspend fun loadAuthnRequest( - input: ResponseParametersFrom, - ): AuthenticationRequestParameters { - val storedId = input.parameters.state - ?: throw IllegalArgumentException("No state in input parameters") - val authnRequest = stateToAuthnRequestStore.get(storedId) - ?: throw IllegalArgumentException("No authn request found for $storedId") - if (authnRequest.responseMode?.requiresEncryption == true) - require(input.hasBeenEncrypted) { - "response_mode requires encryption, but no encrypted response was given" - } - return authnRequest - } - @Throws(IllegalArgumentException::class, CancellationException::class) private suspend fun extractValidatedIdToken( input: ResponseParametersFrom, @@ -507,9 +368,6 @@ class OpenId4VpVerifier @JvmOverloads constructor( origin = null, ).getOrThrow() } - - // should always be ecdh-es for encryption - private fun JsonWebKey.withAlgorithm(): JsonWebKey = this.copy(algorithm = JweAlgorithm.ECDH_ES) }