From 85ef0aae6529048538018c0341e48d8b81b11eb7 Mon Sep 17 00:00:00 2001 From: Chiping Yeh Date: Fri, 22 May 2026 14:55:30 -0400 Subject: [PATCH 1/6] Add verifier app using the DC API --- .gitignore | 1 + .../MyVault/gradle/libs.versions.toml | 2 +- DigitalCredentialsApp/app/build.gradle | 62 +++ .../app/src/main/AndroidManifest.xml | 20 + .../digitalcredentialsapp/MainActivity.kt | 35 ++ .../digitalcredentialsapp/MainScreen.kt | 285 +++++++++++++ .../digitalcredentialsapp/MainUiState.kt | 61 +++ .../digitalcredentialsapp/MainViewModel.kt | 54 +++ .../digitalcredentialsapp/data/Cbor.kt | 203 +++++++++ .../data/CredentialManagerUtil.kt | 401 ++++++++++++++++++ .../digitalcredentialsapp/data/OpenId4Vp.kt | 46 ++ .../digitalcredentialsapp/data/Requests.kt | 85 ++++ .../app/src/main/res/values/strings.xml | 8 + DigitalCredentialsApp/build.gradle | 22 + DigitalCredentialsApp/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 45457 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + DigitalCredentialsApp/gradlew | 251 +++++++++++ DigitalCredentialsApp/gradlew.bat | 94 ++++ DigitalCredentialsApp/settings.gradle | 2 + 20 files changed, 1639 insertions(+), 1 deletion(-) create mode 100644 DigitalCredentialsApp/app/build.gradle create mode 100644 DigitalCredentialsApp/app/src/main/AndroidManifest.xml create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt create mode 100644 DigitalCredentialsApp/app/src/main/res/values/strings.xml create mode 100644 DigitalCredentialsApp/build.gradle create mode 100644 DigitalCredentialsApp/gradle.properties create mode 100644 DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.jar create mode 100644 DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties create mode 100755 DigitalCredentialsApp/gradlew create mode 100644 DigitalCredentialsApp/gradlew.bat create mode 100644 DigitalCredentialsApp/settings.gradle diff --git a/.gitignore b/.gitignore index 07cd9300..9a80a987 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ proguard-project.txt # Android Studio/IDEA *.iml .idea +.kotlin \ No newline at end of file diff --git a/CredentialProvider/MyVault/gradle/libs.versions.toml b/CredentialProvider/MyVault/gradle/libs.versions.toml index f597b3ff..d368d6b7 100644 --- a/CredentialProvider/MyVault/gradle/libs.versions.toml +++ b/CredentialProvider/MyVault/gradle/libs.versions.toml @@ -9,7 +9,7 @@ espressoCore = "3.6.1" lifecycleRuntime = "2.8.7" activityCompose = "1.10.0" composeBom = "2025.02.00" -credentials = "1.6.0" +credentials = "1.7.0-alpha02" room = "2.7.2" biometrics = "1.2.0-alpha05" accompanist = "0.28.0" diff --git a/DigitalCredentialsApp/app/build.gradle b/DigitalCredentialsApp/app/build.gradle new file mode 100644 index 00000000..103f6268 --- /dev/null +++ b/DigitalCredentialsApp/app/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'org.jetbrains.kotlin.plugin.compose' +} + +android { + namespace 'com.example.digitalcredentialsapp' + compileSdk 35 + + defaultConfig { + applicationId "com.example.digitalcredentialsapp" + minSdk 26 + targetSdk 35 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } + buildFeatures { + compose true + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.7.0' + + // Compose + implementation platform('androidx.compose:compose-bom:2024.02.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.activity:activity-compose:1.8.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0' + + // Credential Manager + implementation("androidx.credentials:credentials:1.6.0-beta01") + implementation("androidx.credentials:credentials-play-services-auth:1.6.0-beta01") + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' +} diff --git a/DigitalCredentialsApp/app/src/main/AndroidManifest.xml b/DigitalCredentialsApp/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1ee2ab4d --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt new file mode 100644 index 00000000..782f0458 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainActivity.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MaterialTheme { + MainScreen() + } + } + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt new file mode 100644 index 00000000..46b43f90 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt @@ -0,0 +1,285 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp + +import android.app.Activity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.digitalcredentialsapp.data.CredentialManagerUtil + +/** + * Stateful entry point for the Main screen. + * + * This composable initializes the [MainViewModel] and provides credential retrieval + * logic to the ViewModel via suspend lambdas. + * + * @param modifier The modifier to be applied to the screen. + */ +@Composable +fun MainScreen( + modifier: Modifier = Modifier +) { + val viewModel: MainViewModel = viewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + MainContent( + uiState = uiState, + onGetDigitalCredentialClick = { + val activity = context as? Activity + if (activity != null) { + viewModel.getDigitalCredential { + CredentialManagerUtil.getDigitalCredential(activity) + } + } + }, + onGetVerifiedEmailClick = { + val activity = context as? Activity + if (activity != null) { + viewModel.getVerifiedEmailCredential { + CredentialManagerUtil.getVerifiedEmailCredential(activity) + } + } + }, + modifier = modifier + ) +} + +/** + * Stateless UI content for the Main screen. + * + * Displays the credential request actions and the resulting claims in a clean layout. + * + * @param uiState The current state of the UI. + * @param onGetDigitalCredentialClick Callback triggered when the Digital Credential button is clicked. + * @param onGetVerifiedEmailClick Callback triggered when the Verified Email button is clicked. + * @param modifier The modifier to be applied to the layout. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainContent( + uiState: MainUiState, + onGetDigitalCredentialClick: () -> Unit, + onGetVerifiedEmailClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + CenterAlignedTopAppBar( + title = { + Text( + text = stringResource(R.string.header_title), + style = MaterialTheme.typography.titleLarge + ) + }, + colors = TopAppBarDefaults.centerAlignedTopAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) + }, + modifier = modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Spacer(modifier = Modifier.weight(0.1f)) + + Button( + onClick = onGetDigitalCredentialClick, + enabled = uiState !is MainUiState.Loading, + modifier = Modifier + .fillMaxWidth(0.8f) + .padding(bottom = 8.dp) + ) { + Text(text = stringResource(R.string.get_digital_credential)) + } + + Button( + onClick = onGetVerifiedEmailClick, + enabled = uiState !is MainUiState.Loading, + modifier = Modifier.fillMaxWidth(0.8f) + ) { + Text(text = stringResource(R.string.get_verified_email)) + } + + Spacer(modifier = Modifier.height(32.dp)) + + ResultSection(uiState) + + Spacer(modifier = Modifier.weight(0.2f)) + } + } +} + +/** + * Renders the results of a credential request based on the current [MainUiState]. + * + * @param uiState The state to render. + */ +@Composable +fun ResultSection(uiState: MainUiState) { + when (uiState) { + is MainUiState.Initial -> { + Text( + text = stringResource(R.string.results_placeholder), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline + ) + } + is MainUiState.Loading -> { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.requesting_credential), + style = MaterialTheme.typography.bodyMedium + ) + } + } + is MainUiState.Success -> { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = uiState.title, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(bottom = 16.dp), + color = MaterialTheme.colorScheme.primary + ) + uiState.claims.forEach { claim -> + ClaimCard(claim) + Spacer(modifier = Modifier.height(8.dp)) + } + if (uiState.claims.isEmpty()) { + Text( + text = "No claims were extracted from the response. This may happen if the credential format is unrecognized.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + is MainUiState.Error -> { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = uiState.message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } +} + +/** + * Displays an individual [CredentialClaim] in a card. + * + * @param claim The claim to display. + */ +@Composable +fun ClaimCard(claim: CredentialClaim) { + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = claim.label, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f) + ) + Text( + text = claim.value, + style = MaterialTheme.typography.bodyLarge + ) + } + } +} + +@Preview(showBackground = true) +@Composable +fun MainScreenPreview() { + MaterialTheme { + MainContent( + uiState = MainUiState.Success( + title = "Driver's License", + claims = listOf( + CredentialClaim("Given Name", "John"), + CredentialClaim("Family Name", "Doe"), + CredentialClaim("Age Over 21", "Yes") + ) + ), + onGetDigitalCredentialClick = {}, + onGetVerifiedEmailClick = {} + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MainScreenLoadingPreview() { + MaterialTheme { + MainContent( + uiState = MainUiState.Loading, + onGetDigitalCredentialClick = {}, + onGetVerifiedEmailClick = {} + ) + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt new file mode 100644 index 00000000..63c22e27 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainUiState.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp + +/** + * Represents a single field or "claim" extracted from a digital credential. + * + * @property label The human-readable name of the field (e.g., "Given Name"). + * @property value The retrieved data for that field (e.g., "John"). + */ +data class CredentialClaim( + val label: String, + val value: String +) + +/** + * Sealed interface representing the various states of the Main screen UI. + */ +sealed interface MainUiState { + /** + * The initial state before any request has been made. + */ + data object Initial : MainUiState + + /** + * Represents an active credential retrieval request in progress. + */ + data object Loading : MainUiState + + /** + * Represents a successful credential retrieval. + * + * @property title The type of credential received (e.g., "Driver's License"). + * @property claims The list of individual fields extracted from the response. + */ + data class Success( + val title: String, + val claims: List + ) : MainUiState + + /** + * Represents a failure in the credential retrieval flow. + * + * @property message A human-readable error or status message (e.g., "Request cancelled"). + */ + data class Error(val message: String) : MainUiState +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt new file mode 100644 index 00000000..288c9deb --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainViewModel.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class MainViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(MainUiState.Initial) + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * Orchestrates a Digital Credential (e.g., mDL) request. + * + * @param requestBlock A suspend lambda that performs the retrieval and returns a [MainUiState]. + */ + fun getDigitalCredential(requestBlock: suspend () -> MainUiState) { + viewModelScope.launch { + _uiState.value = MainUiState.Loading + _uiState.value = requestBlock() + } + } + + /** + * Orchestrates a Verified Email credential request. + * + * @param requestBlock A suspend lambda that performs the retrieval and returns a [MainUiState]. + */ + fun getVerifiedEmailCredential(requestBlock: suspend () -> MainUiState) { + viewModelScope.launch { + _uiState.value = MainUiState.Loading + _uiState.value = requestBlock() + } + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt new file mode 100644 index 00000000..78b3a62e --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp.data + +import java.lang.IllegalArgumentException +import java.nio.ByteBuffer + +const val TYPE_UNSIGNED_INT = 0x00 +const val TYPE_NEGATIVE_INT = 0x01 +const val TYPE_BYTE_STRING = 0x02 +const val TYPE_TEXT_STRING = 0x03 +const val TYPE_ARRAY = 0x04 +const val TYPE_MAP = 0x05 +const val TYPE_TAG = 0x06 +const val TYPE_SIMPLE = 0x07 + +/** + * A utility class for encoding and decoding data in CBOR (Concise Binary Object Representation) format. + * + * This implementation is designed specifically for demonstration purposes when handling + * Mobile Driver's License (mDL) credential responses. + */ +class Cbor { + /** + * Encodes a supported object into a CBOR [ByteArray]. + * + * @param data The object to encode (Supports: Number, ByteArray, String, List, Map). + * @return The encoded CBOR byte array. + * @throws IllegalArgumentException if the data type is unsupported. + */ + fun encode(data: Any): ByteArray { + if (data is Number) { + return if (data is Double) { + throw IllegalArgumentException("Don't support doubles yet") + } else { + val value = data.toLong() + if (value >= 0) { + createArg(TYPE_UNSIGNED_INT, value) + } else { + createArg(TYPE_NEGATIVE_INT, -1 - value) + } + } + } + if (data is ByteArray) { + return createArg(TYPE_BYTE_STRING, data.size.toLong()) + data + } + if (data is String) { + return createArg(TYPE_TEXT_STRING, data.length.toLong()) + data.encodeToByteArray() + } + if (data is List<*>) { + var ret = createArg(TYPE_ARRAY, data.size.toLong()) + for (i in data) { + ret += encode(i!!) + } + return ret + } + if (data is Map<*, *>) { + var ret = createArg(TYPE_MAP, data.size.toLong()) + for (i in data) { + ret += encode(i.key!!) + ret += encode(i.value!!) + } + return ret + } + throw IllegalArgumentException("Bad type") + } + + /** + * Helper to create the head/argument of a CBOR item. + */ + private fun createArg(type: Int, arg: Long): ByteArray { + val t = type shl 5 + val a = arg.toInt() + if (arg < 24) { + return byteArrayOf(((t or a) and 0xFF).toByte()) + } + if (arg <= 0xFF) { + return byteArrayOf( + ((t or 24) and 0xFF).toByte(), + (a and 0xFF).toByte(), + ) + } + if (arg <= 0xFFFF) { + return byteArrayOf( + ((t or 25) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte(), + ) + } + if (arg <= 0xFFFFFFFF) { + return byteArrayOf( + ((t or 26) and 0xFF).toByte(), + ((a shr 24) and 0xFF).toByte(), + ((a shr 16) and 0xFF).toByte(), + ((a shr 8) and 0xFF).toByte(), + (a and 0xFF).toByte(), + ) + } + throw IllegalArgumentException("bad Arg") + } + + /** + * A decoder for CBOR-encoded data. + * + * @property buffer The [ByteBuffer] containing the CBOR data. + */ + class Decoder(private val buffer: ByteBuffer) { + constructor(bytes: ByteArray) : this(ByteBuffer.wrap(bytes)) + + /** + * Decodes the next item in the CBOR stream. + * + * @return The decoded object, or null if the buffer is empty. + * @throws IllegalArgumentException for unsupported major types or malformed data. + */ + 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() + repeat(size) { + list.add(decodeNext()) + } + list + } + TYPE_MAP -> { + val size = readLength(additionalInfo).toInt() + val map = mutableMapOf() + 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") + } + } + + private fun readLength(info: Int): Long { + return when (info) { + in 0..23 -> info.toLong() + 24 -> buffer.get().toLong() and 0xFF + 25 -> buffer.short.toLong() and 0xFFFF + 26 -> buffer.int.toLong() and 0xFFFFFFFFL + 27 -> buffer.long + else -> throw IllegalArgumentException("Invalid length info: $info") + } + } + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt new file mode 100644 index 00000000..12b7ddbb --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt @@ -0,0 +1,401 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp.data + +import android.app.Activity +import android.util.Base64 +import android.util.Log +import androidx.credentials.CredentialManager +import androidx.credentials.DigitalCredential +import androidx.credentials.ExperimentalDigitalCredentialApi +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetDigitalCredentialOption +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialCustomException +import androidx.credentials.exceptions.GetCredentialUnknownException +import androidx.credentials.exceptions.NoCredentialException +import com.example.digitalcredentialsapp.CredentialClaim +import com.example.digitalcredentialsapp.MainUiState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.security.SecureRandom + +/** + * Utility object for interacting with the Android Credential Manager API. + * + * This object handles the creation of credential requests and the parsing of responses + * for both Mobile Driver's Licenses (mDL) and Verified Emails. + */ +object CredentialManagerUtil { + + /** + * Initiates a request for a Digital Credential (e.g., mDL). + * + * @param activity The activity context used to display the Credential Manager UI. + * @return The resulting [MainUiState]. + */ + suspend fun getDigitalCredential(activity: Activity): MainUiState { + val requestJson = simulateMdlServerResponse() + return requestCredential(activity, requestJson) + } + + /** + * Initiates a request for a Verified Email credential. + * + * @param activity The activity context used to display the Credential Manager UI. + * @return The resulting [MainUiState]. + */ + suspend fun getVerifiedEmailCredential(activity: Activity): MainUiState { + val requestJson = simulateEmailServerResponse() + return requestCredential(activity, requestJson) + } + + /** + * Simulates a server-side request generation for an mDL. + */ + private suspend fun simulateMdlServerResponse(): String = withContext(Dispatchers.IO) { + Requests.getOpenId4VpDigitalCredentialRequest( + nonce = generateSecureRandomNonce(), + format = "mso_mdoc", + metaKey = "doctype_value", + metaValue = "org.iso.18013.5.1.mDL", + requestedClaims = listOf( + RequestedClaim("family_name", listOf("org.iso.18013.5.1", "family_name")), + RequestedClaim("given_name", listOf("org.iso.18013.5.1", "given_name")), + RequestedClaim("age_over_21", listOf("org.iso.18013.5.1", "age_over_21")) + ) + ) + } + + /** + * Simulates a server-side request generation for a Verified Email. + */ + private suspend fun simulateEmailServerResponse(): String = withContext(Dispatchers.IO) { + Requests.getOpenId4VpDigitalCredentialRequest( + nonce = generateSecureRandomNonce(), + format = "dc+sd-jwt", + metaKey = "vct_values", + metaValue = "UserInfoCredential", + requestedClaims = listOf( + RequestedClaim("email", listOf("email")), + RequestedClaim("email_verified", listOf("email_verified")) + ) + ) + } + + /** + * Internal helper to execute a credential request and handle common error states. + */ + @OptIn(ExperimentalDigitalCredentialApi::class) + private suspend fun requestCredential( + activity: Activity, + requestJson: String + ): MainUiState = withContext(Dispatchers.IO) { + try { + val credentialManager = CredentialManager.create(activity) + + val getDigitalCredentialOption = GetDigitalCredentialOption(requestJson = requestJson) + val request = GetCredentialRequest(listOf(getDigitalCredentialOption)) + + val result = credentialManager.getCredential(activity, request) + verifyResult(result) + } catch (e: GetCredentialException) { + handleFailure(e) + } catch (e: Exception) { + Log.e("CredentialManagerUtil", "Unexpected error", e) + MainUiState.Error(e.message ?: "An unknown error occurred") + } + } + + /** + * Handles the successfully returned credential. + */ + @OptIn(ExperimentalDigitalCredentialApi::class) + private fun verifyResult(result: GetCredentialResponse): MainUiState { + val credential = result.credential + return when (credential) { + is DigitalCredential -> { + val responseJson = credential.credentialJson + validateResponseOnServer(responseJson) + val claims = parseClaims(responseJson) + MainUiState.Success( + title = "Credential Received", + claims = claims + ) + } + else -> { + Log.e("CredentialManagerUtil", "Unexpected type of credential ${credential.type}") + MainUiState.Error("Unexpected credential type: ${credential.type}") + } + } + } + + /** + * Handles credential request failures. + */ + private fun handleFailure(e: GetCredentialException): MainUiState { + return when (e) { + is GetCredentialCancellationException -> { + // The user intentionally canceled the operation and chose not + // to share the credential. + MainUiState.Error("Request was cancelled") + } + is GetCredentialInterruptedException -> { + // Retry-able error. Consider retrying the call. + MainUiState.Error("Request was interrupted. Please try again.") + } + is NoCredentialException -> { + // No credential was available. + MainUiState.Error("No matching credentials found") + } + is GetCredentialUnknownException -> { + // An unknown, usually unexpected, error has occurred. Check the + // message error for any additional debugging information. + MainUiState.Error("An unknown error occurred: ${e.message}") + } + is GetCredentialCustomException -> { + // You have encountered a custom error thrown by the wallet. + MainUiState.Error("A custom error occurred: ${e.type}") + } + else -> { + Log.w("CredentialManagerUtil", "Unexpected exception type ${e::class.java}") + MainUiState.Error(e.message ?: "An unknown error occurred") + } + } + } + + /** + * Simulates sending the response to a backend server for validation. + * + * @param responseJson The raw JSON response from the Digital Credentials API. + */ + private fun validateResponseOnServer(responseJson: String) { + Log.d("CredentialManagerUtil", "Response validated on (simulated) server") + } + + /** + * Generates a cryptographically secure nonce for the request. + */ + private fun generateSecureRandomNonce(): String { + val sr = SecureRandom() + val nonceBytes = ByteArray(32) + sr.nextBytes(nonceBytes) + return Base64.encodeToString(nonceBytes, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + } + + /** + * Parses the raw JSON response from the Digital Credentials API into a list of [CredentialClaim]s. + * + * This implementation robustly handles OpenID4VP responses by utilizing the + * `presentation_submission` metadata to identify and extract tokens from the `vp_token`. + */ + private fun parseClaims(responseJsonString: String): List { + val claims = mutableListOf() + try { + val responseJson = JSONObject(responseJsonString) + val data = responseJson.optJSONObject("data") ?: return emptyList() + + // Extract Presentation Submission metadata + val submissionJson = data.optJSONObject("presentation_submission") + val submission = submissionJson?.let { parsePresentationSubmission(it) } + + val vpToken = data.opt("vp_token") ?: return emptyList() + + if (submission != null) { + // Robust parsing using standardized descriptor maps + for (descriptor in submission.descriptor_map) { + val rawToken = extractTokenByDescriptor(vpToken, descriptor) ?: continue + when (descriptor.format) { + "mso_mdoc" -> claims.addAll(parseMdocClaims(rawToken)) + "dc+sd-jwt", "vc+sd-jwt" -> claims.addAll(parseSdJwtClaims(rawToken)) + else -> Log.w("CredentialManagerUtil", "Unsupported format: ${descriptor.format}") + } + } + } else { + // Fallback for cases where presentation_submission is missing (some DCQL responses) + handleLegacyParsing(vpToken, claims) + } + } catch (e: Exception) { + Log.e("CredentialManagerUtil", "Failed to parse claims", e) + } + return claims + } + + /** + * Parses a [PresentationSubmission] from its JSON representation. + */ + private fun parsePresentationSubmission(json: JSONObject): PresentationSubmission { + val descriptorMap = mutableListOf() + val descriptors = json.optJSONArray("descriptor_map") + if (descriptors != null) { + for (i in 0 until descriptors.length()) { + val desc = descriptors.getJSONObject(i) + descriptorMap.add( + DescriptorMapping( + id = desc.getString("id"), + format = desc.getString("format"), + path = desc.getString("path") + ) + ) + } + } + return PresentationSubmission( + id = json.getString("id"), + definition_id = json.getString("definition_id"), + descriptor_map = descriptorMap + ) + } + + /** + * Extracts a raw token (Base64 mDoc or SD-JWT string) from the vp_token based on a descriptor's path. + */ + private fun extractTokenByDescriptor(vpToken: Any, descriptor: DescriptorMapping): String? { + return try { + if (vpToken is JSONObject) { + // Handle cases where vp_token is an object with credential IDs as keys (typical for DCQL) + val token = vpToken.opt(descriptor.id) + if (token is JSONArray && token.length() > 0) { + token.getString(0) + } else if (token is String) { + token + } else { + null + } + } else if (vpToken is JSONArray && vpToken.length() > 0) { + // Handle standard array vp_tokens + vpToken.getString(0) + } else if (vpToken is String) { + vpToken + } else { + null + } + } catch (e: Exception) { + null + } + } + + /** + * Fallback parsing logic for responses that don't include formal presentation_submission metadata. + */ + private fun handleLegacyParsing(vpToken: Any, claims: MutableList) { + 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) + } + } + + /** + * Specifically parses claims from an mDoc (ISO 18013-5) binary response. + */ + private fun parseMdocClaims(base64Mdoc: String): List { + val claims = mutableListOf() + try { + val bytes = Base64.decode(base64Mdoc, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) + val decoded = Cbor.Decoder(bytes).decodeNext() as? Map<*, *> ?: return emptyList() + + val documents = decoded["documents"] as? List<*> ?: return emptyList() + for (doc in documents) { + val docMap = doc as? Map<*, *> ?: continue + val issuerSigned = docMap["issuerSigned"] as? Map<*, *> ?: continue + val nameSpaces = issuerSigned["nameSpaces"] as? Map<*, *> ?: continue + + val mdlNamespace = nameSpaces["org.iso.18013.5.1"] + + if (mdlNamespace is List<*>) { + for (item in mdlNamespace) { + (item as? Map<*, *>)?.let { extractClaim(it, claims) } + } + } else if (mdlNamespace is Map<*, *>) { + mdlNamespace.forEach { (k, v) -> + claims.add(CredentialClaim(formatLabel(k.toString()), v.toString())) + } + } + } + } catch (e: Exception) { + Log.e("CredentialManagerUtil", "Failed to parse mDoc", e) + } + return claims + } + + /** + * Extracts an individual claim from an IssuerSignedItem map. + */ + private fun extractClaim(itemMap: Map<*, *>, claims: MutableList) { + val key = itemMap["elementIdentifier"] as? String ?: return + val value = itemMap["elementValue"] ?: return + + val formattedValue = when (value) { + is Boolean -> if (value) "Yes" else "No" + else -> value.toString() + } + claims.add(CredentialClaim(formatLabel(key), formattedValue)) + } + + /** + * Parses claims from an SD-JWT (Selective Disclosure JWT) format. + */ + private fun parseSdJwtClaims(sdJwt: String): List { + val claims = mutableListOf() + val parts = sdJwt.split("~") + for (i in 1 until parts.size - 1) { + val part = parts[i] + if (part.isNotEmpty()) { + try { + val decodedBytes = Base64.decode(part, Base64.URL_SAFE or Base64.NO_WRAP) + val jsonArray = JSONArray(String(decodedBytes)) + if (jsonArray.length() == 3) { + val key = jsonArray.getString(1) + val value = jsonArray.get(2).toString() + claims.add(CredentialClaim(formatLabel(key), value)) + } + } catch (e: Exception) { + // Skip invalid parts + } + } + } + return claims + } + + /** + * Formats a raw identifier key into a human-readable label. + */ + private fun formatLabel(key: String): String { + return key.replace("_", " ") + .split(" ") + .joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } + } +} diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt new file mode 100644 index 00000000..4194599b --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp.data + +/** + * Represents the OpenID4VP Presentation Submission metadata. + * + * This structure follows the DIF Presentation Exchange (PE) specification, + * identifying which requested credentials were provided and where they reside. + * + * @property id Unique identifier for this submission. + * @property definition_id The ID of the Presentation Definition this submission satisfies. + * @property descriptor_map A list of mappings from credential IDs to their location in the token. + */ +data class PresentationSubmission( + val id: String, + val definition_id: String, + val descriptor_map: List +) + +/** + * Maps a specific credential in the response to its format and path. + * + * @property id The identifier of the input descriptor from the request. + * @property format The format of the credential (e.g., "mso_mdoc" or "dc+sd-jwt"). + * @property path A JSONPath pointing to the credential within the vp_token. + */ +data class DescriptorMapping( + val id: String, + val format: String, + val path: String +) diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt new file mode 100644 index 00000000..54f5582c --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp.data + +/** + * Represents a claim being requested in a Digital Credential query. + * + * @property id A unique identifier for this claim within the query. + * @property path The path to the claim in the credential's namespace (e.g., ["org.iso.18013.5.1", "family_name"]). + */ +data class RequestedClaim( + val id: String, + val path: List +) + +/** + * Represents JSON request strings for the Digital Credentials API. + * + * This object follows the OpenID4VP specifications to request credentials from digital wallets. + */ +object Requests { + + /** + * Generates a dynamic OpenID4VP JSON request for any digital credential. + * + * @param nonce A cryptographically secure random string to prevent replay attacks. + * @param format The credential format (e.g., "mso_mdoc" or "dc+sd-jwt"). + * @param metaKey The key for metadata filtering (e.g., "doctype_value" or "vct_values"). + * @param metaValue The value for metadata filtering (e.g., "org.iso.18013.5.1.mDL"). + * @param requestedClaims A list of specific claims to request from the credential. + * @return The formatted OpenID4VP JSON request string. + */ + fun getOpenId4VpDigitalCredentialRequest( + nonce: String, + format: String, + metaKey: String, + metaValue: String, + requestedClaims: List + ): String { + val claimsJson = requestedClaims.joinToString(",") { claim -> + """{"id": "${claim.id}", "path": ${claim.path.map { "\"$it\"" }}}""" + } + + return """ + { + "requests": [ + { + "protocol": "openid4vp-v1-unsigned", + "data": { + "response_type": "vp_token", + "response_mode": "dc_api", + "nonce": "$nonce", + "dcql_query": { + "credentials": [ + { + "id": "digital_credential_query", + "format": "$format", + "meta": { + "$metaKey": ["$metaValue"] + }, + "claims": [$claimsJson] + } + ] + } + } + } + ] + } + """.trimIndent() + } +} diff --git a/DigitalCredentialsApp/app/src/main/res/values/strings.xml b/DigitalCredentialsApp/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..efb54db1 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/res/values/strings.xml @@ -0,0 +1,8 @@ + + Digital Credentials App + Digital Credentials API sample app + Get Digital Credential from Wallets + Get Verified Email from Device + Results will appear here + Requesting credential... + diff --git a/DigitalCredentialsApp/build.gradle b/DigitalCredentialsApp/build.gradle new file mode 100644 index 00000000..1bae234a --- /dev/null +++ b/DigitalCredentialsApp/build.gradle @@ -0,0 +1,22 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.6.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0" + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.0" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/DigitalCredentialsApp/gradle.properties b/DigitalCredentialsApp/gradle.properties new file mode 100644 index 00000000..5bac8ac5 --- /dev/null +++ b/DigitalCredentialsApp/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.jar b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..8bdaf60c75ab801e22807dde59e12a8735a34077 GIT binary patch literal 45457 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 c82=K%QXLKFx%@O{wJjEi6Y56o#$)Bpeg literal 0 HcmV?d00001 diff --git a/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..2a84e188 --- /dev/null +++ b/DigitalCredentialsApp/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/DigitalCredentialsApp/gradlew b/DigitalCredentialsApp/gradlew new file mode 100755 index 00000000..ef07e016 --- /dev/null +++ b/DigitalCredentialsApp/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/DigitalCredentialsApp/gradlew.bat b/DigitalCredentialsApp/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/DigitalCredentialsApp/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/DigitalCredentialsApp/settings.gradle b/DigitalCredentialsApp/settings.gradle new file mode 100644 index 00000000..28ae021b --- /dev/null +++ b/DigitalCredentialsApp/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "DigitalCredentialsApp" +include ':app' From 8b336abcf80c8b63edba0ddf0321b1ce4f376b28 Mon Sep 17 00:00:00 2001 From: Chiping Yeh Date: Tue, 26 May 2026 16:32:21 -0400 Subject: [PATCH 2/6] Resolve Gemini comments and fix bugs --- .../app/src/main/AndroidManifest.xml | 5 +- .../{data => }/CredentialManagerUtil.kt | 102 +++++++++--------- .../digitalcredentialsapp/Extensions.kt | 27 +++++ .../digitalcredentialsapp/MainScreen.kt | 11 +- .../digitalcredentialsapp/data/Requests.kt | 27 +++-- .../app/src/main/res/values/strings.xml | 1 + 6 files changed, 101 insertions(+), 72 deletions(-) rename DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/{data => }/CredentialManagerUtil.kt (87%) create mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt diff --git a/DigitalCredentialsApp/app/src/main/AndroidManifest.xml b/DigitalCredentialsApp/app/src/main/AndroidManifest.xml index 1ee2ab4d..fe99027e 100644 --- a/DigitalCredentialsApp/app/src/main/AndroidManifest.xml +++ b/DigitalCredentialsApp/app/src/main/AndroidManifest.xml @@ -5,11 +5,10 @@ android:allowBackup="true" android:label="Digital Credentials Demo" android:supportsRtl="true" - android:theme="@style/Theme.AppCompat.Light.DarkActionBar"> + android:theme="@android:style/Theme.DeviceDefault.NoActionBar"> + android:exported="true"> diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt similarity index 87% rename from DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt rename to DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt index 12b7ddbb..54eb717f 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/CredentialManagerUtil.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt @@ -1,20 +1,4 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.digitalcredentialsapp.data +package com.example.digitalcredentialsapp import android.app.Activity import android.util.Base64 @@ -23,21 +7,25 @@ import androidx.credentials.CredentialManager import androidx.credentials.DigitalCredential import androidx.credentials.ExperimentalDigitalCredentialApi import androidx.credentials.GetCredentialRequest -import androidx.credentials.GetDigitalCredentialOption import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetDigitalCredentialOption import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialCustomException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialInterruptedException -import androidx.credentials.exceptions.GetCredentialCustomException import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException -import com.example.digitalcredentialsapp.CredentialClaim -import com.example.digitalcredentialsapp.MainUiState +import com.example.digitalcredentialsapp.data.Cbor +import com.example.digitalcredentialsapp.data.DescriptorMapping +import com.example.digitalcredentialsapp.data.PresentationSubmission +import com.example.digitalcredentialsapp.data.RequestedClaim +import com.example.digitalcredentialsapp.data.Requests import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import java.security.SecureRandom +import kotlin.collections.get /** * Utility object for interacting with the Android Credential Manager API. @@ -75,13 +63,24 @@ object CredentialManagerUtil { private suspend fun simulateMdlServerResponse(): String = withContext(Dispatchers.IO) { Requests.getOpenId4VpDigitalCredentialRequest( nonce = generateSecureRandomNonce(), + protocol = "openid4vp-v1-unsigned", + clientId = null, + clientMetadata = """ + { + "vp_formats_supported": { + "mso_mdoc": { + "deviceauth_alg_values": [-7], + "issuerauth_alg_values": [-7] + } + } + } + """.trimIndent(), format = "mso_mdoc", - metaKey = "doctype_value", - metaValue = "org.iso.18013.5.1.mDL", + meta = """{"doctype_value": "org.iso.18013.5.1.mDL"}""", requestedClaims = listOf( - RequestedClaim("family_name", listOf("org.iso.18013.5.1", "family_name")), - RequestedClaim("given_name", listOf("org.iso.18013.5.1", "given_name")), - RequestedClaim("age_over_21", listOf("org.iso.18013.5.1", "age_over_21")) + RequestedClaim("", listOf("org.iso.18013.5.1", "family_name")), + RequestedClaim("", listOf("org.iso.18013.5.1", "given_name")), + RequestedClaim("", listOf("org.iso.18013.5.1", "age_over_21")) ) ) } @@ -92,9 +91,11 @@ object CredentialManagerUtil { private suspend fun simulateEmailServerResponse(): String = withContext(Dispatchers.IO) { Requests.getOpenId4VpDigitalCredentialRequest( nonce = generateSecureRandomNonce(), + protocol = "openid4vp-v1-unsigned", + clientId = null, + clientMetadata = null, format = "dc+sd-jwt", - metaKey = "vct_values", - metaValue = "UserInfoCredential", + meta = """{"vct_values": ["UserInfoCredential"]}""", requestedClaims = listOf( RequestedClaim("email", listOf("email")), RequestedClaim("email_verified", listOf("email_verified")) @@ -111,7 +112,7 @@ object CredentialManagerUtil { requestJson: String ): MainUiState = withContext(Dispatchers.IO) { try { - val credentialManager = CredentialManager.create(activity) + val credentialManager = CredentialManager.Companion.create(activity) val getDigitalCredentialOption = GetDigitalCredentialOption(requestJson = requestJson) val request = GetCredentialRequest(listOf(getDigitalCredentialOption)) @@ -212,13 +213,14 @@ object CredentialManagerUtil { val claims = mutableListOf() try { val responseJson = JSONObject(responseJsonString) - val data = responseJson.optJSONObject("data") ?: return emptyList() - // Extract Presentation Submission metadata + // Check for 'data' wrapper or flat structure as per documentation + val data = responseJson.optJSONObject("data") ?: responseJson + val vpToken = data.opt("vp_token") ?: return emptyList() + + // Extract Presentation Submission metadata if available val submissionJson = data.optJSONObject("presentation_submission") val submission = submissionJson?.let { parsePresentationSubmission(it) } - - val vpToken = data.opt("vp_token") ?: return emptyList() if (submission != null) { // Robust parsing using standardized descriptor maps @@ -231,7 +233,7 @@ object CredentialManagerUtil { } } } else { - // Fallback for cases where presentation_submission is missing (some DCQL responses) + // Fallback for cases where presentation_submission is missing handleLegacyParsing(vpToken, claims) } } catch (e: Exception) { @@ -241,7 +243,7 @@ object CredentialManagerUtil { } /** - * Parses a [PresentationSubmission] from its JSON representation. + * Parses a [com.example.digitalcredentialsapp.data.PresentationSubmission] from its JSON representation. */ private fun parsePresentationSubmission(json: JSONObject): PresentationSubmission { val descriptorMap = mutableListOf() @@ -272,22 +274,16 @@ object CredentialManagerUtil { return try { if (vpToken is JSONObject) { // Handle cases where vp_token is an object with credential IDs as keys (typical for DCQL) - val token = vpToken.opt(descriptor.id) - if (token is JSONArray && token.length() > 0) { - token.getString(0) - } else if (token is String) { - token - } else { - null + val token = vpToken.opt(descriptor.id) ?: vpToken.opt("digital_credential_query") + when (token) { + is JSONArray -> if (token.length() > 0) token.getString(0) else null + is String -> token + else -> null } } else if (vpToken is JSONArray && vpToken.length() > 0) { // Handle standard array vp_tokens vpToken.getString(0) - } else if (vpToken is String) { - vpToken - } else { - null - } + } else vpToken as? String } catch (e: Exception) { null } @@ -326,15 +322,15 @@ object CredentialManagerUtil { try { val bytes = Base64.decode(base64Mdoc, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) val decoded = Cbor.Decoder(bytes).decodeNext() as? Map<*, *> ?: return emptyList() - + val documents = decoded["documents"] as? List<*> ?: return emptyList() for (doc in documents) { val docMap = doc as? Map<*, *> ?: continue val issuerSigned = docMap["issuerSigned"] as? Map<*, *> ?: continue val nameSpaces = issuerSigned["nameSpaces"] as? Map<*, *> ?: continue - + val mdlNamespace = nameSpaces["org.iso.18013.5.1"] - + if (mdlNamespace is List<*>) { for (item in mdlNamespace) { (item as? Map<*, *>)?.let { extractClaim(it, claims) } @@ -357,7 +353,7 @@ object CredentialManagerUtil { private fun extractClaim(itemMap: Map<*, *>, claims: MutableList) { val key = itemMap["elementIdentifier"] as? String ?: return val value = itemMap["elementValue"] ?: return - + val formattedValue = when (value) { is Boolean -> if (value) "Yes" else "No" else -> value.toString() @@ -371,7 +367,7 @@ object CredentialManagerUtil { private fun parseSdJwtClaims(sdJwt: String): List { val claims = mutableListOf() val parts = sdJwt.split("~") - for (i in 1 until parts.size - 1) { + for (i in 1 until parts.size) { val part = parts[i] if (part.isNotEmpty()) { try { @@ -398,4 +394,4 @@ object CredentialManagerUtil { .split(" ") .joinToString(" ") { it.replaceFirstChar { char -> char.uppercase() } } } -} +} \ No newline at end of file diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt new file mode 100644 index 00000000..6f0c1016 --- /dev/null +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/Extensions.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.digitalcredentialsapp + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} \ No newline at end of file diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt index 46b43f90..a1e92622 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/MainScreen.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.digitalcredentialsapp.data.CredentialManagerUtil /** * Stateful entry point for the Main screen. @@ -68,7 +67,7 @@ fun MainScreen( MainContent( uiState = uiState, onGetDigitalCredentialClick = { - val activity = context as? Activity + val activity = context.findActivity() if (activity != null) { viewModel.getDigitalCredential { CredentialManagerUtil.getDigitalCredential(activity) @@ -76,7 +75,7 @@ fun MainScreen( } }, onGetVerifiedEmailClick = { - val activity = context as? Activity + val activity = context.findActivity() if (activity != null) { viewModel.getVerifiedEmailCredential { CredentialManagerUtil.getVerifiedEmailCredential(activity) @@ -130,7 +129,7 @@ fun MainContent( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - Spacer(modifier = Modifier.weight(0.1f)) + Spacer(modifier = Modifier.height(24.dp)) Button( onClick = onGetDigitalCredentialClick, @@ -154,7 +153,7 @@ fun MainContent( ResultSection(uiState) - Spacer(modifier = Modifier.weight(0.2f)) + Spacer(modifier = Modifier.height(24.dp)) } } } @@ -198,7 +197,7 @@ fun ResultSection(uiState: MainUiState) { } if (uiState.claims.isEmpty()) { Text( - text = "No claims were extracted from the response. This may happen if the credential format is unrecognized.", + text = stringResource(R.string.no_claims_extracted), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt index 54f5582c..5d6a9a1a 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt @@ -38,40 +38,47 @@ object Requests { * Generates a dynamic OpenID4VP JSON request for any digital credential. * * @param nonce A cryptographically secure random string to prevent replay attacks. + * @param protocol The protocol identifier (e.g., "openid4vp-v1" or "openid4vp-v1-unsigned"). + * @param clientId The unique identifier for the verifier (optional). + * @param clientMetadata Optional JSON string for client_metadata (e.g., supported algorithms). * @param format The credential format (e.g., "mso_mdoc" or "dc+sd-jwt"). - * @param metaKey The key for metadata filtering (e.g., "doctype_value" or "vct_values"). - * @param metaValue The value for metadata filtering (e.g., "org.iso.18013.5.1.mDL"). + * @param meta The metadata object for filtering (passed as a raw JSON string). * @param requestedClaims A list of specific claims to request from the credential. * @return The formatted OpenID4VP JSON request string. */ fun getOpenId4VpDigitalCredentialRequest( nonce: String, + protocol: String, + clientId: String?, + clientMetadata: String?, format: String, - metaKey: String, - metaValue: String, + meta: String, requestedClaims: List ): String { val claimsJson = requestedClaims.joinToString(",") { claim -> - """{"id": "${claim.id}", "path": ${claim.path.map { "\"$it\"" }}}""" + """{"path": ${claim.path.map { "\"$it\"" }}}""" } + val clientIdJson = if (clientId != null) """ "client_id": "$clientId", """ else "" + val clientMetadataJson = if (clientMetadata != null) """ "client_metadata": $clientMetadata, """ else "" + return """ { "requests": [ { - "protocol": "openid4vp-v1-unsigned", + "protocol": "$protocol", "data": { "response_type": "vp_token", "response_mode": "dc_api", + $clientIdJson + $clientMetadataJson "nonce": "$nonce", "dcql_query": { "credentials": [ { - "id": "digital_credential_query", + "id": "cred1", "format": "$format", - "meta": { - "$metaKey": ["$metaValue"] - }, + "meta": $meta, "claims": [$claimsJson] } ] diff --git a/DigitalCredentialsApp/app/src/main/res/values/strings.xml b/DigitalCredentialsApp/app/src/main/res/values/strings.xml index efb54db1..3bd67aaa 100644 --- a/DigitalCredentialsApp/app/src/main/res/values/strings.xml +++ b/DigitalCredentialsApp/app/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ Get Verified Email from Device Results will appear here Requesting credential... + No claims were extracted from the response. This may happen if the credential format is unrecognized. From 3f3f6102402d191e45effda4c1b5d03eb61bc588 Mon Sep 17 00:00:00 2001 From: Chiping Yeh Date: Tue, 26 May 2026 16:55:04 -0400 Subject: [PATCH 3/6] Replace Cbor file with original and refactor --- .../CredentialManagerUtil.kt | 19 +- .../digitalcredentialsapp/data/Cbor.kt | 284 ++++++++++-------- 2 files changed, 175 insertions(+), 128 deletions(-) diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt index 54eb717f..24f7bced 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt @@ -15,7 +15,8 @@ import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.GetCredentialInterruptedException import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException -import com.example.digitalcredentialsapp.data.Cbor +import com.example.digitalcredentialsapp.data.CborTag +import com.example.digitalcredentialsapp.data.cborDecode import com.example.digitalcredentialsapp.data.DescriptorMapping import com.example.digitalcredentialsapp.data.PresentationSubmission import com.example.digitalcredentialsapp.data.RequestedClaim @@ -321,7 +322,7 @@ object CredentialManagerUtil { val claims = mutableListOf() try { val bytes = Base64.decode(base64Mdoc, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) - val decoded = Cbor.Decoder(bytes).decodeNext() as? Map<*, *> ?: return emptyList() + val decoded = cborDecode(bytes) as? Map<*, *> ?: return emptyList() val documents = decoded["documents"] as? List<*> ?: return emptyList() for (doc in documents) { @@ -333,11 +334,21 @@ object CredentialManagerUtil { if (mdlNamespace is List<*>) { for (item in mdlNamespace) { - (item as? Map<*, *>)?.let { extractClaim(it, claims) } + val decodedItem = if (item is CborTag && item.tag == 24L) { + cborDecode(item.item as ByteArray) + } else { + item + } + (decodedItem as? Map<*, *>)?.let { extractClaim(it, claims) } } } else if (mdlNamespace is Map<*, *>) { mdlNamespace.forEach { (k, v) -> - claims.add(CredentialClaim(formatLabel(k.toString()), v.toString())) + val value = if (v is CborTag && v.tag == 24L) { + cborDecode(v.item as ByteArray) + } else { + v + } + claims.add(CredentialClaim(formatLabel(k.toString()), value.toString())) } } } diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt index 78b3a62e..d2473812 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt @@ -16,42 +16,50 @@ package com.example.digitalcredentialsapp.data -import java.lang.IllegalArgumentException -import java.nio.ByteBuffer - -const val TYPE_UNSIGNED_INT = 0x00 -const val TYPE_NEGATIVE_INT = 0x01 -const val TYPE_BYTE_STRING = 0x02 -const val TYPE_TEXT_STRING = 0x03 -const val TYPE_ARRAY = 0x04 -const val TYPE_MAP = 0x05 -const val TYPE_TAG = 0x06 -const val TYPE_SIMPLE = 0x07 - -/** - * A utility class for encoding and decoding data in CBOR (Concise Binary Object Representation) format. - * - * This implementation is designed specifically for demonstration purposes when handling - * Mobile Driver's License (mDL) credential responses. - */ +data class CborTag( + val tag: Long, + val item: Any? +) + +fun cborDecode(data: ByteArray): Any? { + return Cbor().decode(data) +} + +fun cborEncode(data: Any?): ByteArray { + return Cbor().encode(data) +} + class Cbor { - /** - * Encodes a supported object into a CBOR [ByteArray]. - * - * @param data The object to encode (Supports: Number, ByteArray, String, List, Map). - * @return The encoded CBOR byte array. - * @throws IllegalArgumentException if the data type is unsupported. - */ - fun encode(data: Any): ByteArray { + data class Item(val item: Any?, val len: Int, val type: Int) + data class Arg(val arg: Long, val len: Int) + + val TYPE_UNSIGNED_INT = 0x00 + val TYPE_NEGATIVE_INT = 0x01 + val TYPE_BYTE_STRING = 0x02 + val TYPE_TEXT_STRING = 0x03 + val TYPE_ARRAY = 0x04 + val TYPE_MAP = 0x05 + val TYPE_TAG = 0x06 + val TYPE_FLOAT = 0x07 + + fun decode(data: ByteArray): Any? { + val ret = parseItem(data, 0) + return ret.item + } + + fun encode(data: Any?): ByteArray { + if (data == null) { + return createArg(TYPE_FLOAT, 22) + } if (data is Number) { - return if (data is Double) { + if (data is Double) { throw IllegalArgumentException("Don't support doubles yet") } else { val value = data.toLong() if (value >= 0) { - createArg(TYPE_UNSIGNED_INT, value) + return createArg(TYPE_UNSIGNED_INT, value) } else { - createArg(TYPE_NEGATIVE_INT, -1 - value) + return createArg(TYPE_NEGATIVE_INT, -1 - value) } } } @@ -64,7 +72,7 @@ class Cbor { if (data is List<*>) { var ret = createArg(TYPE_ARRAY, data.size.toLong()) for (i in data) { - ret += encode(i!!) + ret += encode(i) } return ret } @@ -76,12 +84,128 @@ class Cbor { } return ret } + if (data is CborTag) { + var ret = createArg(TYPE_TAG, data.tag) + ret += encode(data.item) + return ret + } throw IllegalArgumentException("Bad type") } - /** - * Helper to create the head/argument of a CBOR item. - */ + private fun getType(data: ByteArray, offset: Int): Int { + val d = data[offset].toInt() + return (d and 0xFF) shr 5 + } + + private fun getArg(data: ByteArray, offset: Int): Arg { + val arg = data[offset].toLong() and 0x1F + if (arg < 24) { + return Arg(arg, 1) + } + if (arg == 24L) { + return Arg(data[offset + 1].toLong() and 0xFF, 2) + } + if (arg == 25L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 8 + ret = ret or (data[offset + 2].toLong() and 0xFF) + return Arg(ret, 3) + } + if (arg == 26L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 24 + ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 16) + ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 8) + ret = ret or (data[offset + 4].toLong() and 0xFF) + return Arg(ret, 5) + } + if (arg == 27L) { + var ret = (data[offset + 1].toLong() and 0xFF) shl 56 + ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 48) + ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 40) + ret = ret or ((data[offset + 4].toLong() and 0xFF) shl 32) + ret = ret or ((data[offset + 5].toLong() and 0xFF) shl 24) + ret = ret or ((data[offset + 6].toLong() and 0xFF) shl 16) + ret = ret or ((data[offset + 7].toLong() and 0xFF) shl 8) + ret = ret or (data[offset + 8].toLong() and 0xFF) + return Arg(ret, 9) + } + throw IllegalArgumentException("Bad arg $arg") + } + + private fun parseItem(data: ByteArray, offset: Int): Item { + val itemType = getType(data, offset) + val arg = getArg(data, offset) + + when (itemType) { + TYPE_UNSIGNED_INT -> { + return Item(arg.arg, arg.len, TYPE_UNSIGNED_INT) + } + + TYPE_NEGATIVE_INT -> { + return Item(-1 - arg.arg, arg.len, TYPE_NEGATIVE_INT) + } + + TYPE_BYTE_STRING -> { + val ret = + data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + return Item(ret, arg.len + arg.arg.toInt(), TYPE_BYTE_STRING) + } + + TYPE_TEXT_STRING -> { + val ret = + data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + return Item( + ret.toString(Charsets.UTF_8), arg.len + arg.arg.toInt(), + TYPE_TEXT_STRING + ) + } + + TYPE_ARRAY -> { + val ret = mutableListOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + val item = parseItem(data, offset + consumed) + ret.add(item.item) + consumed += item.len + } + return Item(ret.toList(), consumed, TYPE_ARRAY) + } + + TYPE_MAP -> { + val ret = mutableMapOf() + var consumed = arg.len + for (i in 0 until arg.arg.toInt()) { + val key = parseItem(data, offset + consumed) + consumed += key.len + val value = parseItem(data, offset + consumed) + consumed += value.len + ret[key.item] = value.item + } + return Item(ret.toMap(), consumed, TYPE_MAP) + } + + TYPE_TAG -> { + val tagItem = parseItem(data, offset + arg.len) + return Item(CborTag(arg.arg, tagItem.item), arg.len + tagItem.len, TYPE_TAG) + } + + TYPE_FLOAT -> { + if (arg.arg.toInt() == 22) { + return Item(null, arg.len, TYPE_FLOAT) + } else if (arg.arg.toInt() == 20) { + return Item(false, arg.len, TYPE_FLOAT) + } else if (arg.arg.toInt() == 21) { + return Item(true, arg.len, TYPE_FLOAT) + } else { + throw IllegalArgumentException("Bad float $arg") + } + } + + else -> { + throw IllegalArgumentException("Bad type") + } + } + } + private fun createArg(type: Int, arg: Long): ByteArray { val t = type shl 5 val a = arg.toInt() @@ -91,14 +215,14 @@ class Cbor { if (arg <= 0xFF) { return byteArrayOf( ((t or 24) and 0xFF).toByte(), - (a and 0xFF).toByte(), + (a and 0xFF).toByte() ) } if (arg <= 0xFFFF) { return byteArrayOf( ((t or 25) and 0xFF).toByte(), ((a shr 8) and 0xFF).toByte(), - (a and 0xFF).toByte(), + (a and 0xFF).toByte() ) } if (arg <= 0xFFFFFFFF) { @@ -107,97 +231,9 @@ class Cbor { ((a shr 24) and 0xFF).toByte(), ((a shr 16) and 0xFF).toByte(), ((a shr 8) and 0xFF).toByte(), - (a and 0xFF).toByte(), + (a and 0xFF).toByte() ) } throw IllegalArgumentException("bad Arg") } - - /** - * A decoder for CBOR-encoded data. - * - * @property buffer The [ByteBuffer] containing the CBOR data. - */ - class Decoder(private val buffer: ByteBuffer) { - constructor(bytes: ByteArray) : this(ByteBuffer.wrap(bytes)) - - /** - * Decodes the next item in the CBOR stream. - * - * @return The decoded object, or null if the buffer is empty. - * @throws IllegalArgumentException for unsupported major types or malformed data. - */ - 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() - repeat(size) { - list.add(decodeNext()) - } - list - } - TYPE_MAP -> { - val size = readLength(additionalInfo).toInt() - val map = mutableMapOf() - 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") - } - } - - private fun readLength(info: Int): Long { - return when (info) { - in 0..23 -> info.toLong() - 24 -> buffer.get().toLong() and 0xFF - 25 -> buffer.short.toLong() and 0xFFFF - 26 -> buffer.int.toLong() and 0xFFFFFFFFL - 27 -> buffer.long - else -> throw IllegalArgumentException("Invalid length info: $info") - } - } - } } From 051a7bc268aff2a670f087cfe0f0db860702f028 Mon Sep 17 00:00:00 2001 From: Chiping Yeh Date: Wed, 27 May 2026 13:15:46 -0400 Subject: [PATCH 4/6] Remove some logic redundancy and add README file --- DigitalCredentialsApp/README.md | 58 ++++++++ .../CredentialManagerUtil.kt | 124 +++++------------- .../digitalcredentialsapp/data/OpenId4Vp.kt | 46 ------- 3 files changed, 91 insertions(+), 137 deletions(-) create mode 100644 DigitalCredentialsApp/README.md delete mode 100644 DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt diff --git a/DigitalCredentialsApp/README.md b/DigitalCredentialsApp/README.md new file mode 100644 index 00000000..63939a56 --- /dev/null +++ b/DigitalCredentialsApp/README.md @@ -0,0 +1,58 @@ +# Digital Credentials Sample App + +This is a sample repository for the **Digital Credentials API** integration in Android, showcasing how to request and parse verifiable credentials using the Credential Manager. + +The Digital Credentials Sample App is a functional Android app built with Kotlin and Jetpack Compose. It is designed to help developers understand the workflow for retrieving and presenting digital credentials, such as Mobile Driver's Licenses (mDL) and Verified Emails, using the OpenID4VP and DCQL standards. + +## Features + +This sample app implements the following use cases: + +* **Get Digital Credential (mdoc)**: Request a Mobile Driver's License (mDL) or other ISO 18013-5 compliant credentials. +* **Get Verified Email**: Request a verified email address using the SD-JWT (Selective Disclosure JWT) format. +* **OpenID4VP & DCQL Support**: Demonstrates how to construct modern, simplified Digital Credential Query Language (DCQL) requests. +* **CBOR & SD-JWT Parsing**: Includes a reference implementation for decoding CBOR-based mDoc data and parsing selectively disclosed claims from SD-JWTs. + +## Requirements + +* Latest release of [Android Studio](https://developer.android.com/studio) +* Java 17 or higher +* A physical device or emulator running Android 9 (API level 28) or higher. +* A digital wallet app (like [CMWallet](https://github.com/digitalcredentialsdev/CMWallet)) installed and registered on the device to test the generic retrieval flow. This is not needed to test email verification functionality. + +## Typical Workflow + +* **Launch the app**: The main screen presents two primary actions for credential retrieval. +* **Request a Credential**: Tap on "Get Digital Credential (mdoc)" or "Get Verified Email". +* **Credential Selection**: The Credential Manager bottom sheet will appear, listing available credentials from registered wallets. +* **User Consent**: Once a credential is selected, the wallet app will handle user consent and authentication. +* **Result Display**: The app receives the response, validates it locally (simulated), parses the individual claims (e.g., Given Name, Email), and displays them in the UI. + +## Architecture & Design + +### Credential Manager Integration + +Credential Manager calls are centralized in `CredentialManagerUtil.kt`. The app uses `GetDigitalCredentialOption` to pass JSON requests formatted according to the OpenID4VP specification. + +### Data Requests + +The `Requests.kt` file contains the logic for generating dynamic JSON requests. It supports: +- **DCQL Query Structure**: Simplified claim requests using paths. +- **Client Metadata**: Specifies supported algorithms for secure credential exchange. + +### Parsing Logic + +Since digital credentials come in various formats, the app includes robust parsing utilities: +- **CBOR Decoding**: A dedicated `Cbor.kt` utility handles the binary mDoc format, including manual unwrapping of Tag 24 items. +- **SD-JWT Parsing**: Logic to extract claims from Selective Disclosure JWTs by decoding individual disclosures. + +### UI Layer + +Built with **Jetpack Compose**, the UI follows a simple MVI-like pattern: +- `MainScreen.kt`: Handles the UI layout and interaction events. +- `MainViewModel.kt`: Manages the state of the credential request (Initial, Loading, Success, or Error). +- `MainUiState.kt`: Defines the data model for the UI states and extracted claims. + +## License + +This sample is distributed under the terms of the Apache License (Version 2.0). See the [LICENSE](LICENSE) file for more information. diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt index 24f7bced..693ffc6b 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt @@ -17,8 +17,6 @@ import androidx.credentials.exceptions.GetCredentialUnknownException import androidx.credentials.exceptions.NoCredentialException import com.example.digitalcredentialsapp.data.CborTag import com.example.digitalcredentialsapp.data.cborDecode -import com.example.digitalcredentialsapp.data.DescriptorMapping -import com.example.digitalcredentialsapp.data.PresentationSubmission import com.example.digitalcredentialsapp.data.RequestedClaim import com.example.digitalcredentialsapp.data.Requests import kotlinx.coroutines.Dispatchers @@ -113,7 +111,7 @@ object CredentialManagerUtil { requestJson: String ): MainUiState = withContext(Dispatchers.IO) { try { - val credentialManager = CredentialManager.Companion.create(activity) + val credentialManager = CredentialManager.create(activity) val getDigitalCredentialOption = GetDigitalCredentialOption(requestJson = requestJson) val request = GetCredentialRequest(listOf(getDigitalCredentialOption)) @@ -133,8 +131,7 @@ object CredentialManagerUtil { */ @OptIn(ExperimentalDigitalCredentialApi::class) private fun verifyResult(result: GetCredentialResponse): MainUiState { - val credential = result.credential - return when (credential) { + return when (val credential = result.credential) { is DigitalCredential -> { val responseJson = credential.credentialJson validateResponseOnServer(responseJson) @@ -207,35 +204,41 @@ object CredentialManagerUtil { /** * Parses the raw JSON response from the Digital Credentials API into a list of [CredentialClaim]s. * - * This implementation robustly handles OpenID4VP responses by utilizing the - * `presentation_submission` metadata to identify and extract tokens from the `vp_token`. + * In DCQL (Digital Credential Query Language), the response contains a 'vp_token' + * which can be a single token string or a map of credential IDs to tokens. */ private fun parseClaims(responseJsonString: String): List { val claims = mutableListOf() try { val responseJson = JSONObject(responseJsonString) - - // Check for 'data' wrapper or flat structure as per documentation val data = responseJson.optJSONObject("data") ?: responseJson val vpToken = data.opt("vp_token") ?: return emptyList() - // Extract Presentation Submission metadata if available - val submissionJson = data.optJSONObject("presentation_submission") - val submission = submissionJson?.let { parsePresentationSubmission(it) } - - if (submission != null) { - // Robust parsing using standardized descriptor maps - for (descriptor in submission.descriptor_map) { - val rawToken = extractTokenByDescriptor(vpToken, descriptor) ?: continue - when (descriptor.format) { - "mso_mdoc" -> claims.addAll(parseMdocClaims(rawToken)) - "dc+sd-jwt", "vc+sd-jwt" -> claims.addAll(parseSdJwtClaims(rawToken)) - else -> Log.w("CredentialManagerUtil", "Unsupported format: ${descriptor.format}") + when (vpToken) { + is JSONObject -> { + // Handle map of IDs to tokens/arrays + val keys = vpToken.keys() + while (keys.hasNext()) { + val key = keys.next() + val token = when (val value = vpToken.get(key)) { + is JSONArray -> if (value.length() > 0) value.getString(0) else null + is String -> value + else -> null + } + token?.let { claims.addAll(parseToken(it)) } + } + } + is JSONArray -> { + // Handle array of tokens + for (i in 0 until vpToken.length()) { + val token = vpToken.optString(i) + if (token.isNotEmpty()) claims.addAll(parseToken(token)) } } - } else { - // Fallback for cases where presentation_submission is missing - handleLegacyParsing(vpToken, claims) + is String -> { + // Handle single token string + claims.addAll(parseToken(vpToken)) + } } } catch (e: Exception) { Log.e("CredentialManagerUtil", "Failed to parse claims", e) @@ -244,74 +247,13 @@ object CredentialManagerUtil { } /** - * Parses a [com.example.digitalcredentialsapp.data.PresentationSubmission] from its JSON representation. - */ - private fun parsePresentationSubmission(json: JSONObject): PresentationSubmission { - val descriptorMap = mutableListOf() - val descriptors = json.optJSONArray("descriptor_map") - if (descriptors != null) { - for (i in 0 until descriptors.length()) { - val desc = descriptors.getJSONObject(i) - descriptorMap.add( - DescriptorMapping( - id = desc.getString("id"), - format = desc.getString("format"), - path = desc.getString("path") - ) - ) - } - } - return PresentationSubmission( - id = json.getString("id"), - definition_id = json.getString("definition_id"), - descriptor_map = descriptorMap - ) - } - - /** - * Extracts a raw token (Base64 mDoc or SD-JWT string) from the vp_token based on a descriptor's path. + * Identifies the format of a raw token and parses its claims. */ - private fun extractTokenByDescriptor(vpToken: Any, descriptor: DescriptorMapping): String? { - return try { - if (vpToken is JSONObject) { - // Handle cases where vp_token is an object with credential IDs as keys (typical for DCQL) - val token = vpToken.opt(descriptor.id) ?: vpToken.opt("digital_credential_query") - when (token) { - is JSONArray -> if (token.length() > 0) token.getString(0) else null - is String -> token - else -> null - } - } else if (vpToken is JSONArray && vpToken.length() > 0) { - // Handle standard array vp_tokens - vpToken.getString(0) - } else vpToken as? String - } catch (e: Exception) { - null - } - } - - /** - * Fallback parsing logic for responses that don't include formal presentation_submission metadata. - */ - private fun handleLegacyParsing(vpToken: Any, claims: MutableList) { - 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) + private fun parseToken(rawToken: String): List { + return if (rawToken.contains("~")) { + parseSdJwtClaims(rawToken) + } else { + parseMdocClaims(rawToken) } } diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt deleted file mode 100644 index 4194599b..00000000 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/OpenId4Vp.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.example.digitalcredentialsapp.data - -/** - * Represents the OpenID4VP Presentation Submission metadata. - * - * This structure follows the DIF Presentation Exchange (PE) specification, - * identifying which requested credentials were provided and where they reside. - * - * @property id Unique identifier for this submission. - * @property definition_id The ID of the Presentation Definition this submission satisfies. - * @property descriptor_map A list of mappings from credential IDs to their location in the token. - */ -data class PresentationSubmission( - val id: String, - val definition_id: String, - val descriptor_map: List -) - -/** - * Maps a specific credential in the response to its format and path. - * - * @property id The identifier of the input descriptor from the request. - * @property format The format of the credential (e.g., "mso_mdoc" or "dc+sd-jwt"). - * @property path A JSONPath pointing to the credential within the vp_token. - */ -data class DescriptorMapping( - val id: String, - val format: String, - val path: String -) From d568a1404fd55dd0f21f2869452ea27993fd7a20 Mon Sep 17 00:00:00 2001 From: Chiping Yeh Date: Wed, 10 Jun 2026 15:03:03 -0400 Subject: [PATCH 5/6] Added more robust memory checks in Cbor.kt and made minor loop syntax optimization --- .../CredentialManagerUtil.kt | 4 +- .../digitalcredentialsapp/data/Cbor.kt | 176 +++++++++++++++--- 2 files changed, 154 insertions(+), 26 deletions(-) diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt index 693ffc6b..eabe492d 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/CredentialManagerUtil.kt @@ -217,9 +217,7 @@ object CredentialManagerUtil { when (vpToken) { is JSONObject -> { // Handle map of IDs to tokens/arrays - val keys = vpToken.keys() - while (keys.hasNext()) { - val key = keys.next() + for (key in vpToken.keys()) { val token = when (val value = vpToken.get(key)) { is JSONArray -> if (value.length() > 0) value.getString(0) else null is String -> value diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt index d2473812..85a55808 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Cbor.kt @@ -16,40 +16,120 @@ package com.example.digitalcredentialsapp.data +import java.io.ByteArrayOutputStream + +/** + * Represents a CBOR tag and its associated item. + * + * @property tag The CBOR tag value. + * @property item The item associated with the tag. + */ data class CborTag( val tag: Long, val item: Any? ) +/** + * Decodes a CBOR-encoded byte array into its corresponding Kotlin object representation. + * + * @param data The CBOR-encoded byte array. + * @return The decoded object (e.g., Long, String, ByteArray, List, Map, CborTag, or null). + */ fun cborDecode(data: ByteArray): Any? { return Cbor().decode(data) } +/** + * Encodes a Kotlin object into a CBOR byte array. + * + * @param data The object to encode. Supports Number, String, ByteArray, List, Map, CborTag, and null. + * @return The CBOR-encoded byte array. + */ fun cborEncode(data: Any?): ByteArray { return Cbor().encode(data) } +/** + * A lightweight CBOR (Concise Binary Object Representation) encoder and decoder implementation. + * Supports basic types, collections, and tags with security depth limits and bounds checking. + */ class Cbor { + /** + * Internal representation of a decoded CBOR item. + * + * @property item The decoded object. + * @property len The number of bytes consumed for this item. + * @property type The CBOR major type. + */ data class Item(val item: Any?, val len: Int, val type: Int) + + /** + * Internal representation of a CBOR argument (value/length) and its header length. + * + * @property arg The value or length parsed from the header. + * @property len The length of the header in bytes. + */ data class Arg(val arg: Long, val len: Int) + private val MAX_RECURSION_DEPTH = 32 + private val MAX_ITEM_SIZE = 1024 * 1024 // 1MB limit for individual strings/byte arrays + + /** Major type 0: Unsigned integer. */ val TYPE_UNSIGNED_INT = 0x00 + /** Major type 1: Negative integer. */ val TYPE_NEGATIVE_INT = 0x01 + /** Major type 2: Byte string. */ val TYPE_BYTE_STRING = 0x02 + /** Major type 3: Text string. */ val TYPE_TEXT_STRING = 0x03 + /** Major type 4: Array of items. */ val TYPE_ARRAY = 0x04 + /** Major type 5: Map of pairs. */ val TYPE_MAP = 0x05 + /** Major type 6: Optional semantic tag. */ val TYPE_TAG = 0x06 + /** Major type 7: Floating-point, simple values, and null/bool. */ val TYPE_FLOAT = 0x07 + /** + * Decodes the provided CBOR byte array. + * + * @param data The byte array to decode. + * @return The decoded object. + * @throws IllegalArgumentException if the data is malformed or exceeds safety limits. + */ fun decode(data: ByteArray): Any? { - val ret = parseItem(data, 0) + val ret = parseItem(data, 0, 0) return ret.item } + /** + * Encodes the provided object into CBOR format. + * + * @param data The object to encode. + * @return The encoded byte array. + * @throws IllegalArgumentException if the type is unsupported or recursion depth is exceeded. + */ fun encode(data: Any?): ByteArray { + val out = ByteArrayOutputStream() + encodeInternal(data, out, 0) + return out.toByteArray() + } + + /** + * Internal recursive encoding function. + * + * @param data The object to encode. + * @param out The stream to write encoded bytes to. + * @param depth Current recursion depth. + */ + private fun encodeInternal(data: Any?, out: ByteArrayOutputStream, depth: Int) { + if (depth > MAX_RECURSION_DEPTH) { + throw IllegalArgumentException("Max recursion depth exceeded during encoding") + } if (data == null) { - return createArg(TYPE_FLOAT, 22) + out.write(createArg(TYPE_FLOAT, 22)) + return } if (data is Number) { if (data is Double) { @@ -57,60 +137,84 @@ class Cbor { } else { val value = data.toLong() if (value >= 0) { - return createArg(TYPE_UNSIGNED_INT, value) + out.write(createArg(TYPE_UNSIGNED_INT, value)) + return } else { - return createArg(TYPE_NEGATIVE_INT, -1 - value) + out.write(createArg(TYPE_NEGATIVE_INT, -1 - value)) + return } } } if (data is ByteArray) { - return createArg(TYPE_BYTE_STRING, data.size.toLong()) + data + out.write(createArg(TYPE_BYTE_STRING, data.size.toLong())) + out.write(data) + return } if (data is String) { - return createArg(TYPE_TEXT_STRING, data.length.toLong()) + data.encodeToByteArray() + val bytes = data.encodeToByteArray() + out.write(createArg(TYPE_TEXT_STRING, bytes.size.toLong())) + out.write(bytes) + return } if (data is List<*>) { - var ret = createArg(TYPE_ARRAY, data.size.toLong()) + out.write(createArg(TYPE_ARRAY, data.size.toLong())) for (i in data) { - ret += encode(i) + encodeInternal(i, out, depth + 1) } - return ret + return } if (data is Map<*, *>) { - var ret = createArg(TYPE_MAP, data.size.toLong()) + out.write(createArg(TYPE_MAP, data.size.toLong())) for (i in data) { - ret += encode(i.key!!) - ret += encode(i.value!!) + encodeInternal(i.key!!, out, depth + 1) + encodeInternal(i.value!!, out, depth + 1) } - return ret + return } if (data is CborTag) { - var ret = createArg(TYPE_TAG, data.tag) - ret += encode(data.item) - return ret + out.write(createArg(TYPE_TAG, data.tag)) + encodeInternal(data.item, out, depth + 1) + return } throw IllegalArgumentException("Bad type") } + /** + * Extracts the major type from the CBOR header at the specified offset. + */ private fun getType(data: ByteArray, offset: Int): Int { val d = data[offset].toInt() return (d and 0xFF) shr 5 } + /** + * Parses the argument (value or length) from the CBOR header. + * + * @param data The byte array being parsed. + * @param offset The starting position of the header. + * @return An [Arg] containing the parsed value and the header length. + * @throws IllegalArgumentException if the header is truncated or uses unsupported 64-bit sizes. + */ private fun getArg(data: ByteArray, offset: Int): Arg { + if (offset >= data.size) { + throw IllegalArgumentException("Offset out of bounds") + } val arg = data[offset].toLong() and 0x1F if (arg < 24) { return Arg(arg, 1) } if (arg == 24L) { + if (offset + 1 >= data.size) throw IllegalArgumentException("Unexpected end of data") return Arg(data[offset + 1].toLong() and 0xFF, 2) } if (arg == 25L) { + if (offset + 2 >= data.size) throw IllegalArgumentException("Unexpected end of data") var ret = (data[offset + 1].toLong() and 0xFF) shl 8 ret = ret or (data[offset + 2].toLong() and 0xFF) return Arg(ret, 3) } if (arg == 26L) { + if (offset + 4 >= data.size) throw IllegalArgumentException("Unexpected end of data") var ret = (data[offset + 1].toLong() and 0xFF) shl 24 ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 16) ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 8) @@ -118,6 +222,7 @@ class Cbor { return Arg(ret, 5) } if (arg == 27L) { + if (offset + 8 >= data.size) throw IllegalArgumentException("Unexpected end of data") var ret = (data[offset + 1].toLong() and 0xFF) shl 56 ret = ret or ((data[offset + 2].toLong() and 0xFF) shl 48) ret = ret or ((data[offset + 3].toLong() and 0xFF) shl 40) @@ -126,12 +231,24 @@ class Cbor { ret = ret or ((data[offset + 6].toLong() and 0xFF) shl 16) ret = ret or ((data[offset + 7].toLong() and 0xFF) shl 8) ret = ret or (data[offset + 8].toLong() and 0xFF) + if (ret < 0) throw IllegalArgumentException("Unsupported 64-bit arg size") return Arg(ret, 9) } throw IllegalArgumentException("Bad arg $arg") } - private fun parseItem(data: ByteArray, offset: Int): Item { + /** + * Recursively parses a CBOR item from the byte array. + * + * @param data The byte array to parse from. + * @param offset Current position in the byte array. + * @param depth Current recursion depth. + * @return The parsed [Item]. + */ + private fun parseItem(data: ByteArray, offset: Int, depth: Int): Item { + if (depth > MAX_RECURSION_DEPTH) { + throw IllegalArgumentException("Max recursion depth exceeded") + } val itemType = getType(data, offset) val arg = getArg(data, offset) @@ -145,14 +262,20 @@ class Cbor { } TYPE_BYTE_STRING -> { + if (arg.arg > MAX_ITEM_SIZE) throw IllegalArgumentException("Byte string too large") + val end = offset + arg.len + arg.arg.toInt() + if (end > data.size) throw IllegalArgumentException("Unexpected end of data for byte string") val ret = - data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + data.sliceArray(offset + arg.len until end) return Item(ret, arg.len + arg.arg.toInt(), TYPE_BYTE_STRING) } TYPE_TEXT_STRING -> { + if (arg.arg > MAX_ITEM_SIZE) throw IllegalArgumentException("Text string too large") + val end = offset + arg.len + arg.arg.toInt() + if (end > data.size) throw IllegalArgumentException("Unexpected end of data for text string") val ret = - data.sliceArray(offset + arg.len.toInt() until offset + arg.len.toInt() + arg.arg.toInt()) + data.sliceArray(offset + arg.len until end) return Item( ret.toString(Charsets.UTF_8), arg.len + arg.arg.toInt(), TYPE_TEXT_STRING @@ -163,7 +286,8 @@ class Cbor { val ret = mutableListOf() var consumed = arg.len for (i in 0 until arg.arg.toInt()) { - val item = parseItem(data, offset + consumed) + if (offset + consumed >= data.size) throw IllegalArgumentException("Unexpected end of data for array") + val item = parseItem(data, offset + consumed, depth + 1) ret.add(item.item) consumed += item.len } @@ -174,9 +298,11 @@ class Cbor { val ret = mutableMapOf() var consumed = arg.len for (i in 0 until arg.arg.toInt()) { - val key = parseItem(data, offset + consumed) + if (offset + consumed >= data.size) throw IllegalArgumentException("Unexpected end of data for map key") + val key = parseItem(data, offset + consumed, depth + 1) consumed += key.len - val value = parseItem(data, offset + consumed) + if (offset + consumed >= data.size) throw IllegalArgumentException("Unexpected end of data for map value") + val value = parseItem(data, offset + consumed, depth + 1) consumed += value.len ret[key.item] = value.item } @@ -184,7 +310,8 @@ class Cbor { } TYPE_TAG -> { - val tagItem = parseItem(data, offset + arg.len) + if (offset + arg.len >= data.size) throw IllegalArgumentException("Unexpected end of data for tag") + val tagItem = parseItem(data, offset + arg.len, depth + 1) return Item(CborTag(arg.arg, tagItem.item), arg.len + tagItem.len, TYPE_TAG) } @@ -206,6 +333,9 @@ class Cbor { } } + /** + * Creates a CBOR header (major type + value/length argument). + */ private fun createArg(type: Int, arg: Long): ByteArray { val t = type shl 5 val a = arg.toInt() From f2014c46b7c2c0c8fc9d5b42da59ca7d88c75c04 Mon Sep 17 00:00:00 2001 From: Chiping Yeh Date: Thu, 11 Jun 2026 17:41:07 -0400 Subject: [PATCH 6/6] Update Requests to use JSONObject instead of a string --- .../digitalcredentialsapp/data/Requests.kt | 64 ++++++++++--------- 1 file changed, 34 insertions(+), 30 deletions(-) diff --git a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt index 5d6a9a1a..0c4ed8e7 100644 --- a/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt +++ b/DigitalCredentialsApp/app/src/main/java/com/example/digitalcredentialsapp/data/Requests.kt @@ -16,6 +16,9 @@ package com.example.digitalcredentialsapp.data +import org.json.JSONArray +import org.json.JSONObject + /** * Represents a claim being requested in a Digital Credential query. * @@ -55,38 +58,39 @@ object Requests { meta: String, requestedClaims: List ): String { - val claimsJson = requestedClaims.joinToString(",") { claim -> - """{"path": ${claim.path.map { "\"$it\"" }}}""" + val claimsJson = JSONObject().apply { + put("response_type", "vp_token") + put("response_mode", "dc_api") + clientId?.let { put("client_id", it) } + clientMetadata?.let { put("client_metadata", JSONObject(it)) } + put("nonce", nonce) + put("dcql_query", JSONObject().apply { + put("credentials", JSONArray().apply { + put(JSONObject().apply { + put("id", "cred1") + put("format", format) + put("meta", JSONObject(meta)) + put("claims", JSONArray().apply { + for (claim in requestedClaims) { + put(JSONObject().apply { + put("path", JSONArray(claim.path)) + }) + } + }) + }) + }) + }) } - val clientIdJson = if (clientId != null) """ "client_id": "$clientId", """ else "" - val clientMetadataJson = if (clientMetadata != null) """ "client_metadata": $clientMetadata, """ else "" - - return """ - { - "requests": [ - { - "protocol": "$protocol", - "data": { - "response_type": "vp_token", - "response_mode": "dc_api", - $clientIdJson - $clientMetadataJson - "nonce": "$nonce", - "dcql_query": { - "credentials": [ - { - "id": "cred1", - "format": "$format", - "meta": $meta, - "claims": [$claimsJson] - } - ] - } - } - } - ] + val openId4VPJson = JSONObject().apply { + put("requests", JSONArray().apply { + put(JSONObject().apply { + put("protocol", protocol) + put("data", claimsJson) + }) + }) } - """.trimIndent() + + return openId4VPJson.toString(2) } }