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 0ff73be6b..806888074 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 @@ -13,13 +13,10 @@ import at.asitplus.dcapi.OpenId4VpResponse import at.asitplus.dcapi.SessionTranscriptContentHashable import at.asitplus.dcapi.request.IsoMdocRequest 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.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.iso.EncryptionInfo import at.asitplus.iso.EncryptionParameters @@ -28,7 +25,6 @@ import at.asitplus.iso.serializeOrigin import at.asitplus.iso.sha256 import at.asitplus.openid.AuthenticationRequestParameters import at.asitplus.openid.CredentialFormatEnum -import at.asitplus.openid.IdTokenType import at.asitplus.openid.OpenIdConstants import at.asitplus.openid.RelyingPartyMetadata import at.asitplus.openid.ResponseParametersFrom @@ -68,7 +64,6 @@ 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.IsoDocumentParsed import at.asitplus.wallet.lib.data.VerifiablePresentationJws import at.asitplus.wallet.lib.data.toBase64UrlJsonString @@ -155,11 +150,6 @@ class DcApiVerifier @JvmOverloads constructor( private val supportedCoseAlgorithms = supportedAlgorithms .mapNotNull { it.toCoseAlgorithm().getOrNull()?.coseValue } private val responseParser = ResponseParser(decryptJwe, verifyJwsObject) - 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]) @@ -206,50 +196,64 @@ class DcApiVerifier @JvmOverloads constructor( * relying party's frontend needs to pass to the browser in `navigator.credentials.get()`. * * [requestOptions] must use [OpenIdConstants.ResponseMode.DcApi] or [OpenIdConstants.ResponseMode.DcApiJwt]. + * + * Pass more than one [creationOptions] to offer the same request over several exchange protocols in one + * browser call, e.g. [DcApiCreationOptions.OpenId4VpSigned] and [DcApiCreationOptions.Iso180137AnnexC]. + * Do not combine [DcApiCreationOptions.OpenId4VpSigned] and [DcApiCreationOptions.OpenId4VpUnsigned], + * as the stored requests to validate the response would overwrite each other. */ suspend fun createAuthnRequest( requestOptions: OpenId4VpRequestOptions, - creationOptions: DcApiCreationOptions, + vararg creationOptions: DcApiCreationOptions, ): KmmResult = catching { require(requestOptions.isAnyDcApi) { "responseMode must be ${OpenIdConstants.ResponseMode.DcApi} or ${OpenIdConstants.ResponseMode.DcApiJwt}" } + require(creationOptions.isNotEmpty()) { + "at least one creation option is required" + } CredentialRequestOptions.create( - listOf( - when (creationOptions) { - is DcApiCreationOptions.OpenId4VpUnsigned -> OpenId4VpUnsigned( - // client_id MUST be omitted in unsigned requests, per OpenID4VP 1.0 Appendix A.3.1 - createPlainAuthnRequest(requestOptions.copy(populateClientId = false)) - ) + creationOptions.map { it.toGetRequest(requestOptions) } + ) + } - is DcApiCreationOptions.OpenId4VpSigned -> OpenId4VpSigned( - SignedDataElement( - createSignedRequestObject(requestOptions).getOrThrow().jws - ) - ) + private suspend fun DcApiCreationOptions.toGetRequest( + requestOptions: OpenId4VpRequestOptions, + ): 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)) + ) - DcApiCreationOptions.Iso180137AnnexC -> IsoMdoc( - createIsoMdocRequest(requestOptions) - ) - } + is DcApiCreationOptions.OpenId4VpSigned -> OpenId4VpSigned( + SignedDataElement( + createSignedRequestObject(requestOptions.requireEncryptionKeyConveyed()).getOrThrow().jws ) ) + + DcApiCreationOptions.Iso180137AnnexC -> { + requireNotNull(decryptHpke) { + "ISO 18013-7 Annex C requires decryptHpke to be set, the response could never be validated" + } + IsoMdoc( + createIsoMdocRequest(requestOptions) + ) + } } private suspend fun createSignedRequestObject( requestOptions: OpenId4VpRequestOptions, ): KmmResult> = catching { val requestObject = createPlainAuthnRequest(requestOptions) - 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, + // 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() @@ -284,38 +288,18 @@ class DcApiVerifier @JvmOverloads constructor( 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, + scope = null, nonce = nonceAwareVerifier.provideNonce(), clientMetadata = clientMetadata(), - idTokenType = if (isSiop) IdTokenType.SUBJECT_SIGNED.text else null, + idTokenType = null, responseMode = responseMode, state = null, + // Presentation Exchange is not available for the DC API, only DCQL 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 }, - ) - ) - private suspend fun storeAuthnRequest( authenticationRequestParameters: AuthenticationRequestParameters, externalId: String? = null, @@ -326,6 +310,19 @@ class DcApiVerifier @JvmOverloads constructor( 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) { @@ -364,7 +361,7 @@ class DcApiVerifier @JvmOverloads constructor( externalId: String, ): KmmResult = catching { when (input) { - is IsoMdocResponse -> validateResponse( + is IsoMdocResponse -> validateIsoResponse( receivedData = input.data, externalId = externalId, expectedOrigin = "TODO" @@ -417,7 +414,7 @@ class DcApiVerifier @JvmOverloads constructor( } @OptIn(SecretExposure::class) - suspend fun validateResponse( + internal suspend fun validateIsoResponse( receivedData: DCAPIResponse, externalId: String, expectedOrigin: String @@ -438,7 +435,7 @@ class DcApiVerifier @JvmOverloads constructor( ) ) val encodedSessionTranscript = coseCompliantSerializer.encodeToByteArray(sessionTranscript) - val encodedDeviceResponse = decryptHpke!!( + val encodedDeviceResponse = requireNotNull(decryptHpke) { "decryptHpke required for ISO 18013-7 Annex C" }( encryptedResponseData.enc, encryptedResponseData.cipherText, privateKey, @@ -446,19 +443,27 @@ class DcApiVerifier @JvmOverloads constructor( ) val deviceResponse = coseCompliantSerializer.decodeFromByteArray(encodedDeviceResponse) - Iso180137AnnexCWrapper( - verifier.verifyPresentationIsoMdoc( - input = deviceResponse, - verifyDocument = verifyDocument( - sessionTranscript = sessionTranscript - ) - ).getOrThrow().documents - ) + val documents = verifier.verifyPresentationIsoMdoc( + input = deviceResponse, + verifyDocument = verifyDocument( + sessionTranscript = sessionTranscript + ) + ).getOrThrow().documents + + // an authentic document of a type we never asked for must not be mistaken for the requested one + val requestedDocTypes = isoMdocRequest.deviceRequest.docRequests + .map { it.itemsRequest.value.docType }.toSet() + documents.forEach { document -> + require(document.document.docType in requestedDocTypes) { + "Response contains docType '${document.document.docType}', but requested were $requestedDocTypes" + } + } + + Iso180137AnnexCWrapper(documents) } private fun AuthnResponseResult.isFullyValid(): Boolean = - idTokenValidationResult?.isFailure != true && - vpTokenValidationResult?.isFailure != true && + vpTokenValidationResult?.isFailure != true && (vpTokenValidationResult?.getOrNull()?.isFullyValid() ?: true) private fun VpTokenValidationResult.isFullyValid(): Boolean = @@ -694,17 +699,6 @@ class DcApiVerifier @JvmOverloads constructor( 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 - } - sealed class DcApiResponseResult { } diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/Iso180137AnnexCProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/Iso180137AnnexCProtocolTest.kt index b3d847b6d..666330425 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/Iso180137AnnexCProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/Iso180137AnnexCProtocolTest.kt @@ -10,6 +10,9 @@ import at.asitplus.dcapi.request.IsoMdocRequest import at.asitplus.dcapi.request.verifier.CredentialRequestOptions import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest import at.asitplus.iso.DeviceAuthentication +import at.asitplus.iso.DeviceRequest +import at.asitplus.iso.DocRequest +import at.asitplus.iso.ItemsRequest import at.asitplus.iso.SingleItemsRequest import at.asitplus.iso.SessionTranscript import at.asitplus.iso.serializeOrigin @@ -159,7 +162,7 @@ val Iso180137AnnexCProtocolTest by matrixSuite { val dcApiResponse = f.walletResponse(isoMdocRequest) - val result = f.verifier.validateResponse( + val result = f.verifier.validateIsoResponse( receivedData = dcApiResponse, externalId = transactionId, expectedOrigin = callingOrigin, @@ -180,7 +183,7 @@ val Iso180137AnnexCProtocolTest by matrixSuite { val dcApiResponse = f.walletResponse(isoMdocRequest) - f.verifier.validateResponse( + f.verifier.validateIsoResponse( receivedData = dcApiResponse, externalId = transactionId, expectedOrigin = "https://evil.example.com", @@ -202,7 +205,33 @@ val Iso180137AnnexCProtocolTest by matrixSuite { val dcApiResponse = f.walletResponse(otherRequest) - f.verifier.validateResponse( + f.verifier.validateIsoResponse( + receivedData = dcApiResponse, + externalId = transactionId, + expectedOrigin = callingOrigin, + ).isFailure shouldBe true + } + + test("response with a document of a different docType than requested fails validation") { f -> + val transactionId = uuid4().toString() + val isoMdocRequest = f.createIsoMdocRequest(transactionId) + // same encryption info (so decryption and device signature verification succeed), + // but the stored request asks for a different docType than the wallet presents + f.stateToIsoMdocRequestStore.put( + transactionId, + isoMdocRequest.copy( + deviceRequest = DeviceRequest( + version = "1.0", + docRequests = arrayOf( + DocRequest(ByteStringWrapper(ItemsRequest("org.iso.18013.5.1.mDL", emptyMap()))) + ), + ) + ) + ) + + val dcApiResponse = f.walletResponse(isoMdocRequest) + + f.verifier.validateIsoResponse( receivedData = dcApiResponse, externalId = transactionId, expectedOrigin = callingOrigin, @@ -215,7 +244,7 @@ val Iso180137AnnexCProtocolTest by matrixSuite { val dcApiResponse = f.walletResponse(isoMdocRequest) - f.verifier.validateResponse( + f.verifier.validateIsoResponse( receivedData = dcApiResponse, externalId = "unknown-${uuid4()}", expectedOrigin = callingOrigin, @@ -231,7 +260,7 @@ val Iso180137AnnexCProtocolTest by matrixSuite { .also { it[0] = (it[0].toInt() xor 0x01).toByte() } .let { DCAPIResponse(EncryptedResponse(TYPE_DCAPI, EncryptedResponseData(dcApiResponse.response.encryptedResponseData.enc, it))) } - f.verifier.validateResponse( + f.verifier.validateIsoResponse( receivedData = tampered, externalId = transactionId, expectedOrigin = callingOrigin, diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpDcApiProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpDcApiProtocolTest.kt index d45c30f3a..f556ff469 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpDcApiProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpDcApiProtocolTest.kt @@ -20,6 +20,7 @@ import at.asitplus.signum.indispensable.josef.typed import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.RequestOptionsCredential +import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.Holder import at.asitplus.wallet.lib.agent.HolderAgent @@ -34,6 +35,7 @@ import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.rfc3986.toUri import com.benasher44.uuid.uuid4 import io.kotest.matchers.collections.shouldBeSingleton +import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe @@ -80,6 +82,8 @@ val OpenId4VpDcApiProtocolTest by matrixSuite { clientId = "dc-api-rp-${uuid4()}", redirectUri = "https://example.com/callback", ), + // stub, response decryption is exercised in Iso180137AnnexCProtocolTest + decryptHpke = { _, _, _, _ -> byteArrayOf() }, ) /** Extracts the unsigned authn request from the browser-facing [CredentialRequestOptions]. */ @@ -113,6 +117,41 @@ val OpenId4VpDcApiProtocolTest by matrixSuite { .isFailure shouldBe true } + test("createAuthnRequest rejects encrypted response mode when metadata conveys no encryption key") { f -> + val reqOptions = OpenId4VpRequestOptions( + presentationRequest = dcqlRequest, + responseMode = OpenIdConstants.ResponseMode.DcApiJwt, + expectedOrigins = listOf(callingOrigin), + ) + // fixture verifier uses ClientIdScheme.PreRegistered, for which no client metadata is populated + f.dcApiVerifier.createAuthnRequest(reqOptions, DcApiCreationOptions.OpenId4VpUnsigned) + .isFailure shouldBe true + f.dcApiVerifier.createAuthnRequest(reqOptions, DcApiCreationOptions.OpenId4VpSigned) + .isFailure shouldBe true + } + + test("createAuthnRequest with encrypted response mode conveys encryption key in metadata") { + val verifierKeyMaterial = EphemeralKeyWithSelfSignedCert() + val dcApiVerifier = DcApiVerifier( + keyMaterial = verifierKeyMaterial, + clientIdScheme = ClientIdScheme.CertificateHash( + chain = listOf(verifierKeyMaterial.getCertificate()!!), + redirectUri = "https://example.com/callback", + ), + ) + val reqOptions = OpenId4VpRequestOptions( + presentationRequest = dcqlRequest, + responseMode = OpenIdConstants.ResponseMode.DcApiJwt, + expectedOrigins = listOf(callingOrigin), + ) + + val authnRequest = dcApiVerifier.createAuthnRequest(reqOptions, DcApiCreationOptions.OpenId4VpUnsigned) + .getOrThrow().singleRequest() + .data + + authnRequest.clientMetadata.shouldNotBeNull().jsonWebKeySet.shouldNotBeNull().keys.shouldBeSingleton() + } + test("DC API Annex C: createAuthnRequest renders the DCQL query as ISO device request") { f -> val isoDcqlRequest = CredentialPresentationRequestBuilder( RequestOptionsCredential( @@ -139,6 +178,67 @@ val OpenId4VpDcApiProtocolTest by matrixSuite { isoMdocRequest.encryptionInfo.encryptionParameters.nonce.shouldNotBeNull() } + test("createAuthnRequest combines several exchange protocols in one browser call") { f -> + val isoDcqlRequest = CredentialPresentationRequestBuilder( + RequestOptionsCredential( + credentialScheme = AtomicAttribute2023, + representation = ISO_MDOC, + attributePaths = setOf(DCQLClaimsPathPointer(CLAIM_GIVEN_NAME)), + ), + ).toDCQLRequest() + val reqOptions = OpenId4VpRequestOptions( + presentationRequest = isoDcqlRequest, + responseMode = OpenIdConstants.ResponseMode.DcApi, + expectedOrigins = listOf(callingOrigin), + ) + + val requests = f.dcApiVerifier.createAuthnRequest( + reqOptions, + DcApiCreationOptions.OpenId4VpUnsigned, + DcApiCreationOptions.Iso180137AnnexC, + ).getOrThrow().digital.requests + + requests.shouldHaveSize(2) + requests[0].shouldBeInstanceOf() + .data.dcqlQuery shouldBe isoDcqlRequest!!.dcqlQuery + requests[1].shouldBeInstanceOf() + .data.deviceRequest.docRequests.single().itemsRequest.value.docType shouldBe + AtomicAttribute2023.isoDocType + } + + test("createAuthnRequest rejects empty creation options") { f -> + val reqOptions = OpenId4VpRequestOptions( + presentationRequest = dcqlRequest, + responseMode = OpenIdConstants.ResponseMode.DcApi, + expectedOrigins = listOf(callingOrigin), + ) + f.dcApiVerifier.createAuthnRequest(reqOptions).isFailure shouldBe true + } + + test("DC API Annex C: createAuthnRequest rejects verifier without HPKE decryption") { + val verifierWithoutHpke = DcApiVerifier( + clientIdScheme = ClientIdScheme.PreRegistered( + clientId = "dc-api-rp-${uuid4()}", + redirectUri = "https://example.com/callback", + ), + ) + val isoDcqlRequest = CredentialPresentationRequestBuilder( + RequestOptionsCredential( + credentialScheme = AtomicAttribute2023, + representation = ISO_MDOC, + attributePaths = setOf(DCQLClaimsPathPointer(CLAIM_GIVEN_NAME)), + ), + ).toDCQLRequest() + val reqOptions = OpenId4VpRequestOptions( + presentationRequest = isoDcqlRequest, + responseMode = OpenIdConstants.ResponseMode.DcApi, + expectedOrigins = listOf(callingOrigin), + ) + // without decryptHpke, the verifier could never validate the response to this request + verifierWithoutHpke.createAuthnRequest(reqOptions, DcApiCreationOptions.Iso180137AnnexC) + .isFailure shouldBe true + } + test("DC API Annex C: createAuthnRequest rejects non-mdoc DCQL queries") { f -> val reqOptions = OpenId4VpRequestOptions( presentationRequest = dcqlRequest, // SD-JWT credential query