Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

testOptions {
unitTests {
isIncludeAndroidResources = true
}
}

buildTypes {
release {
isMinifyEnabled = false
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".testap2.Ap2TestActivity"
android:exported="true"
android:label="AP2 E2E Test"
android:theme="@style/Theme.CMWallet" />
<activity
android:name=".getcred.GetCredentialActivity"
android:exported="true"
Expand Down
Binary file modified app/src/main/assets/openid4vp1_0.wasm
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import com.credman.cmwallet.mdoc.webOriginOrAppOrigin
import com.credman.cmwallet.openid4vci.data.CredentialConfigurationMDoc
import com.credman.cmwallet.openid4vci.data.CredentialConfigurationSdJwtVc
import com.credman.cmwallet.openid4vci.data.CredentialConfigurationUnknownFormat
import com.credman.cmwallet.openid4vp.DelegateProposal
import com.credman.cmwallet.openid4vp.OpenId4VP
import com.credman.cmwallet.openid4vp.OpenId4VP.Companion.IDENTIFIERS_1_0
import com.credman.cmwallet.openid4vp.OpenId4VP.Companion.IDENTIFIER_DRAFT_24
Expand Down Expand Up @@ -81,13 +82,27 @@ fun createOpenID4VPResponse(
val transactionDataHashes =
openId4VPRequest.generateDeviceSignedTransactionData(matchedCredential.dcqlId).deviceSignedTransactionData

credentialResponse =
// Check if the request contains delegate proposals (AP2 flow).
// If so, use presentWithDelegations to create a dSD-JWT chain.
// Otherwise, fall back to standard presentation.
credentialResponse = if (openId4VPRequest.delegateProposals.isNotEmpty()) {
Log.d(TAG, "Creating response with delegations (AP2 flow)")
sdJwtVc.presentWithDelegations(
claimSets = claims,
nonce = openId4VPRequest.nonce,
aud = openId4VPRequest.getSdJwtKbAud(origin),
transactionDataHashes = transactionDataHashes,
delegateProposals = openId4VPRequest.delegateProposals
)
} else {
Log.d(TAG, "Creating standard presentation response")
sdJwtVc.present(
claims,
nonce = openId4VPRequest.nonce,
aud = openId4VPRequest.getSdJwtKbAud(origin),
transactionDataHashes = transactionDataHashes
)
}
}

is CredentialConfigurationMDoc -> {
Expand Down
38 changes: 37 additions & 1 deletion app/src/main/java/com/credman/cmwallet/openid4vp/DCQL.kt
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,33 @@ fun matchCredential(credential: JSONObject, credentialStore: JSONObject): List<M
} else {
return matchedCredentials
}
}

// Support for SD-JWT format in DCQL.
// We filter candidates by verifying if their 'vct' matches any of the requested 'vct_values'.
"dc+sd-jwt" -> {
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
}

Expand Down Expand Up @@ -299,6 +319,22 @@ fun matchCredential(credential: JSONObject, credentialStore: JSONObject): List<M
}
}
}
// For SD-JWT, we expect a 'path' in the claim.
// We use the last element of the path as the claim name and check if it exists in the candidate's paths.
"dc+sd-jwt" -> {
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) {
Expand Down
46 changes: 46 additions & 0 deletions app/src/main/java/com/credman/cmwallet/openid4vp/OpenId4VP.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, // pre-computed disclosure strings
val credentialIds: List<String> // credential_ids this mandate is scoped to
)

class OpenId4VP(
var requestJson: JSONObject,
var clientId: String,
Expand All @@ -33,6 +41,7 @@ class OpenId4VP(

val dcqlQuery: JSONObject
val transactionData: List<TransactionData>
val delegateProposals: List<DelegateProposal>
val issuanceOffer: JSONObject?
val clientMedtadata: JSONObject?
val responseMode: String?
Expand Down Expand Up @@ -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<DelegateProposal>().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(
Expand Down
174 changes: 174 additions & 0 deletions app/src/main/java/com/credman/cmwallet/sdjwt/SdJwt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(["<salt>", <mandate_json>]) 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<String, List<ByteArray>>,
delegateProposals: List<com.credman.cmwallet.openid4vp.DelegateProposal>
): 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<String>()
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..<claimSets.length()) {
val claimSet = claimSets[i] as JSONArray
val ret = mutableListOf<String>()
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<JSONObject>()
for (pathIdx in 0..<path.length()) {
val currPath = path.getString(pathIdx)
if (sd.has(currPath)) {
sd = sd.getJSONObject(currPath)
sds.add(JSONObject(sd.toString()))
} else { ok = false; break }
}
if (!ok) break
addDisclosuresToPresentation(sd, ret)
if (sds.size > 1) {
for (k in 0..<sds.size - 1) {
val currSd = sds[k]
if (currSd.has("_sd")) {
val digest = currSd.getString("_sd")
ret.add(verifiedResult.digestDisclosureMap[digest]!!)
}
}
}
}
if (ok) {
selectedDisclosures.addAll(ret)
matched = true
android.util.Log.d("SdJwt", "Matched claim set $i")
break@outer
}
}
require(matched) { "Could not match against any claim sets." }
}

android.util.Log.d("SdJwt", "Selected ${selectedDisclosures.size} DPC disclosures")

// ── Step 2: sd_hash over DPC base (issuer_jwt ~ dpc_discs ~) ─────────────────
val dpcBase = (listOf(issuerJwt) + selectedDisclosures).joinToString("~", postfix = "~")
val sdHash = MessageDigest.getInstance("SHA-256")
.digest(dpcBase.encodeToByteArray())
.toBase64UrlNoPadding()

android.util.Log.d("SdJwt", "Computed sd_hash: $sdHash")

// ── Step 3: create one mandate disclosure per proposal ────────────────────────
// Each disclosure: base64url(["<random_salt>", <mandate_json_object>])
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<String, kotlinx.serialization.json.JsonElement>()
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(
Expand Down
Loading
Loading