Skip to content

Commit aa5cf3b

Browse files
committed
OID4VP: Fix ISO mDoc presentation containing more than one document
1 parent 5957539 commit aa5cf3b

File tree

7 files changed

+188
-99
lines changed

7 files changed

+188
-99
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Release 5.5.2:
44
- OpenID for Verifiable Presentations:
55
- Fix parsing `group` in presentation exchange input descriptors
66
- Set content type for authentication responses to `application/x-www-form-urlencoded`, without the charset appended
7+
- Fix ISO mDoc presentations containing multiple documents in one device response
78
- When creating JWS, and `x5c` header is set, do not set `jwk` and `kid`
89
- When creating JWS, and `jwk` header is set, do not set `kid`
910

vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/AuthenticationResponseFactory.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import at.asitplus.wallet.lib.oidvci.formUrlEncode
1515
import io.github.aakira.napier.Napier
1616
import io.ktor.http.*
1717
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
18+
import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString
1819
import kotlinx.serialization.builtins.serializer
1920
import kotlinx.serialization.encodeToString
2021
import kotlin.coroutines.cancellation.CancellationException

vck-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/openid/PresentationFactory.kt

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import at.asitplus.signum.indispensable.CryptoPublicKey
1212
import at.asitplus.signum.indispensable.cosef.CoseSigned
1313
import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper
1414
import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer
15+
import at.asitplus.signum.indispensable.io.Base64Strict
1516
import at.asitplus.signum.indispensable.io.Base64UrlStrict
1617
import at.asitplus.signum.indispensable.josef.JsonWebKey
1718
import at.asitplus.signum.indispensable.josef.JwsSigned
@@ -62,8 +63,8 @@ internal class PresentationFactory(
6263
nonce = nonce,
6364
audience = audience,
6465
transactionData = transactionData,
65-
calcIsoDeviceSignature = { docType ->
66-
calcDeviceSignature(responseWillBeEncrypted, clientId, responseUrl, nonce, docType)
66+
calcIsoDeviceSignature = { docType, mdocGenNonce ->
67+
reuseMdocGeneratedNonce(mdocGenNonce, clientId, responseUrl, nonce, docType, responseWillBeEncrypted)
6768
}
6869
)
6970

@@ -86,6 +87,17 @@ internal class PresentationFactory(
8687
}
8788
}
8889

