From fa6a46e3f05caae63711b06ae65fc62971f68190 Mon Sep 17 00:00:00 2001 From: Dimitris ZARRAS <138439389+dzarras@users.noreply.github.com> Date: Thu, 18 Jan 2024 19:31:33 +0200 Subject: [PATCH] Add support for mDL issuance (#79) --- README.md | 20 +- .../realms/pid-issuer-realm-realm.json | 80 ++- .../ec/eudi/pidissuer/PidIssuerApplication.kt | 27 +- .../adapter/out/jose/ECKeyExtensions.kt | 34 ++ .../mdl/EncodeMobileDrivingLicenceInCbor.kt | 29 ++ ...ileDrivingLicenceInCborWithMicroservice.kt | 222 +++++++++ .../out/mdl/GetMobileDrivingLicenceData.kt | 33 ++ .../mdl/GetMobileDrivingLicenceDataMock.kt | 112 +++++ .../out/mdl/IssueMobileDrivingLicence.kt | 305 ++++++++++++ .../adapter/out/mdl/MobileDrivingLicence.kt | 466 ++++++++++++++++++ .../pid/EncodePidInCborWithMicroService.kt | 19 +- .../resources/application-prod.properties | 2 + src/main/resources/application.properties | 3 +- .../pidissuer/adapter/out/mdl/Portrait.jpg | Bin 0 -> 18861 bytes .../pidissuer/adapter/out/mdl/Signature.jpg | Bin 0 -> 13619 bytes .../adapter/out/jose/ECKeyExtensionsTest.kt | 38 ++ .../GetMobileDrivingLicenceDataMockTest.kt | 35 ++ .../out/mdl/IssueMobileDrivingLicenceTest.kt | 52 ++ .../EncodePidInCborWithMicroServiceTest.kt | 5 - src/test/resources/logback-test.xml | 15 + 20 files changed, 1459 insertions(+), 38 deletions(-) create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensions.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCbor.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCborWithMicroservice.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceData.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMock.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicence.kt create mode 100644 src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/MobileDrivingLicence.kt create mode 100644 src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Portrait.jpg create mode 100644 src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Signature.jpg create mode 100644 src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensionsTest.kt create mode 100644 src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMockTest.kt create mode 100644 src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicenceTest.kt create mode 100644 src/test/resources/logback-test.xml diff --git a/README.md b/README.md index b9926688..4545afd6 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,15 @@ the [EUDI Wallet Reference Implementation project description](https://github.co An implementation of a credential issuing service, according to [OpenId4VCI - draft12](https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-wg-draft.html) -The service provides generic support for `mso_mdoc` and `SD-JWT-VC` formats using PID as an example +The service provides generic support for `mso_mdoc` and `SD-JWT-VC` formats using PID and mDL as an example and requires the use of a suitable OAUTH2 server. +| Credential/Attestation | Format | +|------------------------|-----------| +| PID | mso_mdoc | +| PID | SD-JWT-VC | +| mDL | mso_mdoc | + ### OpenId4VCI coverage | Feature | Coverage | @@ -45,7 +51,8 @@ A Keycloak instance accessible via https://localhost/idp/ with the Realm *pid-is The Realm *pid-issuer-realm*: -- has user self-registration active with a custom registration page accessible via https://localhost/idp/realms/pid-issuer-realm/account/#/ +- has user self-registration active with a custom registration page accessible + via https://localhost/idp/realms/pid-issuer-realm/account/#/ - defines *eu.europa.ec.eudiw.pid_vc_sd_jwt* scope for requesting PID issuance in SD JWT VC format - defines *eu.europa.ec.eudiw.pid_mso_mdoc* scope for requesting PID issuance in MSO MDOC format - defines *wallet-dev* and *pid-issuer-srv* clients @@ -53,12 +60,12 @@ The Realm *pid-issuer-realm*: Administration console is accessible via https://localhost/idp/admin/ using the credentials admin / password -### PID Issuer +### PID mDL Issuer -A PID Issuer instance accessible via https://localhost/pid-issuer/ +A PID mDL Issuer instance accessible via https://localhost/pid-issuer/ -It uses the configured Keycloak instance as an Authorization Server, and PID issuance both *SD JWT VC* and *MSO MDOC* -formats is enabled. Additionally *deferred issuance* is enabled for *SD JWT VC* format. +It uses the configured Keycloak instance as an Authorization Server, and supports issuing of PID and mDL. +Additionally, *deferred issuance* is enabled for PID in *SD JWT VC* format. The issuing country is set to GR (Greece). @@ -147,7 +154,6 @@ curl http://localhost:8080/.well-known/openid-credential-issuer | jq . ### Credential Endpoint - ### Credentials Offer Generate sample offer diff --git a/docker-compose/keycloak/realms/pid-issuer-realm-realm.json b/docker-compose/keycloak/realms/pid-issuer-realm-realm.json index a7adbe1b..0cbe1a11 100644 --- a/docker-compose/keycloak/realms/pid-issuer-realm-realm.json +++ b/docker-compose/keycloak/realms/pid-issuer-realm-realm.json @@ -495,6 +495,12 @@ "roles": [ "eid-holder-natural-person" ] + }, + { + "clientScope": "org.iso.18013.5.1.mDL", + "roles": [ + "eid-holder-natural-person" + ] } ], "clientScopeMappings": { @@ -709,7 +715,8 @@ ], "optionalClientScopes": [ "eu.europa.ec.eudiw.pid_vc_sd_jwt", - "eu.europa.ec.eudiw.pid_mso_mdoc" + "eu.europa.ec.eudiw.pid_mso_mdoc", + "org.iso.18013.5.1.mDL" ] }, { @@ -1012,7 +1019,8 @@ "optionalClientScopes": [ "roles", "eu.europa.ec.eudiw.pid_vc_sd_jwt", - "eu.europa.ec.eudiw.pid_mso_mdoc" + "eu.europa.ec.eudiw.pid_mso_mdoc", + "org.iso.18013.5.1.mDL" ] } ], @@ -1364,6 +1372,71 @@ } ] }, + { + "id": "261a329e-327b-43fa-849b-5c3c8748c663", + "name": "org.iso.18013.5.1.mDL", + "description": "", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "true", + "gui.order": "", + "consent.screen.text": "Do you consent to issue mDL?" + }, + "protocolMappers": [ + { + "id": "d06095b4-af59-40e1-ad1a-017c5c1f8473", + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "multivalued": "false", + "user.attribute": "firstName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "id": "7b14d41e-74ec-4cf8-bc07-9afb932a797e", + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "multivalued": "false", + "user.attribute": "lastName", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "family_name", + "jsonType.label": "String" + } + }, + { + "id": "1ab7730f-9a35-4587-86de-1fc2db219989", + "name": "email", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "aggregate.attrs": "false", + "userinfo.token.claim": "true", + "multivalued": "false", + "user.attribute": "email", + "id.token.claim": "false", + "access.token.claim": "false", + "claim.name": "email", + "jsonType.label": "String" + } + } + ] + }, { "id": "00bf2e53-5336-47ef-819f-3f1823a2cc81", "name": "roles", @@ -1419,7 +1492,8 @@ ], "defaultOptionalClientScopes": [ "eu.europa.ec.eudiw.pid_mso_mdoc", - "eu.europa.ec.eudiw.pid_vc_sd_jwt" + "eu.europa.ec.eudiw.pid_vc_sd_jwt", + "org.iso.18013.5.1.mDL" ], "browserSecurityHeaders": { "contentSecurityPolicyReportOnly": "", diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt index 0257d798..1991d268 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/PidIssuerApplication.kt @@ -27,6 +27,9 @@ import eu.europa.ec.eudi.pidissuer.adapter.input.web.MetaDataApi import eu.europa.ec.eudi.pidissuer.adapter.input.web.WalletApi import eu.europa.ec.eudi.pidissuer.adapter.out.jose.DefaultExtractJwkFromCredentialKey import eu.europa.ec.eudi.pidissuer.adapter.out.jose.EncryptCredentialResponseWithNimbus +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.EncodeMobileDrivingLicenceInCborWithMicroservice +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.GetMobileDrivingLicenceDataMock +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.IssueMobileDrivingLicence import eu.europa.ec.eudi.pidissuer.adapter.out.persistence.InMemoryCNonceRepository import eu.europa.ec.eudi.pidissuer.adapter.out.persistence.InMemoryDeferredCredentialRepository import eu.europa.ec.eudi.pidissuer.adapter.out.pid.* @@ -77,7 +80,7 @@ private val log = LoggerFactory.getLogger(PidIssuerApplication::class.java) /** * [WebClient] instances for usage within the application. */ -private object WebClients { +internal object WebClients { /** * A [WebClient] with [Json] serialization enabled. @@ -129,6 +132,18 @@ fun beans(clock: Clock) = beans { ref(), ) } + bean { + EncodePidInCborWithMicroService(env.readRequiredUrl("issuer.pid.mso_mdoc.encoderUrl"), ref()) + } + bean { + GetMobileDrivingLicenceDataMock() + } + bean { + EncodeMobileDrivingLicenceInCborWithMicroservice( + ref(), + env.readRequiredUrl("issuer.mdl.mso_mdoc.encoderUrl"), + ) + } // // Encryption of credential response // @@ -161,10 +176,6 @@ fun beans(clock: Clock) = beans { bean { val issuerPublicUrl = env.readRequiredUrl("issuer.publicUrl", removeTrailingSlash = true) - bean { - EncodePidInCborWithMicroService(env.readRequiredUrl("issuer.pid.mso_mdoc.encoderUrl"), ref()) - } - CredentialIssuerMetaData( id = issuerPublicUrl, credentialEndPoint = issuerPublicUrl.appendPath(WalletApi.CREDENTIAL_ENDPOINT), @@ -216,6 +227,12 @@ fun beans(clock: Clock) = beans { else issueSdJwtVcPid, ) } + + val enableMobileDrivingLicence = env.getProperty("issuer.mdl.enabled", true) + if (enableMobileDrivingLicence) { + val mdlIssuer = IssueMobileDrivingLicence(issuerPublicUrl, ref(), ref()) + add(mdlIssuer) + } }, ) } diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensions.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensions.kt new file mode 100644 index 00000000..88dff0b3 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensions.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.jose + +import com.nimbusds.jose.jwk.ECKey +import org.bouncycastle.util.io.pem.PemObject +import org.bouncycastle.util.io.pem.PemWriter +import java.io.StringWriter +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +internal fun ECKey.toPem(): String = + StringWriter().use { stringWriter -> + PemWriter(stringWriter).use { pemWriter -> + pemWriter.writeObject(PemObject("PUBLIC KEY", this.toECPublicKey().encoded)) + } + stringWriter.toString() + } + +@OptIn(ExperimentalEncodingApi::class) +internal fun ECKey.toBase64UrlSafeEncodedPem(): String = Base64.UrlSafe.encode(this.toPem().toByteArray()) diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCbor.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCbor.kt new file mode 100644 index 00000000..843f2b71 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCbor.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.mdl + +import arrow.core.raise.Raise +import com.nimbusds.jose.jwk.ECKey +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError + +/** + * Encodes a Mobile Driving Licence in CBOR format. + */ +fun interface EncodeMobileDrivingLicenceInCbor { + + context(Raise) + suspend operator fun invoke(licence: MobileDrivingLicence, holderKey: ECKey): String +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCborWithMicroservice.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCborWithMicroservice.kt new file mode 100644 index 00000000..73ade1c6 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/EncodeMobileDrivingLicenceInCborWithMicroservice.kt @@ -0,0 +1,222 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.mdl + +import arrow.core.Either +import arrow.core.getOrElse +import arrow.core.left +import arrow.core.raise.Raise +import arrow.core.right +import com.nimbusds.jose.jwk.ECKey +import eu.europa.ec.eudi.pidissuer.adapter.out.jose.toBase64UrlSafeEncodedPem +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.DrivingPrivilege.Restriction +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.DrivingPrivilege.Restriction.GenericRestriction +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.DrivingPrivilege.Restriction.ParameterizedRestriction +import eu.europa.ec.eudi.pidissuer.domain.HttpsUrl +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.* +import org.slf4j.LoggerFactory +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.BodyInserters +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.awaitBody +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Implementation of [EncodeMobileDrivingLicenceInCbor] using a microservice. + */ +class EncodeMobileDrivingLicenceInCborWithMicroservice( + private val webClient: WebClient, + private val service: HttpsUrl, +) : EncodeMobileDrivingLicenceInCbor { + init { + log.info("Initialized using: {}", service) + } + + context(Raise) + override suspend fun invoke( + licence: MobileDrivingLicence, + holderKey: ECKey, + ): String { + log.info("Encoding mDL in CBOR") + val request = createRequest(licence, holderKey).also { log.debug("Request data {}", it) } + return Either.catch { + webClient.post() + .uri(service.value.toExternalForm()) + .contentType(MediaType.APPLICATION_JSON) + .body(BodyInserters.fromValue(request)) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .awaitBody() + .also { log.debug("Response data {}", it) } + .fold() + .bind() + }.getOrElse { raise(IssueCredentialError.Unexpected("Unable to encode mDL in CBOR", it)) } + } + + companion object { + + private val log = LoggerFactory.getLogger(EncodeMobileDrivingLicenceInCborWithMicroservice::class.java) + + private fun createRequest(licence: MobileDrivingLicence, holderKey: ECKey): Request = + buildJsonObject { + put("version", "0.3") + put("country", "FC") + put("doctype", MobileDrivingLicenceV1.docType) + put("device_publickey", holderKey.toBase64UrlSafeEncodedPem()) + + putJsonObject("data") { + putJsonObject(MobileDrivingLicenceV1Namespace) { + addDriver(licence.driver) + addIssueAndExpiry(licence.issueAndExpiry) + addIssuer(licence.issuer) + put("document_number", licence.documentNumber.value) + addDrivingPrivileges(licence.privileges) + licence.administrativeNumber?.let { put("administrative_number", it.value) } + } + } + } + + @OptIn(ExperimentalEncodingApi::class) + private fun JsonObjectBuilder.addDriver(driver: Driver) { + with(driver) { + put("family_name", familyName.latin.value) + put("given_name", givenName.latin.value) + put("birth_date", birthDate.toString()) + with(portrait) { + put("portrait", Base64.UrlSafe.encode(image.content)) + capturedAt?.let { put("portrait_capture_date", it.toString()) } + } + sex?.let { put("sex", it.code.toInt()) } + height?.let { put("height", it.value.toInt()) } + weight?.let { put("weight", it.value.toInt()) } + eyeColour?.let { put("eye_colour", it.code) } + hairColour?.let { put("hair_colour", it.code) } + birthPlace?.let { put("birth_place", it.value) } + residence?.let { residence -> + residence.address?.let { put("resident_address", it.value) } + residence.city?.let { put("resident_city", it.value) } + residence.state?.let { put("resident_state", it.value) } + residence.postalCode?.let { put("resident_postal_code", it.value) } + put("resident_country", residence.country.code) + } + age?.let { age -> + put("age_in_years", age.value.value.toInt()) + age.birthYear?.let { put("age_birth_year", it.value.toInt()) } + put("age_over_18", age.over18) + put("age_over_21", age.over21) + } + nationality?.let { put("nationality", it.code) } + familyName.utf8?.let { put("family_name_national_character", it) } + givenName.utf8?.let { put("given_name_national_character", it) } + signature?.let { put("signature_usual_mark", Base64.UrlSafe.encode(it.content)) } + } + } + + private fun JsonObjectBuilder.addIssueAndExpiry(issueAndExpiry: IssueAndExpiry) { + with(issueAndExpiry) { + put("issue_date", issuedAt.toString()) + put("expiry_date", expiresAt.toString()) + } + } + + private fun JsonObjectBuilder.addIssuer(issuer: Issuer) { + with(issuer) { + put("issuing_country", country.countryCode.code) + put("issuing_authority", authority.value) + put("un_distinguishing_sign", country.distinguishingSign.code) + jurisdiction?.let { put("issuing_jurisdiction", it.value) } + } + } + + private fun JsonObjectBuilder.addDrivingPrivileges(privileges: Set) { + putJsonArray("driving_privileges") { + privileges.forEach { drivingPrivilege -> + addJsonObject { + put("vehicle_category_code", drivingPrivilege.vehicleCategory.code) + + drivingPrivilege.issueAndExpiry?.let { issueAndExpiry -> + addIssueAndExpiry(issueAndExpiry) + } + + drivingPrivilege.restrictions?.let { restrictions -> + putJsonArray("codes") { + restrictions.forEach { restriction -> addRestriction(restriction) } + } + } + } + } + } + } + + private fun JsonArrayBuilder.addRestriction(restriction: Restriction) { + val (code, sign, value) = + when (restriction) { + is GenericRestriction -> Triple(restriction.code, null, null) + is ParameterizedRestriction.VehiclePower -> Triple( + restriction.code, + restriction.value.code, + restriction.value.value.value, + ) + + is ParameterizedRestriction.VehicleAuthorizedMass -> Triple( + restriction.code, + restriction.value.code, + restriction.value.value.value, + ) + + is ParameterizedRestriction.VehicleCylinderCapacity -> Triple( + restriction.code, + restriction.value.code, + restriction.value.value.value, + ) + + is ParameterizedRestriction.VehicleAuthorizedPassengerSeats -> Triple( + restriction.code, + restriction.value.code, + restriction.value.value.value, + ) + } + + addJsonObject { + put("code", code) + sign?.let { put("sign", sign) } + value?.let { put("value", value.toString()) } + } + } + } +} + +private typealias Request = JsonObject + +@Serializable +private data class Response( + @SerialName("error_code") val errorCode: Int? = null, + @SerialName("error_message") val errorMessage: String? = null, + @SerialName("mdoc") val mdoc: String? = null, +) { + fun fold(): Either = + if (!mdoc.isNullOrBlank()) { + mdoc.right() + } else { + IssueCredentialError.Unexpected( + "Unable to encode mDL in CBOR. Code: '$errorCode', Message: '$errorMessage'", + ).left() + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceData.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceData.kt new file mode 100644 index 00000000..9b9c4e79 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceData.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.mdl + +import arrow.core.raise.Raise +import eu.europa.ec.eudi.pidissuer.port.input.AuthorizationContext +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError + +/** + * Gets the data of the Mobile Driving Licence of an authorized user. + */ +fun interface GetMobileDrivingLicenceData { + + /** + * Gets the data of the Mobile Driving Licence of an authorized user. In case the authorized user + * has no Driving Licence, null is returned. + */ + context(Raise) + suspend operator fun invoke(context: AuthorizationContext): MobileDrivingLicence? +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMock.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMock.kt new file mode 100644 index 00000000..033d96bf --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMock.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.mdl + +import arrow.core.nonEmptySetOf +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.ensureNotNull +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.DrivingPrivilege.Restriction.GenericRestriction +import eu.europa.ec.eudi.pidissuer.adapter.out.mdl.DrivingPrivilege.Restriction.ParameterizedRestriction +import eu.europa.ec.eudi.pidissuer.port.input.AuthorizationContext +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.time.LocalDate +import java.time.Month + +/** + * Mock implementation for [GetMobileDrivingLicenceData]. + */ +class GetMobileDrivingLicenceDataMock : GetMobileDrivingLicenceData { + + context(Raise) + override suspend fun invoke(context: AuthorizationContext): MobileDrivingLicence { + log.info("Getting mock data for Mobile Driving Licence") + + val driver = Driver( + Latin150AndUtf8(Latin150("Georgiou"), "Γεωργίου"), + Latin150AndUtf8(Latin150("Georgios"), "Γεώργιος"), + LocalDate.of(1948, Month.MAY, 30), + Portrait(Image.Jpeg(loadResource("/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Portrait.jpg"))), + Sex.MALE, + 175u.cm(), + 80u.kg(), + EyeColour.BROWN, + HairColour.GREY, + null, + Age(79u.natural(), 1948u.natural()), + IsoAlpha2CountryCode("GR"), + Residence( + IsoAlpha2CountryCode("GR"), + ), + Image.Jpeg(loadResource("/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Signature.jpg")), + ) + + val issuer = Issuer( + IssuingCountry(IsoAlpha2CountryCode("GR"), DistinguishingSign("GR")), + Latin150("Ministry of Infrastructure and Transportation"), + ) + + val privileges = setOf( + DrivingPrivilege( + VehicleCategory.LIGHT_VEHICLE, + IssueAndExpiry(LocalDate.of(2000, Month.JANUARY, 1), LocalDate.of(2040, Month.DECEMBER, 31)), + nonEmptySetOf( + GenericRestriction.VEHICLE_WITH_AUTOMATIC_TRANSMISSION, + ParameterizedRestriction.VehicleAuthorizedPassengerSeats(Sign.LessThanOrEqualTo(5u.natural())), + ), + ), + DrivingPrivilege( + VehicleCategory.MOTORCYCLE, + IssueAndExpiry(LocalDate.of(2000, Month.JANUARY, 1), LocalDate.of(2040, Month.DECEMBER, 31)), + nonEmptySetOf( + ParameterizedRestriction.VehicleCylinderCapacity(Sign.LessThanOrEqualTo(125u.cm3())), + ), + ), + ) + + return MobileDrivingLicence( + driver, + IssueAndExpiry(LocalDate.of(2000, Month.JANUARY, 1), LocalDate.of(2040, Month.DECEMBER, 31)), + issuer, + Latin150("A123-4567-8900"), + privileges, + Latin150("12345678900"), + ) + } + + companion object { + + private val log = LoggerFactory.getLogger(GetMobileDrivingLicenceDataMock::class.java) + + context(Raise) + private suspend fun loadResource(path: String): ByteArray = + withContext(Dispatchers.IO) { + val portrait = + ensureNotNull(Companion::class.java.getResourceAsStream(path)) { + IssueCredentialError.Unexpected("Unable to load resource $path") + } + + portrait.use { + catch({ it.readAllBytes() }) { + raise(IssueCredentialError.Unexpected("Unable to read $path", it)) + } + } + } + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicence.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicence.kt new file mode 100644 index 00000000..a3019d2c --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicence.kt @@ -0,0 +1,305 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.mdl + +import arrow.core.nonEmptySetOf +import arrow.core.raise.Raise +import arrow.core.raise.catch +import arrow.core.raise.ensureNotNull +import com.nimbusds.jose.JWSAlgorithm +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.JWK +import eu.europa.ec.eudi.pidissuer.adapter.out.jose.ValidateProof +import eu.europa.ec.eudi.pidissuer.domain.* +import eu.europa.ec.eudi.pidissuer.port.input.AuthorizationContext +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError +import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError.InvalidProof +import eu.europa.ec.eudi.pidissuer.port.out.IssueSpecificCredential +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import org.slf4j.LoggerFactory +import java.util.* + +val MobileDrivingLicenceV1Scope: Scope = Scope(mdlDocType(1u)) + +val MobileDrivingLicenceV1Namespace: MsoNameSpace = mdlNamespace(1u) + +val MobileDrivingLicenceV1Attributes: List = listOf( + AttributeDetails( + name = "family_name", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Last name, surname, or primary identifier of the mDL holder."), + ), + AttributeDetails( + name = "given_name", + mandatory = true, + display = mapOf(Locale.ENGLISH to "First name(s), other name(s), or secondary identifier, of the mDL holder."), + ), + AttributeDetails( + name = "birth_date", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Day, month and year on which the mDL holder was born."), + ), + AttributeDetails( + name = "issue_date", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Date when mDL was issued."), + ), + AttributeDetails( + name = "expiry_date", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Date when mDL expires."), + ), + AttributeDetails( + name = "issuing_country", + mandatory = true, + display = buildMap { + put( + Locale.ENGLISH, + "Alpha-2 country code, as defined in ISO 3166-1 of the issuing authority’s country or territory.", + ) + }, + ), + AttributeDetails( + name = "issuing_authority", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Issuing authority name."), + ), + AttributeDetails( + name = "document_number", + mandatory = true, + display = mapOf(Locale.ENGLISH to "The number assigned or calculated by the issuing authority."), + ), + AttributeDetails( + name = "portrait", + mandatory = true, + display = mapOf(Locale.ENGLISH to "A reproduction of the mDL holder’s portrait."), + ), + AttributeDetails( + name = "driving_privileges", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Driving privileges of the mDL holder."), + ), + AttributeDetails( + name = "un_distinguishing_sign", + mandatory = true, + display = mapOf(Locale.ENGLISH to "Distinguishing sign of the issuing country according to ISO/IEC 18013-1:2018, Annex F."), + ), + AttributeDetails( + name = "administrative_number", + mandatory = false, + display = mapOf(Locale.ENGLISH to "An audit control number assigned by the issuing authority."), + ), + AttributeDetails( + name = "sex", + mandatory = false, + display = mapOf(Locale.ENGLISH to "mDL holder’s sex using values as defined in ISO/IEC 5218."), + ), + AttributeDetails( + name = "height", + mandatory = false, + display = mapOf(Locale.ENGLISH to "mDL holder’s height in centimetres."), + ), + AttributeDetails( + name = "weight", + mandatory = false, + display = mapOf(Locale.ENGLISH to "mDL holder’s weight in kilograms."), + ), + AttributeDetails( + name = "eye_colour", + mandatory = false, + display = mapOf(Locale.ENGLISH to "mDL holder’s eye colour."), + ), + AttributeDetails( + name = "hair_colour", + mandatory = false, + display = mapOf(Locale.ENGLISH to "mDL holder’s hair colour."), + ), + AttributeDetails( + name = "birth_place", + mandatory = false, + display = mapOf(Locale.ENGLISH to "Country and municipality or state/province where the mDL holder was born."), + ), + AttributeDetails( + name = "resident_address", + mandatory = false, + display = buildMap { + put( + Locale.ENGLISH, + "The place where the mDL holder resides and/or may be contacted (street/house number, municipality etc.).", + ) + }, + ), + AttributeDetails( + name = "portrait_capture_date", + mandatory = false, + display = mapOf(Locale.ENGLISH to "Date when portrait was taken."), + ), + AttributeDetails( + name = "age_in_years", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The age of the mDL holder."), + ), + AttributeDetails( + name = "age_birth_year", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The year when the mDL holder was born."), + ), + AttributeDetails( + name = "age_over_18", + mandatory = false, + display = mapOf(Locale.ENGLISH to "Whether the mDL holder is over 18 years old."), + ), + AttributeDetails( + name = "age_over_21", + mandatory = false, + display = mapOf(Locale.ENGLISH to "Whether the mDL holder is over 21 years old."), + ), + AttributeDetails( + name = "issuing_jurisdiction", + mandatory = false, + display = buildMap { + put( + Locale.ENGLISH, + "Country subdivision code of the jurisdiction that issued the mDL as defined in ISO 3166-2:2020, Clause 8.", + ) + }, + ), + AttributeDetails( + name = "nationality", + mandatory = false, + display = buildMap { + put( + Locale.ENGLISH, + "Nationality of the mDL holder as a two letter country code (alpha-2 code) defined in ISO 3166-1.", + ) + }, + ), + AttributeDetails( + name = "resident_city", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The city where the mDL holder lives."), + ), + AttributeDetails( + name = "resident_state", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The state/province/district where the mDL holder lives."), + ), + AttributeDetails( + name = "resident_postal_code", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The postal code of the mDL holder."), + ), + AttributeDetails( + name = "resident_country", + mandatory = false, + display = buildMap { + put( + Locale.ENGLISH, + "The country where the mDL holder lives as a two letter country code (alpha-2 code) defined in ISO 3166-1.", + ) + }, + ), + AttributeDetails( + name = "family_name_national_character", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The family name of the mDL holder using full UTF-8 character set."), + ), + AttributeDetails( + name = "given_name_national_character", + mandatory = false, + display = mapOf(Locale.ENGLISH to "The given name of the mDL holder using full UTF-8 character set."), + ), + AttributeDetails( + name = "signature_usual_mark", + mandatory = false, + display = mapOf(Locale.ENGLISH to "Image of the signature or usual mark of the mDL holder."), + ), +) + +val MobileDrivingLicenceDisplay: List = listOf( + CredentialDisplay( + name = DisplayName("Mobile Driving Licence", Locale.ENGLISH), + ), +) + +val MobileDrivingLicenceV1: MsoMdocMetaData = run { + val algorithms = nonEmptySetOf( + JWSAlgorithm.RS256, + JWSAlgorithm.ES256, + ) + MsoMdocMetaData( + id = CredentialUniqueId(MobileDrivingLicenceV1Scope.value), + docType = mdlDocType(1u), + display = MobileDrivingLicenceDisplay, + msoClaims = mapOf(MobileDrivingLicenceV1Namespace to MobileDrivingLicenceV1Attributes), + cryptographicBindingMethodsSupported = listOf( + CryptographicBindingMethod.Mso(algorithms), + CryptographicBindingMethod.Jwk(algorithms), + ), + scope = MobileDrivingLicenceV1Scope, + proofTypesSupported = setOf(ProofType.JWT), + ) +} + +/** + * Issuing service for Mobile Driving Licence. + */ +class IssueMobileDrivingLicence( + credentialIssuerId: CredentialIssuerId, + private val getMobileDrivingLicenceData: GetMobileDrivingLicenceData, + private val encodeMobileDrivingLicenceInCbor: EncodeMobileDrivingLicenceInCbor, +) : IssueSpecificCredential { + + override val supportedCredential: CredentialMetaData + get() = MobileDrivingLicenceV1 + + override val publicKey: JWK? + get() = null + + private val validateProof: ValidateProof = ValidateProof(credentialIssuerId) + + context(Raise) + override suspend fun invoke( + authorizationContext: AuthorizationContext, + request: CredentialRequest, + expectedCNonce: CNonce, + ): CredentialResponse { + log.info("Issuing mDL") + val holderKey = + catch({ + val credentialKey = validateProof(request.unvalidatedProof, expectedCNonce, supportedCredential) + when (credentialKey) { + is CredentialKey.DIDUrl -> raise(InvalidProof("DID not supported")) + is CredentialKey.Jwk -> credentialKey.value.toECKey() + is CredentialKey.X5c -> ECKey.parse(credentialKey.certificate) + } + }) { raise(InvalidProof("Only EC Key is supported", it)) } + val licence = ensureNotNull(getMobileDrivingLicenceData(authorizationContext)) { + IssueCredentialError.Unexpected("Unable to fetch mDL data") + } + val cbor = encodeMobileDrivingLicenceInCbor(licence, holderKey) + return CredentialResponse.Issued(MSO_MDOC_FORMAT, JsonPrimitive(cbor)) + .also { + log.info("Successfully issued mDL") + log.debug("Issued mDL data {}", it) + } + } + + companion object { + private val log = LoggerFactory.getLogger(IssueMobileDrivingLicence::class.java) + } +} diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/MobileDrivingLicence.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/MobileDrivingLicence.kt new file mode 100644 index 00000000..12f35711 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/MobileDrivingLicence.kt @@ -0,0 +1,466 @@ +/* + * Copyright (c) 2023 European Commission + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package eu.europa.ec.eudi.pidissuer.adapter.out.mdl + +import arrow.core.NonEmptySet +import eu.europa.ec.eudi.pidissuer.domain.MsoDocType +import eu.europa.ec.eudi.pidissuer.domain.MsoNameSpace +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * Get the versioned document type for a mDL. + */ +fun mdlDocType(version: UInt): MsoDocType = "org.iso.18013.5.$version.mDL" + +/** + * Get the versioned namespace for a mDL. + */ +fun mdlNamespace(version: UInt): MsoNameSpace = "org.iso.18013.5.$version" + +/** + * A string that contains characters in the [ISO/IEC 8859-1](https://en.wikipedia.org/wiki/ISO/IEC_8859-1) + * character set and has a max length of 150 characters. + * + * ISO/IEC 8859-1 corresponds to the Latin1 character set, also known as Latin alphabet No. 1. + * Its characters are contained in the [Basic Latin](https://en.wikipedia.org/wiki/Basic_Latin_(Unicode_block)) + * and [Latin 1 Supplement](https://en.wikipedia.org/wiki/Latin-1_Supplement) Unicode blocks. + */ +@JvmInline +value class Latin150(val value: String) { + init { + require(value.length in 1..150) { "value must be at least 1 and at most 150 characters long" } + require(value.matches(REGEX)) { "value contains non ISO/IEC 8859-1 characters" } + } + + companion object { + + /** + * Regular expression used to verify a string contains only ISO/IEC 8859-1 characters. + * + * The range 0020-007e corresponds to Basic Latin while the range 00a0-00ff corresponds + * to Latin 1 Supplement + */ + val REGEX: Regex = """^[\u0020-\u007e\u00a0-\u00ff]+$""".toRegex() + } +} + +/** + * Issue and expiry date of an mDL. + * + * TODO: issuedAt and expiredAt are defined as either LocalDate or LocalDateTime. + */ +data class IssueAndExpiry( + val issuedAt: LocalDate, + val expiresAt: LocalDate, +) { + init { + require(issuedAt <= expiresAt) + } +} + +/** + * An ISO 3166-1 alpha-2 country code. + */ +@JvmInline +value class IsoAlpha2CountryCode(val code: String) { + init { + require(code.matches(REGEX)) { "Not a valid ISO 3166-1 alpha-2 country code" } + } + + companion object { + + /** + * Regular expression for matching [ISO 3166-1 alpha-2](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) + * country codes. + * + * This is merely used for syntactic checks of the input values and does not actually validate whether the + * matching values correspond to an assigned code. + */ + val REGEX: Regex = """^[A-Z]{2}$""".toRegex() + } +} + +/** + * Distinguishing sign of Vehicles of a country according to ISO/IEC 18013-1:2018, Annex F. + */ +@JvmInline +value class DistinguishingSign(val code: String) { + init { + require(code.matches(REGEX)) { "Not a valid Distinguishing Sign of Vehicle as per ISO/IEC 18013-1:2018" } + } + + companion object { + + /** + * Regular expression for matching [Distinguishing Sings of Vehicles](https://unece.org/DAM/trans/conventn/Distsigns.pdf). + * + * This is merely used for syntactic checks of the input values and does not actually validate whether the + * matching values correspond to assigned code. + */ + @Suppress("RegExpRedundantEscape") + val REGEX: Regex = """^[A-Z\./]{1,6}$""".toRegex() + } +} + +/** + * The country issuing a Mobile Driving Licence. + */ +data class IssuingCountry( + val countryCode: IsoAlpha2CountryCode, + val distinguishingSign: DistinguishingSign, +) + +/** + * The issuer of a Mobile Driving Licence. + */ +data class Issuer( + val country: IssuingCountry, + val authority: Latin150, + val jurisdiction: Latin150? = null, +) { + init { + jurisdiction?.let { + require(it.value.startsWith(country.countryCode.code)) { "Issuing Jurisdiction must be in the Issuing Country" } + } + } +} + +/** + * An image. + */ +sealed interface Image { + + /** + * The binary content of the image. + */ + val content: ByteArray + + /** + * An image compressed using the JPEG standard. + */ + @JvmInline + value class Jpeg(override val content: ByteArray) : Image { + init { + require(content.isNotEmpty()) { "content cannot be empty" } + } + } + + /** + * An image compressed using the JPEG2000 standard. + */ + @JvmInline + value class Jpeg2000(override val content: ByteArray) : Image { + init { + require(content.isNotEmpty()) { "content cannot be empty" } + } + } +} + +/** + * The portrait of a Mobile Driving Licence Holder. + */ +data class Portrait( + val image: Image, + val capturedAt: LocalDateTime? = null, +) + +/** + * The category of a vehicle as defined in ISO/IEC 18013-1. + * [Reference](https://unece.org/DAM/trans/doc/2011/wp1/Informal_document_ISOe-UN-EU._comparison.pdf) + */ +enum class VehicleCategory(val code: String) { + MOTORCYCLE("A"), + LIGHT_MOTORCYCLE("A1"), + MEDIUM_MOTORCYCLE("A2"), + MOPED("AM"), + LIGHT_VEHICLE("B"), + LIGHT_VEHICLE_WITH_TRAILER("BE"), + MOTOR_POWERED_QUADRICYCLE("B1"), + GOODS_VEHICLE("C"), + GOODS_VEHICLE_WITH_TRAILER("CE"), + MEDIUM_GOODS_VEHICLE("C1"), + MEDIUM_GOODS_VEHICLE_WITH_TRAILER("C1E"), + PASSENGER_VEHICLE("D"), + PASSENGER_VEHICLE_WITH_TRAILER("DE"), + MEDIUM_PASSENGER_VEHICLE("D1"), + MEDIUM_PASSENGER_VEHICLE_WITH_TRAILER("D1E"), +} + +/** + * Sex as defined in ISO/IEC 5218. + */ +enum class Sex(val code: UInt) { + NOT_KNOWN(0u), + MALE(1u), + FEMALE(2u), + NOT_APPLICABLE(9u), +} + +/** + * A [UInt] that represents centimeters. + */ +@JvmInline +value class Cm(val value: UInt) + +/** + * Wraps [this] to a [Cm] instance. + * + * @receiver the [UInt] to wrap + * @return the resulting [Cm] instance + */ +fun UInt.cm(): Cm = Cm(this) + +/** + * A [UInt] that represents kilograms. + */ +@JvmInline +value class Kg(val value: UInt) + +/** + * Wraps [this] to a [Kg] instance. + * + * @receiver the [UInt] to wrap + * @return the resulting [Kg] instance + */ +fun UInt.kg(): Kg = Kg(this) + +/** + * A [UInt] that represents a natural number. + */ +@JvmInline +value class Natural(val value: UInt) { + init { + require(value > 0u) { "value is not a natural number" } + } +} + +/** + * Wraps [this] to a [Natural] instance. + * + * @receiver the [UInt] to wrap + * @return the resulting [Natural] instance + */ +fun UInt.natural(): Natural = Natural(this) + +/** + * A [UInt] that represents cubic centimeters. + */ +@JvmInline +value class Cm3(val value: UInt) + +/** + * Wraps [this] to a [Cm3] instance. + * + * @receiver the [UInt] to wrap + * @return the resulting [Cm3] instance + */ +fun UInt.cm3(): Cm3 = Cm3(this) + +/** + * A [UInt] that represents kilowatts. + */ +@JvmInline +value class KWatt(val value: UInt) + +/** + * Wraps [this] to a [KWatt] instance. + * + * @receiver the [UInt] to wrap + * @return the resulting [KWatt] instance + */ +fun UInt.kwatt(): KWatt = KWatt(this) + +/** + * Eye colour as defined in ISO/IEC 18013-5. + */ +enum class EyeColour(val code: String) { + BLACK("black"), + BLUE("blue"), + BROWN("brown"), + DICHROMATIC("dichromatic"), + GREY("grey"), + GREEN("green"), + HAZEL("hazel"), + MAROON("maroon"), + PINK("pink"), + UNKNOWN("unknown"), +} + +/** + * Hair colour as defined in ISO/IEC 18013-5. + */ +enum class HairColour(val code: String) { + BALD("bald"), + BLACK("black"), + BLOND("blond"), + Brown("brown"), + GREY("grey"), + RED("red"), + AUBURN("auburn"), + SANDY("sandy"), + WHITE("white"), + UNKNOWN("unknown"), +} + +/** + * Represents a comparison with a value as per ISO/IEC 18013-2 Annex A. + */ +sealed interface Sign { + + val value: V + val code: String + + data class LessThan(override val value: V) : Sign { + override val code: String = "<" + } + + data class LessThanOrEqualTo(override val value: V) : Sign { + override val code: String = "<=" + } + + data class EqualTo(override val value: V) : Sign { + override val code: String = "=" + } + + data class MoreThan(override val value: V) : Sign { + override val code: String = ">" + } + + data class MoreThanOrEqualTo(override val value: V) : Sign { + override val code: String = ">=" + } +} + +/** + * A Driving Privilege as defined in ISO/IEC 18013-1:2018, Clause 5. + */ +data class DrivingPrivilege( + val vehicleCategory: VehicleCategory, + val issueAndExpiry: IssueAndExpiry? = null, + val restrictions: NonEmptySet? = null, +) { + + /** + * Restrictions for a [DrivingPrivilege] as defined in ISO/IEC 18013-2 Annex A. + */ + sealed interface Restriction { + + val code: String + + /** + * Generic restrictions for the driver or the vehicle. + */ + enum class GenericRestriction(override val code: String) : Restriction { + EYESIGHT_CORRECTION_OR_PROTECTION("01"), + PROSTHETIC_DEVICE_FOR_LIMBS("03"), + VEHICLE_WITH_AUTOMATIC_TRANSMISSION("78"), + VEHICLE_WITH_ADAPTER_FOR_PHYSICALLY_DISABLED("S05"), + } + + /** + * Parameterized restrictions for the vehicle. + */ + sealed interface ParameterizedRestriction : Restriction { + + val value: Sign + + data class VehicleAuthorizedMass(override val value: Sign) : ParameterizedRestriction { + override val code: String = "S01" + } + + data class VehicleAuthorizedPassengerSeats(override val value: Sign) : + ParameterizedRestriction { + override val code: String = "S02" + } + + data class VehicleCylinderCapacity(override val value: Sign) : ParameterizedRestriction { + override val code: String = "S03" + } + + data class VehiclePower(override val value: Sign) : ParameterizedRestriction { + override val code: String = "S04" + } + } + } +} + +/** + * A [Latin150] value, optionally alongside its [original][utf8] UTF-8 representation. + */ +data class Latin150AndUtf8( + val latin: Latin150, + val utf8: String? = null, +) + +/** + * The age of a [Driver]. + */ +data class Age( + val value: Natural, + val birthYear: Natural? = null, +) { + val over18: Boolean + get() = value.value > 18u + + val over21: Boolean + get() = value.value > 21u +} + +/** + * Details of a residence. + */ +data class Residence( + val country: IsoAlpha2CountryCode, + val postalCode: Latin150? = null, + val state: Latin150? = null, + val city: Latin150? = null, + val address: Latin150? = null, +) + +/** + * A Driver for whom a Mobile Driving Licence is issued. + * + * TODO: Model and add missing optional 'biometric_template_xx' data element. + */ +data class Driver( + val familyName: Latin150AndUtf8, + val givenName: Latin150AndUtf8, + val birthDate: LocalDate, + val portrait: Portrait, + val sex: Sex? = null, + val height: Cm? = null, + val weight: Kg? = null, + val eyeColour: EyeColour? = null, + val hairColour: HairColour? = null, + val birthPlace: Latin150? = null, + val age: Age? = null, + val nationality: IsoAlpha2CountryCode? = null, + val residence: Residence? = null, + val signature: Image? = null, +) + +/** + * A Mobile Driving Licence (mDL) as per ISO/IEC 18013-5. + */ +data class MobileDrivingLicence( + val driver: Driver, + val issueAndExpiry: IssueAndExpiry, + val issuer: Issuer, + val documentNumber: Latin150, + val privileges: Set, + val administrativeNumber: Latin150? = null, +) diff --git a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroService.kt b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroService.kt index 059a7a47..f8c2f7af 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroService.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroService.kt @@ -16,20 +16,16 @@ package eu.europa.ec.eudi.pidissuer.adapter.out.pid import com.nimbusds.jose.jwk.ECKey +import eu.europa.ec.eudi.pidissuer.adapter.out.jose.toBase64UrlSafeEncodedPem import eu.europa.ec.eudi.pidissuer.domain.HttpsUrl import kotlinx.coroutines.coroutineScope import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.* -import org.bouncycastle.util.io.pem.PemObject -import org.bouncycastle.util.io.pem.PemWriter import org.slf4j.LoggerFactory import org.springframework.http.MediaType import org.springframework.web.reactive.function.client.WebClient import org.springframework.web.reactive.function.client.awaitBody -import java.io.StringWriter -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi private val log = LoggerFactory.getLogger(EncodePidInCborWithMicroService::class.java) @@ -61,17 +57,6 @@ class EncodePidInCborWithMicroService( } } -@OptIn(ExperimentalEncodingApi::class) -fun ECKey.base64EncodedPem(): String { - val output = StringWriter() - PemWriter(output).use { pemWriter -> - val pem = PemObject("PUBLIC KEY", this.toECPublicKey().encoded) - pemWriter.writeObject(pem) - } - val pem = output.toString() - return Base64.UrlSafe.encode(pem.toByteArray()) -} - internal fun createMsoMdocReq( pid: Pid, pidMetaData: PidMetaData, @@ -81,7 +66,7 @@ internal fun createMsoMdocReq( put("version", "0.3") put("country", "FC") put("doctype", PidMsoMdocV1.docType) - put("device_publickey", key.base64EncodedPem()) + put("device_publickey", key.toBase64UrlSafeEncodedPem()) putJsonObject("data") { val nameSpaces = PidMsoMdocV1.msoClaims.keys check(nameSpaces.isNotEmpty()) diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index b0831157..9f97900f 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -25,6 +25,8 @@ issuer.pid.sd_jwt_vc.notUseBefore=PT20 issuer.pid.sd_jwt_vc.complexObjectsSdOption=Structured issuer.pid.sd_jwt_vc.deferred=true issuer.pid.issuingCountry=FC +issuer.mdl.enabled=true +issuer.mdl.mso_mdoc.encoderUrl=https://preprod.issuer.eudiw.dev/formatter/cbor spring.security.oauth2.resourceserver.opaquetoken.client-id=pid-issuer-srv spring.security.oauth2.resourceserver.opaquetoken.client-secret=zIKAV9DIIIaJCzHCVBPlySgU8KgY68U2 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index bfa5111c..3941059b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -25,7 +25,8 @@ issuer.pid.sd_jwt_vc.notUseBefore=PT20 issuer.pid.sd_jwt_vc.complexObjectsSdOption=Structured issuer.pid.sd_jwt_vc.deferred=true issuer.pid.issuingCountry=FC - +issuer.mdl.enabled=true +issuer.mdl.mso_mdoc.encoderUrl=https://preprod.issuer.eudiw.dev/formatter/cbor spring.security.oauth2.resourceserver.opaquetoken.client-id=pid-issuer-srv spring.security.oauth2.resourceserver.opaquetoken.client-secret=zIKAV9DIIIaJCzHCVBPlySgU8KgY68U2 diff --git a/src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Portrait.jpg b/src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Portrait.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e511c9cbf87f9cc1bebc1858e991dce56d98ad14 GIT binary patch literal 18861 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Signature.jpg b/src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Signature.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7a01c783446b70ff9c4a8a21af9c2b348c4ec90f GIT binary patch literal 13619 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 XjjIGs%H#FK-i+khCGfo0&d>h= + + + TRACE + + + %d{yyyy-MM-dd HH:mm:ss} | %-5p | [%thread] | %logger{5}:%L :: %msg%n + UTF-8 + + + + + + + \ No newline at end of file