Skip to content

Commit

Permalink
OID4VP: Sign hashes of received transaction data
Browse files Browse the repository at this point in the history
  • Loading branch information
nodh committed Dec 4, 2024
1 parent efc3715 commit f29788f
Show file tree
Hide file tree
Showing 13 changed files with 85 additions and 46 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ Release 5.2.0:
- Add extension functions to `JwsService` to create JWTs for OAuth 2.0 Attestation-Based Client Authentication
- New artefact `vck-openid-ktor` implements a ktor client for OpenID for Verifiable Credential Issuance and OpenID for Verifiable Presentations
- Remove `scopePresentationDefinitionRetriever` from `OidcSiopWallet` to keep implementation simple
- OpenID4VP: Update implementation to draft 23, adding transaction data hashes to the response of the Wallet
- OpenID4VP: Update implementation to draft 23
- Add transaction data hashes to the response of the Wallet (at least for SD-JWT)

Release 5.1.0:
- Drop ARIES protocol implementation, and the `vck-aries` artifact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ data class AuthenticationRequestParameters(
* Optional when JAR (RFC9101) is used.
*/
@SerialName("response_type")
val responseType: String? = null,
override val responseType: String? = null,

/**
* OIDC: REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server.
*/
@SerialName("client_id")
val clientId: String? = null,
override val clientId: String? = null,

/**
* OIDC: REQUIRED. Redirection URI to which the response will be sent. This URI MUST exactly match one of the
Expand All @@ -42,7 +42,7 @@ data class AuthenticationRequestParameters(
* Optional when JAR (RFC9101) is used.
*/
@SerialName("redirect_uri")
val redirectUrl: String? = null,
override val redirectUrl: String? = null,

/**
* OIDC: REQUIRED. OpenID Connect requests MUST contain the openid scope value. If the openid scope value is not
Expand All @@ -59,15 +59,15 @@ data class AuthenticationRequestParameters(
* parameter with a browser cookie.
*/
@SerialName("state")
val state: String? = null,
override val state: String? = null,

/**
* OIDC: OPTIONAL. String value used to associate a Client session with an ID Token, and to mitigate replay attacks.
* The value is passed through unmodified from the Authentication Request to the ID Token. Sufficient entropy MUST
* be present in the nonce values used to prevent attackers from guessing values.
*/
@SerialName("nonce")
val nonce: String? = null,
override val nonce: String? = null,

/**
* OIDC: OPTIONAL. This parameter is used to request that specific Claims be returned. The value is a JSON object
Expand Down Expand Up @@ -221,7 +221,7 @@ data class AuthenticationRequestParameters(
* value of `aud` should be the value of the authorization server (AS) `issuer`, as defined in RFC 8414.
*/
@SerialName("aud")
val audience: String? = null,
override val audience: String? = null,

/**
* OAuth 2.0 JAR: If signed, the Authorization Request Object SHOULD contain the Claims `iss` (issuer) and `aud`
Expand Down Expand Up @@ -261,6 +261,7 @@ data class AuthenticationRequestParameters(
) : RequestParameters {

fun serialize() = odcJsonSerializer.encodeToString(this)
override val transactionData: Set<String>? = null

companion object {
fun deserialize(it: String) = kotlin.runCatching {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
package at.asitplus.openid

interface RequestParameters
interface RequestParameters {
val responseType: String?
val nonce: String?
val clientId: String?
val redirectUrl: String?
val audience: String?
val state: String?
val transactionData: Set<String>?
}



Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ data class CscAuthenticationRequestParameters(
* Optional when JAR (RFC9101) is used.
*/
@SerialName("response_type")
val responseType: String,
override val responseType: String,

/**
* OIDC: REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server.
*/
@SerialName("client_id")
val clientId: String,
override val clientId: String,

/**
* OIDC: REQUIRED. Redirection URI to which the response will be sent. This URI MUST exactly match one of the
Expand All @@ -47,7 +47,7 @@ data class CscAuthenticationRequestParameters(
* Optional when JAR (RFC9101) is used.
*/
@SerialName("redirect_uri")
val redirectUrl: String? = null,
override val redirectUrl: String? = null,

/**
* OIDC: REQUIRED. OpenID Connect requests MUST contain the openid scope value. If the openid scope value is not
Expand All @@ -64,7 +64,7 @@ data class CscAuthenticationRequestParameters(
* parameter with a browser cookie.
*/
@SerialName("state")
val state: String? = null,
override val state: String? = null,

/**
* OAuth 2.0 JAR: REQUIRED unless request is specified. The absolute URI, as defined by RFC3986, that is the
Expand Down Expand Up @@ -230,6 +230,10 @@ data class CscAuthenticationRequestParameters(
return result
}

override val nonce: String? = null
override val audience: String? = null
override val transactionData: Set<String>? = null

companion object {
fun deserialize(it: String) = kotlin.runCatching {
odcJsonSerializer.decodeFromString<AuthenticationRequestParameters>(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ data class QesInputDescriptor(
override val format: FormatHolder? = null,
@SerialName("constraints")
override val constraints: Constraint? = null,
// TODO Is this now obsoleted by OpenID4VP draft 23?
@SerialName("transaction_data")
val transactionData: List<@Serializable(Base64URLTransactionDataSerializer::class) TransactionData>,
) : InputDescriptor
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import at.asitplus.rqes.enums.ConformanceLevel
import at.asitplus.rqes.enums.SignatureFormat
import at.asitplus.rqes.enums.SignatureQualifier
import at.asitplus.rqes.enums.SignedEnvelopeProperty
import at.asitplus.rqes.serializers.Base64URLTransactionDataSerializer
import at.asitplus.signum.indispensable.Digest
import at.asitplus.signum.indispensable.X509SignatureAlgorithm
import at.asitplus.signum.indispensable.asn1.Asn1Element
import at.asitplus.signum.indispensable.asn1.ObjectIdentifier
import at.asitplus.signum.indispensable.io.Base64UrlStrict
import io.matthewnelson.encoding.core.Decoder.Companion.decodeToByteArray
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
Expand Down Expand Up @@ -44,13 +45,13 @@ data class SignatureRequestParameters(
* Optional when JAR (RFC9101) is used.
*/
@SerialName("response_type")
val responseType: String,
override val responseType: String,

/**
* OIDC: REQUIRED. OAuth 2.0 Client Identifier valid at the Authorization Server.
*/
@SerialName("client_id")
val clientId: String,
override val clientId: String,

/**
* OID4VP: OPTIONAL. A string identifying the scheme of the value in the `client_id` Authorization Request parameter
Expand Down Expand Up @@ -91,15 +92,15 @@ data class SignatureRequestParameters(
* be present in the nonce values used to prevent attackers from guessing values.
*/
@SerialName("nonce")
val nonce: String,
override val nonce: String,

/**
* OIDC: RECOMMENDED. Opaque value used to maintain state between the request and the callback. Typically,
* Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the value of this
* parameter with a browser cookie.
*/
@SerialName("state")
val state: String? = null,
override val state: String? = null,

/**
* UC5 Draft REQUIRED.
Expand Down Expand Up @@ -150,12 +151,17 @@ data class SignatureRequestParameters(
* data not conforming to the respective type definition.
*/
@SerialName("transaction_data")
val transactionData: Set<@Serializable(Base64URLTransactionDataSerializer::class) TransactionData>? = null,
override val transactionData: Set<String>? = null,
) : RequestParameters {

@Transient
val hashAlgorithm: Digest = hashAlgorithmOid.getHashAlgorithm()

@Transient
val transactionDataTyped: Set<TransactionData>? = transactionData?.map {
rdcJsonSerializer.decodeFromString<TransactionData>(it.decodeToByteArray(Base64UrlStrict).decodeToString())
}?.toSet()

fun toAuthorizationDetails(): AuthorizationDetails =
CscAuthorizationDetails(
credentialID = this.clientId,
Expand Down Expand Up @@ -183,4 +189,7 @@ data class SignatureRequestParameters(
signedProps = signedProps,
signedEnvelopeProperty = signedEnvelopeProperty
)

override val redirectUrl: String? = null
override val audience: String? = null
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package at.asitplus.rqes.collection_entries

import at.asitplus.rqes.Method
import at.asitplus.openid.UrlSerializer
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package at.asitplus.rqes.serializers
import at.asitplus.openid.AuthenticationRequestParameters
import at.asitplus.openid.CscAuthenticationRequestParameters
import at.asitplus.openid.RequestParameters
import at.asitplus.rqes.Hashes
import at.asitplus.rqes.SignatureRequestParameters
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ class OidcSiopWallet(
* [AuthenticationResponseResult].
*/
suspend fun parseAuthenticationRequestParameters(input: String): KmmResult<RequestParametersFrom<AuthenticationRequestParameters>> =
catching { requestParser.parseRequestParameters(input).getOrThrow() as RequestParametersFrom<AuthenticationRequestParameters> }
catching {
requestParser.parseRequestParameters(input)
.getOrThrow() as RequestParametersFrom<AuthenticationRequestParameters>
}

/**
* Pass in the deserialized [AuthenticationRequestParameters], which were either encoded as query params,
Expand Down Expand Up @@ -210,15 +213,18 @@ class OidcSiopWallet(
* @param preparationState The preparation state from [startAuthorizationResponsePreparation]
* @param inputDescriptorSubmissions Map from input descriptor ids to [CredentialSubmission]
*/
suspend fun finalizeAuthorizationResponseParameters(
request: RequestParametersFrom<AuthenticationRequestParameters>,
suspend fun <T : RequestParameters> finalizeAuthorizationResponseParameters(
request: RequestParametersFrom<T>,
preparationState: AuthorizationResponsePreparationState,
inputDescriptorSubmissions: Map<String, CredentialSubmission>? = null,
): KmmResult<AuthenticationResponse> = preparationState.catching {
val certKey = (request as? RequestParametersFrom.JwsSigned<AuthenticationRequestParameters>)
?.jwsSigned?.header?.certificateChain?.firstOrNull()?.publicKey?.toJsonWebKey()
val clientJsonWebKeySet = clientMetadata?.loadJsonWebKeySet()
val audience = request.extractAudience(clientJsonWebKeySet)
val audience = request.parameters.extractAudience(clientJsonWebKeySet)
val nonce = request.parameters.nonce
?: throw OAuth2Exception(Errors.INVALID_REQUEST)
.also { Napier.w("nonce is null in ${request.parameters}") }
val presentationFactory = PresentationFactory(jwsService)
val idToken = presentationFactory.createSignedIdToken(
clock = clock,
Expand All @@ -229,8 +235,9 @@ class OidcSiopWallet(
val resultContainer = presentationDefinition?.let {
presentationFactory.createPresentationExchangePresentation(
holder = holder,
request = request,
request = request.parameters,
audience = audience,
nonce = nonce,
presentationDefinition = presentationDefinition,
clientMetadata = clientMetadata,
inputDescriptorSubmissions = inputDescriptorSubmissions
Expand All @@ -249,10 +256,10 @@ class OidcSiopWallet(
}

@Throws(OAuth2Exception::class)
private fun RequestParametersFrom<AuthenticationRequestParameters>.extractAudience(
private fun RequestParameters.extractAudience(
clientJsonWebKeySet: JsonWebKeySet?,
) = parameters.clientId
?: parameters.audience
) = this.clientId
?: this.audience
?: clientJsonWebKeySet?.keys?.firstOrNull()
?.let { it.keyId ?: it.didEncoded ?: it.jwkThumbprint }
?: throw OAuth2Exception(Errors.INVALID_REQUEST)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import at.asitplus.catching
import at.asitplus.dif.ClaimFormat
import at.asitplus.dif.FormatHolder
import at.asitplus.dif.PresentationDefinition
import at.asitplus.openid.AuthenticationRequestParameters
import at.asitplus.openid.IdToken
import at.asitplus.openid.OpenIdConstants.Errors
import at.asitplus.openid.OpenIdConstants.ID_TOKEN
import at.asitplus.openid.OpenIdConstants.VP_TOKEN
import at.asitplus.openid.RelyingPartyMetadata
import at.asitplus.openid.RequestParameters
import at.asitplus.openid.RequestParametersFrom
import at.asitplus.signum.indispensable.CryptoPublicKey
import at.asitplus.signum.indispensable.josef.JwsSigned
Expand All @@ -30,17 +30,14 @@ internal class PresentationFactory(
) {
suspend fun createPresentationExchangePresentation(
holder: Holder,
request: RequestParametersFrom<AuthenticationRequestParameters>,
request: RequestParameters,
nonce: String,
audience: String,
presentationDefinition: PresentationDefinition,
clientMetadata: RelyingPartyMetadata?,
inputDescriptorSubmissions: Map<String, CredentialSubmission>? = null,
): KmmResult<Holder.PresentationResponseParameters> = catching {
request.parameters.verifyResponseType()
val nonce = request.parameters.nonce ?: run {
Napier.w("nonce is null in ${request.parameters}")
throw OAuth2Exception(Errors.INVALID_REQUEST)
}
request.verifyResponseType()
val credentialSubmissions = inputDescriptorSubmissions
?: holder.matchInputDescriptorsAgainstCredentialStore(
inputDescriptors = presentationDefinition.inputDescriptors,
Expand All @@ -56,6 +53,8 @@ internal class PresentationFactory(
holder.createPresentation(
challenge = nonce,
audienceId = audience,
// TODO Exact encoding is not specified
transactionData = request.transactionData?.map { it.encodeToByteArray() },
presentationDefinitionId = presentationDefinition.id,
presentationSubmissionSelection = credentialSubmissions,
).getOrElse {
Expand All @@ -69,10 +68,10 @@ internal class PresentationFactory(
}


suspend fun createSignedIdToken(
suspend fun <T : RequestParameters> createSignedIdToken(
clock: Clock,
agentPublicKey: CryptoPublicKey,
request: RequestParametersFrom<AuthenticationRequestParameters>,
request: RequestParametersFrom<T>,
): KmmResult<JwsSigned<IdToken>?> = catching {
if (request.parameters.responseType?.contains(ID_TOKEN) != true) {
return@catching null
Expand Down Expand Up @@ -105,7 +104,7 @@ internal class PresentationFactory(
}

@Throws(OAuth2Exception::class)
private fun AuthenticationRequestParameters.verifyResponseType() {
private fun RequestParameters.verifyResponseType() {
if (responseType == null || !responseType!!.contains(VP_TOKEN)) {
Napier.w("vp_token not requested in response_type='$responseType'")
throw OAuth2Exception(Errors.INVALID_REQUEST)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ interface Holder {
suspend fun createPresentation(
challenge: String,
audienceId: String,
transactionData: Collection<ByteArray>? = null,
presentationDefinition: PresentationDefinition,
fallbackFormatHolder: FormatHolder? = null,
pathAuthorizationValidator: PathAuthorizationValidator? = null,
): KmmResult<PresentationResponseParameters>


/**
* Creates [PresentationResponseParameters] (that is a list of [CreatePresentationResult] and a
* [PresentationSubmission]) to match the [presentationSubmissionSelection].
Expand All @@ -134,6 +134,7 @@ interface Holder {
suspend fun createPresentation(
challenge: String,
audienceId: String,
transactionData: Collection<ByteArray>? = null,
presentationDefinitionId: String?,
presentationSubmissionSelection: Map<String, CredentialSubmission>,
): KmmResult<PresentationResponseParameters>
Expand Down
Loading

0 comments on commit f29788f

Please sign in to comment.