From c52d1cf96279968b2dc5c9a5c8c0eb388fd148ef Mon Sep 17 00:00:00 2001 From: Dimitris ZARRAS Date: Tue, 17 Dec 2024 16:25:17 +0200 Subject: [PATCH] Add utility endpoint for validating SD-JWT-VCs. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 2 + .../eudi/verifier/endpoint/VerifierContext.kt | 48 +++--- .../endpoint/adapter/input/web/UtilityApi.kt | 34 ++++ .../endpoint/port/input/ValidateSdJwtVc.kt | 147 +++++++++++++++++ src/main/resources/public/openapi.json | 148 ++++++++++++++++++ 6 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/port/input/ValidateSdJwtVc.kt diff --git a/build.gradle.kts b/build.gradle.kts index 5566e93..7040925 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation("com.augustcellars.cose:cose-java:1.1.0") { because("required by walt.id") } + implementation(libs.sd.jwt) testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5029abe..406794f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ dependencycheck = "11.1.0" jacoco = "0.8.11" swaggerUi = "5.18.2" waltid = "0.9.0" +sdJwt = "0.9.0" [libraries] kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } @@ -28,6 +29,7 @@ arrow-core = { module = "io.arrow-kt:arrow-core", version.ref = "arrow" } arrow-fx-coroutines = { module = "io.arrow-kt:arrow-fx-coroutines", version.ref = "arrow" } swagger-ui = { module = "org.webjars:swagger-ui", version.ref = "swaggerUi" } waltid-mdoc-credentials = { module = "id.walt.mdoc-credentials:waltid-mdoc-credentials-jvm", version.ref = "waltid" } +sd-jwt = { module = "eu.europa.ec.eudi:eudi-lib-jvm-sdjwt-kt", version.ref = "sdJwt" } [plugins] foojay-resolver-convention = { id = "org.gradle.toolchains.foojay-resolver-convention", version.ref = "foojay" } diff --git a/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/VerifierContext.kt b/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/VerifierContext.kt index c128b84..56f00b4 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/VerifierContext.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/VerifierContext.kt @@ -66,6 +66,27 @@ private val log = LoggerFactory.getLogger(VerifierApplication::class.java) @OptIn(ExperimentalSerializationApi::class) internal fun beans(clock: Clock) = beans { + val trustedIssuers: KeyStore? by lazy { + env.getProperty("trustedIssuers.keystore.path") + ?.takeIf { it.isNotBlank() } + ?.let { keystorePath -> + val keystoreType = env.getRequiredProperty("trustedIssuers.keystore.type") + val keystorePassword = env.getProperty("trustedIssuers.keystore.password") + ?.takeIf { it.isNotBlank() } + ?.toCharArray() + + log.info("Loading trusted issuers' certificates from '$keystorePath'") + DefaultResourceLoader().getResource(keystorePath) + .inputStream + .use { + KeyStore.getInstance(keystoreType) + .apply { + load(it, keystorePassword) + } + } + } + } + // // JOSE // @@ -133,29 +154,8 @@ internal fun beans(clock: Clock) = beans { bean { GetWalletResponseLive(clock, ref(), ref()) } bean { GetJarmJwksLive(ref(), clock, ref()) } bean { GetPresentationEventsLive(ref(), ref()) } - bean { - val trustedIssuers = - env.getProperty("trustedIssuers.keystore.path") - ?.takeIf { it.isNotBlank() } - ?.let { keystorePath -> - val keystoreType = env.getRequiredProperty("trustedIssuers.keystore.type") - val keystorePassword = env.getProperty("trustedIssuers.keystore.password") - ?.takeIf { it.isNotBlank() } - ?.toCharArray() - - log.info("Loading trusted issuers' certificates from '$keystorePath'") - DefaultResourceLoader().getResource(keystorePath) - .inputStream - .use { - KeyStore.getInstance(keystoreType) - .apply { - load(it, keystorePassword) - } - } - } - - ValidateMsoMdocDeviceResponse(clock, trustedIssuers) - } + bean { ValidateMsoMdocDeviceResponse(clock, trustedIssuers) } + bean { ValidateSdJwtVc(trustedIssuers, env.publicUrl()) } // // Scheduled @@ -187,7 +187,7 @@ internal fun beans(clock: Clock) = beans { webJarResourcesBasePath = env.getRequiredProperty("spring.webflux.webjars-path-pattern") .removeSuffix("/**"), ) - val utilityApi = UtilityApi(ref()) + val utilityApi = UtilityApi(ref(), ref()) walletApi.route .and(verifierApi.route) .and(staticContent.route) diff --git a/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/adapter/input/web/UtilityApi.kt b/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/adapter/input/web/UtilityApi.kt index 747df1c..55dc7c0 100644 --- a/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/adapter/input/web/UtilityApi.kt +++ b/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/adapter/input/web/UtilityApi.kt @@ -15,8 +15,11 @@ */ package eu.europa.ec.eudi.verifier.endpoint.adapter.input.web +import eu.europa.ec.eudi.verifier.endpoint.domain.Nonce import eu.europa.ec.eudi.verifier.endpoint.port.input.DeviceResponseValidationResult +import eu.europa.ec.eudi.verifier.endpoint.port.input.SdJwtVcValidationResult import eu.europa.ec.eudi.verifier.endpoint.port.input.ValidateMsoMdocDeviceResponse +import eu.europa.ec.eudi.verifier.endpoint.port.input.ValidateSdJwtVc import org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED import org.springframework.http.MediaType.APPLICATION_JSON import org.springframework.web.reactive.function.server.* @@ -25,6 +28,7 @@ import org.springframework.web.reactive.function.server.ServerResponse.ok internal class UtilityApi( private val validateMsoMdocDeviceResponse: ValidateMsoMdocDeviceResponse, + private val validateSdJwtVc: ValidateSdJwtVc, ) { val route: RouterFunction = coRouter { POST( @@ -32,6 +36,12 @@ internal class UtilityApi( contentType(APPLICATION_FORM_URLENCODED) and accept(APPLICATION_JSON), ::handleValidateMsoMdocDeviceResponse, ) + + POST( + VALIDATE_SD_JWT_VC_PATH, + contentType(APPLICATION_FORM_URLENCODED) and accept(APPLICATION_JSON), + ::handleValidateSdJwtVc, + ) } /** @@ -54,7 +64,31 @@ internal class UtilityApi( } } + /** + * Handles a request to validate an SD-JWT Verifiable Credential. + */ + private suspend fun handleValidateSdJwtVc(request: ServerRequest): ServerResponse { + val form = request.awaitFormData() + val unverifiedSdJwtVc = form["sd_jwt_vc"] + ?.firstOrNull { it.isNotBlank() } + .let { + requireNotNull(it) { "sd_jwt_vc must be provided" } + } + val nonce = form["nonce"] + ?.firstOrNull { it.isNotBlank() } + .let { + requireNotNull(it) { "nonce must be provided" } + Nonce(it) + } + + return when (val result = validateSdJwtVc(unverifiedSdJwtVc, nonce)) { + is SdJwtVcValidationResult.Valid -> ok().json().bodyValueAndAwait(result.payload) + is SdJwtVcValidationResult.Invalid -> badRequest().json().bodyValueAndAwait(result.reason) + } + } + companion object { const val VALIDATE_MSO_MDOC_DEVICE_RESPONSE_PATH = "/utilities/validations/msoMdoc/deviceResponse" + const val VALIDATE_SD_JWT_VC_PATH = "/utilities/validations/sdJwtVc" } } diff --git a/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/port/input/ValidateSdJwtVc.kt b/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/port/input/ValidateSdJwtVc.kt new file mode 100644 index 0000000..f6965b0 --- /dev/null +++ b/src/main/kotlin/eu/europa/ec/eudi/verifier/endpoint/port/input/ValidateSdJwtVc.kt @@ -0,0 +1,147 @@ +/* + * 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.verifier.endpoint.port.input + +import arrow.core.toNonEmptyListOrNull +import com.nimbusds.jose.proc.BadJOSEException +import eu.europa.ec.eudi.sdjwt.* +import eu.europa.ec.eudi.sdjwt.vc.SdJwtVcVerifier +import eu.europa.ec.eudi.sdjwt.vc.X509CertificateTrust +import eu.europa.ec.eudi.verifier.endpoint.adapter.out.cert.X5CShouldBe +import eu.europa.ec.eudi.verifier.endpoint.adapter.out.cert.X5CValidator +import eu.europa.ec.eudi.verifier.endpoint.domain.Nonce +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.slf4j.LoggerFactory +import java.security.KeyStore + +/** + * Reasons why validation of an SD-JWT Verifiable Credential might fail. + */ +@Serializable +enum class SdJwtVcValidationErrorTO { + ContainsInvalidDisclosures, + ContainsInvalidJwt, + ContainsInvalidKeyBindingJwt, + IsMissingHolderPublicKey, + IsMissingKeyBindingJwt, + ContainsDisclosuresWithNoDigests, + ContainsUnknownHashingAlgorithm, + ContainsNonUniqueDigests, + ContainsNonUniqueDisclosures, + IsUnparsable, + Other, +} + +sealed interface SdJwtVcValidationResult { + /** + * Successfully validated an SD-JWT Verifiable Credential. + */ + data class Valid(val payload: JsonObject) : SdJwtVcValidationResult + + /** + * SD-JWT Verifiable Credential validation failed. + */ + data class Invalid(val reason: SdJwtVcValidationErrorTO) : SdJwtVcValidationResult +} + +internal typealias SdJwt = String +internal typealias Audience = String + +private object Claims { + val Audience = RFC7519.AUDIENCE + val Nonce = "nonce" +} + +/** + * Validates an SD-JWT Verifiable Credential. + * + * @param trustedIssuers keystore containing the X509 Certificates of the trusted issuers + * @param audience the public url of this verifier, expected to be found in the KeyBinding JWT + */ +internal class ValidateSdJwtVc( + trustedIssuers: KeyStore?, + private val audience: Audience, +) { + private val verifier: SdJwtVcVerifier by lazy { + val x5CShouldBe = trustedIssuers?.let { + X5CShouldBe.fromKeystore(it) { + isRevocationEnabled = false + } + } ?: X5CShouldBe.Ignored + val x5cValidator = X5CValidator(x5CShouldBe) + val x509CertificateTrust = X509CertificateTrust { chain -> + chain.toNonEmptyListOrNull()?.let { + x5cValidator.ensureTrusted(it).fold( + ifLeft = { _ -> false }, + ifRight = { _ -> true }, + ) + } ?: false + } + SdJwtVcVerifier.usingX5c(x509CertificateTrust) + } + + suspend operator fun invoke(unverified: SdJwt, nonce: Nonce): SdJwtVcValidationResult { + val challenge = buildJsonObject { + put(Claims.Audience, audience) + put(Claims.Nonce, nonce.value) + } + + return try { + val (presentation, keyBinding) = verifier.verifyPresentation(unverified, challenge).getOrThrow() + checkNotNull(keyBinding) { "KeyBinding JWT cannot be null" } + + val payload = with(DefaultSdJwtOps) { + presentation.recreateClaims(visitor = null) + } + SdJwtVcValidationResult.Valid(payload) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (verificationError: SdJwtVerificationException) { + log.error("SD-JWT-VC validation failed", verificationError) + SdJwtVcValidationResult.Invalid(verificationError.toSdJwtVcValidationErrorTO()) + } catch (badJoseError: BadJOSEException) { + log.error("SD-JWT-VC validation failed", badJoseError) + SdJwtVcValidationResult.Invalid(SdJwtVcValidationErrorTO.Other) + } + } +} + +private val log = LoggerFactory.getLogger(ValidateSdJwtVc::class.java) + +private fun SdJwtVerificationException.toSdJwtVcValidationErrorTO(): SdJwtVcValidationErrorTO = + when (val reason = this.reason) { + is VerificationError.InvalidDisclosures -> SdJwtVcValidationErrorTO.ContainsInvalidDisclosures + VerificationError.InvalidJwt -> SdJwtVcValidationErrorTO.ContainsInvalidJwt + is VerificationError.KeyBindingFailed -> reason.details.toSdJwtVcValidationErrorTO() + is VerificationError.MissingDigests -> SdJwtVcValidationErrorTO.ContainsDisclosuresWithNoDigests + VerificationError.MissingOrUnknownHashingAlgorithm -> SdJwtVcValidationErrorTO.ContainsUnknownHashingAlgorithm + VerificationError.NonUniqueDisclosureDigests -> SdJwtVcValidationErrorTO.ContainsNonUniqueDigests + VerificationError.NonUniqueDisclosures -> SdJwtVcValidationErrorTO.ContainsNonUniqueDisclosures + is VerificationError.Other -> SdJwtVcValidationErrorTO.Other + VerificationError.ParsingError -> SdJwtVcValidationErrorTO.IsUnparsable + } + +private fun KeyBindingError.toSdJwtVcValidationErrorTO(): SdJwtVcValidationErrorTO = + when (this) { + KeyBindingError.InvalidKeyBindingJwt -> SdJwtVcValidationErrorTO.ContainsInvalidKeyBindingJwt + KeyBindingError.MissingHolderPubKey -> SdJwtVcValidationErrorTO.IsMissingHolderPublicKey + KeyBindingError.MissingKeyBindingJwt -> SdJwtVcValidationErrorTO.IsMissingKeyBindingJwt + KeyBindingError.UnexpectedKeyBindingJwt -> error("KeyBindingJwt is required, but verification failed with '$this'") + } diff --git a/src/main/resources/public/openapi.json b/src/main/resources/public/openapi.json index b24917e..90381c5 100644 --- a/src/main/resources/public/openapi.json +++ b/src/main/resources/public/openapi.json @@ -272,6 +272,69 @@ } } } + }, + "/utilities/validations/sdJwtVc": { + "post": { + "tags": [ + "utility api" + ], + "summary": "Validates an SD-JWT Verifiable Credential.", + "description": "Validates an SD-JWT Verifiable Credentials.

Verifies the provided value:

  1. Is an SD-JWT-VC serialized using compact serialization
  2. Is signed by a trusted Issuer (checked against a configured X5C chain)
  3. Contains a valid KeyBinding JWT (with this Verifier as the Audience and the provided Nonce value)

", + "operationId": "validateSdJwtVc", + "requestBody": { + "description": "The value to validate as an SD-JWT Verifiable Credential and the expected Nonce of the KeyBinding JWT.", + "required": true, + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ValidateSdJwtVc" + } + } + } + }, + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "description": "The payload of the processed SD-JWT-VC including all disclosed claims.", + "type": "object", + "nullable": false, + "additionalProperties": true + }, + "examples": { + "SdJwtVcPidProccessedPayload": { + "$ref": "#/components/examples/SdJwtVcPidProccessedPayload" + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SdJwtVcValidationError" + } + } + } + }, + "500": { + "description": "Server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": false, + "additionalProperties": true + } + } + } + } + } + } } }, "components": { @@ -1170,6 +1233,41 @@ "$ref": "#/components/schemas/MsoMdocDeviceResponseValidationFailureDocumentValidationFailure" } ] + }, + "ValidateSdJwtVc": { + "type": "object", + "nullable": false, + "properties": { + "sd_jwt_vc": { + "type": "string", + "nullable": false + }, + "nonce": { + "$ref": "#/components/schemas/Nonce" + } + }, + "required": [ + "sd_jwt_vc", + "nonce" + ], + "additionalProperties": false + }, + "SdJwtVcValidationError": { + "type": "string", + "nullable": false, + "enum": [ + "ContainsInvalidDisclosures", + "ContainsInvalidJwt", + "ContainsInvalidKeyBindingJwt", + "IsMissingHolderPublicKey", + "IsMissingKeyBindingJwt", + "ContainsDisclosuresWithNoDigests", + "ContainsUnknownHashingAlgorithm", + "ContainsNonUniqueDigests", + "ContainsNonUniqueDisclosures", + "IsUnparsable", + "Other" + ] } }, "examples": { @@ -1535,6 +1633,56 @@ } ] } + }, + "SdJwtVcPidProccessedPayload": { + "description": "The proccessed payload of a PID issued as an SD-JWT-VC.", + "value": { + "place_of_birth": { + "country": "AT", + "locality": "101 Trauner" + }, + "address": { + "street_address": "Trauner", + "country": "AT", + "postal_code": "3331", + "house_number": "101 ", + "region": "Lower Austria", + "locality": "Gemeinde Biberbach" + }, + "vct": "urn:eu.europa.ec.eudi:pid:1", + "iss": "https://dev.issuer-backend.eudiw.dev", + "cnf": { + "jwk": { + "kty": "RSA", + "e": "AQAB", + "use": "sig", + "kid": "30f54ba1-2df0-41c2-8520-1241576c794f", + "iat": 1734444712, + "n": "uzK4yxP9Z-1S8Z3Q6Hbk5PCIOVvjvHH_mXDQlVyWDy2t57RiRjVoSz4xvcy4lkIsIqMLfMVPhRx3AMy7Gl_rSq3qi5GwBEsEVjLP07r9HaSwod6hen4f36h3sUf6EE_3LMJbFLTTx2aHais2p2cQ49tv8ri4d6FSleSDGdvPSqDQfNffsSuex-8k6g8VIF_SEeK2z_tprUUkEua5z2EUJykZCGdvtyqpWVTNnV69eaEYF2W42a10MvxTVRNY4qg7p1RcSCM-AC7kpURY1n4tk8Y6Ybty4csQy7QaOftsvE1BTw_rZ_Qhw9Bto4HEJVC_Ca_HTRqUkiVHzxOjX7EO8Q" + } + }, + "exp": 1737036713, + "iat": 1734444713, + "age_equal_or_over": { + "18": true + }, + "age_in_years": 70, + "birthdate": "1955-04-12", + "age_birth_year": "1955", + "gender": "male", + "family_name": "Neal", + "birth_given_name": "Tyler", + "issuing_authority": "GR Administrative authority", + "birth_family_name": "Neal", + "nationalities": [ + "AT" + ], + "document_number": "e16416d4-7d6f-4d95-8c91-d78e200bc8d4", + "issuing_country": "GR", + "administrative_number": "888adb27-d4d5-4004-8c71-97f61273c203", + "given_name": "Tyler", + "issuing_jurisdiction": "GR-I" + } } } }