Skip to content

Commit 62d5154

Browse files
leoScytalesvkanellopoulos
authored andcommitted
feature: Enhance SD-JWT VC handling with transaction data support
1 parent 0355f44 commit 62d5154

7 files changed

Lines changed: 108 additions & 10 deletions

File tree

wallet-core/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ dependencies {
125125
// OpenID4VCI
126126
implementation(libs.nimbus.oauth2.oidc.sdk)
127127
// Siop-Openid4VP library
128-
implementation(libs.eudi.lib.jvm.siop.openid4vp.kt) {
128+
api(libs.eudi.lib.jvm.siop.openid4vp.kt) {
129129
exclude(group = "org.bouncycastle")
130130
}
131131
// SD-JWT VC library

wallet-core/src/main/java/eu/europa/ec/eudi/wallet/dcapi/Utils.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import java.util.Locale
2626
import kotlin.io.encoding.ExperimentalEncodingApi
2727
import androidx.core.graphics.scale
2828

29+
private const val SHA_256_ALGORITHM = "SHA-256"
30+
2931
/**
3032
* Utility functions for DCAPI.
3133
*/
@@ -89,7 +91,7 @@ internal fun getDCAPIIsoMdocSessionTranscript(encryptionInfoBase64: String, orig
8991
Add(encryptionInfoBase64)
9092
Add(origin)
9193
}.EncodeToBytes()
92-
val dcapiInfoHash = MessageDigest.getInstance("SHA-256").digest(dcapiInfo)
94+
val dcapiInfoHash = MessageDigest.getInstance(SHA_256_ALGORITHM).digest(dcapiInfo)
9395
val dcapiIsoMdocHandover = CBORObject.NewArray().apply {
9496
Add(DCAPI)
9597
Add(dcapiInfoHash)

wallet-core/src/main/java/eu/europa/ec/eudi/wallet/internal/OpenId4VpUtils.kt

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import eu.europa.ec.eudi.openid4vp.ResolvedRequestObject
5151
import eu.europa.ec.eudi.openid4vp.ResponseEncryptionConfiguration
5252
import eu.europa.ec.eudi.openid4vp.ResponseMode
5353
import eu.europa.ec.eudi.openid4vp.SupportedClientIdPrefix
54+
import eu.europa.ec.eudi.openid4vp.TransactionData
5455
import eu.europa.ec.eudi.openid4vp.VPConfiguration
5556
import eu.europa.ec.eudi.openid4vp.VerifiablePresentation
5657
import eu.europa.ec.eudi.openid4vp.VerifierId
@@ -84,6 +85,8 @@ import java.security.SecureRandom
8485
import java.util.Base64
8586
import java.util.Date
8687

88+
private const val SHA_256_ALGORITHM = "SHA-256"
89+
8790
/**
8891
* Utility to generate the session transcript for the OpenID4VP protocol.
8992
*
@@ -164,8 +167,8 @@ internal fun generateOpenId4VpHandover(
164167
Add(mdocGeneratedNonce)
165168
}.EncodeToBytes()
166169

167-
val clientIdHash = MessageDigest.getInstance("SHA-256").digest(clientIdToHash)
168-
val responseUriHash = MessageDigest.getInstance("SHA-256").digest(responseUriToHash)
170+
val clientIdHash = MessageDigest.getInstance(SHA_256_ALGORITHM).digest(clientIdToHash)
171+
val responseUriHash = MessageDigest.getInstance(SHA_256_ALGORITHM).digest(responseUriToHash)
169172

170173
val openID4VPHandover = CBORObject.NewArray().apply {
171174
Add(clientIdHash)
@@ -317,6 +320,7 @@ internal val EncryptionMethod.nimbus: com.nimbusds.jose.EncryptionMethod
317320
* @param nonce The nonce for the session.
318321
* @param signatureAlgorithm The algorithm to use for signing.
319322
* @param issueDate The date of issuance.
323+
* @param transactionData Optional list of transaction data
320324
* @return The serialized SD-JWT as a string.
321325
*/
322326
internal suspend fun SdJwt<JwtAndClaims>.serializeWithKeyBinding(
@@ -326,6 +330,7 @@ internal suspend fun SdJwt<JwtAndClaims>.serializeWithKeyBinding(
326330
nonce: String,
327331
signatureAlgorithm: Algorithm,
328332
issueDate: Date,
333+
transactionData: List<TransactionData.SdJwtVc>? = null,
329334
): String {
330335
val algorithm = JWSAlgorithm.parse((signatureAlgorithm).joseAlgorithmIdentifier)
331336
val publicKey = credential.secureArea.getKeyInfo(credential.alias).publicKey
@@ -350,17 +355,31 @@ internal suspend fun SdJwt<JwtAndClaims>.serializeWithKeyBinding(
350355
audience(clientId.clientId)
351356
claim("nonce", nonce)
352357
issueTime(issueDate)
358+
if (!transactionData.isNullOrEmpty()) {
359+
val transactionDataHashes = transactionData.map { td ->
360+
computeTransactionDataHash(td.value)
361+
}
362+
claim("transaction_data_hashes", transactionDataHashes)
363+
claim("transaction_data_hashes_alg", "sha-256")
364+
}
353365
}
354366
return serializeWithKeyBinding(buildKbJwt).getOrThrow()
355367
}
356368

369+
internal fun computeTransactionDataHash(transactionDataValue: String): String {
370+
val digest = MessageDigest.getInstance(SHA_256_ALGORITHM)
371+
digest.update(transactionDataValue.encodeToByteArray())
372+
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest.digest())
373+
}
374+
357375
/**
358376
* Constructs a verifiable presentation for an SD-JWT VC credential.
359377
*
360378
* @param resolvedRequestObject The resolved OpenID4VP authorization request.
361379
* @param document The issued document containing the credential.
362380
* @param disclosedDocument The document with disclosed claims.
363381
* @param signatureAlgorithm The algorithm to use for signing.
382+
* @param transactionData Optional list of SD-JWT VC transaction data applicable to this presentation.
364383
* @return The constructed [VerifiablePresentation.Generic].
365384
* @throws IllegalArgumentException if no claims are disclosed or presentation creation fails.
366385
*/
@@ -369,6 +388,7 @@ internal suspend fun verifiablePresentationForSdJwtVc(
369388
document: IssuedDocument,
370389
disclosedDocument: DisclosedDocument,
371390
signatureAlgorithm: Algorithm,
391+
transactionData: List<TransactionData.SdJwtVc>? = null,
372392
): VerifiablePresentation.Generic {
373393
return document.consumingCredential {
374394
val credentialIssuedData =
@@ -399,7 +419,8 @@ internal suspend fun verifiablePresentationForSdJwtVc(
399419
clientId = resolvedRequestObject.client.id,
400420
nonce = resolvedRequestObject.nonce,
401421
signatureAlgorithm = signatureAlgorithm,
402-
issueDate = Date()
422+
issueDate = Date(),
423+
transactionData = transactionData
403424
)
404425
} else {
405426
presentation.serialize()

wallet-core/src/main/java/eu/europa/ec/eudi/wallet/transfer/openId4vp/OpenId4VpManager.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import eu.europa.ec.eudi.iso18013.transfer.response.Response
2424
import eu.europa.ec.eudi.openid4vp.DispatchOutcome
2525
import eu.europa.ec.eudi.openid4vp.OpenId4Vp
2626
import eu.europa.ec.eudi.openid4vp.Resolution
27-
import eu.europa.ec.eudi.openid4vp.ResolvedRequestObject
2827
import eu.europa.ec.eudi.openid4vp.asException
2928
import eu.europa.ec.eudi.wallet.internal.d
3029
import eu.europa.ec.eudi.wallet.internal.e

wallet-core/src/main/java/eu/europa/ec/eudi/wallet/transfer/openId4vp/SdJwtVcItem.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ import eu.europa.ec.eudi.iso18013.transfer.response.DocItem
2222
* Represents a SD-JWT VC item.
2323
* @property path The path of the item.
2424
*/
25-
class SdJwtVcItem(val path: List<String>) : DocItem
25+
class SdJwtVcItem(val path: List<String>) : DocItem
26+
27+
// query id dcql gia kathe ena apanta me document , ta query ids dn ta dinw sto ui, prepei na
28+
// xeri pia tranction data aforoun pio query

wallet-core/src/main/java/eu/europa/ec/eudi/wallet/transfer/openId4vp/dcql/ProcessedDcqlRequest.kt

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import eu.europa.ec.eudi.iso18013.transfer.response.RequestedDocuments
2323
import eu.europa.ec.eudi.iso18013.transfer.response.ResponseResult
2424
import eu.europa.ec.eudi.openid4vp.Consensus
2525
import eu.europa.ec.eudi.openid4vp.ResolvedRequestObject
26+
import eu.europa.ec.eudi.openid4vp.TransactionData
2627
import eu.europa.ec.eudi.openid4vp.VerifiablePresentation
2728
import eu.europa.ec.eudi.openid4vp.VerifiablePresentations
2829
import eu.europa.ec.eudi.openid4vp.dcql.QueryId
@@ -63,6 +64,17 @@ class ProcessedDcqlRequest(
6364
private val queryMap: RequestedDocumentsByQueryId,
6465
val msoMdocNonce: String,
6566
) : RequestProcessor.ProcessedRequest.Success(RequestedDocuments(queryMap.flatMap { it.value.requestedDocuments })) {
67+
68+
val transactionData: List<TransactionData>?
69+
get() = resolvedRequestObject.transactionData
70+
71+
private fun getTransactionDataForQuery(queryId: QueryId): List<TransactionData.SdJwtVc>? {
72+
return transactionData
73+
?.filterIsInstance<TransactionData.SdJwtVc>()
74+
?.filter { it.credentialIds.contains(queryId) }
75+
?.takeIf { it.isNotEmpty() }
76+
}
77+
6678
/**
6779
* Generates an OpenID4VP response with verifiable presentations for the selected documents.
6880
*
@@ -103,6 +115,7 @@ class ProcessedDcqlRequest(
103115
respondedDocuments.add(respondedDocument)
104116
val verifiablePresentation = runBlocking {
105117
vpFromRequestedDocuments(
118+
queryId = queryId,
106119
format = format,
107120
requestedDocuments = requestedDocuments,
108121
disclosedDocument = disclosedDocument,
@@ -140,6 +153,7 @@ class ProcessedDcqlRequest(
140153
* This method handles the conversion of different document formats into their
141154
* corresponding verifiable presentation formats as defined by OpenID4VP specifications.
142155
*
156+
* @param queryId The DCQL query ID
143157
* @param format Document format identifier (MSO_MDOC or SD_JWT_VC)
144158
* @param requestedDocuments The collection of all requested documents for this query
145159
* @param disclosedDocument The specific document selected to disclose
@@ -148,6 +162,7 @@ class ProcessedDcqlRequest(
148162
* @throws IllegalArgumentException If document format doesn't match expected format or is unsupported
149163
*/
150164
private suspend fun vpFromRequestedDocuments(
165+
queryId: QueryId,
151166
format: String,
152167
requestedDocuments: RequestedDocuments,
153168
disclosedDocument: DisclosedDocument,
@@ -170,7 +185,7 @@ class ProcessedDcqlRequest(
170185
// Generate the appropriate verifiable presentation based on format
171186
val vp = when (format) {
172187
FORMAT_MSO_MDOC -> {
173-
// For MSO mdoc, include session transcript and handle devic engagement
188+
// MSO mdoc does not support transaction data binding in the same way as SD-JWT VC
174189
verifiablePresentationForMsoMdoc(
175190
documentManager = documentManager,
176191
sessionTranscript = resolvedRequestObject
@@ -182,12 +197,14 @@ class ProcessedDcqlRequest(
182197
}
183198

184199
FORMAT_SD_JWT_VC -> {
185-
// For SD-JWT, create presentation according to SD-JWT VC format
200+
// Filter transaction data to only include entries that reference this query ID
201+
val queryTransactionData = getTransactionDataForQuery(queryId)
186202
verifiablePresentationForSdJwtVc(
187203
resolvedRequestObject = resolvedRequestObject,
188204
document = document,
189205
disclosedDocument = disclosedDocument,
190-
signatureAlgorithm = signatureAlgorithm
206+
signatureAlgorithm = signatureAlgorithm,
207+
transactionData = queryTransactionData
191208
)
192209
}
193210

wallet-core/src/test/java/eu/europa/ec/eudi/wallet/internal/Openid4VpUtilsTest.kt

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ package eu.europa.ec.eudi.wallet.internal
3737
//import eu.europa.ec.eudi.wallet.transfer.openId4vp.OpenId4VpReaderTrust
3838
import eu.europa.ec.eudi.wallet.transfer.openId4vp.OpenId4VpReaderTrustImpl
3939
import org.bouncycastle.util.encoders.Hex
40+
import java.security.MessageDigest
41+
import java.util.Base64
4042
import kotlin.test.Test
4143
import kotlin.test.assertEquals
44+
import kotlin.test.assertNotEquals
4245

4346

4447
/**
@@ -212,4 +215,57 @@ class Openid4VpUtilsTest {
212215
// val sessionTranscriptBytes = auth.getSessionTranscriptBytes(mdocGeneratedNonce)
213216
// assertContentEquals(expected, sessionTranscriptBytes)
214217
}
218+
219+
/**
220+
* Tests for transaction data hash computation.
221+
* Per OpenID4VP spec, transaction data hashes use SHA-256 and are base64url-encoded without padding.
222+
*/
223+
@Test
224+
fun testComputeTransactionDataHash_producesCorrectHash() {
225+
// Test with a known input - a simple base64url-encoded JSON object
226+
val transactionDataValue = "eyJ0eXBlIjoiZXUuZXVyb3BhLmVjLmV1ZGkuZmFtaWx5LW5hbWUtcHJlc2VudGF0aW9uIiwiY3JlZGVudGlhbF9pZHMiOlsicGlkX3NkX2p3dCJdfQ"
227+
228+
// Compute the hash using our function
229+
val hash = computeTransactionDataHash(transactionDataValue)
230+
231+
// Manually compute expected hash for verification
232+
val digest = MessageDigest.getInstance("SHA-256")
233+
digest.update(transactionDataValue.encodeToByteArray())
234+
val expectedHash = Base64.getUrlEncoder().withoutPadding().encodeToString(digest.digest())
235+
236+
assertEquals(expectedHash, hash)
237+
}
238+
239+
@Test
240+
fun testComputeTransactionDataHash_differentInputsProduceDifferentHashes() {
241+
val transactionDataValue1 = "eyJ0eXBlIjoiZXhhbXBsZTEifQ"
242+
val transactionDataValue2 = "eyJ0eXBlIjoiZXhhbXBsZTIifQ"
243+
244+
val hash1 = computeTransactionDataHash(transactionDataValue1)
245+
val hash2 = computeTransactionDataHash(transactionDataValue2)
246+
247+
assertNotEquals(hash1, hash2, "Different inputs should produce different hashes")
248+
}
249+
250+
@Test
251+
fun testComputeTransactionDataHash_sameInputProducesSameHash() {
252+
val transactionDataValue = "eyJ0eXBlIjoiZXhhbXBsZSJ9"
253+
254+
val hash1 = computeTransactionDataHash(transactionDataValue)
255+
val hash2 = computeTransactionDataHash(transactionDataValue)
256+
257+
assertEquals(hash1, hash2, "Same input should always produce the same hash")
258+
}
259+
260+
@Test
261+
fun testComputeTransactionDataHash_outputIsBase64UrlWithoutPadding() {
262+
val transactionDataValue = "eyJ0eXBlIjoiZXhhbXBsZSJ9"
263+
264+
val hash = computeTransactionDataHash(transactionDataValue)
265+
266+
// Verify it doesn't contain standard base64 characters that differ in base64url
267+
assert(!hash.contains('+')) { "Hash should not contain '+' (base64url uses '-' instead)" }
268+
assert(!hash.contains('/')) { "Hash should not contain '/' (base64url uses '_' instead)" }
269+
assert(!hash.contains('=')) { "Hash should not contain '=' padding" }
270+
}
215271
}

0 commit comments

Comments
 (0)