Skip to content

Commit

Permalink
transaction_data support (#307)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: babisRoutis <[email protected]>
  • Loading branch information
dzarras and babisRoutis authored Feb 5, 2025
1 parent 8cfe301 commit d0d4a1d
Show file tree
Hide file tree
Showing 19 changed files with 693 additions and 64 deletions.
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ kotlin {
optIn = listOf(
"kotlinx.serialization.ExperimentalSerializationApi",
"kotlin.contracts.ExperimentalContracts",
"kotlin.io.encoding.ExperimentalEncodingApi",
)
freeCompilerArgs = listOf(
"-Xconsistent-data-class-copy-visibility",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@
package eu.europa.ec.eudi.openid4vp

import eu.europa.ec.eudi.openid4vp.Client.*
import eu.europa.ec.eudi.openid4vp.TransactionData.Companion.credentialIds
import eu.europa.ec.eudi.openid4vp.TransactionData.Companion.hashAlgorithms
import eu.europa.ec.eudi.openid4vp.TransactionData.Companion.type
import eu.europa.ec.eudi.openid4vp.dcql.DCQL
import eu.europa.ec.eudi.openid4vp.internal.*
import eu.europa.ec.eudi.openid4vp.internal.request.RequestUriMethod
import eu.europa.ec.eudi.prex.PresentationDefinition
import kotlinx.io.bytestring.decodeToByteString
import kotlinx.io.bytestring.decodeToString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.*
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x500.style.BCStyle
import java.io.Serializable
Expand Down Expand Up @@ -78,6 +86,122 @@ fun Client.legalName(legalName: X509Certificate.() -> String? = X509Certificate:
}
}

/**
* Represents resolved (i.e., supported by the Wallet) Transaction Data.
*
* @property json this Transaction Data as a generic JsonObject
* @property type the type of the Transaction Data
* @property credentialIds identifiers of the requested Credentials this Transaction Data is applicable to
* @property hashAlgorithms Hash Algorithms with which the Hash of this Transaction Data can be calculated
*/
data class TransactionData private constructor(val value: String) : Serializable {

val json: JsonObject by lazy {
decode(value)
}

init {
json.type()
json.hashAlgorithms()
json.credentialIds()
}

val type: TransactionDataType
get() = json.type()

val credentialIds: List<TransactionDataCredentialId>
get() = json.credentialIds()

val hashAlgorithms: List<HashAlgorithm>
get() = json.hashAlgorithms()

companion object {

private val DefaultHashAlgorithm: HashAlgorithm get() = HashAlgorithm.SHA_256
private fun decode(s: String): JsonObject {
val decoded = base64UrlNoPadding.decodeToByteString(s)
return jsonSupport.decodeFromString(decoded.decodeToString())
}

internal fun parse(s: String): Result<TransactionData> = runCatching {
TransactionData(s)
}

private fun JsonObject.type(): TransactionDataType =
TransactionDataType(requiredString(OpenId4VPSpec.TRANSACTION_DATA_TYPE))

private fun JsonObject.hashAlgorithms(): List<HashAlgorithm> =
optionalStringArray(OpenId4VPSpec.TRANSACTION_DATA_HASH_ALGORITHMS)
?.map { HashAlgorithm(it) }
?: listOf(DefaultHashAlgorithm)

private fun JsonObject.credentialIds(): List<TransactionDataCredentialId> =
requiredStringArray(OpenId4VPSpec.TRANSACTION_DATA_CREDENTIAL_IDS).map { TransactionDataCredentialId(it) }

private fun TransactionData.isSupported(supportedTypes: List<SupportedTransactionDataType>) {
val type = this.type
val supportedType = supportedTypes.firstOrNull { it.type == type }
requireNotNull(supportedType) { "Unsupported transaction_data '${OpenId4VPSpec.TRANSACTION_DATA_TYPE}': '$type'" }

val hashAlgorithms = hashAlgorithms.toSet()
val supportedHashAlgorithms = supportedType.hashAlgorithms
require(supportedHashAlgorithms.intersect(hashAlgorithms).isNotEmpty()) {
"Unsupported '${OpenId4VPSpec.TRANSACTION_DATA_HASH_ALGORITHMS}': '$hashAlgorithms'"
}
}

private fun TransactionData.hasCorrectIds(query: PresentationQuery) {
val requestedCredentialIds = query.requestedCredentialIds()
require(requestedCredentialIds.containsAll(credentialIds)) {
"Invalid '${OpenId4VPSpec.TRANSACTION_DATA_CREDENTIAL_IDS}': '$credentialIds'"
}
}

private fun PresentationQuery.requestedCredentialIds(): List<TransactionDataCredentialId> =
when (this) {
is PresentationQuery.ByPresentationDefinition ->
value.inputDescriptors.map { TransactionDataCredentialId(it.id.value) }

is PresentationQuery.ByDigitalCredentialsQuery ->
value.credentials.map { TransactionDataCredentialId(it.id.value) }
}

internal operator fun invoke(
type: TransactionDataType,
credentialIds: List<TransactionDataCredentialId>,
hashAlgorithms: List<HashAlgorithm>? = null,
builder: JsonObjectBuilder.() -> Unit = {},
): TransactionData {
val json = buildJsonObject {
put(OpenId4VPSpec.TRANSACTION_DATA_TYPE, type.value)
putJsonArray(OpenId4VPSpec.TRANSACTION_DATA_CREDENTIAL_IDS) {
credentialIds.forEach { add(it.value) }
}
if (!hashAlgorithms.isNullOrEmpty()) {
putJsonArray(OpenId4VPSpec.TRANSACTION_DATA_HASH_ALGORITHMS) {
hashAlgorithms.forEach { add(it.name) }
}
}
builder()
}
val serialized = jsonSupport.encodeToString(json)
val base64 = base64UrlNoPadding.encode(serialized.encodeToByteArray())
return TransactionData(base64)
}

internal operator fun invoke(
s: String,
supportedTypes: List<SupportedTransactionDataType>,
query: PresentationQuery,
): Result<TransactionData> = runCatching {
parse(s).getOrThrow().also {
it.isSupported(supportedTypes)
it.hasCorrectIds(query)
}
}
}
}

/**
* Represents an OAUTH2 authorization request. In particular
* either a [SIOPv2 for id_token][SiopOpenId4VPAuthentication] or
Expand Down Expand Up @@ -122,6 +246,7 @@ sealed interface ResolvedRequestObject : Serializable {
override val jarmRequirement: JarmRequirement?,
val vpFormats: VpFormats,
val presentationQuery: PresentationQuery,
val transactionData: List<TransactionData>?,
) : ResolvedRequestObject

/**
Expand All @@ -138,6 +263,7 @@ sealed interface ResolvedRequestObject : Serializable {
val subjectSyntaxTypesSupported: List<SubjectSyntaxType>,
val scope: Scope,
val presentationQuery: PresentationQuery,
val transactionData: List<TransactionData>?,
) : ResolvedRequestObject
}

Expand All @@ -156,11 +282,13 @@ sealed interface RequestValidationError : AuthorizationRequestError {
data class InvalidJarJwt(val cause: String) : AuthorizationRequestError

data object InvalidUseOfBothRequestAndRequestUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = InvalidUseOfBothRequestAndRequestUri
}

data class UnsupportedRequestUriMethod(val method: RequestUriMethod) : RequestValidationError
data object InvalidRequestUriMethod : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = InvalidRequestUriMethod
}

Expand All @@ -170,6 +298,7 @@ sealed interface RequestValidationError : AuthorizationRequestError {
data class UnsupportedResponseType(val value: String) : RequestValidationError

data object MissingResponseType : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingResponseType
}

Expand All @@ -182,98 +311,120 @@ sealed interface RequestValidationError : AuthorizationRequestError {
// Query source errors
//
data object MissingQuerySource : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingQuerySource
}

data object MultipleQuerySources : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MultipleQuerySources
}

data object InvalidClientId : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = InvalidClientId
}

data class InvalidPresentationDefinition(val cause: Throwable) : RequestValidationError

data object InvalidPresentationDefinitionUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = InvalidPresentationDefinitionUri
}

data class InvalidDigitalCredentialsQuery(val cause: Throwable) : RequestValidationError

data object InvalidRedirectUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = InvalidRedirectUri
}

data object MissingRedirectUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingRedirectUri
}

data object MissingResponseUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingResponseUri
}

data object InvalidResponseUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = InvalidResponseUri
}

data object ResponseUriMustNotBeProvided : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = ResponseUriMustNotBeProvided
}

data object RedirectUriMustNotBeProvided : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = RedirectUriMustNotBeProvided
}

data object MissingNonce : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingNonce
}

data object MissingScope : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingScope
}

data object MissingClientId : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingClientId
}

data object UnsupportedClientIdScheme : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = UnsupportedClientIdScheme
}

data class UnsupportedClientMetaData(val value: String) : RequestValidationError

data object OneOfClientMedataOrUri : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = OneOfClientMedataOrUri
}

data class InvalidClientMetaData(val cause: String) : RequestValidationError

data object SubjectSyntaxTypesNoMatch : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = SubjectSyntaxTypesNoMatch
}

data object MissingClientMetadataJwksSource : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = MissingClientMetadataJwksSource
}

data object BothJwkUriAndInlineJwks : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = BothJwkUriAndInlineJwks
}

data object SubjectSyntaxTypesWrongSyntax : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = SubjectSyntaxTypesWrongSyntax
}

data object IdTokenSigningAlgMissing : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = IdTokenSigningAlgMissing
}

data object IdTokenEncryptionAlgMissing : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = IdTokenEncryptionAlgMissing
}

data object IdTokenEncryptionMethodMissing : RequestValidationError {
@Suppress("unused")
private fun readResolve(): Any = IdTokenEncryptionMethodMissing
}

Expand All @@ -292,13 +443,15 @@ sealed interface ResolutionError : AuthorizationRequestError {
ResolutionError

data object FetchingPresentationDefinitionNotSupported : ResolutionError {
@Suppress("unused")
private fun readResolve(): Any = FetchingPresentationDefinitionNotSupported
}

data class UnableToFetchPresentationDefinition(val cause: Throwable) : ResolutionError
data class UnableToFetchRequestObject(val cause: Throwable) : ResolutionError
data class ClientMetadataJwkUriUnparsable(val cause: Throwable) : ResolutionError
data class ClientMetadataJwkResolutionFailed(val cause: Throwable) : ResolutionError
data class InvalidTransactionData(val cause: Throwable) : ResolutionError
}

/**
Expand Down
15 changes: 15 additions & 0 deletions src/main/kotlin/eu/europa/ec/eudi/openid4vp/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,19 @@ value class VpFormats(val values: List<VpFormat>) {
}
}

/**
* A type of Transaction Data supported by the Wallet.
*/
data class SupportedTransactionDataType(
val type: TransactionDataType,
val hashAlgorithms: Set<HashAlgorithm>,
) {
init {
require(hashAlgorithms.isNotEmpty()) { "hashAlgorithms cannot be empty" }
require(HashAlgorithm.SHA_256 in hashAlgorithms) { "'${HashAlgorithm.SHA_256.name}' must be a supported hash algorithm" }
}
}

/**
* Configurations options for OpenId4VP
*
Expand All @@ -191,12 +204,14 @@ value class VpFormats(val values: List<VpFormat>) {
* a pre-agreed scope (instead of explicitly using presentation_definition or presentation_definition_uri)
* @param knownDCQLQueriesPerScope a set of DCQL queries that a verifier may request via a pre-agreed scope
* @param vpFormats The formats the wallet supports
* @param supportedTransactionDataTypes the types of Transaction Data that are supported by the wallet
*/
data class VPConfiguration(
val presentationDefinitionUriSupported: Boolean = true,
val knownPresentationDefinitionsPerScope: Map<String, PresentationDefinition> = emptyMap(),
val knownDCQLQueriesPerScope: Map<String, DCQL> = emptyMap(),
val vpFormats: VpFormats,
val supportedTransactionDataTypes: List<SupportedTransactionDataType> = emptyList(),
)

interface JarmSigner : JWSSigner {
Expand Down
10 changes: 10 additions & 0 deletions src/main/kotlin/eu/europa/ec/eudi/openid4vp/OpenId4VPSpec.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ package eu.europa.ec.eudi.openid4vp

object OpenId4VPSpec {

const val RESPONSE_URI = "response_uri"
const val PRESENTATION_DEFINITION = "presentation_definition"
const val PRESENTATION_DEFINITION_URI = "presentation_definition_uri"
const val DCQL_QUERY = "dcql_query"

const val CLIENT_ID_SCHEME_SEPARATOR = ':'
const val CLIENT_ID_SCHEME_PRE_REGISTERED = "pre-registered"
const val CLIENT_ID_SCHEME_REDIRECT_URI = "redirect_uri"
Expand Down Expand Up @@ -55,6 +60,11 @@ object OpenId4VPSpec {
const val DCQL_MSO_MDOC_DOCTYPE_VALUE: String = "doctype_value"
const val DCQL_MSO_MDOC_NAMESPACE: String = "namespace"
const val DCQL_MSO_MDOC_CLAIM_NAME: String = "claim_name"

const val TRANSACTION_DATA: String = "transaction_data"
const val TRANSACTION_DATA_TYPE: String = "type"
const val TRANSACTION_DATA_CREDENTIAL_IDS: String = "credential_ids"
const val TRANSACTION_DATA_HASH_ALGORITHMS: String = "transaction_data_hashes_alg"
}

object SIOPv2
Loading

0 comments on commit d0d4a1d

Please sign in to comment.