Skip to content

Commit d357dee

Browse files
committed
DCAPI: Integrate ISO 18013-7 Annex C
1 parent 811871c commit d357dee

13 files changed

Lines changed: 256 additions & 128 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Release 7.0.0 (unreleased):
1818
- Fix disclosure of SD-JWT claims from foreign issuers: match disclosure digests against the originally serialized disclosures instead of re-serializing them, since digests are computed over the exact bytes (RFC 9901, section 4.2.3), e.g. failing for disclosures serialized with whitespace.
1919
- Extend `DCQLCredentialQueryMatchingResult` by case `AllMandatoryClaimsMatchingResult`
2020
- Consolidate interface of `OpenId4VpVerifier`: All clients should use `createAuthnRequest()`, so we deprecate methods `submitAuthnRequest()` or `createAuthnRequestAsSignedRequestObject()`
21-
- Extract `DcApiVerifier` as a pendant to `OpenId4VpVerifier` which handles DCAPI requests only
21+
- Extract `DcApiVerifier` as a pendant to `OpenId4VpVerifier` which handles DCAPI requests only, deprecating `Iso180137AnnexCVerifier`
2222
- Move `CreationOptions` and `CreatedRequest` to upper level (`at.asitplus.wallet.lib.openid`) instead of nesting in `OpenId4VpVerifier`
2323
- Verifier:
2424
- Add `NonceChallengeVerifier`, a thin `Verifier` wrapper that creates presentation challenges from a `NonceService` and verifies SD-JWT/VC-JWT presentations against the embedded challenge.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package at.asitplus.openid.dcql
2+
3+
import at.asitplus.iso.DeviceRequest
4+
import at.asitplus.iso.DocRequest
5+
import at.asitplus.iso.ItemsRequest
6+
import at.asitplus.iso.ItemsRequestList
7+
import at.asitplus.iso.SingleItemsRequest
8+
import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper
9+
10+
/**
11+
* Renders this DCQL query as an ISO 18013-5 [DeviceRequest],
12+
* to be used for ISO/IEC 18013-7 Annex C flows over the W3C Digital Credentials API.
13+
*
14+
* All credential queries must be [DCQLIsoMdocCredentialQuery] with explicitly enumerated
15+
* [DCQLIsoMdocCredentialQuery.claims]: DCQL's `claims == null` (requesting all claims)
16+
* cannot be expressed in an ISO device request.
17+
*/
18+
fun DCQLQuery.toIso180137AnnexCDeviceRequest(): DeviceRequest = DeviceRequest(
19+
version = "1.0",
20+
docRequests = credentials.map { it.toDocRequest() }.toTypedArray(),
21+
)
22+
23+
private fun DCQLCredentialQuery.toDocRequest(): DocRequest {
24+
require(this is DCQLIsoMdocCredentialQuery) {
25+
"Only mso_mdoc credential queries are supported for ISO 18013-7 Annex C, got $format"
26+
}
27+
val namespaces = requireNotNull(claims) {
28+
"ISO device requests require explicitly enumerated claims"
29+
}.groupBy(
30+
keySelector = { it.namespace },
31+
valueTransform = { SingleItemsRequest(it.claimName, it.intentToRetain ?: false) },
32+
).mapValues { (_, items) -> ItemsRequestList(items) }
33+
return DocRequest(ByteStringWrapper(ItemsRequest(meta.doctypeValue, namespaces)))
34+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ data class AuthnResponseResult(
1212
val idTokenValidationResult: KmmResult<IdToken>?,
1313
val vpTokenValidationResult: KmmResult<VpTokenValidationResult>?,
1414
val request: AuthenticationRequestParameters?,
15-
) {
15+
) : DcApiResponseResult() {
1616
val state
1717
get() = request?.state
1818
}

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

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ import at.asitplus.dif.FormatContainerSdJwt
88
import at.asitplus.dif.FormatHolder
99
import at.asitplus.dif.InputDescriptor
1010
import at.asitplus.dif.PresentationDefinition
11-
import at.asitplus.iso.DeviceRequest
12-
import at.asitplus.iso.DocRequest
13-
import at.asitplus.iso.ItemsRequest
14-
import at.asitplus.iso.ItemsRequestList
15-
import at.asitplus.iso.SingleItemsRequest
1611
import at.asitplus.openid.dcql.DCQLClaimsPathPointer
1712
import at.asitplus.openid.dcql.DCQLClaimsPathPointerSegment.NameSegment
1813
import at.asitplus.openid.dcql.DCQLClaimsQueryList
@@ -28,10 +23,10 @@ import at.asitplus.openid.dcql.DCQLJwtVcCredentialQuery
2823
import at.asitplus.openid.dcql.DCQLQuery
2924
import at.asitplus.openid.dcql.DCQLSdJwtCredentialMetadataAndValidityConstraints
3025
import at.asitplus.openid.dcql.DCQLSdJwtCredentialQuery
31-
import at.asitplus.signum.indispensable.cosef.io.ByteStringWrapper
3226
import at.asitplus.wallet.lib.RequestOptionsCredential
3327
import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.*
3428
import at.asitplus.wallet.lib.data.CredentialPresentationRequest
29+
import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest
3530
import at.asitplus.wallet.lib.toIsoMdocClaimPath
3631
import com.benasher44.uuid.uuid4
3732

@@ -66,8 +61,8 @@ data class CredentialPresentationRequestBuilder(
6661
ISO_MDOC -> FormatHolder(msoMdoc = FormatContainerJwt())
6762
}
6863

69-
fun toDCQLRequest(): CredentialPresentationRequest.DCQLRequest? {
70-
return CredentialPresentationRequest.DCQLRequest(
64+
fun toDCQLRequest(): DCQLRequest? {
65+
return DCQLRequest(
7166
DCQLQuery(
7267
credentials = DCQLCredentialQueryList(
7368
credentials.mapNotNull {
@@ -80,26 +75,6 @@ data class CredentialPresentationRequestBuilder(
8075
)
8176
}
8277

83-
fun toIso180137AnnexCDeviceRequest() = credentials.map {
84-
if (it.representation != ISO_MDOC) {
85-
throw UnsupportedOperationException("Wrong representation: Only ISO MDoc is supported")
86-
}
87-
val docType = it.credentialScheme.isoDocType ?: throw IllegalStateException("Missing doc type")
88-
val itemsRequestList = it.effectiveRequestedAttributePaths().map { reqAttr ->
89-
val (namespace, claimName) = reqAttr.toIsoMdocClaimPath(it.credentialScheme)
90-
.toIsoMdocNamespaceAndClaimName()
91-
namespace to SingleItemsRequest(claimName, false)
92-
}.groupBy(
93-
keySelector = { (namespace, _) -> namespace },
94-
valueTransform = { (_, itemRequest) -> itemRequest }
95-
).mapValues { (_, itemRequests) ->
96-
ItemsRequestList(itemRequests)
97-
}
98-
DocRequest(ByteStringWrapper(ItemsRequest(docType, itemsRequestList)))
99-
}.toTypedArray().let {
100-
DeviceRequest("1.0", it)
101-
}
102-
10378
private fun RequestOptionsCredential.toQuery(): DCQLCredentialQuery? = when (representation) {
10479
PLAIN_JWT -> toJwtVcQuery()
10580
SD_JWT -> toSdJwtQuery()

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,11 @@ sealed class DcApiCreationOptions {
1919
*/
2020
data object OpenId4VpSigned : DcApiCreationOptions()
2121

22-
// TODO: OpenId4VpMultiSigned (`openid4vp-v1-multisigned`) and Iso18013AnnexC (`org-iso-mdoc`)
22+
/**
23+
* ISO 18013-7 Annex C request, i.e. protocol `org-iso-mdoc`,
24+
* see [at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest.IsoMdoc].
25+
*/
26+
data object Iso180137AnnexC : DcApiCreationOptions()
27+
28+
// TODO: OpenId4VpMultiSigned (`openid4vp-v1-multisigned`)
2329
}

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

Lines changed: 100 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,27 @@ import at.asitplus.catching
55
import at.asitplus.dcapi.DCAPIHandover
66
import at.asitplus.dcapi.DCAPIHandover.Companion.TYPE_DCAPI
77
import at.asitplus.dcapi.DCAPIInfo
8+
import at.asitplus.dcapi.DCAPIResponse
89
import at.asitplus.dcapi.DigitalCredentialInterface
910
import at.asitplus.dcapi.IsoMdocResponse
1011
import at.asitplus.dcapi.OpenID4VPDCAPIHandoverInfo
1112
import at.asitplus.dcapi.OpenId4VpResponse
1213
import at.asitplus.dcapi.SessionTranscriptContentHashable
14+
import at.asitplus.dcapi.request.IsoMdocRequest
1315
import at.asitplus.dcapi.request.verifier.CredentialRequestOptions
14-
import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest
16+
import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest.*
17+
import at.asitplus.dcapi.request.verifier.DigitalCredentialGetRequest.OpenId4Vp.SignedDataElement
1518
import at.asitplus.dif.ClaimFormat
1619
import at.asitplus.dif.DifInputDescriptor
1720
import at.asitplus.dif.FormatContainerJwt
1821
import at.asitplus.dif.FormatContainerSdJwt
1922
import at.asitplus.dif.PresentationSubmissionDescriptor
2023
import at.asitplus.iso.DeviceResponse
24+
import at.asitplus.iso.EncryptionInfo
25+
import at.asitplus.iso.EncryptionParameters
2126
import at.asitplus.iso.SessionTranscript
2227
import at.asitplus.iso.serializeOrigin
2328
import at.asitplus.iso.sha256
24-
import at.asitplus.jsonpath.JsonPath
2529
import at.asitplus.openid.AuthenticationRequestParameters
2630
import at.asitplus.openid.CredentialFormatEnum
2731
import at.asitplus.openid.IdTokenType
@@ -36,10 +40,14 @@ import at.asitplus.openid.VpFormatsSupported
3640
import at.asitplus.openid.dcql.DCQLCredentialQueryIdentifier
3741
import at.asitplus.openid.dcql.DCQLQuery
3842
import at.asitplus.openid.dcql.DCQLQueryResponse
43+
import at.asitplus.openid.dcql.toIso180137AnnexCDeviceRequest
3944
import at.asitplus.rfc6749OAuth2AuthorizationFramework.ResponseType
45+
import at.asitplus.signum.indispensable.CryptoPrivateKey
46+
import at.asitplus.signum.indispensable.SecretExposure
4047
import at.asitplus.signum.indispensable.SignatureAlgorithm
4148
import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer
4249
import at.asitplus.signum.indispensable.cosef.toCoseAlgorithm
50+
import at.asitplus.signum.indispensable.cosef.toCoseKey
4351
import at.asitplus.signum.indispensable.io.Base64UrlStrict
4452
import at.asitplus.signum.indispensable.josef.JsonWebKey
4553
import at.asitplus.signum.indispensable.josef.JsonWebKeySet
@@ -61,6 +69,7 @@ import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKey
6169
import at.asitplus.wallet.lib.cbor.VerifyCoseSignatureWithKeyFun
6270
import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest
6371
import at.asitplus.wallet.lib.data.CredentialPresentationRequest.PresentationExchangeRequest
72+
import at.asitplus.wallet.lib.data.IsoDocumentParsed
6473
import at.asitplus.wallet.lib.data.VerifiablePresentationJws
6574
import at.asitplus.wallet.lib.data.toBase64UrlJsonString
6675
import at.asitplus.wallet.lib.extensions.sessionTranscriptThumbprint
@@ -76,6 +85,7 @@ import at.asitplus.wallet.lib.procedures.dcql.DCQLQueryAdapter
7685
import at.asitplus.wallet.lib.utils.DefaultMapStore
7786
import at.asitplus.wallet.lib.utils.MapStore
7887
import io.github.aakira.napier.Napier
88+
import io.ktor.utils.io.core.*
7989
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
8090
import kotlinx.serialization.decodeFromByteArray
8191
import kotlinx.serialization.encodeToByteArray
@@ -124,8 +134,15 @@ class DcApiVerifier @JvmOverloads constructor(
124134
override val nonceService: NonceService = DefaultNonceService(),
125135
/** Used to store issued authn requests to verify the authn response to it */
126136
private val stateToAuthnRequestStore: MapStore<String, AuthenticationRequestParameters> = DefaultMapStore(),
137+
/** Used to store issued requests to verify the response to it */
138+
private val stateToIsoMdocRequestStore: MapStore<String, IsoMdocRequest> = DefaultMapStore(),
127139
/** Algorithms supported to decrypt responses from wallets, for [metadataWithEncryption]. */
128140
private val supportedJweEncryptionAlgorithms: Set<JweEncryption> = JweEncryption.entries.toSet(),
141+
/**
142+
* Workaround until signum is ready. Required for ISO 18013-7 Annex decryption.
143+
* Parameters: Serialized ephemeral key, cipher text, decryption key, encoded session transcript
144+
*/
145+
private val decryptHpke: (suspend (ByteArray, ByteArray, CryptoPrivateKey.EC.WithPublicKey, ByteArray) -> ByteArray)? = null,
129146
) : AbstractMdocVerifier() {
130147

131148
private val nonceAwareVerifier = NonceChallengeVerifier(
@@ -200,16 +217,20 @@ class DcApiVerifier @JvmOverloads constructor(
200217
CredentialRequestOptions.create(
201218
listOf(
202219
when (creationOptions) {
203-
is DcApiCreationOptions.OpenId4VpUnsigned -> DigitalCredentialGetRequest.OpenId4VpUnsigned(
220+
is DcApiCreationOptions.OpenId4VpUnsigned -> OpenId4VpUnsigned(
204221
// client_id MUST be omitted in unsigned requests, per OpenID4VP 1.0 Appendix A.3.1
205222
createPlainAuthnRequest(requestOptions.copy(populateClientId = false))
206223
)
207224

208-
is DcApiCreationOptions.OpenId4VpSigned -> DigitalCredentialGetRequest.OpenId4VpSigned(
209-
DigitalCredentialGetRequest.OpenId4Vp.SignedDataElement(
225+
is DcApiCreationOptions.OpenId4VpSigned -> OpenId4VpSigned(
226+
SignedDataElement(
210227
createSignedRequestObject(requestOptions).getOrThrow().jws
211228
)
212229
)
230+
231+
DcApiCreationOptions.Iso180137AnnexC -> IsoMdoc(
232+
createIsoMdocRequest(requestOptions)
233+
)
213234
}
214235
)
215236
)
@@ -241,6 +262,21 @@ class DcApiVerifier @JvmOverloads constructor(
241262
storeAuthnRequest(it, requestOptions.state)
242263
}
243264

265+
private suspend fun createIsoMdocRequest(
266+
requestOptions: OpenId4VpRequestOptions,
267+
): IsoMdocRequest {
268+
val deviceRequest = ((requestOptions.presentationRequest as? DCQLRequest)?.dcqlQuery
269+
?: throw IllegalArgumentException("ISO 18013-7 Annex C requires a DCQL presentation request"))
270+
.toIso180137AnnexCDeviceRequest()
271+
272+
val encryptionParameters = EncryptionParameters(
273+
nonceService.provideNonce().toByteArray(),
274+
decryptionKeyMaterial.publicKey.toCoseKey().getOrThrow()
275+
)
276+
return IsoMdocRequest(deviceRequest, EncryptionInfo(TYPE_DCAPI, encryptionParameters))
277+
.also { stateToIsoMdocRequestStore.put(requestOptions.state, it) }
278+
}
279+
244280
private suspend fun OpenId4VpRequestOptions.toAuthnRequest(): AuthenticationRequestParameters =
245281
AuthenticationRequestParameters(
246282
responseType = responseType,
@@ -311,7 +347,7 @@ class DcApiVerifier @JvmOverloads constructor(
311347
suspend fun validateAuthnResponse(
312348
input: String,
313349
externalId: String,
314-
): KmmResult<AuthnResponseResult> = catching {
350+
): KmmResult<DcApiResponseResult> = catching {
315351
validateAuthnResponse(
316352
input = joseCompliantSerializer.decodeFromString<DigitalCredentialInterface>(input),
317353
externalId = externalId
@@ -326,9 +362,14 @@ class DcApiVerifier @JvmOverloads constructor(
326362
suspend fun validateAuthnResponse(
327363
input: DigitalCredentialInterface,
328364
externalId: String,
329-
): KmmResult<AuthnResponseResult> = catching {
365+
): KmmResult<DcApiResponseResult> = catching {
330366
when (input) {
331-
is IsoMdocResponse -> TODO()
367+
is IsoMdocResponse -> validateResponse(
368+
receivedData = input.data,
369+
externalId = externalId,
370+
expectedOrigin = "TODO"
371+
).getOrThrow()
372+
332373
else -> validateAuthnResponse(
333374
input = responseParser.parseAuthnResponse(input as OpenId4VpResponse),
334375
externalId = externalId
@@ -375,6 +416,46 @@ class DcApiVerifier @JvmOverloads constructor(
375416
}
376417
}
377418

419+
@OptIn(SecretExposure::class)
420+
suspend fun validateResponse(
421+
receivedData: DCAPIResponse,
422+
externalId: String,
423+
expectedOrigin: String
424+
): KmmResult<Iso180137AnnexCWrapper> = catching {
425+
val isoMdocRequest = stateToIsoMdocRequestStore.get(externalId)!!
426+
val privateKey = decryptionKeyMaterial.exportPrivateKey().getOrThrow()
427+
as? CryptoPrivateKey.EC.WithPublicKey
428+
?: throw IllegalStateException("Expected EC private key")
429+
430+
val encryptedResponseData = receivedData.response.encryptedResponseData
431+
val serializedOrigin = expectedOrigin.serializeOrigin()
432+
?: throw IllegalStateException("Expected origin invalid")
433+
434+
val sessionTranscript = createDcApiSessionTranscriptAnnexC(
435+
DCAPIInfo(
436+
encryptionInfo = isoMdocRequest.encryptionInfo,
437+
serializedOrigin = serializedOrigin,
438+
)
439+
)
440+
val encodedSessionTranscript = coseCompliantSerializer.encodeToByteArray(sessionTranscript)
441+
val encodedDeviceResponse = decryptHpke!!(
442+
encryptedResponseData.enc,
443+
encryptedResponseData.cipherText,
444+
privateKey,
445+
encodedSessionTranscript
446+
)
447+
val deviceResponse = coseCompliantSerializer.decodeFromByteArray<DeviceResponse>(encodedDeviceResponse)
448+
449+
Iso180137AnnexCWrapper(
450+
verifier.verifyPresentationIsoMdoc(
451+
input = deviceResponse,
452+
verifyDocument = verifyDocument(
453+
sessionTranscript = sessionTranscript
454+
)
455+
).getOrThrow().documents
456+
)
457+
}
458+
378459
private fun AuthnResponseResult.isFullyValid(): Boolean =
379460
idTokenValidationResult?.isFailure != true &&
380461
vpTokenValidationResult?.isFailure != true &&
@@ -416,35 +497,13 @@ class DcApiVerifier @JvmOverloads constructor(
416497
val vpToken = responseParameters.parameters.vpToken
417498
?: throw IllegalArgumentException("vp_token not present in ${responseParameters.parameters}")
418499
val clientIdRequired = responseParameters.clientIdRequired
419-
420500
val originalResponseParameters = responseParameters.originalResponseParameters
421-
422-
(originalResponseParameters as? ResponseParametersFrom.DcApi)?.let {
423-
authnRequest.verifyExpectedOrigin(it.origin)
501+
require(originalResponseParameters is ResponseParametersFrom.DcApi) {
502+
"Unsupported response parameters: $originalResponseParameters"
424503
}
504+
authnRequest.verifyExpectedOrigin(originalResponseParameters.origin)
425505

426-
authnRequest.presentationDefinition?.let {
427-
val presentationSubmission = responseParameters.parameters.presentationSubmission?.descriptorMap
428-
?: throw IllegalArgumentException("Presentation Exchange need to present a presentation submission.")
429-
430-
val presentation = presentationSubmission.associate { descriptor ->
431-
descriptor.id to verifyPresentationResult(
432-
claimFormat = descriptor.format,
433-
relatedPresentation = descriptor.relatedPresentation(vpToken),
434-
expectedNonce = expectedNonce,
435-
input = responseParameters,
436-
clientId = authnRequest.clientId,
437-
responseUrl = authnRequest.responseUrl ?: authnRequest.redirectUrlExtracted,
438-
transactionData = authnRequest.transactionData,
439-
clientIdRequired = clientIdRequired,
440-
origin = (originalResponseParameters as? ResponseParametersFrom.DcApi)?.origin,
441-
)
442-
}
443-
444-
VpTokenValidationResultPresentationExchange(
445-
inputDescriptorResponseValidations = presentation,
446-
)
447-
} ?: authnRequest.dcqlQuery?.let { query ->
506+
authnRequest.dcqlQuery?.let { query ->
448507
val presentation = vpToken.jsonObject.mapKeys {
449508
DCQLCredentialQueryIdentifier(it.key)
450509
}.mapValues { (credentialQueryId, relatedPresentation) ->
@@ -462,7 +521,7 @@ class DcApiVerifier @JvmOverloads constructor(
462521
?: authnRequest.redirectUrlExtracted,
463522
transactionData = authnRequest.transactionData,
464523
clientIdRequired = clientIdRequired,
465-
origin = (originalResponseParameters as? ResponseParametersFrom.DcApi)?.origin,
524+
origin = originalResponseParameters.origin,
466525
requireCryptographicHolderBinding = query.credentialQuery(credentialQueryId)?.requireCryptographicHolderBinding,
467526
)
468527
}
@@ -490,9 +549,6 @@ class DcApiVerifier @JvmOverloads constructor(
490549
private fun DCQLQuery.credentialQuery(id: DCQLCredentialQueryIdentifier) =
491550
credentials.associateBy { it.id }[id]
492551

493-
private fun PresentationSubmissionDescriptor.relatedPresentation(vpToken: JsonElement) =
494-
JsonPath(cumulativeJsonPath).query(vpToken).first().value
495-
496552
private fun CredentialFormatEnum.toClaimFormat(): ClaimFormat = when (this) {
497553
CredentialFormatEnum.JWT_VC -> ClaimFormat.JWT_VP
498554
CredentialFormatEnum.DC_SD_JWT -> ClaimFormat.SD_JWT
@@ -648,3 +704,9 @@ private val PresentationSubmissionDescriptor.cumulativeJsonPath: String
648704
}
649705
return cummulativeJsonPath
650706
}
707+
708+
sealed class DcApiResponseResult {
709+
710+
}
711+
712+
data class Iso180137AnnexCWrapper(val documents: Collection<IsoDocumentParsed>) : DcApiResponseResult()

0 commit comments

Comments
 (0)