Conversation
There was a problem hiding this comment.
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.
|
/gemini review |
There was a problem hiding this comment.
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.
| 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") | ||
| } | ||
| } |
There was a problem hiding this comment.
The CBOR decoder is vulnerable to several resource exhaustion and crash vectors when parsing untrusted or malformed credential payloads:
- OutOfMemoryError / NegativeArraySizeException: Large or negative lengths read from the CBOR stream are directly used to allocate
ByteArrays (e.g., forTYPE_BYTE_STRINGandTYPE_TEXT_STRING) or loop viarepeat(e.g., forTYPE_ARRAYandTYPE_MAP). This can crash the application. - 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")
}
}| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
}
}
This works with CMWallet and GPM (for email verification).