Skip to content

Add verifier app using the DC API#200

Open
cy245 wants to merge 4 commits into
mainfrom
dc-rp-app
Open

Add verifier app using the DC API#200
cy245 wants to merge 4 commits into
mainfrom
dc-rp-app

Conversation

@cy245
Copy link
Copy Markdown
Collaborator

@cy245 cy245 commented May 26, 2026

This works with CMWallet and GPM (for email verification).

@cy245 cy245 requested a review from niharika2810 as a code owner May 26, 2026 13:20
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Android sample application demonstrating the integration of the Android Credential Manager API to request and parse digital credentials (such as Mobile Driver's Licenses and Verified Emails). The review feedback highlights several critical issues that need to be addressed: using Modifier.weight inside a scrollable Column will throw an IllegalStateException at runtime; referencing Theme.AppCompat in the manifest without declaring the appcompat dependency will cause build or runtime failures; casting context directly to Activity is unsafe and should be replaced with a recursive unwrapper; and an off-by-one error in the SD-JWT parser may skip the last disclosure. Additionally, a hardcoded string should be moved to resources for localization.

Comment thread DigitalCredentialsApp/app/src/main/AndroidManifest.xml Outdated
Comment thread DigitalCredentialsApp/app/src/main/res/values/strings.xml
@cy245 cy245 marked this pull request as draft May 26, 2026 14:29
@cy245 cy245 removed the request for review from niharika2810 May 26, 2026 14:29
@cy245
Copy link
Copy Markdown
Collaborator Author

cy245 commented May 26, 2026

/gemini review

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new Android sample application, DigitalCredentialsApp, demonstrating integration with the Android Credential Manager API to request and parse Mobile Driver's Licenses (mDL) and Verified Emails. It includes a custom CBOR decoder, utility classes for OpenID4VP request generation, and a Jetpack Compose UI to display extracted claims. The review feedback highlights a critical security vulnerability in the CBOR decoder regarding resource exhaustion (OOM and stack overflow) from untrusted payloads, and suggests enhancing the legacy parsing fallback to support flat strings and JSON arrays for broader wallet compatibility.

Comment on lines +130 to +190
fun decodeNext(): Any? {
if (!buffer.hasRemaining()) return null
val b = buffer.get().toInt() and 0xFF
val majorType = b shr 5
val additionalInfo = b and 0x1F

return when (majorType) {
TYPE_UNSIGNED_INT -> readLength(additionalInfo)
TYPE_NEGATIVE_INT -> -1 - readLength(additionalInfo)
TYPE_BYTE_STRING -> {
val length = readLength(additionalInfo).toInt()
val bytes = ByteArray(length)
buffer.get(bytes)
bytes
}
TYPE_TEXT_STRING -> {
val length = readLength(additionalInfo).toInt()
val bytes = ByteArray(length)
buffer.get(bytes)
String(bytes)
}
TYPE_ARRAY -> {
val size = readLength(additionalInfo).toInt()
val list = mutableListOf<Any?>()
repeat(size) {
list.add(decodeNext())
}
list
}
TYPE_MAP -> {
val size = readLength(additionalInfo).toInt()
val map = mutableMapOf<Any?, Any?>()
repeat(size) {
val key = decodeNext()
val value = decodeNext()
map[key] = value
}
map
}
TYPE_TAG -> {
val tag = readLength(additionalInfo)
if (tag == 24L) {
// ISO 18013-5 unwrapping of Tag 24 items
val innerBytes = decodeNext() as? ByteArray
?: throw IllegalArgumentException("Tag 24 must be followed by byte string")
Decoder(innerBytes).decodeNext()
} else {
decodeNext()
}
}
TYPE_SIMPLE -> {
when (additionalInfo) {
20 -> false
21 -> true
22 -> null
else -> null
}
}
else -> throw IllegalArgumentException("Unsupported major type: $majorType")
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The CBOR decoder is vulnerable to several resource exhaustion and crash vectors when parsing untrusted or malformed credential payloads:

  1. OutOfMemoryError / NegativeArraySizeException: Large or negative lengths read from the CBOR stream are directly used to allocate ByteArrays (e.g., for TYPE_BYTE_STRING and TYPE_TEXT_STRING) or loop via repeat (e.g., for TYPE_ARRAY and TYPE_MAP). This can crash the application.
  2. StackOverflowError: Deeply nested CBOR structures (such as nested arrays, maps, or Tag 24 items) can cause stack overflow due to unbounded recursion in decodeNext().

To mitigate these issues, we should:

  • Validate that any decoded length/size is non-negative and does not exceed the remaining bytes in the buffer.
  • Track and limit the recursion depth of decodeNext().
        fun decodeNext(depth: Int = 0): Any? {
            if (depth > 64) {
                throw IllegalArgumentException("Max decoding depth exceeded")
            }
            if (!buffer.hasRemaining()) return null
            val b = buffer.get().toInt() and 0xFF
            val majorType = b shr 5
            val additionalInfo = b and 0x1F

            return when (majorType) {
                TYPE_UNSIGNED_INT -> readLength(additionalInfo)
                TYPE_NEGATIVE_INT -> -1 - readLength(additionalInfo)
                TYPE_BYTE_STRING -> {
                    val length = readLength(additionalInfo).toInt()
                    if (length < 0 || length > buffer.remaining()) {
                        throw IllegalArgumentException("Invalid byte string length: $length")
                    }
                    val bytes = ByteArray(length)
                    buffer.get(bytes)
                    bytes
                }
                TYPE_TEXT_STRING -> {
                    val length = readLength(additionalInfo).toInt()
                    if (length < 0 || length > buffer.remaining()) {
                        throw IllegalArgumentException("Invalid text string length: $length")
                    }
                    val bytes = ByteArray(length)
                    buffer.get(bytes)
                    String(bytes, Charsets.UTF_8)
                }
                TYPE_ARRAY -> {
                    val size = readLength(additionalInfo).toInt()
                    if (size < 0 || size > buffer.remaining()) {
                        throw IllegalArgumentException("Invalid array size: $size")
                    }
                    val list = mutableListOf<Any?>()
                    repeat(size) {
                        list.add(decodeNext(depth + 1))
                    }
                    list
                }
                TYPE_MAP -> {
                    val size = readLength(additionalInfo).toInt()
                    if (size < 0 || size * 2 > buffer.remaining()) {
                        throw IllegalArgumentException("Invalid map size: $size")
                    }
                    val map = mutableMapOf<Any?, Any?>()
                    repeat(size) {
                        val key = decodeNext(depth + 1)
                        val value = decodeNext(depth + 1)
                        map[key] = value
                    }
                    map
                }
                TYPE_TAG -> {
                    val tag = readLength(additionalInfo)
                    if (tag == 24L) {
                        // ISO 18013-5 unwrapping of Tag 24 items
                        val innerBytes = decodeNext(depth + 1) as? ByteArray
                            ?: throw IllegalArgumentException("Tag 24 must be followed by byte string")
                        Decoder(innerBytes).decodeNext(depth + 1)
                    } else {
                        decodeNext(depth + 1)
                    }
                }
                TYPE_SIMPLE -> {
                    when (additionalInfo) {
                        20 -> false
                        21 -> true
                        22 -> null
                        else -> null
                    }
                }
                else -> throw IllegalArgumentException("Unsupported major type: $majorType")
            }
        }

Comment on lines +295 to +315
private fun handleLegacyParsing(vpToken: Any, claims: MutableList<CredentialClaim>) {
try {
if (vpToken is JSONObject) {
val keys = vpToken.keys()
while (keys.hasNext()) {
val key = keys.next()
val tokenArray = vpToken.optJSONArray(key)
if (tokenArray != null && tokenArray.length() > 0) {
val rawToken = tokenArray.getString(0)
if (rawToken.contains("~")) {
claims.addAll(parseSdJwtClaims(rawToken))
} else {
claims.addAll(parseMdocClaims(rawToken))
}
}
}
}
} catch (e: Exception) {
Log.e("CredentialManagerUtil", "Fallback parsing failed", e)
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The handleLegacyParsing function currently only handles cases where vpToken is a JSONObject. However, in many standard or simpler OpenID4VP responses (especially when presentation_submission is missing), vp_token can be returned directly as a flat String (the raw token) or a JSONArray of strings.

Updating handleLegacyParsing to support String and JSONArray types will significantly improve compatibility with various digital wallet implementations.

    private fun handleLegacyParsing(vpToken: Any, claims: MutableList<CredentialClaim>) {
        try {
            when (vpToken) {
                is String -> {
                    if (vpToken.contains("~")) {
                        claims.addAll(parseSdJwtClaims(vpToken))
                    } else {
                        claims.addAll(parseMdocClaims(vpToken))
                    }
                }
                is JSONArray -> {
                    for (i in 0 until vpToken.length()) {
                        val token = vpToken.optString(i) ?: continue
                        if (token.contains("~")) {
                            claims.addAll(parseSdJwtClaims(token))
                        } else {
                            claims.addAll(parseMdocClaims(token))
                        }
                    }
                }
                is JSONObject -> {
                    val keys = vpToken.keys()
                    while (keys.hasNext()) {
                        val key = keys.next()
                        val tokenArray = vpToken.optJSONArray(key)
                        if (tokenArray != null && tokenArray.length() > 0) {
                            val rawToken = tokenArray.getString(0)
                            if (rawToken.contains("~")) {
                                claims.addAll(parseSdJwtClaims(rawToken))
                            } else {
                                claims.addAll(parseMdocClaims(rawToken))
                            }
                        } else {
                            val rawToken = vpToken.optString(key)
                            if (rawToken.isNotEmpty()) {
                                if (rawToken.contains("~")) {
                                    claims.addAll(parseSdJwtClaims(rawToken))
                                } else {
                                    claims.addAll(parseMdocClaims(rawToken))
                                }
                            }
                        }
                    }
                }
            }
        } catch (e: Exception) {
            Log.e("CredentialManagerUtil", "Fallback parsing failed", e)
        }
    }

@cy245 cy245 requested a review from QZHelen May 27, 2026 16:31
@cy245 cy245 marked this pull request as ready for review May 27, 2026 16:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant