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