From 363cd7eea8093f90f3fe3805ff46f84cc7ba942b Mon Sep 17 00:00:00 2001 From: Christian Kollmann Date: Mon, 31 Jul 2023 09:56:24 +0200 Subject: [PATCH] SIOP: Request specific claims in ISO use case --- .../wallet/lib/oidc/OidcSiopVerifier.kt | 32 ++++++++++--------- .../wallet/lib/oidc/OidcSiopWallet.kt | 10 ++++-- .../lib/oidc/OidcSiopIsoProtocolTest.kt | 30 +++++++++++++++++ .../wallet/lib/oidc/OidcSiopProtocolTest.kt | 8 ++++- .../at/asitplus/wallet/lib/agent/Holder.kt | 3 +- .../asitplus/wallet/lib/agent/HolderAgent.kt | 11 ++++--- .../agent/InMemorySubjectCredentialStore.kt | 5 ++- .../wallet/lib/data/dif/ConstraintField.kt | 8 +++-- 8 files changed, 77 insertions(+), 30 deletions(-) diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt index 0f0c323fb..8a91d24a8 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopVerifier.kt @@ -49,7 +49,7 @@ import kotlin.time.toDuration * Implements [OIDC for VP](https://openid.net/specs/openid-connect-4-verifiable-presentations-1_0.html) (2023-04-21) * as well as [SIOP V2](https://openid.net/specs/openid-connect-self-issued-v2-1_0.html) (2023-01-01). * - * The [verifier] creates the Authentication Request, see [OidcSiopWallet] for the holder. + * This class creates the Authentication Request, [verifier] verifies the response. See [OidcSiopWallet] for the holder. */ class OidcSiopVerifier( private val verifier: Verifier, @@ -60,6 +60,7 @@ class OidcSiopVerifier( timeLeewaySeconds: Long = 300L, private val clock: Clock = Clock.System, private val credentialScheme: ConstantIndex.CredentialScheme? = null, + private val requestedAttributes: List? = null, ) { private val timeLeeway = timeLeewaySeconds.toDuration(DurationUnit.SECONDS) @@ -75,6 +76,7 @@ class OidcSiopVerifier( timeLeewaySeconds: Long = 300L, clock: Clock = Clock.System, credentialScheme: ConstantIndex.CredentialScheme? = null, + requestedAttributes: List? = null, ) = OidcSiopVerifier( verifier = verifier, relyingPartyUrl = relyingPartyUrl, @@ -84,6 +86,7 @@ class OidcSiopVerifier( timeLeewaySeconds = timeLeewaySeconds, clock = clock, credentialScheme = credentialScheme, + requestedAttributes = requestedAttributes, ) } @@ -95,13 +98,11 @@ class OidcSiopVerifier( fun createAuthnRequestUrl( walletUrl: String, responseMode: String? = null, - credentialScheme: ConstantIndex.CredentialScheme? = null, state: String? = uuid4().toString(), ): String { val urlBuilder = URLBuilder(walletUrl) createAuthnRequest( responseMode = responseMode, - credentialScheme = credentialScheme, state = state, ).encodeToParameters() .forEach { urlBuilder.parameters.append(it.key, it.value) } @@ -112,20 +113,17 @@ class OidcSiopVerifier( * Creates an OIDC Authentication Request, encoded as query parameters to the [walletUrl], * containing a JWS Authorization Request (JAR, RFC9101), containing the request parameters itself. * - * @param credentialScheme which credential to request, or any credential if `null` * @param responseMode which response mode to request, see [OpenIdConstants.ResponseModes] * @param state opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] */ suspend fun createAuthnRequestUrlWithRequestObject( walletUrl: String, responseMode: String? = null, - credentialScheme: ConstantIndex.CredentialScheme? = null, state: String? = uuid4().toString(), ): String { val urlBuilder = URLBuilder(walletUrl) createAuthnRequestAsRequestObject( responseMode = responseMode, - credentialScheme = credentialScheme, state = state, ).encodeToParameters() .forEach { urlBuilder.parameters.append(it.key, it.value) } @@ -135,18 +133,15 @@ class OidcSiopVerifier( /** * Creates an JWS Authorization Request (JAR, RFC9101), wrapping the usual [AuthenticationRequestParameters]. * - * @param credentialScheme which credential to request, or any credential if `null` * @param responseMode which response mode to request, see [OpenIdConstants.ResponseModes] * @param state opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] */ suspend fun createAuthnRequestAsRequestObject( responseMode: String? = null, - credentialScheme: ConstantIndex.CredentialScheme? = null, state: String? = uuid4().toString(), ): AuthenticationRequestParameters { val requestObject = createAuthnRequest( responseMode = responseMode, - credentialScheme = credentialScheme, state = state, ) val requestObjectSerialized = jsonSerializer.encodeToString( @@ -166,12 +161,10 @@ class OidcSiopVerifier( * * Callers may serialize the result with `result.encodeToParameters().formUrlEncode()` * - * @param credentialScheme which credential to request, or any credential if `null` * @param responseMode which response mode to request, see [OpenIdConstants.ResponseModes] * @param state opaque value which will be returned by the OpenId Provider and also in [AuthnResponseResult] */ fun createAuthnRequest( - credentialScheme: ConstantIndex.CredentialScheme? = null, responseMode: String? = null, state: String? = uuid4().toString(), ): AuthenticationRequestParameters { @@ -230,10 +223,7 @@ class OidcSiopVerifier( id = uuid4().toString(), schema = arrayOf(SchemaReference(credentialScheme?.schemaUri ?: "https://example.com")), constraints = Constraint( - fields = arrayOf( - mainConstraint - // TODO Add constraints for requested MDOC fields - ), + fields = (requestedAttributes?.let { createConstraints(it) } ?: arrayOf()) + mainConstraint, ), ) ), @@ -241,6 +231,18 @@ class OidcSiopVerifier( ) } + private fun createConstraints(attributeTypes: List): Array { + if (credentialScheme?.credentialFormat != ConstantIndex.CredentialFormat.ISO_18013) + return arrayOf() + + return attributeTypes.map { + ConstraintField( + path = arrayOf("\$.mdoc.$it"), + intentToRetain = false, + ) + }.toTypedArray() + } + sealed class AuthnResponseResult { /** * Error in parsing the URL or content itself, before verifying the contents of the OpenId response diff --git a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt index 2f45997e6..728c51a3e 100644 --- a/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt +++ b/vclib-openid/src/commonMain/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopWallet.kt @@ -235,8 +235,14 @@ class OidcSiopWallet( val requestedScopes = params.scope?.split(" ") ?.filterNot { it == SCOPE_OPENID }?.filterNot { it == SCOPE_PROFILE } ?.toList() ?: listOf() - // TODO also add requested claims - val vp = holder.createPresentation(params.nonce, audience, requestedScopes) + val requestedClaims = params.presentationDefinition?.inputDescriptors + ?.mapNotNull { it.constraints }?.flatMap { it.fields?.toList() ?: listOf() } + ?.flatMap { it.path.toList() } + ?.filter { it != "$.type" } + ?.filter { it != "$.mdoc.doctype" } + ?.map { it.removePrefix("\$.mdoc.") } + ?: listOf() + val vp = holder.createPresentation(params.nonce, audience, requestedScopes, requestedClaims) ?: return KmmResult.failure(OAuth2Exception(Errors.USER_CANCELLED)) .also { Napier.w("Could not create presentation") } diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt index b2983eace..406cde943 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopIsoProtocolTest.kt @@ -8,9 +8,13 @@ import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.iso.IsoDataModelConstants import com.benasher44.uuid.uuid4 import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.collections.shouldBeSingleton +import io.kotest.matchers.collections.shouldHaveSingleElement +import io.kotest.matchers.collections.shouldMatchEach import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.runBlocking @@ -57,6 +61,7 @@ class OidcSiopIsoProtocolTest : FreeSpec({ verifier = verifierAgent, cryptoService = verifierCryptoService, relyingPartyUrl = relyingPartyUrl, + credentialScheme = ConstantIndex.MobileDrivingLicence2023, ) } @@ -76,4 +81,29 @@ class OidcSiopIsoProtocolTest : FreeSpec({ document.invalidItems.shouldBeEmpty() } + "Selective Disclosure" { + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + credentialScheme = ConstantIndex.MobileDrivingLicence2023, + requestedAttributes = listOf(IsoDataModelConstants.DataElements.FAMILY_NAME), + ) + val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl) + println(authnRequest) + + val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() + authnResponse.shouldBeInstanceOf() + println(authnResponse) + + val result = verifierSiop.validateAuthnResponse(authnResponse.url) + result.shouldBeInstanceOf() + val document = result.document + println(document) + document.validItems.shouldNotBeEmpty() + document.validItems.shouldBeSingleton() + document.validItems.shouldHaveSingleElement { it.elementIdentifier == IsoDataModelConstants.DataElements.FAMILY_NAME } + document.invalidItems.shouldBeEmpty() + } + }) diff --git a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt index 757189959..c313b71eb 100644 --- a/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt +++ b/vclib-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidc/OidcSiopProtocolTest.kt @@ -169,6 +169,12 @@ class OidcSiopProtocolTest : FreeSpec({ } "test specific credential" { + verifierSiop = OidcSiopVerifier.newInstance( + verifier = verifierAgent, + cryptoService = verifierCryptoService, + relyingPartyUrl = relyingPartyUrl, + credentialScheme = TestCredentialScheme + ) holderAgent.storeCredentials( IssuerAgent.newDefaultInstance( DefaultCryptoService(), @@ -179,7 +185,7 @@ class OidcSiopProtocolTest : FreeSpec({ ).toStoreCredentialInput() ) - val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl, credentialScheme = TestCredentialScheme) + val authnRequest = verifierSiop.createAuthnRequestUrl(walletUrl) println(authnRequest) val authnResponse = holderSiop.createAuthnResponse(authnRequest).getOrThrow() diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt index 23e7d1181..27de96109 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/Holder.kt @@ -98,7 +98,7 @@ interface Holder { /** * Creates a [VerifiablePresentation] serialized as a JWT for all the credentials we have stored, - * that match the [attributeTypes] (if specified). + * that match the [attributeTypes] (if specified). Optionally filters by [requestedClaims] (e.g. in ISO case). * * May return null if no valid credentials (i.e. non-revoked, matching attribute name) are available. */ @@ -106,6 +106,7 @@ interface Holder { challenge: String, audienceId: String, attributeTypes: Collection? = null, + requestedClaims: Collection? = null, ): CreatePresentationResult? /** diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt index 8126b0089..3359df76b 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/HolderAgent.kt @@ -156,7 +156,7 @@ class HolderAgent( /** * Creates a [VerifiablePresentation] serialized as a JWT for all the credentials we have stored, - * that match [attributeTypes] (if specified). + * that match [attributeTypes] (if specified). Optionally filters by [requestedClaims] (e.g. in ISO case). * * May return null if no valid credentials (i.e. non-revoked, matching attribute name) are available. */ @@ -164,6 +164,7 @@ class HolderAgent( challenge: String, audienceId: String, attributeTypes: Collection?, + requestedClaims: Collection?, ): Holder.CreatePresentationResult? { val credentials = subjectCredentialStore.getCredentials(attributeTypes).getOrNull() ?: return null @@ -196,7 +197,7 @@ class HolderAgent( docType = DOC_TYPE_MDL, issuerSigned = IssuerSigned( namespaces = mapOf(NAMESPACE_MDL to IssuerSignedList(attributes.entries.filter { - it.discloseItem(attributeTypes) + it.discloseItem(requestedClaims) })), issuerAuth = validIsoCredential.issuerAuth ), @@ -213,9 +214,9 @@ class HolderAgent( return null } - private fun ByteStringWrapper.discloseItem(attributeTypes: Collection?) = - if (attributeTypes?.isNotEmpty() == true) { - value.elementIdentifier in attributeTypes + private fun ByteStringWrapper.discloseItem(requestedClaims: Collection?) = + if (requestedClaims?.isNotEmpty() == true) { + value.elementIdentifier in requestedClaims } else { true } diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/InMemorySubjectCredentialStore.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/InMemorySubjectCredentialStore.kt index fdbe17292..6509b9c4b 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/InMemorySubjectCredentialStore.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/agent/InMemorySubjectCredentialStore.kt @@ -1,6 +1,7 @@ package at.asitplus.wallet.lib.agent import at.asitplus.KmmResult +import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.VerifiableCredentialJws import at.asitplus.wallet.lib.iso.IssuerSigned @@ -31,9 +32,7 @@ class InMemorySubjectCredentialStore : SubjectCredentialStore { requiredAttributeTypes: Collection? ) = if (requiredAttributeTypes?.isNotEmpty() == true) { when (this) { - is SubjectCredentialStore.StoreEntry.Iso -> issuerSigned.namespaces?.keys?.any { it in requiredAttributeTypes } - ?: false - + is SubjectCredentialStore.StoreEntry.Iso -> ConstantIndex.MobileDrivingLicence2023.vcType in requiredAttributeTypes is SubjectCredentialStore.StoreEntry.Vc -> vc.vc.type.any { it in requiredAttributeTypes } } } else true diff --git a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt index 4ef9366bd..766add87c 100644 --- a/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt +++ b/vclib/src/commonMain/kotlin/at/asitplus/wallet/lib/data/dif/ConstraintField.kt @@ -19,7 +19,9 @@ data class ConstraintField( // should be JSONPath val path: Array, @SerialName("filter") - val filter: ConstraintFilter? = null + val filter: ConstraintFilter? = null, + @SerialName("intent_to_retain") + val intentToRetain: Boolean? = null, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -32,8 +34,7 @@ data class ConstraintField( if (predicate != other.predicate) return false if (!path.contentEquals(other.path)) return false if (filter != other.filter) return false - - return true + return intentToRetain == other.intentToRetain } override fun hashCode(): Int { @@ -42,6 +43,7 @@ data class ConstraintField( result = 31 * result + (predicate?.hashCode() ?: 0) result = 31 * result + path.contentHashCode() result = 31 * result + (filter?.hashCode() ?: 0) + result = 31 * result + (intentToRetain?.hashCode() ?: 0) return result } } \ No newline at end of file