Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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<CredentialRequestOptions> = 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<JwsCompactTyped<AuthenticationRequestParameters>> = 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()
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -364,7 +361,7 @@ class DcApiVerifier @JvmOverloads constructor(
externalId: String,
): KmmResult<DcApiResponseResult> = catching {
when (input) {
is IsoMdocResponse -> validateResponse(
is IsoMdocResponse -> validateIsoResponse(
receivedData = input.data,
externalId = externalId,
expectedOrigin = "TODO"
Expand Down Expand Up @@ -417,7 +414,7 @@ class DcApiVerifier @JvmOverloads constructor(
}

@OptIn(SecretExposure::class)
suspend fun validateResponse(
internal suspend fun validateIsoResponse(
receivedData: DCAPIResponse,
externalId: String,
expectedOrigin: String
Expand All @@ -438,27 +435,35 @@ 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,
encodedSessionTranscript
)
val deviceResponse = coseCompliantSerializer.decodeFromByteArray<DeviceResponse>(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 =
Expand Down Expand Up @@ -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 {

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading