From e890fc2e8637061b149524c12ca9a3498e68810f Mon Sep 17 00:00:00 2001 From: babisRoutis Date: Thu, 5 Dec 2024 23:48:44 +0200 Subject: [PATCH] DCQL definitions --- build.gradle.kts | 7 + .../eu/europa/ec/eudi/openid4vp/Format.kt | 37 ++ .../europa/ec/eudi/openid4vp/OpenId4VPSpec.kt | 58 ++ .../ec/eudi/openid4vp/dcql/ClaimPath.kt | 224 +++++++ .../eu/europa/ec/eudi/openid4vp/dcql/DCQL.kt | 370 +++++++++++ .../ec/eudi/openid4vp/dcql/DCQLParseTest.kt | 614 ++++++++++++++++++ .../ec/eudi/openid4vp/dcql/DCQLRulesTest.kt | 81 +++ 7 files changed, 1391 insertions(+) create mode 100644 src/main/kotlin/eu/europa/ec/eudi/openid4vp/Format.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/openid4vp/OpenId4VPSpec.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/ClaimPath.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQL.kt create mode 100644 src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLParseTest.kt create mode 100644 src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLRulesTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index e48262d..564934a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -59,6 +59,13 @@ kotlin { } compilerOptions { apiVersion = KotlinVersion.KOTLIN_2_0 + optIn = listOf( + "kotlinx.serialization.ExperimentalSerializationApi", + "kotlin.contracts.ExperimentalContracts", + ) + freeCompilerArgs = listOf( + "-Xconsistent-data-class-copy-visibility", + ) } } diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vp/Format.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/Format.kt new file mode 100644 index 0000000..e3d6e6b --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/Format.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vp + +import kotlinx.serialization.Serializable + +@Serializable +@JvmInline +public value class Format(public val value: String) { + init { + require(value.isNotBlank()) { "Format cannot be blank" } + } + + override fun toString(): String = value + + public companion object { + public val MsoMdoc: Format get() = Format(OpenId4VPSpec.FORMAT_MSO_MDOC) + public val SdJwtVcDeprecated: Format get() = Format(OpenId4VPSpec.FORMAT_SD_JWT_VC) + public val SdJwtVc: Format get() = Format(OpenId4VPSpec.FORMAT_SD_JWT_VC) + public val W3CLdpVc: Format get() = Format(OpenId4VPSpec.FORMAT_W3C_JSONLD_DATA_INTEGRITY) + public val W3CJwtVcJsonLd: Format get() = Format(OpenId4VPSpec.FORMAT_W3C_JSONLD_SIGNED_JWT) + public val W3CJwtVcJson: Format get() = Format(OpenId4VPSpec.FORMAT_W3C_SIGNED_JWT) + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vp/OpenId4VPSpec.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/OpenId4VPSpec.kt new file mode 100644 index 0000000..b7d4997 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/OpenId4VPSpec.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vp + +public object OpenId4VPSpec { + + public const val RM_DIRECT_POST: String = "direct_post" + public const val RM_DIRECT_POST_JWT: String = "direct_post.jwt" + + public const val VP_TOKEN: String = "vp_token" + + public const val WALLET_NONCE: String = "wallet_nonce" + public const val WALLET_METADATA: String = "wallet_metadata" + + public const val FORMAT_MSO_MDOC: String = "mso_mdoc" + + @Deprecated( + message = "Removed by spec", + ) + public const val FORMAT_SD_JWT_VC_DEPRECATED: String = "vc+sd-jwt" + public const val FORMAT_SD_JWT_VC: String = "dc+sd-jwt" + public const val FORMAT_W3C_JSONLD_DATA_INTEGRITY: String = "ldp_vc" + public const val FORMAT_W3C_JSONLD_SIGNED_JWT: String = "jwt_vc_json-ld" + public const val FORMAT_W3C_SIGNED_JWT: String = "jwt_vc_json" + + public const val DCQL_CREDENTIALS: String = "credentials" + public const val DCQL_CREDENTIAL_SETS: String = "credential_sets" + + public const val DCQL_ID: String = "id" + public const val DCQL_FORMAT: String = "format" + public const val DCQL_META: String = "meta" + public const val DCQL_CLAIMS: String = "claims" + public const val DCQL_CLAIM_SETS: String = "claim_sets" + public const val DCQL_OPTIONS: String = "options" + public const val DCQL_REQUIRED: String = "required" + public const val DCQL_PURPOSE: String = "purpose" + public const val DCQL_PATH: String = "path" + public const val DCQL_VALUES: String = "values" + public const val DCQL_SD_JWT_VC_VCT_VALUES: String = "vct_values" + public const val DCQL_MSO_MDOC_DOCTYPE_VALUE: String = "doctype_value" + public const val DCQL_MSO_MDOC_NAMESPACE: String = "namespace" + public const val DCQL_MSO_MDOC_CLAIM_NAME: String = "claim_name" +} + +public object SIOPv2 diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/ClaimPath.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/ClaimPath.kt new file mode 100644 index 0000000..80a272e --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/ClaimPath.kt @@ -0,0 +1,224 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vp.dcql + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.serializer +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +// +// That's a copy from sd-jwt-kt lib +// + +/** + * The path is a non-empty [list][value] of [elements][ClaimPathElement], + * null values, or non-negative integers. + * It is used to [select][SelectPath] a particular claim in the credential or a set of claims. + * + * It is [serialized][ClaimPathSerializer] as a [JsonArray] which may contain + * string, `null`, or integer elements + */ +@Serializable(with = ClaimPathSerializer::class) +@JvmInline +value class ClaimPath(val value: List) { + + init { + require(value.isNotEmpty()) + } + + override fun toString(): String = value.toString() + + operator fun plus(other: ClaimPathElement): ClaimPath = ClaimPath(this.value + other) + + operator fun plus(other: ClaimPath): ClaimPath = ClaimPath(this.value + other.value) + + operator fun contains(that: ClaimPath): Boolean = value.foldIndexed(this.value.size <= that.value.size) { index, acc, thisElement -> + fun comp() = that.value.getOrNull(index)?.let { thatElement -> thatElement in thisElement } == true + acc and comp() + } + + /** + * Appends a wild-card indicator [ClaimPathElement.AllArrayElements] + */ + fun allArrayElements(): ClaimPath = this + ClaimPathElement.AllArrayElements + + /** + * Appends an indexed path [ClaimPathElement.ArrayElement] + */ + fun arrayElement(i: Int): ClaimPath = this + ClaimPathElement.ArrayElement(i) + + /** + * Appends a named path [ClaimPathElement.Claim] + */ + fun claim(name: String): ClaimPath = this + ClaimPathElement.Claim(name) + + /** + * Gets the ClaimPath of the parent element. Returns `null` to indicate the root element. + */ + fun parent(): ClaimPath? = value.dropLast(1) + .takeIf { it.isNotEmpty() } + ?.let { ClaimPath(it) } + + fun head(): ClaimPathElement = value.first() + fun tail(): ClaimPath? { + val tailElements = value.drop(1) + return if (tailElements.isEmpty()) return null + else ClaimPath(tailElements) + } + + /** + * Gets the [head] + */ + operator fun component1(): ClaimPathElement = head() + + /** + * Gets the [tail] + */ + operator fun component2(): ClaimPath? = tail() + + companion object { + fun claim(name: String): ClaimPath = ClaimPath(listOf(ClaimPathElement.Claim(name))) + } +} + +/** + * Elements of a [ClaimPath] + * - [Claim] indicates that the respective [key][Claim.name] is to be selected + * - [AllArrayElements] indicates that all elements of the currently selected array(s) are to be selected, and + * - [ArrayElement] indicates that the respective [index][ArrayElement.index] in an array is to be selected + */ +sealed interface ClaimPathElement { + + /** + * Indicates that all elements of the currently selected array(s) are to be selected + * It is serialized as a [JsonNull] + */ + data object AllArrayElements : ClaimPathElement { + override fun toString(): String = "null" + } + + /** + * Indicates that the respective [index][index] in an array is to be selected. + * It is serialized as an [integer][JsonPrimitive] + * @param index Non-negative index + */ + @JvmInline + value class ArrayElement(val index: Int) : ClaimPathElement { + init { + require(index >= 0) { "Index should be non-negative" } + } + + override fun toString(): String = index.toString() + } + + /** + * Indicates that the respective [key][name] is to be selected. + * It is serialized as a [string][JsonPrimitive] + * @param name the attribute name + */ + @JvmInline + value class Claim(val name: String) : ClaimPathElement { + override fun toString(): String = name + } + + /** + * Indication of whether the current instance contains the other. + * @param that the element to compare with + * @return in case that the two elements are of the same type, and if they are equal (including attribute), + * then true is being returned. Also, an [AllArrayElements] contains [ArrayElement]. + * In all other cases, a false is being returned. + */ + operator fun contains(that: ClaimPathElement): Boolean = when (this) { + AllArrayElements -> when (that) { + AllArrayElements -> true + is ArrayElement -> true + is Claim -> false + } + + is ArrayElement -> this == that + is Claim -> this == that + } +} + +inline fun ClaimPathElement.fold( + ifAllArrayElements: () -> T, + ifArrayElement: (Int) -> T, + ifClaim: (String) -> T, +): T { + contract { + callsInPlace(ifAllArrayElements, InvocationKind.AT_MOST_ONCE) + callsInPlace(ifArrayElement, InvocationKind.AT_MOST_ONCE) + callsInPlace(ifClaim, InvocationKind.AT_MOST_ONCE) + } + return when (this) { + ClaimPathElement.AllArrayElements -> ifAllArrayElements() + is ClaimPathElement.ArrayElement -> ifArrayElement(index) + is ClaimPathElement.Claim -> ifClaim(name) + } +} + +/** + * Serializer for [ClaimPath] + */ +object ClaimPathSerializer : KSerializer { + + private fun claimPathElement(it: JsonPrimitive): ClaimPathElement = when { + it is JsonNull -> ClaimPathElement.AllArrayElements + it.isString -> ClaimPathElement.Claim(it.content) + it.intOrNull != null -> ClaimPathElement.ArrayElement(it.int) + else -> throw IllegalArgumentException("Only string, null, int can be used") + } + + private fun claimPath(array: JsonArray): ClaimPath { + val elements = array.map { + require(it is JsonPrimitive) + claimPathElement(it) + } + return ClaimPath(elements) + } + + private fun ClaimPath.toJson(): JsonArray = JsonArray(value.map { it.toJson() }) + + private fun ClaimPathElement.toJson(): JsonPrimitive = when (this) { + is ClaimPathElement.Claim -> JsonPrimitive(name) + is ClaimPathElement.ArrayElement -> JsonPrimitive(index) + ClaimPathElement.AllArrayElements -> JsonNull + } + + private val arraySerializer = serializer() + + override val descriptor: SerialDescriptor = arraySerializer.descriptor + + override fun serialize(encoder: Encoder, value: ClaimPath) { + val array = value.toJson() + arraySerializer.serialize(encoder, array) + } + + override fun deserialize(decoder: Decoder): ClaimPath { + val array = arraySerializer.deserialize(decoder) + return claimPath(array) + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQL.kt b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQL.kt new file mode 100644 index 0000000..a62290e --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQL.kt @@ -0,0 +1,370 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vp.dcql + +import eu.europa.ec.eudi.openid4vp.Format +import eu.europa.ec.eudi.openid4vp.OpenId4VPSpec +import kotlinx.serialization.Required +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject + +typealias Credentials = List +typealias CredentialSets = List +typealias CredentialSet = Set +typealias ClaimSet = Set + +@Serializable +data class DCQL( + /** + * A non-empty list of [Credential Queries][CredentialQuery], that specify the requested Verifiable Credentials + */ + @SerialName(OpenId4VPSpec.DCQL_CREDENTIALS) @Required val credentials: Credentials, + /** + * A non-empty list of [credential set queries][CredentialSetQuery], that specifies additional constraints + * on which of the requested Verifiable Credentials to return + */ + @SerialName(OpenId4VPSpec.DCQL_CREDENTIAL_SETS) val credentialSets: CredentialSets? = null, + +) { + init { + val uniqueIds = credentials.ensureValid() + credentialSets?.apply { ensureValid(uniqueIds) } + } + + companion object { + private fun Credentials.ensureValid(): Set { + require(isNotEmpty()) { "At least one credential must be defined" } + return ensureUniqueIds() + } + + private fun Credentials.ensureUniqueIds(): Set { + val uniqueIds = map { it.id }.toSet() + require(uniqueIds.size == size) { + "Within the Authorization Request, the same credential query id MUST NOT be present more than once" + } + return uniqueIds + } + + private fun CredentialSets.ensureValid(queryIds: Set) { + require(isNotEmpty()) + forEach { credentialSet -> credentialSet.ensureOptionsWithKnownIds(queryIds) } + } + + private fun CredentialSetQuery.ensureOptionsWithKnownIds(knownIds: Set) { + options.forEach { credentialSet -> + require(credentialSet.all { it in knownIds }) { "Unknown credential query ids in option $credentialSet" } + } + } + } +} + +/** + * The [value] must be a non-empty string consisting of alphanumeric, underscore (_) or hyphen (-) characters + */ +@Serializable +@JvmInline +value class QueryId(val value: String) { + init { + DCQLId.ensureValid(value) + } + + override fun toString(): String = value +} + +/** + * Represents a request for a presentation of one Credential. + */ +@Serializable +data class CredentialQuery( + @SerialName(OpenId4VPSpec.DCQL_ID) @Required val id: QueryId, + @SerialName(OpenId4VPSpec.DCQL_FORMAT) @Required val format: Format, + /** + * An object defining additional properties requested by the Verifier that apply + * to the metadata and validity data of the Credential. + * The properties of this object are defined per Credential Format. + * If omitted, no specific constraints are placed on the metadata or validity of the requested Credential. + * + * @see [CredentialQuery.metaMsoMdoc] + */ + @SerialName(OpenId4VPSpec.DCQL_META) val meta: JsonObject? = null, + + /** + * A non-empty list that specifies claims in the requested Credential. + */ + @SerialName(OpenId4VPSpec.DCQL_CLAIMS) val claims: List? = null, + + /** + *A non-empty set containing sets of identifiers for elements in claims that + * specifies which combinations of claims for the Credential are requested + */ + @SerialName(OpenId4VPSpec.DCQL_CLAIM_SETS) val claimSets: List? = null, + +) { + + init { + if (claims != null) { + claims.ensureValid(format) + claimSets?.ensureValid(claims) + } else { + require(claimSets == null) { "Cannot provide ${OpenId4VPSpec.DCQL_CLAIM_SETS} without ${OpenId4VPSpec.DCQL_CLAIMS}" } + } + } + + companion object { + + fun sdJwtVc( + id: QueryId, + sdJwtVcMeta: DCQLMetaSdJwtVcExtensions? = null, + claims: List? = null, + claimSets: List? = null, + ): CredentialQuery { + val meta = sdJwtVcMeta?.let { JsonSupport.encodeToJsonElement(it).jsonObject } + return CredentialQuery(id, Format.SdJwtVc, meta, claims, claimSets) + } + + fun mdoc( + id: QueryId, + msoMdocMeta: DCQLMetaMsoMdocExtensions? = null, + claims: List? = null, + claimSets: List? = null, + ): CredentialQuery { + val meta = msoMdocMeta?.let { JsonSupport.encodeToJsonElement(it).jsonObject } + return CredentialQuery(id, Format.MsoMdoc, meta, claims, claimSets) + } + + private fun List.ensureValid(format: Format) { + require(isNotEmpty()) { "At least one claim must be defined" } + ensureUniqueIds() + when (format) { + Format.MsoMdoc -> { + forEach { ClaimsQuery.ensureMsoMdocExtensions(it) } + } + } + } + + private fun List.ensureUniqueIds() { + val ids = mapNotNull { it.id } + val uniqueIdsNo = ids.toSet().count() + require(uniqueIdsNo == ids.size) { + "Within a CredentialQuery, the same id of claims MUST NOT be present more than once" + } + } + + private fun List.ensureValid(claims: List) { + val claimIds = claims.mapNotNull { it.id } + require(this.isNotEmpty()) { "${OpenId4VPSpec.DCQL_CLAIM_SETS} cannot be empty" } + this.forEach { claimSet -> + + require(claimSet.isNotEmpty()) { + "Each element of ${OpenId4VPSpec.DCQL_CLAIM_SETS} cannot be empty" + } + require(claimSet.all { id -> id in claimIds }) { + "Unknown claim ids within $claimSet" + } + } + } + } +} + +val CredentialQuery.metaMsoMdoc: DCQLMetaMsoMdocExtensions? get() = meta.metaAs() +val CredentialQuery.metaSdJwtVc: DCQLMetaSdJwtVcExtensions? get() = meta.metaAs() +internal inline fun JsonObject?.metaAs(): T? = this?.let { JsonSupport.decodeFromJsonElement(it) } + +@Serializable +data class CredentialSetQuery( + + @SerialName(OpenId4VPSpec.DCQL_OPTIONS) @Required val options: List, + + /** + * A boolean which indicates whether this set of Credentials is required + * to satisfy the particular use case at the Verifier. + * + * If omitted, the default value is true + */ + @SerialName(OpenId4VPSpec.DCQL_REQUIRED) val required: Boolean? = DefaultRequiredValue, + + /** + * A string, number or object specifying the purpose of the query. + * [OpenId4VPSpec] does not define a specific structure or specific values for this property. + * The purpose is intended to be used by the Verifier to communicate the reason for the query to the Wallet. + * The Wallet MAY use this information to show the user the reason for the request + */ + @SerialName(OpenId4VPSpec.DCQL_PURPOSE) val purpose: JsonElement? = null, +) { + + init { + options.forEach { credentialSet -> + require(credentialSet.isNotEmpty()) { "An credentialSet must have at least one CredentialQueryId" } + } + } + + companion object { + + val DefaultRequiredValue: Boolean? = true + } +} + +@Serializable +@JvmInline +value class ClaimId(val value: String) { + init { + DCQLId.ensureValid(value) + } + + override fun toString(): String = value +} + +@Serializable +data class ClaimsQuery( + @SerialName(OpenId4VPSpec.DCQL_ID) val id: ClaimId? = null, + @SerialName(OpenId4VPSpec.DCQL_PATH) val path: ClaimPath? = null, + @SerialName(OpenId4VPSpec.DCQL_VALUES) val values: JsonArray? = null, + @SerialName(OpenId4VPSpec.DCQL_MSO_MDOC_NAMESPACE) override val namespace: MsoMdocNamespace? = null, + @SerialName(OpenId4VPSpec.DCQL_MSO_MDOC_CLAIM_NAME) override val claimName: MsoMdocClaimName? = null, +) : MsoMdocClaimsQueryExtension { + + companion object { + + fun sdJwtVc( + id: ClaimId? = null, + path: ClaimPath? = null, + values: JsonArray? = null, + ): ClaimsQuery = ClaimsQuery(id, path, values) + + fun mdoc( + id: ClaimId? = null, + values: JsonArray? = null, + namespace: MsoMdocNamespace, + claimName: MsoMdocClaimName, + ): ClaimsQuery = ClaimsQuery(id = id, path = null, values = values, namespace = namespace, claimName = claimName) + + fun ensureMsoMdocExtensions(claimsQuery: ClaimsQuery) { + requireNotNull(claimsQuery.namespace) { + "Namespace is required if the credential format is based on the mdoc format " + } + requireNotNull(claimsQuery.claimName) { + "Claim name is required if the credential format is based on the mdoc format " + } + } + } +} + +// +// SD-JWT-VC +// + +@Serializable +data class DCQLMetaSdJwtVcExtensions( + /** + * Specifies allowed values for the type of the requested Verifiable Credential. + * All elements in the array MUST be valid type identifiers. + * The Wallet may return credentials that inherit from any of the specified types + */ + @SerialName(OpenId4VPSpec.DCQL_SD_JWT_VC_VCT_VALUES) val vctValues: List?, +) + +// +// +// MSO_MDOC +// + +@Serializable +@JvmInline +value class MsoMdocDocType(val value: String) { + init { + require(value.isNotBlank()) { "Doctype cannt be blank" } + } + + override fun toString(): String = value +} + +/** + * The following is an ISO mdoc specific parameter in the [meta parameter][CredentialQuery.meta] + */ +@Serializable +data class DCQLMetaMsoMdocExtensions( + /** + * Specifies an allowed value for the doctype of the requested Verifiable Credential. + * It MUST be a valid doctype identifier as defined + */ + @SerialName(OpenId4VPSpec.DCQL_MSO_MDOC_DOCTYPE_VALUE) val doctypeValue: MsoMdocDocType?, +) + +@Serializable +@JvmInline +value class MsoMdocNamespace(val value: String) { + init { + require(value.isNotBlank()) { "Namespace must not be blank" } + } + + override fun toString(): String = value +} + +@Serializable +@JvmInline +value class MsoMdocClaimName(val value: String) { + init { + require(value.isNotBlank()) { "Claim name must not be blank" } + } + + override fun toString(): String = value +} + +/** + * The following are ISO mdoc specific parameters to be used in a [Claims Query][ClaimsQuery] + */ +interface MsoMdocClaimsQueryExtension { + + /** + * Required if the Credential Format is based on the mdoc format. + * Must not be present otherwise. + * The namespace of the data element within the mdoc + */ + @SerialName(OpenId4VPSpec.DCQL_MSO_MDOC_NAMESPACE) + val namespace: MsoMdocNamespace? + + /** + * REQUIRED if the Credential Format is based on mdoc format + * Must not be present otherwise. + * Specifies the data element identifier of the data element + * within the provided [namespace] in the mdoc + */ + @SerialName(OpenId4VPSpec.DCQL_MSO_MDOC_CLAIM_NAME) + val claimName: MsoMdocClaimName? +} + +internal object DCQLId { + const val REGEX: String = "^[a-zA-Z0-9_-]+$" + fun ensureValid(value: String): String { + require(value.isNotEmpty()) { "Value cannot be be empty" } + require(REGEX.toRegex().matches(value)) { + "The value must be a non-empty string consisting of alphanumeric, underscore (_) or hyphen (-) characters" + } + return value + } +} + +private val JsonSupport = Json { + prettyPrint = false + ignoreUnknownKeys = true +} diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLParseTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLParseTest.kt new file mode 100644 index 0000000..e632ed7 --- /dev/null +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLParseTest.kt @@ -0,0 +1,614 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vp.dcql + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.fail + +class DCQLParseTest { + + private val jsonSupport = Json { + prettyPrint = true + ignoreUnknownKeys = false + } + + @Test + fun whenMsoMdocNamespaceMissesAnExceptionIsRaised() { + val json = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.7367.1.mVRC" + }, + "claims": [ + { + "claim_name": "vehicle_holder" + }, + { + "namespace": "org.iso.18013.5.1", + "claim_name": "first_name" + } + ] + } + ] + } + """.trimIndent() + + try { + jsonSupport.decodeFromString(json) + fail("An MsoMdoc query missing namespace was processed") + } catch (e: Throwable) { + assertIs(e) + } + } + + @Test + fun whenMsoMdocClaimNameMissesAnExceptionIsRaised() { + val json = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.7367.1.mVRC" + }, + "claims": [ + { + "namespace": "org.iso.18013.5.1", + } + ] + } + ] + } + """.trimIndent() + + try { + jsonSupport.decodeFromString(json) + fail("An MsoMdoc query missing claim_name was processed") + } catch (e: Throwable) { + assertIs(e) + } + } + + @Test + fun test01() = assertEqualsDCQL( + json = """ + { + "credentials": [ + { + "id": "my_credential", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.7367.1.mVRC" + }, + "claims": [ + { + "namespace": "org.iso.7367.1", + "claim_name": "vehicle_holder" + }, + { + "namespace": "org.iso.18013.5.1", + "claim_name": "first_name" + } + ] + } + ] + } + """.trimIndent(), + expected = DCQL( + credentials = listOf( + CredentialQuery.mdoc( + id = QueryId("my_credential"), + msoMdocMeta = DCQLMetaMsoMdocExtensions(MsoMdocDocType("org.iso.7367.1.mVRC")), + claims = listOf( + ClaimsQuery.mdoc( + namespace = MsoMdocNamespace("org.iso.7367.1"), + claimName = MsoMdocClaimName("vehicle_holder"), + ), + ClaimsQuery.mdoc( + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("first_name"), + ), + ), + + ), + ), + ), + ) + + @Test + fun test02() = assertEqualsDCQL( + json = """ + { + "credentials": [ + { + "id": "pid", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://credentials.example.com/identity_credential"] + }, + "claims": [ + {"path": ["given_name"]}, + {"path": ["family_name"]}, + {"path": ["address", "street_address"]} + ] + }, + { + "id": "mdl", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.7367.1.mVRC" + }, + "claims": [ + { + "namespace": "org.iso.7367.1", + "claim_name": "vehicle_holder" + }, + { + "namespace": "org.iso.18013.5.1", + "claim_name": "first_name" + } + ] + } + ] + } + """.trimIndent(), + expected = DCQL( + credentials = listOf( + CredentialQuery.sdJwtVc( + id = QueryId("pid"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions(vctValues = listOf("https://credentials.example.com/identity_credential")), + claims = listOf( + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("given_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("family_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("address").claim("street_address")), + ), + ), + CredentialQuery.mdoc( + id = QueryId("mdl"), + msoMdocMeta = DCQLMetaMsoMdocExtensions(MsoMdocDocType("org.iso.7367.1.mVRC")), + claims = listOf( + ClaimsQuery.mdoc( + namespace = MsoMdocNamespace("org.iso.7367.1"), + claimName = MsoMdocClaimName("vehicle_holder"), + ), + ClaimsQuery.mdoc( + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("first_name"), + ), + ), + ), + ), + ), + + ) + + @Test + fun test03() = assertEqualsDCQL( + json = """ + { + "credentials": [ + { + "id": "pid", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://credentials.example.com/identity_credential"] + }, + "claims": [ + {"path": ["given_name"]}, + {"path": ["family_name"]}, + {"path": ["address", "street_address"]} + ] + }, + { + "id": "other_pid", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://othercredentials.example/pid"] + }, + "claims": [ + {"path": ["given_name"]}, + {"path": ["family_name"]}, + {"path": ["address", "street_address"]} + ] + }, + { + "id": "pid_reduced_cred_1", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://credentials.example.com/reduced_identity_credential"] + }, + "claims": [ + {"path": ["family_name"]}, + {"path": ["given_name"]} + ] + }, + { + "id": "pid_reduced_cred_2", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://cred.example/residence_credential"] + }, + "claims": [ + {"path": ["postal_code"]}, + {"path": ["locality"]}, + {"path": ["region"]} + ] + }, + { + "id": "nice_to_have", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://company.example/company_rewards"] + }, + "claims": [ + {"path": ["rewards_number"]} + ] + } + ], + "credential_sets": [ + { + "purpose": "Identification", + "options": [ + [ "pid" ], + [ "other_pid" ], + [ "pid_reduced_cred_1", "pid_reduced_cred_2" ] + ] + }, + { + "purpose": "Show your rewards card", + "required": false, + "options": [ + [ "nice_to_have" ] + ] + } + ] + } + """.trimIndent(), + expected = DCQL( + credentials = listOf( + CredentialQuery.sdJwtVc( + id = QueryId("pid"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions(vctValues = listOf("https://credentials.example.com/identity_credential")), + claims = listOf( + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("given_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("family_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("address").claim("street_address")), + ), + ), + CredentialQuery.sdJwtVc( + id = QueryId("other_pid"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions(vctValues = listOf("https://othercredentials.example/pid")), + claims = listOf( + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("given_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("family_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("address").claim("street_address")), + ), + ), + CredentialQuery.sdJwtVc( + id = QueryId("pid_reduced_cred_1"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions( + vctValues = listOf("https://credentials.example.com/reduced_identity_credential"), + ), + claims = listOf( + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("family_name")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("given_name")), + ), + ), + CredentialQuery.sdJwtVc( + id = QueryId("pid_reduced_cred_2"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions(vctValues = listOf("https://cred.example/residence_credential")), + claims = listOf( + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("postal_code")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("locality")), + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("region")), + ), + ), + CredentialQuery.sdJwtVc( + id = QueryId("nice_to_have"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions(vctValues = listOf("https://company.example/company_rewards")), + claims = listOf( + ClaimsQuery.sdJwtVc(path = ClaimPath.claim("rewards_number")), + ), + ), + ), + credentialSets = listOf( + CredentialSetQuery( + purpose = JsonPrimitive("Identification"), + options = listOf( + setOf(QueryId("pid")), + setOf(QueryId("other_pid")), + setOf(QueryId("pid_reduced_cred_1"), QueryId("pid_reduced_cred_2")), + ), + ), + CredentialSetQuery( + purpose = JsonPrimitive("Show your rewards card"), + required = false, + options = listOf( + setOf(QueryId("nice_to_have")), + ), + ), + ), + ), + ) + + @Test + fun test04() = assertEqualsDCQL( + json = """ + { + "credentials": [ + { + "id": "mdl-id", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.18013.5.1.mDL" + }, + "claims": [ + { + "id": "given_name", + "namespace": "org.iso.18013.5.1", + "claim_name": "given_name" + }, + { + "id": "family_name", + "namespace": "org.iso.18013.5.1", + "claim_name": "family_name" + }, + { + "id": "portrait", + "namespace": "org.iso.18013.5.1", + "claim_name": "portrait" + } + ] + }, + { + "id": "mdl-address", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.18013.5.1.mDL" + }, + "claims": [ + { + "id": "resident_address", + "namespace": "org.iso.18013.5.1", + "claim_name": "resident_address" + }, + { + "id": "resident_country", + "namespace": "org.iso.18013.5.1", + "claim_name": "resident_country" + } + ] + }, + { + "id": "photo_card-id", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.23220.photoid.1" + }, + "claims": [ + { + "id": "given_name", + "namespace": "org.iso.23220.1", + "claim_name": "given_name" + }, + { + "id": "family_name", + "namespace": "org.iso.23220.1", + "claim_name": "family_name" + }, + { + "id": "portrait", + "namespace": "org.iso.23220.1", + "claim_name": "portrait" + } + ] + }, + { + "id": "photo_card-address", + "format": "mso_mdoc", + "meta": { + "doctype_value": "org.iso.23220.photoid.1" + }, + "claims": [ + { + "id": "resident_address", + "namespace": "org.iso.23220.1", + "claim_name": "resident_address" + }, + { + "id": "resident_country", + "namespace": "org.iso.23220.1", + "claim_name": "resident_country" + } + ] + } + ], + "credential_sets": [ + { + "purpose": "Identification", + "options": [ + [ "mdl-id" ], + [ "photo_card-id" ] + ] + }, + { + "purpose": "Proof of address", + "required": false, + "options": [ + [ "mdl-address" ], + [ "photo_card-address" ] + ] + } + ] + } + """.trimIndent(), + expected = DCQL( + credentials = listOf( + CredentialQuery.mdoc( + id = QueryId("mdl-id"), + msoMdocMeta = DCQLMetaMsoMdocExtensions(MsoMdocDocType("org.iso.18013.5.1.mDL")), + claims = listOf( + ClaimsQuery.mdoc( + id = ClaimId("given_name"), + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("given_name"), + ), + ClaimsQuery.mdoc( + id = ClaimId("family_name"), + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("family_name"), + ), + ClaimsQuery.mdoc( + id = ClaimId("portrait"), + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("portrait"), + ), + ), + ), + CredentialQuery.mdoc( + id = QueryId("mdl-address"), + msoMdocMeta = DCQLMetaMsoMdocExtensions(MsoMdocDocType("org.iso.18013.5.1.mDL")), + claims = listOf( + ClaimsQuery.mdoc( + id = ClaimId("resident_address"), + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("resident_address"), + ), + ClaimsQuery.mdoc( + id = ClaimId("resident_country"), + namespace = MsoMdocNamespace("org.iso.18013.5.1"), + claimName = MsoMdocClaimName("resident_country"), + ), + ), + ), + CredentialQuery.mdoc( + id = QueryId("photo_card-id"), + msoMdocMeta = DCQLMetaMsoMdocExtensions(MsoMdocDocType("org.iso.23220.photoid.1")), + claims = listOf( + ClaimsQuery.mdoc( + id = ClaimId("given_name"), + namespace = MsoMdocNamespace("org.iso.23220.1"), + claimName = MsoMdocClaimName("given_name"), + ), + ClaimsQuery.mdoc( + id = ClaimId("family_name"), + namespace = MsoMdocNamespace("org.iso.23220.1"), + claimName = MsoMdocClaimName("family_name"), + ), + ClaimsQuery.mdoc( + id = ClaimId("portrait"), + namespace = MsoMdocNamespace("org.iso.23220.1"), + claimName = MsoMdocClaimName("portrait"), + ), + ), + ), + CredentialQuery.mdoc( + id = QueryId("photo_card-address"), + msoMdocMeta = DCQLMetaMsoMdocExtensions(MsoMdocDocType("org.iso.23220.photoid.1")), + claims = listOf( + ClaimsQuery.mdoc( + id = ClaimId("resident_address"), + namespace = MsoMdocNamespace("org.iso.23220.1"), + claimName = MsoMdocClaimName("resident_address"), + ), + ClaimsQuery.mdoc( + id = ClaimId("resident_country"), + namespace = MsoMdocNamespace("org.iso.23220.1"), + claimName = MsoMdocClaimName("resident_country"), + ), + ), + ), + ), + credentialSets = listOf( + CredentialSetQuery( + purpose = JsonPrimitive("Identification"), + options = listOf( + setOf(QueryId("mdl-id")), + setOf(QueryId("photo_card-id")), + ), + ), + CredentialSetQuery( + purpose = JsonPrimitive("Proof of address"), + required = false, + options = listOf( + setOf(QueryId("mdl-address")), + setOf(QueryId("photo_card-address")), + ), + ), + ), + ), + ) + + @Test + fun test05() = assertEqualsDCQL( + json = """ + { + "credentials": [ + { + "id": "pid", + "format": "dc+sd-jwt", + "meta": { + "vct_values": [ "https://credentials.example.com/identity_credential" ] + }, + "claims": [ + {"id": "a", "path": ["last_name"]}, + {"id": "b", "path": ["postal_code"]}, + {"id": "c", "path": ["locality"]}, + {"id": "d", "path": ["region"]}, + {"id": "e", "path": ["date_of_birth"]} + ], + "claim_sets": [ + ["a", "c", "d", "e"], + ["a", "b", "e"] + ] + } + ] + } + """.trimIndent(), + expected = DCQL( + credentials = listOf( + CredentialQuery.sdJwtVc( + id = QueryId("pid"), + sdJwtVcMeta = DCQLMetaSdJwtVcExtensions(vctValues = listOf("https://credentials.example.com/identity_credential")), + claims = listOf( + ClaimsQuery.sdJwtVc(id = ClaimId("a"), path = ClaimPath.claim("last_name")), + ClaimsQuery.sdJwtVc(id = ClaimId("b"), path = ClaimPath.claim("postal_code")), + ClaimsQuery.sdJwtVc(id = ClaimId("c"), path = ClaimPath.claim("locality")), + ClaimsQuery.sdJwtVc(id = ClaimId("d"), path = ClaimPath.claim("region")), + ClaimsQuery.sdJwtVc(id = ClaimId("e"), path = ClaimPath.claim("date_of_birth")), + ), + claimSets = listOf( + setOf(ClaimId("a"), ClaimId("c"), ClaimId("d"), ClaimId("e")), + setOf(ClaimId("a"), ClaimId("b"), ClaimId("e")), + ), + ), + ), + ), + ) + + private fun assertEqualsDCQL(expected: DCQL, json: String) { + assertEquals(expected, jsonSupport.decodeFromString(json)) + } +} diff --git a/src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLRulesTest.kt b/src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLRulesTest.kt new file mode 100644 index 0000000..29e5f9e --- /dev/null +++ b/src/test/kotlin/eu/europa/ec/eudi/openid4vp/dcql/DCQLRulesTest.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.openid4vp.dcql + +import eu.europa.ec.eudi.openid4vp.Format +import kotlin.test.Test +import kotlin.test.fail + +class DCQLRulesTest { + + @Test + fun credentialQueryIdMustBeNonEemptyStringAlphanumericUnderscoreOrHyphen() { + val illegalIds = listOf( + "", + "@@123a", + "^&())_", + ) + illegalIds.forEach { + try { + QueryId(it) + fail("Accepted as id $it") + } catch (_: IllegalArgumentException) { + } + } + } + + @Test + fun whenCredentialsIsEmptyAnExceptionIsRaised() { + try { + DCQL(credentials = emptyList()) + fail("DCQL cannot have an empty credentials attribute") + } catch (_: IllegalArgumentException) { + // ok + } + } + + @Test + fun whenCredentialsContainsEntriesWithTheSameIdAnExceptionIsRaised() { + try { + val id = QueryId("id") + DCQL( + credentials = listOf( + CredentialQuery(id = id, format = Format.MsoMdoc), + CredentialQuery(id = id, format = Format.SdJwtVc), + ), + ) + fail("CredentialQuery ids must be unique") + } catch (_: IllegalArgumentException) { + // ok + } + } + + @Test + fun whenCredentialsSetIsEmptyAnExceptionIsRaised() { + try { + DCQL( + credentials = listOf( + CredentialQuery(id = QueryId("id1"), format = Format.MsoMdoc), + CredentialQuery(id = QueryId("id2"), format = Format.SdJwtVc), + ), + credentialSets = listOf(), + ) + fail("credentialSets, if provided, cannot be empty") + } catch (_: IllegalArgumentException) { + // ok + } + } +}