diff --git a/.github/config/test-strategy-matrix.json b/.github/config/test-strategy-matrix.json index 34b219462..926da4034 100644 --- a/.github/config/test-strategy-matrix.json +++ b/.github/config/test-strategy-matrix.json @@ -3,18 +3,18 @@ { "os": "ubuntu-latest", "name": "jvmRunner", - "testCommand": "./gradlew jvmTest && (cd mobile-driving-licence-credential && ./gradlew -DregressionTest=true jvmTest )" + "testCommand": "./gradlew jvmTest" }, { "os": "ubuntu-latest", "name": "androidRunner", "enableKvm": true, - "testCommand": "./gradlew pixelAVDAndroidDeviceTest " + "testCommand": "./gradlew pixelAVDAndroidDeviceTest" }, { "os": "macos-latest", "name": "iosRunner", - "testCommand": "./gradlew iosArm64MainKlibrary iosSimulatorArm64MainKlibrary iosSimulatorArm64Test && (cd mobile-driving-licence-credential && ./gradlew -DregressionTest=true iosSimulatorArm64Test )" + "testCommand": "./gradlew iosArm64MainKlibrary iosSimulatorArm64MainKlibrary iosSimulatorArm64Test" } ] } diff --git a/.gitmodules b/.gitmodules index e5320869d..dbaaca7c1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "conventions-vclib/gradle-conventions-plugin"] path = conventions-vclib/gradle-conventions-plugin url = https://github.com/a-sit-plus/gradle-conventions-plugin.git -[submodule "mobile-driving-licence-credential"] - path = mobile-driving-licence-credential - url = https://github.com/a-sit-plus/mobile-driving-licence-credential.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 8593fd620..b8a1136da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ Release 6.0.0: - In `CredentialScheme` deprecate `claimNames` (list of strings), to be replaced with `claimDescriptions` (set of typed descriptions) - In `StoreEntry` deprecate property `scheme` and add suspending function `resolveScheme()` to replace it - Add `UnknownCredentialScheme` so that the `scheme` property of several classes is never null + - Import data classes and data element strings from credentials into this library for [EU PID](https://github.com/a-sit-plus/eu-pid-credential), [EU PID in SD-JWT](https://github.com/a-sit-plus/eu-pid-credential-sdjwt/) and [Mobile Driving Licence](https://github.com/a-sit-plus/mobile-driving-licence-credential/) - New modules: - `etsi-data-classes` implements list of trusted entities from [ETSI TS 119 602](https://www.etsi.org/deliver/etsi_ts/119600_119699/119602/01.01.01_60/ts_119602v010101p.pdf) - `sd-jwt-type-metadata` implements SD-JWT VC Type Metadata from [draft-ietf-oauth-sd-jwt-vc-16](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/): diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index caf8975dc..fb4794beb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,9 +9,6 @@ uuid = "0.8.1" jsonpath = "3.0.0" jvmJson = "20230618" jvmCbor = "1.21" -eupid = "3.5.0-SNAPSHOT" -eupidsdjwt = "1.4.0" -mdl = "1.4.0" obor = "2.1.3" #testballoonAddons = "0.15.0" diff --git a/mobile-driving-licence-credential b/mobile-driving-licence-credential deleted file mode 160000 index e7d8ac9b1..000000000 --- a/mobile-driving-licence-credential +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e7d8ac9b1238fdece8faa29e50ccf5c24cc6a9e1 diff --git a/sd-jwt-type-metadata/build.gradle.kts b/sd-jwt-type-metadata/build.gradle.kts index 8a534b938..30be8fc15 100644 --- a/sd-jwt-type-metadata/build.gradle.kts +++ b/sd-jwt-type-metadata/build.gradle.kts @@ -1,7 +1,6 @@ import at.asitplus.gradle.VcLibVersions import at.asitplus.gradle.envExtra import at.asitplus.gradle.exportXCFramework -import at.asitplus.gradle.ktor import at.asitplus.gradle.napier import at.asitplus.gradle.setupDokka import at.asitplus.gradle.vckAndroid @@ -30,8 +29,6 @@ kotlin { commonMain { dependencies { implementation(project.napier()) - implementation(project.ktor("http")) - implementation(ktor("client-core")) api(project(":rfc3986-uri-syntax")) api("at.asitplus.signum:supreme:${VcLibVersions.supreme}") api("at.asitplus.signum:indispensable:${VcLibVersions.signum}") diff --git a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadata.kt b/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadata.kt index 61fac0713..18161c87a 100644 --- a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadata.kt +++ b/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadata.kt @@ -1,18 +1,21 @@ package at.asitplus.wallet.sdjwt +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition.SerialNames import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class SdJwtTypeMetadata( - @SerialName(SdJwtTypeMetadataDefinition.SerialNames.VCT) + @SerialName(SerialNames.VCT) val vct: SdJwtVcType, - @SerialName(SdJwtTypeMetadataDefinition.SerialNames.NAME) + @SerialName(SerialNames.NAME) val name: String? = null, - @SerialName(SdJwtTypeMetadataDefinition.SerialNames.DESCRIPTION) + @SerialName(SerialNames.DESCRIPTION) val description: String? = null, - @SerialName(SdJwtTypeMetadataDefinition.SerialNames.DISPLAY) + @SerialName(SerialNames.DISPLAY) val display: SdJwtTypeMetadataTypeDisplayInformationList? = null, - @SerialName(SdJwtTypeMetadataDefinition.SerialNames.CLAIMS) + @SerialName(SerialNames.CLAIMS) val claims: SdJwtTypeMetadataClaimInformationList? = null, -) \ No newline at end of file + @SerialName(SerialNames.VCK) + val vckExtensions: SdJwtTypeMetadataVckExtensions? = null, +) diff --git a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataDefinition.kt b/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataDefinition.kt index b4de44670..8de2f0a80 100644 --- a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataDefinition.kt +++ b/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataDefinition.kt @@ -26,6 +26,8 @@ data class SdJwtTypeMetadataDefinition( val display: SdJwtTypeMetadataTypeDisplayInformationList? = null, @SerialName(SerialNames.CLAIMS) val claims: SdJwtTypeMetadataClaimInformationList? = null, + @SerialName(SerialNames.VCK) + val vckExtensions: SdJwtTypeMetadataVckExtensions? = null, ) { object SerialNames { const val VCT = "vct" @@ -35,6 +37,7 @@ data class SdJwtTypeMetadataDefinition( const val EXTENDS_INTEGRITY = "extends#integrity" const val DISPLAY = "display" const val CLAIMS = "claims" + const val VCK = "vck" } fun toSdJwtTypeMetadata(): SdJwtTypeMetadata { @@ -47,6 +50,7 @@ data class SdJwtTypeMetadataDefinition( description = description, display = display, claims = claims, + vckExtensions = vckExtensions, ) } @@ -80,7 +84,8 @@ data class SdJwtTypeMetadataDefinition( } childClaimInfo.extendFrom(baseClaimInfo) }.values.filterNotNull() - }?.let(::SdJwtTypeMetadataClaimInformationList) ?: base.claims + }?.let(::SdJwtTypeMetadataClaimInformationList) ?: base.claims, + vckExtensions = vckExtensions ?: base.vckExtensions, ) } } diff --git a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataVckExtensions.kt b/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataVckExtensions.kt new file mode 100644 index 000000000..995eb3827 --- /dev/null +++ b/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/SdJwtTypeMetadataVckExtensions.kt @@ -0,0 +1,58 @@ +package at.asitplus.wallet.sdjwt + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +@Serializable +data class SdJwtTypeMetadataVckExtensions( + @SerialName(SerialNames.FORMAT) + val format: CredentialFormatEnum, + @SerialName(SerialNames.ISO_DOCTYPE) + val isoDocType: String? = null, + @SerialName(SerialNames.ISO_NAMESPACE) + val isoNamespace: String? = null, + @SerialName(SerialNames.VC_TYPE) + val vcType: String? = null, +) { + + object SerialNames { + const val ISO_DOCTYPE = "isoDocType" + const val ISO_NAMESPACE = "isoNamespace" + const val VC_TYPE = "vcType" + const val FORMAT = "format" + } + +} + +@Serializable(with = CredentialFormatEnum.Companion.Serializer::class) +enum class CredentialFormatEnum(val text: String) { + JWT_VC("jwt_vc_json"), + DC_SD_JWT("dc+sd-jwt"), + MSO_MDOC("mso_mdoc"); + + + companion object { + fun parse(text: String) = CredentialFormatEnum.entries.firstOrNull { it.text == text } + + object Serializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("CredentialFormatEnumSerializer", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: CredentialFormatEnum) { + encoder.encodeString(value.text) + } + + override fun deserialize(decoder: Decoder): CredentialFormatEnum { + val text = decoder.decodeString() + return CredentialFormatEnum.parse(text) ?: throw IllegalArgumentException(text) + } + } + } +} diff --git a/vck-openid-ktor/build.gradle.kts b/vck-openid-ktor/build.gradle.kts index 9f8fd4917..eacd69b4f 100644 --- a/vck-openid-ktor/build.gradle.kts +++ b/vck-openid-ktor/build.gradle.kts @@ -52,9 +52,6 @@ kotlin { commonTest { dependencies { - implementation("at.asitplus.wallet:eupidcredential:${VcLibVersions.eupid}") - implementation("at.asitplus.wallet:eupidcredential-sdjwt:${VcLibVersions.eupidsdjwt}") - implementation("at.asitplus.wallet:mobiledrivinglicence:${VcLibVersions.mdl}") implementation(ktor("client-mock")) implementation(kotest("assertions-core")) } diff --git a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/KtorSdJwtTypeMetadataDocumentRetriever.kt b/vck-openid-ktor/src/commonMain/kotlin/at/asitplus/wallet/lib/ktor/openid/KtorSdJwtTypeMetadataDocumentRetriever.kt similarity index 82% rename from sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/KtorSdJwtTypeMetadataDocumentRetriever.kt rename to vck-openid-ktor/src/commonMain/kotlin/at/asitplus/wallet/lib/ktor/openid/KtorSdJwtTypeMetadataDocumentRetriever.kt index e2c14fd2c..b3114296a 100644 --- a/sd-jwt-type-metadata/src/commonMain/kotlin/at/asitplus/wallet/sdjwt/KtorSdJwtTypeMetadataDocumentRetriever.kt +++ b/vck-openid-ktor/src/commonMain/kotlin/at/asitplus/wallet/lib/ktor/openid/KtorSdJwtTypeMetadataDocumentRetriever.kt @@ -1,7 +1,13 @@ -package at.asitplus.wallet.sdjwt +package at.asitplus.wallet.lib.ktor.openid import at.asitplus.rfc3986uri.Rfc3986UniformResourceIdentifier import at.asitplus.rfc3986uri.Rfc3986UriSchemeName +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentIntegrityChecker +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRetriever +import at.asitplus.wallet.sdjwt.SdJwtVcType +import at.asitplus.wallet.sdjwt.W3cSubresourceIntegrityMetadata import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* @@ -13,11 +19,16 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.Instant -// unused because there are currently no officially available type metadata documents -@Suppress("unused") class KtorSdJwtTypeMetadataDocumentRetriever( val httpClient: HttpClient, val clock: Clock, + /** + * Resolves the URL a metadata document is hosted at for a given `vct` (used both as document identity and for + * walking `extends`). Required because a `vct` is not necessarily a URL (e.g. `urn:eudi:pid:1`); the owning + * [at.asitplus.wallet.lib.data.CredentialMetadataRegistry] keeps the actual `vct -> URL` mapping and supplies this + * lookup. A `vct` for which this returns `null` cannot be fetched, and [retrieve] returns `null` for it. + */ + val locateUrl: (SdJwtVcType) -> String?, val json: Json = Json.Default, val integrityChecker: SdJwtTypeMetadataDocumentIntegrityChecker = SdJwtTypeMetadataDocumentIntegrityChecker.DEFAULT, ) : SdJwtTypeMetadataDocumentRetriever { @@ -28,8 +39,10 @@ class KtorSdJwtTypeMetadataDocumentRetriever( sdJwtVcType: SdJwtVcType, integrityMetadata: W3cSubresourceIntegrityMetadata?, ): SdJwtTypeMetadataDocument? { + val url = locateUrl(sdJwtVcType) ?: return null + val uri = runCatching { - Rfc3986UniformResourceIdentifier.Companion(sdJwtVcType.string) + Rfc3986UniformResourceIdentifier.Companion(url) }.getOrNull() ?: return null if (uri.schemeName !in Rfc3986UriSchemeName.Common.run { listOf(HTTPS, HTTP) }) { @@ -52,7 +65,7 @@ class KtorSdJwtTypeMetadataDocumentRetriever( } } - val response = httpClient.get(sdJwtVcType.string) + val response = httpClient.get(url) if (response.status == HttpStatusCode.OK) { val rawBytes = response.body() val definition = json.decodeFromString(SdJwtTypeMetadataDefinition.serializer(), rawBytes.decodeToString()) @@ -141,4 +154,4 @@ class KtorSdJwtTypeMetadataDocumentRetriever( dynamicCache[sdJwtVcType] = validUntil to document return true } -} \ No newline at end of file +} diff --git a/vck-openid-ktor/src/commonMain/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataRegistry.kt b/vck-openid-ktor/src/commonMain/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataRegistry.kt new file mode 100644 index 000000000..ad47dcec4 --- /dev/null +++ b/vck-openid-ktor/src/commonMain/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataRegistry.kt @@ -0,0 +1,70 @@ +package at.asitplus.wallet.lib.ktor.openid + +import at.asitplus.catching +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT +import at.asitplus.wallet.lib.data.CredentialMetadataLookup +import at.asitplus.wallet.lib.data.CredentialMetadataRegistry +import at.asitplus.wallet.lib.data.ResolvedCredentialMetadata +import at.asitplus.wallet.sdjwt.DelegatingSdJwtTypeMetadataDocumentResolver +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentIntegrityChecker +import at.asitplus.wallet.sdjwt.SdJwtVcType +import at.asitplus.wallet.sdjwt.W3cSubresourceIntegrityMetadata +import io.ktor.client.HttpClient +import kotlinx.serialization.json.Json +import kotlin.time.Clock + +/** + * A [CredentialMetadataRegistry] that fetches metadata documents over HTTP, mirroring + * [at.asitplus.wallet.lib.data.StaticCredentialMetadataRegistry] but with remote retrieval. + * + * This registry **owns** the `vct -> URL` mapping ([documentUrls]); the underlying + * [KtorSdJwtTypeMetadataDocumentRetriever] is given only a lookup (`documentUrls::get`), so the mapping stays in the + * registry, not in the retriever. The same URL is reused as the resolved scheme's `schemaUri`. + * + * [documentUrls] is intentionally minimal — fill it with the known `vct`/URL pairs. The map is shared with the + * retriever, so later additions are picked up for both lookup and retrieval (incl. `extends` parents). + * + * Identifier resolution mirrors the static registry: an [aliases] entry wins; otherwise for [SD_JWT] the identifier is + * the `vct` directly. Mapping a decoupled W3C `vcType` / ISO `docType` to its `vct` needs an [aliases] entry until + * richer discovery is added. + */ +class RemoteCredentialMetadataRegistry( + httpClient: HttpClient, + clock: Clock, + /** `vct` -> hosted document URL. Owned here; fill in the known pairs. */ + val documentUrls: MutableMap = mutableMapOf(), + private val aliases: Map = emptyMap(), + private val integrityMetadata: Map = emptyMap(), + json: Json = Json.Default, + integrityChecker: SdJwtTypeMetadataDocumentIntegrityChecker = SdJwtTypeMetadataDocumentIntegrityChecker.DEFAULT, +) : CredentialMetadataRegistry { + + private val resolver = DelegatingSdJwtTypeMetadataDocumentResolver( + documentRetriever = KtorSdJwtTypeMetadataDocumentRetriever( + httpClient = httpClient, + clock = clock, + locateUrl = documentUrls::get, + json = json, + integrityChecker = integrityChecker, + ), + integrityChecker = integrityChecker, + ) + + override suspend fun findEntry( + identifier: String, + representation: CredentialRepresentation, + ): ResolvedCredentialMetadata? { + val vct = aliases[CredentialMetadataLookup(representation, identifier)] + ?: SdJwtVcType(identifier).takeIf { representation == SD_JWT && documentUrls.containsKey(it) } + ?: return null + val loadedFrom = documentUrls[vct] ?: return null + // Return null on fetch/integrity failure so AttributeIndex can fall back to a fallback scheme. + val metadata = catching { resolver.resolve(vct, integrityMetadata[vct]) }.getOrNull() ?: return null + return ResolvedCredentialMetadata( + metadata = metadata, + loadedFrom = loadedFrom, + aliases = aliases.entries.filter { it.value == vct }.map { it.key.identifier }.toSet(), + ) + } +} diff --git a/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt b/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt index cc7d9a9d0..c0079f09a 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/TestConfig.kt @@ -1,5 +1,16 @@ import at.asitplus.testballoon.matrix.ExecutionMode import at.asitplus.testballoon.matrix.MatrixTestDefaults +import at.asitplus.wallet.eupid.EuPidItemValueSerializerMap +import at.asitplus.wallet.eupid.EuPidJsonValueEncoder +import at.asitplus.wallet.eupid.EuPidMetadataDocument +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtMetadataDocument +import at.asitplus.wallet.lib.LibraryInitializer +import at.asitplus.wallet.lib.data.StaticCredentialMetadataRegistry +import at.asitplus.wallet.mdl.MobileDrivingLicenceItemValueSerializerMap +import at.asitplus.wallet.mdl.MobileDrivingLicenceJsonValueEncoder +import at.asitplus.wallet.mdl.MobileDrivingLicenceMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRegistry +import at.asitplus.wallet.sdjwt.SdJwtVcType import de.infix.testBalloon.framework.core.TestSession import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -10,8 +21,31 @@ class TestConfig : TestSession( init { Napier.takeLogarithm() Napier.base(DebugAntilog()) - at.asitplus.wallet.eupid.Initializer.initWithVCK() - at.asitplus.wallet.eupidsdjwt.Initializer.initWithVCK() - at.asitplus.wallet.mdl.Initializer.initWithVCK() + + LibraryInitializer.registerCredentialMetadataRegistry( + StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + EuPidSdJwtMetadataDocument, + EuPidMetadataDocument, + MobileDrivingLicenceMetadataDocument, + ), + documentUrls = mapOf( + SdJwtVcType(EuPidSdJwtMetadataDocument.first.string) to "https://example.com", + SdJwtVcType(EuPidMetadataDocument.first.string) to "https://example.com", + SdJwtVcType(MobileDrivingLicenceMetadataDocument.first.string) to "https://example.com", + ) + ) + ) + + LibraryInitializer.registerCredentialSerializers( + jsonValueEncoder = MobileDrivingLicenceJsonValueEncoder, + itemValueSerializerMap = MobileDrivingLicenceItemValueSerializerMap + ) + LibraryInitializer.registerCredentialSerializers( + jsonValueEncoder = EuPidJsonValueEncoder, + itemValueSerializerMap = EuPidItemValueSerializerMap, + ) } } + + diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OAuth2KtorClientTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OAuth2KtorClientTest.kt index becbfdb60..a9ff96043 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OAuth2KtorClientTest.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OAuth2KtorClientTest.kt @@ -6,11 +6,11 @@ import at.asitplus.openid.TokenIntrospectionRequest import at.asitplus.openid.TokenRequestParameters import at.asitplus.signum.indispensable.josef.JwsAlgorithm import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupid.EuPidScheme import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.jws.JwsHeaderCertOrJwk import at.asitplus.wallet.lib.jws.SignJwt import at.asitplus.wallet.lib.ktor.openid.TestUtils.dummyUser @@ -145,7 +145,7 @@ val OAuth2KtorClientTest by matrixSuite { ) } - val strategy = CredentialAuthorizationServiceStrategy(setOf(EuPidScheme)) + val strategy = CredentialAuthorizationServiceStrategy(AttributeIndex.schemeSet) val requestedScope = strategy.validScopes().split(" ").first() listOf?>>( diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientExternalAuthorizationServerTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientExternalAuthorizationServerTest.kt index fd02b6289..63b496503 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientExternalAuthorizationServerTest.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientExternalAuthorizationServerTest.kt @@ -8,12 +8,16 @@ import at.asitplus.openid.OidcUserInfo import at.asitplus.openid.OidcUserInfoExtended import at.asitplus.openid.OpenIdConstants import at.asitplus.openid.RequestParameters +import at.asitplus.openid.SupportedCredentialFormatIsoMdoc +import at.asitplus.openid.SupportedCredentialFormatSdJwt import at.asitplus.openid.TokenIntrospectionRequest import at.asitplus.openid.TokenRequestParameters import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE +import at.asitplus.wallet.eupid.EuPidDataElements +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialRenewalInfo import at.asitplus.wallet.lib.agent.CredentialToBeIssued.Iso @@ -23,6 +27,7 @@ import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* import at.asitplus.wallet.lib.data.CredentialRepresentation import at.asitplus.wallet.lib.data.CredentialScheme @@ -115,7 +120,6 @@ val OpenId4VciClientExternalAuthorizationServerTest by matrixSuite { } } } - val credentialSchemes = setOf(EuPidScheme, EuPidSdJwtScheme) val authorizationEndpointPath = "/authorize" val tokenEndpointPath = "/token" val credentialEndpointPath = "/credential" @@ -129,7 +133,7 @@ val OpenId4VciClientExternalAuthorizationServerTest by matrixSuite { issueRefreshTokens = true ) val externalAuthorizationServer = SimpleAuthorizationService( - strategy = CredentialAuthorizationServiceStrategy(credentialSchemes), + strategy = CredentialAuthorizationServiceStrategy(AttributeIndex.schemeSet), publicContext = authServerPublicContext, authorizationEndpointPath = authorizationEndpointPath, tokenEndpointPath = tokenEndpointPath, @@ -255,7 +259,7 @@ val OpenId4VciClientExternalAuthorizationServerTest by matrixSuite { internalTokenVerificationService = tokenService.verification, ), issuer = issuer, - credentialSchemes = credentialSchemes, + credentialSchemes = AttributeIndex.schemeSet, publicContext = issuerPublicContext, credentialEndpointPath = credentialEndpointPath, nonceEndpointPath = nonceEndpointPath, @@ -296,14 +300,18 @@ val OpenId4VciClientExternalAuthorizationServerTest by matrixSuite { test("loadEuPidCredentialSdJwt") { val expectedAttributeValue = uuid4().toString() - val expectedAttributeName = EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME - with(setup(EuPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedAttributeValue))) { + val expectedAttributeName = EuPidSdJwtDataElements.FAMILY_NAME + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + with(setup(euPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedAttributeValue))) { var refreshTokenStore: CredentialRenewalInfo? = null // Load credential identifier infos from Issuing service val credentialIdentifierInfos = client.loadCredentialMetadata(issuerPublicContext).getOrThrow() - // just pick the first credential in SD-JWT that is available + // Pick the EuPID SD-JWT credential configuration; other SD-JWT schemes may also be registered. val selectedCredential = credentialIdentifierInfos - .first { it.supportedCredentialFormat.format == CredentialFormatEnum.DC_SD_JWT } + .first { + (it.supportedCredentialFormat as? SupportedCredentialFormatSdJwt)?.sdJwtVcType == + euPidSdJwtScheme.sdJwtType + } client.startProvisioningWithAuthRequestReturningResult( credentialIssuerUrl = issuerPublicContext, @@ -338,12 +346,16 @@ val OpenId4VciClientExternalAuthorizationServerTest by matrixSuite { // not the credential issuer URL (issuerPublicContext). Without the fix, both token calls send // aud = issuerPublicContext and the AS rejects them with InvalidClient. val expectedAttributeValue = uuid4().toString() - val expectedAttributeName = EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME - with(setup(EuPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedAttributeValue), validatePopAudience = true)) { + val expectedAttributeName = EuPidSdJwtDataElements.FAMILY_NAME + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + with(setup(euPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedAttributeValue), validatePopAudience = true)) { var refreshTokenStore: CredentialRenewalInfo? = null val credentialIdentifierInfos = client.loadCredentialMetadata(issuerPublicContext).getOrThrow() val selectedCredential = credentialIdentifierInfos - .first { it.supportedCredentialFormat.format == CredentialFormatEnum.DC_SD_JWT } + .first { + (it.supportedCredentialFormat as? SupportedCredentialFormatSdJwt)?.sdJwtVcType == + euPidSdJwtScheme.sdJwtType + } client.startProvisioningWithAuthRequestReturningResult( credentialIssuerUrl = issuerPublicContext, @@ -370,19 +382,23 @@ val OpenId4VciClientExternalAuthorizationServerTest by matrixSuite { test("loadEuPidCredentialIsoWithOffer") { val expectedAttributeValue = uuid4().toString() - val expectedAttributeName = EuPidScheme.Attributes.GIVEN_NAME - with(setup(EuPidScheme, ISO_MDOC, mapOf(expectedAttributeName to expectedAttributeValue))) { + val expectedAttributeName = EuPidDataElements.GIVEN_NAME + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) + with(setup(euPidScheme, ISO_MDOC, mapOf(expectedAttributeName to expectedAttributeValue))) { var refreshTokenStore: CredentialRenewalInfo? = null // Load credential identifier infos from Issuing service val credentialIdentifierInfos = client.loadCredentialMetadata(issuerPublicContext).getOrThrow() - // just pick the first credential in MSO_MDOC that is available + // Pick the EuPID ISO mdoc credential configuration; other ISO schemes may also be registered. val selectedCredential = credentialIdentifierInfos - .first { it.supportedCredentialFormat.format == CredentialFormatEnum.MSO_MDOC } + .first { + (it.supportedCredentialFormat as? SupportedCredentialFormatIsoMdoc)?.docType == + euPidScheme.isoDocType + } val offer = externalAuthorizationServer.offerWithPreAuthnForUserForSchemes( user = dummyUser(), credentialIssuer = credentialIssuer.metadata.credentialIssuer, - schemes = setOf(EuPidScheme to ISO_MDOC), + schemes = setOf(euPidScheme to ISO_MDOC), ) client.loadCredentialWithOfferReturningResult(offer, selectedCredential, null).getOrThrow().also { it.shouldBeInstanceOf().also { diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientIntegratedDPoPTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientIntegratedDPoPTest.kt index 0e0f50cc4..65cba9ce9 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientIntegratedDPoPTest.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientIntegratedDPoPTest.kt @@ -7,13 +7,15 @@ import at.asitplus.openid.RequestParameters import at.asitplus.openid.TokenRequestParameters import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.agent.CredentialRenewalInfo import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.rfc3986.toUri import at.asitplus.wallet.lib.jws.JwsHeaderCertOrJwk @@ -35,6 +37,7 @@ import at.asitplus.wallet.lib.oidvci.decodeFromUrlQuery import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.kotest.assertions.fail +import io.kotest.engine.runBlocking import io.kotest.matchers.nulls.shouldNotBeNull import io.ktor.client.* import io.ktor.client.engine.mock.* @@ -59,143 +62,146 @@ val OpenId4VciClientIntegratedDPoPTest by matrixSuite { val client: OpenId4VciClient, ) - fixture { - val scheme = EuPidSdJwtScheme - val representation = SD_JWT - val attributes = mapOf(EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME to uuid4().toString()) - val credentialKeyMaterial = EphemeralKeyWithoutCert() - val clientAuthKeyMaterial = EphemeralKeyWithoutCert() - val credentialSchemes = setOf(scheme) - val authorizationEndpointPath = "/authorize" - val tokenEndpointPath = "/token" - val credentialEndpointPath = "/credential" - val nonceEndpointPath = "/nonce" - val parEndpointPath = "/par" - val publicContext = "https://issuer.example.com" - val authorizationService = SimpleAuthorizationService( - strategy = CredentialAuthorizationServiceStrategy(credentialSchemes), - publicContext = publicContext, - authorizationEndpointPath = authorizationEndpointPath, - tokenEndpointPath = tokenEndpointPath, - pushedAuthorizationRequestEndpointPath = parEndpointPath, - clientAuthenticationService = ClientAuthenticationService( - enforceClientAuthentication = true, - ), - tokenService = TokenService.jwt( - issueRefreshTokens = true - ), - ) - val issuer = IssuerAgent( - keyMaterial = EphemeralKeyWithSelfSignedCert(), - identifier = "https://issuer.example.com/".toUri(), - randomSource = RandomSource.Default - ) - val credentialIssuer = CredentialIssuer( - authorizationService = authorizationService, - issuer = issuer, - credentialSchemes = credentialSchemes, - publicContext = publicContext, - credentialEndpointPath = credentialEndpointPath, - nonceEndpointPath = nonceEndpointPath, - ) - val mockEngine = MockEngine { request -> - when { - request.url.rawSegments.drop(1) == OpenIdConstants.WellKnownPaths.CredentialIssuer -> - this.respond(credentialIssuer.metadata) + fixture({ + runBlocking { + val scheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) - request.url.rawSegments.drop(1) == OpenIdConstants.WellKnownPaths.OauthAuthorizationServer -> - this.respond(authorizationService.metadata()) + val representation = SD_JWT + val attributes = mapOf(EuPidSdJwtDataElements.FAMILY_NAME to uuid4().toString()) + val credentialKeyMaterial = EphemeralKeyWithoutCert() + val clientAuthKeyMaterial = EphemeralKeyWithoutCert() + val credentialSchemes = setOf(scheme) + val authorizationEndpointPath = "/authorize" + val tokenEndpointPath = "/token" + val credentialEndpointPath = "/credential" + val nonceEndpointPath = "/nonce" + val parEndpointPath = "/par" + val publicContext = "https://issuer.example.com" + val authorizationService = SimpleAuthorizationService( + strategy = CredentialAuthorizationServiceStrategy(credentialSchemes), + publicContext = publicContext, + authorizationEndpointPath = authorizationEndpointPath, + tokenEndpointPath = tokenEndpointPath, + pushedAuthorizationRequestEndpointPath = parEndpointPath, + clientAuthenticationService = ClientAuthenticationService( + enforceClientAuthentication = true, + ), + tokenService = TokenService.jwt( + issueRefreshTokens = true + ), + ) + val issuer = IssuerAgent( + keyMaterial = EphemeralKeyWithSelfSignedCert(), + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default + ) + val credentialIssuer = CredentialIssuer( + authorizationService = authorizationService, + issuer = issuer, + credentialSchemes = credentialSchemes, + publicContext = publicContext, + credentialEndpointPath = credentialEndpointPath, + nonceEndpointPath = nonceEndpointPath, + ) + val mockEngine = MockEngine { request -> + when { + request.url.rawSegments.drop(1) == OpenIdConstants.WellKnownPaths.CredentialIssuer -> + this.respond(credentialIssuer.metadata) - request.url.fullPath.startsWith(parEndpointPath) -> { - val requestBody = request.body.toByteArray().decodeToString() - val authnRequest: RequestParameters = requestBody.decodeFromPostBody() - authorizationService.parWithDpopNonce(authnRequest, request.toRequestInfo()).fold( - onSuccess = { respondIncludingDpopNonce(it) }, - onFailure = { fail("$parEndpointPath should not return an error") } - ) - } + request.url.rawSegments.drop(1) == OpenIdConstants.WellKnownPaths.OauthAuthorizationServer -> + this.respond(authorizationService.metadata()) - request.url.fullPath.startsWith(authorizationEndpointPath) -> { - val requestBody = request.body.toByteArray().decodeToString() - val queryParameters: Map = - request.url.parameters.toMap().entries.associate { it.key to it.value.first() } - val authnRequest: RequestParameters = - if (requestBody.isEmpty()) queryParameters.decodeFromUrlQuery() - else requestBody.decodeFromPostBody() - authorizationService.authorize(authnRequest) { this.catching { TestUtils.dummyUser() } }.fold( - onSuccess = { this.respondRedirect(it.url) }, - onFailure = { fail("$authorizationEndpointPath should not return an error") } - ) - } + request.url.fullPath.startsWith(parEndpointPath) -> { + val requestBody = request.body.toByteArray().decodeToString() + val authnRequest: RequestParameters = requestBody.decodeFromPostBody() + authorizationService.parWithDpopNonce(authnRequest, request.toRequestInfo()).fold( + onSuccess = { respondIncludingDpopNonce(it) }, + onFailure = { fail("$parEndpointPath should not return an error") } + ) + } - request.url.fullPath.startsWith(tokenEndpointPath) -> { - val requestBody = request.body.toByteArray().decodeToString() - val params: TokenRequestParameters = requestBody.decodeFromPostBody() - authorizationService.tokenWithDpopNonce(params, request.toRequestInfo()).fold( - onSuccess = { respondIncludingDpopNonce(it) }, - onFailure = { fail("$tokenEndpointPath should not return an error") } - ) - } + request.url.fullPath.startsWith(authorizationEndpointPath) -> { + val requestBody = request.body.toByteArray().decodeToString() + val queryParameters: Map = + request.url.parameters.toMap().entries.associate { it.key to it.value.first() } + val authnRequest: RequestParameters = + if (requestBody.isEmpty()) queryParameters.decodeFromUrlQuery() + else requestBody.decodeFromPostBody() + authorizationService.authorize(authnRequest) { this.catching { TestUtils.dummyUser() } }.fold( + onSuccess = { this.respondRedirect(it.url) }, + onFailure = { fail("$authorizationEndpointPath should not return an error") } + ) + } - request.url.fullPath.startsWith(nonceEndpointPath) -> { - this.respond(credentialIssuer.nonceWithDpopNonce().getOrThrow()) - } + request.url.fullPath.startsWith(tokenEndpointPath) -> { + val requestBody = request.body.toByteArray().decodeToString() + val params: TokenRequestParameters = requestBody.decodeFromPostBody() + authorizationService.tokenWithDpopNonce(params, request.toRequestInfo()).fold( + onSuccess = { respondIncludingDpopNonce(it) }, + onFailure = { fail("$tokenEndpointPath should not return an error") } + ) + } - request.url.fullPath.startsWith(credentialEndpointPath) -> { - val requestBody = request.body.toByteArray().decodeToString() - val authn = request.headers[HttpHeaders.Authorization].shouldNotBeNull() - credentialIssuer.credential( - authorizationHeader = authn, - params = WalletService.CredentialRequest.parse(requestBody).getOrThrow(), - credentialDataProvider = TestUtils.credentialDataProviderFun( - scheme, - representation, - attributes - ), - request = request.toRequestInfo(), - ).fold( - onSuccess = { this.respond(it) }, - onFailure = { fail("$credentialEndpointPath should not return an error") } - ) - } + request.url.fullPath.startsWith(nonceEndpointPath) -> { + this.respond(credentialIssuer.nonceWithDpopNonce().getOrThrow()) + } + + request.url.fullPath.startsWith(credentialEndpointPath) -> { + val requestBody = request.body.toByteArray().decodeToString() + val authn = request.headers[HttpHeaders.Authorization].shouldNotBeNull() + credentialIssuer.credential( + authorizationHeader = authn, + params = WalletService.CredentialRequest.parse(requestBody).getOrThrow(), + credentialDataProvider = TestUtils.credentialDataProviderFun( + scheme, + representation, + attributes + ), + request = request.toRequestInfo(), + ).fold( + onSuccess = { this.respond(it) }, + onFailure = { fail("$credentialEndpointPath should not return an error") } + ) + } - else -> this.respondError(HttpStatusCode.NotFound) - .also { Napier.w("NOT MATCHED ${request.url.fullPath}") } + else -> this.respondError(HttpStatusCode.NotFound) + .also { Napier.w("NOT MATCHED ${request.url.fullPath}") } + } } - } - val clientId = "https://example.com/rp" - Context( - attributes = attributes, - credentialKeyMaterial = credentialKeyMaterial, - clientAuthKeyMaterial = clientAuthKeyMaterial, - mockEngine = mockEngine, - credentialIssuer = credentialIssuer, - authorizationService = authorizationService, - client = OpenId4VciClient( - engine = mockEngine, - oid4vciService = WalletService( - clientId = clientId, - keyMaterial = credentialKeyMaterial, - ), - oauth2Client = OAuth2KtorClient( + val clientId = "https://example.com/rp" + Context( + attributes = attributes, + credentialKeyMaterial = credentialKeyMaterial, + clientAuthKeyMaterial = clientAuthKeyMaterial, + mockEngine = mockEngine, + credentialIssuer = credentialIssuer, + authorizationService = authorizationService, + client = OpenId4VciClient( engine = mockEngine, - loadInstanceAttestation = { _ -> - catching { - BuildClientAttestationJwt( - SignJwt(EphemeralKeyWithSelfSignedCert(), JwsHeaderCertOrJwk()), - clientId = clientId, - issuer = "issuer", - clientKey = clientAuthKeyMaterial.jsonWebKey - ) - } - }, - keyMaterial = clientAuthKeyMaterial, - oAuth2Client = OAuth2Client(clientId = clientId), - randomSource = RandomSource.Default, + oid4vciService = WalletService( + clientId = clientId, + keyMaterial = credentialKeyMaterial, + ), + oauth2Client = OAuth2KtorClient( + engine = mockEngine, + loadInstanceAttestation = { _ -> + catching { + BuildClientAttestationJwt( + SignJwt(EphemeralKeyWithSelfSignedCert(), JwsHeaderCertOrJwk()), + clientId = clientId, + issuer = "issuer", + clientKey = clientAuthKeyMaterial.jsonWebKey + ) + } + }, + keyMaterial = clientAuthKeyMaterial, + oAuth2Client = OAuth2Client(clientId = clientId), + randomSource = RandomSource.Default, + ) ) ) - ) - } - { + } + }) - { test("loadEuPidCredentialSdJwt") { context -> var refreshTokenStore: CredentialRenewalInfo? = null diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientTest.kt index 8fd7b177b..abedfa650 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientTest.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientTest.kt @@ -6,8 +6,10 @@ import at.asitplus.openid.OpenIdConstants import at.asitplus.openid.RequestParameters import at.asitplus.openid.TokenRequestParameters import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE +import at.asitplus.wallet.eupid.EuPidDataElements +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.agent.CredentialRenewalInfo import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert @@ -19,6 +21,7 @@ import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.RandomSource import at.asitplus.wallet.lib.agent.StatusListAgent import at.asitplus.wallet.lib.agent.validation.TokenStatusResolverImpl +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.CredentialRepresentation @@ -222,8 +225,9 @@ val OpenId4VciClientTest by matrixSuite { "loadEuPidCredentialSdJwt" { val expectedFamilyName = uuid4().toString() - val expectedAttributeName = EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME - with(setup(EuPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedFamilyName))) { + val expectedAttributeName = EuPidSdJwtDataElements.FAMILY_NAME + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + with(setup(euPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedFamilyName))) { var refreshTokenStore: CredentialRenewalInfo? = null // Load credential identifier infos from Issuing service @@ -262,10 +266,11 @@ val OpenId4VciClientTest by matrixSuite { "loadEuPidCredentialIsoWithOfferIdentifierListRevocation" { val expectedAttributeValue = uuid4().toString() - val expectedAttributeName = EuPidScheme.Attributes.GIVEN_NAME + val expectedAttributeName = EuPidDataElements.GIVEN_NAME + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) with( setup( - scheme = EuPidScheme, + scheme = euPidScheme, representation = ISO_MDOC, attributes = mapOf(expectedAttributeName to expectedAttributeValue), revocationKind = RevocationList.Kind.IDENTIFIER_LIST, @@ -278,7 +283,7 @@ val OpenId4VciClientTest by matrixSuite { val offer = authorizationService.offerWithPreAuthnForUserForSchemes( user = dummyUser(), credentialIssuer = credentialIssuer.metadata.credentialIssuer, - schemes = setOf(EuPidScheme to ISO_MDOC), + schemes = setOf(euPidScheme to ISO_MDOC), ) val issuedCredential = client.loadCredentialWithOfferReturningResult(offer, selectedCredential, null) .getOrThrow() @@ -309,8 +314,9 @@ val OpenId4VciClientTest by matrixSuite { "loadEuPidCredentialIsoWithOffer" { val expectedAttributeValue = uuid4().toString() - val expectedAttributeName = EuPidScheme.Attributes.GIVEN_NAME - with(setup(EuPidScheme, ISO_MDOC, mapOf(expectedAttributeName to expectedAttributeValue))) { + val expectedAttributeName = EuPidDataElements.GIVEN_NAME + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) + with(setup(euPidScheme, ISO_MDOC, mapOf(expectedAttributeName to expectedAttributeValue))) { var refreshTokenStore: CredentialRenewalInfo? = null // Load credential identifier infos from Issuing service val credentialIdentifierInfos = client.loadCredentialMetadata("http://localhost").getOrThrow() @@ -321,7 +327,7 @@ val OpenId4VciClientTest by matrixSuite { val offer = authorizationService.offerWithPreAuthnForUserForSchemes( user = dummyUser(), credentialIssuer = credentialIssuer.metadata.credentialIssuer, - schemes = setOf(EuPidScheme to ISO_MDOC), + schemes = setOf(euPidScheme to ISO_MDOC), ) client.loadCredentialWithOfferReturningResult(offer, selectedCredential, null).getOrThrow().also { it.shouldBeInstanceOf().also { diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientWithEncryptionTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientWithEncryptionTest.kt index 1d1464d43..972900868 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientWithEncryptionTest.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VciClientWithEncryptionTest.kt @@ -9,14 +9,17 @@ import at.asitplus.openid.RequestParameters import at.asitplus.openid.TokenRequestParameters import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE +import at.asitplus.wallet.eupid.EuPidDataElements +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.agent.CredentialRenewalInfo import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.CredentialRepresentation @@ -212,8 +215,9 @@ val OpenId4VciClientWithEncryptionTest by matrixSuite { test("loadEuPidCredentialSdJwt") { val expectedFamilyName = uuid4().toString() - val expectedAttributeName = EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME - with(setup(EuPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedFamilyName))) { + val expectedAttributeName = EuPidSdJwtDataElements.FAMILY_NAME + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + with(setup(euPidSdJwtScheme, SD_JWT, mapOf(expectedAttributeName to expectedFamilyName))) { var refreshTokenStore: CredentialRenewalInfo? = null // Load credential identifier infos from Issuing service @@ -244,8 +248,9 @@ val OpenId4VciClientWithEncryptionTest by matrixSuite { test("loadEuPidCredentialIsoWithOffer") { val expectedAttributeValue = uuid4().toString() - val expectedAttributeName = EuPidScheme.Attributes.GIVEN_NAME - with(setup(EuPidScheme, ISO_MDOC, mapOf(expectedAttributeName to expectedAttributeValue))) { + val expectedAttributeName = EuPidDataElements.GIVEN_NAME + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) + with(setup(euPidScheme, ISO_MDOC, mapOf(expectedAttributeName to expectedAttributeValue))) { var refreshTokenStore: CredentialRenewalInfo? = null // Load credential identifier infos from Issuing service @@ -257,7 +262,7 @@ val OpenId4VciClientWithEncryptionTest by matrixSuite { val offer = authorizationService.offerWithPreAuthnForUserForSchemes( user = dummyUser(), credentialIssuer = credentialIssuer.metadata.credentialIssuer, - schemes = setOf(EuPidScheme to ISO_MDOC), + schemes = setOf(euPidScheme to ISO_MDOC), ) client.loadCredentialWithOfferReturningResult(offer, selectedCredential, null).getOrThrow().also { it.shouldBeInstanceOf().also { diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VpWalletTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VpWalletTest.kt index 964d32435..3bfb39e60 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VpWalletTest.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/OpenId4VpWalletTest.kt @@ -26,8 +26,10 @@ import at.asitplus.openid.dcql.DCQLQuery import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE +import at.asitplus.wallet.eupid.EuPidDataElements +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.RequestOptionsCredential import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued @@ -39,7 +41,10 @@ import at.asitplus.wallet.lib.agent.RandomSource import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.toStoreCredentialInput import at.asitplus.wallet.lib.data.AtomicAttribute2023 -import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* +import at.asitplus.wallet.lib.data.AttributeIndex +import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.CredentialPresentation.DCQLPresentation import at.asitplus.wallet.lib.data.CredentialPresentationRequest.DCQLRequest import at.asitplus.wallet.lib.data.CredentialRepresentation @@ -63,7 +68,7 @@ import at.asitplus.wallet.lib.openid.OpenId4VpVerifier.CreationOptions import at.asitplus.wallet.lib.openid.PresentationExchangeMatchingResult import at.asitplus.wallet.lib.openid.VpTokenValidationResultDCQL import at.asitplus.wallet.lib.openid.VpTokenValidationResultPresentationExchange -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import at.asitplus.wallet.mdl.MDL_DOCTYPE import com.benasher44.uuid.uuid4 import io.github.aakira.napier.Napier import io.kotest.matchers.collections.shouldBeSingleton @@ -163,7 +168,7 @@ val OpenId4VpWalletTest by matrixSuite { scheme: CredentialScheme, attributes: Map, ): CredentialToBeIssued = when (this) { - PLAIN_JWT -> CredentialToBeIssued.VcJwt( + ConstantIndex.CredentialRepresentation.PLAIN_JWT -> CredentialToBeIssued.VcJwt( subject = AtomicAttribute2023("sub", "name", "value", "text").toJsonElement(), expiration = Clock.System.now().plus(1.minutes), scheme = scheme as VcJwtCredentialScheme, @@ -250,11 +255,12 @@ val OpenId4VpWalletTest by matrixSuite { } } - { test("presentEuPidCredentialSdJwtDirectPost") { + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) it.setup( - scheme = EuPidSdJwtScheme, + scheme = euPidSdJwtScheme, representation = SD_JWT, attributes = mapOf( - EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME to randomString() + EuPidSdJwtDataElements.FAMILY_NAME to randomString() ), responseMode = ResponseMode.DirectPost, clientId = uuid4().toString() @@ -270,11 +276,12 @@ val OpenId4VpWalletTest by matrixSuite { } test("presentEuPidCredentialIsoQuery") { + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) it.setup( - scheme = EuPidScheme, + scheme = euPidScheme, representation = ISO_MDOC, attributes = mapOf( - EuPidScheme.Attributes.GIVEN_NAME to randomString() + EuPidDataElements.GIVEN_NAME to randomString() ), responseMode = ResponseMode.Query, clientId = uuid4().toString() @@ -290,6 +297,7 @@ val OpenId4VpWalletTest by matrixSuite { } test("DC API") { + val mdlScheme = AttributeIndex.resolveIdentifier(MDL_DOCTYPE, ISO_MDOC) it.setupWallet(HttpClient().engine) val attributes = mapOf( @@ -298,7 +306,7 @@ val OpenId4VpWalletTest by matrixSuite { "age_over_21" to true ) - val credential = it.storeMockCredentials(MobileDrivingLicenceScheme, ISO_MDOC, attributes) + val credential = it.storeMockCredentials(mdlScheme, ISO_MDOC, attributes) val dcqlQuery = DCQLQuery( credentials = DCQLCredentialQueryList( @@ -307,7 +315,7 @@ val OpenId4VpWalletTest by matrixSuite { id = DCQLCredentialQueryIdentifier("cred1"), format = CredentialFormatEnum.MSO_MDOC, meta = DCQLIsoMdocCredentialMetadataAndValidityConstraints( - doctypeValue = MobileDrivingLicenceScheme.isoDocType + doctypeValue = mdlScheme.isoDocType!! ), claims = DCQLClaimsQueryList( list = nonEmptyListOf( @@ -448,11 +456,12 @@ val OpenId4VpWalletTest by matrixSuite { } test("No matching credential test") { + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) it.setup( - scheme = EuPidScheme, + scheme = euPidScheme, representation = ISO_MDOC, attributes = mapOf( - EuPidScheme.Attributes.GIVEN_NAME to randomString() + EuPidDataElements.GIVEN_NAME to randomString() ), responseMode = ResponseMode.Query, clientId = uuid4().toString(), diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataRegistryTest.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataRegistryTest.kt new file mode 100644 index 000000000..9f5c70d52 --- /dev/null +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/RemoteCredentialMetadataRegistryTest.kt @@ -0,0 +1,83 @@ +package at.asitplus.wallet.lib.ktor.openid + +import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.lib.agent.FixedTimeClock +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT +import at.asitplus.wallet.lib.data.CredentialMetadataLookup +import at.asitplus.wallet.sdjwt.SdJwtVcType +import com.benasher44.uuid.uuid4 +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.ktor.client.* +import io.ktor.client.engine.mock.* +import io.ktor.http.* + +val RemoteCredentialMetadataRegistryTest by matrixSuite { + + "findEntry fetches aliased remote metadata" { + val vct = SdJwtVcType("urn:test:remote:${uuid4()}") + val vcType = "RemoteTestCredential-${uuid4()}" + val metadataUrl = "https://metadata.example.test/${uuid4()}/type-metadata.json" + var requestedUrl: String? = null + + val httpClient = HttpClient(MockEngine { request -> + requestedUrl = request.url.toString() + respond( + content = """ + { + "vct": "${vct.string}", + "vck": { + "format": "jwt_vc_json", + "vcType": "$vcType" + } + } + """.trimIndent(), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.CacheControl, "max-age=60") + ) + }) + + try { + val registry = RemoteCredentialMetadataRegistry( + httpClient = httpClient, + clock = FixedTimeClock(0), + documentUrls = mutableMapOf(vct to metadataUrl), + aliases = mapOf(CredentialMetadataLookup(PLAIN_JWT, vcType) to vct), + ) + + val entry = registry.findEntry(vcType, PLAIN_JWT).shouldNotBeNull() + + requestedUrl shouldBe metadataUrl + entry.loadedFrom shouldBe metadataUrl + entry.metadata.vct shouldBe vct + entry.metadata.vckExtensions?.vcType shouldBe vcType + entry.aliases shouldContain vcType + } finally { + httpClient.close() + } + } + + "findEntry returns null when remote fetch fails" { + val vct = SdJwtVcType("urn:test:remote:${uuid4()}") + val metadataUrl = "https://metadata.example.test/${uuid4()}/type-metadata.json" + + val httpClient = HttpClient(MockEngine { + respond(content = "", status = HttpStatusCode.InternalServerError) + }) + + try { + val registry = RemoteCredentialMetadataRegistry( + httpClient = httpClient, + clock = FixedTimeClock(0), + documentUrls = mutableMapOf(vct to metadataUrl), + ) + + registry.findEntry(vct.string, SD_JWT).shouldBeNull() + } finally { + httpClient.close() + } + } +} diff --git a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/TestUtils.kt b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/TestUtils.kt index 32fbefd04..67d381bdb 100644 --- a/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/TestUtils.kt +++ b/vck-openid-ktor/src/commonTest/kotlin/at/asitplus/wallet/lib/ktor/openid/TestUtils.kt @@ -15,12 +15,13 @@ import at.asitplus.signum.indispensable.CryptoPublicKey import at.asitplus.signum.indispensable.josef.JsonWebToken import at.asitplus.signum.indispensable.josef.JwsCompactTyped import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer -import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.agent.Holder import at.asitplus.wallet.lib.agent.ValidatorSdJwt +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* import at.asitplus.wallet.lib.data.CredentialRepresentation import at.asitplus.wallet.lib.data.CredentialScheme @@ -118,9 +119,10 @@ object TestUtils { expectedClaimValue: String, credentialKey: CryptoPublicKey, ) { + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) credentials.shouldBeSingleton().also { it.first().shouldBeInstanceOf().also { - it.scheme shouldBe EuPidSdJwtScheme + it.scheme shouldBe euPidSdJwtScheme ValidatorSdJwt().verifySdJwt(it.signedSdJwtVc, credentialKey).getOrThrow() .disclosures.values.any { it.claimName == claimName && @@ -131,13 +133,14 @@ object TestUtils { } } - fun CredentialIssuanceResult.Success.verifyIsoMdocCredential( + suspend fun CredentialIssuanceResult.Success.verifyIsoMdocCredential( claimName: String, expectedClaimValue: String, ) { + val euPidScheme = AttributeIndex.resolveIdentifier(EU_PID_DOCTYPE, ISO_MDOC) credentials.shouldBeSingleton().also { it.first().shouldBeInstanceOf().also { - it.scheme shouldBe EuPidScheme + it.scheme shouldBe euPidScheme it.issuerSigned.namespaces?.values?.flatMap { it.entries }?.map { it.value } ?.any { it.elementIdentifier == claimName && it.elementValue == expectedClaimValue } ?.shouldNotBeNull()?.shouldBeTrue() diff --git a/vck-openid/build.gradle.kts b/vck-openid/build.gradle.kts index 957cdcbb8..8cb5dc810 100644 --- a/vck-openid/build.gradle.kts +++ b/vck-openid/build.gradle.kts @@ -36,9 +36,6 @@ kotlin { commonTest { dependencies { - implementation("at.asitplus.wallet:eupidcredential:${VcLibVersions.eupid}") - implementation("at.asitplus.wallet:eupidcredential-sdjwt:${VcLibVersions.eupidsdjwt}") - implementation("at.asitplus.wallet:mobiledrivinglicence:${VcLibVersions.mdl}") } } diff --git a/vck-openid/src/commonTest/kotlin/TestConfig.kt b/vck-openid/src/commonTest/kotlin/TestConfig.kt index 0db1140ef..b01b78d2d 100644 --- a/vck-openid/src/commonTest/kotlin/TestConfig.kt +++ b/vck-openid/src/commonTest/kotlin/TestConfig.kt @@ -1,5 +1,16 @@ import at.asitplus.testballoon.matrix.ExecutionMode import at.asitplus.testballoon.matrix.MatrixTestDefaults +import at.asitplus.wallet.eupid.EuPidItemValueSerializerMap +import at.asitplus.wallet.eupid.EuPidJsonValueEncoder +import at.asitplus.wallet.eupid.EuPidMetadataDocument +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtMetadataDocument +import at.asitplus.wallet.lib.LibraryInitializer +import at.asitplus.wallet.lib.data.StaticCredentialMetadataRegistry +import at.asitplus.wallet.mdl.MobileDrivingLicenceItemValueSerializerMap +import at.asitplus.wallet.mdl.MobileDrivingLicenceJsonValueEncoder +import at.asitplus.wallet.mdl.MobileDrivingLicenceMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRegistry +import at.asitplus.wallet.sdjwt.SdJwtVcType import de.infix.testBalloon.framework.core.TestSession import io.github.aakira.napier.DebugAntilog import io.github.aakira.napier.Napier @@ -12,8 +23,30 @@ class TestConfig : TestSession( init { Napier.takeLogarithm() Napier.base(DebugAntilog()) - at.asitplus.wallet.eupid.Initializer.initWithVCK() - at.asitplus.wallet.eupidsdjwt.Initializer.initWithVCK() - at.asitplus.wallet.mdl.Initializer.initWithVCK() + + LibraryInitializer.registerCredentialMetadataRegistry( + StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + EuPidSdJwtMetadataDocument, + EuPidMetadataDocument, + MobileDrivingLicenceMetadataDocument, + ), + documentUrls = mapOf( + SdJwtVcType(EuPidSdJwtMetadataDocument.first.string) to "https://example.com", + SdJwtVcType(EuPidMetadataDocument.first.string) to "https://example.com", + SdJwtVcType(MobileDrivingLicenceMetadataDocument.first.string) to "https://example.com", + ) + ) + ) + + LibraryInitializer.registerCredentialSerializers( + jsonValueEncoder = MobileDrivingLicenceJsonValueEncoder, + itemValueSerializerMap = MobileDrivingLicenceItemValueSerializerMap + ) + LibraryInitializer.registerCredentialSerializers( + jsonValueEncoder = EuPidJsonValueEncoder, + itemValueSerializerMap = EuPidItemValueSerializerMap, + ) } } + diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt index 18b95979d..7c9bbc3d2 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/DeserializationTest.kt @@ -9,6 +9,7 @@ import at.asitplus.openid.SupportedCredentialFormatSdJwt import at.asitplus.signum.indispensable.SignatureAlgorithm import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.mdl.MDL_DOCTYPE import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldContain import io.kotest.matchers.collections.shouldNotBeEmpty @@ -206,9 +207,9 @@ val DeserializationTest by matrixSuite { joseCompliantSerializer.decodeFromString(input).apply { supportedCredentialConfigurations.shouldNotBeNull().apply { shouldNotBeEmpty() - get("org.iso.18013.5.1.mDL").shouldBeInstanceOf().apply { + get(MDL_DOCTYPE).shouldBeInstanceOf().apply { format shouldBe CredentialFormatEnum.MSO_MDOC - docType shouldBe "org.iso.18013.5.1.mDL" + docType shouldBe MDL_DOCTYPE supportedBindingMethods.shouldNotBeNull().shouldBeSingleton().shouldContain("cose_key") supportedSigningAlgorithms.shouldNotBeNull().apply { shouldContain(SignatureAlgorithm.ECDSAwithSHA256) // both -7 and -9 shall map to this diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciAttestationTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciAttestationTest.kt index 4824e7c02..9bf44233b 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciAttestationTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciAttestationTest.kt @@ -43,6 +43,7 @@ import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.KeyMaterial import at.asitplus.wallet.lib.agent.RandomSource import at.asitplus.wallet.lib.data.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT import at.asitplus.wallet.lib.data.VerifiableCredentialJws @@ -55,7 +56,6 @@ import at.asitplus.wallet.lib.oidvci.WalletService.KeyAttestationInput import at.asitplus.wallet.lib.oidvci.WalletService.RequestOptions import at.asitplus.wallet.lib.openid.AuthenticationResponseResult import at.asitplus.wallet.lib.openid.DummyOAuth2IssuerCredentialDataProvider -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrow @@ -77,9 +77,7 @@ val OidvciAttestationTest by matrixSuite { fixture { object { val authorizationService = SimpleAuthorizationService( - strategy = CredentialAuthorizationServiceStrategy( - setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme) - ), + strategy = CredentialAuthorizationServiceStrategy(AttributeIndex.schemeSet), ) val oauth2Client = OAuth2Client() var issuer = CredentialIssuer( @@ -88,7 +86,7 @@ val OidvciAttestationTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, proofValidator = ProofValidator( verifyAttestationProof = { true }, requireKeyAttestation = true, // this is important, to require key attestation @@ -151,7 +149,7 @@ val OidvciAttestationTest by matrixSuite { } } - { test("use key attestation for proof") { - val requestOptions = WalletService.RequestOptions(ConstantIndex.AtomicAttribute2023, PLAIN_JWT) + val requestOptions = RequestOptions(ConstantIndex.AtomicAttribute2023, PLAIN_JWT) val credentialFormat = it.client.selectSupportedCredentialFormat(requestOptions, it.issuer.metadata) .shouldNotBeNull() @@ -191,7 +189,7 @@ val OidvciAttestationTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, proofValidator = ProofValidator( verifyAttestationProof = { false }, // do not accept key attestation requireKeyAttestation = true, // this is important, to require key attestation @@ -327,7 +325,7 @@ val OidvciAttestationTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, proofValidator = ProofValidator( verifyAttestationProof = { false }, // do not accept key attestation requireKeyAttestation = false, @@ -415,7 +413,7 @@ val OidvciAttestationTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, proofValidator = ProofValidator(requireKeyAttestation = false) ) @@ -510,7 +508,7 @@ val OidvciAttestationTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(ConstantIndex.AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, proofValidator = ProofValidator(requireKeyAttestation = false), ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciCodeFlowTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciCodeFlowTest.kt index 1eb563294..2315716e5 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciCodeFlowTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciCodeFlowTest.kt @@ -27,11 +27,12 @@ import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer import at.asitplus.signum.indispensable.josef.JwsCompactTyped import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* +import at.asitplus.wallet.lib.data.ExtractedSdJwtCredentialScheme import at.asitplus.wallet.lib.data.VerifiableCredentialJws import at.asitplus.wallet.lib.data.VerifiableCredentialSdJwt import at.asitplus.wallet.lib.data.rfc3986.toUri @@ -43,7 +44,7 @@ import at.asitplus.wallet.lib.openid.AuthenticationResponseResult import at.asitplus.wallet.lib.openid.DummyOAuth2IssuerCredentialDataProvider import at.asitplus.wallet.lib.openid.DummyUserProvider import at.asitplus.wallet.lib.utils.MapStore -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import at.asitplus.wallet.mdl.MDL_DOCTYPE import com.benasher44.uuid.uuid4 import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.assertions.throwables.shouldThrow @@ -69,7 +70,7 @@ val OidvciCodeFlowTest by matrixSuite { object { val mapper = DefaultCredentialSchemeMapper() val strategy = CredentialAuthorizationServiceStrategy( - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, mapper = mapper, ) var authorizationService = SimpleAuthorizationService( @@ -81,7 +82,7 @@ val OidvciCodeFlowTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, credentialSchemeMapper = mapper, ) val client = WalletService() @@ -429,7 +430,10 @@ val OidvciCodeFlowTest by matrixSuite { authorizationServers = it.issuer.metadata.authorizationServers ) val tokenAuthnDetails = it.client.buildAuthorizationDetails( - credentialConfigurationId = it.mapper.toCredentialIdentifier(AtomicAttribute2023, ISO_MDOC), + credentialConfigurationId = it.mapper.toCredentialIdentifier( + AtomicAttribute2023, + ISO_MDOC + ), authorizationServers = it.issuer.metadata.authorizationServers ) val authnRequest = it.oauth2Client.createAuthRequestJar( @@ -458,7 +462,10 @@ val OidvciCodeFlowTest by matrixSuite { authorizationServers = it.issuer.metadata.authorizationServers ) val tokenAuthnDetails = it.client.buildAuthorizationDetails( - credentialConfigurationId = it.mapper.toCredentialIdentifier(AtomicAttribute2023, ISO_MDOC), + credentialConfigurationId = it.mapper.toCredentialIdentifier( + AtomicAttribute2023, + ISO_MDOC + ), authorizationServers = it.issuer.metadata.authorizationServers ) val authnRequest = it.oauth2Client.createAuthRequestJar( @@ -482,8 +489,14 @@ val OidvciCodeFlowTest by matrixSuite { } "request credential with unknown configuration_id" { it -> - // that credential format (from which credential_configuration_id will be derived) is not known to our issuer - val scheme = EuPidSdJwtScheme + // that credential format (from which credential_configuration_id will be derived) is not known to our + // issuer: a typed SD-JWT scheme that is never registered with AttributeIndex (so not in the issuer's + // schemeSet), unlike the metadata-backed schemes pre-loaded via the TestConfig registry + val scheme = ExtractedSdJwtCredentialScheme( + schemaUri = "https://example.com/unknown", + sdJwtType = "urn:eudi:unknown:1", + claimDescriptions = emptySet(), + ) val credentialFormat = with( CredentialIssuer( authorizationService = SimpleAuthorizationService( @@ -568,7 +581,7 @@ val OidvciCodeFlowTest by matrixSuite { "request credential in ISO MDOC, using scope" { it -> - val requestOptions = RequestOptions(MobileDrivingLicenceScheme, ISO_MDOC) + val requestOptions = RequestOptions(AttributeIndex.resolveIdentifier(MDL_DOCTYPE, ISO_MDOC), ISO_MDOC) val credentialFormat = it.client.selectSupportedCredentialFormat(requestOptions, it.issuer.metadata) .shouldNotBeNull() val scope = credentialFormat.scope.shouldNotBeNull() @@ -598,7 +611,7 @@ val OidvciCodeFlowTest by matrixSuite { val namespaces = issuerSigned.namespaces .shouldNotBeNull() - namespaces.keys.first() shouldBe MobileDrivingLicenceScheme.isoNamespace + namespaces.keys.first() shouldBe "org.iso.18013.5.1" val numberOfClaims = namespaces.values.firstOrNull()?.entries?.size.shouldNotBeNull() numberOfClaims shouldBeGreaterThan 1 } diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciMetadataTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciMetadataTest.kt index c5f4b1481..8456045e8 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciMetadataTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciMetadataTest.kt @@ -5,10 +5,10 @@ import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource -import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.rfc3986.toUri import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import at.asitplus.wallet.mdl.MDL_DOCTYPE import io.kotest.matchers.collections.shouldHaveSingleElement import io.kotest.matchers.nulls.shouldNotBeNull import kotlinx.serialization.json.JsonPrimitive @@ -21,12 +21,7 @@ val OidvciMetadataTest by matrixSuite { fixture { object { val authorizationService = SimpleAuthorizationService( - strategy = CredentialAuthorizationServiceStrategy( - setOf( - AtomicAttribute2023, - MobileDrivingLicenceScheme - ) - ), + strategy = CredentialAuthorizationServiceStrategy(AttributeIndex.schemeSet), ) val issuer = CredentialIssuer( authorizationService = authorizationService, @@ -34,14 +29,14 @@ val OidvciMetadataTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, ) } } - { test("metadata for ISO_MDOC") { joseCompliantSerializer.encodeToJsonElement(it.issuer.metadata).jsonObject.apply { get("credential_configurations_supported").shouldNotBeNull().jsonObject.apply { - get("org.iso.18013.5.1").shouldNotBeNull().jsonObject.apply { + get(MDL_DOCTYPE).shouldNotBeNull().jsonObject.apply { get("credential_signing_alg_values_supported").shouldNotBeNull().jsonArray.apply { shouldHaveSingleElement(JsonPrimitive(-9)) } @@ -50,4 +45,4 @@ val OidvciMetadataTest by matrixSuite { } } } -} \ No newline at end of file +} diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciOfferCodeTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciOfferCodeTest.kt index 862729630..dfdd6f268 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciOfferCodeTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciOfferCodeTest.kt @@ -9,6 +9,7 @@ import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT @@ -18,7 +19,6 @@ import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService import at.asitplus.wallet.lib.openid.AuthenticationResponseResult import at.asitplus.wallet.lib.openid.DummyOAuth2IssuerCredentialDataProvider import at.asitplus.wallet.lib.openid.DummyUserProvider -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldBeSingleton @@ -33,7 +33,7 @@ val OidvciOfferCodeTest by matrixSuite { val mapper = DefaultCredentialSchemeMapper() val authorizationService = SimpleAuthorizationService( strategy = CredentialAuthorizationServiceStrategy( - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, mapper = mapper, ), ) @@ -43,7 +43,7 @@ val OidvciOfferCodeTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, credentialSchemeMapper = mapper, ) val client = WalletService() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciPreAuthTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciPreAuthTest.kt index fc727f082..70642f542 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciPreAuthTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciPreAuthTest.kt @@ -10,6 +10,7 @@ import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT import at.asitplus.wallet.lib.data.VerifiableCredentialJws @@ -18,7 +19,6 @@ import at.asitplus.wallet.lib.oauth2.OAuth2Client import at.asitplus.wallet.lib.oauth2.SimpleAuthorizationService import at.asitplus.wallet.lib.openid.DummyOAuth2IssuerCredentialDataProvider import at.asitplus.wallet.lib.openid.DummyUserProvider -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldHaveSize @@ -33,7 +33,7 @@ val OidvciPreAuthTest by matrixSuite { val mapper = DefaultCredentialSchemeMapper() val authorizationService = SimpleAuthorizationService( strategy = CredentialAuthorizationServiceStrategy( - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, mapper = mapper, ), ) @@ -43,7 +43,7 @@ val OidvciPreAuthTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, credentialSchemeMapper = mapper, ) val client = WalletService() @@ -107,14 +107,14 @@ val OidvciPreAuthTest by matrixSuite { schemes = emptySet(), ) val credentialIdsToRequest = credentialOffer.configurationIds - .shouldHaveSize(4) // Atomic Attribute in 3 representations (JWT, ISO, dc+sd-jwt), mDL in ISO + .shouldHaveSize(6) // Atomic Attribute in 3 representations (JWT, ISO, dc+sd-jwt), mDL in ISO, EUPID, EU-PID-SDJWT .toSet() val token = it.getToken(credentialOffer, credentialIdsToRequest) val clientNonce = it.issuer.nonceWithDpopNonce().getOrThrow().response.clientNonce val authnDetails = token.authorizationDetails .shouldNotBeNull() - .shouldHaveSize(4) + .shouldHaveSize(6) authnDetails.forEach { authnDetail -> authnDetail.shouldBeInstanceOf() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciSameScopeTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciSameScopeTest.kt index b151405d1..b096ba9bf 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciSameScopeTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/oidvci/OidvciSameScopeTest.kt @@ -21,6 +21,7 @@ import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* import at.asitplus.wallet.lib.data.VerifiableCredentialJws @@ -32,7 +33,6 @@ import at.asitplus.wallet.lib.oidvci.WalletService.RequestOptions import at.asitplus.wallet.lib.openid.AuthenticationResponseResult import at.asitplus.wallet.lib.openid.DummyOAuth2IssuerCredentialDataProvider import at.asitplus.wallet.lib.openid.DummyUserProvider -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.collections.shouldNotBeEmpty @@ -48,7 +48,7 @@ val OidvciSameScopeTest by matrixSuite { val mapper = SameScopeCredentialSchemeMapper() val authorizationService = SimpleAuthorizationService( strategy = CredentialAuthorizationServiceStrategy( - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, mapper = mapper, ), ) @@ -58,7 +58,7 @@ val OidvciSameScopeTest by matrixSuite { identifier = "https://issuer.example.com".toUri(), randomSource = RandomSource.Default ), - credentialSchemes = setOf(AtomicAttribute2023, MobileDrivingLicenceScheme), + credentialSchemes = AttributeIndex.schemeSet, credentialSchemeMapper = mapper, ) val client = WalletService() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyCredentialDataProvider.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyCredentialDataProvider.kt index 6c7541d87..c447a5607 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyCredentialDataProvider.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyCredentialDataProvider.kt @@ -16,9 +16,8 @@ import at.asitplus.KmmResult import at.asitplus.catching import at.asitplus.iso.IssuerSignedItem import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE import at.asitplus.wallet.eupid.EuPidCredential -import at.asitplus.wallet.eupid.EuPidScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.data.AtomicAttribute2023 @@ -36,7 +35,16 @@ import at.asitplus.wallet.lib.extensions.supportedSdAlgorithms import at.asitplus.wallet.mdl.DrivingPrivilege import at.asitplus.wallet.mdl.DrivingPrivilegeCode import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import at.asitplus.wallet.eupid.EuPidDataElements +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME +import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT +import at.asitplus.wallet.mdl.MDL_DOCTYPE import kotlinx.datetime.LocalDate import kotlinx.serialization.json.Json import kotlin.random.Random @@ -57,16 +65,10 @@ object DummyCredentialDataProvider { if (credentialScheme == ConstantIndex.AtomicAttribute2023) { val subjectId = subjectPublicKey.didEncoded val claims = listOfNotNull( - ClaimToBeIssued(ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME, "Susanne"), - ClaimToBeIssued(ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME, "Meier"), - ClaimToBeIssued( - ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH, - LocalDate.parse("1990-01-01") - ), - ClaimToBeIssued( - ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT, - Random.Default.nextBytes(32) - ), + ClaimToBeIssued(CLAIM_GIVEN_NAME, "Susanne"), + ClaimToBeIssued(CLAIM_FAMILY_NAME, "Meier"), + ClaimToBeIssued(CLAIM_DATE_OF_BIRTH, LocalDate.parse("1990-01-01")), + ClaimToBeIssued(CLAIM_PORTRAIT, Random.nextBytes(32)), ) when (representation) { SD_JWT -> CredentialToBeIssued.VcSd( @@ -96,7 +98,7 @@ object DummyCredentialDataProvider { userInfo = DummyUserProvider.user, ) } - } else if (credentialScheme == MobileDrivingLicenceScheme) { + } else if (credentialScheme.isoDocType == MDL_DOCTYPE) { val drivingPrivilege = DrivingPrivilege( vehicleCategoryCode = "B", issueDate = LocalDate.parse("2023-01-01"), @@ -114,7 +116,7 @@ object DummyCredentialDataProvider { issuerSignedItem(EXPIRY_DATE, LocalDate.parse("2033-01-01"), digestId++), issuerSignedItem(ISSUING_COUNTRY, "AT", digestId++), issuerSignedItem(ISSUING_AUTHORITY, "AT", digestId++), - issuerSignedItem(PORTRAIT, Random.Default.nextBytes(32), digestId++), + issuerSignedItem(PORTRAIT, Random.nextBytes(32), digestId++), issuerSignedItem(UN_DISTINGUISHING_SIGN, "AT", digestId++), issuerSignedItem(DRIVING_PRIVILEGES, arrayOf(drivingPrivilege), digestId++), issuerSignedItem(AGE_OVER_18, true, digestId++), @@ -128,7 +130,7 @@ object DummyCredentialDataProvider { subjectPublicKey = subjectPublicKey, userInfo = DummyUserProvider.user, ) - } else if (credentialScheme == EuPidScheme) { + } else if (credentialScheme.isoDocType == EU_PID_DOCTYPE || credentialScheme.vcType == "EuPid2023") { val subjectId = subjectPublicKey.didEncoded val familyName = "Musterfrau" val givenName = "Maria" @@ -158,7 +160,7 @@ object DummyCredentialDataProvider { ) ISO_MDOC -> CredentialToBeIssued.Iso( - issuerSignedItems = with(EuPidScheme.Attributes) { + issuerSignedItems = with(EuPidDataElements) { listOfNotNull( ClaimToBeIssued(FAMILY_NAME, familyName), ClaimToBeIssued(FAMILY_NAME_BIRTH, familyName), @@ -182,7 +184,7 @@ object DummyCredentialDataProvider { else -> throw NotImplementedError() } - } else if (credentialScheme == EuPidSdJwtScheme) { + } else if (credentialScheme.sdJwtType == EU_PID_SD_JWT_VCT) { val subjectId = subjectPublicKey.didEncoded val familyName = "Musterfrau" val givenName = "Maria" @@ -193,7 +195,7 @@ object DummyCredentialDataProvider { val expirationDate = LocalDateOrInstant.LocalDate(LocalDate.parse("2027-01-01")) when (representation) { SD_JWT -> CredentialToBeIssued.VcSd( - claims = with(EuPidSdJwtScheme.SdJwtAttributes) { + claims = with(EuPidSdJwtDataElements) { listOfNotNull( ClaimToBeIssued(FAMILY_NAME, familyName), ClaimToBeIssued(FAMILY_NAME_BIRTH, familyName), diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyOAuth2IssuerCredentialDataProvider.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyOAuth2IssuerCredentialDataProvider.kt index 3bd6b9f35..2ce851a52 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyOAuth2IssuerCredentialDataProvider.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/DummyOAuth2IssuerCredentialDataProvider.kt @@ -18,8 +18,9 @@ import at.asitplus.iso.IssuerSignedItem import at.asitplus.openid.OidcUserInfo import at.asitplus.openid.OidcUserInfoExtended import at.asitplus.signum.indispensable.CryptoPublicKey +import at.asitplus.wallet.eupid.EU_PID_DOCTYPE import at.asitplus.wallet.eupid.EuPidCredential -import at.asitplus.wallet.eupid.EuPidScheme +import at.asitplus.wallet.eupid.EuPidDataElements import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.data.AtomicAttribute2023 @@ -30,6 +31,7 @@ import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_PORTRAIT import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* import at.asitplus.wallet.lib.data.CredentialRepresentation +import at.asitplus.wallet.lib.data.CredentialScheme import at.asitplus.wallet.lib.data.IsoMdocCredentialScheme import at.asitplus.wallet.lib.data.LocalDateOrInstant import at.asitplus.wallet.lib.data.SdJwtCredentialScheme @@ -38,12 +40,12 @@ import at.asitplus.wallet.lib.data.toJsonElement import at.asitplus.wallet.lib.extensions.supportedSdAlgorithms import at.asitplus.wallet.lib.oidvci.CredentialDataProviderFun import at.asitplus.wallet.lib.oidvci.CredentialDataProviderInput +import at.asitplus.wallet.mdl.MDL_DOCTYPE import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.DOCUMENT_NUMBER import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.EXPIRY_DATE import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.FAMILY_NAME import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.GIVEN_NAME import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.ISSUE_DATE -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import io.matthewnelson.encoding.base64.Base64 import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString @@ -62,18 +64,13 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { override suspend fun invoke( input: CredentialDataProviderInput, ): KmmResult = catching { - when (input.credentialScheme) { - ConstantIndex.AtomicAttribute2023 -> getAtomic( - input.userInfo, - input.subjectPublicKey, - input.credentialRepresentation - ) - - MobileDrivingLicenceScheme -> getMdl(input.userInfo, input.subjectPublicKey) - EuPidScheme -> getEuPid(input.userInfo, input.subjectPublicKey, input.credentialRepresentation) - - else -> throw NotImplementedError() - } + if (input.credentialScheme == ConstantIndex.AtomicAttribute2023) + getAtomic(input.userInfo, input.subjectPublicKey, input.credentialRepresentation, input.credentialScheme) + else if (input.credentialScheme.isoDocType == MDL_DOCTYPE) + getMdl(input.userInfo, input.subjectPublicKey, input.credentialScheme) + else if (input.credentialScheme.isoDocType == EU_PID_DOCTYPE || input.credentialScheme.vcType == "EuPid2023") + getEuPid(input.userInfo, input.subjectPublicKey, input.credentialRepresentation, input.credentialScheme) + else throw NotImplementedError() } @@ -81,6 +78,7 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { userInfo: OidcUserInfoExtended, subjectPublicKey: CryptoPublicKey, representation: CredentialRepresentation, + credentialScheme: CredentialScheme, ): CredentialToBeIssued { val issuance = clock.now() val expiration = issuance + defaultLifetime @@ -134,6 +132,7 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { private fun getMdl( userInfo: OidcUserInfoExtended, subjectPublicKey: CryptoPublicKey, + credentialScheme: CredentialScheme, ): CredentialToBeIssued.Iso { val issuance = clock.now() val expiration = issuance + defaultLifetime @@ -150,7 +149,7 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { return CredentialToBeIssued.Iso( issuerSignedItems, expiration, - MobileDrivingLicenceScheme as IsoMdocCredentialScheme, + credentialScheme as IsoMdocCredentialScheme, subjectPublicKey, DummyUserProvider.user, ) @@ -160,6 +159,7 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { userInfo: OidcUserInfoExtended, subjectPublicKey: CryptoPublicKey, representation: CredentialRepresentation, + credentialScheme: CredentialScheme, ): CredentialToBeIssued { val issuance = clock.now() val expiration = issuance + defaultLifetime @@ -171,19 +171,19 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { val issuanceDate = LocalDateOrInstant.LocalDate(LocalDate.parse("2023-01-01")) val expirationDate = LocalDateOrInstant.LocalDate(LocalDate.parse("2027-01-01")) val claims = listOfNotNull( - ClaimToBeIssued(EuPidScheme.Attributes.FAMILY_NAME, familyName), - ClaimToBeIssued(EuPidScheme.Attributes.GIVEN_NAME, givenName), - ClaimToBeIssued(EuPidScheme.Attributes.BIRTH_DATE, birthDate), - ClaimToBeIssued(EuPidScheme.Attributes.ISSUANCE_DATE, issuanceDate), - ClaimToBeIssued(EuPidScheme.Attributes.EXPIRY_DATE, expirationDate), - ClaimToBeIssued(EuPidScheme.Attributes.ISSUING_COUNTRY, issuingCountry), - ClaimToBeIssued(EuPidScheme.Attributes.ISSUING_AUTHORITY, issuingCountry), + ClaimToBeIssued(EuPidDataElements.FAMILY_NAME, familyName), + ClaimToBeIssued(EuPidDataElements.GIVEN_NAME, givenName), + ClaimToBeIssued(EuPidDataElements.BIRTH_DATE, birthDate), + ClaimToBeIssued(EuPidDataElements.ISSUANCE_DATE, issuanceDate), + ClaimToBeIssued(EuPidDataElements.EXPIRY_DATE, expirationDate), + ClaimToBeIssued(EuPidDataElements.ISSUING_COUNTRY, issuingCountry), + ClaimToBeIssued(EuPidDataElements.ISSUING_AUTHORITY, issuingCountry), ) return when (representation) { SD_JWT -> CredentialToBeIssued.VcSd( claims = claims, expiration = expiration, - scheme = EuPidScheme as SdJwtCredentialScheme, + scheme = credentialScheme as SdJwtCredentialScheme, subjectPublicKey = subjectPublicKey, userInfo = DummyUserProvider.user, sdAlgorithm = supportedSdAlgorithms.random() @@ -203,7 +203,7 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { ) ), expiration = expiration, - scheme = EuPidScheme as VcJwtCredentialScheme, + scheme = credentialScheme as VcJwtCredentialScheme, subjectPublicKey = subjectPublicKey, userInfo = DummyUserProvider.user, ) @@ -213,7 +213,7 @@ object DummyOAuth2IssuerCredentialDataProvider : CredentialDataProviderFun { issuerSignedItem(claim.name, claim.value, index.toUInt()) }, expiration = expiration, - scheme = EuPidScheme as IsoMdocCredentialScheme, + scheme = credentialScheme as IsoMdocCredentialScheme, subjectPublicKey = subjectPublicKey, userInfo = DummyUserProvider.user, ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpCombinedProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpCombinedProtocolTest.kt index 6afaf6fff..bcb51b4d0 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpCombinedProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpCombinedProtocolTest.kt @@ -22,8 +22,8 @@ import at.asitplus.openid.dcql.DCQLSdJwtCredentialQuery import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme.SdJwtAttributes +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.RequestOptionsCredential import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert @@ -36,13 +36,14 @@ import at.asitplus.wallet.lib.agent.SubjectCredentialStore import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.toStoreCredentialInput import at.asitplus.wallet.lib.data.AtomicAttribute2023 +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* import at.asitplus.wallet.lib.data.CredentialPresentation import at.asitplus.wallet.lib.data.CredentialPresentationRequest import at.asitplus.wallet.lib.data.CredentialScheme import at.asitplus.wallet.lib.data.rfc3986.toUri -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme +import at.asitplus.wallet.mdl.MDL_DOCTYPE import com.benasher44.uuid.uuid4 import io.kotest.assertions.AssertionErrorBuilder.Companion.fail import io.kotest.assertions.throwables.shouldNotThrowAny @@ -54,6 +55,7 @@ import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -61,26 +63,31 @@ private fun AuthenticationRequestParameters.serialize(): String = joseCompliantS val OpenId4VpCombinedProtocolTest by matrixSuite { - fixture { - object { - val holderKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert() - val verifierKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert() - val clientId: String = "https://example.com/rp/${uuid4()}" - val holderAgent: Holder = HolderAgent(holderKeyMaterial) - val holderOid4vp: OpenId4VpHolder = OpenId4VpHolder( - keyMaterial = holderKeyMaterial, - holder = holderAgent, - randomSource = RandomSource.Default, - ) - val verifierOid4vp: OpenId4VpVerifier = OpenId4VpVerifier( - keyMaterial = verifierKeyMaterial, - clientIdScheme = ClientIdScheme.RedirectUri(clientId), - ) + fixture({ + runBlocking { + val mdlScheme = AttributeIndex.resolveIdentifier(MDL_DOCTYPE, ISO_MDOC) + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + object { + val mdlScheme = mdlScheme + val euPidSdJwtScheme = euPidSdJwtScheme + val holderKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert() + val verifierKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert() + val clientId: String = "https://example.com/rp/${uuid4()}" + val holderAgent: Holder = HolderAgent(holderKeyMaterial) + val holderOid4vp: OpenId4VpHolder = OpenId4VpHolder( + keyMaterial = holderKeyMaterial, + holder = holderAgent, + randomSource = RandomSource.Default, + ) + val verifierOid4vp: OpenId4VpVerifier = OpenId4VpVerifier( + keyMaterial = verifierKeyMaterial, + clientIdScheme = ClientIdScheme.RedirectUri(clientId), + ) + } } - } - { - + }) - { test("plain jwt: if not available despite others with correct format or correct attribute, but not both") { - it.holderAgent.storeJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) @@ -100,7 +107,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { test("plain jwt: if available despite others") { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) @@ -136,7 +143,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { test("plain jwt: send plain if no cryptographic holder binding") { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) @@ -170,7 +177,8 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { val vcFreshnessSummary = it.verifierOid4vp.validateAuthnResponse(authnResponse.url).getOrThrow() .vpTokenValidationResult.shouldNotBeNull().getOrThrow() .shouldBeInstanceOf() - .credentialQueryResponseValidations.values.shouldBeSingleton().first().shouldBeSingleton().first() + .credentialQueryResponseValidations.values.shouldBeSingleton().first().shouldBeSingleton() + .first() .getOrThrow() .shouldBeInstanceOf() .vc @@ -180,7 +188,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { test("sd-jwt presex: if not available despite others with correct format or correct attribute, but not both") { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = it.verifierOid4vp.createAuthnRequest( @@ -200,7 +208,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { test("sd-jwt presex: if available despite others with correct format or correct attribute, but not both") { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = it.verifierOid4vp.createAuthnRequest( @@ -225,7 +233,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { test("sd-jwt dcql: if not available despite others with correct format or correct attribute, but not both") { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = it.verifierOid4vp.prepareAuthnRequest( @@ -246,7 +254,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { test("sd-jwt dcql: if available despite others with correct format or correct attribute, but not both") { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, it.mdlScheme) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val authnRequest = it.verifierOid4vp.createAuthnRequest( @@ -266,7 +274,8 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { it.verifierOid4vp.validateAuthnResponse(authnResponse.url).getOrThrow() .vpTokenValidationResult.shouldNotBeNull().getOrThrow() .shouldBeInstanceOf() - .credentialQueryResponseValidations.values.shouldBeSingleton().first().shouldBeSingleton().first() + .credentialQueryResponseValidations.values.shouldBeSingleton().first().shouldBeSingleton() + .first() .getOrThrow() .shouldBeInstanceOf() .verifiableCredentialSdJwt.verifiableCredentialType shouldBe ConstantIndex.AtomicAttribute2023.sdJwtType @@ -275,7 +284,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { "mdoc presex: if not available despite others with correct format or correct attribute, but not both" { it -> it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val authnRequest = it.verifierOid4vp.createAuthnRequest( requestOptions = OpenId4VpRequestOptions( @@ -295,7 +304,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val authnRequest = it.verifierOid4vp.createAuthnRequest( requestOptions = OpenId4VpRequestOptions( @@ -319,7 +328,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { "mdoc dcql: if not available despite others with correct format or correct attribute, but not both" { it -> it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val authnRequest = it.verifierOid4vp.createAuthnRequest( requestOptions = OpenId4VpRequestOptions( @@ -340,7 +349,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val authnRequest = it.verifierOid4vp.createAuthnRequest( requestOptions = OpenId4VpRequestOptions( @@ -366,7 +375,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val dcqlRequest = CredentialPresentationRequestBuilder( credentials = setOf( @@ -406,7 +415,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val originalDcqlRequest = CredentialPresentationRequestBuilder( credentials = setOf( @@ -425,10 +434,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { val otherDcqlQuery = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential( - ConstantIndex.AtomicAttribute2023, - SD_JWT - ) + RequestOptionsCredential(ConstantIndex.AtomicAttribute2023, SD_JWT) ), ).toDCQLRequest().shouldNotBeNull().dcqlQuery @@ -436,17 +442,9 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { credentials = DCQLCredentialQueryList( originalDcqlRequest.dcqlQuery.credentials.zip(otherDcqlQuery.credentials) { good, bad -> when (bad) { - is DCQLIsoMdocCredentialQuery -> bad.copy( - id = good.id - ) - - is DCQLJwtVcCredentialQuery -> bad.copy( - id = good.id - ) - - is DCQLSdJwtCredentialQuery -> bad.copy( - id = good.id - ) + is DCQLIsoMdocCredentialQuery -> bad.copy(id = good.id) + is DCQLJwtVcCredentialQuery -> bad.copy(id = good.id) + is DCQLSdJwtCredentialQuery -> bad.copy(id = good.id) } }.toNonEmptyList() ) @@ -480,7 +478,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) it.holderAgent.storeIsoCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val dcqlRequest = CredentialPresentationRequestBuilder( credentials = setOf( @@ -518,14 +516,14 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { "presentation of multiple credentials with different formats in one request/response" { it -> it.holderAgent.storeJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) - it.holderAgent.storeIsoCredential(it.holderKeyMaterial, MobileDrivingLicenceScheme) + it.holderAgent.storeIsoCredential(it.holderKeyMaterial, it.mdlScheme) val authnRequest = it.verifierOid4vp.createAuthnRequest( requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( RequestOptionsCredential(ConstantIndex.AtomicAttribute2023, PLAIN_JWT), - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC) + RequestOptionsCredential(it.mdlScheme, ISO_MDOC) ) ).toPresentationExchangeRequest(), ), @@ -540,7 +538,7 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { } "presentation of multiple SD-JWT credentials in one request/response" { it -> - it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, EuPidSdJwtScheme) + it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, it.euPidSdJwtScheme) it.holderAgent.storeSdJwtCredential(it.holderKeyMaterial, ConstantIndex.AtomicAttribute2023) val requestOptions = OpenId4VpRequestOptions( @@ -552,11 +550,11 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { requestedAttributes = setOf(ConstantIndex.AtomicAttribute2023.CLAIM_DATE_OF_BIRTH), ), RequestOptionsCredential( - credentialScheme = EuPidSdJwtScheme, + credentialScheme = it.euPidSdJwtScheme, representation = SD_JWT, requestedAttributes = setOf( - SdJwtAttributes.FAMILY_NAME, - SdJwtAttributes.GIVEN_NAME + EuPidSdJwtDataElements.FAMILY_NAME, + EuPidSdJwtDataElements.GIVEN_NAME ), ) ) @@ -578,9 +576,9 @@ val OpenId4VpCombinedProtocolTest by matrixSuite { result.shouldBeInstanceOf() result.reconstructedJsonObject.entries.shouldNotBeEmpty() when (result.verifiableCredentialSdJwt.verifiableCredentialType) { - EuPidSdJwtScheme.sdJwtType -> { - result.reconstructedJsonObject[SdJwtAttributes.FAMILY_NAME].shouldNotBeNull() - result.reconstructedJsonObject[SdJwtAttributes.GIVEN_NAME].shouldNotBeNull() + EU_PID_SD_JWT_VCT -> { + result.reconstructedJsonObject[EuPidSdJwtDataElements.FAMILY_NAME].shouldNotBeNull() + result.reconstructedJsonObject[EuPidSdJwtDataElements.GIVEN_NAME].shouldNotBeNull() } ConstantIndex.AtomicAttribute2023.sdJwtType -> { @@ -605,11 +603,8 @@ private suspend fun Holder.storeJwtCredential( identifier = "https://issuer.example.com/".toUri(), randomSource = RandomSource.Default ).issueCredential( - DummyCredentialDataProvider.getCredential( - holderKeyMaterial.publicKey, - credentialScheme, - PLAIN_JWT, - ).getOrThrow() + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, credentialScheme, PLAIN_JWT) + .getOrThrow() ).getOrThrow().toStoreCredentialInput() ) } @@ -624,11 +619,8 @@ private suspend fun Holder.storeSdJwtCredential( identifier = "https://issuer.example.com/".toUri(), randomSource = RandomSource.Default ).issueCredential( - DummyCredentialDataProvider.getCredential( - holderKeyMaterial.publicKey, - credentialScheme, - SD_JWT, - ).getOrThrow() + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, credentialScheme, SD_JWT) + .getOrThrow() ).getOrThrow().toStoreCredentialInput() ) } @@ -642,10 +634,6 @@ private suspend fun Holder.storeIsoCredential( identifier = "https://issuer.example.com/".toUri(), randomSource = RandomSource.Default ).issueCredential( - DummyCredentialDataProvider.getCredential( - holderKeyMaterial.publicKey, - credentialScheme, - ISO_MDOC, - ).getOrThrow() + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, credentialScheme, ISO_MDOC).getOrThrow() ).getOrThrow().toStoreCredentialInput() ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpEuRefInteropTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpEuRefInteropTest.kt index b0b3d180b..666d3e544 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpEuRefInteropTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpEuRefInteropTest.kt @@ -16,7 +16,7 @@ import at.asitplus.signum.indispensable.pki.SubjectAltNameImplicitTags import at.asitplus.signum.indispensable.pki.X509CertificateExtension import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT import at.asitplus.wallet.lib.RequestOptionsCredential import at.asitplus.wallet.lib.agent.EphemeralKeyWithSelfSignedCert import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert @@ -25,6 +25,7 @@ import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_FAMILY_NAME import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME @@ -59,7 +60,7 @@ val OpenId4VpEuRefInteropTest by matrixSuite { issuerAgent.issueCredential( DummyCredentialDataProvider.getCredential( holderKeyMaterial.publicKey, - EuPidSdJwtScheme, + AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT), SD_JWT, ).getOrThrow() ).getOrThrow().toStoreCredentialInput() diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpIsoProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpIsoProtocolTest.kt index 27976d804..33b65e76d 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpIsoProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpIsoProtocolTest.kt @@ -13,15 +13,16 @@ import at.asitplus.wallet.lib.agent.RandomSource import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.Verifier.VerifyPresentationResult.SuccessIso import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023.CLAIM_GIVEN_NAME import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC import at.asitplus.wallet.lib.data.rfc3986.toUri import at.asitplus.wallet.lib.oidvci.formUrlEncode import at.asitplus.wallet.lib.openid.OpenId4VpVerifier.CreationOptions.Query +import at.asitplus.wallet.mdl.MDL_DOCTYPE import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.FAMILY_NAME import at.asitplus.wallet.mdl.MobileDrivingLicenceDataElements.GIVEN_NAME -import at.asitplus.wallet.mdl.MobileDrivingLicenceScheme import com.benasher44.uuid.uuid4 import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldBeSingleton @@ -32,65 +33,66 @@ import io.kotest.matchers.maps.shouldHaveSize import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.string.shouldNotBeEmpty import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject val OpenId4VpIsoProtocolTest by matrixSuite { - fixture({ kotlinx.coroutines.runBlocking { - val material = EphemeralKeyWithoutCert() - val agent = HolderAgent(material).also { - val issuerAgent = IssuerAgent( - keyMaterial = EphemeralKeyWithSelfSignedCert(), - identifier = "https://issuer.example.com/".toUri(), - randomSource = RandomSource.Default - ) - it.storeCredential( - issuerAgent.issueCredential( - DummyCredentialDataProvider.getCredential( - material.publicKey, - MobileDrivingLicenceScheme, - ISO_MDOC, - ).getOrThrow() - ).getOrThrow().toStoreCredentialInput() - ) - it.storeCredential( - issuerAgent.issueCredential( - DummyCredentialDataProvider.getCredential( - material.publicKey, - AtomicAttribute2023, - ISO_MDOC, - ).getOrThrow() - ).getOrThrow().toStoreCredentialInput() - ) - } + fixture({ + runBlocking { + val mdlScheme = AttributeIndex.resolveIdentifier(MDL_DOCTYPE, ISO_MDOC) + val material = EphemeralKeyWithoutCert() + val agent = HolderAgent(material).also { + val issuerAgent = IssuerAgent( + keyMaterial = EphemeralKeyWithSelfSignedCert(), + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default + ) + it.storeCredential( + issuerAgent.issueCredential( + DummyCredentialDataProvider.getCredential( + material.publicKey, mdlScheme, ISO_MDOC, + ).getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ) + it.storeCredential( + issuerAgent.issueCredential( + DummyCredentialDataProvider.getCredential( + material.publicKey, AtomicAttribute2023, ISO_MDOC, + ).getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ) + } - object { - val holderKeyMaterial = material - val verifierKeyMaterial = EphemeralKeyWithoutCert() - //println("this is the key:\n" + (verifierKeyMaterial as EphemeralKeyWithoutCert).key.exportPrivateKey().getOrThrow().encodeToDer().encodeToString(Base64Strict)) + object { + val mdlScheme = mdlScheme + val holderKeyMaterial = material + val verifierKeyMaterial = EphemeralKeyWithoutCert() + //println("this is the key:\n" + (verifierKeyMaterial as EphemeralKeyWithoutCert).key.exportPrivateKey().getOrThrow().encodeToDer().encodeToString(Base64Strict)) - val clientId = "https://example.com/rp/${uuid4()}" - val walletUrl = "https://example.com/wallet/${uuid4()}" - val holderAgent = agent - val verifierOid4vp = OpenId4VpVerifier( - keyMaterial = verifierKeyMaterial, - decryptionKeyMaterial = verifierKeyMaterial, - clientIdScheme = ClientIdScheme.RedirectUri(clientId), - //nonceService = FixedNonceService(), - ) - val holderOid4vp = OpenId4VpHolder( - holder = holderAgent, - keyMaterial = holderKeyMaterial, - randomSource = RandomSource.Default, - ) + val clientId = "https://example.com/rp/${uuid4()}" + val walletUrl = "https://example.com/wallet/${uuid4()}" + val holderAgent = agent + val verifierOid4vp = OpenId4VpVerifier( + keyMaterial = verifierKeyMaterial, + decryptionKeyMaterial = verifierKeyMaterial, + clientIdScheme = ClientIdScheme.RedirectUri(clientId), + //nonceService = FixedNonceService(), + ) + val holderOid4vp = OpenId4VpHolder( + holder = holderAgent, + keyMaterial = holderKeyMaterial, + randomSource = RandomSource.Default, + ) + } } - } }) - { + }) - { "test with Fragment for mDL" { val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(GIVEN_NAME)) + RequestOptionsCredential(it.mdlScheme, ISO_MDOC, setOf(GIVEN_NAME)) ) ).toDCQLRequest(), ) @@ -143,7 +145,7 @@ val OpenId4VpIsoProtocolTest by matrixSuite { val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(requestedClaim)) + RequestOptionsCredential(it.mdlScheme, ISO_MDOC, setOf(requestedClaim)) ) ).toDCQLRequest(), ) @@ -170,7 +172,7 @@ val OpenId4VpIsoProtocolTest by matrixSuite { val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(requestedClaim)) + RequestOptionsCredential(it.mdlScheme, ISO_MDOC, setOf(requestedClaim)) ), ).toDCQLRequest(), responseMode = OpenIdConstants.ResponseMode.DirectPost, @@ -206,7 +208,7 @@ val OpenId4VpIsoProtocolTest by matrixSuite { val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(requestedClaim)) + RequestOptionsCredential(it.mdlScheme, ISO_MDOC, setOf(requestedClaim)) ), ).toDCQLRequest(), responseMode = OpenIdConstants.ResponseMode.DirectPostJwt, @@ -237,23 +239,23 @@ val OpenId4VpIsoProtocolTest by matrixSuite { } } - "Selective Disclosure with two documents in presentation exchange" { + "Selective Disclosure with two documents in presentation exchange" { scope -> val mdlFamilyName = FAMILY_NAME val atomicGivenName = CLAIM_GIVEN_NAME val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(mdlFamilyName)), + RequestOptionsCredential(scope.mdlScheme, ISO_MDOC, setOf(mdlFamilyName)), RequestOptionsCredential(AtomicAttribute2023, ISO_MDOC, setOf(atomicGivenName)) ), ).toPresentationExchangeRequest(), responseMode = OpenIdConstants.ResponseMode.DirectPost, responseUrl = "https://example.com/response", ) - val authnRequest = it.verifierOid4vp.createAuthnRequest(requestOptions, Query(it.walletUrl)) + val authnRequest = scope.verifierOid4vp.createAuthnRequest(requestOptions, Query(scope.walletUrl)) .getOrThrow().url - val authnResponse = it.holderOid4vp.createAuthnResponse(authnRequest).getOrThrow() + val authnResponse = scope.holderOid4vp.createAuthnResponse(authnRequest).getOrThrow() .shouldBeInstanceOf().apply { // make sure there are two device responses for two credentials returned in the presentation params["vp_token"].shouldNotBeEmpty().shouldNotBeNull().apply { @@ -263,7 +265,7 @@ val OpenId4VpIsoProtocolTest by matrixSuite { } } - it.verifierOid4vp.validateAuthnResponse(authnResponse.params.formUrlEncode()).getOrThrow() + scope.verifierOid4vp.validateAuthnResponse(authnResponse.params.formUrlEncode()).getOrThrow() .vpTokenValidationResult.shouldNotBeNull().getOrThrow() .shouldBeInstanceOf() .inputDescriptorResponseValidations.values.flatMap { @@ -271,28 +273,28 @@ val OpenId4VpIsoProtocolTest by matrixSuite { }.apply { first { it.mso.docType == AtomicAttribute2023.isoDocType } .validItems.shouldHaveSingleElement { it.elementIdentifier == atomicGivenName } - first { it.mso.docType == MobileDrivingLicenceScheme.isoDocType } + first { it.mso.docType == scope.mdlScheme.isoDocType } .validItems.shouldHaveSingleElement { it.elementIdentifier == mdlFamilyName } } } - "Selective Disclosure with two documents in DCQL" { + "Selective Disclosure with two documents in DCQL" { scope -> val mdlFamilyName = FAMILY_NAME val atomicGivenName = CLAIM_GIVEN_NAME val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(mdlFamilyName)), + RequestOptionsCredential(scope.mdlScheme, ISO_MDOC, setOf(mdlFamilyName)), RequestOptionsCredential(AtomicAttribute2023, ISO_MDOC, setOf(atomicGivenName)) ), ).toDCQLRequest(), responseMode = OpenIdConstants.ResponseMode.DirectPost, responseUrl = "https://example.com/response", ) - val authnRequest = it.verifierOid4vp.createAuthnRequest(requestOptions, Query(it.walletUrl)) + val authnRequest = scope.verifierOid4vp.createAuthnRequest(requestOptions, Query(scope.walletUrl)) .getOrThrow().url - val authnResponse = it.holderOid4vp.createAuthnResponse(authnRequest).getOrThrow() + val authnResponse = scope.holderOid4vp.createAuthnResponse(authnRequest).getOrThrow() .shouldBeInstanceOf().apply { // make sure there are two device responses for two credentials returned in the presentation params["vp_token"].shouldNotBeEmpty().shouldNotBeNull().apply { @@ -302,14 +304,14 @@ val OpenId4VpIsoProtocolTest by matrixSuite { } } - it.verifierOid4vp.validateAuthnResponse(authnResponse.params.formUrlEncode()).getOrThrow() + scope.verifierOid4vp.validateAuthnResponse(authnResponse.params.formUrlEncode()).getOrThrow() .vpTokenValidationResult.shouldNotBeNull().getOrThrow() .shouldBeInstanceOf() .credentialQueryResponseValidations.shouldHaveSize(2).apply { values.first { it.first().getOrThrow().hasDocType(AtomicAttribute2023.isoDocType) }.first() .getOrThrow().shouldBeInstanceOf().documents.first() .validItems.shouldHaveSingleElement { it.elementIdentifier == atomicGivenName } - values.first { it.first().getOrThrow().hasDocType(MobileDrivingLicenceScheme.isoDocType) }.first() + values.first { it.first().getOrThrow().hasDocType(scope.mdlScheme.isoDocType!!) }.first() .getOrThrow().shouldBeInstanceOf().documents.first() .validItems.shouldHaveSingleElement { it.elementIdentifier == mdlFamilyName } } @@ -319,7 +321,7 @@ val OpenId4VpIsoProtocolTest by matrixSuite { val requestOptions = OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(MobileDrivingLicenceScheme, ISO_MDOC, setOf(FAMILY_NAME)) + RequestOptionsCredential(it.mdlScheme, ISO_MDOC, setOf(FAMILY_NAME)) ) ).toDCQLRequest(), ) diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpSdJwtProtocolTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpSdJwtProtocolTest.kt index 7a97ebcbc..edc1925bd 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpSdJwtProtocolTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/openid/OpenId4VpSdJwtProtocolTest.kt @@ -2,7 +2,8 @@ package at.asitplus.wallet.lib.openid import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.RequestOptionsCredential import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.HolderAgent @@ -10,6 +11,7 @@ import at.asitplus.wallet.lib.agent.IssuerAgent import at.asitplus.wallet.lib.agent.RandomSource import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.AtomicAttribute2023 import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.rfc3986.toUri @@ -19,51 +21,55 @@ import io.kotest.matchers.collections.shouldBeSingleton import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.string.shouldContain import io.kotest.matchers.types.shouldBeInstanceOf +import kotlinx.coroutines.runBlocking val OpenId4VpSdJwtProtocolTest by matrixSuite { - fixture({ kotlinx.coroutines.runBlocking { - val holderKeyMaterial = EphemeralKeyWithoutCert() - val holderAgent = HolderAgent(holderKeyMaterial).also { - it.storeCredential( - IssuerAgent( - identifier = "https://issuer.example.com/".toUri(), - randomSource = RandomSource.Default - ).issueCredential( - DummyCredentialDataProvider.getCredential( - holderKeyMaterial.publicKey, - AtomicAttribute2023, - SD_JWT - ) - .getOrThrow() - ).getOrThrow().toStoreCredentialInput() - ) - it.storeCredential( - IssuerAgent( - identifier = "https://issuer.example.com/".toUri(), - randomSource = RandomSource.Default - ).issueCredential( - DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, EuPidSdJwtScheme, SD_JWT) - .getOrThrow() - ).getOrThrow().toStoreCredentialInput() - ) - } - object { - - val verifierKeyMaterial = EphemeralKeyWithoutCert() - val clientId = "https://example.com/rp/${uuid4()}" - val walletUrl = "https://example.com/wallet/${uuid4()}" + fixture({ + runBlocking { + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + val holderKeyMaterial = EphemeralKeyWithoutCert() + val holderAgent = HolderAgent(holderKeyMaterial).also { + it.storeCredential( + IssuerAgent( + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default + ).issueCredential( + DummyCredentialDataProvider.getCredential( + holderKeyMaterial.publicKey, + AtomicAttribute2023, + SD_JWT + ) + .getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ) + it.storeCredential( + IssuerAgent( + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default + ).issueCredential( + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, euPidSdJwtScheme, SD_JWT) + .getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ) + } + object { + val euPidSdJwtScheme = euPidSdJwtScheme + val verifierKeyMaterial = EphemeralKeyWithoutCert() + val clientId = "https://example.com/rp/${uuid4()}" + val walletUrl = "https://example.com/wallet/${uuid4()}" - val holderOid4vp = OpenId4VpHolder( - holder = holderAgent, - randomSource = RandomSource.Default, - ) - val verifierOid4vp = OpenId4VpVerifier( - keyMaterial = verifierKeyMaterial, - clientIdScheme = ClientIdScheme.RedirectUri(clientId) - ) + val holderOid4vp = OpenId4VpHolder( + holder = holderAgent, + randomSource = RandomSource.Default, + ) + val verifierOid4vp = OpenId4VpVerifier( + keyMaterial = verifierKeyMaterial, + clientIdScheme = ClientIdScheme.RedirectUri(clientId) + ) + } } - } }) - { + }) - { "Selective Disclosure with custom credential" { val requestedClaim = AtomicAttribute2023.CLAIM_GIVEN_NAME @@ -96,16 +102,16 @@ val OpenId4VpSdJwtProtocolTest by matrixSuite { "Selective Disclosure with EU PID credential" { val requestedClaims = setOf( - EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME, - EuPidSdJwtScheme.SdJwtAttributes.GIVEN_NAME, - EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME_BIRTH, - EuPidSdJwtScheme.SdJwtAttributes.GIVEN_NAME_BIRTH, + EuPidSdJwtDataElements.FAMILY_NAME, + EuPidSdJwtDataElements.GIVEN_NAME, + EuPidSdJwtDataElements.FAMILY_NAME_BIRTH, + EuPidSdJwtDataElements.GIVEN_NAME_BIRTH, ) val authnRequest = it.verifierOid4vp.createAuthnRequest( OpenId4VpRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( - RequestOptionsCredential(EuPidSdJwtScheme, SD_JWT, requestedClaims) + RequestOptionsCredential(it.euPidSdJwtScheme, SD_JWT, requestedClaims) ) ).toDCQLRequest(), ), diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/KeyBindingTests.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/KeyBindingTests.kt index 5a43b8a6a..2e56d22ef 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/KeyBindingTests.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/KeyBindingTests.kt @@ -10,7 +10,7 @@ import at.asitplus.signum.indispensable.Digest import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert import at.asitplus.wallet.lib.agent.Holder import at.asitplus.wallet.lib.agent.HolderAgent @@ -21,6 +21,7 @@ import at.asitplus.wallet.lib.agent.ValidatorSdJwt import at.asitplus.wallet.lib.agent.Verifier import at.asitplus.wallet.lib.agent.VerifierAgent import at.asitplus.wallet.lib.agent.toStoreCredentialInput +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.SdJwtConstants import at.asitplus.wallet.lib.data.digest @@ -47,6 +48,7 @@ import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.http.* import io.ktor.utils.io.charsets.* import io.ktor.utils.io.core.* +import kotlinx.coroutines.runBlocking private fun malignTransactionData(): List = listOf( QCertCreationAcceptance( @@ -59,135 +61,138 @@ private fun malignTransactionData(): List = listOf( val KeyBindingTests by matrixSuite { - fixture({ kotlinx.coroutines.runBlocking { - val holderKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert() - val holderAgent: Holder = HolderAgent(holderKeyMaterial).also { agent -> - agent.storeCredential( - IssuerAgent( - identifier = "https://issuer.example.com/".toUri(), - randomSource = RandomSource.Default - ).issueCredential( - DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, EuPidSdJwtScheme, SD_JWT) - .getOrThrow() - ).getOrThrow().toStoreCredentialInput() - ) - } + fixture({ + runBlocking { + val euPidSdJwtScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT) + val holderKeyMaterial: KeyMaterial = EphemeralKeyWithoutCert() + val holderAgent: Holder = HolderAgent(holderKeyMaterial).also { agent -> + agent.storeCredential( + IssuerAgent( + identifier = "https://issuer.example.com/".toUri(), + randomSource = RandomSource.Default + ).issueCredential( + DummyCredentialDataProvider.getCredential(holderKeyMaterial.publicKey, euPidSdJwtScheme, SD_JWT) + .getOrThrow() + ).getOrThrow().toStoreCredentialInput() + ) + } - object { - val holderOid4vp = OpenId4VpHolder( - holder = holderAgent, - randomSource = RandomSource.Default - ) - val externalMapStore = DefaultMapStore() + object { + val holderOid4vp = OpenId4VpHolder( + holder = holderAgent, + randomSource = RandomSource.Default + ) + val externalMapStore = DefaultMapStore() - val walletUrl = "https://example.com/wallet/${uuid4()}" - val clientId = "https://example.com/rp/${uuid4()}" - val cibaWalletTransactionData = """ - eyJ0eXBlIjoicWNlcnRfY3JlYXRpb25fYWNjZXB0YW5jZSIsImNyZWRlbnRpYWxfaWRzIjpbIjYwNzUxMGE5LWM5NTctNDA5NS05MDZkLWY5 - OWZkMDA2YzRhZSJdLCJRQ190ZXJtc19jb25kaXRpb25zX3VyaSI6Imh0dHBzOi8vd3d3LmQtdHJ1c3QubmV0L2RlL2FnYiIsIlFDX2hhc2gi - OiI3UXptNUVqdXpYS1NIRmxjME9IOVBQOXFVYUgtVkJsMmFHTmJ3WWoxb09BIiwiUUNfaGFzaEFsZ29yaXRobU9JRCI6IjIuMTYuODQwLjEu - MTAxLjMuNC4yLjEiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdfQ - """.trimIndent().replace("\n", "") + val walletUrl = "https://example.com/wallet/${uuid4()}" + val clientId = "https://example.com/rp/${uuid4()}" + val cibaWalletTransactionData = """ + eyJ0eXBlIjoicWNlcnRfY3JlYXRpb25fYWNjZXB0YW5jZSIsImNyZWRlbnRpYWxfaWRzIjpbIjYwNzUxMGE5LWM5NTctNDA5NS05MDZkLWY5 + OWZkMDA2YzRhZSJdLCJRQ190ZXJtc19jb25kaXRpb25zX3VyaSI6Imh0dHBzOi8vd3d3LmQtdHJ1c3QubmV0L2RlL2FnYiIsIlFDX2hhc2gi + OiI3UXptNUVqdXpYS1NIRmxjME9IOVBQOXFVYUgtVkJsMmFHTmJ3WWoxb09BIiwiUUNfaGFzaEFsZ29yaXRobU9JRCI6IjIuMTYuODQwLjEu + MTAxLjMuNC4yLjEiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdfQ + """.trimIndent().replace("\n", "") - val cibaWalletTestVector = """ - { - "response_type": "vp_token", - "client_id": "redirect_uri:$clientId", - "scope": "", - "state": "iTGlKl-AJxmncWPbXHp2xy58bNy18wqZ4TR9EzhBl2R4ulxeTEO0VyWYR2qMDpCDV5JWeOxecTqcEJ61bFKrUg", - "nonce": "f90d0982-52f4-4a1c-8525-bdf1d33c232b", - "client_metadata": { - "jwks_uri": "https://cibawallet.local-ip.medicmobile.org/wallet/jarm/iTGlKl-AJxmncWPbXHp2xy58bNy18wqZ4TR9EzhBl2R4ulxeTEO0VyWYR2qMDpCDV5JWeOxecTqcEJ61bFKrUg/jwks.json", - "id_token_signed_response_alg": "RS256", - "authorization_encrypted_response_alg": "ECDH-ES", - "authorization_encrypted_response_enc": "A128CBC-HS256", - "id_token_encrypted_response_alg": "RSA-OAEP-256", - "id_token_encrypted_response_enc": "A128CBC-HS256", - "subject_syntax_types_supported": [ - "urn:ietf:params:oauth:jwk-thumbprint" - ], - "vp_formats": { - "dc+sd-jwt": { - "sd-jwt_alg_values": [ - "ES256" - ], - "kb-jwt_alg_values": [ - "ES256" - ] - }, - "dc+sd-jwt": { - "sd-jwt_alg_values": [ - "ES256" - ], - "kb-jwt_alg_values": [ - "ES256" - ] - }, - "mso_mdoc": { - "alg": [ - "ES256" - ] - } - } - }, - "presentation_definition": { - "id": "4c7038cf-bd1e-47c0-8f70-eaf9d62c6fae", - "name": "Cibazmaj", - "purpose": "where su pare", - "input_descriptors": [ - { - "id": "607510a9-c957-4095-906d-f99fd006c4ae", - "name": "niko kao", - "purpose": "hajduk iz splita", - "format": { - "dc+sd-jwt": { - "sd-jwt_alg_values": [ - "ES256" - ], - "kb-jwt_alg_values": [ - "ES256" - ] - } + val cibaWalletTestVector = """ + { + "response_type": "vp_token", + "client_id": "redirect_uri:$clientId", + "scope": "", + "state": "iTGlKl-AJxmncWPbXHp2xy58bNy18wqZ4TR9EzhBl2R4ulxeTEO0VyWYR2qMDpCDV5JWeOxecTqcEJ61bFKrUg", + "nonce": "f90d0982-52f4-4a1c-8525-bdf1d33c232b", + "client_metadata": { + "jwks_uri": "https://cibawallet.local-ip.medicmobile.org/wallet/jarm/iTGlKl-AJxmncWPbXHp2xy58bNy18wqZ4TR9EzhBl2R4ulxeTEO0VyWYR2qMDpCDV5JWeOxecTqcEJ61bFKrUg/jwks.json", + "id_token_signed_response_alg": "RS256", + "authorization_encrypted_response_alg": "ECDH-ES", + "authorization_encrypted_response_enc": "A128CBC-HS256", + "id_token_encrypted_response_alg": "RSA-OAEP-256", + "id_token_encrypted_response_enc": "A128CBC-HS256", + "subject_syntax_types_supported": [ + "urn:ietf:params:oauth:jwk-thumbprint" + ], + "vp_formats": { + "dc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256" + ], + "kb-jwt_alg_values": [ + "ES256" + ] }, - "constraints": { - "fields": [ - { - "path": [ - "${'$'}.family_name" - ] - }, - { - "path": [ - "${'$'}.given_name" - ] - }, - { - "path": [ - "${'$'}.vct" + "dc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256" + ], + "kb-jwt_alg_values": [ + "ES256" + ] + }, + "mso_mdoc": { + "alg": [ + "ES256" + ] + } + } + }, + "presentation_definition": { + "id": "4c7038cf-bd1e-47c0-8f70-eaf9d62c6fae", + "name": "Cibazmaj", + "purpose": "where su pare", + "input_descriptors": [ + { + "id": "607510a9-c957-4095-906d-f99fd006c4ae", + "name": "niko kao", + "purpose": "hajduk iz splita", + "format": { + "dc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256" ], - "filter": { - "type": "string", - "enum": [ - "urn:eudi:pid:1" + "kb-jwt_alg_values": [ + "ES256" + ] + } + }, + "constraints": { + "fields": [ + { + "path": [ + "${'$'}.family_name" + ] + }, + { + "path": [ + "${'$'}.given_name" ] + }, + { + "path": [ + "${'$'}.vct" + ], + "filter": { + "type": "string", + "enum": [ + "urn:eudi:pid:1" + ] + } } - } - ] + ] + } } - } + ] + }, + "response_mode": "direct_post", + "response_uri": "https://cibawallet.local-ip.medicmobile.org/wallet/direct_post/iTGlKl-AJxmncWPbXHp2xy58bNy18wqZ4TR9EzhBl2R4ulxeTEO0VyWYR2qMDpCDV5JWeOxecTqcEJ61bFKrUg", + "aud": "https://self-issued.me/v2", + "iat": 1744198186, + "transaction_data": [ + "eyJ0eXBlIjoicWNlcnRfY3JlYXRpb25fYWNjZXB0YW5jZSIsImNyZWRlbnRpYWxfaWRzIjpbIjYwNzUxMGE5LWM5NTctNDA5NS05MDZkLWY5OWZkMDA2YzRhZSJdLCJRQ190ZXJtc19jb25kaXRpb25zX3VyaSI6Imh0dHBzOi8vd3d3LmQtdHJ1c3QubmV0L2RlL2FnYiIsIlFDX2hhc2giOiI3UXptNUVqdXpYS1NIRmxjME9IOVBQOXFVYUgtVkJsMmFHTmJ3WWoxb09BIiwiUUNfaGFzaEFsZ29yaXRobU9JRCI6IjIuMTYuODQwLjEuMTAxLjMuNC4yLjEiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdfQ" ] - }, - "response_mode": "direct_post", - "response_uri": "https://cibawallet.local-ip.medicmobile.org/wallet/direct_post/iTGlKl-AJxmncWPbXHp2xy58bNy18wqZ4TR9EzhBl2R4ulxeTEO0VyWYR2qMDpCDV5JWeOxecTqcEJ61bFKrUg", - "aud": "https://self-issued.me/v2", - "iat": 1744198186, - "transaction_data": [ - "eyJ0eXBlIjoicWNlcnRfY3JlYXRpb25fYWNjZXB0YW5jZSIsImNyZWRlbnRpYWxfaWRzIjpbIjYwNzUxMGE5LWM5NTctNDA5NS05MDZkLWY5OWZkMDA2YzRhZSJdLCJRQ190ZXJtc19jb25kaXRpb25zX3VyaSI6Imh0dHBzOi8vd3d3LmQtdHJ1c3QubmV0L2RlL2FnYiIsIlFDX2hhc2giOiI3UXptNUVqdXpYS1NIRmxjME9IOVBQOXFVYUgtVkJsMmFHTmJ3WWoxb09BIiwiUUNfaGFzaEFsZ29yaXRobU9JRCI6IjIuMTYuODQwLjEuMTAxLjMuNC4yLjEiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdfQ" - ] - } - """.trimIndent() + } + """.trimIndent() + } } - } }) - { + }) - { "KB-JWT contains transaction data" { val verifierOid4Vp = OpenId4VpVerifier( diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/RqesRequestOptionsTest.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/RqesRequestOptionsTest.kt index ede2cd8f3..0354bb6b4 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/RqesRequestOptionsTest.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/RqesRequestOptionsTest.kt @@ -9,11 +9,12 @@ import at.asitplus.openid.TransactionData import at.asitplus.signum.indispensable.Digest import at.asitplus.testballoon.matrix.fixture import at.asitplus.testballoon.matrix.matrixSuite -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme.SdJwtAttributes.FAMILY_NAME -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme.SdJwtAttributes.GIVEN_NAME +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements.FAMILY_NAME +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements.GIVEN_NAME import at.asitplus.wallet.lib.RequestOptionsCredential import at.asitplus.wallet.lib.agent.EphemeralKeyWithoutCert +import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT import at.asitplus.wallet.lib.data.SdJwtConstants import at.asitplus.wallet.lib.data.toTransactionData @@ -51,7 +52,7 @@ val RqesRequestOptionsTest by matrixSuite { } } -internal fun buildRequestOptions( +internal suspend fun buildRequestOptions( responseMode: OpenIdConstants.ResponseMode = OpenIdConstants.ResponseMode.Fragment, transactionDataHashAlgorithms: Set?, ): OpenId4VpRequestOptions = uuid4().toString().let { credentialId -> @@ -63,7 +64,7 @@ internal fun buildRequestOptions( presentationRequest = CredentialPresentationRequestBuilder( credentials = setOf( RequestOptionsCredential( - credentialScheme = EuPidSdJwtScheme, + credentialScheme = AttributeIndex.resolveIdentifier(EU_PID_SD_JWT_VCT, SD_JWT), representation = SD_JWT, requestedAttributes = setOf(FAMILY_NAME, GIVEN_NAME), id = credentialId diff --git a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/helper/DummyCredentialDataProvider.kt b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/helper/DummyCredentialDataProvider.kt index 1d0f1d73a..966ba3912 100644 --- a/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/helper/DummyCredentialDataProvider.kt +++ b/vck-openid/src/commonTest/kotlin/at/asitplus/wallet/lib/rqes/helper/DummyCredentialDataProvider.kt @@ -5,7 +5,8 @@ import at.asitplus.catching import at.asitplus.openid.OidcUserInfo import at.asitplus.openid.OidcUserInfoExtended import at.asitplus.signum.indispensable.CryptoPublicKey -import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtScheme +import at.asitplus.wallet.eupidsdjwt.EU_PID_SD_JWT_VCT +import at.asitplus.wallet.eupidsdjwt.EuPidSdJwtDataElements import at.asitplus.wallet.lib.agent.ClaimToBeIssued import at.asitplus.wallet.lib.agent.CredentialToBeIssued import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT @@ -28,7 +29,7 @@ object DummyCredentialDataProvider { ): KmmResult = catching { val issuance = Clock.System.now() val expiration = issuance + defaultLifetime - if (credentialScheme != EuPidSdJwtScheme) { + if (credentialScheme.sdJwtType != EU_PID_SD_JWT_VCT) { throw NotImplementedError() } if (representation != SD_JWT) { @@ -41,7 +42,7 @@ object DummyCredentialDataProvider { val issuingCountry = "AT" val nationality = "FR" CredentialToBeIssued.VcSd( - claims = with(EuPidSdJwtScheme.SdJwtAttributes) { + claims = with(EuPidSdJwtDataElements) { listOfNotNull( ClaimToBeIssued(FAMILY_NAME, familyName), ClaimToBeIssued(FAMILY_NAME_BIRTH, familyName), diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidCredential.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidCredential.kt new file mode 100644 index 000000000..f0f1630e9 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidCredential.kt @@ -0,0 +1,306 @@ +package at.asitplus.wallet.eupid + +import at.asitplus.wallet.lib.data.LocalDateOrInstant +import io.matthewnelson.encoding.base64.Base64 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.LocalDate +import kotlinx.datetime.serializers.LocalDateIso8601Serializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + + +/** + * PID scheme according to + * [PID Rulebook](https://github.com/eu-digital-identity-wallet/eudi-doc-attestation-rulebooks-catalog/blob/main/rulebooks/pid/pid-rulebook.md) + * from 2025-10-02. + */ +@Serializable +@SerialName("EuPid2023") +data class EuPidCredential( + + @SerialName("id") + val id: String, + + /** Current last name(s) or surname(s) of the user to whom the person identification data relates. */ + @SerialName(EuPidDataElements.FAMILY_NAME) + val familyName: String, + + /** Current first name(s), including middle name(s) where applicable, of the user to whom the person identification data relates. */ + @SerialName(EuPidDataElements.GIVEN_NAME) + val givenName: String, + + /** Day, month, and year on which the user to whom the person identification data relates was born. */ + @SerialName(EuPidDataElements.BIRTH_DATE) + @Serializable(with = LocalDateIso8601Serializer::class) + val birthDate: LocalDate, + + /** Last name(s) or surname(s) of the User to whom the person identification data relates at the time of birth. */ + @SerialName(EuPidDataElements.FAMILY_NAME_BIRTH) + val familyNameBirth: String? = null, + + /** First name(s), including middle name(s), of the User to whom the person identification data relates at the time of birth. */ + @SerialName(EuPidDataElements.GIVEN_NAME_BIRTH) + val givenNameBirth: String? = null, + + /** See [PlaceOfBirth]. At least one of the values shall be present. */ + @SerialName(EuPidDataElements.PLACE_OF_BIRTH) + val placeOfBirth: PlaceOfBirth? = null, + + /** + * The full address of the place where the user to whom the person identification data relates currently resides or + * can be contacted (street name, house number, city etc.). + */ + @SerialName(EuPidDataElements.RESIDENT_ADDRESS) + val residentAddress: String? = null, + + /** The country where the user to whom the person identification data relates currently resides, as an alpha-2 + * country code as specified in ISO 3166-1. */ + @SerialName(EuPidDataElements.RESIDENT_COUNTRY) + val residentCountry: String? = null, + + /** The state, province, district, or local area where the user to whom the person identification data relates + * currently resides. */ + @SerialName(EuPidDataElements.RESIDENT_STATE) + val residentState: String? = null, + + /** The municipality, city, town, or village where the user to whom the person identification data relates currently + * resides. */ + @SerialName(EuPidDataElements.RESIDENT_CITY) + val residentCity: String? = null, + + /** The postal code of the place where the user to whom the person identification data relates currently resides. */ + @SerialName(EuPidDataElements.RESIDENT_POSTAL_CODE) + val residentPostalCode: String? = null, + + /** The name of the street where the user to whom the person identification data relates currently resides. */ + @SerialName(EuPidDataElements.RESIDENT_STREET) + val residentStreet: String? = null, + + /** The house number where the user to whom the person identification data relates currently resides, including any + * affix or suffix. */ + @SerialName(EuPidDataElements.RESIDENT_HOUSE_NUMBER) + val residentHouseNumber: String? = null, + + /** Values shall be one of the following: 0 = not known; 1 = male; 2 = female; 3 = other; 4 = inter; 5 = diverse; + * 6 = open; 9 = not applicable. For values 0, 1, 2 and 9, ISO/IEC 5218 applies. */ + @SerialName(EuPidDataElements.SEX) + val sex: UInt? = null, + + /** One or more alpha-2 country codes as specified in ISO 3166-1, representing the nationality of the user to whom + * the person identification data relates. + * Before ARF 1.5.0, this has been a single string, now it may contain more than one entry. */ + @SerialName(EuPidDataElements.NATIONALITY) + val nationalityElement: JsonElement? = null, + + /** Date (and if possible time) when the person identification data was issued and/or the administrative validity period of the person identification data began. */ + @SerialName(EuPidDataElements.ISSUANCE_DATE) + val issuanceDate: LocalDateOrInstant, + + /** Date (and if possible time) when the person identification data will expire. */ + @SerialName(EuPidDataElements.EXPIRY_DATE) + val expiryDate: LocalDateOrInstant, + + /** + * Name of the administrative authority that has issued this PID instance, or + * the ISO 3166 Alpha-2 country code of the respective Member State if + * there is no separate authority authorized to issue PIDs. + */ + @SerialName(EuPidDataElements.ISSUING_AUTHORITY) + val issuingAuthority: String, + + /** A number for the PID, assigned by the PID Provider. */ + @SerialName(EuPidDataElements.DOCUMENT_NUMBER) + val documentNumber: String? = null, + + /** Alpha-2 country code, as defined in ISO 3166-1, of the PID Provider's country or territory. */ + @SerialName(EuPidDataElements.ISSUING_COUNTRY) + val issuingCountry: String, + + /** + * Country subdivision code of the jurisdiction that issued the PID, as + * defined in ISO 3166-2:2020, Clause 8. The first part of the code SHALL + * be the same as the value for [issuingCountry]. + */ + @SerialName(EuPidDataElements.ISSUING_JURISDICTION) + val issuingJurisdiction: String? = null, + + /** + * A value assigned to the natural person that is unique among all personal administrative numbers issued by the + * provider of person identification data. Where Member States opt to include this attribute, they shall + * describe in their electronic identification schemes under which the person identification data is issued, + * the policy that they apply to the values of this attribute, including, where applicable, specific conditions + * for the processing of this value. + */ + @SerialName(EuPidDataElements.PERSONAL_ADMINISTRATIVE_NUMBER) + val personalAdministrativeNumber: String? = null, + + /** Facial image of the wallet user compliant with ISO 19794-5 or ISO 39794 specifications. */ + @SerialName(EuPidDataElements.PORTRAIT) + val portrait: ByteArray? = null, + + /** Electronic mail address of the user to whom the person identification data relates, in conformance with [RFC 5322]. */ + @SerialName(EuPidDataElements.EMAIL_ADDRESS) + val emailAddress: String? = null, + + /** Mobile telephone number of the User to whom the person identification data relates, starting with the '+' + * symbol as the international code prefix and the country code, followed by numbers only. */ + @SerialName(EuPidDataElements.MOBILE_PHONE_NUMBER) + val mobilePhoneNumber: String? = null, + + /** This attribute indicates at least the URL at which a machine-readable version of the trust anchor to be used for verifying the PID can be found or looked up */ + @SerialName(EuPidDataElements.TRUST_ANCHOR) + val trustAnchor: String? = null, + + /** The location of validity status information on the person identification data where the providers of person identification data revoke person identification data. */ + @SerialName(EuPidDataElements.LOCATION_STATUS) + val locationStatus: String? = null, +) { + + /** Values shall be one of the following: 0 = not known; 1 = male; 2 = female; 3 = other; 4 = inter; 5 = diverse; + * 6 = open; 9 = not applicable. For values 0, 1, 2 and 9, ISO/IEC 5218 applies. */ + val sexAsEnum: IsoIec5218Gender? by lazy { + sex?.let { IsoIec5218Gender.entries.firstOrNull { it.code == sex } } + } + + /** One or more alpha-2 country codes as specified in ISO 3166-1, representing the nationality of the user to whom + * the person identification data relates.*/ + val nationality: String? by lazy { + nationalityElement?.let { runCatching { it.jsonPrimitive.content }.getOrNull() } + } + + /** One or more alpha-2 country codes as specified in ISO 3166-1, representing the nationality of the user to whom + * the person identification data relates.*/ + val nationalities: Collection? by lazy { + nationalityElement?.let { runCatching { it.jsonArray.map { it.jsonPrimitive.content } }.getOrNull() } + } + + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as EuPidCredential + + if (id != other.id) return false + if (familyName != other.familyName) return false + if (givenName != other.givenName) return false + if (birthDate != other.birthDate) return false + if (familyNameBirth != other.familyNameBirth) return false + if (givenNameBirth != other.givenNameBirth) return false + if (placeOfBirth != other.placeOfBirth) return false + if (residentAddress != other.residentAddress) return false + if (residentCountry != other.residentCountry) return false + if (residentState != other.residentState) return false + if (residentCity != other.residentCity) return false + if (residentPostalCode != other.residentPostalCode) return false + if (residentStreet != other.residentStreet) return false + if (residentHouseNumber != other.residentHouseNumber) return false + if (sex != other.sex) return false + if (nationalityElement != other.nationalityElement) return false + if (issuanceDate != other.issuanceDate) return false + if (expiryDate != other.expiryDate) return false + if (issuingAuthority != other.issuingAuthority) return false + if (documentNumber != other.documentNumber) return false + if (issuingCountry != other.issuingCountry) return false + if (issuingJurisdiction != other.issuingJurisdiction) return false + if (personalAdministrativeNumber != other.personalAdministrativeNumber) return false + if (!portrait.contentEquals(other.portrait)) return false + if (emailAddress != other.emailAddress) return false + if (mobilePhoneNumber != other.mobilePhoneNumber) return false + if (trustAnchor != other.trustAnchor) return false + if (locationStatus != other.locationStatus) return false + if (sexAsEnum != other.sexAsEnum) return false + if (nationality != other.nationality) return false + if (nationalities != other.nationalities) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + familyName.hashCode() + result = 31 * result + givenName.hashCode() + result = 31 * result + birthDate.hashCode() + result = 31 * result + (familyNameBirth?.hashCode() ?: 0) + result = 31 * result + (givenNameBirth?.hashCode() ?: 0) + result = 31 * result + (placeOfBirth?.hashCode() ?: 0) + result = 31 * result + (residentAddress?.hashCode() ?: 0) + result = 31 * result + (residentCountry?.hashCode() ?: 0) + result = 31 * result + (residentState?.hashCode() ?: 0) + result = 31 * result + (residentCity?.hashCode() ?: 0) + result = 31 * result + (residentPostalCode?.hashCode() ?: 0) + result = 31 * result + (residentStreet?.hashCode() ?: 0) + result = 31 * result + (residentHouseNumber?.hashCode() ?: 0) + result = 31 * result + (sex?.hashCode() ?: 0) + result = 31 * result + (nationalityElement?.hashCode() ?: 0) + result = 31 * result + issuanceDate.hashCode() + result = 31 * result + expiryDate.hashCode() + result = 31 * result + issuingAuthority.hashCode() + result = 31 * result + (documentNumber?.hashCode() ?: 0) + result = 31 * result + issuingCountry.hashCode() + result = 31 * result + (issuingJurisdiction?.hashCode() ?: 0) + result = 31 * result + (personalAdministrativeNumber?.hashCode() ?: 0) + result = 31 * result + (portrait?.contentHashCode() ?: 0) + result = 31 * result + (emailAddress?.hashCode() ?: 0) + result = 31 * result + (mobilePhoneNumber?.hashCode() ?: 0) + result = 31 * result + (trustAnchor?.hashCode() ?: 0) + result = 31 * result + (locationStatus?.hashCode() ?: 0) + result = 31 * result + (sexAsEnum?.hashCode() ?: 0) + result = 31 * result + (nationality?.hashCode() ?: 0) + result = 31 * result + (nationalities?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "EuPidCredential(" + + "id='$id', " + + "familyName='$familyName', " + + "givenName='$givenName', " + + "birthDate=$birthDate, " + + "familyNameBirth=$familyNameBirth, " + + "givenNameBirth=$givenNameBirth, " + + "placeOfBirth=$placeOfBirth, " + + "residentAddress=$residentAddress, " + + "residentCountry=$residentCountry, " + + "residentState=$residentState, " + + "residentCity=$residentCity, " + + "residentPostalCode=$residentPostalCode, " + + "residentStreet=$residentStreet, " + + "residentHouseNumber=$residentHouseNumber, " + + "sex=$sex, " + + "nationality=$nationality, " + + "issuanceDate=$issuanceDate, " + + "expiryDate=$expiryDate, " + + "issuingAuthority='$issuingAuthority', " + + "documentNumber=$documentNumber, " + + "issuingCountry='$issuingCountry', " + + "issuingJurisdiction=$issuingJurisdiction, " + + "personalAdministrativeNumber=$personalAdministrativeNumber, " + + "portrait=${portrait?.encodeToString(Base64())}, " + + "emailAddress=$emailAddress, " + + "mobilePhoneNumber=$mobilePhoneNumber, " + + "trustAnchor=$trustAnchor, " + + "locationStatus=$locationStatus" + + ")" + } + +} + +/** At least one of the values shall be present. */ +@Serializable +data class PlaceOfBirth( + /** The country where the PID User was born, as an Alpha-2 country code as specified in ISO 3166-1. */ + @SerialName(EuPidDataElements.PlaceOfBirth.COUNTRY) + val country: String? = null, + + /** The state, province, district, or local area where the PID User was born. */ + @SerialName(EuPidDataElements.PlaceOfBirth.REGION) + val region: String? = null, + + /** The municipality, city, town, or village where the PID User was born. */ + @SerialName(EuPidDataElements.PlaceOfBirth.LOCALITY) + val locality: String? = null, +) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidDataElements.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidDataElements.kt new file mode 100644 index 000000000..3100f65e5 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidDataElements.kt @@ -0,0 +1,120 @@ +package at.asitplus.wallet.eupid + +object EuPidDataElements { + /** Current last name(s) or surname(s) of the user to whom the person identification data relates. */ + const val FAMILY_NAME = "family_name" + + /** Current first name(s), including middle name(s) where applicable, of the user to whom the person identification data relates. */ + const val GIVEN_NAME = "given_name" + + /** Day, month, and year on which the user to whom the person identification data relates was born. */ + const val BIRTH_DATE = "birth_date" + + /** Last name(s) or surname(s) of the User to whom the person identification data relates at the time of birth. */ + const val FAMILY_NAME_BIRTH = "family_name_birth" + + /** First name(s), including middle name(s), of the User to whom the person identification data relates at the time of birth. */ + const val GIVEN_NAME_BIRTH = "given_name_birth" + + /** Place of birth, see [PlaceOfBirth] */ + const val PLACE_OF_BIRTH = "place_of_birth" + + /** + * The full address of the place where the user to whom the person identification data relates currently + * resides or can be contacted (street name, house number, city etc.). + */ + const val RESIDENT_ADDRESS = "resident_address" + + /** The country where the user to whom the person identification data relates currently resides, as an alpha-2 + * country code as specified in ISO 3166-1. */ + const val RESIDENT_COUNTRY = "resident_country" + + /** The state, province, district, or local area where the user to whom the person identification data relates + * currently resides. */ + const val RESIDENT_STATE = "resident_state" + + /** The municipality, city, town, or village where the PID User currently resides. */ + const val RESIDENT_CITY = "resident_city" + + /** The postal code of the place where the user to whom the person identification data relates currently resides. */ + const val RESIDENT_POSTAL_CODE = "resident_postal_code" + + /** The name of the street where the user to whom the person identification data relates currently resides. */ + const val RESIDENT_STREET = "resident_street" + + /** The house number where the user to whom the person identification data relates currently resides, including + * any affix or suffix. */ + const val RESIDENT_HOUSE_NUMBER = "resident_house_number" + + /** Values shall be one of the following: 0 = not known; 1 = male; 2 = female; 3 = other; 4 = inter; + * 5 = diverse; 6 = open; 9 = not applicable. For values 0, 1, 2 and 9, ISO/IEC 5218 applies. */ + const val SEX = "sex" + + /** One or more alpha-2 country codes as specified in ISO 3166-1, representing the nationality of the user to + * whom the person identification data relates. */ + const val NATIONALITY = "nationality" + + /** Date (and if possible time) when the person identification data was issued and/or the administrative validity period of the person identification data began. */ + const val ISSUANCE_DATE = "issuance_date" + + /** Date (and if possible time) when the person identification data will expire. */ + const val EXPIRY_DATE = "expiry_date" + + /** + * Name of the administrative authority that has issued this PID instance, or + * the ISO 3166 Alpha-2 country code of the respective Member State if + * there is no separate authority authorized to issue PIDs. + */ + const val ISSUING_AUTHORITY = "issuing_authority" + + /** A number for the PID, assigned by the PID Provider. */ + const val DOCUMENT_NUMBER = "document_number" + + /** Alpha-2 country code, as defined in ISO 3166-1, of the PID Provider's country or territory. */ + const val ISSUING_COUNTRY = "issuing_country" + + /** + * Country subdivision code of the jurisdiction that issued the PID, as + * defined in ISO 3166-2:2020, Clause 8. The first part of the code SHALL + * be the same as the value for [ISSUING_COUNTRY]. + */ + const val ISSUING_JURISDICTION = "issuing_jurisdiction" + + /** + * A value assigned to the natural person that is unique among all personal administrative numbers issued by the + * provider of person identification data. Where Member States opt to include this attribute, they shall + * describe in their electronic identification schemes under which the person identification data is issued, + * the policy that they apply to the values of this attribute, including, where applicable, specific conditions + * for the processing of this value. + */ + const val PERSONAL_ADMINISTRATIVE_NUMBER = "personal_administrative_number" + + /** Facial image of the wallet user compliant with ISO 19794-5 or ISO 39794 specifications. */ + const val PORTRAIT = "portrait" + + /** Electronic mail address of the user to whom the person identification data relates, in conformance with [RFC 5322]. */ + const val EMAIL_ADDRESS = "email_address" + + /** Mobile telephone number of the User to whom the person identification data relates, starting with the '+' + * symbol as the international code prefix and the country code, followed by numbers only. */ + const val MOBILE_PHONE_NUMBER = "mobile_phone_number" + + /** This attribute indicates at least the URL at which a machine-readable version of the trust anchor to be used for verifying the PID can be found or looked up */ + const val TRUST_ANCHOR = "trust_anchor" + + /** The location of validity status information on the person identification data where the providers of person identification data revoke person identification data. */ + const val LOCATION_STATUS = "location_status" + + + object PlaceOfBirth { + /** The country where the PID User was born, as an Alpha-2 country code as specified in ISO 3166-1. */ + const val COUNTRY = "country" + + /** The name of a state, province, district, or local area where the PID User was born. */ + const val REGION = "region" + + /** The name of a municipality, city, town, or village where the PID User was born. */ + const val LOCALITY = "locality" + } + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt new file mode 100644 index 000000000..bd7c1e6e6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/EuPidScheme.kt @@ -0,0 +1,96 @@ +package at.asitplus.wallet.eupid + +import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer +import at.asitplus.wallet.eupidsdjwt.PlaceOfBirthSdJwt +import at.asitplus.wallet.lib.InternalHelpers.mandatoryElementsIso +import at.asitplus.wallet.lib.InternalHelpers.optionalElementsIso +import at.asitplus.wallet.lib.IsoNamespaceToElementIdentifierToItemValueSerializerMap +import at.asitplus.wallet.lib.JsonValueEncoder +import at.asitplus.wallet.lib.data.LocalDateOrInstantSerializer +import at.asitplus.wallet.sdjwt.CredentialFormatEnum +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationList +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataVckExtensions +import at.asitplus.wallet.sdjwt.SdJwtVcType +import kotlinx.datetime.LocalDate +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.SetSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.encodeToJsonElement + +@Deprecated( + "Replace with type metadata document", + level = DeprecationLevel.ERROR +) +object EuPidScheme + +/** `eu.europa.ec.eudi.pid.1` */ +const val EU_PID_DOCTYPE: String = "eu.europa.ec.eudi.pid.1" + +val EuPidMetadataDocument: Pair = + SdJwtVcType("EuPid2023") to SdJwtTypeMetadataDocument( + originalBytes = ByteArray(0), + definition = SdJwtTypeMetadataDefinition( + vct = SdJwtVcType("EuPid2023"), + claims = SdJwtTypeMetadataClaimInformationList( + mandatoryElementsIso( + EU_PID_DOCTYPE, // yep, namespace is the same as docType + EuPidDataElements.FAMILY_NAME, + EuPidDataElements.GIVEN_NAME, + EuPidDataElements.BIRTH_DATE, + EuPidDataElements.NATIONALITY, + EuPidDataElements.EXPIRY_DATE, + EuPidDataElements.ISSUING_AUTHORITY, + EuPidDataElements.ISSUING_COUNTRY, + ) + optionalElementsIso( + EU_PID_DOCTYPE, // yep, namespace is the same as docType + EuPidDataElements.FAMILY_NAME_BIRTH, + EuPidDataElements.GIVEN_NAME_BIRTH, + EuPidDataElements.PLACE_OF_BIRTH, + EuPidDataElements.RESIDENT_ADDRESS, + EuPidDataElements.RESIDENT_COUNTRY, + EuPidDataElements.RESIDENT_STATE, + EuPidDataElements.RESIDENT_CITY, + EuPidDataElements.RESIDENT_POSTAL_CODE, + EuPidDataElements.RESIDENT_STREET, + EuPidDataElements.RESIDENT_HOUSE_NUMBER, + EuPidDataElements.SEX, + EuPidDataElements.ISSUANCE_DATE, + EuPidDataElements.DOCUMENT_NUMBER, + EuPidDataElements.ISSUING_JURISDICTION, + EuPidDataElements.PERSONAL_ADMINISTRATIVE_NUMBER, + EuPidDataElements.PORTRAIT, + EuPidDataElements.EMAIL_ADDRESS, + EuPidDataElements.MOBILE_PHONE_NUMBER, + EuPidDataElements.TRUST_ANCHOR, + EuPidDataElements.LOCATION_STATUS, + ) + ), + vckExtensions = SdJwtTypeMetadataVckExtensions( + format = CredentialFormatEnum.MSO_MDOC, + isoDocType = EU_PID_DOCTYPE, + isoNamespace = EU_PID_DOCTYPE // yep, namespace is the same as docType + ) + ) + ) +val EuPidItemValueSerializerMap: IsoNamespaceToElementIdentifierToItemValueSerializerMap = mapOf( + EU_PID_DOCTYPE to mapOf( + EuPidDataElements.BIRTH_DATE to LocalDate.serializer(), + EuPidDataElements.SEX to UInt.serializer(), + EuPidDataElements.NATIONALITY to SetSerializer(String.serializer()), + EuPidDataElements.ISSUANCE_DATE to LocalDateOrInstantSerializer, + EuPidDataElements.EXPIRY_DATE to LocalDateOrInstantSerializer, + EuPidDataElements.PORTRAIT to ByteArraySerializer(), + EuPidDataElements.PLACE_OF_BIRTH to PlaceOfBirth.serializer(), + ) +) + +val EuPidJsonValueEncoder: JsonValueEncoder = { + when (it) { + is IsoIec5218Gender -> joseCompliantSerializer.encodeToJsonElement(it) + is PlaceOfBirthSdJwt -> joseCompliantSerializer.encodeToJsonElement(it) + is PlaceOfBirth -> joseCompliantSerializer.encodeToJsonElement(it) + else -> null + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/IsoIec5218Gender.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/IsoIec5218Gender.kt new file mode 100644 index 000000000..48a1318e5 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupid/IsoIec5218Gender.kt @@ -0,0 +1,43 @@ +package at.asitplus.wallet.eupid + + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Values according to + * [PID Rulebook](https://github.com/eu-digital-identity-wallet/eudi-doc-attestation-rulebooks-catalog/blob/main/rulebooks/pid/pid-rulebook.md) + * from 2025-10-02. + */ +@Serializable(with = IsoIec5218GenderSerializer::class) +enum class IsoIec5218Gender(val code: UInt) { + NOT_KNOWN(0u), + MALE(1u), + FEMALE(2u), + OTHER(3u), + INTER(4u), + DIVERSE(5u), + OPEN(6u), + NOT_APPLICABLE(9u) +} + +object IsoIec5218GenderSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("IsoIec5218Gender", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): IsoIec5218Gender { + val decoded = decoder.decodeInt().toUInt() + return IsoIec5218Gender.entries.first { it.code == decoded } + } + + override fun serialize(encoder: Encoder, value: IsoIec5218Gender) { + encoder.encodeInt(value.code.toInt()) + } + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidCredentialSdJwt.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidCredentialSdJwt.kt new file mode 100644 index 000000000..4e8d395f8 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidCredentialSdJwt.kt @@ -0,0 +1,248 @@ +package at.asitplus.wallet.eupidsdjwt + +import at.asitplus.wallet.eupid.IsoIec5218Gender +import at.asitplus.wallet.lib.data.LocalDateOrInstant +import io.matthewnelson.encoding.base64.Base64 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.LocalDate +import kotlinx.datetime.serializers.LocalDateIso8601Serializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + + +/** + * PID scheme according to + * [PID Rulebook](https://github.com/eu-digital-identity-wallet/eudi-doc-attestation-rulebooks-catalog/blob/main/rulebooks/pid/pid-rulebook.md) + * from 2025-10-02. + */ +@Serializable +data class EuPidCredentialSdJwt( + /** Current last name(s) or surname(s) of the user to whom the person identification data relates. */ + @SerialName(EuPidSdJwtDataElements.FAMILY_NAME) + val familyName: String, + + /** Current first name(s), including middle name(s), of the PID User. */ + @SerialName(EuPidSdJwtDataElements.GIVEN_NAME) + val givenName: String, + + /** Day, month, and year on which the user to whom the person identification data relates was born. */ + @SerialName(EuPidSdJwtDataElements.BIRTH_DATE) + @Serializable(with = LocalDateIso8601Serializer::class) + val birthDate: LocalDate, + + /** Last name(s) or surname(s) of the User to whom the person identification data relates at the time of birth. */ + @SerialName(EuPidSdJwtDataElements.FAMILY_NAME_BIRTH) + val familyNameBirth: String? = null, + + /** First name(s), including middle name(s), of the User to whom the person identification data relates at the time of birth. */ + @SerialName(EuPidSdJwtDataElements.GIVEN_NAME_BIRTH) + val givenNameBirth: String? = null, + + /** Place of birth. */ + @SerialName(EuPidSdJwtDataElements.PREFIX_PLACE_OF_BIRTH) + val placeOfBirth: PlaceOfBirthSdJwt, + + /** Address. */ + @SerialName(EuPidSdJwtDataElements.PREFIX_ADDRESS) + val address: AddressSdJwt? = null, + + /** See [IsoIec5218Gender]. */ + @SerialName(EuPidSdJwtDataElements.SEX) + val sex: IsoIec5218Gender? = null, + + /** Array of Alpha-2 country code as specified in ISO 3166-1, representing the nationality of the PID User. */ + @SerialName(EuPidSdJwtDataElements.NATIONALITIES) + val nationalities: Set? = null, + + /** Date (and if possible time) when the person identification data was issued and/or the administrative validity period of the person identification data began. */ + @SerialName(EuPidSdJwtDataElements.ISSUANCE_DATE) + val issuanceDate: LocalDateOrInstant? = null, + + /** Date (and if possible time) when the person identification data will expire. */ + @SerialName(EuPidSdJwtDataElements.EXPIRY_DATE) + val expiryDate: LocalDateOrInstant, + + /** + * Name of the administrative authority that has issued this PID instance, or + * the ISO 3166 Alpha-2 country code of the respective Member State if + * there is no separate authority authorized to issue PIDs. + */ + @SerialName(EuPidSdJwtDataElements.ISSUING_AUTHORITY) + val issuingAuthority: String, + + /** A number for the PID, assigned by the PID Provider. */ + @SerialName(EuPidSdJwtDataElements.DOCUMENT_NUMBER) + val documentNumber: String? = null, + + /** Alpha-2 country code, as defined in ISO 3166-1, of the PID Provider's country or territory. */ + @SerialName(EuPidSdJwtDataElements.ISSUING_COUNTRY) + val issuingCountry: String, + + /** + * Country subdivision code of the jurisdiction that issued the PID, as + * defined in ISO 3166-2:2020, Clause 8. The first part of the code SHALL + * be the same as the value for [issuingCountry]. + */ + @SerialName(EuPidSdJwtDataElements.ISSUING_JURISDICTION) + val issuingJurisdiction: String? = null, + + /** + * A value assigned to the natural person that is unique among all personal administrative numbers issued by the + * provider of person identification data. Where Member States opt to include this attribute, they shall + * describe in their electronic identification schemes under which the person identification data is issued, + * the policy that they apply to the values of this attribute, including, where applicable, specific conditions + * for the processing of this value. + */ + @SerialName(EuPidSdJwtDataElements.PERSONAL_ADMINISTRATIVE_NUMBER) + val personalAdministrativeNumber: String? = null, + + /** Facial image of the wallet user compliant with ISO 19794-5 or ISO 39794 specifications. */ + @SerialName(EuPidSdJwtDataElements.PORTRAIT) + val portrait: ByteArray? = null, + + /** Electronic mail address of the user to whom the person identification data relates, in conformance with [RFC 5322]. */ + @SerialName(EuPidSdJwtDataElements.EMAIL) + val email: String? = null, + + /** Mobile telephone number of the User to whom the person identification data relates, starting with the '+' + * symbol as the international code prefix and the country code, followed by numbers only. */ + @SerialName(EuPidSdJwtDataElements.PHONE_NUMBER) + val phoneNumber: String? = null, + + /** This attribute indicates at least the URL at which a machine-readable version of the trust anchor to be used for verifying the PID can be found or looked up */ + @SerialName(EuPidSdJwtDataElements.TRUST_ANCHOR) + val trustAnchor: String? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as EuPidCredentialSdJwt + + if (familyName != other.familyName) return false + if (givenName != other.givenName) return false + if (birthDate != other.birthDate) return false + if (familyNameBirth != other.familyNameBirth) return false + if (givenNameBirth != other.givenNameBirth) return false + if (placeOfBirth != other.placeOfBirth) return false + if (address != other.address) return false + if (sex != other.sex) return false + if (nationalities != other.nationalities) return false + if (issuanceDate != other.issuanceDate) return false + if (expiryDate != other.expiryDate) return false + if (issuingAuthority != other.issuingAuthority) return false + if (documentNumber != other.documentNumber) return false + if (issuingCountry != other.issuingCountry) return false + if (issuingJurisdiction != other.issuingJurisdiction) return false + if (personalAdministrativeNumber != other.personalAdministrativeNumber) return false + if (!portrait.contentEquals(other.portrait)) return false + if (email != other.email) return false + if (phoneNumber != other.phoneNumber) return false + if (trustAnchor != other.trustAnchor) return false + + return true + } + + override fun hashCode(): Int { + var result = familyName.hashCode() + result = 31 * result + givenName.hashCode() + result = 31 * result + birthDate.hashCode() + result = 31 * result + (familyNameBirth?.hashCode() ?: 0) + result = 31 * result + (givenNameBirth?.hashCode() ?: 0) + result = 31 * result + placeOfBirth.hashCode() + result = 31 * result + (address?.hashCode() ?: 0) + result = 31 * result + (sex?.hashCode() ?: 0) + result = 31 * result + (nationalities?.hashCode() ?: 0) + result = 31 * result + (issuanceDate?.hashCode() ?: 0) + result = 31 * result + expiryDate.hashCode() + result = 31 * result + issuingAuthority.hashCode() + result = 31 * result + (documentNumber?.hashCode() ?: 0) + result = 31 * result + issuingCountry.hashCode() + result = 31 * result + (issuingJurisdiction?.hashCode() ?: 0) + result = 31 * result + (personalAdministrativeNumber?.hashCode() ?: 0) + result = 31 * result + (portrait?.contentHashCode() ?: 0) + result = 31 * result + (email?.hashCode() ?: 0) + result = 31 * result + (phoneNumber?.hashCode() ?: 0) + result = 31 * result + (trustAnchor?.hashCode() ?: 0) + return result + } + + override fun toString(): String { + return "EuPidCredentialSdJwt(" + + "familyName='$familyName', " + + "givenName='$givenName', " + + "birthDate=$birthDate, " + + "familyNameBirth=$familyNameBirth, " + + "givenNameBirth=$givenNameBirth, " + + "placeOfBirth=$placeOfBirth, " + + "address=$address, " + + "gender=$sex, " + + "nationalities=$nationalities, " + + "issuanceDate=$issuanceDate, " + + "expiryDate=$expiryDate, " + + "issuingAuthority='$issuingAuthority', " + + "documentNumber=$documentNumber, " + + "issuingCountry='$issuingCountry', " + + "issuingJurisdiction=$issuingJurisdiction, " + + "personalAdministrativeNumber=$personalAdministrativeNumber, " + + "portrait=${portrait?.encodeToString(Base64())}, " + + "email=$email, " + + "phoneNumber=$phoneNumber, " + + "trustAnchor=$trustAnchor, " + + ")" + } + +} + +@Serializable +data class PlaceOfBirthSdJwt( + /** The country where the PID User was born, as an Alpha-2 country code as specified in ISO 3166-1. */ + @SerialName(EuPidSdJwtDataElements.PlaceOfBirth.COUNTRY) + val country: String? = null, + + /** The state, province, district, or local area where the PID User was born. */ + @SerialName(EuPidSdJwtDataElements.PlaceOfBirth.REGION) + val region: String? = null, + + /** The municipality, city, town, or village where the PID User was born. */ + @SerialName(EuPidSdJwtDataElements.PlaceOfBirth.LOCALITY) + val locality: String? = null, +) + +@Serializable +data class AddressSdJwt( + /** + * The full address of the place where the user to whom the person identification data relates currently resides + * or can be contacted (street name, house number, city etc.). + */ + @SerialName(EuPidSdJwtDataElements.Address.FORMATTED) + val formatted: String? = null, + + /** The country where the user to whom the person identification data relates currently resides, as an alpha-2 + * country code as specified in ISO 3166-1. */ + @SerialName(EuPidSdJwtDataElements.Address.COUNTRY) + val country: String? = null, + + /** The state, province, district, or local area where the user to whom the person identification data relates + * currently resides. */ + @SerialName(EuPidSdJwtDataElements.Address.REGION) + val region: String? = null, + + /** The municipality, city, town, or village where the user to whom the person identification data relates currently + * resides. */ + @SerialName(EuPidSdJwtDataElements.Address.LOCALITY) + val locality: String? = null, + + /** The postal code of the place where the user to whom the person identification data relates currently resides. */ + @SerialName(EuPidSdJwtDataElements.Address.POSTAL_CODE) + val postalCode: String? = null, + + /** The name of the street where the user to whom the person identification data relates currently resides. */ + @SerialName(EuPidSdJwtDataElements.Address.STREET) + val street: String? = null, + + /** The house number where the user to whom the person identification data relates currently resides, including any + * affix or suffix. */ + @SerialName(EuPidSdJwtDataElements.Address.HOUSE_NUMBER) + val houseNumber: String? = null, +) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtDataElements.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtDataElements.kt new file mode 100644 index 000000000..f17e4a877 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtDataElements.kt @@ -0,0 +1,159 @@ +package at.asitplus.wallet.eupidsdjwt + +object EuPidSdJwtDataElements { + /** Current last name(s) or surname(s) of the user to whom the person identification data relates. */ + const val FAMILY_NAME = "family_name" + + /** Current first name(s), including middle name(s) where applicable, of the user to whom the person identification data relates. */ + const val GIVEN_NAME = "given_name" + + /** Day, month, and year on which the user to whom the person identification data relates was born. */ + const val BIRTH_DATE = "birthdate" + + /** Last name(s) or surname(s) of the User to whom the person identification data relates at the time of birth. */ + const val FAMILY_NAME_BIRTH = "birth_family_name" + + /** First name(s), including middle name(s), of the User to whom the person identification data relates at the time of birth. */ + const val GIVEN_NAME_BIRTH = "birth_given_name" + + /** Place of birth prefix, see [PlaceOfBirth] */ + const val PREFIX_PLACE_OF_BIRTH = "place_of_birth" + + /** The country where the PID User was born, as an Alpha-2 country code as specified in ISO 3166-1. */ + const val PLACE_OF_BIRTH_COUNTRY = "$PREFIX_PLACE_OF_BIRTH.country" + + /** The state, province, district, or local area where the PID User was born. */ + const val PLACE_OF_BIRTH_REGION = "$PREFIX_PLACE_OF_BIRTH.region" + + /** The country as an alpha-2 country code as specified in ISO 3166-1, or the state, province, district, or + * local area or the municipality, city, town, or village where the user to whom the person identification + * data relates was born. */ + const val PLACE_OF_BIRTH_LOCALITY = "$PREFIX_PLACE_OF_BIRTH.locality" + + object PlaceOfBirth { + /** The country where the PID User was born, as an Alpha-2 country code as specified in ISO 3166-1. */ + const val COUNTRY = "country" + + /** The state, province, district, or local area where the PID User was born. */ + const val REGION = "region" + + /** The country as an alpha-2 country code as specified in ISO 3166-1, or the state, province, district, or + * local area or the municipality, city, town, or village where the user to whom the person identification + * data relates was born. */ + const val LOCALITY = "locality" + } + + /** Address prefix, see [Address] */ + const val PREFIX_ADDRESS = "address" + + /** + * The full address of the place where the PID User currently resides and/or can be contacted + * (street name, house number, city etc.). + */ + const val ADDRESS_FORMATTED = "$PREFIX_ADDRESS.formatted" + + /** The country where the PID User currently resides, as an Alpha-2 country code as specified in ISO 3166-1. */ + const val ADDRESS_COUNTRY = "$PREFIX_ADDRESS.country" + + /** The state, province, district, or local area where the PID User currently resides. */ + const val ADDRESS_REGION = "$PREFIX_ADDRESS.region" + + /** The municipality, city, town, or village where the PID User currently resides. */ + const val ADDRESS_LOCALITY = "$PREFIX_ADDRESS.locality" + + /** Postal code of the place where the PID User currently resides. */ + const val ADDRESS_POSTAL_CODE = "$PREFIX_ADDRESS.postal_code" + + /** The name of the street where the PID User currently resides. */ + const val ADDRESS_STREET = "$PREFIX_ADDRESS.street_address" + + /** The house number where the PID User currently resides, including any affix or suffix. */ + const val ADDRESS_HOUSE_NUMBER = "$PREFIX_ADDRESS.house_number" + + object Address { + /** + * The full address of the place where the user to whom the person identification data relates currently + * resides or can be contacted (street name, house number, city etc.). + */ + const val FORMATTED = "formatted" + + /** The country where the user to whom the person identification data relates currently resides, as an + * alpha-2 country code as specified in ISO 3166-1. */ + const val COUNTRY = "country" + + /** The state, province, district, or local area where the user to whom the person identification data + * relates currently resides. */ + const val REGION = "region" + + /** The municipality, city, town, or village where the user to whom the person identification data relates + * currently resides. */ + const val LOCALITY = "locality" + + /** The postal code of the place where the user to whom the person identification data relates currently + * resides. */ + const val POSTAL_CODE = "postal_code" + + /** The name of the street where the user to whom the person identification data relates currently resides. */ + const val STREET = "street_address" + + /** The house number where the user to whom the person identification data relates currently resides, + * including any affix or suffix. */ + const val HOUSE_NUMBER = "house_number" + } + + /** See [IsoIec5218Gender]. */ + const val SEX = "sex" + + /** One or more alpha-2 country codes as specified in ISO 3166-1, representing the nationality of the user to + * whom the person identification data relates. */ + const val NATIONALITIES = "nationalities" + + /** Date (and if possible time) when the person identification data was issued and/or the administrative validity period of the person identification data began. */ + const val ISSUANCE_DATE = "date_of_issuance" + + /** Date (and if possible time) when the person identification data will expire. */ + const val EXPIRY_DATE = "date_of_expiry" + + /** + * Name of the administrative authority that has issued this PID instance, or + * the ISO 3166 Alpha-2 country code of the respective Member State if + * there is no separate authority authorized to issue PIDs. + */ + const val ISSUING_AUTHORITY = "issuing_authority" + + /** A number for the PID, assigned by the PID Provider. */ + const val DOCUMENT_NUMBER = "document_number" + + /** Alpha-2 country code, as defined in ISO 3166-1, of the PID Provider's country or territory. */ + const val ISSUING_COUNTRY = "issuing_country" + + /** + * Country subdivision code of the jurisdiction that issued the PID, as + * defined in ISO 3166-2:2020, Clause 8. The first part of the code SHALL + * be the same as the value for [ISSUING_COUNTRY]. + */ + const val ISSUING_JURISDICTION = "issuing_jurisdiction" + + /** + * A value assigned to the natural person that is unique among all personal administrative numbers issued by the + * provider of person identification data. Where Member States opt to include this attribute, they shall + * describe in their electronic identification schemes under which the person identification data is issued, + * the policy that they apply to the values of this attribute, including, where applicable, specific conditions + * for the processing of this value. + */ + const val PERSONAL_ADMINISTRATIVE_NUMBER = "personal_administrative_number" + + /** Facial image of the wallet user compliant with ISO 19794-5 or ISO 39794 specifications. */ + const val PORTRAIT = "picture" + + /** Electronic mail address of the user to whom the person identification data relates, in conformance with [RFC 5322]. */ + const val EMAIL = "email" + + /** Mobile telephone number of the User to whom the person identification data relates, starting with the '+' + * symbol as the international code prefix and the country code, followed by numbers only. */ + const val PHONE_NUMBER = "phone_number" + + /** This attribute indicates at least the URL at which a machine-readable version of the trust anchor to be used for verifying the PID can be found or looked up */ + const val TRUST_ANCHOR = "trust_anchor" + +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt new file mode 100644 index 000000000..86ced3fa1 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/eupidsdjwt/EuPidSdJwtScheme.kt @@ -0,0 +1,58 @@ +package at.asitplus.wallet.eupidsdjwt + +import at.asitplus.wallet.lib.InternalHelpers.mandatoryElements +import at.asitplus.wallet.lib.InternalHelpers.optionalElements +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationList +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtVcType + +@Deprecated( + "Replace with type metadata document", + level = DeprecationLevel.ERROR +) +object EuPidSdJwtScheme + +/** `urn:eudi:pid:1` */ +const val EU_PID_SD_JWT_VCT: String = "urn:eudi:pid:1" + +val EuPidSdJwtMetadataDocument: Pair = + SdJwtVcType(EU_PID_SD_JWT_VCT) to SdJwtTypeMetadataDocument( + originalBytes = ByteArray(0), + definition = SdJwtTypeMetadataDefinition( + vct = SdJwtVcType(EU_PID_SD_JWT_VCT), + claims = SdJwtTypeMetadataClaimInformationList( + mandatoryElements( + EuPidSdJwtDataElements.FAMILY_NAME, + EuPidSdJwtDataElements.GIVEN_NAME, + EuPidSdJwtDataElements.BIRTH_DATE, + EuPidSdJwtDataElements.NATIONALITIES, + EuPidSdJwtDataElements.EXPIRY_DATE, + EuPidSdJwtDataElements.ISSUING_AUTHORITY, + EuPidSdJwtDataElements.ISSUING_COUNTRY, + ) + optionalElements( + EuPidSdJwtDataElements.PLACE_OF_BIRTH_COUNTRY, + EuPidSdJwtDataElements.PLACE_OF_BIRTH_REGION, + EuPidSdJwtDataElements.PLACE_OF_BIRTH_LOCALITY, + EuPidSdJwtDataElements.ADDRESS_FORMATTED, + EuPidSdJwtDataElements.ADDRESS_COUNTRY, + EuPidSdJwtDataElements.ADDRESS_REGION, + EuPidSdJwtDataElements.ADDRESS_LOCALITY, + EuPidSdJwtDataElements.ADDRESS_POSTAL_CODE, + EuPidSdJwtDataElements.ADDRESS_STREET, + EuPidSdJwtDataElements.ADDRESS_HOUSE_NUMBER, + EuPidSdJwtDataElements.FAMILY_NAME_BIRTH, + EuPidSdJwtDataElements.GIVEN_NAME_BIRTH, + EuPidSdJwtDataElements.EMAIL, + EuPidSdJwtDataElements.PHONE_NUMBER, + EuPidSdJwtDataElements.PORTRAIT, + EuPidSdJwtDataElements.ISSUANCE_DATE, + EuPidSdJwtDataElements.PERSONAL_ADMINISTRATIVE_NUMBER, + EuPidSdJwtDataElements.SEX, + EuPidSdJwtDataElements.DOCUMENT_NUMBER, + EuPidSdJwtDataElements.ISSUING_JURISDICTION, + EuPidSdJwtDataElements.TRUST_ANCHOR, + ) + ) + ), + ) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/InternalHelpers.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/InternalHelpers.kt new file mode 100644 index 000000000..f5ae21e20 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/InternalHelpers.kt @@ -0,0 +1,44 @@ +package at.asitplus.wallet.lib + +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformation +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPath +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentName + +internal object InternalHelpers { + + internal fun mandatoryElements(vararg elements: String) = elements.map { + SdJwtTypeMetadataClaimInformation( + path = SdJwtTypeMetadataClaimInformationPath( + it.split(".").map { SdJwtTypeMetadataClaimInformationPathSegmentName(it) }), + isMandatory = true + ) + } + + internal fun mandatoryElementsIso(namespace: String, vararg elements: String) = elements.map { + SdJwtTypeMetadataClaimInformation( + path = SdJwtTypeMetadataClaimInformationPath( + SdJwtTypeMetadataClaimInformationPathSegmentName(namespace), + SdJwtTypeMetadataClaimInformationPathSegmentName(it) + ), + isMandatory = true + ) + } + + internal fun optionalElements(vararg elements: String) = elements.map { + SdJwtTypeMetadataClaimInformation( + path = SdJwtTypeMetadataClaimInformationPath( + it.split(".").map { SdJwtTypeMetadataClaimInformationPathSegmentName(it) }), + isMandatory = false + ) + } + + internal fun optionalElementsIso(namespace: String, vararg elements: String) = elements.map { + SdJwtTypeMetadataClaimInformation( + path = SdJwtTypeMetadataClaimInformationPath( + SdJwtTypeMetadataClaimInformationPathSegmentName(namespace), + SdJwtTypeMetadataClaimInformationPathSegmentName(it) + ), + isMandatory = false + ) + } +} \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt index 667cb4645..27293b9be 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/LibraryInitializer.kt @@ -5,6 +5,7 @@ package at.asitplus.wallet.lib import at.asitplus.iso.CborCredentialSerializer import at.asitplus.wallet.lib.data.AttributeIndex import at.asitplus.wallet.lib.data.ConstantIndex +import at.asitplus.wallet.lib.data.CredentialMetadataRegistry import at.asitplus.wallet.lib.data.CredentialScheme import at.asitplus.wallet.lib.data.JsonCredentialSerializer import kotlinx.serialization.KSerializer @@ -15,6 +16,12 @@ import kotlinx.serialization.json.JsonElement */ object LibraryInitializer { + fun registerCredentialMetadataRegistry( + credentialMetadataRegistry: CredentialMetadataRegistry + ) { + AttributeIndex.registerCredentialMetadataRegistry(credentialMetadataRegistry) + } + /** * Register [credentialScheme] to be used with this library, e.g. in OpenID protocol implementations. */ @@ -32,10 +39,8 @@ object LibraryInitializer { } /** - * Register [credentialScheme] to be used with this library, e.g. in OpenID protocol implementations. - * Used for credentials supporting [at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC], - * which need to specify several functions to allow encoding any values - * in [at.asitplus.iso.IssuerSignedItem]. + * Register encoders for certain types used in ISO mDoc credential schemes, + * to help encoding "any" values in [at.asitplus.iso.IssuerSignedItem]. * See the function typealiases in [JsonValueEncoder] and [ElementIdentifierToItemValueSerializerMap] * for implementation notes. * Example for [jsonValueEncoder]: @@ -51,35 +56,38 @@ object LibraryInitializer { * Example for [itemValueSerializerMap]: * ``` * mapOf( - * MobileDrivingLicenceDataElements.BIRTH_DATE to LocalDate.serializer(), - * MobileDrivingLicenceDataElements.PORTRAIT to ByteArraySerializer(), + * "org.iso.18013.5.1" to mapOf( + * MobileDrivingLicenceDataElements.BIRTH_DATE to LocalDate.serializer(), + * MobileDrivingLicenceDataElements.PORTRAIT to ByteArraySerializer(), + * ) * ) * ``` * - * @param jsonValueEncoder used to describe the credential in input descriptors used in verifiable presentations, - * e.g. when used in SIOPv2 + * @param jsonValueEncoder used to describe the credential in input descriptors (Presentation Exchange) * @param itemValueSerializerMap used to actually serialize and deserialize `Any` object in - * [at.asitplus.iso.IssuerSignedItemSerializer], with `elementIdentifier` as the key + * [at.asitplus.iso.IssuerSignedItemSerializer], with `elementIdentifier` and `namespace` as the keys */ - fun registerExtensionLibrary( - credentialScheme: CredentialScheme, + fun registerCredentialSerializers( jsonValueEncoder: JsonValueEncoder, - itemValueSerializerMap: ElementIdentifierToItemValueSerializerMap = emptyMap(), + itemValueSerializerMap: IsoNamespaceToElementIdentifierToItemValueSerializerMap = mapOf(), ) { - registerExtensionLibrary(credentialScheme) JsonCredentialSerializer.register(jsonValueEncoder) - credentialScheme.isoNamespace?.let { CborCredentialSerializer.register(itemValueSerializerMap, it) } + itemValueSerializerMap.forEach { + CborCredentialSerializer.register(it.value, it.key) + } } - @Deprecated("Use the other method with CredentialScheme not from ConstantIndex") + @Suppress("DEPRECATION") + @Deprecated("Use registerCredentialMetadataRegistry for schemes and registerCredentialSerializers for serializers") fun registerExtensionLibrary( credentialScheme: ConstantIndex.CredentialScheme, jsonValueEncoder: JsonValueEncoder, itemValueSerializerMap: ElementIdentifierToItemValueSerializerMap = emptyMap(), ) { - registerExtensionLibrary(credentialScheme as CredentialScheme, jsonValueEncoder, itemValueSerializerMap) + registerExtensionLibrary(credentialScheme as CredentialScheme) + JsonCredentialSerializer.register(jsonValueEncoder) + credentialScheme.isoNamespace?.let { CborCredentialSerializer.register(itemValueSerializerMap, it) } } - } /** @@ -101,3 +109,10 @@ typealias JsonValueEncoder */ typealias ElementIdentifierToItemValueSerializerMap = Map> + +/** + * Maps from ISO mDoc namespaces to the element identifier (the claim name) to its corresponding + * [KSerializer]. + */ +typealias IsoNamespaceToElementIdentifierToItemValueSerializerMap + = Map diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/AttributeIndex.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/AttributeIndex.kt index ff3c0a384..5c3939a8c 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/AttributeIndex.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/AttributeIndex.kt @@ -5,18 +5,35 @@ import at.asitplus.wallet.lib.data.AttributeIndex.resolveIdentifierPlainJwt import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_CREDENTIAL import at.asitplus.wallet.lib.data.VcDataModelConstants.VERIFIABLE_PRESENTATION +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.update +@OptIn(ExperimentalAtomicApi::class) object AttributeIndex { - var schemeSet = setOf() - private set + private val credentialMetadataRegistrySetRef = + AtomicReference(setOf()) - init { - schemeSet += ConstantIndex.AtomicAttribute2023 - } + private val schemeSetRef = + AtomicReference(setOf(ConstantIndex.AtomicAttribute2023)) + + val credentialMetadataRegistrySet: Set + get() = credentialMetadataRegistrySetRef.load() + + val schemeSet: Set + get() = schemeSetRef.load() internal fun registerAttributeType(scheme: CredentialScheme) { - schemeSet += scheme + schemeSetRef.update { it + scheme } + } + + internal fun registerCredentialMetadataRegistry( + credentialMetadataRegistry: CredentialMetadataRegistry + ) { + credentialMetadataRegistrySetRef.update { it + credentialMetadataRegistry } + val preloaded = credentialMetadataRegistry.preloadEntries().map { it.toCredentialScheme() } + schemeSetRef.update { it + preloaded } } /** @@ -72,9 +89,25 @@ object AttributeIndex { identifier: String, representation: ConstantIndex.CredentialRepresentation ): CredentialScheme = when (representation) { - PLAIN_JWT -> resolveAttributeType(identifier) ?: VcFallbackCredentialScheme(vcType = identifier) - SD_JWT -> resolveSdJwtAttributeType(identifier) ?: SdJwtFallbackCredentialScheme(sdJwtType = identifier) - ISO_MDOC -> resolveIsoDoctype(identifier) ?: IsoMdocFallbackCredentialScheme(isoDocType = identifier) + PLAIN_JWT -> resolveAttributeType(identifier) + ?: resolveAndRegister(identifier, representation) + ?: VcFallbackCredentialScheme(vcType = identifier) + + SD_JWT -> resolveSdJwtAttributeType(identifier) + ?: resolveAndRegister(identifier, representation) + ?: SdJwtFallbackCredentialScheme(sdJwtType = identifier) + + ISO_MDOC -> resolveIsoDoctype(identifier) + ?: resolveAndRegister(identifier, representation) + ?: IsoMdocFallbackCredentialScheme(isoDocType = identifier) + } + + private suspend fun resolveAndRegister( + identifier: String, + representation: ConstantIndex.CredentialRepresentation + ): CredentialScheme? = credentialMetadataRegistrySet.firstNotNullOfOrNull { + it.findEntry(identifier, representation)?.toCredentialScheme() + ?.also { scheme -> schemeSetRef.update { it + scheme } } } /** @@ -88,7 +121,8 @@ object AttributeIndex { .filterNot { it == VERIFIABLE_CREDENTIAL } .filterNot { it == VERIFIABLE_PRESENTATION }.run { firstNotNullOfOrNull { resolveAttributeType(it) } + ?: firstNotNullOfOrNull { resolveAndRegister(it, PLAIN_JWT) } ?: firstOrNull()?.let { VcFallbackCredentialScheme(vcType = it) } } ?: UnknownCredentialScheme(PLAIN_JWT) -} \ No newline at end of file +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt index a69deaedf..ce9069ea1 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/ConstantIndex.kt @@ -4,10 +4,14 @@ import at.asitplus.data.NonEmptyList.Companion.toNonEmptyList import at.asitplus.openid.ClaimDescription import at.asitplus.openid.DisplayProperties import at.asitplus.openid.OpenId4VciClaimsPathPointer +import at.asitplus.openid.OpenId4VciClaimsPathPointerSegment import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentIndex import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentString import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* +import at.asitplus.wallet.sdjwt.CredentialFormatEnum import at.asitplus.wallet.sdjwt.SdJwtTypeMetadata +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformation +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegment import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentIndex import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentName @@ -77,6 +81,7 @@ interface CredentialScheme { * from the requested schema to the internal attribute type used in [at.asitplus.wallet.lib.agent.Issuer] * when issuing credentials. */ + // TODO Do we still need this? Or can we remove it? Is it the URL where the document has been downloaded? val schemaUri: String /** @@ -216,45 +221,3 @@ interface VcJwtCredentialScheme : CredentialScheme { get() = listOf(PLAIN_JWT) } -// To be replaced in an upcoming PR -fun SdJwtTypeMetadata.toCredentialScheme() = object : CredentialScheme { - override val schemaUri: String - get() = "https://schema.example.com" - - override val sdJwtType: String - get() = vct.string - - override val claimDescriptions: Set - get() = claims?.map { - ClaimDescription( - path = OpenId4VciClaimsPathPointer(it.path.map { - when (it) { - is SdJwtTypeMetadataClaimInformationPathSegmentIndex -> OpenId4VciClaimsPathPointerSegmentIndex( - it.ulong.also { - if (it > UInt.MAX_VALUE) { - throw UnsupportedOperationException("This implementation only supports claims path pointer indices up to ${UInt.MAX_VALUE}, but got $it") - } - }.toUInt() - ) - - is SdJwtTypeMetadataClaimInformationPathSegmentName -> OpenId4VciClaimsPathPointerSegmentString( - it.string - ) - - null -> null - } - }.toNonEmptyList()), - mandatory = it.isMandatory, - display = it.display?.map { - DisplayProperties( - locale = it.locale.string, - name = it.label, - description = it.description, - ) - }?.toSet() - ) - }?.toSet() ?: setOf() - - override val supportedRepresentations: Collection - get() = listOf(SD_JWT) -} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialMetadataRegistry.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialMetadataRegistry.kt new file mode 100644 index 000000000..e6dc15712 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialMetadataRegistry.kt @@ -0,0 +1,116 @@ +package at.asitplus.wallet.lib.data + +import at.asitplus.data.NonEmptyList.Companion.toNonEmptyList +import at.asitplus.openid.ClaimDescription +import at.asitplus.openid.DisplayProperties +import at.asitplus.openid.OpenId4VciClaimsPathPointer +import at.asitplus.openid.OpenId4VciClaimsPathPointerSegment +import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentIndex +import at.asitplus.openid.OpenId4VciClaimsPathPointerSegmentString +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.* +import at.asitplus.wallet.sdjwt.CredentialFormatEnum +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadata +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformation +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegment +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentIndex +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationPathSegmentName + +interface CredentialMetadataRegistry { + suspend fun findEntry( + identifier: String, + representation: CredentialRepresentation, + ): ResolvedCredentialMetadata? + + /** + * Entries that can be resolved eagerly without network access, used to pre-seed the synchronous lookups in + * [AttributeIndex]. Defaults to none; registries that must fetch on demand keep the default. + */ + fun preloadEntries(): Collection = emptyList() +} + +data class ResolvedCredentialMetadata( + val metadata: SdJwtTypeMetadata, + val loadedFrom: String, + val aliases: Set = emptySet(), +) + +fun ResolvedCredentialMetadata.toCredentialScheme() = metadata.toCredentialScheme(loadedFrom) + +private fun SdJwtTypeMetadata.extractRepresentation() = when (vckExtensions?.format) { + CredentialFormatEnum.JWT_VC -> PLAIN_JWT + CredentialFormatEnum.DC_SD_JWT -> SD_JWT + CredentialFormatEnum.MSO_MDOC -> ISO_MDOC + else -> null +} ?: SD_JWT + +fun SdJwtTypeMetadata.toCredentialScheme(schemaUri: String) = when (extractRepresentation()) { + PLAIN_JWT -> ExtractedVcJwtCredentialScheme( + schemaUri = schemaUri, + vcType = vckExtensions?.vcType ?: vct.string, + claimDescriptions = claims?.map { it.toClaimDescription() }?.toSet() ?: setOf() + ) + + SD_JWT -> ExtractedSdJwtCredentialScheme( + schemaUri = schemaUri, + sdJwtType = vct.string, + claimDescriptions = claims?.map { it.toClaimDescription() }?.toSet() ?: setOf() + ) + + ISO_MDOC -> ExtractedIsoMdocCredentialScheme( + schemaUri = schemaUri, + isoDocType = vckExtensions?.isoDocType ?: vct.string, + isoNamespace = vckExtensions?.isoNamespace ?: vct.string, + claimDescriptions = claims?.map { it.toClaimDescription() }?.toSet() ?: setOf() + ) +} + +private fun SdJwtTypeMetadataClaimInformation.toClaimDescription(): ClaimDescription = ClaimDescription( + path = OpenId4VciClaimsPathPointer(path.map { + it.toSegment() + }.toNonEmptyList()), + mandatory = isMandatory, + display = display?.map { + DisplayProperties( + locale = it.locale.string, + name = it.label, + description = it.description, + ) + }?.toSet() +) + +private fun SdJwtTypeMetadataClaimInformationPathSegment?.toSegment(): OpenId4VciClaimsPathPointerSegment? = + when (this) { + is SdJwtTypeMetadataClaimInformationPathSegmentIndex -> + OpenId4VciClaimsPathPointerSegmentIndex(this.ulong.safeToUint()) + + is SdJwtTypeMetadataClaimInformationPathSegmentName -> + OpenId4VciClaimsPathPointerSegmentString(this.string) + + null -> null + } + +private fun ULong.safeToUint(): UInt = also { + if (it > UInt.MAX_VALUE) { + throw UnsupportedOperationException("This implementation only supports claims path pointer indices up to ${UInt.MAX_VALUE}, but got $it") + } +}.toUInt() + +data class ExtractedVcJwtCredentialScheme( + override val schemaUri: String, + override val vcType: String, + override val claimDescriptions: Set +) : VcJwtCredentialScheme + +data class ExtractedSdJwtCredentialScheme( + override val schemaUri: String, + override val sdJwtType: String, + override val claimDescriptions: Set +) : SdJwtCredentialScheme + +data class ExtractedIsoMdocCredentialScheme( + override val schemaUri: String, + override val isoDocType: String, + override val isoNamespace: String, + override val claimDescriptions: Set +) : IsoMdocCredentialScheme + diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt index 5294617b1..b2c4aab89 100644 --- a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/CredentialToJsonConverter.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.jsonObject +import kotlin.time.Instant private const val SD_JWT_VC_TYPE = "vct" @@ -98,7 +99,9 @@ object CredentialToJsonConverter { is Number -> JsonPrimitive(this) is String -> JsonPrimitive(this) is ByteArray -> JsonPrimitive(encodeToString(Base64UrlStrict)) - is LocalDate -> JsonPrimitive(this.toString()) + is LocalDate -> joseCompliantSerializer.encodeToJsonElement(this) + is Instant -> joseCompliantSerializer.encodeToJsonElement(this) + is LocalDateOrInstant -> joseCompliantSerializer.encodeToJsonElement(this) is UByte -> JsonPrimitive(this) is UShort -> JsonPrimitive(this) is UInt -> JsonPrimitive(this) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/StaticCredentialMetadataRegistry.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/StaticCredentialMetadataRegistry.kt new file mode 100644 index 000000000..1c8c9f42e --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/lib/data/StaticCredentialMetadataRegistry.kt @@ -0,0 +1,115 @@ +package at.asitplus.wallet.lib.data + +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT +import at.asitplus.wallet.sdjwt.CredentialFormatEnum +import at.asitplus.wallet.sdjwt.DelegatingSdJwtTypeMetadataDocumentResolver +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadata +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentIntegrityChecker +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRegistry +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataVckExtensions +import at.asitplus.wallet.sdjwt.SdJwtVcType +import at.asitplus.wallet.sdjwt.W3cSubresourceIntegrityMetadata + +/** + * Static [CredentialMetadataRegistry] backed by an in-memory [SdJwtTypeMetadataDocumentRegistry]. + * + * This is intended for libraries that ship known metadata documents in code. [documentUrls] provides the canonical + * hosted URL for each entry document; that URL becomes [CredentialScheme.schemaUri] after resolution. + */ +class StaticCredentialMetadataRegistry( + private val documentRegistry: SdJwtTypeMetadataDocumentRegistry, + private val documentUrls: Map, + private val aliases: Map = emptyMap(), + private val integrityMetadata: Map = emptyMap(), + integrityChecker: SdJwtTypeMetadataDocumentIntegrityChecker = SdJwtTypeMetadataDocumentIntegrityChecker.DEFAULT, +) : CredentialMetadataRegistry { + + private val resolver = DelegatingSdJwtTypeMetadataDocumentResolver( + documentRetriever = documentRegistry, + integrityChecker = integrityChecker, + ) + + /** + * Self-contained bundled documents (those that don't `extends` another type) resolve synchronously, so they are + * registered eagerly to pre-seed the synchronous lookups. Documents that extend another type are left to the + * (suspending) [findEntry] path. + * + * Entries pinned with [integrityMetadata] are also deferred to [findEntry]: the integrity check is suspending, so + * it cannot run here, and preloading them unchecked would let a mismatched document pass the synchronous lookup. + */ + override fun preloadEntries(): Set = + documentRegistry.entries.mapNotNull { (vct, document) -> + if (document.definition.extends != null) return@mapNotNull null + if (integrityMetadata.containsKey(vct)) return@mapNotNull null + resolvedMetadata(vct, document.definition.toSdJwtTypeMetadata()) + }.toSet() + + override suspend fun findEntry( + identifier: String, + representation: CredentialRepresentation, + ): ResolvedCredentialMetadata? { + val lookup = CredentialMetadataLookup(representation, identifier) + val vct = aliases[lookup] + ?: documentRegistry.entries.firstOrNull { (_, document) -> + document.definition.matches(identifier, representation) + }?.key + ?: return null + + return resolvedMetadata(vct, resolver.resolve(vct, integrityMetadata[vct])) + } + + private fun resolvedMetadata(vct: SdJwtVcType, metadata: SdJwtTypeMetadata): ResolvedCredentialMetadata { + val loadedFrom = documentUrls[vct] + ?: error("No metadata document URL configured for vct `$vct`.") + return ResolvedCredentialMetadata( + metadata = metadata, + loadedFrom = loadedFrom, + aliases = aliases.entries + .filter { it.value == vct } + .map { it.key.identifier } + .toSet(), + ) + } + + private fun SdJwtTypeMetadataDefinition.matches( + identifier: String, + representation: CredentialRepresentation, + ): Boolean { + val extensions = effectiveVckExtensions() + return when (representation) { + PLAIN_JWT -> extensions?.format == CredentialFormatEnum.JWT_VC && + extensions.vcType == identifier + + SD_JWT -> vct.string == identifier && + (extensions?.format == null || extensions.format == CredentialFormatEnum.DC_SD_JWT) + + ISO_MDOC -> extensions?.format == CredentialFormatEnum.MSO_MDOC && + extensions.isoDocType == identifier + } + } + + /** + * The `vck` extensions a fully resolved document would carry: a child that `extends` a base inherits the base's + * block when it declares none (see [SdJwtTypeMetadataDefinition.extend]). Matching the unresolved child directly + * would miss it on PLAIN_JWT/ISO_MDOC lookups keyed by the inherited `vcType`/`isoDocType`, so we walk the + * `extends` chain here and take the first declared block. + */ + private fun SdJwtTypeMetadataDefinition.effectiveVckExtensions(): SdJwtTypeMetadataVckExtensions? { + val visited = mutableSetOf() + var current: SdJwtTypeMetadataDefinition? = this + while (current != null && visited.add(current.vct)) { + current.vckExtensions?.let { return it } + current = current.extends?.let { documentRegistry[it]?.definition } + } + return null + } +} + +data class CredentialMetadataLookup( + val representation: CredentialRepresentation, + val identifier: String, +) diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/DrivingPrivilege.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/DrivingPrivilege.kt new file mode 100644 index 000000000..559fb53a0 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/DrivingPrivilege.kt @@ -0,0 +1,62 @@ +@file:OptIn(ExperimentalUnsignedTypes::class) + +package at.asitplus.wallet.mdl + +import kotlinx.datetime.LocalDate +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ValueTags + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for Driving privileges (7.2.4) + */ +@OptIn(ExperimentalSerializationApi::class) +@Serializable +data class DrivingPrivilege( + @SerialName("vehicle_category_code") + val vehicleCategoryCode: String, + @ValueTags(1004u) + @SerialName("issue_date") + val issueDate: LocalDate? = null, + @ValueTags(1004u) + @SerialName("expiry_date") + val expiryDate: LocalDate? = null, + @SerialName("codes") + val codes: Array? = null, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as DrivingPrivilege + + if (vehicleCategoryCode != other.vehicleCategoryCode) return false + if (issueDate != other.issueDate) return false + if (expiryDate != other.expiryDate) return false + if (codes != null) { + if (other.codes == null) return false + if (!codes.contentEquals(other.codes)) return false + } else if (other.codes != null) return false + + return true + } + + override fun hashCode(): Int { + var result = vehicleCategoryCode.hashCode() + result = 31 * result + (issueDate?.hashCode() ?: 0) + result = 31 * result + (expiryDate?.hashCode() ?: 0) + result = 31 * result + (codes?.contentHashCode() ?: 0) + return result + } +} + +@Serializable +data class DrivingPrivilegeCode( + @SerialName("code") + val code: String, + @SerialName("sign") + val sign: String? = null, + @SerialName("value") + val value: String? = null, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/IsoSexEnum.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/IsoSexEnum.kt new file mode 100644 index 000000000..b71197db6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/IsoSexEnum.kt @@ -0,0 +1,17 @@ +package at.asitplus.wallet.mdl + +/** + * ISO/IEC 5218 Codes for the representation of human sexes + */ +enum class IsoSexEnum(val code: Int) { + + NOT_KNOWN(0), + MALE(1), + FEMALE(2), + NOT_APPLICABLE(9); + + companion object { + fun parseCode(code: Int) = entries.firstOrNull { it.code == code } + } + +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/IsoSexEnumSerializer.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/IsoSexEnumSerializer.kt new file mode 100644 index 000000000..9fb1f49ea --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/IsoSexEnumSerializer.kt @@ -0,0 +1,23 @@ +package at.asitplus.wallet.mdl + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object IsoSexEnumSerializer : KSerializer { + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("IsoSexEnum?", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: IsoSexEnum?) { + value?.let { encoder.encodeInt(it.code) } + } + + override fun deserialize(decoder: Decoder): IsoSexEnum? { + return IsoSexEnum.parseCode(decoder.decodeInt()) + } + +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicence.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicence.kt new file mode 100644 index 000000000..877f22581 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicence.kt @@ -0,0 +1,373 @@ +package at.asitplus.wallet.mdl + +import at.asitplus.signum.indispensable.io.ByteArrayBase64UrlSerializer +import io.matthewnelson.encoding.base16.Base16 +import io.matthewnelson.encoding.core.Encoder.Companion.encodeToString +import kotlinx.datetime.LocalDate +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.cbor.ByteString + + +/** + * Part of the ISO/IEC 18013-5:2021 standard: Data structure for mDL (7.2.1) + */ +@Serializable +data class MobileDrivingLicence( + /** Last name, surname, or primary identifier of the mDL holder. */ + @SerialName(MobileDrivingLicenceDataElements.FAMILY_NAME) + val familyName: String, + + /** First name(s), other name(s), or secondary identifier, of the mDL holder. */ + @SerialName(MobileDrivingLicenceDataElements.GIVEN_NAME) + val givenName: String, + + /** Day, month and year on which the mDL holder was born. If unknown, approximate date of birth. */ + @SerialName(MobileDrivingLicenceDataElements.BIRTH_DATE) + val dateOfBirth: LocalDate? = null, + + /** Date when mDL was issued. */ + @SerialName(MobileDrivingLicenceDataElements.ISSUE_DATE) + val issueDate: LocalDate, + + /** Date when mDL expires. */ + @SerialName(MobileDrivingLicenceDataElements.EXPIRY_DATE) + val expiryDate: LocalDate, + + /** Alpha-2 country code, as defined in ISO 3166-1, of the issuing authority's country or territory. */ + @SerialName(MobileDrivingLicenceDataElements.ISSUING_COUNTRY) + val issuingCountry: String? = null, + + /** Issuing authority name. */ + @SerialName(MobileDrivingLicenceDataElements.ISSUING_AUTHORITY) + val issuingAuthority: String? = null, + + /** The number assigned or calculated by the issuing authority. */ + @SerialName(MobileDrivingLicenceDataElements.DOCUMENT_NUMBER) + val licenceNumber: String, + + /** A reproduction of the mDL holder's portrait. */ + @SerialName(MobileDrivingLicenceDataElements.PORTRAIT) + @ByteString + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val portrait: ByteArray, + + /** Driving privileges of the mDL holder. */ + @SerialName(MobileDrivingLicenceDataElements.DRIVING_PRIVILEGES) + val drivingPrivileges: Array, + + /** Distinguishing sign of the issuing country according to ISO/IEC 18013-1:2018, Annex F. */ + @SerialName(MobileDrivingLicenceDataElements.UN_DISTINGUISHING_SIGN) + val unDistinguishingSign: String? = null, + + /** An audit control number assigned by the issuing authority. */ + @SerialName(MobileDrivingLicenceDataElements.ADMINISTRATIVE_NUMBER) + val administrativeNumber: String? = null, + + /** mDL holder's sex using values as defined in ISO/IEC 5218. */ + @SerialName(MobileDrivingLicenceDataElements.SEX) + @Serializable(with = IsoSexEnumSerializer::class) + val sex: IsoSexEnum? = null, + + /** mDL holder's height in centimetres. */ + @SerialName(MobileDrivingLicenceDataElements.HEIGHT) + val height: UInt? = null, + + /** mDL holder's weight in kilograms */ + @SerialName(MobileDrivingLicenceDataElements.WEIGHT) + val weight: UInt? = null, + + /** mDL holder's eye colour. The value shall be one of the following: "black", "blue", "brown", + * "dichromatic", "grey", "green", "hazel", "maroon", "pink", "unknown". */ + @SerialName(MobileDrivingLicenceDataElements.EYE_COLOUR) + val eyeColor: String? = null, + + /** mDL holder's hair color. The value shall be one of the following: "bald", "black", "blond", + * "brown", "grey", "red", "auburn", "sandy","white", "unknown" */ + @SerialName(MobileDrivingLicenceDataElements.HAIR_COLOUR) + val hairColor: String? = null, + + /** Country and municipality or state/province where the mDL holder was born. */ + @SerialName(MobileDrivingLicenceDataElements.BIRTH_PLACE) + val placeOfBirth: String? = null, + + /** The place where the mDL holder resides and/or may be contracted. */ + @SerialName(MobileDrivingLicenceDataElements.RESIDENT_ADDRESS) + val placeOfResidence: String? = null, + + /** Date when portrait was taken. */ + @SerialName(MobileDrivingLicenceDataElements.PORTRAIT_CAPTURE_DATE) + val portraitImageTimestamp: LocalDate? = null, + + /** The age of the mDL holder. */ + @SerialName(MobileDrivingLicenceDataElements.AGE_IN_YEARS) + val ageInYears: UInt? = null, + + /** The year when the mDL holder was born. */ + @SerialName(MobileDrivingLicenceDataElements.AGE_BIRTH_YEAR) + val ageBirthYear: UInt? = null, + + /** Age attestation: Over 12 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_12) + val ageOver12: Boolean? = null, + + /** Age attestation: Over 13 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_13) + val ageOver13: Boolean? = null, + + /** Age attestation: Over 14 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_14) + val ageOver14: Boolean? = null, + + /** Age attestation: Over 16 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_16) + val ageOver16: Boolean? = null, + + /** Age attestation: Over 18 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_18) + val ageOver18: Boolean? = null, + + /** Age attestation: Over 21 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_21) + val ageOver21: Boolean? = null, + + /** Age attestation: Over 25 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_25) + val ageOver25: Boolean? = null, + + /** Age attestation: Over 60 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_60) + val ageOver60: Boolean? = null, + + /** Age attestation: Over 62 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_62) + val ageOver62: Boolean? = null, + + /** Age attestation: Over 65 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_65) + val ageOver65: Boolean? = null, + + /** Age attestation: Over 68 years old? */ + @SerialName(MobileDrivingLicenceDataElements.AGE_OVER_68) + val ageOver68: Boolean? = null, + + /** Country subdivision code of the jurisdiction that issued the mDL as defined in ISO 3166-2:2020, Clause 8. */ + @SerialName(MobileDrivingLicenceDataElements.ISSUING_JURISDICTION) + val issuingJurisdiction: String? = null, + + /** Nationality of the mDL holder as a two letter country code (alpha-2 code) defined in ISO 3166-1. */ + @SerialName(MobileDrivingLicenceDataElements.NATIONALITY) + val nationality: String? = null, + + /** The city where the mDL holder lives. */ + @SerialName(MobileDrivingLicenceDataElements.RESIDENT_CITY) + val residentCity: String? = null, + + /** The state/province/district where the mDL holder lives. */ + @SerialName(MobileDrivingLicenceDataElements.RESIDENT_STATE) + val residentState: String? = null, + + /** The postal code of the mDL holder. */ + @SerialName(MobileDrivingLicenceDataElements.RESIDENT_POSTAL_CODE) + val residentPostalCode: String? = null, + + /** The country where the mDL holder lives as a two letter country code (alpha-2 code) defined in ISO 3166-1. */ + @SerialName(MobileDrivingLicenceDataElements.RESIDENT_COUNTRY) + val residentCountry: String? = null, + + /** The family name of the mDL holder using full UTF-8 character set. */ + @SerialName(MobileDrivingLicenceDataElements.FAMILY_NAME_NATIONAL_CHARACTER) + val familyNameNationalCharacters: String? = null, + + /** The given name of the mDL holder using full UTF-8 character set. */ + @SerialName(MobileDrivingLicenceDataElements.GIVEN_NAME_NATIONAL_CHARACTER) + val givenNameNationalCharacters: String? = null, + + /** Image of the signature or usual mark of the mDL holder. */ + @ByteString + @SerialName(MobileDrivingLicenceDataElements.SIGNATURE_USUAL_MARK) + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val signatureOrUsualMark: ByteArray? = null, + + /** This element contains optional facial information of the mDL holder. */ + @ByteString + @SerialName(MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_FACE) + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val biometricTemplateFace: ByteArray? = null, + + /** This element contains optional fingerprint information of the mDL holder. */ + @ByteString + @SerialName(MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_FINGER) + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val biometricTemplateFinger: ByteArray? = null, + + /** This element contains optional signature/sign information of the mDL holder. */ + @ByteString + @SerialName(MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_SIGNATURE_SIGN) + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val biometricTemplateSignatureSign: ByteArray? = null, + + /** This element contains optional iris information of the mDL holder. */ + @ByteString + @SerialName(MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_IRIS) + @Serializable(with = ByteArrayBase64UrlSerializer::class) + val biometricTemplateIris: ByteArray? = null, +) { + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || this::class != other::class) return false + + other as MobileDrivingLicence + + if (ageOver12 != other.ageOver12) return false + if (ageOver13 != other.ageOver13) return false + if (ageOver14 != other.ageOver14) return false + if (ageOver16 != other.ageOver16) return false + if (ageOver18 != other.ageOver18) return false + if (ageOver21 != other.ageOver21) return false + if (ageOver25 != other.ageOver25) return false + if (ageOver60 != other.ageOver60) return false + if (ageOver62 != other.ageOver62) return false + if (ageOver65 != other.ageOver65) return false + if (ageOver68 != other.ageOver68) return false + if (familyName != other.familyName) return false + if (givenName != other.givenName) return false + if (dateOfBirth != other.dateOfBirth) return false + if (issueDate != other.issueDate) return false + if (expiryDate != other.expiryDate) return false + if (issuingCountry != other.issuingCountry) return false + if (issuingAuthority != other.issuingAuthority) return false + if (licenceNumber != other.licenceNumber) return false + if (!portrait.contentEquals(other.portrait)) return false + if (!drivingPrivileges.contentEquals(other.drivingPrivileges)) return false + if (unDistinguishingSign != other.unDistinguishingSign) return false + if (administrativeNumber != other.administrativeNumber) return false + if (sex != other.sex) return false + if (height != other.height) return false + if (weight != other.weight) return false + if (eyeColor != other.eyeColor) return false + if (hairColor != other.hairColor) return false + if (placeOfBirth != other.placeOfBirth) return false + if (placeOfResidence != other.placeOfResidence) return false + if (portraitImageTimestamp != other.portraitImageTimestamp) return false + if (ageInYears != other.ageInYears) return false + if (ageBirthYear != other.ageBirthYear) return false + if (issuingJurisdiction != other.issuingJurisdiction) return false + if (nationality != other.nationality) return false + if (residentCity != other.residentCity) return false + if (residentState != other.residentState) return false + if (residentPostalCode != other.residentPostalCode) return false + if (residentCountry != other.residentCountry) return false + if (familyNameNationalCharacters != other.familyNameNationalCharacters) return false + if (givenNameNationalCharacters != other.givenNameNationalCharacters) return false + if (!signatureOrUsualMark.contentEquals(other.signatureOrUsualMark)) return false + if (!biometricTemplateFace.contentEquals(other.biometricTemplateFace)) return false + if (!biometricTemplateFinger.contentEquals(other.biometricTemplateFinger)) return false + if (!biometricTemplateSignatureSign.contentEquals(other.biometricTemplateSignatureSign)) return false + if (!biometricTemplateIris.contentEquals(other.biometricTemplateIris)) return false + + return true + } + + override fun hashCode(): Int { + var result = ageOver12?.hashCode() ?: 0 + result = 31 * result + (ageOver13?.hashCode() ?: 0) + result = 31 * result + (ageOver14?.hashCode() ?: 0) + result = 31 * result + (ageOver16?.hashCode() ?: 0) + result = 31 * result + (ageOver18?.hashCode() ?: 0) + result = 31 * result + (ageOver21?.hashCode() ?: 0) + result = 31 * result + (ageOver25?.hashCode() ?: 0) + result = 31 * result + (ageOver60?.hashCode() ?: 0) + result = 31 * result + (ageOver62?.hashCode() ?: 0) + result = 31 * result + (ageOver65?.hashCode() ?: 0) + result = 31 * result + (ageOver68?.hashCode() ?: 0) + result = 31 * result + familyName.hashCode() + result = 31 * result + givenName.hashCode() + result = 31 * result + (dateOfBirth?.hashCode() ?: 0) + result = 31 * result + issueDate.hashCode() + result = 31 * result + expiryDate.hashCode() + result = 31 * result + (issuingCountry?.hashCode() ?: 0) + result = 31 * result + (issuingAuthority?.hashCode() ?: 0) + result = 31 * result + licenceNumber.hashCode() + result = 31 * result + portrait.contentHashCode() + result = 31 * result + drivingPrivileges.contentHashCode() + result = 31 * result + (unDistinguishingSign?.hashCode() ?: 0) + result = 31 * result + (administrativeNumber?.hashCode() ?: 0) + result = 31 * result + (sex?.hashCode() ?: 0) + result = 31 * result + (height?.hashCode() ?: 0) + result = 31 * result + (weight?.hashCode() ?: 0) + result = 31 * result + (eyeColor?.hashCode() ?: 0) + result = 31 * result + (hairColor?.hashCode() ?: 0) + result = 31 * result + (placeOfBirth?.hashCode() ?: 0) + result = 31 * result + (placeOfResidence?.hashCode() ?: 0) + result = 31 * result + (portraitImageTimestamp?.hashCode() ?: 0) + result = 31 * result + (ageInYears?.hashCode() ?: 0) + result = 31 * result + (ageBirthYear?.hashCode() ?: 0) + result = 31 * result + (issuingJurisdiction?.hashCode() ?: 0) + result = 31 * result + (nationality?.hashCode() ?: 0) + result = 31 * result + (residentCity?.hashCode() ?: 0) + result = 31 * result + (residentState?.hashCode() ?: 0) + result = 31 * result + (residentPostalCode?.hashCode() ?: 0) + result = 31 * result + (residentCountry?.hashCode() ?: 0) + result = 31 * result + (familyNameNationalCharacters?.hashCode() ?: 0) + result = 31 * result + (givenNameNationalCharacters?.hashCode() ?: 0) + result = 31 * result + (signatureOrUsualMark?.contentHashCode() ?: 0) + result = 31 * result + (biometricTemplateFace?.contentHashCode() ?: 0) + result = 31 * result + (biometricTemplateFinger?.contentHashCode() ?: 0) + result = 31 * result + (biometricTemplateSignatureSign?.contentHashCode() ?: 0) + result = 31 * result + (biometricTemplateIris?.contentHashCode() ?: 0) + return result + } + + override fun toString(): String { + return "MobileDrivingLicence(" + + "familyName='$familyName', " + + "givenName='$givenName', " + + "dateOfBirth=$dateOfBirth, " + + "issueDate=$issueDate, " + + "expiryDate=$expiryDate, " + + "issuingCountry=$issuingCountry, " + + "issuingAuthority=$issuingAuthority, " + + "licenceNumber='$licenceNumber', " + + "portrait=${portrait?.encodeToString(Base16(strict = true))}, " + + "drivingPrivileges=${drivingPrivileges.contentToString()}, " + + "unDistinguishingSign=$unDistinguishingSign, " + + "administrativeNumber=$administrativeNumber, " + + "sex=$sex, " + + "height=$height, " + + "weight=$weight, " + + "eyeColor=$eyeColor, " + + "hairColor=$hairColor, " + + "placeOfBirth=$placeOfBirth, " + + "placeOfResidence=$placeOfResidence, " + + "portraitImageTimestamp=$portraitImageTimestamp, " + + "ageInYears=$ageInYears, " + + "ageBirthYear=$ageBirthYear, " + + "ageOver12=$ageOver12, " + + "ageOver13=$ageOver13, " + + "ageOver14=$ageOver14, " + + "ageOver16=$ageOver16, " + + "ageOver18=$ageOver18, " + + "ageOver21=$ageOver21, " + + "ageOver25=$ageOver25, " + + "ageOver60=$ageOver60, " + + "ageOver62=$ageOver62, " + + "ageOver65=$ageOver65, " + + "ageOver68=$ageOver68, " + + "issuingJurisdiction=$issuingJurisdiction, " + + "nationality=$nationality, " + + "residentCity=$residentCity, " + + "residentState=$residentState, " + + "residentPostalCode=$residentPostalCode, " + + "residentCountry=$residentCountry, " + + "familyNameNationalCharacters=$familyNameNationalCharacters, " + + "givenNameNationalCharacters=$givenNameNationalCharacters, " + + "signatureOrUsualMark=${signatureOrUsualMark?.encodeToString(Base16(strict = true))}, " + + "biometricTemplateFace=${biometricTemplateFace?.encodeToString(Base16(strict = true))}, " + + "biometricTemplateFinger=${biometricTemplateFinger?.encodeToString(Base16(strict = true))}, " + + "biometricTemplateSignatureSign=${biometricTemplateSignatureSign?.encodeToString(Base16(strict = true))}, " + + "biometricTemplateIris=${biometricTemplateIris?.encodeToString(Base16(strict = true))}" + + ")" + } +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceDataElements.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceDataElements.kt new file mode 100644 index 000000000..3dc2c1be6 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceDataElements.kt @@ -0,0 +1,208 @@ +package at.asitplus.wallet.mdl + + +object MobileDrivingLicenceDataElements { + + /** Last name, surname, or primary identifier of the mDL holder. */ + const val FAMILY_NAME = "family_name" + + /** First name(s), other name(s), or secondary identifier, of the mDL holder. */ + const val GIVEN_NAME = "given_name" + + /** Day, month and year on which the mDL holder was born. If unknown, approximate date of birth. */ + const val BIRTH_DATE = "birth_date" + + /** Date when mDL was issued. */ + const val ISSUE_DATE = "issue_date" + + /** Date when mDL expires. */ + const val EXPIRY_DATE = "expiry_date" + + /** Alpha-2 country code, as defined in ISO 3166-1, of the issuing authority's country or territory. */ + const val ISSUING_COUNTRY = "issuing_country" + + /** Issuing authority name. */ + const val ISSUING_AUTHORITY = "issuing_authority" + + /** The number assigned or calculated by the issuing authority. */ + const val DOCUMENT_NUMBER = "document_number" + + /** A reproduction of the mDL holder's portrait. */ + const val PORTRAIT = "portrait" + + /** Driving privileges of the mDL holder. */ + const val DRIVING_PRIVILEGES = "driving_privileges" + + /** Distinguishing sign of the issuing country according to ISO/IEC 18013-1:2018, Annex F. */ + const val UN_DISTINGUISHING_SIGN = "un_distinguishing_sign" + + /** An audit control number assigned by the issuing authority. */ + const val ADMINISTRATIVE_NUMBER = "administrative_number" + + /** mDL holder's sex using values as defined in ISO/IEC 5218. */ + const val SEX = "sex" + + /** mDL holder's height in centimetres. */ + const val HEIGHT = "height" + + /** mDL holder's weight in kilograms */ + const val WEIGHT = "weight" + + /** mDL holder's eye colour. The value shall be one of the following: "black", "blue", "brown", + * "dichromatic", "grey", "green", "hazel", "maroon", "pink", "unknown". */ + const val EYE_COLOUR = "eye_colour" + + /** mDL holder's hair color. The value shall be one of the following: "bald", "black", "blond", + * "brown", "grey", "red", "auburn", "sandy","white", "unknown" */ + const val HAIR_COLOUR = "hair_colour" + + /** Country and municipality or state/province where the mDL holder was born. */ + const val BIRTH_PLACE = "birth_place" + + /** The place where the mDL holder resides and/or may be contracted. */ + const val RESIDENT_ADDRESS = "resident_address" + + /** Date when portrait was taken. */ + const val PORTRAIT_CAPTURE_DATE = "portrait_capture_date" + + /** The age of the mDL holder. */ + const val AGE_IN_YEARS = "age_in_years" + + /** The year when the mDL holder was born. */ + const val AGE_BIRTH_YEAR = "age_birth_year" + + /** Age attestation: Over 12 years old? */ + const val AGE_OVER_12 = "age_over_12" + + /** Age attestation: Over 13 years old? */ + const val AGE_OVER_13 = "age_over_13" + + /** Age attestation: Over 14 years old? */ + const val AGE_OVER_14 = "age_over_14" + + /** Age attestation: Over 16 years old? */ + const val AGE_OVER_16 = "age_over_16" + + /** Age attestation: Over 18 years old? */ + const val AGE_OVER_18 = "age_over_18" + + /** Age attestation: Over 21 years old? */ + const val AGE_OVER_21 = "age_over_21" + + /** Age attestation: Over 25 years old? */ + const val AGE_OVER_25 = "age_over_25" + + /** Age attestation: Over 60 years old? */ + const val AGE_OVER_60 = "age_over_60" + + /** Age attestation: Over 62 years old? */ + const val AGE_OVER_62 = "age_over_62" + + /** Age attestation: Over 65 years old? */ + const val AGE_OVER_65 = "age_over_65" + + /** Age attestation: Over 68 years old? */ + const val AGE_OVER_68 = "age_over_68" + + /** Country subdivision code of the jurisdiction that issued the mDL as defined in ISO 3166-2:2020, Clause 8. */ + const val ISSUING_JURISDICTION = "issuing_jurisdiction" + + /** Nationality of the mDL holder as a two letter country code (alpha-2 code) defined in ISO 3166-1. */ + const val NATIONALITY = "nationality" + + /** The city where the mDL holder lives. */ + const val RESIDENT_CITY = "resident_city" + + /** The state/province/district where the mDL holder lives. */ + const val RESIDENT_STATE = "resident_state" + + /** The postal code of the mDL holder. */ + const val RESIDENT_POSTAL_CODE = "resident_postal_code" + + /** The country where the mDL holder lives as a two letter country code (alpha-2 code) defined in ISO 3166-1. */ + const val RESIDENT_COUNTRY = "resident_country" + + /** The family name of the mDL holder using full UTF-8 character set. */ + const val FAMILY_NAME_NATIONAL_CHARACTER = "family_name_national_character" + + /** The given name of the mDL holder using full UTF-8 character set. */ + const val GIVEN_NAME_NATIONAL_CHARACTER = "given_name_national_character" + + /** Image of the signature or usual mark of the mDL holder. */ + const val SIGNATURE_USUAL_MARK = "signature_usual_mark" + + /** This element contains optional facial information of the mDL holder. */ + const val BIOMETRIC_TEMPLATE_FACE = "biometric_template_face" + + /** This element contains optional fingerprint information of the mDL holder. */ + const val BIOMETRIC_TEMPLATE_FINGER = "biometric_template_finger" + + /** This element contains optional signature/sign information of the mDL holder. */ + const val BIOMETRIC_TEMPLATE_SIGNATURE_SIGN = "biometric_template_signature_sign" + + /** This element contains optional iris information of the mDL holder. */ + const val BIOMETRIC_TEMPLATE_IRIS = "biometric_template_iris" + + val ALL_ELEMENTS = listOf( + FAMILY_NAME, + GIVEN_NAME, + BIRTH_DATE, + ISSUE_DATE, + EXPIRY_DATE, + ISSUING_COUNTRY, + ISSUING_AUTHORITY, + DOCUMENT_NUMBER, + PORTRAIT, + DRIVING_PRIVILEGES, + UN_DISTINGUISHING_SIGN, + ADMINISTRATIVE_NUMBER, + SEX, + HEIGHT, + WEIGHT, + EYE_COLOUR, + HAIR_COLOUR, + BIRTH_PLACE, + RESIDENT_ADDRESS, + PORTRAIT_CAPTURE_DATE, + AGE_IN_YEARS, + AGE_BIRTH_YEAR, + AGE_OVER_12, + AGE_OVER_13, + AGE_OVER_14, + AGE_OVER_16, + AGE_OVER_18, + AGE_OVER_21, + AGE_OVER_25, + AGE_OVER_60, + AGE_OVER_62, + AGE_OVER_65, + AGE_OVER_68, + ISSUING_JURISDICTION, + NATIONALITY, + RESIDENT_CITY, + RESIDENT_STATE, + RESIDENT_POSTAL_CODE, + RESIDENT_COUNTRY, + FAMILY_NAME_NATIONAL_CHARACTER, + GIVEN_NAME_NATIONAL_CHARACTER, + SIGNATURE_USUAL_MARK, + BIOMETRIC_TEMPLATE_FACE, + BIOMETRIC_TEMPLATE_FINGER, + BIOMETRIC_TEMPLATE_SIGNATURE_SIGN, + BIOMETRIC_TEMPLATE_IRIS, + ) + + val MANDATORY_ELEMENTS = listOf( + FAMILY_NAME, + GIVEN_NAME, + BIRTH_DATE, + ISSUE_DATE, + EXPIRY_DATE, + ISSUING_COUNTRY, + ISSUING_AUTHORITY, + DOCUMENT_NUMBER, + PORTRAIT, + DRIVING_PRIVILEGES, + UN_DISTINGUISHING_SIGN, + ) +} diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceJws.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceJws.kt new file mode 100644 index 000000000..98cecd413 --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceJws.kt @@ -0,0 +1,25 @@ +package at.asitplus.wallet.mdl + +import at.asitplus.signum.indispensable.io.InstantLongSerializer +import at.asitplus.wallet.lib.data.NullableInstantLongSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +/** + * JWS representation of a [MobileDrivingLicence], used e.g. in the payload of a JWS in a single + * instance of [at.asitplus.iso.ServerResponse.documents] + */ +@Serializable +data class MobileDrivingLicenceJws( + @SerialName("doctype") + val doctype: String, + @SerialName("namespaces") + val namespaces: MobileDrivingLicenceJwsNamespace, + @SerialName("iat") + @Serializable(with = InstantLongSerializer::class) + val issuedAt: Instant, + @SerialName("exp") + @Serializable(with = NullableInstantLongSerializer::class) + val expiration: Instant?, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceJwsNamespace.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceJwsNamespace.kt new file mode 100644 index 000000000..43b26c24b --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceJwsNamespace.kt @@ -0,0 +1,13 @@ +package at.asitplus.wallet.mdl + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * JWS representation of a [MobileDrivingLicence]. + */ +@Serializable +data class MobileDrivingLicenceJwsNamespace( + @SerialName(MDL_NAMESPACE) + val mdl: MobileDrivingLicence, +) \ No newline at end of file diff --git a/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt new file mode 100644 index 000000000..8b8b23f5a --- /dev/null +++ b/vck/src/commonMain/kotlin/at/asitplus/wallet/mdl/MobileDrivingLicenceScheme.kt @@ -0,0 +1,136 @@ +package at.asitplus.wallet.mdl + +import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer +import at.asitplus.wallet.lib.InternalHelpers.mandatoryElementsIso +import at.asitplus.wallet.lib.InternalHelpers.optionalElementsIso +import at.asitplus.wallet.lib.IsoNamespaceToElementIdentifierToItemValueSerializerMap +import at.asitplus.wallet.lib.JsonValueEncoder +import at.asitplus.wallet.sdjwt.CredentialFormatEnum +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataClaimInformationList +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataVckExtensions +import at.asitplus.wallet.sdjwt.SdJwtVcType +import kotlinx.datetime.LocalDate +import kotlinx.serialization.builtins.ArraySerializer +import kotlinx.serialization.builtins.ByteArraySerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.encodeToJsonElement + + +@Deprecated( + "Replace with type metadata document", + level = DeprecationLevel.ERROR +) +object MobileDrivingLicenceScheme + +/** `org.iso.18013.5.1.mDL` */ +const val MDL_DOCTYPE: String = "org.iso.18013.5.1.mDL" + +/** `org.iso.18013.5.1` */ +const val MDL_NAMESPACE: String = "org.iso.18013.5.1" + +val MobileDrivingLicenceMetadataDocument: Pair = + SdJwtVcType(MDL_DOCTYPE) to SdJwtTypeMetadataDocument( + originalBytes = ByteArray(0), + definition = SdJwtTypeMetadataDefinition( + vct = SdJwtVcType(MDL_DOCTYPE), + claims = SdJwtTypeMetadataClaimInformationList( + mandatoryElementsIso( + MDL_NAMESPACE, + MobileDrivingLicenceDataElements.FAMILY_NAME, + MobileDrivingLicenceDataElements.GIVEN_NAME, + MobileDrivingLicenceDataElements.BIRTH_DATE, + MobileDrivingLicenceDataElements.ISSUE_DATE, + MobileDrivingLicenceDataElements.EXPIRY_DATE, + MobileDrivingLicenceDataElements.ISSUING_COUNTRY, + MobileDrivingLicenceDataElements.ISSUING_AUTHORITY, + MobileDrivingLicenceDataElements.DOCUMENT_NUMBER, + MobileDrivingLicenceDataElements.PORTRAIT, + MobileDrivingLicenceDataElements.DRIVING_PRIVILEGES, + MobileDrivingLicenceDataElements.UN_DISTINGUISHING_SIGN + ) + optionalElementsIso( + MDL_NAMESPACE, + MobileDrivingLicenceDataElements.ADMINISTRATIVE_NUMBER, + MobileDrivingLicenceDataElements.SEX, + MobileDrivingLicenceDataElements.HEIGHT, + MobileDrivingLicenceDataElements.WEIGHT, + MobileDrivingLicenceDataElements.EYE_COLOUR, + MobileDrivingLicenceDataElements.HAIR_COLOUR, + MobileDrivingLicenceDataElements.BIRTH_PLACE, + MobileDrivingLicenceDataElements.RESIDENT_ADDRESS, + MobileDrivingLicenceDataElements.PORTRAIT_CAPTURE_DATE, + MobileDrivingLicenceDataElements.AGE_IN_YEARS, + MobileDrivingLicenceDataElements.AGE_BIRTH_YEAR, + MobileDrivingLicenceDataElements.AGE_OVER_12, + MobileDrivingLicenceDataElements.AGE_OVER_13, + MobileDrivingLicenceDataElements.AGE_OVER_14, + MobileDrivingLicenceDataElements.AGE_OVER_16, + MobileDrivingLicenceDataElements.AGE_OVER_18, + MobileDrivingLicenceDataElements.AGE_OVER_21, + MobileDrivingLicenceDataElements.AGE_OVER_25, + MobileDrivingLicenceDataElements.AGE_OVER_60, + MobileDrivingLicenceDataElements.AGE_OVER_62, + MobileDrivingLicenceDataElements.AGE_OVER_65, + MobileDrivingLicenceDataElements.AGE_OVER_68, + MobileDrivingLicenceDataElements.ISSUING_JURISDICTION, + MobileDrivingLicenceDataElements.NATIONALITY, + MobileDrivingLicenceDataElements.RESIDENT_CITY, + MobileDrivingLicenceDataElements.RESIDENT_STATE, + MobileDrivingLicenceDataElements.RESIDENT_POSTAL_CODE, + MobileDrivingLicenceDataElements.RESIDENT_COUNTRY, + MobileDrivingLicenceDataElements.FAMILY_NAME_NATIONAL_CHARACTER, + MobileDrivingLicenceDataElements.GIVEN_NAME_NATIONAL_CHARACTER, + MobileDrivingLicenceDataElements.SIGNATURE_USUAL_MARK, + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_FACE, + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_FINGER, + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_SIGNATURE_SIGN, + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_IRIS + ) + ), + vckExtensions = SdJwtTypeMetadataVckExtensions( + format = CredentialFormatEnum.MSO_MDOC, + isoDocType = MDL_DOCTYPE, + isoNamespace = MDL_NAMESPACE + ) + ) + ) + +val MobileDrivingLicenceItemValueSerializerMap: IsoNamespaceToElementIdentifierToItemValueSerializerMap = mapOf( + MDL_NAMESPACE to mapOf( + MobileDrivingLicenceDataElements.BIRTH_DATE to LocalDate.serializer(), + MobileDrivingLicenceDataElements.ISSUE_DATE to LocalDate.serializer(), + MobileDrivingLicenceDataElements.EXPIRY_DATE to LocalDate.serializer(), + MobileDrivingLicenceDataElements.PORTRAIT to ByteArraySerializer(), + MobileDrivingLicenceDataElements.DRIVING_PRIVILEGES to ArraySerializer(DrivingPrivilege.serializer()), + MobileDrivingLicenceDataElements.SEX to IsoSexEnumSerializer, + MobileDrivingLicenceDataElements.HEIGHT to UInt.serializer(), + MobileDrivingLicenceDataElements.WEIGHT to UInt.serializer(), + MobileDrivingLicenceDataElements.PORTRAIT_CAPTURE_DATE to LocalDate.serializer(), + MobileDrivingLicenceDataElements.AGE_IN_YEARS to UInt.serializer(), + MobileDrivingLicenceDataElements.AGE_BIRTH_YEAR to UInt.serializer(), + MobileDrivingLicenceDataElements.SIGNATURE_USUAL_MARK to ByteArraySerializer(), + MobileDrivingLicenceDataElements.AGE_OVER_12 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_13 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_14 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_16 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_18 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_21 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_25 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_60 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_62 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_65 to Boolean.serializer(), + MobileDrivingLicenceDataElements.AGE_OVER_68 to Boolean.serializer(), + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_FACE to ByteArraySerializer(), + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_FINGER to ByteArraySerializer(), + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_SIGNATURE_SIGN to ByteArraySerializer(), + MobileDrivingLicenceDataElements.BIOMETRIC_TEMPLATE_IRIS to ByteArraySerializer(), + ) +) + +val MobileDrivingLicenceJsonValueEncoder: JsonValueEncoder = { + when (it) { + is DrivingPrivilege -> joseCompliantSerializer.encodeToJsonElement(it) + else -> null + } +} \ No newline at end of file diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/LibraryInitializerTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/LibraryInitializerTest.kt index 8facf2ba2..26270da9e 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/LibraryInitializerTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/LibraryInitializerTest.kt @@ -8,10 +8,10 @@ import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer import at.asitplus.signum.indispensable.josef.io.joseCompliantSerializer import at.asitplus.testballoon.matrix.matrixSuite import at.asitplus.wallet.lib.data.AttributeIndex +import at.asitplus.wallet.lib.data.ConstantIndex import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT import at.asitplus.wallet.lib.data.CredentialRepresentation -import at.asitplus.wallet.lib.data.CredentialScheme import at.asitplus.wallet.lib.data.JsonCredentialSerializer import com.benasher44.uuid.uuid4 import io.kotest.matchers.shouldBe @@ -27,7 +27,7 @@ private data class TestCredentialScheme( override val isoDocType: String? = null, override val claimNames: Collection = emptyList(), override val supportedRepresentations: Collection = listOf(PLAIN_JWT), -) : CredentialScheme +) : ConstantIndex.CredentialScheme val LibraryInitializerTest by matrixSuite { "registerExtensionLibrary registers schemes without serializer modules" { diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/Iso18013SpecTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/Iso18013SpecTest.kt index d566dbdf0..c9c3aa6fe 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/Iso18013SpecTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/cbor/Iso18013SpecTest.kt @@ -10,6 +10,7 @@ import at.asitplus.iso.ValueDigestList import at.asitplus.signum.indispensable.cosef.CoseSigned import at.asitplus.signum.indispensable.cosef.io.coseCompliantSerializer import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.mdl.MDL_NAMESPACE import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.matthewnelson.encoding.base16.Base16 @@ -26,7 +27,7 @@ val Iso18013SpecTest by matrixSuite { "issue_date" to LocalDate.serializer(), "expiry_date" to LocalDate.serializer(), ), - isoNamespace = "org.iso.18013.5.1" + isoNamespace = MDL_NAMESPACE ) // From ISO/IEC 18013-5:2021(E), D4.1.1, page 115 diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SdJwtTypeMetadataSerializationTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SdJwtTypeMetadataSerializationTest.kt index 1d499fd12..77e3e2601 100644 --- a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SdJwtTypeMetadataSerializationTest.kt +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/SdJwtTypeMetadataSerializationTest.kt @@ -228,7 +228,9 @@ val SdJwtTypeMetadataDocumentSerializationTest by matrixSuite { joseCompliantSerializer.decodeFromString( SdJwtTypeMetadataDocument.serializer(), input - ).definition.toSdJwtTypeMetadata().toCredentialScheme().apply { + ).definition.toSdJwtTypeMetadata() + .toCredentialScheme("https://metadata.example.test/ehic.json") + .apply { SD_JWT shouldBeIn supportedRepresentations sdJwtType shouldBe "urn:eudi:ehic:1" claimDescriptions shouldHaveSize 13 diff --git a/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/StaticCredentialMetadataRegistryTest.kt b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/StaticCredentialMetadataRegistryTest.kt new file mode 100644 index 000000000..a652d3efd --- /dev/null +++ b/vck/src/commonTest/kotlin/at/asitplus/wallet/lib/data/StaticCredentialMetadataRegistryTest.kt @@ -0,0 +1,202 @@ +package at.asitplus.wallet.lib.data + +import at.asitplus.testballoon.matrix.matrixSuite +import at.asitplus.wallet.lib.LibraryInitializer +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.ISO_MDOC +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.PLAIN_JWT +import at.asitplus.wallet.lib.data.ConstantIndex.CredentialRepresentation.SD_JWT +import at.asitplus.wallet.sdjwt.CredentialFormatEnum +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDefinition +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocument +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataDocumentRegistry +import at.asitplus.wallet.sdjwt.SdJwtTypeMetadataVckExtensions +import at.asitplus.wallet.sdjwt.SdJwtVcType +import at.asitplus.wallet.sdjwt.W3cSubresourceIntegrityMetadata +import com.benasher44.uuid.uuid4 +import io.kotest.matchers.collections.shouldBeEmpty +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe + +val StaticCredentialMetadataRegistryTest by matrixSuite { + + "static registry resolves SD-JWT metadata through AttributeIndex" { + val vct = SdJwtVcType("urn:test:sd-jwt:${uuid4()}") + val loadedFrom = "https://metadata.example.test/${uuid4()}/sd-jwt.json" + LibraryInitializer.registerCredentialMetadataRegistry( + StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry(vct to metadataDocument(vct)), + documentUrls = mapOf(vct to loadedFrom), + ) + ) + + AttributeIndex.resolveSdJwtAttributeType(vct.string).shouldNotBeNull().apply { + schemaUri shouldBe loadedFrom + sdJwtType shouldBe vct.string + } + + AttributeIndex.resolveIdentifier(vct.string, SD_JWT).apply { + schemaUri shouldBe loadedFrom + sdJwtType shouldBe vct.string + } + } + + "preload defers integrity-pinned entries to the checked findEntry path" { + val vct = SdJwtVcType("urn:test:sd-jwt:${uuid4()}") + val loadedFrom = "https://metadata.example.test/${uuid4()}/sd-jwt.json" + val registry = StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry(vct to metadataDocument(vct)), + documentUrls = mapOf(vct to loadedFrom), + integrityMetadata = mapOf( + vct to W3cSubresourceIntegrityMetadata( + "sha384-H8BRh8j48O9oYatfu5AZzq6A9RINhZO5H16dQZngK7T62em8MUt1FLm52t+eX6xO" + ) + ), + ) + // Not eagerly preloaded: the integrity check is suspending, so an unchecked entry must not pass synchronously. + registry.preloadEntries().shouldBeEmpty() + } + + "static registry resolves W3C JWT metadata through AttributeIndex" { + val vct = SdJwtVcType("urn:test:w3c:${uuid4()}") + val vcType = "TestW3cCredential-${uuid4()}" + val loadedFrom = "https://metadata.example.test/${uuid4()}/w3c.json" + LibraryInitializer.registerCredentialMetadataRegistry( + StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + vct to metadataDocument( + vct = vct, + vckExtensions = SdJwtTypeMetadataVckExtensions( + format = CredentialFormatEnum.JWT_VC, + vcType = vcType, + ), + ) + ), + documentUrls = mapOf(vct to loadedFrom), + ) + ) + + AttributeIndex.resolveAttributeType(vcType).shouldNotBeNull().apply { + schemaUri shouldBe loadedFrom + this.vcType shouldBe vcType + } + + AttributeIndex.resolveIdentifierPlainJwt(listOf("VerifiableCredential", vcType)).apply { + schemaUri shouldBe loadedFrom + this.vcType shouldBe vcType + } + } + + "static registry resolves ISO mDoc metadata through AttributeIndex" { + val vct = SdJwtVcType("urn:test:iso:${uuid4()}") + val docType = "org.example.${uuid4()}.credential" + val namespace = "org.example.${uuid4()}" + val loadedFrom = "https://metadata.example.test/${uuid4()}/iso.json" + val registry = StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + vct to metadataDocument( + vct = vct, + vckExtensions = SdJwtTypeMetadataVckExtensions( + format = CredentialFormatEnum.MSO_MDOC, + isoDocType = docType, + isoNamespace = namespace, + ), + ) + ), + documentUrls = mapOf(vct to loadedFrom), + ) + registry.findEntry(docType, ISO_MDOC).shouldNotBeNull() + LibraryInitializer.registerCredentialMetadataRegistry( + registry + ) + + AttributeIndex.resolveIsoDoctype(docType).shouldNotBeNull().apply { + schemaUri shouldBe loadedFrom + isoDocType shouldBe docType + isoNamespace shouldBe namespace + } + + AttributeIndex.resolveIdentifier(docType, ISO_MDOC).apply { + schemaUri shouldBe loadedFrom + isoDocType shouldBe docType + isoNamespace shouldBe namespace + } + } + + // Regression for PR #566: a child that `extends` a base and relies on inherited `vckExtensions` (no own `vck` + // block) must still be matchable by the inherited isoDocType — the matcher has to resolve the chain, not inspect + // the unresolved child whose `vckExtensions` is null. + "static registry matches an extending child by inherited ISO docType" { + val baseVct = SdJwtVcType("urn:test:iso:base:${uuid4()}") + val childVct = SdJwtVcType("urn:test:iso:child:${uuid4()}") + val docType = "org.example.${uuid4()}.credential" + val namespace = "org.example.${uuid4()}" + val childUrl = "https://metadata.example.test/${uuid4()}/child.json" + val registry = StaticCredentialMetadataRegistry( + // Child first, so a passing match proves the child (not the base) was selected. + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + childVct to metadataDocument(vct = childVct, extends = baseVct), + baseVct to metadataDocument( + vct = baseVct, + vckExtensions = SdJwtTypeMetadataVckExtensions( + format = CredentialFormatEnum.MSO_MDOC, + isoDocType = docType, + isoNamespace = namespace, + ), + ), + ), + documentUrls = mapOf( + childVct to childUrl, + baseVct to "https://metadata.example.test/${uuid4()}/base.json", + ), + ) + + registry.findEntry(docType, ISO_MDOC).shouldNotBeNull().apply { + metadata.vct shouldBe childVct + loadedFrom shouldBe childUrl + metadata.vckExtensions?.isoDocType shouldBe docType + } + } + + // Same inheritance bug for the W3C JWT path: the child must be matchable by the inherited vcType. + "static registry matches an extending child by inherited W3C vcType" { + val baseVct = SdJwtVcType("urn:test:w3c:base:${uuid4()}") + val childVct = SdJwtVcType("urn:test:w3c:child:${uuid4()}") + val vcType = "TestW3cCredential-${uuid4()}" + val childUrl = "https://metadata.example.test/${uuid4()}/child.json" + val registry = StaticCredentialMetadataRegistry( + documentRegistry = SdJwtTypeMetadataDocumentRegistry( + childVct to metadataDocument(vct = childVct, extends = baseVct), + baseVct to metadataDocument( + vct = baseVct, + vckExtensions = SdJwtTypeMetadataVckExtensions( + format = CredentialFormatEnum.JWT_VC, + vcType = vcType, + ), + ), + ), + documentUrls = mapOf( + childVct to childUrl, + baseVct to "https://metadata.example.test/${uuid4()}/base.json", + ), + ) + + registry.findEntry(vcType, PLAIN_JWT).shouldNotBeNull().apply { + metadata.vct shouldBe childVct + loadedFrom shouldBe childUrl + metadata.vckExtensions?.vcType shouldBe vcType + } + } +} + +private fun metadataDocument( + vct: SdJwtVcType, + vckExtensions: SdJwtTypeMetadataVckExtensions? = null, + extends: SdJwtVcType? = null, +) = SdJwtTypeMetadataDocument( + originalBytes = ByteArray(0), + definition = SdJwtTypeMetadataDefinition( + vct = vct, + extends = extends, + vckExtensions = vckExtensions, + ), +)