Skip to content

Commit

Permalink
Adding utility endpoint for validating SD-JWT VCs (#221)
Browse files Browse the repository at this point in the history
  • Loading branch information
dzarras authored Dec 18, 2024
1 parent 0412e4b commit 9379662
Show file tree
Hide file tree
Showing 6 changed files with 362 additions and 39 deletions.
19 changes: 4 additions & 15 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import org.owasp.dependencycheck.gradle.extension.DependencyCheckExtension
import org.springframework.boot.gradle.tasks.bundling.BootBuildImage
import kotlin.jvm.optionals.getOrNull

plugins {
base
Expand Down Expand Up @@ -50,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)
Expand All @@ -58,15 +58,13 @@ dependencies {
}

java {
val javaVersion = getVersionFromCatalog("java")
sourceCompatibility = JavaVersion.toVersion(javaVersion)
sourceCompatibility = JavaVersion.toVersion(libs.versions.java.get())
}

kotlin {

jvmToolchain {
val javaVersion = getVersionFromCatalog("java")
languageVersion.set(JavaLanguageVersion.of(javaVersion))
languageVersion.set(JavaLanguageVersion.of(libs.versions.java.get()))
}

compilerOptions {
Expand Down Expand Up @@ -126,7 +124,7 @@ tasks.named<BootBuildImage>("bootBuildImage") {
}

spotless {
val ktlintVersion = getVersionFromCatalog("ktlintVersion")
val ktlintVersion = libs.versions.ktlintVersion.get()
kotlin {
ktlint(ktlintVersion)
licenseHeaderFile("FileHeader.txt")
Expand All @@ -136,15 +134,6 @@ spotless {
}
}

fun getVersionFromCatalog(lookup: String): String {
val versionCatalog: VersionCatalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
return versionCatalog
.findVersion(lookup)
.getOrNull()
?.requiredVersion
?: throw GradleException("Version '$lookup' is not specified in the version catalog")
}

val nvdApiKey: String? = System.getenv("NVD_API_KEY") ?: properties["nvdApiKey"]?.toString()
val dependencyCheckExtension = extensions.findByType(DependencyCheckExtension::class.java)
dependencyCheckExtension?.apply {
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*
Expand All @@ -25,13 +28,20 @@ import org.springframework.web.reactive.function.server.ServerResponse.ok

internal class UtilityApi(
private val validateMsoMdocDeviceResponse: ValidateMsoMdocDeviceResponse,
private val validateSdJwtVc: ValidateSdJwtVc,
) {
val route: RouterFunction<ServerResponse> = coRouter {
POST(
VALIDATE_MSO_MDOC_DEVICE_RESPONSE_PATH,
contentType(APPLICATION_FORM_URLENCODED) and accept(APPLICATION_JSON),
::handleValidateMsoMdocDeviceResponse,
)

POST(
VALIDATE_SD_JWT_VC_PATH,
contentType(APPLICATION_FORM_URLENCODED) and accept(APPLICATION_JSON),
::handleValidateSdJwtVc,
)
}

/**
Expand All @@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/*
* 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.Either
import arrow.core.getOrElse
import arrow.core.toNonEmptyListOrNull
import eu.europa.ec.eudi.sdjwt.*
import eu.europa.ec.eudi.sdjwt.vc.DefaultHttpClientFactory
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.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.usingX5cOrIssuerMetadata(
x509CertificateTrust = x509CertificateTrust,
httpClientFactory = DefaultHttpClientFactory,
)
}

suspend operator fun invoke(unverified: SdJwt, nonce: Nonce): SdJwtVcValidationResult {
val challenge = buildJsonObject {
put(Claims.Audience, audience)
put(Claims.Nonce, nonce.value)
}

return Either.catch {
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)
}.getOrElse {
log.error("SD-JWT-VC validation failed", it)
if (it is SdJwtVerificationException) {
SdJwtVcValidationResult.Invalid(it.toSdJwtVcValidationErrorTO())
} else {
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'")
}
Loading

0 comments on commit 9379662

Please sign in to comment.