Skip to content

Commit

Permalink
First support for CWT proofs
Browse files Browse the repository at this point in the history
  • Loading branch information
babisRoutis committed Jun 15, 2024
1 parent 12abf7e commit 3ee5b3d
Show file tree
Hide file tree
Showing 14 changed files with 341 additions and 43 deletions.
3 changes: 3 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ dependencies {
implementation(libs.keycloak.admin.client) {
because("To be able to fetch user attributes")
}
implementation(libs.authlete.cbor) {
because("To implement CWT proof")
}
implementation(libs.waltid.mdoc.credentials) {
because("To sign CBOR credentials")
}
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ resultMonad = "1.4.0"
keycloak = "24.0.3"
waltid = "0.3.1"
uri-kmp = "0.0.18"
authlete-cbor = "1.18"

[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
Expand All @@ -44,6 +45,7 @@ result-monad = { module = "org.erwinkok.result:result-monad", version.ref = "res
keycloak-admin-client = { module = "org.keycloak:keycloak-admin-client", version.ref = "keycloak" }
waltid-mdoc-credentials = { module = "id.walt:waltid-mdoc-credentials-jvm", version.ref = "waltid" }
uri-kmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" }
authlete-cbor = { module = "com.authlete:cbor", version.ref = "authlete-cbor" }

[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
@@ -0,0 +1,183 @@
/*
* 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 arrow.core.raise.Raise
import arrow.core.raise.ensureNotNull
import arrow.core.toNonEmptyListOrNull
import com.authlete.cbor.*
import com.authlete.cose.COSEEC2Key
import com.authlete.cose.COSEProtectedHeader
import com.authlete.cose.COSESign1
import com.authlete.cose.COSEVerifier
import com.authlete.cwt.CWT
import com.authlete.cwt.CWTClaimsSet
import com.nimbusds.jose.jwk.ECKey
import com.nimbusds.jose.jwk.JWK
import eu.europa.ec.eudi.pidissuer.domain.*
import eu.europa.ec.eudi.pidissuer.port.input.IssueCredentialError
import java.time.Clock
import java.time.Instant
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.time.Duration.Companion.minutes

context (Raise<IssueCredentialError.InvalidProof>)
fun validateCwtProof(
credentialIssuerId: CredentialIssuerId,
unvalidatedProof: UnvalidatedProof.Cwt,
expectedCNonce: CNonce,
credentialConfiguration: CredentialConfiguration,
): CredentialKey {
val proofType = credentialConfiguration.proofTypesSupported[ProofTypeEnum.CWT]
ensureNotNull(proofType) {
IssueCredentialError.InvalidProof("credential configuration '${credentialConfiguration.id.value}' doesn't support 'jwt' proofs")
}
check(proofType is ProofType.Cwt)

return validateCwtProof(credentialIssuerId, unvalidatedProof, expectedCNonce, proofType)
}

context (Raise<IssueCredentialError.InvalidProof>)
fun validateCwtProof(
credentialIssuerId: CredentialIssuerId,
unvalidatedProof: UnvalidatedProof.Cwt,
expectedCNonce: CNonce,
proofType: ProofType.Cwt,
): CredentialKey {
return CwtProofValidator.isValid(
Clock.systemDefaultZone(),
iss = null,
aud = credentialIssuerId,
nonce = expectedCNonce,
p = unvalidatedProof,
).getOrElse {
raise(IssueCredentialError.InvalidProof("Reason: " + it.message))
}
}

internal object CwtProofValidator {

fun isValid(
clock: Clock,
iss: String?,
aud: CredentialIssuerId,
nonce: CNonce,
p: UnvalidatedProof.Cwt,
): Result<CredentialKey> = runCatching {
val (credentialKey, claimSet) = claimSet(p)
if (iss != null) {
require(iss == claimSet.iss) {
"Invalid CWT proof. Expecting iss=$iss found ${claimSet.iss}"
}
}
require(aud.toString() == claimSet.aud) {
"Invalid CWT proof. Expecting aud=$aud found ${claimSet.aud}"
}
require(nonce.nonce == claimSet.nonce.toString(Charsets.UTF_8)) {
"Invalid CWT proof. Expecting nonce=${nonce.nonce}"
}
val claimSetIat = requireNotNull(claimSet.iat) {
"Invalid CWT proof. Missing iat"
}
val now = Instant.now()
val skew = 3.minutes.inWholeSeconds // seconds
val range = now.minusSeconds(skew).epochSecond..now.plusSeconds(skew).epochSecond
require(claimSetIat.toInstant().epochSecond in range) {
"Invalid CWT proof. Invalid iat"
}
credentialKey
}

private fun claimSet(p: UnvalidatedProof.Cwt): Pair<CredentialKey, CWTClaimsSet> {
val cwt = ensureIsCWT(p)
val sign1 = ensureContainsSignOneMessage(cwt)
val credentialKey = verifySignature(sign1)
return credentialKey to claimSet(sign1)
}

@OptIn(ExperimentalEncodingApi::class)
private fun ensureIsCWT(p: UnvalidatedProof.Cwt): CWT {
val cwtInBytes = Base64.UrlSafe.decode(p.cwt)
val cborItem = CBORDecoder(cwtInBytes).next()
require(cborItem is CWT) { "Not CBOR CWT" }
return cborItem
}

private fun ensureContainsSignOneMessage(cwt: CWT): COSESign1 {
val message = cwt.message
require(message is COSESign1) { "CWT does not contain a COSE Sign one message" }
return message
}

private fun verifySignature(sign1: COSESign1): CredentialKey {
val credentialKey = ensureValidProtectedHeader(sign1)
val coseKey = when (credentialKey) {
is CredentialKey.DIDUrl -> error("Unsupported")
is CredentialKey.Jwk -> {
require(credentialKey.value is ECKey)
credentialKey.value.toPublicKey()
}

is CredentialKey.X5c -> {
credentialKey.certificate.publicKey
}
}
require(COSEVerifier(coseKey).verify(sign1)) { "Invalid signature" }
return credentialKey
}

private fun ensureValidProtectedHeader(sign1: COSESign1): CredentialKey {
val pHeader: COSEProtectedHeader = sign1.protectedHeader
require("openid4vci-proof+cwt" == pHeader.contentType) { "Invalid content type ${pHeader.contentType}" }

val coseKey = run {
val coseKeyAsByteString = pHeader.pairs.firstOrNull { (key, value) ->
key is CBORString && key.value == "COSE_Key" &&
value is CBORByteArray
}?.value as CBORByteArray?
val cborItem = coseKeyAsByteString?.let { CBORDecoder(it.value).next() }

cborItem?.takeIf { it is CBORPairList }?.let { item ->
check(item is CBORPairList)
COSEEC2Key(item.pairs)
}
}

val x5cChain = pHeader.x5Chain.orEmpty()
require(!(null != coseKey && x5cChain.isNotEmpty())) {
"Cannot have both a COSE_Key and x5c chain"
}
return if (coseKey != null) {
val authJwk = coseKey.toJwk()
val jwk: JWK = JWK.parse(authJwk)
require(jwk is ECKey)
CredentialKey.Jwk(jwk)
} else {
CredentialKey.X5c(checkNotNull(x5cChain.toNonEmptyListOrNull()))
}
}

private fun claimSet(sign1: COSESign1): CWTClaimsSet {
val payload = sign1.payload
val parsed = payload.parse()
val listOfPairs = CBORDecoder(parsed as ByteArray).next() as CBORPairList
return CWTClaimsSet(listOfPairs.pairs)
}
}

private operator fun CBORPair.component1(): CBORItem = key
private operator fun CBORPair.component2(): CBORItem = value
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import arrow.core.NonEmptySet
import arrow.core.raise.Raise
import arrow.core.raise.ensureNotNull
import arrow.core.raise.result
import arrow.core.toNonEmptySetOrNull
import com.nimbusds.jose.JOSEObjectType
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.JWSHeader
Expand Down Expand Up @@ -51,26 +50,24 @@ fun validateJwtProof(
expectedCNonce: CNonce,
credentialConfiguration: CredentialConfiguration,
): CredentialKey {
val proofTypes = credentialConfiguration.proofTypesSupported
.filterIsInstance<ProofType.Jwt>()
.toNonEmptySetOrNull()
ensureNotNull(proofTypes) {
val proofType = credentialConfiguration.proofTypesSupported[ProofTypeEnum.JWT]
ensureNotNull(proofType) {
IssueCredentialError.InvalidProof("credential configuration '${credentialConfiguration.id.value}' doesn't support 'jwt' proofs")
}
check(proofType is ProofType.Jwt)

val supportedAlgorithms = proofTypes.flatMap { it.signingAlgorithmsSupported }.toNonEmptySet()
return validateJwtProof(credentialIssuerId, unvalidatedProof, expectedCNonce, supportedAlgorithms)
return validateJwtProof(credentialIssuerId, unvalidatedProof, expectedCNonce, proofType)
}

context (Raise<IssueCredentialError.InvalidProof>)
fun validateJwtProof(
credentialIssuerId: CredentialIssuerId,
unvalidatedProof: UnvalidatedProof.Jwt,
expectedCNonce: CNonce,
supportedAlgorithms: NonEmptySet<JWSAlgorithm>,
proofType: ProofType.Jwt,
): CredentialKey = result {
val signedJwt = SignedJWT.parse(unvalidatedProof.jwt)
val (algorithm, credentialKey) = algorithmAndCredentialKey(signedJwt.header, supportedAlgorithms)
val (algorithm, credentialKey) = algorithmAndCredentialKey(signedJwt.header, proofType.signingAlgorithmsSupported)
val keySelector = keySelector(credentialKey, algorithm)
val processor = processor(expectedCNonce, credentialIssuerId, keySelector)
processor.process(signedJwt, null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,18 @@ val MobileDrivingLicenceV1: MsoMdocCredentialConfiguration =
cryptographicBindingMethodsSupported = emptySet(),
credentialSigningAlgorithmsSupported = emptySet(),
scope = MobileDrivingLicenceV1Scope,
proofTypesSupported = nonEmptySetOf(ProofType.Jwt(nonEmptySetOf(JWSAlgorithm.ES256))),
proofTypesSupported = ProofTypesSupported(
nonEmptySetOf(
ProofType.Jwt(
signingAlgorithmsSupported = nonEmptySetOf(JWSAlgorithm.ES256),
),
ProofType.Cwt(
algorithms = nonEmptySetOf(CoseAlgorithm.ES256),
curves = nonEmptySetOf(CoseCurve.P_256),
),
),
),
policy = MsoMdocPolicy(oneTimeUse = false, batchSize = 2),
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ val PidMsoMdocV1: MsoMdocCredentialConfiguration =
cryptographicBindingMethodsSupported = emptySet(),
credentialSigningAlgorithmsSupported = emptySet(),
scope = PidMsoMdocScope,
proofTypesSupported = nonEmptySetOf(ProofType.Jwt(nonEmptySetOf(JWSAlgorithm.ES256))),
proofTypesSupported = ProofTypesSupported(nonEmptySetOf(ProofType.Jwt(nonEmptySetOf(JWSAlgorithm.ES256)))),
)

//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ fun pidSdJwtVcV1(signingAlgorithm: JWSAlgorithm): SdJwtVcCredentialConfiguration
cryptographicBindingMethodsSupported = nonEmptySetOf(CryptographicBindingMethod.Jwk),
credentialSigningAlgorithmsSupported = nonEmptySetOf(signingAlgorithm),
scope = PidSdJwtVcScope,
proofTypesSupported = nonEmptySetOf(ProofType.Jwt(nonEmptySetOf(JWSAlgorithm.RS256, JWSAlgorithm.ES256))),
proofTypesSupported = ProofTypesSupported(nonEmptySetOf(ProofType.Jwt(nonEmptySetOf(JWSAlgorithm.RS256, JWSAlgorithm.ES256)))),
)

typealias TimeDependant<F> = (ZonedDateTime) -> F
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,39 @@ value class CredentialConfigurationId(val value: String)

sealed interface ProofType {

val signingAlgorithmsSupported: NonEmptySet<JWSAlgorithm>

/**
* A JWT is used as proof of possession.
*/
data class Jwt(override val signingAlgorithmsSupported: NonEmptySet<JWSAlgorithm>) : ProofType
data class Jwt(val signingAlgorithmsSupported: NonEmptySet<JWSAlgorithm>) : ProofType

/**
* A CWT is used as proof of possession.
*/
data class Cwt(override val signingAlgorithmsSupported: NonEmptySet<JWSAlgorithm>) : ProofType
data class Cwt(val algorithms: NonEmptySet<CoseAlgorithm>, val curves: NonEmptySet<CoseCurve>) : ProofType
}

/**
* A W3C Verifiable Presentation object signed using the Data Integrity Proof is used as proof of possession.
*/
data class LdpVp(override val signingAlgorithmsSupported: NonEmptySet<JWSAlgorithm>) : ProofType
fun ProofType.type(): ProofTypeEnum = when (this) {
is ProofType.Cwt -> ProofTypeEnum.CWT
is ProofType.Jwt -> ProofTypeEnum.JWT
}
enum class ProofTypeEnum {
JWT, CWT
}

@JvmInline
value class ProofTypesSupported private constructor(val values: Set<ProofType>) {

operator fun get(type: ProofTypeEnum): ProofType? = values.firstOrNull { it.type() == type }

companion object {
val Empty: ProofTypesSupported = ProofTypesSupported(emptySet())
operator fun invoke(values: Set<ProofType>): ProofTypesSupported {
require(values.groupBy(ProofType::type).all { (_, instances) -> instances.size == 1 }) {
"Multiple instance of the same proof type are not allowed"
}
return ProofTypesSupported(values)
}
}
}

/**
Expand All @@ -54,5 +71,5 @@ sealed interface CredentialConfiguration {
val display: List<CredentialDisplay>
val cryptographicBindingMethodsSupported: Set<CryptographicBindingMethod>
val credentialSigningAlgorithmsSupported: Set<JWSAlgorithm>
val proofTypesSupported: Set<ProofType>
val proofTypesSupported: ProofTypesSupported
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ data class JwtVcJsonCredentialConfiguration(
override val cryptographicBindingMethodsSupported: Set<CryptographicBindingMethod>,
override val credentialSigningAlgorithmsSupported: Set<JWSAlgorithm>,
override val display: List<CredentialDisplay>,
override val proofTypesSupported: Set<ProofType>,
override val proofTypesSupported: ProofTypesSupported = ProofTypesSupported.Empty,
) : CredentialConfiguration

//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
*/
package eu.europa.ec.eudi.pidissuer.domain

import arrow.core.NonEmptySet
import arrow.core.raise.Raise
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import com.nimbusds.jose.JWSAlgorithm
import kotlinx.serialization.SerialName

//
// Credential MetaData
Expand All @@ -32,6 +32,11 @@ const val MSO_MDOC_FORMAT_VALUE = "mso_mdoc"
val MSO_MDOC_FORMAT = Format(MSO_MDOC_FORMAT_VALUE)
typealias MsoClaims = Map<MsoNameSpace, List<AttributeDetails>>

data class MsoMdocPolicy(
@SerialName("one_time_use") val oneTimeUse: Boolean,
@SerialName("batch_size") val batchSize: Int? = null,
)

/**
* @param docType string identifying the credential type as defined in ISO.18013-5.
*/
Expand All @@ -43,7 +48,8 @@ data class MsoMdocCredentialConfiguration(
override val scope: Scope? = null,
override val display: List<CredentialDisplay> = emptyList(),
val msoClaims: MsoClaims = emptyMap(),
override val proofTypesSupported: NonEmptySet<ProofType>,
override val proofTypesSupported: ProofTypesSupported = ProofTypesSupported.Empty,
val policy: MsoMdocPolicy? = null,
) : CredentialConfiguration

//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ data class SdJwtVcCredentialConfiguration(
override val credentialSigningAlgorithmsSupported: NonEmptySet<JWSAlgorithm>,
override val display: List<CredentialDisplay>,
val claims: List<AttributeDetails>,
override val proofTypesSupported: NonEmptySet<ProofType>,
override val proofTypesSupported: ProofTypesSupported = ProofTypesSupported.Empty,
) : CredentialConfiguration

//
Expand Down
Loading

0 comments on commit 3ee5b3d

Please sign in to comment.