diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 92779b4..afbefd0 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -19,6 +19,12 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+
buildTypes {
release {
isMinifyEnabled = false
@@ -69,6 +75,8 @@ dependencies {
implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler)
testImplementation(libs.junit)
+ testImplementation("org.robolectric:robolectric:4.12.2")
+ testImplementation("org.json:json:20240303")
implementation(libs.kotlinx.serialization.json)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2513dba..157b22c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -23,6 +23,11 @@
+
{
diff --git a/app/src/main/java/com/credman/cmwallet/openid4vp/DCQL.kt b/app/src/main/java/com/credman/cmwallet/openid4vp/DCQL.kt
index b7ad061..a52ad70 100644
--- a/app/src/main/java/com/credman/cmwallet/openid4vp/DCQL.kt
+++ b/app/src/main/java/com/credman/cmwallet/openid4vp/DCQL.kt
@@ -255,13 +255,33 @@ fun matchCredential(credential: JSONObject, credentialStore: JSONObject): List {
+ val vctValues = meta.opt("vct_values") as JSONArray? ?: return matchedCredentials
+ val matched = JSONArray()
+ for (i in 0 until vctValues.length()) {
+ val vct = vctValues.getString(i)
+ if (candidatesByFormat.has(vct)) {
+ val candidates = candidatesByFormat.getJSONArray(vct)
+ for (j in 0 until candidates.length()) {
+ matched.put(candidates.getJSONObject(j))
+ }
+ }
+ }
+ if (matched.length() == 0) {
+ Log.i("DCQL", "No candidates matched vct_values for dc+sd-jwt")
+ return matchedCredentials
+ }
+ Log.i("DCQL", "Matched ${matched.length()} candidates for dc+sd-jwt")
+ candidatesByMeta = matched
}
else -> return matchedCredentials
}
} else {
- // TODO: fix the fact that doctype is required at the moment.
return matchedCredentials
}
@@ -299,6 +319,22 @@ fun matchCredential(credential: JSONObject, credentialStore: JSONObject): List {
+ require(claim.has("path")) { "sd-jwt claim must contain path" }
+ val path = claim.getJSONArray("path")
+ val claimName = path.getString(path.length() - 1)
+ val paths = candidate.optJSONObject("paths")
+ if (paths != null && paths.has(claimName)) {
+ Log.d("DCQL", "Matched claim $claimName for dc+sd-jwt")
+ matchedCredential.matchedClaims.add(
+ MatchedMDocClaim("", claimName)
+ )
+ } else {
+ Log.d("DCQL", "Claim $claimName not found in candidate for dc+sd-jwt")
+ }
+ }
}
}
if (claims.length() == matchedCredential.matchedClaims.size) {
diff --git a/app/src/main/java/com/credman/cmwallet/openid4vp/OpenId4VP.kt b/app/src/main/java/com/credman/cmwallet/openid4vp/OpenId4VP.kt
index bebe9cd..05a769b 100644
--- a/app/src/main/java/com/credman/cmwallet/openid4vp/OpenId4VP.kt
+++ b/app/src/main/java/com/credman/cmwallet/openid4vp/OpenId4VP.kt
@@ -17,6 +17,14 @@ data class TransactionData(
val data: JSONObject
)
+data class DelegateProposal(
+ val encodedItem: String, // original base64url item from transaction_data array
+ val format: String, // e.g. "dc+sd-jwt"
+ val delegatePayload: JSONObject, // proposed JWT claims (includes vct, cnf.jwk, mandate fields, _sd if any)
+ val delegateDisclosures: List, // pre-computed disclosure strings
+ val credentialIds: List // credential_ids this mandate is scoped to
+)
+
class OpenId4VP(
var requestJson: JSONObject,
var clientId: String,
@@ -33,6 +41,7 @@ class OpenId4VP(
val dcqlQuery: JSONObject
val transactionData: List
+ val delegateProposals: List
val issuanceOffer: JSONObject?
val clientMedtadata: JSONObject?
val responseMode: String?
@@ -101,6 +110,43 @@ class OpenId4VP(
transactionData = emptyList()
}
+ // Each mandate object in delegate_payload[] becomes one DelegateProposal.
+ // delegate_disclosures are item-level sub-disclosures (e.g. checkout_jwt value).
+ // Sub-disclosures are attached to the first proposal; wallet appends them after
+ // mandate disclosures in the chain. Usually empty for our flow.
+ delegateProposals = transactionData.filter {
+ it.type == "delegate"
+ }.flatMap { td ->
+ val payloadArr = td.data.optJSONArray("delegate_payload")
+ ?: return@flatMap emptyList().also {
+ android.util.Log.d("OpenId4VP", "No delegate_payload found in delegate transaction data")
+ }
+ val disclosuresArr = td.data.optJSONArray("delegate_disclosures")
+ val subDisclosures = if (disclosuresArr != null)
+ (0 until disclosuresArr.length()).map { disclosuresArr.getString(it) }
+ else
+ emptyList()
+ val credIdsArr = td.data.optJSONArray("credential_ids")
+ val credIds = if (credIdsArr != null)
+ (0 until credIdsArr.length()).map { credIdsArr.getString(it) }
+ else
+ emptyList()
+ val format = td.data.optString("format", "dc+sd-jwt")
+
+ android.util.Log.d("OpenId4VP", "Found ${payloadArr.length()} delegate proposals")
+
+ (0 until payloadArr.length()).map { i ->
+ DelegateProposal(
+ encodedItem = td.encodedData,
+ format = format,
+ delegatePayload = payloadArr.getJSONObject(i),
+ // Sub-disclosures attached to first proposal
+ delegateDisclosures = if (i == 0) subDisclosures else emptyList(),
+ credentialIds = credIds
+ )
+ }
+ }
+
}
data class TransactionDataResult(
diff --git a/app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt b/app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt
index cf46ed9..6d7dbcd 100644
--- a/app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt
+++ b/app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt
@@ -160,6 +160,180 @@ class SdJwt(
val kbJwt = createJWTES256(kbHeader, kbPayload, holderKey)
return sdJwt + kbJwt
}
+
+ /**
+ * Produces a dSD-JWT chain for the HITL AP2 mandate flow.
+ *
+ * Structure (per dSD-JWT spec):
+ * dpc_jwt ~ dpc_discs ~~ KB-SD-JWT ~ mandate_disc_1 ~ mandate_disc_2 ~ [sub_discs] ~
+ *
+ * - ONE KB-SD-JWT, signed by device key, whose [delegate_payload] is an array of
+ * SHA-256 digests — one per mandate disclosure.
+ * - Each mandate object from [delegateProposals] becomes an array disclosure
+ * base64url(["", ]) appended AFTER the KB-SD-JWT.
+ * - [sd_hash] in KB-SD-JWT covers the DPC SD-JWT only: issuer_jwt ~ dpc_discs ~
+ * - [_sd_alg] = "sha-256"; typ = "kb-sd-jwt+kb" (delegate payload contains cnf.jwk)
+ *
+ * Agent presentations (agent appends its own KB-JWT, revealing one mandate disc):
+ * → Merchant: dpc_jwt~dpc_discs~~KB-SD-JWT~checkout_disc~agent_KB-JWT
+ * → Credential provider: dpc_jwt~dpc_discs~~KB-SD-JWT~payment_disc~agent_KB-JWT
+ */
+ @OptIn(ExperimentalSerializationApi::class)
+ fun presentWithDelegations(
+ claimSets: JSONArray?,
+ nonce: String,
+ aud: String,
+ transactionDataHashes: Map>,
+ delegateProposals: List
+ ): String {
+ require(delegateProposals.isNotEmpty()) {
+ "Use present() when there are no delegate proposals"
+ }
+
+ android.util.Log.d("SdJwt", "presentWithDelegations started with ${delegateProposals.size} proposals")
+
+ // ── Step 1: select DPC disclosures ────────────────────────────────────────────
+ val selectedDisclosures = mutableListOf()
+ if (claimSets == null) {
+ android.util.Log.d("SdJwt", "No claimSets provided, adding all disclosures")
+ selectedDisclosures.addAll(disclosures)
+ } else {
+ var matched = false
+ outer@ for (i in 0..()
+ var ok = true
+ for (claimIdx in 0 until claimSet.length()) {
+ val claim = claimSet.getJSONObject(claimIdx)
+ val path = claim.getJSONArray("path")
+ var sd = verifiedResult.sdMap
+ val sds = mutableListOf()
+ for (pathIdx in 0.. 1) {
+ for (k in 0..", ])
+ val rng = java.security.SecureRandom()
+ val mandateDisclosures = delegateProposals.map { proposal ->
+ val saltBytes = ByteArray(16).also { rng.nextBytes(it) }
+ val salt = saltBytes.toBase64UrlNoPadding()
+ val discArr = JSONArray().put(salt).put(proposal.delegatePayload)
+ discArr.toString().toByteArray().toBase64UrlNoPadding()
+ }
+
+ android.util.Log.d("SdJwt", "Created ${mandateDisclosures.size} mandate disclosures")
+
+ // ── Step 4: digest of each mandate disclosure → KB-SD-JWT delegate_payload ────
+ val mandateDigests = mandateDisclosures.map { disc ->
+ MessageDigest.getInstance("SHA-256")
+ .digest(disc.encodeToByteArray())
+ .toBase64UrlNoPadding()
+ }
+
+ // ── Step 5: build ONE KB-SD-JWT ───────────────────────────────────────────────
+ val hasCnf = delegateProposals.any { it.delegatePayload.has("cnf") }
+ android.util.Log.d("SdJwt", "hasCnf evaluated to: $hasCnf")
+ val typ = if (hasCnf) "kb-sd-jwt+kb" else "kb-sd-jwt"
+ android.util.Log.d("SdJwt", "Setting KB-SD-JWT typ to: $typ")
+ val kbSdHeader = buildJsonObject {
+ put("typ", typ)
+ put("alg", "ES256")
+ }
+ val kbSdPayload = buildJsonObject {
+ put("iat", Instant.now().epochSecond)
+ put("aud", aud)
+ put("nonce", nonce)
+ put("sd_hash", sdHash)
+ putJsonArray("delegate_payload") { mandateDigests.forEach { add(it) } }
+ put("_sd_alg", "sha-256")
+ if (transactionDataHashes.isNotEmpty()) {
+ for (entry in transactionDataHashes) {
+ putJsonArray(entry.key) {
+ entry.value.forEach { data -> add(data.toBase64UrlNoPadding()) }
+ }
+ }
+ }
+ }
+ val kbSdJwt = createJWTES256(kbSdHeader, kbSdPayload, holderKey)
+
+ android.util.Log.d("SdJwt", "Created KB-SD-JWT")
+
+ // ── Step 6: collect sub-disclosures (usually empty in our flow) ───────────────
+ val allSubDisclosures = delegateProposals.flatMap { it.delegateDisclosures }
+
+ if (allSubDisclosures.isNotEmpty()) {
+ android.util.Log.d("SdJwt", "Adding ${allSubDisclosures.size} sub-disclosures")
+ }
+
+ // ── Step 7: assemble chain ─────────────────────────────────────────────────────
+ // As per the dSD-JWT proposal, separate the SD-JWT part and the KB-JWT part with a double tilde (~~).
+ // dpc_jwt ~ dpc_discs ~~ KB-SD-JWT ~ mandate_disc_1 ~ mandate_disc_2 ~ sub_discs ~
+ val sdJwtPart = (listOf(issuerJwt) + selectedDisclosures).joinToString("~")
+ val kbPart = (listOf(kbSdJwt) + mandateDisclosures + allSubDisclosures).joinToString("~")
+
+ android.util.Log.d("SdJwt", "Assembled chain with double tilde separator")
+
+ return "$sdJwtPart~~$kbPart~"
+ }
+
+
+ /** Converts an org.json value to a kotlinx.serialization JsonElement. */
+ private fun anyToJsonElement(v: Any?): kotlinx.serialization.json.JsonElement = when (v) {
+ null, JSONObject.NULL -> kotlinx.serialization.json.JsonNull
+ is Boolean -> kotlinx.serialization.json.JsonPrimitive(v)
+ is Int -> kotlinx.serialization.json.JsonPrimitive(v)
+ is Long -> kotlinx.serialization.json.JsonPrimitive(v)
+ is Double -> kotlinx.serialization.json.JsonPrimitive(v)
+ is Float -> kotlinx.serialization.json.JsonPrimitive(v)
+ is String -> kotlinx.serialization.json.JsonPrimitive(v)
+ is JSONObject -> {
+ val map = mutableMapOf()
+ for (k in v.keys()) map[k] = anyToJsonElement(v.get(k))
+ kotlinx.serialization.json.JsonObject(map)
+ }
+ is JSONArray -> {
+ val list = (0 until v.length()).map { anyToJsonElement(v.get(it)) }
+ kotlinx.serialization.json.JsonArray(list)
+ }
+ else -> kotlinx.serialization.json.JsonPrimitive(v.toString())
+ }
+
}
class VerificationResult(
diff --git a/app/src/main/java/com/credman/cmwallet/testap2/Ap2TestActivity.kt b/app/src/main/java/com/credman/cmwallet/testap2/Ap2TestActivity.kt
new file mode 100644
index 0000000..35e79fa
--- /dev/null
+++ b/app/src/main/java/com/credman/cmwallet/testap2/Ap2TestActivity.kt
@@ -0,0 +1,410 @@
+package com.credman.cmwallet.testap2
+
+import android.os.Bundle
+import android.util.Log
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.credentials.*
+import androidx.credentials.exceptions.GetCredentialException
+import androidx.lifecycle.lifecycleScope
+import com.credman.cmwallet.ui.theme.CMWalletTheme
+import kotlinx.coroutines.launch
+import org.json.JSONArray
+import org.json.JSONObject
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.Signature
+import java.security.interfaces.ECPrivateKey
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+import java.util.Base64
+
+@OptIn(ExperimentalDigitalCredentialApi::class)
+class Ap2TestActivity : ComponentActivity() {
+
+ private val TAG = "Ap2TestActivity"
+
+ private val agentKeyPair by lazy {
+ KeyPairGenerator.getInstance("EC")
+ .apply { initialize(ECGenParameterSpec("secp256r1")) }
+ .generateKeyPair()
+ }
+
+ private val uiState = mutableStateOf(Ap2UiState())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ CMWalletTheme { Ap2TestScreen() }
+ }
+ }
+
+ private fun invokeCredentialManager(reqJson: String, mandatesJson: String, checkoutJson: String) {
+ uiState.value = uiState.value.copy(status = "Processing edits & Invoking…", error = null)
+ lifecycleScope.launch {
+ try {
+ val checkoutJwtPayload = JSONObject(checkoutJson)
+ val headerB64 = b64("{\"alg\":\"ES256\",\"typ\":\"checkout+jwt\"}")
+ val payloadB64 = b64u(checkoutJwtPayload.toString())
+ val mockSigB64 = b64("MOCK_SIGNATURE_BYTES")
+
+ val checkoutJwt = "$headerB64.$payloadB64.$mockSigB64"
+ val checkoutHash = b64u(sha256bytes(checkoutJwt))
+
+ val mandatesArray = JSONArray(mandatesJson)
+ for (i in 0 until mandatesArray.length()) {
+ val mandate = mandatesArray.getJSONObject(i)
+ if (mandate.optString("vct") == "mandate.checkout") {
+ mandate.put("checkout_hash", checkoutHash)
+ mandate.put("checkout_jwt", checkoutJwt)
+ } else if (mandate.optString("vct") == "mandate.payment") {
+ mandate.put("transaction_id", checkoutHash)
+ }
+ }
+
+ val tdItem = JSONObject().apply {
+ put("type", "delegate")
+ put("format", "dc+sd-jwt")
+ put("credential_ids", JSONArray().put("dpc_credential"))
+ put("delegate_payload", mandatesArray)
+ put("delegate_disclosures", JSONArray())
+ }
+
+ val requestObj = JSONObject(reqJson)
+ val dataObject = requestObj.getJSONArray("requests").getJSONObject(0).getJSONObject("data")
+ dataObject.put("transaction_data", JSONArray().put(b64u(tdItem.toString())))
+
+ val finalRequestString = requestObj.toString()
+ Log.d(TAG, "Final Request: $finalRequestString")
+
+ uiState.value = uiState.value.copy(requestPreview = previewTdItem(finalRequestString))
+
+ val response = CredentialManager.create(this@Ap2TestActivity).getCredential(
+ context = this@Ap2TestActivity,
+ request = GetCredentialRequest(listOf(GetDigitalCredentialOption(finalRequestString)))
+ )
+
+ val cred = response.credential
+ if (cred is DigitalCredential) processVpToken(cred.credentialJson)
+
+ } catch (e: org.json.JSONException) {
+ uiState.value = uiState.value.copy(status = "JSON Error", error = "Syntax error in your input blocks.")
+ } catch (e: GetCredentialException) {
+ uiState.value = uiState.value.copy(status = "CredentialManager error", error = "${e::class.simpleName}: ${e.message}")
+ } catch (e: Exception) {
+ uiState.value = uiState.value.copy(status = "Error", error = e.message)
+ }
+ }
+ }
+
+ private fun buildDefaultRequest() = JSONObject().apply {
+ put("requests", JSONArray().put(JSONObject().apply {
+ put("protocol", "openid4vp-v1-unsigned")
+ put("data", JSONObject().apply {
+ put("response_type", "vp_token")
+ put("response_mode", "dc_api")
+ put("client_id", "origin:https://agent.ap2.example")
+ put("nonce", "s6FhdRcsNDIIm_4YmFDd1A")
+ put("dcql_query", JSONObject().apply {
+ put("credentials", JSONArray().put(JSONObject().apply {
+ put("id", "dpc_credential")
+ put("format", "dc+sd-jwt")
+ put("meta", JSONObject().put("vct_values", JSONArray().put("com.emvco.dpc")))
+ put("claims", JSONArray().apply {
+ put(JSONObject().put("path", JSONArray().put("card_last_four")))
+ put(JSONObject().put("path", JSONArray().put("card_network_code")))
+ put(JSONObject().put("path", JSONArray().put("credential_id")))
+ })
+ }))
+ })
+ put("transaction_data", JSONArray())
+ })
+ }))
+ }.toString(2)
+
+ private fun buildDefaultMandates(): String {
+ val agentJwk = agentPublicJwk()
+ val checkoutMandate = JSONObject().apply {
+ put("vct", "mandate.checkout"); put("exp", 9999999999L); put("cnf", JSONObject().put("jwk", agentJwk))
+ }
+ val paymentMandate = JSONObject().apply {
+ put("vct", "mandate.payment")
+ put("payee", JSONObject().apply { put("id", "m_fashion_001"); put("name", "Acme Fashion") })
+ put("payment_amount", JSONObject().apply { put("value", "112.65"); put("currency", "USD") })
+ put("payment_instrument", JSONObject().apply { put("type", "dpc"); put("credential_id", "b3f1c8a2-6d4e-4f9a-9e3d-8a7c2f1b9d34") })
+ put("exp", 9999999999L); put("cnf", JSONObject().put("jwk", agentJwk))
+ }
+ return JSONArray().put(checkoutMandate).put(paymentMandate).toString(2)
+ }
+
+ private fun buildDefaultCheckout() = JSONObject().apply {
+ put("id", "order_20260330_9f3a")
+ put("status", "pending_payment")
+ put("currency", "USD")
+ put("merchant", JSONObject().put("id", "m_fashion_001").put("name", "Acme Fashion"))
+ put("line_items", JSONArray().apply {
+ put(JSONObject().apply { put("title", "Vintage Denim Jacket"); put("quantity", 1); put("unit_price", "65.00") })
+ put(JSONObject().apply { put("title", "Cotton T-Shirt"); put("quantity", 2); put("unit_price", "15.00") })
+ put(JSONObject().apply { put("title", "Express Shipping"); put("quantity", 1); put("unit_price", "10.00") })
+ put(JSONObject().apply { put("title", "Sales Tax"); put("quantity", 1); put("unit_price", "7.65") })
+ })
+ put("totals", JSONObject().put("subtotal", "95.00").put("total", "112.65"))
+ }.toString(2)
+
+ private fun processVpToken(vpTokenJson: String) {
+ try {
+ val chain = runCatching { JSONObject(vpTokenJson).getString("token") }
+ .getOrDefault(vpTokenJson)
+
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val compactPos = parts.indices.filter { parts[it].split(".").size == 3 }
+ if (compactPos.size < 2) {
+ uiState.value = uiState.value.copy(status = "Expected 2 compact JWTs, got ${compactPos.size}")
+ return
+ }
+
+ val kbSdJwtIdx = compactPos[1]
+ val kbSdJwt = parts[kbSdJwtIdx]
+ val dpcParts = parts.subList(0, kbSdJwtIdx)
+ val (kbH, kbP) = decodeJwt(kbSdJwt)
+
+ val log = StringBuilder()
+ log.appendLine("KB-SD-JWT header:")
+ log.appendLine(" typ = ${kbH.optString("typ")}")
+ log.appendLine("KB-SD-JWT payload:")
+ log.appendLine(" nonce = ${kbP.optString("nonce")}")
+ log.appendLine(" aud = ${kbP.optString("aud")}")
+ log.appendLine(" sd_hash = ${kbP.optString("sd_hash")}")
+
+ val dpcBase = dpcParts.joinToString("~", postfix = "~")
+ val expectedSdHash = b64u(sha256bytes(dpcBase))
+ val sdHashOk = expectedSdHash == kbP.optString("sd_hash")
+ log.appendLine(" sd_hash valid = $sdHashOk ✓")
+
+ val dpArr = kbP.optJSONArray("delegate_payload")
+ log.appendLine(" delegate_payload digests = ${dpArr?.length() ?: 0}")
+
+ val mandateDiscs = parts.subList(kbSdJwtIdx + 1, parts.size)
+ .filter { it.split(".").size == 1 }
+
+ log.appendLine("\nMandate disclosures: ${mandateDiscs.size}")
+ mandateDiscs.forEachIndexed { i, disc ->
+ val actualDigest = b64u(sha256bytes(disc))
+ val claimedDigest = dpArr?.optString(i) ?: "?"
+ log.appendLine(" disc[$i] bound to KB-SD-JWT: ${actualDigest == claimedDigest} ✓")
+ }
+
+ var checkoutObj: JSONObject? = null
+ var paymentObj: JSONObject? = null
+ mandateDiscs.forEach { disc ->
+ runCatching {
+ val arr = decodeDisclosure(disc)
+ val obj = arr.getJSONObject(1)
+ when (obj.optString("vct")) {
+ "mandate.checkout" -> checkoutObj = obj
+ "mandate.payment" -> paymentObj = obj
+ }
+ }
+ }
+
+ val merchantToken = buildPresentation(dpcParts, kbSdJwt, mandateDiscs, "mandate.checkout", nonce = "merchant-nonce-xyz", aud = "https://lyft.com")
+ val cpToken = buildPresentation(dpcParts, kbSdJwt, mandateDiscs, "mandate.payment", nonce = "cp-nonce-xyz", aud = "https://credential-provider.paynet.example")
+
+ uiState.value = uiState.value.copy(
+ status = "dSD-JWT verified ✓",
+ rawChain = chain.take(400) + "…",
+ verifyLog = log.toString(),
+ checkoutMandate = checkoutObj?.toString(2),
+ paymentMandate = paymentObj?.toString(2),
+ merchantToken = merchantToken?.take(400) + "…",
+ cpToken = cpToken?.take(400) + "…"
+ )
+
+ } catch (e: Exception) {
+ Log.e(TAG, "processVpToken error", e)
+ uiState.value = uiState.value.copy(status = "Parse error", error = e.message)
+ }
+ }
+
+ private fun buildPresentation(dpcParts: List, kbSdJwt: String, mandateDiscs: List, targetVct: String, nonce: String, aud: String): String? {
+ val targetDisc = mandateDiscs.firstOrNull { disc ->
+ runCatching { decodeDisclosure(disc).getJSONObject(1).optString("vct") == targetVct }.getOrDefault(false)
+ } ?: return null
+ val prefix = (dpcParts + listOf(kbSdJwt, targetDisc)).joinToString("~", postfix = "~")
+ val agentKb = buildAgentKbJwt(prefix, nonce, aud)
+ return "$prefix$agentKb"
+ }
+
+ private fun buildAgentKbJwt(chainPrefix: String, nonce: String, aud: String): String {
+ val sdHash = b64u(sha256bytes(chainPrefix))
+ val header = b64u("""{"typ":"kb+jwt","alg":"ES256"}""")
+ val payload = b64u("""{"iat":${System.currentTimeMillis()/1000},"aud":"$aud","nonce":"$nonce","sd_hash":"$sdHash"}""")
+ val der = Signature.getInstance("SHA256withECDSA").apply {
+ initSign(agentKeyPair.private as ECPrivateKey)
+ update("$header.$payload".toByteArray())
+ }.sign()
+ return "$header.$payload.${b64u(derToRaw(der))}"
+ }
+
+ private fun b64u(s: String) = b64u(s.toByteArray())
+ private fun b64u(b: ByteArray) = Base64.getUrlEncoder().withoutPadding().encodeToString(b)
+ private fun b64(s: String) = s
+ private fun sha256bytes(s: String) = MessageDigest.getInstance("SHA-256").digest(s.toByteArray())
+
+ private fun agentPublicJwk(): JSONObject {
+ val pub = agentKeyPair.public as ECPublicKey
+ return JSONObject().apply {
+ put("kty", "EC"); put("crv", "P-256"); put("use", "sig")
+ put("x", encodeCoord(pub.w.affineX.toByteArray()))
+ put("y", encodeCoord(pub.w.affineY.toByteArray()))
+ }
+ }
+
+ private fun encodeCoord(raw: ByteArray): String {
+ val fixed = if (raw.size > 32) raw.copyOfRange(raw.size - 32, raw.size)
+ else raw.copyOf(32).also { raw.copyInto(it, 32 - raw.size) }
+ return b64u(fixed)
+ }
+
+ private fun decodeJwt(compact: String): Pair {
+ fun dec(s: String) = JSONObject(String(Base64.getUrlDecoder().decode(s.padEnd(s.length + (4 - s.length % 4) % 4, '='))))
+ val p = compact.split(".")
+ return dec(p[0]) to dec(p[1])
+ }
+
+ private fun decodeDisclosure(b64: String): JSONArray {
+ val padded = b64.padEnd(b64.length + (4 - b64.length % 4) % 4, '=')
+ return JSONArray(String(Base64.getUrlDecoder().decode(padded)))
+ }
+
+ private fun derToRaw(der: ByteArray): ByteArray {
+ val rLen = der[3].toInt() and 0xff
+ val r = der.copyOfRange(4, 4 + rLen)
+ val sOff = 4 + rLen + 2
+ val sLen = der[sOff - 1].toInt() and 0xff
+ val s = der.copyOfRange(sOff, sOff + sLen)
+ fun pad32(a: ByteArray) = if (a.size > 32) a.copyOfRange(a.size - 32, a.size)
+ else a.copyOf(32).also { a.copyInto(it, 32 - a.size) }
+ return pad32(r) + pad32(s)
+ }
+
+ private fun previewTdItem(requestJson: String): String {
+ return runCatching {
+ val requests = JSONObject(requestJson).getJSONArray("requests")
+ val data = requests.getJSONObject(0).getJSONObject("data")
+ val tdEnc = data.getJSONArray("transaction_data").getString(0)
+ val padded = tdEnc.padEnd(tdEnc.length + (4 - tdEnc.length % 4) % 4, '=')
+ val tdJson = String(Base64.getUrlDecoder().decode(padded))
+ JSONObject(tdJson).toString(2)
+ }.getOrDefault("(parse error)")
+ }
+
+ // ── Full Screen Workspace Layout ─────────────────────────────────────────
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun Ap2TestScreen() {
+ val state by uiState
+ val scroll = rememberScrollState()
+
+ var reqJson by remember { mutableStateOf(buildDefaultRequest()) }
+ var mandatesJson by remember { mutableStateOf(buildDefaultMandates()) }
+ var checkoutJson by remember { mutableStateOf(buildDefaultCheckout()) }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("AP2 dSD-JWT Workspace") },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "Back")
+ }
+ }
+ )
+ }
+ ) { pad ->
+ Column(
+ modifier = Modifier.padding(pad).padding(16.dp).verticalScroll(scroll),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text("Edit Core Request & Payload Arrays", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Text("Modify these blocks directly before executing the flow.", fontSize = 12.sp, color = Color.Gray)
+
+ // ── The 3 Workspace Text Boxes on full screen ──
+ OutlinedTextField(value = reqJson, onValueChange = { reqJson = it }, label = { Text("1. Core OpenID4VP JSON") }, modifier = Modifier.fillMaxWidth().height(180.dp), textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp))
+ OutlinedTextField(value = mandatesJson, onValueChange = { mandatesJson = it }, label = { Text("2. AP2 Mandates Array") }, modifier = Modifier.fillMaxWidth().height(180.dp), textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp))
+ OutlinedTextField(value = checkoutJson, onValueChange = { checkoutJson = it }, label = { Text("3. Decoded Checkout JWT Payload") }, modifier = Modifier.fillMaxWidth().height(220.dp), textStyle = androidx.compose.ui.text.TextStyle(fontFamily = FontFamily.Monospace, fontSize = 11.sp))
+
+ Button(
+ onClick = { invokeCredentialManager(reqJson, mandatesJson, checkoutJson) },
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text("Invoke CredentialManager")
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ // Results and Logs rendered down below
+ InfoCard("Status", state.status, error = state.error != null)
+ state.error?.let { Card(Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(MaterialTheme.colorScheme.errorContainer)) { Text(it, Modifier.padding(12.dp), fontSize = 11.sp) } }
+ state.requestPreview?.let { ExpandCard("Transaction Data (decoded)", it) }
+ state.rawChain?.let { ExpandCard("Raw dSD-JWT Chain", it) }
+ state.verifyLog?.let { ExpandCard("Verification Log", it, mono = true) }
+ state.checkoutMandate?.let { ExpandCard("Checkout Mandate → Merchant", it, mono = true) }
+ state.paymentMandate?.let { ExpandCard("Payment Mandate → Cred Provider", it, mono = true) }
+ state.merchantToken?.let { ExpandCard("Merchant Presentation (truncated)", it, mono = true) }
+ state.cpToken?.let { ExpandCard("CP Presentation (truncated)", it, mono = true) }
+ }
+ }
+ }
+
+ @Composable
+ private fun InfoCard(label: String, value: String, error: Boolean = false) {
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Text(label, fontWeight = FontWeight.Bold, fontSize = 13.sp)
+ Text(value, color = if (error) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
+ }
+ }
+ }
+
+ @Composable
+ private fun ExpandCard(title: String, content: String, mono: Boolean = false) {
+ var open by remember { mutableStateOf(false) }
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ Text(title, fontWeight = FontWeight.Bold, fontSize = 13.sp, modifier = Modifier.weight(1f))
+ TextButton(onClick = { open = !open }) { Text(if (open) "Hide" else "Show") }
+ }
+ if (open) Text(content, fontSize = 11.sp, fontFamily = if (mono) FontFamily.Monospace else FontFamily.Default)
+ }
+ }
+ }
+}
+
+data class Ap2UiState(
+ val status: String = "Ready",
+ val error: String? = null,
+ val requestPreview: String? = null,
+ val rawChain: String? = null,
+ val verifyLog: String? = null,
+ val checkoutMandate: String? = null,
+ val paymentMandate: String? = null,
+ val merchantToken: String? = null,
+ val cpToken: String? = null
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/credman/cmwallet/ui/HomeScreen.kt b/app/src/main/java/com/credman/cmwallet/ui/HomeScreen.kt
index f5ef2ab..0ec0e0a 100644
--- a/app/src/main/java/com/credman/cmwallet/ui/HomeScreen.kt
+++ b/app/src/main/java/com/credman/cmwallet/ui/HomeScreen.kt
@@ -1,5 +1,6 @@
package com.credman.cmwallet.ui
+import android.content.Intent
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -36,6 +37,21 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.activity.compose.LocalActivity
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.NavigationDrawerItem
+import androidx.compose.material3.NavigationDrawerItemDefaults
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -45,9 +61,13 @@ import androidx.compose.ui.window.Dialog
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.credman.cmwallet.MainActivity
-import com.credman.cmwallet.R
import com.credman.cmwallet.data.model.CredentialItem
import com.credman.cmwallet.data.model.CredentialKeySoftware
+import com.credman.cmwallet.R
+import com.credman.cmwallet.data.model.toPrivateKey
+import com.credman.cmwallet.decodeBase64
+import com.credman.cmwallet.testap2.Ap2TestActivity
+import kotlinx.coroutines.launch
import com.credman.cmwallet.data.model.toPrivateKey
import com.credman.cmwallet.decodeBase64
import com.credman.cmwallet.openid4vci.data.CredentialConfigurationMDoc
@@ -55,6 +75,7 @@ import com.credman.cmwallet.openid4vci.data.CredentialConfigurationSdJwtVc
import com.credman.cmwallet.sdjwt.SdJwt
import kotlin.io.encoding.ExperimentalEncodingApi
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
@@ -62,40 +83,91 @@ fun HomeScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val openCredentialDialog = remember { mutableStateOf(null) }
- Scaffold(
- modifier = Modifier.fillMaxSize(),
- topBar = {
- CenterAlignedTopAppBar(
- title = {
- Text(text = "CMWallet")
- }
- )
+
+ // Sidebar states
+ val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
+ val scope = rememberCoroutineScope()
+ val context = LocalContext.current
+
+ ModalNavigationDrawer(
+ drawerState = drawerState,
+ drawerContent = {
+ ModalDrawerSheet(
+ modifier = Modifier.width(280.dp).fillMaxHeight()
+ ) {
+ Spacer(Modifier.height(16.dp))
+ Text(
+ text = "Credential Wallet",
+ modifier = Modifier.padding(16.dp),
+ fontWeight = FontWeight.Bold,
+ fontSize = 20.sp
+ )
+ HorizontalDivider()
+
+ // Sidebar Option to launch AP2 Activity
+ NavigationDrawerItem(
+ label = { Text("Try out AP2") },
+ selected = false,
+ onClick = {
+ scope.launch { drawerState.close() }
+ context.startActivity(Intent(context, Ap2TestActivity::class.java))
+ },
+ modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
+ )
+ }
}
- ) { innerPadding ->
- Column(
- modifier = Modifier.padding(innerPadding),
- ) {
- HorizontalDivider(thickness = 2.dp)
- CredentialList(
- uiState.credentials,
- onCredentialClick = { cred ->
- openCredentialDialog.value = cred
- }
+ ) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ CenterAlignedTopAppBar(
+ title = { Text(text = "CMWallet") },
+ navigationIcon = {
+ IconButton(onClick = { scope.launch { drawerState.open() } }) {
+ // ── FIXED: Proper 3-lined Hamburger Menu Icon ──
+ Icon(
+ imageVector = Icons.Default.Menu,
+ contentDescription = "Menu"
+ )
+ }
+ }
+ )
+ }
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier.padding(innerPadding),
+ ) {
+ HorizontalDivider(thickness = 2.dp)
+
+ // Helper text to guide users
+ Text(
+ text = "Swipe from left or use top menu to find 'Try out AP2'",
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ color = Color.Gray
+ )
+
+ HorizontalDivider(thickness = 1.dp)
+ CredentialList(
+ uiState.credentials,
+ onCredentialClick = { cred ->
+ openCredentialDialog.value = cred
+ }
+ )
+ }
+ }
+ if (openCredentialDialog.value != null) {
+ CredentialDialog(
+ onDismissRequest = { openCredentialDialog.value = null },
+ onDeleteCredential = { id ->
+ openCredentialDialog.value = null
+ viewModel.deleteCredential(id)
+ },
+ credentialItem = openCredentialDialog.value!!
)
}
}
- if (openCredentialDialog.value != null) {
- CredentialDialog(
- onDismissRequest = {
- openCredentialDialog.value = null
- },
- onDeleteCredential = {id ->
- openCredentialDialog.value = null
- viewModel.deleteCredential(id)
- },
- credentialItem = openCredentialDialog.value!!
- )
- }
}
@Composable
diff --git a/app/src/test/java/com/credman/cmwallet/Ap2SampleGenerator.kt b/app/src/test/java/com/credman/cmwallet/Ap2SampleGenerator.kt
new file mode 100644
index 0000000..86aff88
--- /dev/null
+++ b/app/src/test/java/com/credman/cmwallet/Ap2SampleGenerator.kt
@@ -0,0 +1,566 @@
+package com.credman.cmwallet
+
+import com.credman.cmwallet.openid4vp.DelegateProposal
+import com.credman.cmwallet.sdjwt.SdJwt
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+import java.security.spec.PKCS8EncodedKeySpec
+import java.util.Base64 as JBase64
+
+/**
+ * Generates a concrete annotated OID4VP request+response sample for AP2 HITL mandate flow.
+ * Run with: ./gradlew testDebugUnitTest --tests "*Ap2SampleGenerator*"
+ * Output goes to stdout / test report.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [33])
+class Ap2SampleGenerator {
+
+ private val holderPrivKeyB64Url =
+ "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD17D2RSlvQ8ElFrP" +
+ "qEG3JfXTjxyKEH9DMpFnWp_Z63ihRANCAATyMFauK4kFj767__aM4l9xfgmPiQSp" +
+ "jgJRf1x_VtB11nLB9pDhoZXpoUUbj1GBSiWGYFahF0IdiX6LUShkTHyx"
+
+ private val dpcCredential = DpcSdJwtMandateTest.DPC_SDJWT_CREDENTIAL
+
+ @Test
+ fun `generate annotated AP2 end-to-end sample`() {
+
+ // ── Agent key pair (AI agent that will hold the mandates) ──────────────
+ val kpg = KeyPairGenerator.getInstance("EC")
+ kpg.initialize(ECGenParameterSpec("secp256r1"))
+ val agentKp = kpg.generateKeyPair()
+ val agentPub = agentKp.public as ECPublicKey
+
+ fun coord(raw: ByteArray): String {
+ val fixed = if (raw.size > 32) raw.copyOfRange(raw.size - 32, raw.size)
+ else raw.copyOf(32).also { raw.copyInto(it, 32 - raw.size) }
+ return JBase64.getUrlEncoder().withoutPadding().encodeToString(fixed)
+ }
+ val agentJwk = JSONObject().apply {
+ put("kty", "EC"); put("crv", "P-256"); put("use", "sig")
+ put("x", coord(agentPub.w.affineX.toByteArray()))
+ put("y", coord(agentPub.w.affineY.toByteArray()))
+ }
+
+ // ── Build a realistic checkout_jwt (merchant-signed, here just a compact JWT stub) ─
+ val checkoutJwtPayload = JSONObject().apply {
+ put("id", "order_20260330_9f3a")
+ put("status", "pending_payment")
+ put("currency", "USD")
+ put("merchant", JSONObject().apply { put("id", "m_lyft_001"); put("name", "Lyft") })
+ put("line_items", JSONArray().put(JSONObject().apply {
+ put("title", "Ride to SFO"); put("quantity", 1); put("unit_price", "42.50")
+ }))
+ put("totals", JSONObject().apply {
+ put("subtotal", "42.50"); put("tax", "3.72"); put("total", "46.22")
+ })
+ }
+ // checkout_jwt = "merchant.header.sig" (stub — in real flow merchant signs this)
+ val checkoutJwtStub = "eyJhbGciOiJFUzI1NiIsInR5cCI6ImNoZWNrb3V0K2p3dCJ9" +
+ "." + JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(checkoutJwtPayload.toString().toByteArray()) +
+ ".MERCHANT_SIGNATURE_STUB"
+ val checkoutHash = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256")
+ .digest(checkoutJwtStub.toByteArray()))
+
+ // ── Selective disclosure for checkout_jwt in the checkout mandate ──────
+ // Merchant pre-computes: disclosure = base64url(["salt","checkout_jwt",""])
+ val checkoutDisclosureArr = JSONArray()
+ .put("8eONq8oSDj4kQ7R2aF5Lnw")
+ .put("checkout_jwt")
+ .put(checkoutJwtStub)
+ val checkoutDisclosure = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(checkoutDisclosureArr.toString().toByteArray())
+ val checkoutDiscDigest = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256")
+ .digest(checkoutDisclosure.toByteArray()))
+
+ // ── Delegate payloads ──────────────────────────────────────────────────
+ val checkoutDelegatePayload = JSONObject().apply {
+ put("vct", "mandate.checkout.1")
+ put("exp", 9_999_999_999L)
+ put("cnf", JSONObject().put("jwk", agentJwk))
+ put("checkout_hash", checkoutHash)
+ put("_sd", JSONArray().put(checkoutDiscDigest))
+ put("_sd_alg", "sha-256")
+ }
+
+ val paymentDelegatePayload = JSONObject().apply {
+ put("vct", "mandate.payment")
+ put("exp", 9_999_999_999L)
+ put("cnf", JSONObject().put("jwk", agentJwk))
+ put("constraints", JSONArray().apply {
+ put(JSONObject().apply {
+ put("type", "payment.amount"); put("currency", "USD"); put("max", "46.22")
+ })
+ put(JSONObject().apply {
+ put("type", "payment.allowed_payees")
+ put("allowed", JSONArray().put("lyft.com"))
+ })
+ put(JSONObject().apply {
+ put("type", "payment.reference")
+ put("checkout_reference", checkoutHash)
+ })
+ })
+ }
+
+ // ── Encode transaction_data items ─────────────────────────────────────
+ fun encodeTd(payload: JSONObject, discs: List = emptyList()): String {
+ val item = JSONObject().apply {
+ put("type", "delegate")
+ put("format", "dc+sd-jwt")
+ put("credential_ids", JSONArray().put("dpc_credential"))
+ put("delegate_payload", JSONArray().put(payload))
+ put("delegate_disclosures", JSONArray().apply { discs.forEach { put(it) } })
+ }
+ return JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(item.toString().toByteArray())
+ }
+ val td0 = encodeTd(checkoutDelegatePayload, listOf(checkoutDisclosure))
+ val td1 = encodeTd(paymentDelegatePayload)
+
+ // ── OID4VP Authorization Request ───────────────────────────────────────
+ val nonce = "s6FhdRcsNDIIm_4YmFDd1A"
+ val clientId = "origin:https://agent.ap2.example"
+ val request = JSONObject().apply {
+ put("nonce", nonce)
+ put("client_id", clientId)
+ put("response_type", "vp_token")
+ put("response_mode", "dc_api")
+ put("dcql_query", JSONObject().apply {
+ put("credentials", JSONArray().put(JSONObject().apply {
+ put("id", "dpc_credential")
+ put("format", "dc+sd-jwt")
+ put("meta", JSONObject().put("vct_values", JSONArray().put("com.emvco.dpc")))
+ put("claims", JSONArray().apply {
+ put(JSONObject().put("path", JSONArray().put("card_last_four")))
+ put(JSONObject().put("path", JSONArray().put("card_network_code")))
+ put(JSONObject().put("path", JSONArray().put("credential_id")))
+ })
+ }))
+ })
+ put("transaction_data", JSONArray().put(td0).put(td1))
+ }
+
+ // ── Wallet processes the request ───────────────────────────────────────
+ val keyBytes = JBase64.getUrlDecoder().decode(holderPrivKeyB64Url)
+ val keySpec = PKCS8EncodedKeySpec(keyBytes)
+ val keyFactory = KeyFactory.getInstance("EC")
+ val holderKey = keyFactory.generatePrivate(keySpec)
+
+ val proposals = listOf(
+ DelegateProposal("e1","dc+sd-jwt", checkoutDelegatePayload,
+ listOf(checkoutDisclosure), listOf("dpc_credential")),
+ DelegateProposal("e2","dc+sd-jwt", paymentDelegatePayload,
+ emptyList(), listOf("dpc_credential"))
+ )
+ val dpcSdJwt = SdJwt(dpcCredential, holderKey)
+ val vpChain = dpcSdJwt.presentWithDelegations(
+ claimSets = null,
+ nonce = nonce,
+ aud = clientId,
+ transactionDataHashes = emptyMap(),
+ delegateProposals = proposals
+ )
+
+ // ── Parse the chain for annotation ────────────────────────────────────
+ val chainParts = vpChain.split("~").dropLast(1)
+ fun dec(b64: String): JSONObject {
+ val p = b64.padEnd(b64.length + (4-b64.length%4)%4,'=')
+ return JSONObject(String(JBase64.getUrlDecoder().decode(p)))
+ }
+ fun decJwt(c: String) = c.split(".").let { dec(it[0]) to dec(it[1]) }
+ val isCompact = { s: String -> s.split(".").size == 3 }
+
+ val issuerJwt = chainParts[0]
+ val dpcDiscs = chainParts.drop(1).filter { !isCompact(it) }.takeWhile { !isCompact(it) }
+ // KB-SD-JWTs: compact JWTs after index 0
+ val kbSdJwts = chainParts.drop(1).filter { isCompact(it) }
+ val (_, issPayload) = decJwt(issuerJwt)
+ val (kb1Header, kb1Payload) = decJwt(kbSdJwts[0])
+ val (kb2Header, kb2Payload) = decJwt(kbSdJwts[1])
+
+ val sep = "═".repeat(72)
+ val sep2 = "─".repeat(72)
+
+ println("\n$sep")
+ println(" AP2 HITL dSD-JWT MANDATE FLOW — END-TO-END SAMPLE")
+ println(sep)
+
+ println("""
+┌─────────────────────────────────────────────────────────────────────┐
+│ OVERVIEW │
+│ │
+│ User opens AI shopping agent. Agent wants to pay for a Lyft ride │
+│ on the user's behalf (HITL consent). Flow: │
+│ │
+│ 1. Agent constructs OID4VP request with two mandate proposals │
+│ 2. Wallet presents DPC SD-JWT + signs two KB-SD-JWTs (mandates) │
+│ 3. Agent stores the dSD-JWT chain (trailing ~, no agent KB-JWT) │
+│ │
+│ Later (user offline): │
+│ 4. Agent → merchant: chain prefix [DPC~discs~KB-checkout~] + KB │
+│ 5. Agent → payment: full chain [DPC~discs~KB-checkout~KB-pay~] + KB │
+└─────────────────────────────────────────────────────────────────────┘""")
+
+ println("\n$sep")
+ println(" STEP 1 — OID4VP AUTHORIZATION REQUEST (agent → DC API)")
+ println(sep)
+ println("""
+ protocol: openid4vp-v1-qrcode
+ response_type: vp_token
+ response_mode: dc_api
+
+ nonce: $nonce
+ client_id: $clientId
+
+ dcql_query:
+ credentials[0]:
+ id: "dpc_credential"
+ format: "dc+sd-jwt"
+ meta: { vct_values: ["com.emvco.dpc"] }
+ claims: [ card_last_four, card_network_code, credential_id ]
+
+ transaction_data[0] ← checkout mandate proposal
+ (base64url-decoded):
+ {
+ "type": "delegate",
+ "format": "dc+sd-jwt",
+ "credential_ids": ["dpc_credential"],
+ "delegate_payload": [{
+ "vct": "mandate.checkout.1",
+ "exp": 9999999999,
+ "cnf": { "jwk": { "kty":"EC","crv":"P-256",
+ "x":"${agentJwk.getString("x").take(22)}...",
+ "y":"${agentJwk.getString("y").take(22)}..." } },
+ "checkout_hash": "${checkoutHash.take(32)}...",
+ "_sd": ["${checkoutDiscDigest.take(32)}..."],
+ "_sd_alg": "sha-256"
+ }],
+ "delegate_disclosures": [
+ // disclosure for checkout_jwt (selectively disclosed):
+ // base64url(["8eONq8oSDj4kQ7R2aF5Lnw", "checkout_jwt", ""])
+ "${checkoutDisclosure.take(40)}..."
+ ]
+ }
+
+ transaction_data[1] ← payment mandate proposal
+ (base64url-decoded):
+ {
+ "type": "delegate",
+ "format": "dc+sd-jwt",
+ "credential_ids": ["dpc_credential"],
+ "delegate_payload": [{
+ "vct": "mandate.payment",
+ "exp": 9999999999,
+ "cnf": { "jwk": { ... same agent key ... } },
+ "constraints": [
+ { "type":"payment.amount", "currency":"USD", "max":"46.22" },
+ { "type":"payment.allowed_payees","allowed":["lyft.com"] },
+ { "type":"payment.reference", "checkout_reference":"${checkoutHash.take(20)}..." }
+ ]
+ }],
+ "delegate_disclosures": []
+ }""")
+
+ println("\n$sep")
+ println(" STEP 2 — WALLET PROCESSES REQUEST")
+ println(sep)
+ println("""
+ DPC credential matched: dpc_v3_2_sdjwt (card ending 4444, ACME network)
+ Holder device key: EC P-256 (software key bound to DPC via cnf.jwk)
+
+ delegateProposals parsed:
+ [0] format=dc+sd-jwt vct=mandate.checkout.1 discs=1
+ [1] format=dc+sd-jwt vct=mandate.payment discs=0
+
+ → User sees: "Lyft wants to charge up to ${"$"}46.22 to card ending 4444"
+ → User approves via biometric
+ → Wallet calls presentWithDelegations()""")
+
+ println("\n$sep")
+ println(" STEP 3 — VP TOKEN (dSD-JWT chain, trailing ~)")
+ println(sep)
+ println("""
+ vp_token: {
+ "dpc_credential": ""
+ }
+
+ Chain structure (parts joined by ~):
+
+ ┌─ [0] DPC ISSUER JWT (compact JWT, signed by EMVCo issuer)
+ │ typ: dc+sd-jwt
+ │ iss: https://digital-credentials.dev
+ │ vct: com.emvco.dpc
+ │ iat: ${issPayload.optLong("iat")}
+ │ exp: ${issPayload.optLong("exp")}
+ │ cnf.jwk.x: "${issPayload.optJSONObject("cnf")?.optJSONObject("jwk")?.optString("x")?.take(30)}..."
+ │ ↑ holder device key (P-256)
+ │ _sd: [8 digests for card_last_four, card_art_url, card_network_code, ...]
+ │ value: ${issuerJwt.take(50)}...
+ │
+ ├─ [1..8] DPC SELECTIVE DISCLOSURES (${dpcDiscs.size + chainParts.drop(1).filter { !isCompact(it) && chainParts.indexOf(it) > 0 }.size} disclosures)
+ │ Each: base64url(["", "", ""])
+ │ card_last_four → "4444"
+ │ card_network_code → "ACME"
+ │ credential_id → "b3f1c8a2-6d4e-4f9a-9e3d-8a7c2f1b9d34"
+ │ (+ 5 others: card_art_url, card_cobadged_network_code, card_bin, card_id, card_par)
+ │
+ ├─ [9] CHECKOUT DISCLOSURE (from delegate_disclosures[0])
+ │ base64url(["8eONq8oSDj4kQ7R2aF5Lnw", "checkout_jwt", ""])
+ │ value: ${checkoutDisclosure.take(50)}...
+ │
+ ├─ [10] KB-SD-JWT_1 ← CHECKOUT MANDATE (signed by device key)
+ │ typ: ${kb1Header.optString("typ")}
+ │ alg: ES256
+ │ vct: ${kb1Payload.optString("vct")}
+ │ exp: ${kb1Payload.optLong("exp")}
+ │ cnf.jwk.x: "${kb1Payload.optJSONObject("cnf")?.optJSONObject("jwk")?.optString("x")?.take(30)}..."
+ │ ↑ AGENT key (delegatee)
+ │ checkout_hash: "${kb1Payload.optString("checkout_hash").take(32)}..."
+ │ _sd: ["${kb1Payload.optJSONArray("_sd")?.optString(0)?.take(20)}..."]
+ │ nonce: ${kb1Payload.optString("nonce")}
+ │ aud: ${kb1Payload.optString("aud")}
+ │ iat: ${kb1Payload.optLong("iat")}
+ │ sd_hash: ${kb1Payload.optString("sd_hash").take(32)}...
+ │ ↑ SHA-256(parts[0..9]~) = commits to DPC + all disclosures
+ │ value: ${kbSdJwts[0].take(50)}...
+ │
+ ├─ [11] KB-SD-JWT_2 ← PAYMENT MANDATE (signed by device key)
+ │ typ: ${kb2Header.optString("typ")}
+ │ alg: ES256
+ │ vct: ${kb2Payload.optString("vct")}
+ │ exp: ${kb2Payload.optLong("exp")}
+ │ cnf.jwk.x: "${kb2Payload.optJSONObject("cnf")?.optJSONObject("jwk")?.optString("x")?.take(30)}..."
+ │ ↑ AGENT key (same delegatee)
+ │ constraints: [
+ │ { type:payment.amount, currency:USD, max:46.22 }
+ │ { type:payment.allowed_payees, allowed:[lyft.com] }
+ │ { type:payment.reference, checkout_reference:... }
+ │ ]
+ │ nonce: ${kb2Payload.optString("nonce")}
+ │ aud: ${kb2Payload.optString("aud")}
+ │ iat: ${kb2Payload.optLong("iat")}
+ │ sd_hash: ${kb2Payload.optString("sd_hash").take(32)}...
+ │ ↑ SHA-256(parts[0..10]~) = commits to DPC + discs + KB-SD-JWT_checkout
+ │ ↑ Payment mandate is cryptographically bound to the checkout mandate
+ │ value: ${kbSdJwts[1].take(50)}...
+ │
+ └─ [trailing ~] ← no agent KB-JWT yet; agent appends when presenting""")
+
+ // ── Incremental presentations ──────────────────────────────────────────
+ // Compute where KB-SD-JWT_1 is in parts
+ val kbPos1 = chainParts.indexOfFirst { isCompact(it) && chainParts.indexOf(it) > 0 }
+ val checkoutOnlyChain = chainParts.subList(0, kbPos1 + 1).joinToString("~", postfix = "~")
+ val sdHashCheckout = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(checkoutOnlyChain.toByteArray()))
+
+ val fullChain = chainParts.joinToString("~", postfix = "~")
+ val sdHashFull = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(fullChain.toByteArray()))
+
+ println("\n$sep")
+ println(" STEP 4a — AGENT PRESENTS CHECKOUT MANDATE (agent → merchant)")
+ println(sep)
+ println("""
+ Agent takes prefix of chain up to KB-SD-JWT_checkout, appends its own KB-JWT:
+
+ dpc_jwt ~ dpc_discs(8) ~ checkout_disc(1) ~ KB-SD-JWT_checkout ~ [AGENT_KB-JWT]
+
+ AGENT_KB-JWT:
+ typ: kb+jwt
+ alg: ES256 (signed with AGENT private key, matching cnf.jwk in KB-SD-JWT_checkout)
+ aud: https://lyft.com/checkout-verifier
+ nonce:
+ sd_hash: $sdHashCheckout
+ ↑ SHA-256(dpc_jwt~discs~checkout_disc~KB-SD-JWT_checkout~)
+ ↑ covers DPC + checkout mandate only
+
+ Merchant verifies:
+ 1. DPC issuer JWT → valid x5c chain to EMVCo root
+ 2. KB-SD-JWT_checkout as the KB-JWT → signed by device key (cnf of DPC) ✓
+ 3. KB-SD-JWT_checkout as SD-JWT → vct=mandate.checkout.1, checkout_hash matches ✓
+ 4. checkout_jwt disclosure → reveals full checkout object ✓
+ 5. AGENT_KB-JWT → signed by agent key (cnf.jwk of KB-SD-JWT_checkout) ✓""")
+
+ println("\n$sep")
+ println(" STEP 4b — AGENT PRESENTS PAYMENT MANDATE (agent → payment network)")
+ println(sep)
+ println("""
+ Agent uses full chain (both KB-SD-JWTs), appends its own KB-JWT:
+
+ dpc_jwt ~ dpc_discs(8) ~ checkout_disc(1) ~ KB-SD-JWT_checkout ~ KB-SD-JWT_payment ~ [AGENT_KB-JWT]
+
+ AGENT_KB-JWT:
+ typ: kb+jwt
+ alg: ES256 (signed with AGENT private key)
+ aud: https://paymentnetwork.example/auth
+ nonce:
+ sd_hash: $sdHashFull
+ ↑ SHA-256(dpc_jwt~discs~checkout_disc~KB-SD-JWT_checkout~KB-SD-JWT_payment~)
+ ↑ covers DPC + checkout mandate + payment mandate
+
+ Payment network verifies:
+ 1. DPC issuer JWT → valid, vct=com.emvco.dpc ✓
+ 2. KB-SD-JWT_checkout → sd_hash covers DPC only; signed by device key ✓
+ 3. KB-SD-JWT_payment → sd_hash covers DPC + checkout (binding!) ✓
+ vct=mandate.payment, amount≤46.22 USD, payee=lyft.com ✓
+ constraints[payment.reference] links to checkout_hash ✓
+ 4. AGENT_KB-JWT → signed by agent key (cnf.jwk of KB-SD-JWT_payment) ✓
+ 5. card_last_four=4444, credential_id=b3f1c8a2-... ✓ (from DPC disclosures)""")
+
+ println("\n$sep")
+ println(" KEY SECURITY PROPERTIES")
+ println(sep)
+ println("""
+ ✓ User consent is biometric-bound (device key signs KB-SD-JWTs)
+ ✓ Checkout mandate → presentable independently to merchant
+ ✓ Payment mandate → carries full consent chain (binds to checkout via sd_hash)
+ ✓ Agent cannot forge mandates (device key required for signing)
+ ✓ Agent cannot strip checkout from payment presentation (sd_hash locks it in)
+ ✓ Replay prevented (nonce+aud in each KB-SD-JWT; nonce+aud in agent KB-JWT)
+ ✓ DPC selective disclosure (only card_last_four, card_network_code, credential_id revealed)
+ ✓ checkout_jwt selectively disclosed (only revealed to checkout verifier if needed)
+ ✓ Chain is extensible (more mandate types → more KB-SD-JWTs appended)""")
+
+ println("\n$sep\n")
+ }
+
+ @Test
+ fun `output raw request and response JSON`() {
+ val kpg = KeyPairGenerator.getInstance("EC")
+ kpg.initialize(ECGenParameterSpec("secp256r1"))
+ val agentKp = kpg.generateKeyPair()
+ val agentPub = agentKp.public as ECPublicKey
+ fun coord(raw: ByteArray): String {
+ val fixed = if (raw.size > 32) raw.copyOfRange(raw.size - 32, raw.size)
+ else raw.copyOf(32).also { raw.copyInto(it, 32 - raw.size) }
+ return JBase64.getUrlEncoder().withoutPadding().encodeToString(fixed)
+ }
+ val agentJwk = JSONObject().apply {
+ put("kty","EC"); put("crv","P-256"); put("use","sig")
+ put("x", coord(agentPub.w.affineX.toByteArray()))
+ put("y", coord(agentPub.w.affineY.toByteArray()))
+ }
+
+ val checkoutJwtPayload = JSONObject().apply {
+ put("id","order_20260331_9f3a"); put("status","pending_payment"); put("currency","USD")
+ put("merchant", JSONObject().apply { put("id","m_lyft_001"); put("name","Lyft") })
+ put("line_items", JSONArray().put(JSONObject().apply {
+ put("title","Ride to SFO"); put("quantity",1); put("unit_price","42.50")
+ }))
+ put("totals", JSONObject().apply { put("subtotal","42.50"); put("tax","3.72"); put("total","46.22") })
+ }
+ val checkoutJwt = "eyJhbGciOiJFUzI1NiIsInR5cCI6ImNoZWNrb3V0K2p3dCJ9." +
+ JBase64.getUrlEncoder().withoutPadding().encodeToString(checkoutJwtPayload.toString().toByteArray()) +
+ ".MERCHANT_SIG"
+ val checkoutHash = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(checkoutJwt.toByteArray()))
+
+ val checkoutDiscArr = JSONArray().put("8eONq8oSDj4kQ7R2aF5Lnw").put("checkout_jwt").put(checkoutJwt)
+ val checkoutDisc = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(checkoutDiscArr.toString().toByteArray())
+ val checkoutDiscDigest = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(checkoutDisc.toByteArray()))
+
+ val checkoutPayload = JSONObject().apply {
+ put("vct","mandate.checkout.1"); put("exp",9_999_999_999L)
+ put("cnf", JSONObject().put("jwk", agentJwk))
+ put("checkout_hash", checkoutHash)
+ put("_sd", JSONArray().put(checkoutDiscDigest)); put("_sd_alg","sha-256")
+ }
+ val paymentPayload = JSONObject().apply {
+ put("vct","mandate.payment"); put("exp",9_999_999_999L)
+ put("cnf", JSONObject().put("jwk", agentJwk))
+ put("constraints", JSONArray().apply {
+ put(JSONObject().apply { put("type","payment.amount"); put("currency","USD"); put("max","46.22") })
+ put(JSONObject().apply { put("type","payment.allowed_payees"); put("allowed", JSONArray().put("lyft.com")) })
+ put(JSONObject().apply { put("type","payment.reference"); put("checkout_reference", checkoutHash) })
+ })
+ }
+
+ fun encodeTd(p: JSONObject, discs: List = emptyList()) =
+ JBase64.getUrlEncoder().withoutPadding().encodeToString(
+ JSONObject().apply {
+ put("type","delegate"); put("format","dc+sd-jwt")
+ put("credential_ids", JSONArray().put("dpc_credential"))
+ put("delegate_payload", JSONArray().put(p))
+ put("delegate_disclosures", JSONArray().apply { discs.forEach { put(it) } })
+ }.toString().toByteArray()
+ )
+
+ val nonce = "s6FhdRcsNDIIm_4YmFDd1A"
+ val clientId = "origin:https://agent.ap2.example"
+ val td0 = encodeTd(checkoutPayload, listOf(checkoutDisc))
+ val td1 = encodeTd(paymentPayload)
+
+ val request = JSONObject().apply {
+ put("nonce", nonce); put("client_id", clientId)
+ put("response_type","vp_token"); put("response_mode","dc_api")
+ put("dcql_query", JSONObject().apply {
+ put("credentials", JSONArray().put(JSONObject().apply {
+ put("id","dpc_credential"); put("format","dc+sd-jwt")
+ put("meta", JSONObject().put("vct_values", JSONArray().put("com.emvco.dpc")))
+ put("claims", JSONArray().apply {
+ put(JSONObject().put("path", JSONArray().put("card_last_four")))
+ put(JSONObject().put("path", JSONArray().put("card_network_code")))
+ put(JSONObject().put("path", JSONArray().put("credential_id")))
+ })
+ }))
+ })
+ put("transaction_data", JSONArray().put(td0).put(td1))
+ }
+
+ val keyBytes = JBase64.getUrlDecoder().decode(holderPrivKeyB64Url)
+ val keySpec = PKCS8EncodedKeySpec(keyBytes)
+ val keyFactory = KeyFactory.getInstance("EC")
+ val holderKey = keyFactory.generatePrivate(keySpec)
+
+ val proposals = listOf(
+ DelegateProposal("e1","dc+sd-jwt",checkoutPayload,listOf(checkoutDisc),listOf("dpc_credential")),
+ DelegateProposal("e2","dc+sd-jwt",paymentPayload,emptyList(),listOf("dpc_credential"))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, nonce, clientId, emptyMap(), proposals
+ )
+
+ // sd_hashes for agent KB-JWTs
+ val parts = chain.split("~").dropLast(1)
+ val isCompact = { s: String -> s.split(".").size == 3 }
+ val kbPositions = parts.indices.filter { it > 0 && isCompact(parts[it]) }
+ val checkoutPrefix = parts.subList(0, kbPositions[0]+1).joinToString("~", postfix="~")
+ val fullPrefix = parts.joinToString("~", postfix="~")
+ val sdHashCheckout = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(checkoutPrefix.toByteArray()))
+ val sdHashFull = JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(fullPrefix.toByteArray()))
+
+ println("RAW_REQUEST_START")
+ println(request.toString(2))
+ println("RAW_REQUEST_END")
+ println("RAW_RESPONSE_START")
+ println("""{"vp_token":{"dpc_credential":"${chain.take(200)}..."}}""")
+ println("RAW_CHAIN_PARTS_START")
+ parts.forEachIndexed { i, p -> println("PART[$i]: ${p.take(80)}") }
+ println("RAW_CHAIN_PARTS_END")
+ println("SD_HASH_CHECKOUT: $sdHashCheckout")
+ println("SD_HASH_FULL: $sdHashFull")
+ println("CHECKOUT_HASH: $checkoutHash")
+ println("CHECKOUT_DISC: $checkoutDisc")
+ println("AGENT_JWK_X: ${agentJwk.getString("x")}")
+ println("AGENT_JWK_Y: ${agentJwk.getString("y")}")
+ println("CHECKOUT_JWT: $checkoutJwt")
+ println("RAW_RESPONSE_END")
+ }
+}
diff --git a/app/src/test/java/com/credman/cmwallet/DpcSdJwtMandateTest.kt b/app/src/test/java/com/credman/cmwallet/DpcSdJwtMandateTest.kt
new file mode 100644
index 0000000..d3f34f4
--- /dev/null
+++ b/app/src/test/java/com/credman/cmwallet/DpcSdJwtMandateTest.kt
@@ -0,0 +1,553 @@
+package com.credman.cmwallet
+
+import com.credman.cmwallet.openid4vp.DelegateProposal
+import com.credman.cmwallet.openid4vp.OpenId4VP
+import com.credman.cmwallet.sdjwt.SdJwt
+import org.json.JSONArray
+import org.json.JSONObject
+import org.junit.Assert.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.PrivateKey
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+import java.security.spec.PKCS8EncodedKeySpec
+import java.util.Base64 as JBase64
+
+/**
+ * dSD-JWT HITL mandate tests for [SdJwt.presentWithDelegations].
+ *
+ * The scenario: a DPC SD-JWT credential is presented in response to an OID4VP request that
+ * carries two mandate proposals via `transaction_data[].type = "delegate"`:
+ * 1. checkout mandate (vct = "mandate.checkout.1", checkout_hash)
+ * 2. payment mandate (vct = "mandate.payment", open with constraints)
+ *
+ * Wallet produces a dSD-JWT chain:
+ * dpc_issuer_jwt ~ dpc_discs ~ checkout_discs ~ KB-SD-JWT_checkout
+ * ~ payment_discs ~ KB-SD-JWT_payment ~
+ *
+ * All KB-SD-JWTs signed by the holder's device key.
+ * The agent appends its own KB-JWT (with its key from cnf.jwk) when presenting to a verifier.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(manifest = Config.NONE, sdk = [33])
+class DpcSdJwtMandateTest {
+
+ // Holder (device) private key PKCS8 – from databasenew.json dpc_v3_2_sdjwt
+ private val holderPrivKeyB64Url =
+ "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgD17D2RSlvQ8ElFrP" +
+ "qEG3JfXTjxyKEH9DMpFnWp_Z63ihRANCAATyMFauK4kFj767__aM4l9xfgmPiQSp" +
+ "jgJRf1x_VtB11nLB9pDhoZXpoUUbj1GBSiWGYFahF0IdiX6LUShkTHyx"
+
+ private lateinit var dpcCredential: String
+ private lateinit var holderKey: PrivateKey
+ private lateinit var agentPubKeyJwk: JSONObject
+
+ private val TEST_NONCE = "test-nonce-abc123"
+ private val TEST_AUD = "origin:https://pay.example.com"
+ private val DPC_CRED_ID = "dpc_credential"
+
+ @Before
+ fun setUp() {
+ dpcCredential = DPC_SDJWT_CREDENTIAL
+
+ // Decode the Base64URL PKCS#8 key string into a PrivateKey object
+ val keyBytes = JBase64.getUrlDecoder().decode(holderPrivKeyB64Url)
+ val keySpec = PKCS8EncodedKeySpec(keyBytes)
+ val keyFactory = KeyFactory.getInstance("EC")
+ holderKey = keyFactory.generatePrivate(keySpec)
+
+ // Generate agent key pair
+ val kpg = KeyPairGenerator.getInstance("EC")
+ kpg.initialize(ECGenParameterSpec("secp256r1"))
+ val agentKp = kpg.generateKeyPair()
+ val agentPub = agentKp.public as ECPublicKey
+ agentPubKeyJwk = JSONObject().apply {
+ put("kty", "EC"); put("crv", "P-256"); put("use", "sig")
+ put("x", encodeCoord(agentPub.w.affineX.toByteArray()))
+ put("y", encodeCoord(agentPub.w.affineY.toByteArray()))
+ }
+ }
+
+ private fun encodeCoord(raw: ByteArray): String {
+ val fixed = if (raw.size > 32) raw.copyOfRange(raw.size - 32, raw.size)
+ else raw.copyOf(32).also { raw.copyInto(it, 32 - raw.size) }
+ return JBase64.getUrlEncoder().withoutPadding().encodeToString(fixed)
+ }
+
+ // ── Fixture builders ───────────────────────────────────────────────────────
+
+ private fun checkoutPayload(checkoutHash: String = "oK0usjWjRUaXbH2PHBvhRGfldH4") =
+ JSONObject().apply {
+ put("vct", "mandate.checkout.1")
+ put("exp", 9_999_999_999L)
+ put("cnf", JSONObject().put("jwk", agentPubKeyJwk))
+ put("checkout_hash", checkoutHash)
+ put("checkout_jwt", "eyJhbGciOiJFUzI1NiIsInR5cCI6ImNoZWNrb3V0K2p3dCJ9.eyJpZCI6Im9yZGVyXzEyMyJ9.sig")
+ }
+
+ private fun paymentPayload(transactionId: String = "oK0usjWjRUaXbH2PHBvhRGfldH4") =
+ JSONObject().apply {
+ put("vct", "mandate.payment")
+ put("exp", 9_999_999_999L)
+ put("cnf", JSONObject().put("jwk", agentPubKeyJwk))
+ put("transaction_id", transactionId)
+ put("payee", JSONObject().apply { put("id", "m_lyft_001"); put("name", "Lyft") })
+ put("payment_amount", JSONObject().apply { put("amount", 4622); put("currency", "USD") })
+ put("payment_instrument", JSONObject().apply {
+ put("id", "b3f1c8a2-6d4e-4f9a-9e3d-8a7c2f1b9d34")
+ put("type", "dpc")
+ put("description", "DPC ···· 4444")
+ })
+ }
+
+ private fun encodeDelegateItem(
+ delegatePayloads: List,
+ delegateDisclosures: List = emptyList()
+ ): String {
+ val item = JSONObject().apply {
+ put("type", "delegate")
+ put("format", "dc+sd-jwt")
+ put("credential_ids", JSONArray().put(DPC_CRED_ID))
+ put("delegate_payload", JSONArray().apply { delegatePayloads.forEach { put(it) } })
+ put("delegate_disclosures", JSONArray().apply { delegateDisclosures.forEach { put(it) } })
+ }
+ return JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(item.toString().toByteArray())
+ }
+
+ // Convenience overload for single payload (backward compat in tests)
+ private fun encodeDelegateItem(
+ delegatePayload: JSONObject,
+ delegateDisclosures: List = emptyList()
+ ) = encodeDelegateItem(listOf(delegatePayload), delegateDisclosures)
+
+ private fun oid4vpRequest(txItems: List = emptyList()) = JSONObject().apply {
+ put("nonce", TEST_NONCE)
+ put("client_id", TEST_AUD)
+ put("dcql_query", JSONObject().apply {
+ put("credentials", JSONArray().put(JSONObject().apply {
+ put("id", DPC_CRED_ID)
+ put("format", "dc+sd-jwt")
+ put("meta", JSONObject().put("vct_values", JSONArray().put("com.emvco.dpc")))
+ put("claims", JSONArray().apply {
+ put(JSONObject().put("path", JSONArray().put("card_last_four")))
+ put(JSONObject().put("path", JSONArray().put("credential_id")))
+ })
+ }))
+ })
+ if (txItems.isNotEmpty()) put("transaction_data", JSONArray().apply { txItems.forEach { put(it) } })
+ }
+
+ private fun decodeJwt(compact: String): Pair {
+ fun dec(b64: String): JSONObject {
+ val p = b64.padEnd(b64.length + (4 - b64.length % 4) % 4, '=')
+ return JSONObject(String(JBase64.getUrlDecoder().decode(p)))
+ }
+ val parts = compact.split(".")
+ return dec(parts[0]) to dec(parts[1])
+ }
+
+ private fun sha256b64url(input: String): String =
+ JBase64.getUrlEncoder().withoutPadding()
+ .encodeToString(MessageDigest.getInstance("SHA-256").digest(input.toByteArray()))
+
+ private fun agentAddKbJwt(chainPrefix: String, agentNonce: String, agentAud: String): String {
+ require(chainPrefix.endsWith("~")) { "Chain prefix must end with ~" }
+ val sdHash = sha256b64url(chainPrefix)
+ val header = """{"typ":"kb+jwt","alg":"ES256"}"""
+ val payload = """{"iat":${System.currentTimeMillis()/1000},"aud":"$agentAud","nonce":"$agentNonce","sd_hash":"$sdHash"}"""
+ val h64 = JBase64.getUrlEncoder().withoutPadding().encodeToString(header.toByteArray())
+ val p64 = JBase64.getUrlEncoder().withoutPadding().encodeToString(payload.toByteArray())
+ val sig = java.security.Signature.getInstance("SHA256withECDSA").apply {
+ initSign(holderKey); update("$h64.$p64".toByteArray())
+ }.sign()
+ val r = sig.copyOfRange(4, 4 + sig[3].toInt())
+ val s = sig.copyOfRange(4 + sig[3].toInt() + 2, sig.size)
+ val rawR = if (r.size > 32) r.copyOfRange(r.size - 32, r.size) else r.copyOf(32).also { r.copyInto(it, 32 - r.size) }
+ val rawS = if (s.size > 32) s.copyOfRange(s.size - 32, s.size) else s.copyOf(32).also { s.copyInto(it, 32 - s.size) }
+ val rawSig = JBase64.getUrlEncoder().withoutPadding().encodeToString(rawR + rawS)
+ return chainPrefix + "$h64.$p64.$rawSig"
+ }
+
+ private fun decodeDisclosure(b64: String): JSONArray {
+ val pad = 4 - b64.length % 4
+ return JSONArray(String(JBase64.getUrlDecoder().decode(b64 + "=".repeat(pad % 4))))
+ }
+
+ // ── Tests ──────────────────────────────────────────────────────────────────
+
+ @Test
+ fun `OpenId4VP correctly parses two delegate proposals from single transaction_data item`() {
+ val item = encodeDelegateItem(listOf(checkoutPayload(), paymentPayload()))
+ val oid4vp = OpenId4VP(oid4vpRequest(listOf(item)), TEST_AUD, "openid4vp-v1-qrcode")
+
+ assertEquals(2, oid4vp.delegateProposals.size)
+ with(oid4vp.delegateProposals[0]) {
+ assertEquals("dc+sd-jwt", format)
+ assertEquals("mandate.checkout.1", delegatePayload.getString("vct"))
+ assertTrue(delegatePayload.has("checkout_hash"))
+ assertTrue(delegatePayload.has("cnf"))
+ }
+ with(oid4vp.delegateProposals[1]) {
+ assertEquals("mandate.payment", delegatePayload.getString("vct"))
+ assertTrue(delegatePayload.has("transaction_id"))
+ assertTrue(delegatePayload.has("payee"))
+ assertTrue(delegatePayload.has("payment_amount"))
+ assertTrue(delegatePayload.has("payment_instrument"))
+ }
+ }
+
+ @Test
+ fun `no delegate proposals means empty list`() {
+ val oid4vp = OpenId4VP(oid4vpRequest(), TEST_AUD, "openid4vp-v1-qrcode")
+ assertTrue(oid4vp.delegateProposals.isEmpty())
+ }
+
+ @Test
+ fun `chain has exactly one KB-SD-JWT`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val compactJwts = parts.filter { it.split(".").size == 3 }
+ // issuer JWT + one KB-SD-JWT = 2 compact JWTs total
+ assertEquals("Chain must have exactly 2 compact JWTs (issuer + one KB-SD-JWT)", 2, compactJwts.size)
+ }
+
+ @Test
+ fun `chain ends with trailing tilde`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ assertTrue("Chain must end with ~", chain.endsWith("~"))
+ }
+
+ @Test
+ fun `KB-SD-JWT header has typ kb-sd-jwt+kb`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbSdJwt = parts.first { it.split(".").size == 3 && it != parts[0] }
+ val (header, _) = decodeJwt(kbSdJwt)
+ assertEquals("kb-sd-jwt+kb", header.getString("typ"))
+ assertEquals("ES256", header.getString("alg"))
+ }
+
+ @Test
+ fun `KB-SD-JWT header has typ kb-sd-jwt when cnf is missing`() {
+ val checkoutNoCnf = checkoutPayload().apply { remove("cnf") }
+ val paymentNoCnf = paymentPayload().apply { remove("cnf") }
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutNoCnf, emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentNoCnf, emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbSdJwt = parts.first { it.split(".").size == 3 && it != parts[0] }
+ val (header, _) = decodeJwt(kbSdJwt)
+ assertEquals("kb-sd-jwt", header.getString("typ"))
+ assertEquals("ES256", header.getString("alg"))
+ }
+ @Test
+ fun `KB-SD-JWT delegate_payload has one digest per mandate`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbSdJwt = parts.first { it.split(".").size == 3 && it != parts[0] }
+ val (_, payload) = decodeJwt(kbSdJwt)
+
+ assertTrue("KB-SD-JWT must have delegate_payload", payload.has("delegate_payload"))
+ val dp = payload.getJSONArray("delegate_payload")
+ assertEquals("delegate_payload must have one digest per mandate", 2, dp.length())
+
+ // Each entry must be a non-empty string (base64url digest)
+ for (i in 0 until dp.length()) {
+ val digest = dp.getString(i)
+ assertTrue("Digest must be non-empty", digest.isNotEmpty())
+ assertTrue("Digest must be base64url (no +/=)", !digest.contains('+') && !digest.contains('='))
+ }
+ }
+
+ @Test
+ fun `KB-SD-JWT has standard KB fields and sd_hash covers DPC base`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+ val (_, payload) = decodeJwt(parts[kbPos])
+
+ assertEquals(TEST_NONCE, payload.getString("nonce"))
+ assertEquals(TEST_AUD, payload.getString("aud"))
+ assertTrue(payload.has("iat"))
+ assertEquals("sha-256", payload.getString("_sd_alg"))
+
+ // sd_hash = SHA-256(issuer_jwt ~ dpc_discs ~)
+ val dpcBase = parts.subList(0, kbPos).joinToString("~", postfix = "~")
+ val expectedSdHash = sha256b64url(dpcBase)
+ assertEquals("sd_hash must cover DPC base only", expectedSdHash, payload.getString("sd_hash"))
+ }
+
+ @Test
+ fun `mandate disclosures come after KB-SD-JWT in chain`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+
+ // Everything after KB-SD-JWT must be disclosures (base64url arrays, not compact JWTs)
+ val afterKb = parts.subList(kbPos + 1, parts.size)
+ assertEquals("Must have exactly 2 mandate disclosures after KB-SD-JWT", 2, afterKb.size)
+ afterKb.forEach { part ->
+ assertEquals("Mandate disc must not be a compact JWT", 1, part.split(".").size)
+ // Must decode to a 2-element array [salt, mandate_object]
+ val decoded = decodeDisclosure(part)
+ assertEquals("Mandate disclosure must be 2-element array [salt, object]", 2, decoded.length())
+ assertTrue("Second element must be a JSON object", decoded.get(1) is JSONObject)
+ }
+ }
+
+ @Test
+ fun `each mandate digest in delegate_payload matches its disclosure`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+ val (_, kbPayload) = decodeJwt(parts[kbPos])
+ val delegatePayload = kbPayload.getJSONArray("delegate_payload")
+ val mandateDiscs = parts.subList(kbPos + 1, parts.size)
+
+ assertEquals(delegatePayload.length(), mandateDiscs.size)
+ for (i in 0 until delegatePayload.length()) {
+ val expectedDigest = delegatePayload.getString(i)
+ val actualDigest = sha256b64url(mandateDiscs[i])
+ assertEquals("Digest in delegate_payload[$i] must match SHA-256(mandate_disc[$i])",
+ expectedDigest, actualDigest)
+ }
+ }
+
+ @Test
+ fun `checkout mandate object is correctly embedded in its disclosure`() {
+ val checkout = checkoutPayload("specific-hash-123")
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkout, emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+ val checkoutDiscRaw = parts[kbPos + 1] // first mandate disc = checkout
+ val discArr = decodeDisclosure(checkoutDiscRaw)
+ val mandateObj = discArr.getJSONObject(1)
+
+ assertEquals("mandate.checkout.1", mandateObj.getString("vct"))
+ assertEquals("specific-hash-123", mandateObj.getString("checkout_hash"))
+ assertTrue(mandateObj.has("cnf"))
+ assertTrue(mandateObj.has("checkout_jwt"))
+ }
+
+ @Test
+ fun `payment mandate object is correctly embedded in its disclosure`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+ val paymentDiscRaw = parts[kbPos + 2] // second mandate disc = payment
+ val discArr = decodeDisclosure(paymentDiscRaw)
+ val mandateObj = discArr.getJSONObject(1)
+
+ assertEquals("mandate.payment", mandateObj.getString("vct"))
+ assertTrue(mandateObj.has("transaction_id"))
+ assertTrue(mandateObj.has("payee"))
+ assertTrue(mandateObj.has("payment_amount"))
+ assertTrue(mandateObj.has("payment_instrument"))
+ assertTrue(mandateObj.has("cnf"))
+ }
+
+ @Test
+ fun `checkout mandate independently presentable to merchant`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+
+ // Agent builds checkout-only presentation: dpc_base + KB-SD-JWT + checkout_mandate_disc
+ val checkoutDisc = parts[kbPos + 1]
+ val checkoutPrefix = (parts.subList(0, kbPos + 1) + checkoutDisc).joinToString("~", postfix = "~")
+
+ val merchantNonce = "merchant-nonce-xyz"
+ val merchantAud = "https://lyft.com"
+ val presented = agentAddKbJwt(checkoutPrefix, merchantNonce, merchantAud)
+
+ assertFalse("Presented chain must not end with ~", presented.endsWith("~"))
+ val pParts = presented.split("~").filter { it.isNotEmpty() }
+
+ // Agent KB-JWT sd_hash covers dpc_base + KB-SD-JWT + checkout_disc
+ val (_, agentKb) = decodeJwt(pParts.last())
+ assertEquals(merchantNonce, agentKb.getString("nonce"))
+ assertEquals(merchantAud, agentKb.getString("aud"))
+ assertEquals(sha256b64url(checkoutPrefix), agentKb.getString("sd_hash"))
+
+ // Chain contains only checkout mandate disc, not payment mandate disc
+ val discsAfterKb = pParts.drop(kbPos + 1).dropLast(1)
+ assertEquals("Only checkout disc should be in merchant presentation", 1, discsAfterKb.size)
+ val obj = decodeDisclosure(discsAfterKb[0]).getJSONObject(1)
+ assertEquals("mandate.checkout.1", obj.getString("vct"))
+
+ println("✓ Checkout-only presentation to merchant: ${pParts.size} parts")
+ }
+
+ @Test
+ fun `payment mandate independently presentable to credential provider`() {
+ val proposals = listOf(
+ DelegateProposal("e1", "dc+sd-jwt", checkoutPayload(), emptyList(), listOf(DPC_CRED_ID)),
+ DelegateProposal("e2", "dc+sd-jwt", paymentPayload(), emptyList(), listOf(DPC_CRED_ID))
+ )
+ val chain = SdJwt(dpcCredential, holderKey).presentWithDelegations(
+ null, TEST_NONCE, TEST_AUD, emptyMap(), proposals
+ )
+ val parts = chain.split("~").filter { it.isNotEmpty() }
+ val kbPos = parts.indexOfFirst { it.split(".").size == 3 && it != parts[0] }
+
+ // Agent builds payment-only presentation: dpc_base + KB-SD-JWT + payment_mandate_disc
+ val paymentDisc = parts[kbPos + 2]
+ val paymentPrefix = (parts.subList(0, kbPos + 1) + paymentDisc).joinToString("~", postfix = "~")
+
+ val cpNonce = "cp-nonce-xyz"
+ val cpAud = "https://credential-provider.paynet.example"
+ val presented = agentAddKbJwt(paymentPrefix, cpNonce, cpAud)
+
+ assertFalse(presented.endsWith("~"))
+ val pParts = presented.split("~").filter { it.isNotEmpty() }
+
+ val (_, agentKb) = decodeJwt(pParts.last())
+ assertEquals(cpNonce, agentKb.getString("nonce"))
+ assertEquals(sha256b64url(paymentPrefix), agentKb.getString("sd_hash"))
+
+ // Chain contains only payment mandate disc, not checkout
+ val discsAfterKb = pParts.drop(kbPos + 1).dropLast(1)
+ assertEquals("Only payment disc in CP presentation", 1, discsAfterKb.size)
+ val obj = decodeDisclosure(discsAfterKb[0]).getJSONObject(1)
+ assertEquals("mandate.payment", obj.getString("vct"))
+
+ println("✓ Payment-only presentation to credential provider: ${pParts.size} parts")
+ }
+
+
+ companion object {
+ /**
+ * DPC SD-JWT credential from databasenew.json (dpc_v3_2_sdjwt).
+ * issuer_jwt~disc1~...~disc8~ (8 selective disclosures, trailing ~)
+ */
+ const val DPC_SDJWT_CREDENTIAL =
+ "eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImRjK3NkLWp3dCIsICJ4NWMiOiBbIk1JSUM1akNDQW8yZ0F3" +
+ "SUJBZ0lVRVJjNEQzRVpQY25MdXg2N1ZWZDU4d2lrWGRjd0NnWUlLb1pJemowRUF3SXdlakVMTUFrR0Ex" +
+ "VUVCaE1DVlZNeEV6QVJCZ05WQkFnTUNrTmhiR2xtYjNKdWFXRXhGakFVQmdOVkJBY01EVTF2ZFc1MFlX" +
+ "bHVJRlpwWlhjeEhEQWFCZ05WQkFvTUUwUnBaMmwwWVd3Z1EzSmxaR1Z1ZEdsaGJITXhJREFlQmdOVkJB" +
+ "TU1GMlJwWjJsMFlXd3RZM0psWkdWdWRHbGhiSE11WkdWMk1CNFhEVEkxTURReU5URTBNVEl5TmxvWERU" +
+ "STJNRFF5TlRFME1USXlObG93ZWpFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJBZ01Da05oYkdsbWIz" +
+ "SnVhV0V4RmpBVUJnTlZCQWNNRFUxdmRXNTBZV2x1SUZacFpYY3hIREFhQmdOVkJBb01FMFJwWjJsMFlX" +
+ "d2dRM0psWkdWdWRHbGhiSE14SURBZUJnTlZCQU1NRjJScFoybDBZV3d0WTNKbFpHVnVkR2xoYkhNdVpH" +
+ "VjJNRmt3RXdZSEtvWkl6ajBDQVFZSUtvWkl6ajBEQVFjRFFnQUV1TGQ1aUhPK05UNlJzNDZwQkFrQWM4" +
+ "RW1mb3gvOGtqSXJFclF2UGFBSjMxemRWWEV2a1pPZFFqV0wydy9xblJKZ2c4c2hETnp5RUZ0UENqMTg0" +
+ "WExGcU9COERDQjdUQWZCZ05WSFNNRUdEQVdnQlQ2aVpRaFo4NG83Mi9lWGZyZHpxMXBUSTdQQ2pBZEJn" +
+ "TlZIUTRFRmdRVWc3ZE1LSjViaElVTnBsS2RmWFlhUkdQQ2dOVXdJZ1lEVlIwUkJCc3dHWUlYWkdsbmFY" +
+ "UmhiQzFqY21Wa1pXNTBhV0ZzY3k1a1pYWXdOQVlEVlIwZkJDMHdLekFwb0NlZ0pZWWphSFIwY0hNNkx5" +
+ "OWthV2RwZEdGc0xXTnlaV1JsYm5ScFlXeHpMbVJsZGk5amNtd3dLZ1lEVlIwU0JDTXdJWVlmYUhSMGNI" +
+ "TTZMeTlrYVdkcGRHRnNMV055WldSbGJuUnBZV3h6TG1SbGRqQU9CZ05WSFE4QkFmOEVCQU1DQjRBd0ZR" +
+ "WURWUjBsQVFIL0JBc3dDUVlIS0lHTVhRVUJBakFLQmdncWhrak9QUVFEQWdOSEFEQkVBaUFnR3VXekxp" +
+ "dnJGbTRWOU45SEN5Z1ErbHU2am9zN2FlZ0d1N2xaOEs1WFFRSWdLM1N0Rm5nL2YwTTdhcUZGWGs1S0VU" +
+ "UTN1UUZtY3JUcVE3eHJwWWF3dTFNPSIsICJNSUlDdVRDQ0FsK2dBd0lCQWdJVVE3aG5TbTNrSWRGdUFO" +
+ "YW5GcGs0ekVkeW4xc3dDZ1lJS29aSXpqMEVBd0l3ZWpFTE1Ba0dBMVVFQmhNQ1ZWTXhFekFSQmdOVkJB" +
+ "Z01Da05oYkdsbWIzSnVhV0V4RmpBVUJnTlZCQWNNRFUxdmRXNTBZV2x1SUZacFpYY3hIREFhQmdOVkJB" +
+ "b01FMFJwWjJsMFlXd2dRM0psWkdWdWRHbGhiSE14SURBZUJnTlZCQU1NRjJScFoybDBZV3d0WTNKbFpH" +
+ "VnVkR2xoYkhNdVpHVjJNQjRYRFRJMU1EUXlOVEUwTVRJeU5sb1hEVE0xTURReE16RTBNVEl5Tmxvd2Vq" +
+ "RUxNQWtHQTFVRUJoTUNWVk14RXpBUkJnTlZCQWdNQ2tOaGJHbG1iM0p1YVdFeEZqQVVCZ05WQkFjTURV" +
+ "MXZkVzUwWVdsdUlGWnBaWGN4SERBYUJnTlZCQW9NRTBScFoybDBZV3dnUTNKbFpHVnVkR2xoYkhNeElE" +
+ "QWVCZ05WQkFNTUYyUnBaMmwwWVd3dFkzSmxaR1Z1ZEdsaGJITXVaR1YyTUZrd0V3WUhLb1pJemowQ0FR" +
+ "WUlLb1pJemowREFRY0RRZ0FFcUlEL0lLV21UMGVlYmQzaEd5OEIwQ2R6VDlxclliOG5IYVFSNGJFNG5Y" +
+ "UVFCSEF3ZFd5bTJqakxmYjVXbzJzSCtSdkZrRkFwUG5tdjBhcFA3SXkwaTZPQndqQ0J2ekFpQmdOVkhS" +
+ "RUVHekFaZ2hka2FXZHBkR0ZzTFdOeVpXUmxiblJwWVd4ekxtUmxkakFkQmdOVkhRNEVGZ1FVK29tVUlX" +
+ "Zk9LTzl2M2wzNjNjNnRhVXlPendvd0h3WURWUjBqQkJnd0ZvQVUrb21VSVdmT0tPOXYzbDM2M2M2dGFV" +
+ "eU96d293RWdZRFZSMFRBUUgvQkFnd0JnRUIvd0lCQURBT0JnTlZIUThCQWY4RUJBTUNBUVl3S2dZRFZS" +
+ "MFNCQ013SVlZZmFIUjBjSE02THk5a2FXZHBkR0ZzTFdOeVpXUmxiblJwWVd4ekxtUmxkakFKQmdOVkhS" +
+ "OEVBakFBTUFvR0NDcUdTTTQ5QkFNQ0EwZ0FNRVVDSUEwdFc0ayt1SEFsOXRmNFdOa3NxRVIwT1JLK2pH" +
+ "d1NoV2Z2RjJtVzZKenZBaUVBaGhjQUxxNm1sSmd2MThwZnpjZ1B6N3lPMTc1bmxFWTF0ZVlpYVBmWWlu" +
+ "cz0iXX0.eyJfc2QiOiBbIjB5Z1NJTWJ5Q3pfU0FMN0NyWmVEZ19DM0FucUpWZ2YzNUkxdDFpZTBSWnMi" +
+ "LCAiMWlwU2VqQUF3X2xBU09lTnNHYmozUl8zTVpOUnRhbGdVOU1ZdmM3M1o1ZyIsICIzZF9rc0xhWTdO" +
+ "QXl1OVBRWm9kUkI0WHNxRjJqcXVDc2wyYXZPbG5XQ244IiwgIlBCaFc0MkFUSnFjczNfb2RWaEh1VEdF" +
+ "RGhON2lkRG1aTUxMT1JSLWxBZWMiLCAiUE5TSlJYekdQY0J5RUwzX2pGbWM0amd6eEpVSnNUbXVESkZv" +
+ "amtUeXNEMCIsICJSQVdnNVhmOXFoaVA3N3BiMVI0TVlZLXJWMjExSlRvS3ZBeF9SdzVzUjd3IiwgInBY" +
+ "amJuUmpuMjhKUlVKRHcxa3VVOGtIck5HQWZUQXNzazhCTTF5MUlEd0kiLCAieUdYclZnYjlIS0dJVTlu" +
+ "NndFVlBkc3hhZmRGSUllVllSZHI3MkRVOWdpTSJdLCAiaXNzIjogImh0dHBzOi8vZGlnaXRhbC1jcmVk" +
+ "ZW50aWFscy5kZXYiLCAiaWF0IjogMTY4MzAwMDAwMCwgImV4cCI6IDE4ODMwMDAwMDAsICJ2Y3QiOiAi" +
+
+ "Y29tLmVtdmNvLmRwYyIsICJfc2RfYWxnIjogInNoYS0yNTYiLCAiY25mIjogeyJqd2siOiB7Imt0eSI6" +
+ "ICJFQyIsICJjcnYiOiAiUC0yNTYiLCAieCI6ICI4akJXcml1SkJZLS11X18yak9KZmNYNEpqNGtFcVk0" +
+ "Q1VYOWNmMWJRZGRZIiwgInkiOiAiY3NIMmtPR2hsZW1oUlJ1UFVZRktKWVpnVnFFWFFoMkpmb3RSS0dS" +
+ "TWZMRSJ9fX0.Coglr0YLOqUrjDLP7nBl_OCWggnn8mO_DrL_Oc7XI2R8xHJvA0fzK3nnSns0sDZ_sAvP" +
+ "7wbmR28eJj1dk8XmDw~WyJiWk5wbVRlb0w1dFlVN2dLVFZrVFVBIiwgImNhcmRfbGFzdF9mb3VyIiwgI" +
+ "jQ0NDQiXQ~WyJXR01wb2pPQWMtcUVfaUV5bUEyUmlRIiwgImNhcmRfYXJ0X3VybCIsICJodHRwczovL3" +
+ "BvY2tldGJhbmsuZXhhbXBsZS9jYXJkLnBuZyJd~WyJCdThHaWU5NDluQWdCZEw2QjY1N013IiwgImNhc" +
+ "mRfbmV0d29ya19jb2RlIiwgIkFDTUUiXQ~WyJlNVlrMS01RjM2RlNpa2JWUVhCRFh3IiwgImNhcmRfY2" +
+ "9iYWRnZWRfbmV0d29ya19jb2RlIiwgIkxBU0VSIl0~WyJ2eklEdUdxOFcxMTcybW5UWUcxOEp3IiwgIm" +
+
+ "NhcmRfYmluIiwgIjk5MDAwMSJd~WyJFbHIxTmV6QVVHTzBLN21UNUNhVDN3IiwgImNhcmRfaWQiLCAiN" +
+ "WQ4ZjdlOWMwYTEyIl0~WyIzOGtxMzBtYzZmZ1MxYnVyeTh1UWtnIiwgImNhcmRfcGFyIiwgIjk5MDBBQ" +
+ "kMxMjNYWVo3ODlMTU5PUFFSU1RVVldYIl0~WyJNUThsck5rQXdZbGF2TVQ4b3duNERBIiwgImNyZWRlb" +
+ "nRpYWxfaWQiLCAiYjNmMWM4YTItNmQ0ZS00ZjlhLTllM2QtOGE3YzJmMWI5ZDM0Il0~"
+ }
+}
diff --git a/matcher/dcql.c b/matcher/dcql.c
index 72d232c..896cf8b 100644
--- a/matcher/dcql.c
+++ b/matcher/dcql.c
@@ -1,4 +1,5 @@
#include
+#include // ── ADDED TO FIX THE 'FREE' ERROR ──
#include
#include "dcql.h"
@@ -24,6 +25,10 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
cJSON* inline_issuance = NULL;
cJSON* matched_credentials = cJSON_CreateArray();
char* format = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(credential, "format"));
+ char* req_id = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(credential, "id"));
+
+ // ── LOG 1 ──
+ printf("[DCQL_DEBUG] 1. MatchCredential called for target format: %s (id: %s)\n", format ? format : "NULL", req_id ? req_id : "NULL");
// check for optional params
cJSON* meta = cJSON_GetObjectItemCaseSensitive(credential, "meta");
@@ -35,6 +40,8 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
inline_issuance_candidates = cJSON_GetObjectItemCaseSensitive(inline_issuance_candidates, format);
if (candidates == NULL && inline_issuance_candidates == NULL) {
+ // ── LOG 2 ──
+ printf("[DCQL_DEBUG] 2. Dropout: No candidate credentials found in store for format: %s\n", format ? format : "NULL");
cJSON_AddItemReferenceToObject(result, "matched_creds", matched_credentials);
cJSON_AddItemReferenceToObject(result, "inline_issuance", inline_issuance);
return result;
@@ -47,7 +54,6 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
if (doctype_value_obj != NULL) {
char* doctype_value = cJSON_GetStringValue(doctype_value_obj);
candidates = cJSON_GetObjectItemCaseSensitive(candidates, doctype_value);
- //printf("candidates %s\n", cJSON_Print(candidates));
if (inline_issuance_candidates != NULL) {
cJSON* inline_issuance_candidate;
@@ -67,14 +73,31 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
}
}
} else if (strcmp(format, "dc+sd-jwt") == 0) {
+ // Handle SD-JWT format. We filter candidates based on requested VCT (Verifiable Credential Type) values.
cJSON* vct_values_obj = cJSON_GetObjectItemCaseSensitive(meta, "vct_values");
cJSON* cred_candidates = candidates;
candidates = cJSON_CreateArray();
cJSON* vct_value;
+
+ // ── LOG 3 ──
+ printf("[DCQL_DEBUG] 3. Filtering candidates by dc+sd-jwt VCTs...\n");
+
cJSON_ArrayForEach(vct_value, vct_values_obj) {
- cJSON* vct_candidates = cJSON_GetObjectItemCaseSensitive(cred_candidates, cJSON_GetStringValue(vct_value));
+ char* requested_vct = cJSON_GetStringValue(vct_value);
+ // ── LOG 3a ──
+ printf("[DCQL_DEBUG] 3a. Checking for requested VCT: %s\n", requested_vct ? requested_vct : "NULL");
+
+ cJSON* vct_candidates = cJSON_GetObjectItemCaseSensitive(cred_candidates, requested_vct);
+ if (vct_candidates == NULL) {
+ // ── LOG 3b ──
+ printf("[DCQL_DEBUG] 3b. Credential store does NOT have requested VCT key: %s\n", requested_vct ? requested_vct : "NULL");
+ }
+
cJSON* curr_candidate;
cJSON_ArrayForEach(curr_candidate, vct_candidates) {
+ char* cand_id = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(curr_candidate, "id"));
+ // ── LOG 3c ──
+ printf("[DCQL_DEBUG] 3c. Candidate accepted by VCT: %s\n", cand_id ? cand_id : "NULL");
cJSON_AddItemReferenceToArray(candidates, curr_candidate);
}
}
@@ -97,14 +120,20 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
}
}
}
- } else {
+ } else {
+ // ── LOG 2b ──
+ printf("[DCQL_DEBUG] 2b. Dropout: Unsupported format type %s\n", format ? format : "NULL");
cJSON_AddItemReferenceToObject(result, "matched_creds", matched_credentials);
cJSON_AddItemReferenceToObject(result, "inline_issuance", inline_issuance);
return result;
}
}
- if (candidates == NULL) {
+ int candidate_count = cJSON_GetArraySize(candidates);
+ // ── LOG 4 ──
+ printf("[DCQL_DEBUG] 4. Meta filtering completed. Total candidates passing format/VCT: %d\n", candidate_count);
+
+ if (candidates == NULL || candidate_count == 0) {
cJSON_AddItemReferenceToObject(result, "matched_creds", matched_credentials);
cJSON_AddItemReferenceToObject(result, "inline_issuance", inline_issuance);
return result;
@@ -112,6 +141,9 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
// Match on the claims
if (claims == NULL) {
+ // ── LOG 5 ──
+ printf("[DCQL_DEBUG] 5. No claims requested. Auto-matching every candidate.\n");
+
// Match every candidate
cJSON* candidate;
cJSON_ArrayForEach(candidate, candidates) {
@@ -120,7 +152,6 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
cJSON_AddItemReferenceToObject(matched_credential, "display", cJSON_GetObjectItemCaseSensitive(candidate, "display"));
cJSON* matched_claim_names = cJSON_CreateArray();
cJSON* matched_claim_metadata = cJSON_CreateArray();
- //printf("candidate %s\n", cJSON_Print(candidate));
AddAllClaims(matched_claim_names, cJSON_GetObjectItemCaseSensitive(candidate, "paths"));
cJSON_AddItemReferenceToObject(matched_credential, "matched_claim_names", matched_claim_names);
cJSON_AddItemReferenceToObject(matched_credential, "matched_claim_metadata", matched_claim_metadata); // Empty array represents all matched
@@ -128,8 +159,15 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
}
} else {
if (claim_sets == NULL) {
+ // ── LOG 5 ──
+ printf("[DCQL_DEBUG] 5. Matching specific claims (no claim sets)...\n");
+
cJSON* candidate;
cJSON_ArrayForEach(candidate, candidates) {
+ char* cand_id = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(candidate, "id"));
+ // ── LOG 5a ──
+ printf("[DCQL_DEBUG] 5a. Evaluating Candidate ID: %s\n", cand_id ? cand_id : "NULL");
+
cJSON* matched_credential = cJSON_CreateObject();
cJSON_AddItemReferenceToObject(matched_credential, "id", cJSON_GetObjectItemCaseSensitive(candidate, "id"));
cJSON_AddItemReferenceToObject(matched_credential, "display", cJSON_GetObjectItemCaseSensitive(candidate, "display"));
@@ -138,9 +176,17 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
cJSON* claim;
cJSON* candidate_claims = cJSON_GetObjectItemCaseSensitive(candidate, "paths");
+ int claims_matched_count = 0;
+
cJSON_ArrayForEach(claim, claims) {
cJSON* claim_values = cJSON_GetObjectItemCaseSensitive(claim, "values");
cJSON* paths = cJSON_GetObjectItemCaseSensitive(claim, "path");
+
+ // ── LOG 5b ──
+ char* path_string = cJSON_PrintUnformatted(paths);
+ printf("[DCQL_DEBUG] 5b. Searching for path: %s\n", path_string ? path_string : "NULL");
+ free(path_string);
+
cJSON* curr_path;
cJSON* curr_claim = candidate_claims;
int matched = 1;
@@ -149,29 +195,49 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
if (cJSON_HasObjectItem(curr_claim, path_value)) {
curr_claim = cJSON_GetObjectItemCaseSensitive(curr_claim, path_value);
} else {
+ // ── LOG 5c ──
+ printf("[DCQL_DEBUG] 5c. Path segment missing in candidate: %s\n", path_value ? path_value : "NULL");
matched = 0;
break;
}
}
- if (matched != 0 && curr_claim != NULL && cJSON_HasObjectItem(curr_claim, "display")) {
- if (claim_values != NULL) {
- cJSON* v;
- cJSON_ArrayForEach(v, claim_values) {
- if (cJSON_Compare(v, cJSON_GetObjectItemCaseSensitive(curr_claim, "value"), cJSON_True)) {
- cJSON_AddItemReferenceToArray(matched_claim_metadata, paths);
- cJSON_AddItemReferenceToArray(matched_claim_names, cJSON_GetObjectItem(curr_claim, "display"));
- break;
+
+ // If the path was fully matched and the candidate has this claim
+ if (matched != 0 && curr_claim != NULL) {
+ if (!cJSON_HasObjectItem(curr_claim, "display")) {
+ // ── LOG 5d ──
+ printf("[DCQL_DEBUG] 5d. Claim path found but lacked a 'display' object child.\n");
+ } else {
+ // If specific values are requested, check if candidate value matches any of them
+ if (claim_values != NULL) {
+ cJSON* v;
+ cJSON_ArrayForEach(v, claim_values) {
+ if (cJSON_Compare(v, cJSON_GetObjectItemCaseSensitive(curr_claim, "value"), cJSON_True)) {
+ claims_matched_count++;
+ cJSON_AddItemReferenceToArray(matched_claim_metadata, paths);
+ cJSON_AddItemReferenceToArray(matched_claim_names, cJSON_GetObjectItem(curr_claim, "display"));
+ break;
+ }
}
+ } else {
+ // No specific values requested, just match on presence of claim
+ claims_matched_count++;
+ cJSON_AddItemReferenceToArray(matched_claim_metadata, paths);
+ cJSON_AddItemReferenceToArray(matched_claim_names, cJSON_GetObjectItem(curr_claim, "display"));
}
- } else {
- cJSON_AddItemReferenceToArray(matched_claim_metadata, paths);
- cJSON_AddItemReferenceToArray(matched_claim_names, cJSON_GetObjectItem(curr_claim, "display"));
}
}
}
cJSON_AddItemReferenceToObject(matched_credential, "matched_claim_names", matched_claim_names);
cJSON_AddItemReferenceToObject(matched_credential, "matched_claim_metadata", matched_claim_metadata);
+
+ // ── LOG 5e ──
+ printf("[DCQL_DEBUG] 5e. Candidate finished claim check. Matched %d of %d requested claims.\n",
+ claims_matched_count, cJSON_GetArraySize(claims));
+
if (cJSON_GetArraySize(matched_claim_names) == cJSON_GetArraySize(claims)) {
+ // ── LOG 5f ──
+ printf("[DCQL_DEBUG] 5f. Candidate MATCHED and pushed to array! ID: %s\n", cand_id ? cand_id : "NULL");
cJSON_AddItemReferenceToArray(matched_credentials, matched_credential);
}
}
@@ -250,6 +316,9 @@ cJSON* MatchCredential(cJSON* credential, cJSON* credential_store) {
}
cJSON* dcql_query(cJSON* query, cJSON* credential_store) {
+ // ── LOG 0 ──
+ printf("[DCQL_DEBUG] 0. dcql_query started.\n");
+
cJSON* match_result = cJSON_CreateObject();
cJSON* matched_credential_sets = cJSON_CreateArray();
cJSON* candidate_matched_credentials = cJSON_CreateObject();
diff --git a/matcher/openid4vp1_0.c b/matcher/openid4vp1_0.c
index 24ab0f8..4ac0c90 100644
--- a/matcher/openid4vp1_0.c
+++ b/matcher/openid4vp1_0.c
@@ -88,6 +88,10 @@ void report_matched_credential(uint32_t wasm_version, cJSON* matched_doc, cJSON*
if (wasm_version >= 3)
{
+ printf("[MATCHER] MATCH SUCCESS! Calling AddPaymentEntryToSetV2 for ID: %s, Merchant: %s, Amount: %s\n",
+ matched_id ? matched_id : "NULL",
+ merchant_name ? merchant_name : "NULL",
+ transaction_amount ? transaction_amount : "NULL");
AddPaymentEntryToSetV2(matched_id, merchant_name, title, subtitle, creds_blob + icon_start_int, icon_len, transaction_amount, NULL, 0, NULL, 0, additional_info, metadata, set_id, doc_idx);
}
else if (wasm_version == 2)
@@ -95,12 +99,7 @@ void report_matched_credential(uint32_t wasm_version, cJSON* matched_doc, cJSON*
AddPaymentEntryToSet(matched_id, merchant_name, title, subtitle, creds_blob + icon_start_int, icon_len, transaction_amount, NULL, 0, NULL, 0, metadata, set_id, doc_idx);
}
else
- { // TODO: remove
- cJSON *id_obj = cJSON_CreateObject();
- cJSON_AddItemReferenceToObject(id_obj, "id", cJSON_GetObjectItem(c, "id"));
- cJSON_AddItemReferenceToObject(id_obj, "dcql_cred_id", cJSON_GetObjectItem(matched_doc, "id"));
- cJSON_AddItemReferenceToObject(id_obj, "provider_idx", cJSON_CreateNumber(request_id));
- char *id = cJSON_PrintUnformatted(id_obj);
+ {
AddPaymentEntry(matched_id, merchant_name, title, subtitle, creds_blob + icon_start_int, icon_len, transaction_amount, NULL, 0, NULL, 0);
}
}
@@ -134,13 +133,8 @@ void report_matched_credential(uint32_t wasm_version, cJSON* matched_doc, cJSON*
AddEntryToSet(matched_id, creds_blob + icon_start_int, icon_len, title, subtitle, explainer, NULL, metadata, set_id, doc_idx);
}
else
- { // TODO: remove
- cJSON *id_obj = cJSON_CreateObject();
- cJSON_AddItemReferenceToObject(id_obj, "id", cJSON_GetObjectItem(c, "id"));
- cJSON_AddItemReferenceToObject(id_obj, "dcql_cred_id", cJSON_GetObjectItem(matched_doc, "id"));
- cJSON_AddItemReferenceToObject(id_obj, "provider_idx", cJSON_CreateNumber(request_id));
- char *id = cJSON_PrintUnformatted(id_obj);
- AddStringIdEntry(id, creds_blob + icon_start_int, icon_len, title, subtitle, NULL, NULL);
+ {
+ AddStringIdEntry(matched_id, creds_blob + icon_start_int, icon_len, title, subtitle, NULL, NULL);
}
cJSON *matched_claim_names = cJSON_GetObjectItem(c, "matched_claim_names");
cJSON *claim;
@@ -154,13 +148,8 @@ void report_matched_credential(uint32_t wasm_version, cJSON* matched_doc, cJSON*
AddFieldToEntrySet(matched_id, claim_display, claim_value, set_id, doc_idx);
}
else
- { // TODO: remove
- cJSON *id_obj = cJSON_CreateObject();
- cJSON_AddItemReferenceToObject(id_obj, "id", cJSON_GetObjectItem(c, "id"));
- cJSON_AddItemReferenceToObject(id_obj, "dcql_cred_id", cJSON_GetObjectItem(matched_doc, "id"));
- cJSON_AddItemReferenceToObject(id_obj, "provider_idx", cJSON_CreateNumber(request_id));
- char *id = cJSON_PrintUnformatted(id_obj);
- AddFieldForStringIdEntry(id, claim_display, claim_value);
+ {
+ AddFieldForStringIdEntry(matched_id, claim_display, claim_value);
}
}
if (wasm_version >= 5) {
@@ -199,63 +188,90 @@ int main()
{
uint32_t credentials_size;
GetCredentialsSize(&credentials_size);
+ printf("[OPENID_DEBUG] 1. Total credentials_size from host: %u\n", credentials_size);
char *creds_blob = malloc(credentials_size);
+ if (creds_blob == NULL) {
+ printf("[OPENID_DEBUG] Error: Failed to allocate memory for creds_blob\n");
+ return 1;
+ }
ReadCredentialsBuffer(creds_blob, 0, credentials_size);
int json_offset = *((int *)creds_blob);
- printf("Creds JSON offset %d\n", json_offset);
+ printf("[OPENID_DEBUG] 2. Creds JSON offset calculated: %d\n", json_offset);
+
+ if (json_offset >= credentials_size || json_offset < 0) {
+ printf("[OPENID_DEBUG] FATAL ERROR: json_offset (%d) goes past buffer size (%u)!\n", json_offset, credentials_size);
+ return 1;
+ }
- cJSON *creds = cJSON_Parse(creds_blob + json_offset);
+ printf("[OPENID_DEBUG] 3. Target address is valid. Attempting cJSON_Parse...\n");
+
+ char* json_string_ptr = creds_blob + json_offset;
+
+ char snapshot[21];
+ strncpy(snapshot, json_string_ptr, 20);
+ snapshot[20] = '\0';
+ printf("[OPENID_DEBUG] 4. String snapshot at offset: %s\n", snapshot);
+
+ cJSON *creds = cJSON_Parse(json_string_ptr);
+ if (creds == NULL) {
+ printf("[OPENID_DEBUG] FATAL ERROR: cJSON_Parse failed (Invalid JSON or missing null terminator)\n");
+ return 1;
+ }
cJSON *credential_store = cJSON_GetObjectItem(creds, "credentials");
- printf("Creds JSON %s\n", cJSON_Print(credential_store));
+ printf("[OPENID_DEBUG] 5. Creds parsed successfully. Size: %d\n", cJSON_GetArraySize(credential_store));
cJSON *dc_request = GetDCRequestJson();
- printf("Request JSON %s\n", cJSON_Print(dc_request));
+ printf("[OPENID_DEBUG] 6. Request JSON fetched and parsed.\n");
uint32_t wasm_version;
GetWasmVersion(&wasm_version);
printf("Wasm version %u\n", wasm_version);
- // Parse each top level request looking for OpenID4VP requests
cJSON_bool is_modern_request = cJSON_HasObjectItem(dc_request, "requests");
cJSON *requests;
if (is_modern_request)
{
+ printf("[OPENID_DEBUG] 7. is_modern_requestd.\n");
requests = cJSON_GetObjectItem(dc_request, "requests");
}
else
{
+ printf("[OPENID_DEBUG] 8. NOT is_modern_requestd.\n");
requests = cJSON_GetObjectItem(dc_request, "providers");
}
int requests_size = cJSON_GetArraySize(requests);
+ printf("[OPENID_DEBUG] request size %d\n", requests_size);
int matched = 0;
int should_offer_issuance = 0;
char *merchant_name = NULL;
char *transaction_amount = NULL;
char *additional_info = NULL;
+
for (int i = 0; i < requests_size; i++)
{
cJSON *request = cJSON_GetArrayItem(requests, i);
- // printf("Request %s\n", cJSON_Print(request));
+ printf("[OPENID_DEBUG] Request %s\n", cJSON_Print(request));
char *protocol = cJSON_GetStringValue(cJSON_GetObjectItem(request, "protocol"));
if (strcmp(protocol, PROTOCOL_OPENID4VP_1_0_UNSIGNED) == 0 || strcmp(protocol, PROTOCOL_OPENID4VP_1_0_SIGNED) == 0)
{
- // We have an OpenID4VP request
+ printf("[OPENID_DEBUG] 10. Protocol match 1.\n");
+
cJSON *data_json;
if (is_modern_request)
{
data_json = cJSON_GetObjectItem(request, "data");
if (cJSON_IsString(data_json))
- { // Legacy spec
+ {
char *data_json_string = cJSON_GetStringValue(data_json);
data_json = cJSON_Parse(data_json_string);
}
}
else
- { // Legacy spec
+ {
cJSON *data = cJSON_GetObjectItem(request, "request");
char *data_json_string = cJSON_GetStringValue(data);
data_json = cJSON_Parse(data_json_string);
@@ -274,86 +290,265 @@ int main()
int decoded_request_json_len = B64DecodeURL(payload_start, &decoded_request_json);
data_json = cJSON_Parse(decoded_request_json);
}
+
cJSON *query = cJSON_GetObjectItem(data_json, "dcql_query");
if (cJSON_HasObjectItem(data_json, "offer"))
{
should_offer_issuance = 1;
}
- // For now we only support one transaction data item
-
cJSON *transaction_data_list = cJSON_GetObjectItem(data_json, "transaction_data");
cJSON *transaction_data = NULL;
cJSON *transaction_credential_ids = NULL;
+
+ if (transaction_data_list == NULL) {
+ printf("[OPENID_DEBUG] Transaction data null.\n");
+ }
+
if (transaction_data_list != NULL)
{
- if (cJSON_GetArraySize(transaction_data_list) == 1)
+ int td_count = cJSON_GetArraySize(transaction_data_list);
+ printf("[OPENID_DEBUG] Found %d transaction_data items to decode.\n", td_count);
+
+ for (int td_i = 0; td_i < td_count; td_i++)
{
- cJSON *transaction_data_encoded = cJSON_GetArrayItem(transaction_data_list, 0);
+ cJSON *transaction_data_encoded = cJSON_GetArrayItem(transaction_data_list, td_i);
char *transaction_data_encoded_str = cJSON_GetStringValue(transaction_data_encoded);
- char *transaction_data_json;
+
+ if (transaction_data_encoded_str == NULL) {
+ printf("[OPENID_DEBUG] Error: transaction_data[%d] is not a string.\n", td_i);
+ continue;
+ }
+ char *transaction_data_json = NULL;
+ printf("[OPENID_DEBUG] Attempting B64DecodeURL on item %d...\n", td_i);
int transaction_data_json_len = B64DecodeURL(transaction_data_encoded_str, &transaction_data_json);
- printf("transaction data %s\n", transaction_data_json);
- transaction_data = cJSON_Parse(transaction_data_json);
- transaction_credential_ids = cJSON_GetObjectItem(transaction_data, "credential_ids");
- char *transaction_data_type = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "type"));
- if (strcmp(transaction_data_type, "urn:eudi:sca:payment:1") == 0) {
- cJSON *payload = cJSON_GetObjectItem(transaction_data, "payload");
- merchant_name = cJSON_GetStringValue(cJSON_GetObjectItem(cJSON_GetObjectItem(payload, "payee"), "name"));
-
- transaction_amount = cJSON_GetStringValue(cJSON_GetObjectItem(payload, "amount_display"));
+ printf("transaction data [%d] %s\n", td_i, transaction_data_json);
+
+ if (transaction_data_json == NULL || transaction_data_json_len <= 0) {
+ printf("[OPENID_DEBUG] Error: B64DecodeURL failed or returned empty on item %d.\n", td_i);
+ continue;
+ }
+ printf("[OPENID_DEBUG] Decode successful. Attempting cJSON_Parse...\n");
+
+ cJSON *td_item = cJSON_Parse(transaction_data_json);
+ if (td_item == NULL) {
+ printf("[OPENID_DEBUG] Error: cJSON_Parse failed on decoded string for item %d.\n", td_i);
+ free(transaction_data_json);
+ continue;
+ }
+ char *transaction_data_type = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "type"));
+ printf("[OPENID_DEBUG] Parsed transaction type: %s\n", transaction_data_type ? transaction_data_type : "NULL");
+
+ if (td_i == 0) {
+ transaction_data = td_item;
+ transaction_credential_ids = cJSON_GetObjectItem(td_item, "credential_ids");
+ }
+ if (transaction_data_type == NULL) {
+ // skip malformed item
+ } else if (strcmp(transaction_data_type, "urn:eudi:sca:payment:1") == 0) {
+ cJSON *payload = cJSON_GetObjectItem(td_item, "payload");
+ if (merchant_name == NULL)
+ merchant_name = cJSON_GetStringValue(cJSON_GetObjectItem(cJSON_GetObjectItem(payload, "payee"), "name"));
if (transaction_amount == NULL) {
- double amount = cJSON_GetNumberValue(cJSON_GetObjectItem(payload, "amount"));
- int length_for_amount = log10(amount);
- char *currency = cJSON_GetStringValue(cJSON_GetObjectItem(payload, "currency"));
- int total_length = length_for_amount + 4 + strlen(currency) + 2;
- transaction_amount = malloc(length_for_amount + 4 + strlen(currency) + 2);
- sprintf(transaction_amount, "%s %f", currency, amount);
- transaction_amount[total_length - 1] = '\0';
+ transaction_amount = cJSON_GetStringValue(cJSON_GetObjectItem(payload, "amount_display"));
+ if (transaction_amount == NULL) {
+ double amount = cJSON_GetNumberValue(cJSON_GetObjectItem(payload, "amount"));
+ int length_for_amount = (int)log10(amount) + 1;
+ char *currency = cJSON_GetStringValue(cJSON_GetObjectItem(payload, "currency"));
+ int total_length = length_for_amount + 4 + (currency ? strlen(currency) : 3) + 2;
+ transaction_amount = malloc(total_length);
+ sprintf(transaction_amount, "%s %f", currency ? currency : "USD", amount);
+ }
}
- printf("transaction amount %s\n", transaction_amount);
-
- additional_info = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "additional_info"));
+ if (additional_info == NULL)
+ additional_info = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "additional_info"));
} else if (strcmp(transaction_data_type, "payment_details") == 0) {
- merchant_name = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "payee_name"));
+ if (merchant_name == NULL)
+ merchant_name = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "payee_name"));
+ if (transaction_amount == NULL) {
+ char *amount = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "payment_amount"));
+ char *currency = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "payment_currency"));
+ if (amount && currency) {
+ transaction_amount = malloc(strlen(amount) + strlen(currency) + 2);
+ sprintf(transaction_amount, "%s %s", currency, amount);
+ }
+ }
+ if (additional_info == NULL)
+ additional_info = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "additional_info"));
+ } else if (strcmp(transaction_data_type, "delegate") == 0) {
+ // Handle delegate transaction data (AP2 flow).
+ // We extract mandate information and build additional_info for the UI.
+ cJSON *delegate_payload_arr = cJSON_GetObjectItem(td_item, "delegate_payload");
+ char *payee_name_val = NULL;
+ char *amt_value_val = NULL;
+ char *amt_currency_val = NULL;
+ cJSON *checkout_jwt_payload = NULL;
+ int amt_value_allocated = 0;
+
+ if (delegate_payload_arr != NULL) {
+ int dp_count = cJSON_GetArraySize(delegate_payload_arr);
+ for (int dp_i = 0; dp_i < dp_count; dp_i++) {
+ cJSON *dp = cJSON_GetArrayItem(delegate_payload_arr, dp_i);
+ char *vct = cJSON_GetStringValue(cJSON_GetObjectItem(dp, "vct"));
+ if (vct == NULL) continue;
+
+ // Extract payment mandate details if present
+ if (strcmp(vct, "mandate.payment") == 0) {
+ printf("[OPENID_DEBUG] Found mandate.payment\n");
+ cJSON *payee = cJSON_GetObjectItem(dp, "payee");
+ if (payee != NULL && payee_name_val == NULL)
+ payee_name_val = cJSON_GetStringValue(cJSON_GetObjectItem(payee, "name"));
+ cJSON *amount_obj = cJSON_GetObjectItem(dp, "payment_amount");
+ if (amount_obj != NULL) {
+ cJSON *amt_item = cJSON_GetObjectItem(amount_obj, "amount");
+ if (amt_item != NULL && amt_value_val == NULL) {
+ if (cJSON_IsNumber(amt_item)) {
+ double amt = cJSON_GetNumberValue(amt_item);
+ amt_value_val = malloc(32);
+ snprintf(amt_value_val, 32, "%.0f", amt);
+ amt_value_allocated = 1;
+ } else {
+ amt_value_val = cJSON_GetStringValue(amt_item);
+ }
+ }
+ if (amt_currency_val == NULL)
+ amt_currency_val = cJSON_GetStringValue(cJSON_GetObjectItem(amount_obj, "currency"));
+ }
+ }
+ // Extract checkout mandate and decode checkout JWT if present
+ else if (strcmp(vct, "mandate.checkout") == 0) {
+ printf("[OPENID_DEBUG] Found mandate.checkout\n");
+ char *checkout_jwt_str = cJSON_GetStringValue(cJSON_GetObjectItem(dp, "checkout_jwt"));
+ if (checkout_jwt_str != NULL) {
+ // Extract payload from compact JWT (header.payload.signature)
+ char *dot1 = strchr(checkout_jwt_str, '.');
+ if (dot1 != NULL) {
+ char *payload_start = dot1 + 1;
+ char *dot2 = strchr(payload_start, '.');
+ int payload_len = dot2 ? (int)(dot2 - payload_start) : (int)strlen(payload_start);
+ char *payload_b64 = malloc(payload_len + 1);
+ memcpy(payload_b64, payload_start, payload_len);
+ payload_b64[payload_len] = '\0';
+
+ char *decoded_json = NULL;
+ int decoded_len = B64DecodeURL(payload_b64, &decoded_json);
+ free(payload_b64);
+
+ if (decoded_len > 0 && decoded_json != NULL) {
+ printf("[OPENID_DEBUG] Decoded checkout JWT successfully.\n");
+ checkout_jwt_payload = cJSON_Parse(decoded_json);
+ free(decoded_json);
+ }
+ }
+ }
+ }
+ }
+ }
- char *amount = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "payment_amount"));
- char *currency = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "payment_currency"));
- transaction_amount = malloc(strlen(amount) + strlen(currency) + 2);
- sprintf(transaction_amount, "%s %s", currency, amount);
- printf("transaction amount %s\n", transaction_amount);
+ // Populate merchant_name and transaction_amount for card picker header
+ if (merchant_name == NULL && payee_name_val != NULL)
+ merchant_name = payee_name_val;
+ if (transaction_amount == NULL && amt_value_val != NULL && amt_currency_val != NULL) {
+ transaction_amount = malloc(strlen(amt_value_val) + strlen(amt_currency_val) + 2);
+ sprintf(transaction_amount, "%s %s", amt_currency_val, amt_value_val);
+ }
+
+ if (amt_value_allocated) {
+ free(amt_value_val);
+ }
- additional_info = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "additional_info"));
+ // Build additional_info JSON for picker UI (table with line items)
+ if (additional_info == NULL) {
+ printf("[OPENID_DEBUG] Building table headers for additional_info...\n");
+ cJSON *ai_obj = cJSON_CreateObject();
+
+ cJSON *header_arr = cJSON_CreateArray();
+ cJSON_AddItemToArray(header_arr, cJSON_CreateString("Item"));
+ cJSON_AddItemToArray(header_arr, cJSON_CreateString("Qty"));
+ cJSON_AddItemToArray(header_arr, cJSON_CreateString("Price"));
+ cJSON_AddItemToObject(ai_obj, "tableHeader", header_arr);
+
+ cJSON *rows_arr = cJSON_CreateArray();
+ if (checkout_jwt_payload != NULL) {
+ printf("[OPENID_DEBUG] Pulling cart line items from checkout payload...\n");
+ cJSON *line_items = cJSON_GetObjectItem(checkout_jwt_payload, "line_items");
+ if (line_items != NULL) {
+ int li_count = cJSON_GetArraySize(line_items);
+ for (int li = 0; li < li_count; li++) {
+ cJSON *item = cJSON_GetArrayItem(line_items, li);
+ char *title = cJSON_GetStringValue(cJSON_GetObjectItem(item, "title"));
+ char *unit_price = cJSON_GetStringValue(cJSON_GetObjectItem(item, "unit_price"));
+ double qty_num = cJSON_GetNumberValue(cJSON_GetObjectItem(item, "quantity"));
+ char qty_str[16] = {0};
+ snprintf(qty_str, sizeof(qty_str), "%.0f", qty_num);
+ cJSON *row = cJSON_CreateArray();
+ cJSON_AddItemToArray(row, cJSON_CreateString(title ? title : ""));
+ cJSON_AddItemToArray(row, cJSON_CreateString(qty_str));
+ cJSON_AddItemToArray(row, cJSON_CreateString(unit_price ? unit_price : ""));
+ cJSON_AddItemToArray(rows_arr, row);
+ }
+ }
+ } else {
+ printf("[OPENID_DEBUG] Warning: checkout_jwt_payload was NULL, tableRows will be empty.\n");
+ }
+ cJSON_AddItemToObject(ai_obj, "tableRows", rows_arr);
+
+ char footer_str[64] = {0};
+ if (checkout_jwt_payload != NULL) {
+ cJSON *totals = cJSON_GetObjectItem(checkout_jwt_payload, "totals");
+ char *total_val = totals ? cJSON_GetStringValue(cJSON_GetObjectItem(totals, "total")) : NULL;
+ char *cur = cJSON_GetStringValue(cJSON_GetObjectItem(checkout_jwt_payload, "currency"));
+ if (total_val && cur)
+ snprintf(footer_str, sizeof(footer_str), "Total: %s %s", cur, total_val);
+ else if (total_val)
+ snprintf(footer_str, sizeof(footer_str), "Total: %s", total_val);
+ }
+ cJSON_AddItemToObject(ai_obj, "footer", cJSON_CreateString(footer_str));
+
+ additional_info = cJSON_PrintUnformatted(ai_obj);
+ cJSON_Delete(ai_obj);
+ if (checkout_jwt_payload != NULL) {
+ cJSON_Delete(checkout_jwt_payload);
+ checkout_jwt_payload = NULL;
+ }
+ }
} else {
- merchant_name = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "merchant_name"));
- transaction_amount = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "amount"));
- additional_info = cJSON_GetStringValue(cJSON_GetObjectItem(transaction_data, "additional_info"));
+ if (merchant_name == NULL)
+ merchant_name = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "merchant_name"));
+ if (transaction_amount == NULL)
+ transaction_amount = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "amount"));
+ if (additional_info == NULL)
+ additional_info = cJSON_GetStringValue(cJSON_GetObjectItem(td_item, "additional_info"));
+ }
+ if (td_i > 0) {
+ cJSON_Delete(td_item);
}
}
}
+ printf("[OPENID_DEBUG] Reached the line before dcql_query!\n");
+
cJSON *matched_result = dcql_query(query, credential_store);
- // printf("matched_creds %d\n", cJSON_GetArraySize(matched_creds));
printf("match result %s\n", cJSON_Print(matched_result));
cJSON *matched_credential_sets = cJSON_GetObjectItemCaseSensitive(matched_result, "matched_credential_sets");
cJSON *matched_docs = cJSON_GetObjectItemCaseSensitive(matched_result, "matched_credentials");
int matched_credential_sets_size = cJSON_GetArraySize(matched_credential_sets);
- if (matched_credential_sets_size > 0) { // Some credential(s) matched
+ if (matched_credential_sets_size > 0) {
cJSON *first_matched_credential_set = cJSON_GetArrayItem(matched_credential_sets, 0);
cJSON *matched_option;
cJSON_ArrayForEach(matched_option, first_matched_credential_set) {
cJSON *matched_credential_ids = cJSON_GetObjectItemCaseSensitive(matched_option, "matched_credential_ids");
int credential_set_size = cJSON_GetArraySize(matched_credential_ids);
char set_id_buffer[26];
-
+
if (cJSON_HasObjectItem(matched_option, "set_id")) {
char *set_idx = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(matched_option, "set_id"));
char *option_idx = cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(matched_option, "option_id"));
int chars_written = sprintf(set_id_buffer, "req:%d;set:%s;option:%s", i, set_idx, option_idx);
- if (wasm_version > 1) { // Report set length
+ if (wasm_version > 1) {
report_credential_set_length(set_id_buffer, credential_set_size, 1, matched_credential_sets, matched_credential_sets_size);
}
@@ -368,9 +563,9 @@ int main()
++doc_idx;
}
report_matched_credential_set(set_id_buffer, 1, matched_credential_sets, doc_idx, matched_credential_sets_size, wasm_version, matched_docs, i, creds_blob, transaction_credential_ids, merchant_name, transaction_amount, additional_info);
- } else { // No credential_sets present in dcql
+ } else {
int chars_written = sprintf(set_id_buffer, "req:%d;null", i);
- if (wasm_version > 1) { // Report set length
+ if (wasm_version > 1) {
AddEntrySet(set_id_buffer, credential_set_size);
}
diff --git a/matcher/openid4vp1_0.wasm b/matcher/openid4vp1_0.wasm
new file mode 100755
index 0000000..3c47b78
Binary files /dev/null and b/matcher/openid4vp1_0.wasm differ