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 00000000..e511c9cb Binary files /dev/null and b/src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Portrait.jpg differ 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 00000000..7a01c783 Binary files /dev/null and b/src/main/resources/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/Signature.jpg differ diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensionsTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensionsTest.kt new file mode 100644 index 00000000..64655b5a --- /dev/null +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/jose/ECKeyExtensionsTest.kt @@ -0,0 +1,38 @@ +/* + * 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.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.gen.ECKeyGenerator +import kotlin.test.Test + +internal class ECKeyExtensionsTest { + + private val key: ECKey by lazy { + ECKeyGenerator(Curve.P_256).generate() + } + + @Test + internal fun `toPem() must not fail`() { + key.toPem().also { println(it) } + } + + @Test + internal fun `toBase64UrlSafeEncodedPem() must not fail`() { + key.toBase64UrlSafeEncodedPem().also { println(it) } + } +} diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMockTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMockTest.kt new file mode 100644 index 00000000..86828288 --- /dev/null +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/GetMobileDrivingLicenceDataMockTest.kt @@ -0,0 +1,35 @@ +/* + * 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.getOrElse +import arrow.core.nonEmptySetOf +import arrow.core.raise.either +import eu.europa.ec.eudi.pidissuer.domain.Scope +import eu.europa.ec.eudi.pidissuer.port.input.AuthorizationContext +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +internal class GetMobileDrivingLicenceDataMockTest { + + @Test + internal fun `get mDL success`() = runTest { + val getMobileDrivingLicenceData = GetMobileDrivingLicenceDataMock() + either { + getMobileDrivingLicenceData(AuthorizationContext("access-token", nonEmptySetOf(Scope("test")))) + }.getOrElse { throw RuntimeException(it.msg, it.cause) } + } +} diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicenceTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicenceTest.kt new file mode 100644 index 00000000..6ea304de --- /dev/null +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/mdl/IssueMobileDrivingLicenceTest.kt @@ -0,0 +1,52 @@ +/* + * 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.getOrElse +import arrow.core.nonEmptySetOf +import arrow.core.raise.either +import com.nimbusds.jose.jwk.Curve +import com.nimbusds.jose.jwk.ECKey +import com.nimbusds.jose.jwk.gen.ECKeyGenerator +import eu.europa.ec.eudi.pidissuer.WebClients +import eu.europa.ec.eudi.pidissuer.domain.HttpsUrl +import eu.europa.ec.eudi.pidissuer.port.input.AuthorizationContext +import kotlinx.coroutines.runBlocking + +private val getMobileDrivingLicenceData: GetMobileDrivingLicenceData by lazy { + GetMobileDrivingLicenceDataMock() +} + +private val encodeMobileDrivingLicenceInCbor: EncodeMobileDrivingLicenceInCbor by lazy { + EncodeMobileDrivingLicenceInCborWithMicroservice( + WebClients.Insecure, + HttpsUrl.unsafe("https://preprod.issuer.eudiw.dev/formatter/cbor"), + ) +} + +private val holderKey: ECKey by lazy { + ECKeyGenerator(Curve.P_256).generate() +} + +fun main() { + runBlocking { + val context = AuthorizationContext("access-token", nonEmptySetOf(MobileDrivingLicenceV1Scope)) + either { + val licence = requireNotNull(getMobileDrivingLicenceData(context)) + encodeMobileDrivingLicenceInCbor(licence, holderKey) + }.getOrElse { throw RuntimeException(it.msg, it.cause) } + } +} diff --git a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroServiceTest.kt b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroServiceTest.kt index 96ef23b4..cc5b2928 100644 --- a/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroServiceTest.kt +++ b/src/test/kotlin/eu/europa/ec/eudi/pidissuer/adapter/out/pid/EncodePidInCborWithMicroServiceTest.kt @@ -25,11 +25,6 @@ import kotlin.test.Test class EncodePidInCborWithMicroServiceTest { - @Test - fun `base64EncodedPem() should not raise exception`() { - holderKey.base64EncodedPem().also { println(it) } - } - private val json = Json { prettyPrint = true } @Test diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 00000000..0315ca97 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,15 @@ + + + + TRACE + + + %d{yyyy-MM-dd HH:mm:ss} | %-5p | [%thread] | %logger{5}:%L :: %msg%n + UTF-8 + + + + + + + \ No newline at end of file