90+
private suspend fun PresentationFactory.reuseMdocGeneratedNonce(
91+
mdocGeneratedNonce: String?,
92+
clientId: String?,
93+
responseUrl: String?,
94+
nonce: String,
95+
docType: String,
96+
responseWillBeEncrypted: Boolean
97+
) = mdocGeneratedNonce?.let {
98+
calcDeviceSignatureWithNonce(mdocGeneratedNonce, clientId!!, responseUrl!!, nonce, docType)
99+
} ?: calcDeviceSignature(responseWillBeEncrypted, clientId, responseUrl, nonce, docType)
100+
89101
/**
90102
* Parses all `transaction_data` fields from the request, with a JsonPath, because
91103
* ... for OpenID4VP Draft 23, that's encoded in the AuthnRequest
@@ -118,10 +130,35 @@ internal class PresentationFactory(
118130
nonce: String,
119131
docType: String,
120132
): Pair<CoseSigned<ByteArray>, String?> = if (clientId != null && responseUrl != null) {
121-
val deviceNameSpaceBytes = ByteStringWrapper(DeviceNameSpaces(mapOf()))
122133
// if it's not encrypted, we have no way of transporting the mdocGeneratedNonce, so we'll use the empty string
123134
val mdocGeneratedNonce = if (responseWillBeEncrypted)
124135
Random.Default.nextBytes(16).encodeToString(Base64UrlStrict) else ""
136+
calcDeviceSignatureWithNonce(mdocGeneratedNonce, clientId, responseUrl, nonce, docType)
137+
} else {
138+
coseService.createSignedCose(
139+
payload = nonce.encodeToByteArray(),
140+
serializer = ByteArraySerializer(),
141+
addKeyId = false
142+
).getOrElse {
143+
Napier.w("Could not create DeviceAuth for presentation", it)
144+
throw PresentationException(it)
145+
} to null
146+
}
147+
148+
149+
/**
150+
* Performs calculation of the [at.asitplus.wallet.lib.iso.SessionTranscript] and [at.asitplus.wallet.lib.iso.DeviceAuthentication],
151+
* acc. to ISO/IEC 18013-5:2021 and ISO/IEC 18013-7:2024, with the [mdocGeneratedNonce] provided.
152+
*/
153+
@Throws(PresentationException::class, CancellationException::class)
154+
private suspend fun calcDeviceSignatureWithNonce(
155+
mdocGeneratedNonce: String,
156+
clientId: String,
157+
responseUrl: String,
158+
nonce: String,
159+
docType: String,
160+
): Pair<CoseSigned<ByteArray>, String?> = run {
161+
val deviceNameSpaceBytes = ByteStringWrapper(DeviceNameSpaces(mapOf()))
125162
val clientIdToHash = ClientIdToHash(
126163
clientId = clientId,
127164
mdocGeneratedNonce = mdocGeneratedNonce
@@ -158,15 +195,6 @@ internal class PresentationFactory(
158195
Napier.w("Could not create DeviceAuth for presentation", it)
159196
throw PresentationException(it)
160197
} to mdocGeneratedNonce
161-
} else {
162-
coseService.createSignedCose(
163-
payload = nonce.encodeToByteArray(),
164-
serializer = ByteArraySerializer(),
165-
addKeyId = false
166-
).getOrElse {
167-
Napier.w("Could not create DeviceAuth for presentation", it)
168-
throw PresentationException(it)
169-
} to null
170198
}
171199

172200
suspend fun <T : RequestParameters> createSignedIdToken(

vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpIsoProtocolTest.kt

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package at.asitplus.wallet.lib.openid
33
import at.asitplus.openid.OpenIdConstants
44
import at.asitplus.wallet.lib.agent.*
55
import at.asitplus.wallet.lib.data.ConstantIndex
6+
import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023
67
import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME
78
import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC
89
import at.asitplus.wallet.lib.data.IsoDocumentParsed
@@ -49,7 +50,7 @@ class OpenId4VpIsoProtocolTest : FreeSpec({
4950
issuerAgent.issueCredential(
5051
DummyCredentialDataProvider.getCredential(
5152
holderKeyMaterial.publicKey,
52-
ConstantIndex.AtomicAttribute2023,
53+
AtomicAttribute2023,
5354
ISO_MDOC,
5455
).getOrThrow()
5556
).getOrThrow().toStoreCredentialInput()
@@ -94,7 +95,7 @@ class OpenId4VpIsoProtocolTest : FreeSpec({
9495
walletUrl,
9596
OpenIdRequestOptions(
9697
credentials = setOf(
97-
RequestOptionsCredential(ConstantIndex.AtomicAttribute2023, ISO_MDOC, setOf(CLAIM_GIVEN_NAME))
98+
RequestOptionsCredential(AtomicAttribute2023, ISO_MDOC, setOf(CLAIM_GIVEN_NAME))
9899
)
99100
),
100101
holderOid4vp
@@ -185,6 +186,38 @@ class OpenId4VpIsoProtocolTest : FreeSpec({
185186
document.invalidItems.shouldBeEmpty()
186187
}
187188

189+
"Selective Disclosure with two documents and encryption (ISO/IEC 18013-7:2024 Annex B)" {
190+
val mdlFamilyName = MobileDrivingLicenceDataElements.FAMILY_NAME
191+
val atomicGivenName = CLAIM_GIVEN_NAME
192+
verifierOid4vp = OpenId4VpVerifier(
193+
keyMaterial = verifierKeyMaterial,
194+
clientIdScheme = ClientIdScheme.RedirectUri(clientId),
195+
)
196+
val requestOptions = OpenIdRequestOptions(
197+
credentials = setOf(
198+
RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(mdlFamilyName)),
199+
RequestOptionsCredential(AtomicAttribute2023, ISO_MDOC, setOf(atomicGivenName))
200+
),
201+
responseMode = OpenIdConstants.ResponseMode.DirectPostJwt,
202+
responseUrl = "https://example.com/response",
203+
encryption = true
204+
)
205+
val authnRequest = verifierOid4vp.createAuthnRequest(
206+
requestOptions, OpenId4VpVerifier.CreationOptions.Query(walletUrl)
207+
).getOrThrow().url
208+
209+
val authnResponse = holderOid4vp.createAuthnResponse(authnRequest).getOrThrow()
210+
.shouldBeInstanceOf<AuthenticationResponseResult.Post>()
211+
212+
val documents = verifierOid4vp.validateAuthnResponse(authnResponse.params.formUrlEncode())
213+
.shouldBeInstanceOf<AuthnResponseResult.VerifiablePresentationValidationResults>()
214+
.validationResults.flatMap { it.shouldBeInstanceOf<AuthnResponseResult.SuccessIso>().documents }
215+
documents.first { it.mso.docType == AtomicAttribute2023.isoDocType }
216+
.validItems.shouldHaveSingleElement { it.elementIdentifier == atomicGivenName }
217+
documents.first { it.mso.docType == MobileDrivingLicenceScheme.isoDocType }
218+
.validItems.shouldHaveSingleElement { it.elementIdentifier == mdlFamilyName }
219+
}
220+
188221
"Selective Disclosure with mDL JSON Path syntax" {
189222
verifierOid4vp = OpenId4VpVerifier(
190223
keyMaterial = verifierKeyMaterial,

vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -214,19 +214,30 @@ class HolderAgent(
214214
)
215215
}.toList()
216216

217+
// Presentation will be one single ISO mDoc DeviceResponse, containing multiple documents
218+
val isSingleIsoMdocPresentation = submissionList.all { it.second.credential is StoreEntry.Iso }
217219
val presentationSubmission = PresentationSubmission.fromMatches(
218220
presentationId = presentationDefinition.id,
219221
matches = submissionList,
222+
isSingleIsoMdocPresentation = isSingleIsoMdocPresentation
220223
)
221224

222-
val verifiablePresentations = submissionList.map { match ->
223-
val credential = match.second.credential
224-
val disclosedAttributes = match.second.disclosedAttributes
225-
verifiablePresentationFactory.createVerifiablePresentation(
226-
request = request,
227-
credential = credential,
228-
disclosedAttributes = disclosedAttributes,
229-
).getOrThrow()
225+
val verifiablePresentations = if (isSingleIsoMdocPresentation) {
226+
listOf(
227+
verifiablePresentationFactory.createVerifiablePresentationForIsoCredentials(
228+
request = request,
229+
credentialAndDisclosedAttributes = submissionList
230+
.associate { it.second.credential as StoreEntry.Iso to it.second.disclosedAttributes },
231+
).getOrThrow()
232+
)
233+
} else {
234+
submissionList.map { match ->
235+
verifiablePresentationFactory.createVerifiablePresentation(
236+
request = request,
237+
credential = match.second.credential,
238+
disclosedAttributes = match.second.disclosedAttributes,
239+
).getOrThrow()
240+
}
230241
}
231242

232243
PresentationResponseParameters.PresentationExchangeParameters(
@@ -362,14 +373,15 @@ class HolderAgent(
362373
private fun PresentationSubmission.Companion.fromMatches(
363374
presentationId: String?,
364375
matches: List<Pair<String, PresentationExchangeCredentialDisclosure>>,
376+
isSingleIsoMdocPresentation: Boolean,
365377
) = PresentationSubmission(
366378
id = uuid4().toString(),
367379
definitionId = presentationId,
368380
descriptorMap = matches.mapIndexed { index, match ->
369381
PresentationSubmissionDescriptor.fromMatch(
370382
inputDescriptorId = match.first,
371383
credential = match.second.credential,
372-
index = if (matches.size == 1) null else index,
384+
index = if (matches.size == 1 || isSingleIsoMdocPresentation) null else index,
373385
)
374386
},
375387
)

vck/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/PresentationDataClasses.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ data class PresentationRequestParameters(
3939
* Handle calculating device signature for ISO mDocs, as this depends on the transport protocol
4040
* (OpenId4VP with ISO/IEC 18013-7)
4141
*/
42-
val calcIsoDeviceSignature: (suspend (docType: String) -> Pair<CoseSigned<ByteArray>, String?>?) = { null },
42+
val calcIsoDeviceSignature: (suspend (docType: String, existingMdocGeneratedNonce: String?) -> Pair<CoseSigned<ByteArray>, String?>?) = { _, _ -> null },
4343
) {
4444
internal fun getTransactionDataHashes(): Set<ByteArray>? = transactionData?.map {
4545
(vckJsonSerializer.encodeToJsonElement(

0 commit comments

Comments
 (0)