From 112b42d3f7db128a31660efe8d663291cee4f2ff Mon Sep 17 00:00:00 2001 From: Reema Bajwa Date: Mon, 27 Apr 2026 21:41:08 +0000 Subject: [PATCH 1/2] Support delegated presentations --- app/build.gradle.kts | 8 + app/src/main/AndroidManifest.xml | 5 + app/src/main/assets/openid4vp1_0.wasm | Bin 69256 -> 76330 bytes .../cmwallet/getcred/GetCredentialActivity.kt | 17 +- .../com/credman/cmwallet/openid4vp/DCQL.kt | 38 +- .../credman/cmwallet/openid4vp/OpenId4VP.kt | 46 ++ .../java/com/credman/cmwallet/sdjwt/SdJwt.kt | 171 ++++++ .../cmwallet/testap2/Ap2TestActivity.kt | 410 +++++++++++++ .../com/credman/cmwallet/ui/HomeScreen.kt | 134 ++++- .../credman/cmwallet/Ap2SampleGenerator.kt | 566 ++++++++++++++++++ .../credman/cmwallet/DpcSdJwtMandateTest.kt | 553 +++++++++++++++++ matcher/dcql.c | 101 +++- matcher/openid4vp1_0.c | 333 ++++++++--- matcher/openid4vp1_0.wasm | Bin 0 -> 76330 bytes 14 files changed, 2264 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/com/credman/cmwallet/testap2/Ap2TestActivity.kt create mode 100644 app/src/test/java/com/credman/cmwallet/Ap2SampleGenerator.kt create mode 100644 app/src/test/java/com/credman/cmwallet/DpcSdJwtMandateTest.kt create mode 100755 matcher/openid4vp1_0.wasm 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 @@ + OvKfsI{U3V*PAI#TBhuDYaj{vEHcmp%699 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 diff --git a/app/src/main/java/com/credman/cmwallet/getcred/GetCredentialActivity.kt b/app/src/main/java/com/credman/cmwallet/getcred/GetCredentialActivity.kt index 4a90c00..22dd634 100644 --- a/app/src/main/java/com/credman/cmwallet/getcred/GetCredentialActivity.kt +++ b/app/src/main/java/com/credman/cmwallet/getcred/GetCredentialActivity.kt @@ -39,6 +39,7 @@ import com.credman.cmwallet.mdoc.webOriginOrAppOrigin import com.credman.cmwallet.openid4vci.data.CredentialConfigurationMDoc import com.credman.cmwallet.openid4vci.data.CredentialConfigurationSdJwtVc import com.credman.cmwallet.openid4vci.data.CredentialConfigurationUnknownFormat +import com.credman.cmwallet.openid4vp.DelegateProposal import com.credman.cmwallet.openid4vp.OpenId4VP import com.credman.cmwallet.openid4vp.OpenId4VP.Companion.IDENTIFIERS_1_0 import com.credman.cmwallet.openid4vp.OpenId4VP.Companion.IDENTIFIER_DRAFT_24 @@ -81,13 +82,27 @@ fun createOpenID4VPResponse( val transactionDataHashes = openId4VPRequest.generateDeviceSignedTransactionData(matchedCredential.dcqlId).deviceSignedTransactionData - credentialResponse = + // Check if the request contains delegate proposals (AP2 flow). + // If so, use presentWithDelegations to create a dSD-JWT chain. + // Otherwise, fall back to standard presentation. + credentialResponse = if (openId4VPRequest.delegateProposals.isNotEmpty()) { + Log.d(TAG, "Creating response with delegations (AP2 flow)") + sdJwtVc.presentWithDelegations( + claimSets = claims, + nonce = openId4VPRequest.nonce, + aud = openId4VPRequest.getSdJwtKbAud(origin), + transactionDataHashes = transactionDataHashes, + delegateProposals = openId4VPRequest.delegateProposals + ) + } else { + Log.d(TAG, "Creating standard presentation response") sdJwtVc.present( claims, nonce = openId4VPRequest.nonce, aud = openId4VPRequest.getSdJwtKbAud(origin), transactionDataHashes = transactionDataHashes ) + } } is CredentialConfigurationMDoc -> { 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..e710e04 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,177 @@ 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") } + val kbSdHeader = buildJsonObject { + put("typ", if (hasCnf) "kb-sd-jwt+kb" else "kb-sd-jwt") + 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 0000000000000000000000000000000000000000..3c47b7866e63fb2e59dafee163ff7f2c7cc6f130 GIT binary patch literal 76330 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 bEvsXL)rRb}jW6O@A56-zZaRSDa54T5bKrjA literal 0 HcmV?d00001 From 4284c3635b6938246623707c68c8144a774f6496 Mon Sep 17 00:00:00 2001 From: Reema Bajwa Date: Tue, 28 Apr 2026 05:42:01 +0000 Subject: [PATCH 2/2] Add logs to debug typ selection in SdJwt.kt --- app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 e710e04..6d7dbcd 100644 --- a/app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt +++ b/app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt @@ -268,8 +268,11 @@ class SdJwt( // ── 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", if (hasCnf) "kb-sd-jwt+kb" else "kb-sd-jwt") + put("typ", typ) put("alg", "ES256") } val kbSdPayload = buildJsonObject {