diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index db2e71c30..19966672d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,7 +55,8 @@ navigation-plugin = "2.7.7" androidx-preference = "1.2.1" exif = "1.3.7" ausweis-sdk = "2.1.1" -jetbrains-navigation = "2.7.0-alpha07" +jetbrains-navigation = "2.8.0-alpha10" +jetbrains-lifecycle = "2.8.2" cameraLifecycle = "1.3.4" buildconfig = "5.3.5" qrose = "1.0.1" @@ -99,12 +100,14 @@ androidx-navigation-runtime = { group = "androidx.navigation", name = "navigatio androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidx-navigation" } code-scanner = { module = "com.github.yuriy-budiyev:code-scanner", version.ref = "code-scanner" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } javax-servlet-api = { module = "javax.servlet:javax.servlet-api", version.ref = "javax-servlet-api" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } androidx-lifecycle-extensions = { module = "androidx.lifecycle:lifecycle-extensions", version.ref = "androidx-lifecycle" } androidx-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "androidx-lifecycle-ktx" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-ktx" } @@ -127,6 +130,7 @@ exifinterface = { module = "androidx.exifinterface:exifinterface", version.ref = ausweis-sdk = { module = "com.governikus:ausweisapp", version.ref = "ausweis-sdk"} jetbrains-navigation-compose = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref="jetbrains-navigation" } jetbrains-navigation-runtime = { module = "org.jetbrains.androidx.navigation:navigation-runtime", version.ref="jetbrains-navigation" } +jetbrains-lifecycle-viewmodel-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref="jetbrains-lifecycle" } camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraLifecycle" } qrose = { group = "io.github.alexzhirkevich", name = "qrose", version.ref="qrose"} easyqrscan = { module = "io.github.kalinjul.easyqrscan:scanner", version.ref = "easyqrscan" } diff --git a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt index 8a21d1b91..997d8b4b5 100644 --- a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt +++ b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPClient.kt @@ -21,10 +21,10 @@ import android.bluetooth.BluetoothSocket import android.content.Context import android.os.Build import androidx.annotation.RequiresApi -import com.android.identity.cbor.Cbor import com.android.identity.util.Logger -import java.io.ByteArrayOutputStream +import kotlinx.io.bytestring.ByteStringBuilder import java.io.IOException +import java.io.InputStream import java.util.concurrent.BlockingQueue import java.util.concurrent.LinkedTransferQueue import java.util.concurrent.TimeUnit @@ -121,7 +121,6 @@ internal class L2CAPClient(private val context: Context, val listener: Listener) private fun readFromSocket() { Logger.d(TAG, "Start reading socket input") - val pendingDataBaos = ByteArrayOutputStream() // Keep listening to the InputStream until an exception occurs. val inputStream = try { @@ -131,24 +130,19 @@ internal class L2CAPClient(private val context: Context, val listener: Listener) return } while (true) { - val buf = ByteArray(DataTransportBle.L2CAP_BUF_SIZE) try { - val numBytesRead = inputStream.read(buf) - if (numBytesRead == -1) { + val length = try { + val encodedLength = inputStream.readNOctets(4U) + (encodedLength[0].toUInt().and(0xffU) shl 24) + + (encodedLength[1].toUInt().and(0xffU) shl 16) + + (encodedLength[2].toUInt().and(0xffU) shl 8) + + (encodedLength[3].toUInt().and(0xffU) shl 0) + } catch (e: Throwable) { reportPeerDisconnected() break } - pendingDataBaos.write(buf, 0, numBytesRead) - try { - val pendingData = pendingDataBaos.toByteArray() - val (endOffset, _) = Cbor.decode(pendingData, 0) - val dataItemBytes = pendingData.sliceArray(IntRange(0, endOffset - 1)) - pendingDataBaos.reset() - pendingDataBaos.write(pendingData, endOffset, pendingData.size - endOffset) - reportMessageReceived(dataItemBytes) - } catch (e: Exception) { - /* not enough data to decode item, do nothing */ - } + val message = inputStream.readNOctets(length) + reportMessageReceived(message) } catch (e: IOException) { reportError(Error("Error on listening input stream from socket L2CAP", e)) break @@ -157,7 +151,16 @@ internal class L2CAPClient(private val context: Context, val listener: Listener) } fun sendMessage(data: ByteArray) { - writerQueue.add(data) + val bsb = ByteStringBuilder() + val length = data.size.toUInt() + bsb.apply { + append((length shr 24).and(0xffU).toByte()) + append((length shr 16).and(0xffU).toByte()) + append((length shr 8).and(0xffU).toByte()) + append((length shr 0).and(0xffU).toByte()) + } + bsb.append(data) + writerQueue.add(bsb.toByteString().toByteArray()) } fun reportPeerConnected() { @@ -194,4 +197,22 @@ internal class L2CAPClient(private val context: Context, val listener: Listener) companion object { private const val TAG = "L2CAPClient" } -} \ No newline at end of file +} + +// Cannot call it readNBytes() b/c that's taken on API >= 33 +// +internal fun InputStream.readNOctets(len: UInt): ByteArray { + val bsb = ByteStringBuilder() + var remaining = len + while (remaining > 0U) { + val buf = ByteArray(remaining.toInt()) + val numBytesRead = this.read(buf, 0, remaining.toInt()) + if (numBytesRead == -1) { + throw IllegalStateException("Failed reading from input stream") + } + bsb.append(buf) + remaining -= numBytesRead.toUInt() + } + return bsb.toByteString().toByteArray() +} + diff --git a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt index 7182b8969..b4c839a9d 100644 --- a/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt +++ b/identity-android/src/main/java/com/android/identity/android/mdoc/transport/L2CAPServer.kt @@ -22,6 +22,8 @@ import android.os.Build import androidx.annotation.RequiresApi import com.android.identity.cbor.Cbor import com.android.identity.util.Logger +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.ByteStringBuilder import java.io.ByteArrayOutputStream import java.io.IOException import java.util.concurrent.BlockingQueue @@ -130,7 +132,6 @@ internal class L2CAPServer(val listener: Listener) { private fun readFromSocket() { Logger.d(TAG, "Start reading socket input") - val pendingDataBaos = ByteArrayOutputStream() // Keep listening to the InputStream until an exception occurs. val inputStream = try { @@ -140,25 +141,19 @@ internal class L2CAPServer(val listener: Listener) { return } while (true) { - val buf = ByteArray(DataTransportBle.L2CAP_BUF_SIZE) try { - val numBytesRead = inputStream.read(buf) - if (numBytesRead == -1) { - Logger.d(TAG, "End of stream reading from socket") + val length = try { + val encodedLength = inputStream.readNOctets(4U) + (encodedLength[0].toUInt().and(0xffU) shl 24) + + (encodedLength[1].toUInt().and(0xffU) shl 16) + + (encodedLength[2].toUInt().and(0xffU) shl 8) + + (encodedLength[3].toUInt().and(0xffU) shl 0) + } catch (e: Throwable) { reportPeerDisconnected() break } - pendingDataBaos.write(buf, 0, numBytesRead) - try { - val pendingData = pendingDataBaos.toByteArray() - val (endOffset, _) = Cbor.decode(pendingData, 0) - val dataItemBytes = pendingData.sliceArray(IntRange(0, endOffset - 1)) - pendingDataBaos.reset() - pendingDataBaos.write(pendingData, endOffset, pendingData.size - endOffset) - reportMessageReceived(dataItemBytes) - } catch (e: Exception) { - /* not enough data to decode item, do nothing */ - } + val message = inputStream.readNOctets(length) + reportMessageReceived(message) } catch (e: IOException) { reportError(Error("Error on listening input stream from socket L2CAP", e)) break @@ -167,7 +162,16 @@ internal class L2CAPServer(val listener: Listener) { } fun sendMessage(data: ByteArray) { - writerQueue.add(data) + val bsb = ByteStringBuilder() + val length = data.size.toUInt() + bsb.apply { + append((length shr 24).and(0xffU).toByte()) + append((length shr 16).and(0xffU).toByte()) + append((length shr 8).and(0xffU).toByte()) + append((length shr 0).and(0xffU).toByte()) + } + bsb.append(data) + writerQueue.add(bsb.toByteString().toByteArray()) } fun reportPeerConnected() { diff --git a/identity-appsupport/build.gradle.kts b/identity-appsupport/build.gradle.kts index bf0c5d122..8e6ac154e 100644 --- a/identity-appsupport/build.gradle.kts +++ b/identity-appsupport/build.gradle.kts @@ -1,3 +1,4 @@ +import org.gradle.kotlin.dsl.implementation import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -11,8 +12,6 @@ plugins { kotlin { jvmToolchain(17) - jvm() - androidTarget { @OptIn(ExperimentalKotlinGradlePluginApi::class) compilerOptions { @@ -60,19 +59,12 @@ kotlin { } } - val jvmMain by getting { - dependencies { - implementation(libs.bouncy.castle.bcprov) - implementation(libs.bouncy.castle.bcpkix) - implementation(libs.tink) - } - } - val androidMain by getting { dependencies { implementation(libs.bouncy.castle.bcprov) implementation(libs.bouncy.castle.bcpkix) implementation(libs.tink) + implementation(libs.accompanist.permissions) } } } diff --git a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt index 940cc9b82..8c9250321 100644 --- a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt +++ b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/Util.android.kt @@ -1,5 +1,6 @@ package com.android.identity.appsupport.ui +import android.graphics.BitmapFactory import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -8,6 +9,8 @@ import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext @Composable @@ -31,4 +34,8 @@ actual fun AppTheme(content: @Composable () -> Unit) { colorScheme = colorScheme, content = content ) -} \ No newline at end of file +} + +actual fun decodeImage(encodedData: ByteArray): ImageBitmap { + return BitmapFactory.decodeByteArray(encodedData, 0, encodedData.size).asImageBitmap() +} diff --git a/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt new file mode 100644 index 000000000..871220a9d --- /dev/null +++ b/identity-appsupport/src/androidMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.android.kt @@ -0,0 +1,34 @@ +package com.android.identity.appsupport.ui.permissions + +import android.Manifest +import androidx.compose.runtime.Composable +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.rememberMultiplePermissionsState + +@OptIn(ExperimentalPermissionsApi::class) +private class AccompanistBluetoothPermissionState( + val multiplePermissionsState: MultiplePermissionsState +) : BluetoothPermissionState { + + override val isGranted: Boolean + get() = multiplePermissionsState.allPermissionsGranted + + override fun launchPermissionRequest() { + multiplePermissionsState.launchMultiplePermissionRequest() + } +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +actual fun rememberBluetoothPermissionState(): BluetoothPermissionState { + return AccompanistBluetoothPermissionState( + rememberMultiplePermissionsState( + listOf( + Manifest.permission.BLUETOOTH_SCAN, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.BLUETOOTH_ADVERTISE, + ) + ) + ) +} diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt index a7c4aaabf..b7dd30143 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/Util.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap @Composable expect fun AppTheme(content: @Composable () -> Unit) @@ -16,3 +17,5 @@ fun AppThemeDefault(content: @Composable () -> Unit) { content = content ) } + +expect fun decodeImage(encodedData: ByteArray): ImageBitmap diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt new file mode 100644 index 000000000..c0428a3d4 --- /dev/null +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.kt @@ -0,0 +1,13 @@ +package com.android.identity.appsupport.ui.permissions + +import androidx.compose.runtime.Composable + +interface BluetoothPermissionState { + + val isGranted: Boolean + + fun launchPermissionRequest() +} + +@Composable +expect fun rememberBluetoothPermissionState(): BluetoothPermissionState diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt index ea10b2da2..f85420528 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ScanQrCodeDialog.kt @@ -18,7 +18,8 @@ import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions * If the application doesn't have the necessary permission, the user is prompted to grant it. * * @param title The title of the dialog. - * @param description The description text to include in the dialog. + * @param text The description to include in the dialog, displayed above the QR scanner window. + * @param additionalContent Content which is displayed below the QR scanner window. * @param dismissButton The text for the dismiss button. * @param onCodeScanned called when a QR code is scanned, the parameter is the parsed data. Should * return `true` to stop scanning, `false` to continue scanning. @@ -27,8 +28,9 @@ import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions */ @Composable fun ScanQrCodeDialog( - title: String, - description: String, + title: (@Composable () -> Unit)? = null, + text: (@Composable () -> Unit)? = null, + additionalContent: (@Composable () -> Unit)? = null, dismissButton: String, onCodeScanned: (data: String) -> Boolean, onDismiss: () -> Unit, @@ -36,12 +38,12 @@ fun ScanQrCodeDialog( ) { AlertDialog( modifier = modifier ?: Modifier, - title = { Text(text = title) }, + title = title, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Text(text = description) + text?.invoke() ScannerWithPermissions( modifier = Modifier.height(300.dp), @@ -50,6 +52,8 @@ fun ScanQrCodeDialog( }, types = listOf(CodeType.QR) ) + + additionalContent?.invoke() } }, onDismissRequest = onDismiss, diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt index 273022a73..2ca24f0bc 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/qrcode/ShowQrCodeDialog.kt @@ -27,16 +27,18 @@ import org.jetbrains.compose.resources.stringResource * Renders a QR code and shows it in a dialog. * * @param title The title of the dialog. - * @param description The description text to include in the dialog. - * @param dismissButton The text for the dismiss button. + * @param text The description to include in the dialog, displayed above the QR code. + * @param additionalContent Content which is displayed below the QR code. + * @param dismissButton The content for the dismiss button. * @param data the QR code to show, e.g. mdoc:owBjMS4... or https://github.com/.... * @param onDismiss called when the dismiss button is pressed. * @param modifier A [Modifier] or `null`. */ @Composable fun ShowQrCodeDialog( - title: String, - description: String, + title: (@Composable () -> Unit)? = null, + text: (@Composable () -> Unit)? = null, + additionalContent: (@Composable () -> Unit)? = null, dismissButton: String, data: String, onDismiss: () -> Unit, @@ -48,12 +50,12 @@ fun ShowQrCodeDialog( AlertDialog( modifier = modifier ?: Modifier, - title = { Text(text = title) }, + title = title, text = { Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Text(text = description) + text?.invoke() Row( modifier = Modifier.align(Alignment.CenterHorizontally) @@ -74,6 +76,8 @@ fun ShowQrCodeDialog( ) } } + + additionalContent?.invoke() } }, onDismissRequest = onDismiss, diff --git a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt index 5718e8c9c..142848c68 100644 --- a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt +++ b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/Util.ios.kt @@ -1,8 +1,17 @@ package com.android.identity.appsupport.ui +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.toComposeImageBitmap +import org.jetbrains.skia.Image @Composable actual fun AppTheme(content: @Composable () -> Unit) { return AppThemeDefault(content) -} \ No newline at end of file +} + +@OptIn(ExperimentalFoundationApi::class) +actual fun decodeImage(encodedData: ByteArray): ImageBitmap { + return Image.makeFromEncoded(encodedData).toComposeImageBitmap() +} diff --git a/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt new file mode 100644 index 000000000..859c6d596 --- /dev/null +++ b/identity-appsupport/src/iosMain/kotlin/com/android/identity/appsupport/ui/permissions/BluetoothPermissionState.ios.kt @@ -0,0 +1,71 @@ +package com.android.identity.appsupport.ui.permissions + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import com.android.identity.util.Logger +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import platform.CoreBluetooth.CBCentralManager +import platform.CoreBluetooth.CBCentralManagerDelegateProtocol +import platform.CoreBluetooth.CBManager +import platform.CoreBluetooth.CBManagerAuthorization +import platform.CoreBluetooth.CBManagerAuthorizationAllowedAlways +import platform.CoreBluetooth.CBManagerAuthorizationNotDetermined +import platform.darwin.NSObject + +private const val TAG = "BluetoothPermissionState.ios" + +@OptIn(ExperimentalForeignApi::class) +private fun calcIsGranted(): Boolean { + val state: CBManagerAuthorization = CBManager.authorization + Logger.i(TAG, "state (CBManagerAuthorization): $state") + when (state) { + CBManagerAuthorizationAllowedAlways -> { + return true + } + CBManagerAuthorizationNotDetermined -> { + return false + } + else -> { + return false + } + } +} + +private class IosBluetoothPermissionState( + val recomposeToggleState: MutableState, + val scope: CoroutineScope +) : BluetoothPermissionState { + + private var iosIsGranted: Boolean = calcIsGranted() + + override val isGranted: Boolean + get() = iosIsGranted + + override fun launchPermissionRequest() { + Logger.i(TAG, "launchPermissionRequest...") + CBCentralManager(object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(central: CBCentralManager) { + Logger.i(TAG, "Did update state ${central.authorization}") + scope.launch(Dispatchers.Main) { + iosIsGranted = calcIsGranted() + recomposeToggleState.value = !recomposeToggleState.value + } + } + }, null) + } +} + +@Composable +actual fun rememberBluetoothPermissionState(): BluetoothPermissionState { + val scope = rememberCoroutineScope() + val recomposeToggleState: MutableState = mutableStateOf(false) + LaunchedEffect(recomposeToggleState.value) {} + return IosBluetoothPermissionState(recomposeToggleState, scope) +} + diff --git a/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt b/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt deleted file mode 100644 index 5718e8c9c..000000000 --- a/identity-appsupport/src/jvmMain/kotlin/com/android/identity/appsupport/ui/Util.jvm.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.android.identity.appsupport.ui - -import androidx.compose.runtime.Composable - -@Composable -actual fun AppTheme(content: @Composable () -> Unit) { - return AppThemeDefault(content) -} \ No newline at end of file diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt index 2ad7dce14..fdf7558bc 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/DrivingLicense.kt @@ -26,6 +26,9 @@ import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon import com.android.identity.documenttype.IntegerOption import com.android.identity.documenttype.StringOption +import com.android.identity.util.fromBase64Url +import com.android.identity.util.fromHex +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the Driving License @@ -74,7 +77,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -84,7 +87,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -94,7 +97,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.StringOptions(Options.COUNTRY_ISO_3166_1_ALPHA_2), @@ -134,7 +137,7 @@ object DrivingLicense { true, MDL_NAMESPACE, Icon.ACCOUNT_BOX, - null // TODO: include img_erika_portrait.jpg + SampleData.PORTRAIT_BASE64URL.fromBase64Url().toDataItem() ) .addAttribute( DocumentAttributeType.ComplexType, @@ -284,7 +287,7 @@ object DrivingLicense { false, MDL_NAMESPACE, Icon.TODAY, - SampleData.portraitCaptureDate.toDataItemFullDate() + LocalDate.parse(SampleData.PORTRAIT_CAPTURE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Number, @@ -484,7 +487,7 @@ object DrivingLicense { false, MDL_NAMESPACE, Icon.SIGNATURE, - null // TODO: include img_erika_signature.jpg + SampleData.SIGNATURE_OR_USUAL_MARK_BASE64URL.fromBase64Url().toDataItem() ) .addAttribute( DocumentAttributeType.ComplexType, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt index 89b57564c..aedd98e22 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUCertificateOfResidence.kt @@ -5,6 +5,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the EU Certificate of Residency (COR) document. @@ -52,7 +53,7 @@ object EUCertificateOfResidence { true, NAMESPACE, Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Boolean, @@ -82,7 +83,7 @@ object EUCertificateOfResidence { false, NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.String, @@ -192,7 +193,7 @@ object EUCertificateOfResidence { true, NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -202,7 +203,7 @@ object EUCertificateOfResidence { true, NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt index 09c3027ec..a2d9cbf47 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/EUPersonalID.kt @@ -21,6 +21,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the EU Personal ID Document Type. @@ -67,7 +68,7 @@ object EUPersonalID { true, EUPID_NAMESPACE, Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Number, @@ -267,7 +268,7 @@ object EUPersonalID { true, EUPID_NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.Date, @@ -277,7 +278,7 @@ object EUPersonalID { true, EUPID_NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt index f8845bb09..ebd8dfe40 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/GermanPersonalID.kt @@ -6,6 +6,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Object containing the metadata of the German ID Document Type. @@ -45,7 +46,7 @@ object GermanPersonalID { "Date of Birth", "Day, month, and year on which the PID holder was born. If unknown, approximate date of birth.", Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.Number, @@ -205,7 +206,7 @@ object GermanPersonalID { "Date of Issue", "Date (and possibly time) when the PID was issued.", Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.Date, @@ -213,7 +214,7 @@ object GermanPersonalID { "Date of Expiry", "Date (and possibly time) when the PID will expire.", Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt index 91d5d4766..0731eae17 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/PhotoID.kt @@ -7,6 +7,8 @@ import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon import com.android.identity.documenttype.knowntypes.EUPersonalID.EUPID_VCT +import com.android.identity.util.fromBase64Url +import kotlinx.datetime.LocalDate /** * PhotoID according to ISO/IEC TS 23220-4 (E) operational phase - Annex C Photo ID v2 @@ -68,7 +70,7 @@ object PhotoID { ISO_23220_2_NAMESPACE, Icon.TODAY, CborMap.builder() - .put("birth_date", SampleData.birthDate.toDataItemFullDate()) + .put("birth_date", LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate()) .end() .build() ) @@ -80,7 +82,7 @@ object PhotoID { true, ISO_23220_2_NAMESPACE, Icon.ACCOUNT_BOX, - null // TODO: include img_erika_portrait.jpg + SampleData.PORTRAIT_BASE64URL.fromBase64Url().toDataItem() ) .addMdocAttribute( DocumentAttributeType.Date, @@ -90,7 +92,7 @@ object PhotoID { true, ISO_23220_2_NAMESPACE, Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addMdocAttribute( DocumentAttributeType.Date, @@ -100,7 +102,7 @@ object PhotoID { true, ISO_23220_2_NAMESPACE, Icon.CALENDAR_CLOCK, - SampleData.expiryDate.toDataItemFullDate() + LocalDate.parse(SampleData.EXPIRY_DATE).toDataItemFullDate() ) .addMdocAttribute( DocumentAttributeType.String, @@ -250,7 +252,7 @@ object PhotoID { false, ISO_23220_2_NAMESPACE, Icon.TODAY, - SampleData.portraitCaptureDate.toDataItemFullDate() + LocalDate.parse(SampleData.PORTRAIT_CAPTURE_DATE).toDataItemFullDate() ) .addMdocAttribute( DocumentAttributeType.String, diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt index f17a423e2..c9b2de73f 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/SampleData.kt @@ -1,7 +1,5 @@ package com.android.identity.documenttype.knowntypes -import kotlinx.datetime.LocalDate - /** * Sample data used across multiple document types. * @@ -9,8 +7,8 @@ import kotlinx.datetime.LocalDate * [Erika Mustermann](https://en.wiktionary.org/wiki/Erika_Mustermann) * and a fictional country called Utopia. * - * Note: The ISO-3166-1 Alpha-2 country code used for Utopia is UT. This value does not - * appear in that standard. + * Note: The ISO-3166-1 Alpha-2 country code used for Utopia is ZZ. This value is a + * user-assigned country code. */ internal object SampleData { @@ -21,10 +19,10 @@ internal object SampleData { const val GIVEN_NAMES_NATIONAL_CHARACTER = "Ерика" const val FAMILY_NAME_NATIONAL_CHARACTER = "Бабіак" - val birthDate = LocalDate.parse("1971-09-01") + const val BIRTH_DATE = "1971-09-01" const val BIRTH_COUNTRY = "ZZ" // Note: ZZ is a user-assigned country-code as per ISO 3166-1 - val issueDate = LocalDate.parse("2024-03-15") - val expiryDate = LocalDate.parse("2028-09-01") + const val ISSUE_DATE = "2024-03-15" + const val EXPIRY_DATE = "2028-09-01" const val ISSUING_COUNTRY = "ZZ" // Note: ZZ is a user-assigned country-code as per ISO 3166-1 const val ISSUING_AUTHORITY_MDL = "Utopia Department of Motor Vehicles" const val ISSUING_AUTHORITY_EU_PID = "Utopia Central Registry" @@ -41,7 +39,9 @@ internal object SampleData { const val BIRTH_STATE = "Sample State" const val BIRTH_CITY = "Sample City" const val RESIDENT_ADDRESS = "Sample Street 123, 12345 Sample City, Sample State, Utopia" - val portraitCaptureDate = LocalDate.parse("2020-03-14") + const val PORTRAIT_CAPTURE_DATE = "2020-03-14" + const val PORTRAIT_BASE64URL = "_9j_4QDKRXhpZgAATU0AKgAAAAgABgESAAMAAAABAAEAAAEaAAUAAAABAAAAVgEbAAUAAAABAAAAXgEoAAMAAAABAAIAAAITAAMAAAABAAEAAIdpAAQAAAABAAAAZgAAAAAAAABIAAAAAQAAAEgAAAABAAeQAAAHAAAABDAyMjGRAQAHAAAABAECAwCgAAAHAAAABDAxMDCgAQADAAAAAQABAACgAgAEAAAAAQAAAHigAwAEAAAAAQAAAJmkBgADAAAAAQAAAAAAAAAAAAD_2wCEAAQEBAQEBAcEBAcJBwcHCQ0JCQkJDRANDQ0NDRATEBAQEBAQExMTExMTExMXFxcXFxcbGxsbGx8fHx8fHx8fHx8BBQUFCAcIDQcHDSAWEhYgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIP_dAAQACP_AABEIAJkAeAMBIgACEQEDEQH_xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29_j5-gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4-Tl5ufo6ery8_T19vf4-fr_2gAMAwEAAhEDEQA_APouZf3r8_xGosgjFSSf6x_940yus84YvI5pjccA0uQoyccVyvizxHD4c06S6lbDgcZ65_pWVWsqcbs2oUJVXyoq-KvGWl-F7dmuZA854SNT8xP-FfLviHxr4i1-RmA-zwj7qr6erHtTtQke53eIvELEBz-5RurZ7BfX-VZIt7u7YS3ceO8FonQD-_Ie_wCNef8AE-ef_APXVP2ceSGhj-ZuHmXT5z35yf606a_tY0KqspDDHzHt-NT6kbfTFJcb7jbuYfwoPf6-lcsgnmzNIM56sewreCvqYyfL7pZsZYYZCvVWOVB-Xg9efpXZ2aOIxJZPGSOOx_CvO2mUEbQCvQEVrLqF5YquNpiYDaWUEfn1qpwbFTmket6TqswcQaxGCn99RwB7iutm01Y0-26cwkUAfdHb-leNadr8_mKkkMQJ-6A2Ff2BYkKfxx24r03QLjePtmnsy4-V4XG0qe4I7VxzTidcHzaHeaH4jMSo34Nn-Veo280NzGs0DB1YcV5CunQakDcWy7Jf41HANdB4evZdLnNhe8wyn5G_ut7_AF6Vthq_I7PY5cZheePNHdHoWc0cU9SSMHtS_N6GvWPDsf_Q-jJHXzG9Mmq7MynIHWppTtkb6mrVnb7ybqXG1BkGuidRQV2cVOk5tRRRuXi0yzN9e43f8s0x-VfPfiGN9e1OW91XP2Kx-eTP3S_VU_DvXruu3M1_N5yDdhhHEn95zwPwrjPEOly3UsHh22AMMPz3Ln-N-prwJ1nOXMz6mhhFSgoI8Mkik1q7bXL9f3KfLbQnsvbA9-K3ItPa2tG1Kdd0khxEn99z93P-yPyr0I-Ho2uFVgPLT73-FUNasGvdtnGuA4KAf3U9fYtQ6vQ1WGPCYdGudc1AxwZlRn-aTH3vVz7cfKPTHrWZ4jSKGcaVYDMUXEjjHzv3_AdK9_vtPXw_oxsbMbLm6XAYdUXvj07Y_CvPoPDQWBpxHlHOAPZa6Y4lJ-hyPBtqyPKbHT_tNvIzKRj5gO-Bx_hXRaVpr-QtndLvilzsPfftyMfUDGPpXpun-GY7eKMLwGZP--S3P6U5NEeOwSQjmORT-TYz-VOeKvsEMC42PJxpxtJDbuN2BwMfK69vx9a7jw6JbG4jmi5hYbQSeV_2T6r_ACrq9Q0UuiPt7bsY_A_596NO0zY23n1_Csp1-ZFxwvKzubZvLH2yIZK8SIPT_Gt94oL6ITJgk9D2PtWNY4h2MTwPlYDuOx_LirMYl0y98oHEMvK56D2_Cs4vqVKPQ7TS7gyWyxyE7k-XFamV9f0rlomlSTzk4PQ46FaufbH9B-Zr1KOKSikzxa-AbneB_9H6OKbrggDuavXzkW62cXC4yxpsCt5jtx1xn_Cq123lQlz2P61x46rf3UdeWUftmbpdms2pSTcf6MuF9Azf_Wp1ppCGB7zqZXKpn2POPStnw_b-XpzuRlpXY_ieBW7FHDFE-4_u7aMRL7kck1wxjoes6lmeWX2nrbhl6etYlvp6fPqFx9xQP_rCu4vkfUboW8C_e_z-lY3iFYY_9FXCW1uN0re4HA_pWL01O-OyieZ6pFJdziR_vSnaq-iitLUNJgsLNLZQPlXH1LVa8MW76_r5nK4htl3D6Cujntl1HxAqsD5MH7xz268ChLQu1pcvZGLJoqRywWyckNGnPrwKSXQoxpMg248sOPwQjFb-r3kFrfwLnnzBIcdhnI_StScIfDNzNzlmc_gzD_CtF1Ri9FFnAw2EdxpsD4yVO1vxH_6qoiw8qUbR0AOP51paTc4iktycLvAGfdcf0FaDfvhDJjBw0Z-o5oJktTFuLdrWAsOdgK_h1qzcRm-0pCPvoAyketas1uJrA7QOmR-WKr6KA1kocY_oM4rSJyzRS0y6324Y_wAPBHof8K0_tfutZaxC01J4gcK3IFau9f8AOKtO2hztH__S-o0jPmFF4wWNc9rU5iRVHUnIFdYi8vjHOen1rz7xDOTqQgQ_dAQY9Wrx8Qz28HFJHodiDFYwHjIGf0_xrntU1WFY0tSdqk7j6n1rpZWSLTFwcYTAb2xivMb-6tLSzbVtQXdljgegBwqgfQVFRrY68LT5veN7SrpY7SXUZQI2l_1YbsucDPoSf0ryXX_E1vrN02n6dvMMbnc-OHYcbvf2HpUXiTxP4ikgS6s7BIbV2_dfapNrSsBydi_dVQOprJ8OeI2vGD3FnbmIrvSa0O-NwDtJB7_NxRKjJLWOhdCvTdS3Nqes-EoY9M0ieYjDP0_LiodNmjfMC7d85wTn0yMVpWwgurIQw_dI4x0NZTaWLCT90Ru61l2O9QT5r7mVrqebqz3AwUDlEx2VMAVsyaraNok1pI_R-R6fNXIavepDMEmySTz6VlyeJtDtbeVZBvCkbyBx79KunqzGtGMYxTexlzX1vaFxFIMbgy4_EY_GuvsLtLiFXzn5w4I9xg_rXna-J_Bs05tZLR7Z24zIuzP51fWb-xB9sspPMsmPzIOWU-39RROPKZqSqL3Wek2EpeCSB_4cjFQaKygGLIO1wuOn3v8AIqTTDG881xEcpJ5ci47hl5_lVbRRvu7xB_C24fg39KqDOSoiXVYPKu45e_Q_yqHePSt3X4sDdtyP_wBRrktyf3RWkjGL0P_T-rLqT7OjsODub9K8s1ObOrCcHIMikfgo_rXo2vY8lynGM_nXmGoqfLEg4KqT9PT-VeBiJH0mDhoej6rdiLRYQuSzJGB9T1_lXKPZfabeOG8QOIySvtmt7UmaWKy5yvlRn_x2qEscjDehxQ_iud-GilBHEaz4a1UWYi065_dkuFEq79okBV1BH8DjqD07VwyaNfaPp8lgBCg8swoFTy40QncdiDpluTXrk91Oo2nbxWD9njvJh5_z88AVr9YlsH1GlfmcdSz4Okvhp8AvmEkgHzPjG7AwGx2yOtTatfsbsoPp-FdBb28drDvIxt7VyGp-W1wJR3PPFYSOmmluc5rUEc6M7Kx2jIxx-FeSrLdQC5iiiQiR1MatzgLnjj8_rivdvIjl-VuQa4e90eCO4Z7b5cGt6dXlOWthlUVnscN4e0e8-0gXdu08Ucs0ubpg2_zV27C3P7sf3cV0UWg3en6e7xHfH0eIdMdsHqcdB7V1NlmNNqkk_SuhtflQhxwfaqq1-dWMsNgY0XeJj-C71Z7AwH78cSpz1wrED9DWxou6DXLmPGBIG5-p6flisG1Eem67IsX3J4_l-ua6HS3xrrNnGc9vasKLIxVPlOs10GSz3ei_yrg9yeld9qYJtZU45UVxX2Y11NdjhgrI_9T6j1BBNC8eOUk5rz7VohHZSZ9Qv4ZrvXLreTbvuk4A_GuJ8R4W0-X--BgV89X1PqMJpZG08ytY2D8fdCf4UrRO446dK5qW6CaKIc_NDtl_AnNdL9o2kccEA_nQnq0dlL4VY5nUYpY87a5_RLif-02hdeh4r0G9-zyw-YcdcCq9rpMEWZTgMe_pS5feOpVVyaouyROJHR5VztGIgRkfhXO31qqj5xgGq-teGNK1S7S_u2ZbiLBSWMlWGPcUmuP9ptxFFKQAAN3em0KnpazMyJTvyh-QYrlNXJN4QoA9AKvaXolto_mm1ldvNbc5kYsT-dTyRW5vPNYjBpTVlcum1ezItMgYoCRWncfImKsq0ESDaeKyrm4SQ4Bzip5tDTl10Oc1O4SG6trluCJNg_GupsSx1cEd8dK8y8Zz7JtPgXjMobj6gV6Pocvn39sB_FtNXRjZJnmY2SlJpdD0LV9iIU7hgp_AVzm6P0rS1e53ymP_AGi1Yu8e36V13PMSP__V-n9QAFyyL_Ex_LNcRrmJZoLePGZeeeMDoP0rtL5S927J_eKD6ZrzHX7yO3uLi_flYV2Rj3xivn6259PhvhRzV7rMUeuNppbHmwlc9v3S_LXqWjzpqOl29zGfvRr_AIf0r5X1u6afbeCQx3EZbn2PUV7H8GfEH9p6HNp07Zkspig_3G-Zf8KzpxbjdnXKSjLlXyO-v4p4biJwnmBVJVc4GazJPEFpbnF2rwMeCGGQPx6V2NwgcDP8NY11p0V2CHXNXy9jenOL-M5ifWNOlyUuFK46iubuNesh8kkyfhR4h8KQmPzYR5cqnkpxn6ivP20ba7b1yTxu_wAKnra56caFOVPmizsU1vTwSPPQegNQyanpchEPmo2_gBa5iDw_bySD92AB3Izmuut9Os4IfLiRVx7Vo0rbnDKKjoitbTXCNJbSEsq7Sj_7J7H3FTRhmcnPAqeGMx5TualaPYPrXO0XF6Hl_jmfy9VssdV5_WvT_DUmyWK4b-Bf_rV4b4t1JJ_GIthytskZb6lhx_Ova9NgltleOTjZxn2NdihaETxqk1KpM3bi682RGPG5TVfK-9VryIwxwyoPujP61W-2v6fyovYxsf_W-pLwrBbyzL95dyp_vscV4F4wlyhso-QGGT6kmvfNTjHkEHoCxr5_1iI3Fyz-rg_gK-exHxWPqMJ8NzzHV7fah_E_nx_SrXwxvLjR_FCbOY7r91IO3JypH0xWxq1sdjbRz0p-haWbe-S66FSNo98iiM_daNPZ-8mfTJIYbuxqsSY1OamWPzYRPCcEgHFVTdRYIPUUzdLsYOokuvHSuUuE2vlVUj_drrruYMh2gYrm7gYG4qBioaVzugny7GU6rn5U571Crc9Oac90gJGOlUZbuFORxQ5LZEcheHH7zvWFrurxaZZtcHl8YRfUnpVpJpLt_Lg4Hqa4Xx2oijSIHnvUwjeSRNWXJB2PEzNJc3N9eSkmR3DZ_wB09K-vtLZbywhuOv2i3WT81H9a-SbHYc8ehx9P_rV9aeF7VoNH0qN-gto1YHtvXoffivVxGlrHzeF1uaNwN9l5B6xIvPsRmue8v_aH6V2F5CttdIkudsuI_wAO39Kl_syz_vD9K5kzqaP_1_qDXmAt2_3iBXj1zbjezkcCvVNcOdsf_TRq417AzMUxwTXz1bWR9XhV7iOEvtPbYCR7_jXn_wDabx-I7SLdwsqr7HsQRX0Tf6dEZFiRRwM_jXz1478Nah4f1S312FC1sZQXbB-Q9s_U4rSFK5tKVo3R9QaVKTDgnsP5VHcWaTk9j7VxnhPxNp9_aJslG7gH69671ZFkztxxWa1VjR3i7oxbfRYwj78n8a57UdL6oGIFd680ew9iK5XUpFVTyKUkktDajOTepwF7Yp5JYMd6_KcdxWDHA7HGK6iaRGYjr7VB5I6gDNZpG7EsoxBHXlHjt3ZmP1xXsaxlY8ngCvH_AByJbm5i0vTE826nYxxovXJXr7YrajH34o5MU_3cjl_BVlb3d15ZiUxQDzJWYZPy9hX0_YW-zRbaROMgD_vk1594f8HxeG9G-zSmOSZolSTaT1YgHFen3c0NqsdmvCRhY8fQcn866q8tTz6VLkikV9Y23Noj_wAXB_EcGuW2P_tVq2l2XuzpspysiMB_vDt-Iq__AGV_sN-VRYz0Wh__0PoOWQXDlC2Srkj8yKuG1Hykkcdf61eg_wBef97-prfl-5-FeKqZ9Iq1rJIwre3i-1jONzgBavahoVpeW8kUihkddrg8gg9RitNP-PyL610B-631rspU1YwqYlpxsfIPiL4V6hpMxvvCTMqA5MDHoP8AYP8AQ1ylh8SNT0C7MGtIU28ENwf1r7N1n_j0b6V85fE__X23-5Wc8PGWp108XLkvY6HR_F-j6_CJLadAWHTIqO_eIodrhhXCeB_9afxr16X7grhnTs-U6qOL0ukeWsFQks4PP0qvNremWUW6edAR_DkV39__AKv8a88m_wCP6T6VpSw_NLluKeNtb3ShHq2r-IPKi0CDZE5wbmYbYlX1z3_CvR_Cvg2x8Plr6YtJqkq_vLs4K89owfujtXWeF_8AkXLD_rkK7KHon-6f512woxhsck8Q5bo808Qx7bUzsV3Aqc4A71wF1qJurpCSAqoT-Jr3XWv-PE_QV56f9b_wA1hWhqaRrLl2ODhuhDq0DuwXbIh5PHp_9avRv7dtf-esX51lXf8Aro_w_kaWkoHJKp5H_9k" + const val SIGNATURE_OR_USUAL_MARK_BASE64URL = "_9j_4AAQSkZJRgABAQIAOAA4AAD_4g_wSUNDX1BST0ZJTEUAAQEAAA_gYXBwbAIQAABtbnRyUkdCIFhZWiAH5gAIAB4ACAAsAB1hY3NwQVBQTAAAAABBUFBMAAAAAAAAAAAAAAAAAAAAAAAA9tYAAQAAAADTLWFwcGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABJkZXNjAAABXAAAAGJkc2NtAAABwAAABIJjcHJ0AAAGRAAAACN3dHB0AAAGaAAAABRyWFlaAAAGfAAAABRnWFlaAAAGkAAAABRiWFlaAAAGpAAAABRyVFJDAAAGuAAACAxhYXJnAAAOxAAAACB2Y2d0AAAO5AAAADBuZGluAAAPFAAAAD5jaGFkAAAPVAAAACxtbW9kAAAPgAAAACh2Y2dwAAAPqAAAADhiVFJDAAAGuAAACAxnVFJDAAAGuAAACAxhYWJnAAAOxAAAACBhYWdnAAAOxAAAACBkZXNjAAAAAAAAAAhEaXNwbGF5AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbWx1YwAAAAAAAAAmAAAADGhySFIAAAAUAAAB2GtvS1IAAAAMAAAB7G5iTk8AAAASAAAB-GlkAAAAAAASAAACCmh1SFUAAAAUAAACHGNzQ1oAAAAWAAACMGRhREsAAAAcAAACRm5sTkwAAAAWAAACYmZpRkkAAAAQAAACeGl0SVQAAAAUAAACiGVzRVMAAAASAAACnHJvUk8AAAASAAACnGZyQ0EAAAAWAAACrmFyAAAAAAAUAAACxHVrVUEAAAAcAAAC2GhlSUwAAAAWAAAC9HpoVFcAAAAKAAADCnZpVk4AAAAOAAADFHNrU0sAAAAWAAADInpoQ04AAAAKAAADCnJ1UlUAAAAkAAADOGVuR0IAAAAUAAADXGZyRlIAAAAWAAADcG1zAAAAAAASAAADhmhpSU4AAAASAAADmHRoVEgAAAAMAAADqmNhRVMAAAAYAAADtmVuQVUAAAAUAAADXGVzWEwAAAASAAACnGRlREUAAAAQAAADzmVuVVMAAAASAAAD3nB0QlIAAAAYAAAD8HBsUEwAAAASAAAECGVsR1IAAAAiAAAEGnN2U0UAAAAQAAAEPHRyVFIAAAAUAAAETHB0UFQAAAAWAAAEYGphSlAAAAAMAAAEdgBMAEMARAAgAHUAIABiAG8AagBpzuy37AAgAEwAQwBEAEYAYQByAGcAZQAtAEwAQwBEAEwAQwBEACAAVwBhAHIAbgBhAFMAegDtAG4AZQBzACAATABDAEQAQgBhAHIAZQB2AG4A_QAgAEwAQwBEAEwAQwBEAC0AZgBhAHIAdgBlAHMAawDmAHIAbQBLAGwAZQB1AHIAZQBuAC0ATABDAEQAVgDkAHIAaQAtAEwAQwBEAEwAQwBEACAAYwBvAGwAbwByAGkATABDAEQAIABjAG8AbABvAHIAQQBDAEwAIABjAG8AdQBsAGUAdQByIA8ATABDAEQAIAZFBkQGSAZGBikEGgQ-BDsETAQ-BEAEPgQyBDgEOQAgAEwAQwBEIA8ATABDAEQAIAXmBdEF4gXVBeAF2V9pgnIATABDAEQATABDAEQAIABNAOAAdQBGAGEAcgBlAGIAbgD9ACAATABDAEQEJgQyBDUEQgQ9BD4EOQAgBBYEGgAtBDQEOARBBD8EOwQ1BDkAQwBvAGwAbwB1AHIAIABMAEMARABMAEMARAAgAGMAbwB1AGwAZQB1AHIAVwBhAHIAbgBhACAATABDAEQJMAkCCRcJQAkoACAATABDAEQATABDAEQAIA4qDjUATABDAEQAIABlAG4AIABjAG8AbABvAHIARgBhAHIAYgAtAEwAQwBEAEMAbwBsAG8AcgAgAEwAQwBEAEwAQwBEACAAQwBvAGwAbwByAGkAZABvAEsAbwBsAG8AcgAgAEwAQwBEA4gDswPHA8EDyQO8A7cAIAO_A7gDzAO9A7cAIABMAEMARABGAOQAcgBnAC0ATABDAEQAUgBlAG4AawBsAGkAIABMAEMARABMAEMARAAgAGEAIABDAG8AcgBlAHMwqzDpMPwATABDAEQAAHRleHQAAAAAQ29weXJpZ2h0IEFwcGxlIEluYy4sIDIwMjIAAFhZWiAAAAAAAADzFgABAAAAARbKWFlaIAAAAAAAAHHAAAA5igAAAWdYWVogAAAAAAAAYSMAALnmAAAT9lhZWiAAAAAAAAAj8gAADJAAAL3QY3VydgAAAAAAAAQAAAAABQAKAA8AFAAZAB4AIwAoAC0AMgA2ADsAQABFAEoATwBUAFkAXgBjAGgAbQByAHcAfACBAIYAiwCQAJUAmgCfAKMAqACtALIAtwC8AMEAxgDLANAA1QDbAOAA5QDrAPAA9gD7AQEBBwENARMBGQEfASUBKwEyATgBPgFFAUwBUgFZAWABZwFuAXUBfAGDAYsBkgGaAaEBqQGxAbkBwQHJAdEB2QHhAekB8gH6AgMCDAIUAh0CJgIvAjgCQQJLAlQCXQJnAnECegKEAo4CmAKiAqwCtgLBAssC1QLgAusC9QMAAwsDFgMhAy0DOANDA08DWgNmA3IDfgOKA5YDogOuA7oDxwPTA-AD7AP5BAYEEwQgBC0EOwRIBFUEYwRxBH4EjASaBKgEtgTEBNME4QTwBP4FDQUcBSsFOgVJBVgFZwV3BYYFlgWmBbUFxQXVBeUF9gYGBhYGJwY3BkgGWQZqBnsGjAadBq8GwAbRBuMG9QcHBxkHKwc9B08HYQd0B4YHmQesB78H0gflB_gICwgfCDIIRghaCG4IggiWCKoIvgjSCOcI-wkQCSUJOglPCWQJeQmPCaQJugnPCeUJ-woRCicKPQpUCmoKgQqYCq4KxQrcCvMLCwsiCzkLUQtpC4ALmAuwC8gL4Qv5DBIMKgxDDFwMdQyODKcMwAzZDPMNDQ0mDUANWg10DY4NqQ3DDd4N-A4TDi4OSQ5kDn8Omw62DtIO7g8JDyUPQQ9eD3oPlg-zD88P7BAJECYQQxBhEH4QmxC5ENcQ9RETETERTxFtEYwRqhHJEegSBxImEkUSZBKEEqMSwxLjEwMTIxNDE2MTgxOkE8UT5RQGFCcUSRRqFIsUrRTOFPAVEhU0FVYVeBWbFb0V4BYDFiYWSRZsFo8WshbWFvoXHRdBF2UXiReuF9IX9xgbGEAYZRiKGK8Y1Rj6GSAZRRlrGZEZtxndGgQaKhpRGncanhrFGuwbFBs7G2MbihuyG9ocAhwqHFIcexyjHMwc9R0eHUcdcB2ZHcMd7B4WHkAeah6UHr4e6R8THz4faR-UH78f6iAVIEEgbCCYIMQg8CEcIUghdSGhIc4h-yInIlUigiKvIt0jCiM4I2YjlCPCI_AkHyRNJHwkqyTaJQklOCVoJZclxyX3JicmVyaHJrcm6CcYJ0kneierJ9woDSg_KHEooijUKQYpOClrKZ0p0CoCKjUqaCqbKs8rAis2K2krnSvRLAUsOSxuLKIs1y0MLUEtdi2rLeEuFi5MLoIuty7uLyQvWi-RL8cv_jA1MGwwpDDbMRIxSjGCMbox8jIqMmMymzLUMw0zRjN_M7gz8TQrNGU0njTYNRM1TTWHNcI1_TY3NnI2rjbpNyQ3YDecN9c4FDhQOIw4yDkFOUI5fzm8Ofk6Njp0OrI67zstO2s7qjvoPCc8ZTykPOM9Ij1hPaE94D4gPmA-oD7gPyE_YT-iP-JAI0BkQKZA50EpQWpBrEHuQjBCckK1QvdDOkN9Q8BEA0RHRIpEzkUSRVVFmkXeRiJGZ0arRvBHNUd7R8BIBUhLSJFI10kdSWNJqUnwSjdKfUrESwxLU0uaS-JMKkxyTLpNAk1KTZNN3E4lTm5Ot08AT0lPk0_dUCdQcVC7UQZRUFGbUeZSMVJ8UsdTE1NfU6pT9lRCVI9U21UoVXVVwlYPVlxWqVb3V0RXklfgWC9YfVjLWRpZaVm4WgdaVlqmWvVbRVuVW-VcNVyGXNZdJ114XcleGl5sXr1fD19hX7NgBWBXYKpg_GFPYaJh9WJJYpxi8GNDY5dj62RAZJRk6WU9ZZJl52Y9ZpJm6Gc9Z5Nn6Wg_aJZo7GlDaZpp8WpIap9q92tPa6dr_2xXbK9tCG1gbbluEm5rbsRvHm94b9FwK3CGcOBxOnGVcfByS3KmcwFzXXO4dBR0cHTMdSh1hXXhdj52m3b4d1Z3s3gReG54zHkqeYl553pGeqV7BHtje8J8IXyBfOF9QX2hfgF-Yn7CfyN_hH_lgEeAqIEKgWuBzYIwgpKC9INXg7qEHYSAhOOFR4Wrhg6GcobXhzuHn4gEiGmIzokziZmJ_opkisqLMIuWi_yMY4zKjTGNmI3_jmaOzo82j56QBpBukNaRP5GokhGSepLjk02TtpQglIqU9JVflcmWNJaflwqXdZfgmEyYuJkkmZCZ_JpomtWbQpuvnByciZz3nWSd0p5Anq6fHZ-Ln_qgaaDYoUehtqImopajBqN2o-akVqTHpTilqaYapoum_adup-CoUqjEqTepqaocqo-rAqt1q-msXKzQrUStuK4trqGvFq-LsACwdbDqsWCx1rJLssKzOLOutCW0nLUTtYq2AbZ5tvC3aLfguFm40blKucK6O7q1uy67p7whvJu9Fb2Pvgq-hL7_v3q_9cBwwOzBZ8Hjwl_C28NYw9TEUcTOxUvFyMZGxsPHQce_yD3IvMk6ybnKOMq3yzbLtsw1zLXNNc21zjbOts83z7jQOdC60TzRvtI_0sHTRNPG1EnUy9VO1dHWVdbY11zX4Nhk2OjZbNnx2nba-9uA3AXcit0Q3ZbeHN6i3ynfr-A24L3hROHM4lPi2-Nj4-vkc-T85YTmDeaW5x_nqegy6LzpRunQ6lvq5etw6_vshu0R7ZzuKO6070DvzPBY8OXxcvH_8ozzGfOn9DT0wvVQ9d72bfb794r4Gfio-Tj5x_pX-uf7d_wH_Jj9Kf26_kv-3P9t__9wYXJhAAAAAAADAAAAAmZmAADypwAADVkAABPQAAAKW3ZjZ3QAAAAAAAAAAQABAAAAAAAAAAEAAAABAAAAAAAAAAEAAAABAAAAAAAAAAEAAG5kaW4AAAAAAAAANgAAp0AAAFWAAABMwAAAnsAAACWAAAAMwAAAUAAAAFRAAAIzMwACMzMAAjMzAAAAAAAAAABzZjMyAAAAAAABDHIAAAX4___zHQAAB7oAAP1y___7nf___aQAAAPZAADAcW1tb2QAAAAAAAAGEAAAoC8AAAAA0OXf8AAAAAAAAAAAAAAAAAAAAAB2Y2dwAAAAAAADAAAAAmZmAAMAAAACZmYAAwAAAAJmZgAAAAIzMzQAAAAAAjMzNAAAAAACMzM0AP_bAEMABQMEBAQDBQQEBAUFBQYHDAgHBwcHDwsLCQwRDxISEQ8RERMWHBcTFBoVEREYIRgaHR0fHx8TFyIkIh4kHB4fHv_AAAsIADcAeAEBEQD_xAAfAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgv_xAC1EAACAQMDAgQDBQUEBAAAAX0BAgMABBEFEiExQQYTUWEHInEUMoGRoQgjQrHBFVLR8CQzYnKCCQoWFxgZGiUmJygpKjQ1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4eLj5OXm5-jp6vHy8_T19vf4-fr_2gAIAQEAAD8A-y6KM0UUUUUUUUUUUUVzN7qGt6zeTWPh1oLO1gdorjVJ4_M-ccMkMeQGYHgux2qeMOQwEkPhGzILX-qa5qExOTLLqcsf5JEURfwUUeEJnF_rGnRXk99Y2U6RxTTv5jo5XMkW88uE-Xk5ILFSTt46KiiiiiiiiiiiuN02-u_CFt_ZmradcS6VAW8jU7VDKojJJAnjX50YZ5cBlONxK5wOssbq1vrSK7sriG5tplDxSxOHR1PQgjgj3qsH03RoLSyiiW2ilm8mCOKI7d5DN_COM4Y5OBnvk1eLKMZIGeBmkikjlTfG6uuSMqcjg4P61Ct9ZtPHALuAyy7vLQSDc-04bAzzjvjpU7MqjLEAe9NuI_Nt5IhI8e9Su9Dhlz3B9a4KxsdZ1LxbeT6X4hm-xaKfssS30QuI5LllBlPylGIRSiAljy0npXVaJHrBuZpdU1SyuAg8sQWluY1RupLFmZi2MccAA985rWpkMscyCSJ1dD0ZTkGnBgSQCMjqKWioL-7tbG0kuryeO3gjGXkkbaq_jXn_AILGpTeIfFEPhuJdM0Z72OQPdwtlLhog0_lQ8bQ2Y2O4jDs5Kkk1pDTtc0rxfPqKLquuK-npBbGW8jjhjlMjGUuvATIEWCqMcA9-tTWNB8m_GueK9aW6eTZHDbQWp3RvyfKtsEtljglgPMOPvKoAWpp9p4kuvB-sSaAq6VFNc3P9m2UBQOrFtgLyDKxqHDOQgJxnDZ4q2nhBtBbw9_ZmmnU5LAs89wWRJHkWFo4wWY5WP95IxC7jnnDFmJuar4R1XWr2K-1jVoH2RsEsVtg8EDE_eTf1kx8u91bAJ2qmTmr4Qg8Rad4LtNItrK-hvIYC99f6i4llknOWkKDcTIxcnBYhQMfextqHwVdXqeCtM0Xw6xvNRa3Et_qdwpaGKeT95KzHjzJS7N8i9D97bwDFpdj4qtPh7Lovh6ynsNY-ySyXOp3_AJZea9dSXdVBId2kJO5sIBjAYDbV7VIvFdx4NbSPDFvdaS8dqltDeX8iyXO44Tfjcw-XJdnYknBAUkgi_pnguHQ9NWHQdS1GznS2ETHzw6Tsq4VnWRWVTxyVAOOOgFY3gjw5q-l28htra9g1S8iT-0dV1a5WeR5OSxjjRiD8xOMlVUYABAC132n232SzitvPnnMa4Msz7nc9yT6n2wPQAVPXAWGoP4hdNQs_Lv8AUZCWtI2G620pCSA8uODNjkr97J2jau5j2WiabBpWmx2cBd9pLSSOcvK7El3Y92ZiSfrSa5qcGlac93MryHISKKMZeaRjhUUdyTx6dzgAmuR1v7fafZ4xKk3i_Wg0FqyjdHp8XBldAeiRqQS3WRygONyhey0mxt9M0u2060UrBbRLFGCcnaowMnueOTVqiiiiiiiiigADoKK5XxLJew-J7W6TSb3Ult7VjYxQgBDcOSrM7k4TanAY9nfAJ4q34X0KayuLjWNXmju9bvQFnlQHy4YwSVgiB5Ea5JyeWYlj1AG_RRRRRRRRRRRRRRRRRRRRRRRRX__Z" const val AGE_IN_YEARS = 53 const val AGE_BIRTH_YEAR = 1971 const val AGE_OVER = true // Generic "age over" value @@ -63,8 +63,4 @@ internal object SampleData { const val RESIDENT_STATE = "Sample State" const val RESIDENT_COUNTRY = "ZZ" // Note: ZZ is a user-assigned country-code as per ISO 3166-1 - // TODO - //val portrait - //val signatureUsualMark - } \ No newline at end of file diff --git a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt index c56c36b1c..26e58c1bd 100644 --- a/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt +++ b/identity-doctypes/src/commonMain/kotlin/com/android/identity/documenttype/knowntypes/UtopiaNaturalization.kt @@ -5,6 +5,7 @@ import com.android.identity.cbor.toDataItemFullDate import com.android.identity.documenttype.DocumentAttributeType import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.Icon +import kotlinx.datetime.LocalDate /** * Naturalization Certificate of the fictional State of Utopia. @@ -40,7 +41,7 @@ object UtopiaNaturalization { "Date of Birth", "Day, month, and year on which the naturalized person was born. If unknown, approximate date of birth.", Icon.TODAY, - SampleData.birthDate.toDataItemFullDate() + LocalDate.parse(SampleData.BIRTH_DATE).toDataItemFullDate() ) .addVcAttribute( DocumentAttributeType.Date, @@ -48,7 +49,7 @@ object UtopiaNaturalization { "Date of Naturalization", "Date (and possibly time) when the person was naturalized.", Icon.DATE_RANGE, - SampleData.issueDate.toDataItemFullDate() + LocalDate.parse(SampleData.ISSUE_DATE).toDataItemFullDate() ) .addSampleRequest( id = "full", diff --git a/identity-mdoc/build.gradle.kts b/identity-mdoc/build.gradle.kts index 95a3b2ba2..3fd28d34f 100644 --- a/identity-mdoc/build.gradle.kts +++ b/identity-mdoc/build.gradle.kts @@ -1,5 +1,11 @@ +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.implementation +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) id("maven-publish") } @@ -11,6 +17,13 @@ kotlin { jvm() + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + listOf( iosX64(), iosArm64(), @@ -35,6 +48,7 @@ kotlin { implementation(project(":identity")) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.io.bytestring) + implementation(libs.kotlinx.coroutines.core) } } @@ -59,6 +73,39 @@ kotlin { implementation(libs.bouncy.castle.bcpkix) } } + + val androidMain by getting { + dependencies { + implementation(libs.bouncy.castle.bcprov) + implementation(libs.bouncy.castle.bcpkix) + implementation(libs.tink) + implementation(libs.volley) + } + } + } +} + +android { + namespace = "com.android.identity.mdoc" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + dependencies { + } + + packaging { + resources { + excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") + excludes += listOf("/META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } } } diff --git a/identity-mdoc/lint.xml b/identity-mdoc/lint.xml new file mode 100644 index 000000000..6f0834d7a --- /dev/null +++ b/identity-mdoc/lint.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt new file mode 100644 index 000000000..dc73526ec --- /dev/null +++ b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerAndroid.kt @@ -0,0 +1,738 @@ +package com.android.identity.mdoc.transport + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothSocket +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanFilter +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.os.Build +import android.os.ParcelUuid +import androidx.annotation.RequiresApi +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.AndroidInitializer +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toHex +import com.android.identity.util.toJavaUuid +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.io.bytestring.ByteStringBuilder +import java.io.InputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +internal class BleCentralManagerAndroid: BleCentralManager { + companion object { + private const val TAG = "BleCentralManagerAndroid" + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID?, + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks(onError: (Throwable) -> Unit, onClosed: () -> Unit) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + PERIPHERAL_DISCOVERED, + CONNECT_TO_PERIPHERAL, + REQUEST_MTU, + PERIPHERAL_DISCOVER_SERVICES, + GET_READER_IDENT, + GET_L2CAP_PSM, + WRITE_TO_DESCRIPTOR, + CHARACTERISTIC_WRITE_COMPLETED, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + ) + + private var waitFor: WaitFor? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation, + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + ) + } + + private fun clearWaitCondition() { + waitFor = null + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private val context = AndroidInitializer.applicationContext + private val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + private var device: BluetoothDevice? = null + private var gatt: BluetoothGatt? = null + private var service: BluetoothGattService? = null + + private var characteristicState: BluetoothGattCharacteristic? = null + private var characteristicClient2Server: BluetoothGattCharacteristic? = null + private var characteristicServer2Client: BluetoothGattCharacteristic? = null + private var characteristicIdent: BluetoothGattCharacteristic? = null + private var characteristicL2cap: BluetoothGattCharacteristic? = null + + private class ConnectionFailedException( + message: String + ) : Throwable(message) + + private val scanCallback: ScanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + Logger.d(TAG, "onScanResult: callbackType=$callbackType result=$result") + try { + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVERED) { + device = result.device + resumeWait() + } else { + Logger.w(TAG, "onScanResult but not waiting") + } + } catch (error: Throwable) { + onError(Error("onScanResult failed", error)) + } + } + + override fun onScanFailed(errorCode: Int) { + Logger.d(TAG, "onScanFailed: errorCode=$errorCode") + try { + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVERED) { + resumeWaitWithException(Error("BLE scan failed with error code $errorCode")) + } else { + Logger.w(TAG, "onScanFailed but not waiting") + } + } catch (error: Throwable) { + onError(Error("onScanFailed failed", error)) + } + } + } + + private val gattCallback = object : BluetoothGattCallback() { + override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) { + Logger.d(TAG, "onConnectionStateChange: status=$status newState=$newState") + try { + when (newState) { + BluetoothProfile.STATE_CONNECTED -> { + gatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) + if (waitFor?.state == WaitState.CONNECT_TO_PERIPHERAL) { + resumeWait() + } else { + Logger.w(TAG, "onConnectionStateChange but not waiting") + } + } + + BluetoothProfile.STATE_DISCONNECTED -> { + if (waitFor?.state == WaitState.CONNECT_TO_PERIPHERAL) { + // See connectToPeripheral() for retries based on this exception + resumeWaitWithException(ConnectionFailedException("Failed to connect to peripheral")) + } else { + throw Error("Peripheral unexpectedly disconnected") + } + } + + else -> { + Logger.w(TAG, "onConnectionStateChange(): Unexpected newState $newState") + } + } + } catch (error: Throwable) { + onError(Error("onConnectionStateChange failed", error)) + } + } + + override fun onMtuChanged(gatt: BluetoothGatt?, mtu: Int, status: Int) { + Logger.d(TAG, "onMtuChanged: mtu=$mtu status=$status") + try { + if (waitFor?.state == WaitState.REQUEST_MTU) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("Expected GATT_SUCCESS but got $status")) + } else { + if (mtu < 22) { + resumeWaitWithException(Error("Unexpected MTU size $mtu")) + } else { + maxCharacteristicSize = min(mtu - 3, 512) + Logger.i( + TAG, + "MTU is $mtu, using $maxCharacteristicSize as maximum data size for characteristics" + ) + resumeWait() + } + } + } else { + Logger.w(TAG, "onMtuChanged but not waiting") + } + } catch (error: Throwable) { + onError(Error("onMtuChanged failed", error)) + } + } + + override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) { + Logger.d(TAG, "onServicesDiscovered: status=$status") + try { + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVER_SERVICES) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onServicesDiscovered but not waiting") + } + } catch (error: Throwable) { + onError(Error("onServicesDiscovered failed", error)) + } + } + + override fun onServiceChanged(gatt: BluetoothGatt) { + Logger.d(TAG, "onServiceChanged") + // Receiving this event means that the GATT database is out of sync with the remote device. + // We assume this means that the service has vanished. + // + // If we're using L2CAP, we don't use this signal. Instead we rely on the socket being + // closed. + if (l2capSocket == null) { + onError(Error("onServiceChanged: GATT Service vanished")) + } + } + + override fun onCharacteristicRead( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int + ) { + Logger.d( + TAG, + "onCharacteristicRead: characteristic=${characteristic.uuid} value=[${value.size} bytes] status=$status" + ) + try { + if (characteristic.uuid == l2capCharacteristicUuid?.toJavaUuid()) { + if (waitFor?.state == WaitState.GET_L2CAP_PSM) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException( + Error("onCharacteristicRead: Expected GATT_SUCCESS but got $status") + ) + } else { + if (value.size != 4) { + resumeWaitWithException( + Error("onCharacteristicRead: Expected four bytes for PSM, got ${value.size}") + ) + } + _l2capPsm = ((value[0].toUInt().and(0xffU) shl 24) + + (value[1].toUInt().and(0xffU) shl 16) + + (value[2].toUInt().and(0xffU) shl 8) + + (value[3].toUInt().and(0xffU) shl 0)).toInt() + Logger.i(TAG, "L2CAP PSM is $_l2capPsm") + resumeWait() + } + } else { + Logger.w(TAG, "onCharacteristicRead for L2CAP PSM but not waiting") + } + + } else if (characteristic.uuid == identCharacteristicUuid!!.toJavaUuid()) { + if (waitFor?.state == WaitState.GET_READER_IDENT) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException( + Error("onCharacteristicRead: Expected GATT_SUCCESS but got $status") + ) + } + if (expectedIdentValue contentEquals value) { + resumeWait() + } else { + resumeWaitWithException( + Error( + "onCharacteristicRead: Expected ${expectedIdentValue!!.toHex()} " + + "for ident, got ${value.toHex()} instead" + ) + ) + } + } else { + Logger.w(TAG, "onCharacteristicRead for ident but not waiting") + } + } + } catch (error: Throwable) { + onError(Error("onCharacteristicRead failed", error)) + } + } + + override fun onCharacteristicWrite( + gatt: BluetoothGatt?, + characteristic: BluetoothGattCharacteristic?, + status: Int + ) { + Logger.d(TAG, "onCharacteristicWrite: characteristic=${characteristic?.uuid ?: ""} status=$status") + if (waitFor?.state == WaitState.CHARACTERISTIC_WRITE_COMPLETED) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("onCharacteristicWrite: Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onCharacteristicWrite for characteristic ${characteristic?.uuid} but not waiting") + } + } + + override fun onDescriptorWrite( + gatt: BluetoothGatt?, + descriptor: BluetoothGattDescriptor?, + status: Int + ) { + Logger.d(TAG, "onDescriptorWrite: descriptor=${descriptor?.uuid ?: ""} status=$status") + if (waitFor?.state == WaitState.WRITE_TO_DESCRIPTOR) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onDescriptorWrite but not waiting") + } + } + + override fun onCharacteristicChanged( + gatt: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray + ) { + Logger.d(TAG, "onCharacteristicChanged: characteristic=${characteristic.uuid} value=[${value.size} bytes]") + try { + if (characteristic.uuid == server2ClientCharacteristicUuid.toJavaUuid()) { + handleIncomingData(value) + } else if (characteristic.uuid == stateCharacteristicUuid.toJavaUuid()) { + // Transport-specific session termination as per 18013-5 clause 8.3.3.1.1.5 Connection state + if (value contentEquals byteArrayOf(BleTransportConstants.STATE_CHARACTERISTIC_END.toByte())) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Ignoring unexpected write to state characteristic") + } + } + } catch (error: Throwable) { + onError(Error("onCharacteristicChanged failed", error)) + } + } + } + + var incomingMessage = ByteStringBuilder() + + var maxCharacteristicSize = -1 + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Server2Client characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + // Because this is not fatal and some buggy implementations do this, we treat + // it just as a warning, not an error. + Logger.w( + TAG, "Server2Client received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes" + ) + } + } + + else -> { + throw Error( + "Invalid first byte ${chunk[0]} in Server2Client data chunk, " + + "expected 0 or 1" + ) + } + } + } + + + override suspend fun waitForPowerOn() { + // Not needed on Android + return + } + + override suspend fun waitForPeripheralWithUuid(uuid: UUID) { + + // The Android Bluetooth stack has protection built-in against applications scanning + // too frequently. The way this works is that Android will simply not report + // anything back to the app but will print + // + // App 'com.example.appname' is scanning too frequently + // + // to logcat. We work around this by retrying the scan operation if we haven't + // gotten a result in 10 seconds. + // + var retryCount = 0 + while (true) { + val rc = withTimeoutOrNull(10.seconds) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVERED, continuation) + val filter = ScanFilter.Builder() + .setServiceUuid(ParcelUuid(uuid.toJavaUuid())) + .build() + val settings = ScanSettings.Builder() + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + .build() + bluetoothManager.adapter.bluetoothLeScanner + .startScan(listOf(filter), settings, scanCallback) + } + true + } + bluetoothManager.adapter.bluetoothLeScanner.stopScan(scanCallback) + if (rc != null) { + break + } + clearWaitCondition() + retryCount++ + // Note: we never give up b/c it's possible this is used by a wallet app which is simply + // sitting at the "Show QR code" dialog. + // + Logger.i(TAG, "Failed to find peripheral after $retryCount attempt(s) of 10 secs. Restarting scan.") + } + } + + override suspend fun connectToPeripheral() { + check(device != null) + + // Connection attempts sometimes fail... but will work on subsequent + // tries. So we implement a simple retry loop. + var retryCount = 0 + while (true) { + try { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CONNECT_TO_PERIPHERAL, continuation) + gatt = device!!.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE) + } + break + } catch (error: ConnectionFailedException) { + if (retryCount < 10) { + retryCount++ + Logger.w(TAG, "Failed connecting to peripheral after $retryCount attempt(s), retrying in 500 msec") + delay(500.milliseconds) + } else { + Logger.w(TAG, "Failed connecting to peripheral after $retryCount attempts. Giving up.") + throw error + } + } + } + } + + override suspend fun requestMtu() { + check(device != null && gatt != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.REQUEST_MTU, continuation) + // 515 is the largest MTU that makes sense given that characteristics must be 512 + // bytes or less. + gatt!!.requestMtu(515) + } + } + + override suspend fun peripheralDiscoverServices(uuid: UUID) { + check(device != null && gatt != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVER_SERVICES, continuation) + gatt!!.discoverServices() + } + service = gatt!!.getService(uuid.toJavaUuid()) + if (service == null) { + throw Error("No service with the given UUID") + } + } + + override suspend fun peripheralDiscoverCharacteristics() { + check(device != null && gatt != null && service != null) + characteristicState = service!!.getCharacteristic(stateCharacteristicUuid.toJavaUuid()) + if (characteristicState == null) { + throw Error("State characteristic not found") + } + characteristicClient2Server = + service!!.getCharacteristic(client2ServerCharacteristicUuid.toJavaUuid()) + if (characteristicClient2Server == null) { + throw Error("Client2Server characteristic not found") + } + characteristicServer2Client = + service!!.getCharacteristic(server2ClientCharacteristicUuid.toJavaUuid()) + if (characteristicServer2Client == null) { + throw Error("Server2Client characteristic not found") + } + if (identCharacteristicUuid != null) { + characteristicIdent = service!!.getCharacteristic(identCharacteristicUuid!!.toJavaUuid()) + if (characteristicIdent == null) { + throw Error("Ident characteristic not found") + } + } + if (l2capCharacteristicUuid != null) { + characteristicL2cap = service!!.getCharacteristic(l2capCharacteristicUuid!!.toJavaUuid()) + if (characteristicL2cap == null) { + Logger.i(TAG, "L2CAP characteristic requested but not found") + } else { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.GET_L2CAP_PSM, continuation) + gatt!!.readCharacteristic(characteristicL2cap) + } + } + } + } + + var expectedIdentValue: ByteArray? = null + + override suspend fun checkReaderIdentMatches(eSenderKey: EcPublicKey) { + check(device != null && gatt != null && identCharacteristicUuid != null) + + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + expectedIdentValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.GET_READER_IDENT, continuation) + gatt!!.readCharacteristic(characteristicIdent) + } + } + + private suspend fun enableNotifications( + characteristic: BluetoothGattCharacteristic + ) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.WRITE_TO_DESCRIPTOR, continuation) + + // This is what the 16-bit UUID 0x29 0x02 is encoded like. + val clientCharacteristicConfigUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + if (!gatt!!.setCharacteristicNotification(characteristic, true)) { + throw Error("Error setting notification") + } + val descriptor = characteristic.getDescriptor(clientCharacteristicConfigUuid.toJavaUuid()) + if (descriptor == null) { + throw Error("Error getting clientCharacteristicConfig descriptor") + } + descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) + if (!gatt!!.writeDescriptor(descriptor)) { + throw Error("Error writing to clientCharacteristicConfig descriptor") + } + } + } + + override suspend fun subscribeToCharacteristics() { + enableNotifications(characteristicState!!) + enableNotifications(characteristicServer2Client!!) + } + + private suspend fun writeToCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val rc = gatt!!.writeCharacteristic( + characteristic, + value, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + ) + if (rc != BluetoothStatusCodes.SUCCESS) { + throw Error("Error writing to characteristic ${characteristic.uuid}, rc=$rc") + } + } else { + characteristic!!.setValue(value) + if (!gatt!!.writeCharacteristic(characteristic)) { + throw Error("Error writing to characteristic ${characteristic.uuid}") + } + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_WRITE_COMPLETED, continuation) + } + } + + override suspend fun writeToStateCharacteristic(value: Int) { + writeToCharacteristic(characteristicState!!, byteArrayOf(value.toByte())) + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capSocket != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristic(characteristicClient2Server!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + override fun close() { + Logger.d(TAG, "close()") + bluetoothManager.adapter.bluetoothLeScanner.stopScan(scanCallback) + gatt?.disconnect() + gatt?.close() + gatt = null + device = null + // Needed since l2capSocket.outputStream.flush() isn't working + l2capSocket?.let { + CoroutineScope(Dispatchers.IO).launch() { + delay(5000) + it.close() + } + } + l2capSocket = null + incomingMessages.close() + } + + private var _l2capPsm: Int? = null + + override val l2capPsm: Int? + get() = _l2capPsm + + override val usingL2cap: Boolean + get() = (l2capSocket != null) + + private var l2capSocket: BluetoothSocket? = null + + override suspend fun connectL2cap(psm: Int) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + connectL2capQ(psm) + } else { + throw IllegalStateException("L2CAP only support on API 29 or later") + } + } + + @RequiresApi(api = Build.VERSION_CODES.Q) + private suspend fun connectL2capQ(psm: Int) { + l2capSocket = device!!.createInsecureL2capChannel(psm) + l2capSocket!!.connect() + + // Start reading in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capReadSocket(l2capSocket!!.inputStream) + } + } + + private suspend fun l2capReadSocket(inputStream: InputStream) { + try { + while (true) { + val encodedLength = inputStream.readNOctets(4U) + val length = (encodedLength[0].toUInt().and(0xffU) shl 24) + + (encodedLength[1].toUInt().and(0xffU) shl 16) + + (encodedLength[2].toUInt().and(0xffU) shl 8) + + (encodedLength[3].toUInt().and(0xffU) shl 0) + val message = inputStream.readNOctets(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + onError(Error("Reading from L2CAP socket failed", e)) + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.size.toUInt() + bsb.apply { + append((length shr 24).and(0xffU).toByte()) + append((length shr 16).and(0xffU).toByte()) + append((length shr 8).and(0xffU).toByte()) + append((length shr 0).and(0xffU).toByte()) + } + bsb.append(message) + l2capSocket?.outputStream?.write(bsb.toByteString().toByteArray()) + l2capSocket?.outputStream?.flush() + } +} + +// Cannot call it readNBytes() b/c that's taken on API >= 33 +// +internal fun InputStream.readNOctets(len: UInt): ByteArray { + val bsb = ByteStringBuilder() + var remaining = len.toInt() + while (remaining > 0) { + val buf = ByteArray(remaining.toInt()) + val numBytesRead = this.read(buf, 0, remaining) + if (numBytesRead == -1) { + throw IllegalStateException("Failed reading from input stream") + } + bsb.append(buf, 0, numBytesRead) + remaining -= numBytesRead + } + return bsb.toByteString().toByteArray() +} + diff --git a/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt new file mode 100644 index 000000000..995a7fa38 --- /dev/null +++ b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerAndroid.kt @@ -0,0 +1,616 @@ +package com.android.identity.mdoc.transport + +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothGattServer +import android.bluetooth.BluetoothGattServerCallback +import android.bluetooth.BluetoothGattService +import android.bluetooth.BluetoothManager +import android.bluetooth.BluetoothServerSocket +import android.bluetooth.BluetoothSocket +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.AdvertiseCallback +import android.bluetooth.le.AdvertiseData +import android.bluetooth.le.AdvertiseSettings +import android.bluetooth.le.BluetoothLeAdvertiser +import android.os.Build +import android.os.ParcelUuid +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.AndroidInitializer +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toHex +import com.android.identity.util.toJavaUuid +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.io.bytestring.ByteStringBuilder +import java.io.OutputStream +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +internal class BlePeripheralManagerAndroid: BlePeripheralManager { + companion object { + private const val TAG = "BlePeripheralManagerAndroid" + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + private var negotiatedMtu = -1 + private var maxCharacteristicSizeMemoized = 0 + private val maxCharacteristicSize: Int + get() { + if (maxCharacteristicSizeMemoized > 0) { + return maxCharacteristicSizeMemoized + } + var mtuSize = negotiatedMtu + if (mtuSize == -1) { + Logger.w(TAG, "MTU not negotiated, defaulting to 23. Performance will suffer.") + mtuSize = 23 + } + maxCharacteristicSizeMemoized = min(512, mtuSize - 3) + Logger.i(TAG, "Using maxCharacteristicSize $maxCharacteristicSizeMemoized") + return maxCharacteristicSizeMemoized + } + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID? + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks(onError: (Throwable) -> Unit, onClosed: () -> Unit) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + SERVICE_ADDED, + STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, + START_ADVERTISING, + CHARACTERISTIC_WRITE_COMPLETED, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + ) + + private var waitFor: WaitFor? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + ) + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private val context = AndroidInitializer.applicationContext + private val bluetoothManager = context.getSystemService(BluetoothManager::class.java) + private var gattServer: BluetoothGattServer? = null + private var service: BluetoothGattService? = null + private var readCharacteristic: BluetoothGattCharacteristic? = null + private var writeCharacteristic: BluetoothGattCharacteristic? = null + private var stateCharacteristic: BluetoothGattCharacteristic? = null + private var identCharacteristic: BluetoothGattCharacteristic? = null + private var l2capCharacteristic: BluetoothGattCharacteristic? = null + private var identValue: ByteArray? = null + private var advertiser: BluetoothLeAdvertiser? = null + private var device: BluetoothDevice? = null + + private val gattServerCallback = object: BluetoothGattServerCallback() { + + override fun onServiceAdded(status: Int, service: BluetoothGattService?) { + Logger.d(TAG, "onServiceAdded: $status") + if (waitFor?.state == WaitState.SERVICE_ADDED) { + if (status == BluetoothGatt.GATT_SUCCESS) { + resumeWait() + } else { + resumeWaitWithException(Error("onServiceAdded: Expected GATT_SUCCESS got $status")) + } + } else { + Logger.w(TAG, "onServiceAdded but not waiting") + } + } + + override fun onConnectionStateChange(device: BluetoothDevice, status: Int, newState: Int) { + Logger.d(TAG, "onConnectionStateChange: ${device.address} $status + $newState") + } + + override fun onCharacteristicReadRequest( + device: BluetoothDevice, requestId: Int, offset: Int, + characteristic: BluetoothGattCharacteristic + ) { + Logger.d(TAG, "onCharacteristicReadRequest: ${device.address} $requestId " + + "$offset ${characteristic.uuid}") + if (characteristic == identCharacteristic) { + if (identValue == null) { + onError(Error("Received request for ident before it's set..")) + } else { + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + identValue ?: byteArrayOf() + ) + } + } else if (characteristic == l2capCharacteristic) { + var encodedPsm = byteArrayOf() + if (l2capServerSocket != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val psm = l2capServerSocket!!.psm.toUInt() + val bsb = ByteStringBuilder() + bsb.apply { + append((psm shr 24).and(0xffU).toByte()) + append((psm shr 16).and(0xffU).toByte()) + append((psm shr 8).and(0xffU).toByte()) + append((psm shr 0).and(0xffU).toByte()) + } + encodedPsm = bsb.toByteString().toByteArray() + } + } else { + Logger.w(TAG, "Got a request for L2CAP characteristic but not listening") + } + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + encodedPsm + ) + } else { + Logger.w(TAG, "Read on unexpected characteristic with UUID ${characteristic.uuid}") + } + + } + + override fun onCharacteristicWriteRequest( + device: BluetoothDevice, + requestId: Int, + characteristic: BluetoothGattCharacteristic, + preparedWrite: Boolean, + responseNeeded: Boolean, + offset: Int, + value: ByteArray + ) { + Logger.d(TAG, "onCharacteristicWriteRequest: ${device.address} $requestId " + + "$offset ${characteristic.uuid} ${value.toHex()}") + + if (responseNeeded) { + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + null + ) + } + + if (characteristic.uuid == stateCharacteristicUuid.toJavaUuid()) { + if (value contentEquals byteArrayOf( + BleTransportConstants.STATE_CHARACTERISTIC_START.toByte() + )) { + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + this@BlePeripheralManagerAndroid.device = device + // Since the central found us, we can stop advertising.... + advertiser?.stopAdvertising(advertiseCallback) + // Also, stop listening for L2CAP connections... + l2capNotUsed = true + l2capServerSocket?.close() + l2capServerSocket = null + resumeWait() + } + } else if (value contentEquals byteArrayOf(BleTransportConstants.STATE_CHARACTERISTIC_END.toByte())) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Ignoring unexpected write to state characteristic") + } + } else if (characteristic.uuid == client2ServerCharacteristicUuid.toJavaUuid()) { + try { + handleIncomingData(value) + } catch (e: Throwable) { + onError(Error("Error processing incoming data", e)) + } + } + } + + override fun onDescriptorWriteRequest( + device: BluetoothDevice, requestId: Int, + descriptor: BluetoothGattDescriptor, + preparedWrite: Boolean, responseNeeded: Boolean, + offset: Int, value: ByteArray + ) { + if (Logger.isDebugEnabled) { + Logger.d( + TAG, "onDescriptorWriteRequest: ${device.address}" + + "${descriptor.characteristic.uuid} $offset ${value.toHex()}" + ) + } + if (responseNeeded) { + gattServer!!.sendResponse( + device, + requestId, + BluetoothGatt.GATT_SUCCESS, + 0, + null + ) + } + } + + override fun onMtuChanged(device: BluetoothDevice, mtu: Int) { + negotiatedMtu = mtu + Logger.d(TAG, "Negotiated MTU $mtu for $${device.address}") + } + + override fun onNotificationSent(device: BluetoothDevice, status: Int) { + Logger.d(TAG, "onNotificationSent $status for ${device.address}") + if (waitFor?.state == WaitState.CHARACTERISTIC_WRITE_COMPLETED) { + if (status != BluetoothGatt.GATT_SUCCESS) { + resumeWaitWithException(Error("onNotificationSent: Expected GATT_SUCCESS but got $status")) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "onNotificationSent but not waiting") + } + } + } + + private val advertiseCallback: AdvertiseCallback = object : AdvertiseCallback() { + override fun onStartSuccess(settingsInEffect: AdvertiseSettings) { + if (waitFor?.state == WaitState.START_ADVERTISING) { + resumeWait() + } else { + Logger.w(TAG, "Unexpected AdvertiseCallback.onStartSuccess() callback") + } + } + + override fun onStartFailure(errorCode: Int) { + if (waitFor?.state == WaitState.START_ADVERTISING) { + resumeWaitWithException(Error("Started advertising failed with $errorCode")) + } else { + Logger.w(TAG, "Unexpected AdvertiseCallback.onStartFailure() callback") + } + } + } + + private var incomingMessage = ByteStringBuilder() + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Client2Server characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + Logger.w(TAG, "Client2Server received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes") + } + } + + else -> { + throw Error("Invalid first byte ${chunk[0]} in Client2Server data chunk, " + + "expected 0 or 1") + } + } + } + + override suspend fun waitForPowerOn() { + // Not needed on Android + return + } + + // This is what the 16-bit UUID 0x29 0x02 is encoded like. + private var clientCharacteristicConfigUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + private fun addCharacteristic( + characteristicUuid: UUID, + properties: Int, + permissions: Int, + ): BluetoothGattCharacteristic { + val characteristic = BluetoothGattCharacteristic( + characteristicUuid.toJavaUuid(), + properties, + permissions + ) + if (properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) { + val descriptor = BluetoothGattDescriptor( + clientCharacteristicConfigUuid.toJavaUuid(), + BluetoothGattDescriptor.PERMISSION_WRITE + ) + descriptor.setValue(BluetoothGattDescriptor.DISABLE_NOTIFICATION_VALUE) + characteristic.addDescriptor(descriptor) + } + service!!.addCharacteristic(characteristic) + return characteristic + } + + override suspend fun advertiseService(uuid: UUID) { + gattServer = bluetoothManager.openGattServer(context, gattServerCallback) + service = BluetoothGattService( + uuid.toJavaUuid(), + BluetoothGattService.SERVICE_TYPE_PRIMARY + ) + + stateCharacteristic = addCharacteristic( + characteristicUuid = stateCharacteristicUuid, + properties = BluetoothGattCharacteristic.PROPERTY_NOTIFY + BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + permissions = BluetoothGattCharacteristic.PERMISSION_WRITE + ) + readCharacteristic = addCharacteristic( + characteristicUuid = client2ServerCharacteristicUuid, + properties = BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE, + permissions = BluetoothGattCharacteristic.PERMISSION_WRITE + ) + writeCharacteristic = addCharacteristic( + characteristicUuid = server2ClientCharacteristicUuid, + properties = BluetoothGattCharacteristic.PROPERTY_NOTIFY, + permissions = BluetoothGattCharacteristic.PERMISSION_WRITE + ) + if (identCharacteristicUuid != null) { + identCharacteristic = addCharacteristic( + characteristicUuid = identCharacteristicUuid!!, + properties = BluetoothGattCharacteristic.PROPERTY_READ, + permissions = BluetoothGattCharacteristic.PERMISSION_READ + ) + } + if (l2capCharacteristicUuid != null) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + l2capServerSocket = bluetoothManager.adapter.listenUsingInsecureL2capChannel() + // Listen in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capListen() + } + l2capCharacteristic = addCharacteristic( + characteristicUuid = l2capCharacteristicUuid!!, + properties = BluetoothGattCharacteristic.PROPERTY_READ, + permissions = BluetoothGattCharacteristic.PERMISSION_READ + ) + } else { + Logger.w(TAG, "L2CAP only support on API 29 or later") + } + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.SERVICE_ADDED, continuation) + + gattServer!!.addService(service!!) + } + + advertiser = bluetoothManager.adapter.bluetoothLeAdvertiser + if (advertiser == null) { + throw Error("Advertiser not available, is Bluetooth off?") + } + val settings = AdvertiseSettings.Builder() + .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY) + .setConnectable(true) + .setTimeout(0) + .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_MEDIUM) + .build() + val data = AdvertiseData.Builder() + .setIncludeTxPowerLevel(false) + .addServiceUuid(ParcelUuid(uuid.toJavaUuid())) + .build() + Logger.d(TAG, "Started advertising UUID $uuid") + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.START_ADVERTISING, continuation) + advertiser!!.startAdvertising(settings, data, advertiseCallback) + } + } + + override suspend fun setESenderKey(eSenderKey: EcPublicKey) { + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + identValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + } + + override suspend fun waitForStateCharacteristicWriteOrL2CAPClient() { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, continuation) + } + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capSocket != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristic(writeCharacteristic!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + private suspend fun writeToCharacteristic( + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val rc = gattServer!!.notifyCharacteristicChanged( + device!!, + characteristic, + false, + value) + if (rc != BluetoothStatusCodes.SUCCESS) { + throw Error("Error notifyCharacteristicChanged on characteristic ${characteristic.uuid} rc=$rc") + } + } else { + characteristic.setValue(value) + if (!gattServer!!.notifyCharacteristicChanged( + device!!, + characteristic, + false) + ) { + throw Error("Error notifyCharacteristicChanged on characteristic ${characteristic.uuid}") + } + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_WRITE_COMPLETED, continuation) + } + } + + override suspend fun writeToStateCharacteristic(value: Int) { + writeToCharacteristic(stateCharacteristic!!, byteArrayOf(value.toByte())) + } + + override fun close() { + device = null + advertiser?.stopAdvertising(advertiseCallback) + advertiser = null + gattServer?.removeService(service) + gattServer?.close() + gattServer = null + service = null + l2capServerSocket?.close() + l2capServerSocket = null + incomingMessages.close() + l2capSocket?.let { + CoroutineScope(Dispatchers.IO).launch() { + delay(5000) + it.close() + } + } + l2capSocket = null + } + + override val usingL2cap: Boolean + get() = (l2capSocket != null) + + override val l2capPsm: Int? + get() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return l2capServerSocket?.psm + } else { + return null + } + } + + + private var l2capServerSocket: BluetoothServerSocket? = null + private var l2capSocket: BluetoothSocket? = null + // Set to true iff the other peer ended up using GATT instead of L2CAP + private var l2capNotUsed = false + + private suspend fun l2capListen() { + try { + l2capSocket = l2capServerSocket!!.accept() + + l2capServerSocket?.close() + l2capServerSocket = null + + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + Logger.i(TAG, "L2CAP connection") + device = l2capSocket!!.remoteDevice + // Since the central found us, we can stop advertising.... + advertiser?.stopAdvertising(advertiseCallback) + resumeWait() + } else { + Logger.w(TAG, "Got a L2CAP client but not waiting") + return + } + + val inputStream = l2capSocket!!.inputStream + while (true) { + val encodedLength = inputStream.readNOctets(4U) + val length = (encodedLength[0].toUInt().and(0xffU) shl 24) + + (encodedLength[1].toUInt().and(0xffU) shl 16) + + (encodedLength[2].toUInt().and(0xffU) shl 8) + + (encodedLength[3].toUInt().and(0xffU) shl 0) + val message = inputStream.readNOctets(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + if (l2capNotUsed) { + Logger.d(TAG, "Ignoring error since l2capNotUsed is true", e) + } else { + onError(Error("Accepting/reading from L2CAP socket failed", e)) + } + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.size.toUInt() + bsb.apply { + append((length shr 24).and(0xffU).toByte()) + append((length shr 16).and(0xffU).toByte()) + append((length shr 8).and(0xffU).toByte()) + append((length shr 0).and(0xffU).toByte()) + } + bsb.append(message) + l2capSocket?.outputStream?.write(bsb.toByteString().toByteArray()) + l2capSocket?.outputStream?.flush() + } +} \ No newline at end of file diff --git a/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt new file mode 100644 index 000000000..f863193da --- /dev/null +++ b/identity-mdoc/src/androidMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.android.kt @@ -0,0 +1,68 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle + +actual class MdocTransportFactory { + actual companion object { + actual fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions, + ): MdocTransport { + when (connectionMethod) { + is ConnectionMethodBle -> { + if (connectionMethod.supportsCentralClientMode && + connectionMethod.supportsPeripheralServerMode) { + throw IllegalArgumentException( + "Only Central Client or Peripheral Server mode is supported at one time, not both" + ) + } else if (connectionMethod.supportsCentralClientMode) { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportCentralMdoc( + role, + options, + BleCentralManagerAndroid(), + connectionMethod.centralClientModeUuid!!, + connectionMethod.peripheralServerModePsm + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportCentralMdocReader( + role, + options, + BlePeripheralManagerAndroid(), + connectionMethod.centralClientModeUuid!! + ) + } + } + } else { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportPeripheralMdoc( + role, + options, + BlePeripheralManagerAndroid(), + connectionMethod.peripheralServerModeUuid!! + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportPeripheralMdocReader( + role, + options, + BleCentralManagerAndroid(), + connectionMethod.peripheralServerModeUuid!!, + connectionMethod.peripheralServerModePsm, + ) + } + } + } + } + else -> { + throw IllegalArgumentException("$connectionMethod is not supported") + } + } + } + } +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt index 179ce2946..594290e6c 100644 --- a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/sessionencryption/SessionEncryption.kt @@ -228,6 +228,18 @@ class SessionEncryption( end() Cbor.encode(end().build()) } + + /** + * Gets the ephemeral reader key in a `SessionEstablishment` message. + * + * @param the bytes of a `SessionEstablishment` message. + * @return the reader key, as a [EcPublicKey]. + */ + fun getEReaderKey(sessionEstablishmentMessage: ByteArray): EcPublicKey { + val map = Cbor.decode(sessionEstablishmentMessage) + val encodedEReaderKey = map["eReaderKey"].asTagged.asBstr + return Cbor.decode(encodedEReaderKey).asCoseKey.ecPublicKey + } } } diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt new file mode 100644 index 000000000..3c964290c --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleCentralManager.kt @@ -0,0 +1,63 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.UUID +import kotlinx.coroutines.channels.Channel + +internal interface BleCentralManager { + val incomingMessages: Channel + + fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capUuid: UUID? + ) + + fun setCallbacks( + /** + * Called if an error occurs asynchronously and the error isn't bubbled back + * to one of the methods on this object. Never invoked by any of the instance + * methods. + */ + onError: (error: Throwable) -> Unit, + + /** + * Called on transport-specific termination. Never invoked by any of the instance + * methods. + */ + onClosed: () -> Unit + ) + + suspend fun waitForPowerOn() + + suspend fun waitForPeripheralWithUuid(uuid: UUID) + + suspend fun connectToPeripheral() + + suspend fun requestMtu() + + suspend fun peripheralDiscoverServices(uuid: UUID) + + suspend fun peripheralDiscoverCharacteristics() + + suspend fun checkReaderIdentMatches(eSenderKey: EcPublicKey) + + suspend fun subscribeToCharacteristics() + + suspend fun writeToStateCharacteristic(value: Int) + + suspend fun sendMessage(message: ByteArray) + + // The PSM read from the L2CAP characteristic or null if the peripheral server does not + // support it or checking for L2CAP wasn't requested when calling [setUuids] + val l2capPsm: Int? + + suspend fun connectL2cap(psm: Int) + + // True if connected via L2CAP + val usingL2cap: Boolean + + fun close() +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt new file mode 100644 index 000000000..5e76e0d2d --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManager.kt @@ -0,0 +1,56 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.UUID +import kotlinx.coroutines.channels.Channel + +internal interface BlePeripheralManager { + val incomingMessages: Channel + + fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capUuid: UUID? + ) + + fun setCallbacks( + /** + * Called if an error occurs asynchronously and the error isn't bubbled back + * to one of the methods on this object. Never invoked by any of the instance + * methods. + */ + onError: (error: Throwable) -> Unit, + + /** + * Called on transport-specific termination. Never invoked by any of the instance + * methods. + */ + onClosed: () -> Unit + ) + + suspend fun waitForPowerOn() + + suspend fun advertiseService(uuid: UUID) + + suspend fun setESenderKey(eSenderKey: EcPublicKey) + + suspend fun waitForStateCharacteristicWriteOrL2CAPClient() + + suspend fun writeToStateCharacteristic(value: Int) + + suspend fun sendMessage(message: ByteArray) + + fun close() + + // The PSM if listening on L2CAP. + // + // This is guaranteed to be available after [advertiseService] is called if the `l2capUuid` passed + // to [setUuids] isn't `null`. + // + val l2capPsm: Int? + + // True if connected via L2CAP + val usingL2cap: Boolean +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt new file mode 100644 index 000000000..65609934e --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdoc.kt @@ -0,0 +1,172 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportCentralMdoc( + override val role: Role, + private val options: MdocTransportOptions, + private val centralManager: BleCentralManager, + private val uuid: UUID, + private val psm: Int? +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportCentralMdoc" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() = ConnectionMethodBle(false, true, null, uuid) + + init { + centralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000005-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000006-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000007-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = UUID.fromString("00000008-a123-48ce-896b-4c76973373e6"), + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000b-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + centralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + // Nothing to do here. + } + + private var _scanningTime: Duration? = null + override val scanningTime: Duration? + get() = _scanningTime + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + try { + _state.value = State.SCANNING + centralManager.waitForPowerOn() + val timeScanningStarted = Clock.System.now() + centralManager.waitForPeripheralWithUuid(uuid) + _scanningTime = Clock.System.now() - timeScanningStarted + _state.value = State.CONNECTING + if (psm != null) { + // If the PSM is known at engagement-time we can bypass the entire GATT server + // and just connect directly. + centralManager.connectL2cap(psm) + } else { + centralManager.connectToPeripheral() + centralManager.requestMtu() + centralManager.peripheralDiscoverServices(uuid) + centralManager.peripheralDiscoverCharacteristics() + centralManager.checkReaderIdentMatches(eSenderKey) + if (centralManager.l2capPsm != null) { + centralManager.connectL2cap(centralManager.l2capPsm!!) + } else { + centralManager.subscribeToCharacteristics() + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_START) + } + } + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return centralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && centralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + centralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + centralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "closeWithoutDelay called without holding lock" } + centralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + closeWithoutDelay() + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt new file mode 100644 index 000000000..c68450d09 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportCentralMdocReader.kt @@ -0,0 +1,169 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportCentralMdocReader( + override val role: Role, + private val options: MdocTransportOptions, + private val peripheralManager: BlePeripheralManager, + private val uuid: UUID +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportCentralMdocReader" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() { + val cm = ConnectionMethodBle(false, true, null, uuid) + peripheralManager.l2capPsm?.let { cm.peripheralServerModePsm = it } + return cm + } + + init { + peripheralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000005-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000006-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000007-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = UUID.fromString("00000008-a123-48ce-896b-4c76973373e6"), + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000b-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + peripheralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + _state.value = State.ADVERTISING + } + } + + override val scanningTime: Duration? + get() = null + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE || _state.value == State.ADVERTISING) { + "Expected state IDLE or ADVERTISING, got ${_state.value}" + } + try { + if (_state.value != State.ADVERTISING) { + // Start advertising if we aren't already... + _state.value = State.ADVERTISING + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + } + peripheralManager.setESenderKey(eSenderKey) + // Note: It's not really possible to know someone is connecting to use until they're _actually_ + // connected. I mean, for all we know, someone could be BLE scanning us. So not really possible + // to go into State.CONNECTING... + peripheralManager.waitForStateCharacteristicWriteOrL2CAPClient() + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return peripheralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && peripheralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + peripheralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + peripheralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + peripheralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "closeWithoutDelay called without holding lock" } + peripheralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + peripheralManager.close() + _state.value = State.CLOSED + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt new file mode 100644 index 000000000..bfd765ee0 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportConstants.kt @@ -0,0 +1,12 @@ +package com.android.identity.mdoc.transport + +// Various constants used for Bluetooth Low Energy as used in ISO/IEC 18013-5:2021 +internal object BleTransportConstants { + + // This indicates that the mdoc reader may/will begin + // transmission (see ISO/IEC 18013-5:2021 Table 13). + const val STATE_CHARACTERISTIC_START = 0x01 + + // Signal to finish/terminate transaction (see ISO/IEC 18013-5:2021 Table 13).. + const val STATE_CHARACTERISTIC_END = 0x02 +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt new file mode 100644 index 000000000..9941af1c8 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdoc.kt @@ -0,0 +1,170 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportPeripheralMdoc( + override val role: Role, + private val options: MdocTransportOptions, + private val peripheralManager: BlePeripheralManager, + private val uuid: UUID +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportPeripheralMdoc" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() { + val cm = ConnectionMethodBle(true, false, uuid, null) + peripheralManager.l2capPsm?.let { cm.peripheralServerModePsm = it } + return cm + } + + init { + peripheralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000001-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000002-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000003-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = null, + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000a-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + peripheralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + Logger.w(TAG, "BlePeripheralManager close") + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + _state.value = State.ADVERTISING + } + } + + override val scanningTime: Duration? + get() = null + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE || _state.value == State.ADVERTISING) { + "Expected state IDLE or ADVERTISING, got ${_state.value}" + } + try { + if (_state.value != State.ADVERTISING) { + // Start advertising if we aren't already... + _state.value = State.ADVERTISING + peripheralManager.waitForPowerOn() + peripheralManager.advertiseService(uuid) + } + peripheralManager.setESenderKey(eSenderKey) + // Note: It's not really possible to know someone is connecting to us until they're _actually_ + // connected. I mean, for all we know, someone could be BLE scanning us. So not really possible + // to go into State.CONNECTING... + peripheralManager.waitForStateCharacteristicWriteOrL2CAPClient() + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return peripheralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && peripheralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + peripheralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + peripheralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + peripheralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "closeWithoutDelay called without holding lock" } + peripheralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + peripheralManager.close() + _state.value = State.CLOSED + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt new file mode 100644 index 000000000..7dd658c6f --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/BleTransportPeripheralMdocReader.kt @@ -0,0 +1,175 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +internal class BleTransportPeripheralMdocReader( + override val role: Role, + private val options: MdocTransportOptions, + private val centralManager: BleCentralManager, + private val uuid: UUID, + private val psm: Int? +) : MdocTransport() { + companion object { + private const val TAG = "BleTransportPeripheralMdocReader" + } + + private val mutex = Mutex() + + private val _state = MutableStateFlow(State.IDLE) + override val state: StateFlow = _state.asStateFlow() + + override val connectionMethod: ConnectionMethod + get() = ConnectionMethodBle(true, false, uuid, null) + + init { + centralManager.setUuids( + stateCharacteristicUuid = UUID.fromString("00000001-a123-48ce-896b-4c76973373e6"), + client2ServerCharacteristicUuid = UUID.fromString("00000002-a123-48ce-896b-4c76973373e6"), + server2ClientCharacteristicUuid = UUID.fromString("00000003-a123-48ce-896b-4c76973373e6"), + identCharacteristicUuid = null, + l2capUuid = if (options.bleUseL2CAP) { + UUID.fromString("0000000a-a123-48ce-896b-4c76973373e6") + } else { + null + } + ) + centralManager.setCallbacks( + onError = { error -> + runBlocking { + mutex.withLock { + failTransport(error) + } + } + }, + onClosed = { + Logger.w(TAG, "BleCentralManager close") + runBlocking { + mutex.withLock { + closeWithoutDelay() + } + } + } + ) + } + + override suspend fun advertise() { + // Nothing to do here. + } + + private var _scanningTime: Duration? = null + override val scanningTime: Duration? + get() = _scanningTime + + override suspend fun open(eSenderKey: EcPublicKey) { + mutex.withLock { + check(_state.value == State.IDLE) { "Expected state IDLE, got ${_state.value}" } + try { + _state.value = State.SCANNING + centralManager.waitForPowerOn() + val timeScanningStarted = Clock.System.now() + centralManager.waitForPeripheralWithUuid(uuid) + _scanningTime = Clock.System.now() - timeScanningStarted + _state.value = State.CONNECTING + if (psm != null) { + // If the PSM is known at engagement-time we can bypass the entire GATT server + // and just connect directly. + centralManager.connectL2cap(psm) + } else { + centralManager.connectToPeripheral() + centralManager.requestMtu() + centralManager.peripheralDiscoverServices(uuid) + centralManager.peripheralDiscoverCharacteristics() + // NOTE: ident characteristic isn't used when the mdoc is the GATT server so we don't call + // centralManager.checkReaderIdentMatches(eSenderKey) + if (centralManager.l2capPsm != null) { + centralManager.connectL2cap(centralManager.l2capPsm!!) + } else { + centralManager.subscribeToCharacteristics() + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_START) + } + } + _state.value = State.CONNECTED + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while opening transport", error) + } + } + } + + override suspend fun waitForMessage(): ByteArray { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + } + try { + return centralManager.incomingMessages.receive() + } catch (error: Throwable) { + if (_state.value == State.CLOSED) { + throw MdocTransportClosedException("Transport was closed while waiting for message") + } else { + mutex.withLock { + failTransport(error) + } + throw MdocTransportException("Failed while waiting for message", error) + } + } + } + + override suspend fun sendMessage(message: ByteArray) { + mutex.withLock { + check(_state.value == State.CONNECTED) { "Expected state CONNECTED, got ${_state.value}" } + if (message.isEmpty() && centralManager.usingL2cap) { + throw MdocTransportTerminationException("Transport-specific termination not available with L2CAP") + } + try { + if (message.isEmpty()) { + centralManager.writeToStateCharacteristic(BleTransportConstants.STATE_CHARACTERISTIC_END) + } else { + centralManager.sendMessage(message) + } + } catch (error: Throwable) { + failTransport(error) + throw MdocTransportException("Failed while sending message", error) + } + } + } + + private fun failTransport(error: Throwable) { + check(mutex.isLocked) { "failTransport called without holding lock" } + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + Logger.w(TAG, "Failing transport with error", error) + centralManager.close() + _state.value = State.FAILED + } + + private fun closeWithoutDelay() { + check(mutex.isLocked) { "failTransport called without holding lock" } + centralManager.close() + _state.value = State.CLOSED + } + + override suspend fun close() { + mutex.withLock { + if (_state.value == State.FAILED || _state.value == State.CLOSED) { + return + } + centralManager.close() + _state.value = State.CLOSED + } + } +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt new file mode 100644 index 000000000..59ed8fea6 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransport.kt @@ -0,0 +1,151 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.crypto.EcPublicKey +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import kotlinx.coroutines.flow.StateFlow +import kotlin.time.Duration + +/** + * An abstraction of a ISO/IEC 18013-5:2021 device retrieval method. + * + * A [MdocTransport]'s state can be tracked in the [state] property which is [State.IDLE] + * when constructed from the factory. This is a [StateFlow] and intended to be used by + * the application to update its user interface. + * + * To open a connection to the other peer, call [open]. When [open] returns successfully + * the state is [State.CONNECTED]. At this point, the application can use [sendMessage] + * and [waitForMessage] to exchange messages with the remote peer. + * + * Each [MdocTransport] has a role, indicating whether the application is acting as + * the _mdoc_ or _mdoc reader_. For forward engagement, if acting in the role [Role.MDOC] + * the application should create one or more [ConnectionMethod] instances, call [open] on + * each of them, share the [ConnectionMethod]s (through QR or NFC in _Device Engagement_ + * according to ISO/EC 18013-5:2021). Similarly, the other peer - acting in the role + * [Role.MDOC_READER] - should obtain the _Device Engagement_, get one or more + * [ConnectionMethod] objects and call [open] on one of them. For reverse engagement, + * the process is the same but with the roles reversed. + * + * The transport can fail at any time, for example if the other peer sends invalid data + * or actively disconnects. In this case the state is changed to [State.FAILED] and + * any calls except for [close] will fail with the [MdocTransportException] exception. + * + * The connection can be closed at any time using the [close] method which will transition + * the state to [State.CLOSED] except if it's already in [State.FAILED]. + * + * [MdocTransport] instances are thread-safe and methods and properties can be called from + * any thread or coroutine. + */ +abstract class MdocTransport { + /** + * The role of the transport + */ + enum class Role { + /** The transport is being used by an _mdoc_. */ + MDOC, + + /** The transport is being used by an _mdoc reader_. */ + MDOC_READER + } + + /** + * Possible states for a transport. + */ + enum class State { + /** The transport is idle. */ + IDLE, + + /** The transport is being advertised. */ + ADVERTISING, + + /** The transport is scanning. */ + SCANNING, + + /** A remote peer has been identified and the connection is being set up. */ + CONNECTING, + + /** The transport is connected to the remote peer. */ + CONNECTED, + + /** The transport was connected at one point but one of the sides closed the connection. */ + CLOSED, + + /** The connection to the remote peer failed. */ + FAILED + } + + /** + * The current state of the transport. + */ + abstract val state: StateFlow + + /** + * The role which the transport is for. + */ + abstract val role: Role + + /** + * A [ConnectionMethod] which can be sent to the other peer to connect to. + */ + abstract val connectionMethod: ConnectionMethod + + /** + * The time spent scanning for the other peer. + * + * This is always `null` until [open] completes and it's only set for transports that actually perform active + * scanning for the other peer. This includes _BLE mdoc central client mode_ for [Role.MDOC] and + * _BLE mdoc peripheral server mode_ for [Role.MDOC_READER]. + */ + abstract val scanningTime: Duration? + + /** + * Starts advertising the connection. + * + * This is optional for transports to implement. + */ + abstract suspend fun advertise() + + /** + * Opens the connection to the other peer. + * + * @param eSenderKey This should be set to `EDeviceKey` if using forward engagement or + * `EReaderKey` if using reverse engagement. + */ + abstract suspend fun open(eSenderKey: EcPublicKey) + + /** + * Sends a message to the other peer. + * + * This should be formatted as `SessionEstablishment` or `SessionData` according to + * ISO/IEC 18013-5:2021. + * + * To signal transport-specific termination send an empty message. If the transport doesn't support + * this, [MdocTransportTerminationException] is thrown. + * + * This blocks the calling coroutine until the message is sent. + * + * @param message the message to send. + * @throws MdocTransportException if an unrecoverable error occurs. + * @throws MdocTransportTerminationException if the transport doesn't support transport-specific termination. + */ + abstract suspend fun sendMessage(message: ByteArray) + + /** + * Waits for the other peer to send a message. + * + * This received message should be formatted as `SessionEstablishment` or `SessionData` + * according to ISO/IEC 18013-5:2021. Transport-specific session termination is indicated + * by the returned message being empty. + * + * @return the message that was received or empty if transport-specific session termination was used. + * @throws MdocTransportClosedException if [close] was called from another coroutine while waiting. + * @throws MdocTransportException if an unrecoverable error occurs. + */ + abstract suspend fun waitForMessage(): ByteArray + + /** + * Closes the connection. + * + * This can be called from any thread. + */ + abstract suspend fun close() +} diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt new file mode 100644 index 000000000..947fe4799 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportClosedException.kt @@ -0,0 +1,35 @@ +package com.android.identity.mdoc.transport + +/** + * Thrown by [MdocTransport.waitForMessage] if the transport was closed by another coroutine while waiting. + */ +class MdocTransportClosedException : Exception { + /** + * Construct a new exception. + */ + constructor() + + /** + * Construct a new exception. + * + */ + constructor(message: String) : super(message) + + /** + * Construct a new exception. + * + * @param message the message. + * @param cause the cause. + */ + constructor( + message: String, + cause: Throwable + ) : super(message, cause) + + /** + * Construct a new exception. + * + * @param cause the cause. + */ + constructor(cause: Throwable) : super(cause) +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt new file mode 100644 index 000000000..caa7ee432 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportException.kt @@ -0,0 +1,35 @@ +package com.android.identity.mdoc.transport + +/** + * Thrown if an unrecoverable error occurs. + */ +class MdocTransportException : Exception { + /** + * Construct a new exception. + */ + constructor() + + /** + * Construct a new exception. + * + */ + constructor(message: String) : super(message) + + /** + * Construct a new exception. + * + * @param message the message. + * @param cause the cause. + */ + constructor( + message: String, + cause: Throwable + ) : super(message, cause) + + /** + * Construct a new exception. + * + * @param cause the cause. + */ + constructor(cause: Throwable) : super(cause) +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt new file mode 100644 index 000000000..93edc7760 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.kt @@ -0,0 +1,24 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod + +/** + * A factory used to create [MdocTransport] instances. + */ +expect class MdocTransportFactory { + companion object { + /** + * Creates a new [MdocTransport]. + * + * @param connectionMethod the address to listen on or connect to. + * @param role the role of the [MdocTransport]. + * @param options options for the [MdocTransport]. + * @return A [MdocTransport] instance, ready to be used. + */ + fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions = MdocTransportOptions() + ): MdocTransport + } +} \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt new file mode 100644 index 000000000..ff82f048d --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportOptions.kt @@ -0,0 +1,10 @@ +package com.android.identity.mdoc.transport + +/** + * Options for using a [MdocTransport]. + * + * @property bleUseL2CAP set to `true` to use L2CAP if available, `false` otherwise + */ +data class MdocTransportOptions( + val bleUseL2CAP: Boolean = false +) \ No newline at end of file diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt new file mode 100644 index 000000000..6eb975522 --- /dev/null +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/transport/MdocTransportTerminationException.kt @@ -0,0 +1,36 @@ +package com.android.identity.mdoc.transport + +/** + * Thrown if transport-specific termination was requested but not supported + * by the underlying tranposrt. + */ +class MdocTransportTerminationException : Exception { + /** + * Construct a new exception. + */ + constructor() + + /** + * Construct a new exception. + * + */ + constructor(message: String) : super(message) + + /** + * Construct a new exception. + * + * @param message the message. + * @param cause the cause. + */ + constructor( + message: String, + cause: Throwable + ) : super(message, cause) + + /** + * Construct a new exception. + * + * @param cause the cause. + */ + constructor(cause: Throwable) : super(cause) +} \ No newline at end of file diff --git a/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt new file mode 100644 index 000000000..01d6cfe13 --- /dev/null +++ b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BleCentralManagerIos.kt @@ -0,0 +1,651 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toByteArray +import com.android.identity.util.toKotlinError +import com.android.identity.util.toHex +import com.android.identity.util.toNSData +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlinx.io.bytestring.ByteStringBuilder +import kotlinx.io.readByteArray +import platform.CoreBluetooth.CBCentralManager +import platform.CoreBluetooth.CBCentralManagerDelegateProtocol +import platform.CoreBluetooth.CBCentralManagerStatePoweredOn +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBCharacteristicWriteWithoutResponse +import platform.CoreBluetooth.CBL2CAPChannel +import platform.CoreBluetooth.CBPeripheral +import platform.CoreBluetooth.CBPeripheralDelegateProtocol +import platform.CoreBluetooth.CBPeripheralStateConnected +import platform.CoreBluetooth.CBService +import platform.CoreBluetooth.CBUUID +import platform.CoreFoundation.CFAbsoluteTime +import platform.Foundation.NSError +import platform.Foundation.NSNumber +import platform.darwin.NSObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +internal class BleCentralManagerIos: BleCentralManager { + companion object { + private const val TAG = "BleCentralManagerIos" + private const val L2CAP_CHUNK_SIZE = 512 + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID?, + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks( + onError: (Throwable) -> Unit, + onClosed: () -> Unit + ) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + POWER_ON, + PERIPHERAL_DISCOVERED, + CONNECT_TO_PERIPHERAL, + PERIPHERAL_DISCOVER_SERVICES, + PERIPHERAL_DISCOVER_CHARACTERISTICS, + PERIPHERAL_READ_VALUE_FOR_CHARACTERISTIC, + CHARACTERISTIC_READY_TO_WRITE, + OPEN_L2CAP_CHANNEL, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + val characteristic: CBCharacteristic? = null, + ) + + private var waitFor: WaitFor? = null + + private val centralManager: CBCentralManager + + private var peripheral: CBPeripheral? = null + + private var maxCharacteristicSize = 512 + + private var readCharacteristic: CBCharacteristic? = null + private var writeCharacteristic: CBCharacteristic? = null + private var stateCharacteristic: CBCharacteristic? = null + private var identCharacteristic: CBCharacteristic? = null + private var l2capCharacteristic: CBCharacteristic? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation, + characteristic: CBCharacteristic? = null + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + characteristic + ) + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + private val peripheralDelegate = object : NSObject(), CBPeripheralDelegateProtocol { + + override fun peripheral( + peripheral: CBPeripheral, + didDiscoverServices: NSError? + ) { + val error = didDiscoverServices + Logger.d(TAG, "didDiscoverServices peripheral=$peripheral error=${didDiscoverServices?.toKotlinError()}") + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVER_SERVICES) { + if (error != null) { + resumeWaitWithException(error.toKotlinError()) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "CBPeripheralDelegate didDiscoverServices callback but not waiting") + } + } + + override fun peripheral( + peripheral: CBPeripheral, + didDiscoverCharacteristicsForService: CBService, + error: NSError? + ) { + val service = didDiscoverCharacteristicsForService + Logger.d(TAG, "didDiscoverCharacteristicsForService service=$service error=${error?.toKotlinError()}") + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVER_CHARACTERISTICS) { + if (error != null) { + resumeWaitWithException(error.toKotlinError()) + } else { + resumeWait() + } + } else { + Logger.w(TAG, "CBPeripheralDelegate didDiscoverCharacteristicsForService callback but not waiting") + } + } + + override fun peripheral( + peripheral: CBPeripheral, + didUpdateValueForCharacteristic: CBCharacteristic, + error: NSError? + ) { + val characteristic = didUpdateValueForCharacteristic + Logger.d(TAG, "didUpdateValueForCharacteristic characteristic=$characteristic error=${error?.toKotlinError()}") + if (didUpdateValueForCharacteristic == readCharacteristic) { + try { + handleIncomingData(characteristic.value!!.toByteArray()) + } catch (error: Throwable) { + onError(error) + } + } else { + if (waitFor?.state == WaitState.PERIPHERAL_READ_VALUE_FOR_CHARACTERISTIC && + waitFor?.characteristic == characteristic + ) { + if (error != null) { + resumeWaitWithException(error.toKotlinError()) + } else { + resumeWait() + } + } else { + if (characteristic == stateCharacteristic) { + if (characteristic.value?.toByteArray() contentEquals byteArrayOf( + BleTransportConstants.STATE_CHARACTERISTIC_END.toByte() + )) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Unexpected value written to state characteristic") + } + } else { + Logger.w(TAG, "CBPeripheralDelegate didUpdateValueForCharacteristic callback but not waiting") + } + } + } + } + + override fun peripheralIsReadyToSendWriteWithoutResponse(peripheral: CBPeripheral) { + Logger.d(TAG, "peripheralIsReadyToSendWriteWithoutResponse peripheral=$peripheral") + if (waitFor?.state == WaitState.CHARACTERISTIC_READY_TO_WRITE) { + resumeWait() + } else { + Logger.w(TAG, "peripheralIsReadyToSendWriteWithoutResponse but not waiting") + } + } + + override fun peripheral(peripheral: CBPeripheral, didModifyServices: List<*>) { + val invalidatedServices = didModifyServices + Logger.d(TAG, "peripheral:didModifyServices invalidatedServices=${invalidatedServices}") + onError(Error("Remote service vanished")) + } + + override fun peripheral(peripheral: CBPeripheral, didOpenL2CAPChannel: CBL2CAPChannel?, error: NSError?) { + Logger.d(TAG, "peripheralDidOpenL2CAPChannel") + if (waitFor?.state == WaitState.OPEN_L2CAP_CHANNEL) { + if (error != null) { + resumeWaitWithException(Error("peripheralDidOpenL2CAPChannel failed", error.toKotlinError())) + } else { + this@BleCentralManagerIos.l2capChannel = didOpenL2CAPChannel + resumeWait() + } + } else { + Logger.w(TAG, "peripheralDidOpenL2CAPChannel but not waiting") + } + } + } + + private suspend fun writeToCharacteristicWithoutResponse( + characteristic: CBCharacteristic, + value: ByteArray + ) { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_READY_TO_WRITE, continuation) + + Logger.i(TAG, "writing") + peripheral!!.writeValue( + data = value.toNSData(), + forCharacteristic = characteristic, + CBCharacteristicWriteWithoutResponse + ) + if (peripheral!!.canSendWriteWithoutResponse) { + throw Error("canSendWriteWithoutResponse is true right after writing value") + } + } + } + + + private var incomingMessage = ByteStringBuilder() + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Server2Client characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + // Because this is not fatal and some buggy implementations do this, we treat + // it just as a warning, not an error. + Logger.w(TAG, "Server2Client received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes") + } + } + + else -> { + throw Error("Invalid first byte ${chunk[0]} in Server2Client data chunk, " + + "expected 0 or 1") + } + } + } + + private val centralManagerDelegate = object : NSObject(), CBCentralManagerDelegateProtocol { + override fun centralManagerDidUpdateState(cbCentralManager: CBCentralManager) { + Logger.d(TAG, "didUpdateState state=${centralManager.state}") + if (waitFor?.state == WaitState.POWER_ON) { + if (centralManager.state == CBCentralManagerStatePoweredOn) { + resumeWait() + } else { + resumeWaitWithException(Error("Excepted poweredOn, got ${centralManager.state}")) + } + } else { + Logger.w(TAG, "CBCentralManagerDelegate didUpdateState callback but not waiting") + } + } + + override fun centralManager( + central: CBCentralManager, + didDiscoverPeripheral: CBPeripheral, + advertisementData: Map, + RSSI: NSNumber + ) { + val discoveredPeripheral = didDiscoverPeripheral + Logger.d(TAG, "didDiscoverPeripheral: discoveredPeripheral=$discoveredPeripheral") + if (waitFor?.state == WaitState.PERIPHERAL_DISCOVERED) { + peripheral = discoveredPeripheral + peripheral!!.delegate = peripheralDelegate + resumeWait() + } else { + Logger.w(TAG, "CBCentralManagerDelegate didDiscoverPeripheral callback but not waiting") + } + } + + override fun centralManager( + central: CBCentralManager, + didConnectPeripheral: CBPeripheral + ) { + var connectedPeripheral = didConnectPeripheral + Logger.d(TAG, "didDiscoverPeripheral: peripheral=$connectedPeripheral") + if (waitFor?.state == WaitState.CONNECT_TO_PERIPHERAL) { + if (connectedPeripheral == peripheral) { + resumeWait() + } else { + Logger.w(TAG, "Callback for unexpected peripheral") + } + } else { + Logger.w(TAG, "CBCentralManagerDelegate didConnectPeripheral callback but not waiting") + } + } + + override fun centralManager( + central: CBCentralManager, + didDisconnectPeripheral: CBPeripheral, + timestamp: CFAbsoluteTime, + isReconnecting: Boolean, + error: NSError? + ) { + Logger.d(TAG, "didDisconnectPeripheral: peripheral=$didDisconnectPeripheral timestamp=${timestamp} " + + "isReconnecting=$isReconnecting error=${error?.toKotlinError()}") + onError(Error("Peripheral unexpectedly disconnected")) + } + } + + init { + centralManager = CBCentralManager( + delegate = centralManagerDelegate, + queue = null, + options = null, + ) + } + + override suspend fun waitForPowerOn() { + if (centralManager.state != CBCentralManagerStatePoweredOn) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.POWER_ON, continuation) + } + } + } + + override suspend fun waitForPeripheralWithUuid(uuid: UUID) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVERED, continuation) + centralManager.scanForPeripheralsWithServices( + serviceUUIDs = listOf(CBUUID.UUIDWithString(uuid.toString())), + options = null + ) + } + centralManager.stopScan() + } + + override suspend fun connectToPeripheral() { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CONNECT_TO_PERIPHERAL, continuation) + centralManager.connectPeripheral(peripheral!!, null) + } + } + + override suspend fun requestMtu() { + // The MTU isn't configurable in CoreBluetooth so this is a NO-OP. + } + + override suspend fun peripheralDiscoverServices(uuid: UUID) { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVER_SERVICES, continuation) + peripheral!!.discoverServices( + serviceUUIDs = listOf(CBUUID.UUIDWithString(uuid.toString())), + ) + } + } + + private fun getCharacteristic( + availableCharacteristics: List, + uuid: UUID, + ): CBCharacteristic { + val uuidLowercase = uuid.toString().lowercase() + val characteristic = availableCharacteristics.find { + it.UUID.UUIDString.lowercase() == uuidLowercase + } + if (characteristic == null) { + throw IllegalStateException("Missing characteristic with UUID $uuidLowercase") + } + return characteristic + } + + private suspend fun processCharacteristics( + availableCharacteristics: List + ) { + readCharacteristic = getCharacteristic( + availableCharacteristics, + server2ClientCharacteristicUuid + ) + writeCharacteristic = getCharacteristic( + availableCharacteristics, + client2ServerCharacteristicUuid + ) + stateCharacteristic = getCharacteristic( + availableCharacteristics, + stateCharacteristicUuid + ) + if (identCharacteristicUuid != null) { + identCharacteristic = getCharacteristic( + availableCharacteristics, + identCharacteristicUuid!! + ) + } + if (l2capCharacteristicUuid != null) { + try { + l2capCharacteristic = getCharacteristic( + availableCharacteristics, + l2capCharacteristicUuid!! + ) + readValueForCharacteristic(l2capCharacteristic!!) + val value = l2capCharacteristic!!.value!!.toByteArray() + _l2capPsm = ((value[0].toUInt().and(0xffU) shl 24) + + (value[1].toUInt().and(0xffU) shl 16) + + (value[2].toUInt().and(0xffU) shl 8) + + (value[3].toUInt().and(0xffU) shl 0)).toInt() + Logger.i(TAG, "L2CAP PSM is $_l2capPsm") + } catch (e: Throwable) { + Logger.i(TAG, "L2CAP not available on peripheral", e) + } + } + } + + override suspend fun peripheralDiscoverCharacteristics() { + check(peripheral != null) + for (service in peripheral!!.services!!) { + service as CBService + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PERIPHERAL_DISCOVER_CHARACTERISTICS, continuation) + peripheral!!.discoverCharacteristics( + characteristicUUIDs = null, + service as CBService, + ) + } + processCharacteristics(service.characteristics as List) + } + + val maximumWriteValueLengthForType = peripheral!!.maximumWriteValueLengthForType( + CBCharacteristicWriteWithoutResponse + ).toInt() + maxCharacteristicSize = min(maximumWriteValueLengthForType, 512) + Logger.i(TAG, "Using $maxCharacteristicSize as maximum data size for characteristics") + } + + private suspend fun readValueForCharacteristic(characteristic: CBCharacteristic) { + check(peripheral != null) + suspendCancellableCoroutine { continuation -> + setWaitCondition( + WaitState.PERIPHERAL_READ_VALUE_FOR_CHARACTERISTIC, + continuation, + characteristic + ) + peripheral!!.readValueForCharacteristic(characteristic) + } + } + + override suspend fun checkReaderIdentMatches(eSenderKey: EcPublicKey) { + check(peripheral != null) + check(identCharacteristic != null) + readValueForCharacteristic(identCharacteristic!!) + + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + val expectedIdentValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + val identValue = identCharacteristic!!.value!!.toByteArray() + if (!(expectedIdentValue contentEquals identValue)) { + close() + throw Error( + "Ident doesn't match, expected ${expectedIdentValue.toHex()} " + + " got ${identValue.toHex()}" + ) + } + } + + override suspend fun subscribeToCharacteristics() { + check(stateCharacteristic != null) + check(readCharacteristic != null) + peripheral!!.setNotifyValue(true, stateCharacteristic!!) + peripheral!!.setNotifyValue(true, readCharacteristic!!) + } + + override suspend fun writeToStateCharacteristic(value: Int) { + check(stateCharacteristic != null) + writeToCharacteristicWithoutResponse( + stateCharacteristic!!, + byteArrayOf(value.toByte()) + ) + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capChannel != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristicWithoutResponse(writeCharacteristic!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + override fun close() { + centralManager.delegate = null + // Delayed closed because there's no way to flush L2CAP connections... + peripheral?.let { + CoroutineScope(Dispatchers.IO).launch { + delay(5000) + centralManager.cancelPeripheralConnection(it) + } + } + peripheral?.delegate = null + peripheral = null + incomingMessages.close() + l2capSink?.close() + l2capSink = null + l2capSource?.close() + l2capSource = null + l2capChannel = null + } + + private var _l2capPsm: Int? = null + + override val l2capPsm: Int? + get() = _l2capPsm + + override val usingL2cap: Boolean + get() = (l2capChannel != null) + + private var l2capChannel: CBL2CAPChannel? = null + private var l2capSink: Sink? = null + private var l2capSource: Source? = null + + override suspend fun connectL2cap(psm: Int) { + Logger.i(TAG, "connectL2cap $psm") + check(peripheral != null) + if (peripheral!!.state != CBPeripheralStateConnected) { + // This can happen on the path where the PSM is used directly from the engagement + connectToPeripheral() + } + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.OPEN_L2CAP_CHANNEL, continuation) + peripheral!!.openL2CAPChannel(psm.toUShort()) + } + Logger.i(TAG, "l2capChannel open") + l2capSink = l2capChannel!!.outputStream!!.asSink().buffered() + l2capSource = l2capChannel!!.inputStream!!.asSource().buffered() + + // Start reading in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capReadChannel() + } + } + + private suspend fun l2capReadChannel() { + try { + while (true) { + val length = l2capSource!!.readInt() + val message = l2capSource!!.readByteArray(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + onError(Error("Reading from L2CAP channel failed", e)) + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.size.toUInt() + bsb.apply { + append((length shr 24).and(0xffU).toByte()) + append((length shr 16).and(0xffU).toByte()) + append((length shr 8).and(0xffU).toByte()) + append((length shr 0).and(0xffU).toByte()) + } + bsb.append(message) + val messageWithHeader = bsb.toByteString().toByteArray() + + // NOTE: for some reason, iOS just fills in zeroes near the end if we send a + // really large message. Chunking it up in individual writes fixes this. We use + // the constant value L2CAP_CHUNK_SIZE for the chunk size. + // + //l2capSink?.write(payload) + //l2capSink?.emit() + + for (offset in 0 until messageWithHeader.size step L2CAP_CHUNK_SIZE) { + val size = min(L2CAP_CHUNK_SIZE, messageWithHeader.size - offset) + val chunk = messageWithHeader.slice(IntRange(offset, offset + size - 1)).toByteArray() + l2capSink?.write(chunk) + l2capSink?.flush() + } + } +} diff --git a/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt new file mode 100644 index 000000000..eb37f983e --- /dev/null +++ b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/BlePeripheralManagerIos.kt @@ -0,0 +1,530 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.Tagged +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcPublicKey +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toByteArray +import com.android.identity.util.toHex +import com.android.identity.util.toKotlinError +import com.android.identity.util.toNSData +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.io.Sink +import kotlinx.io.Source +import kotlinx.io.asSink +import kotlinx.io.asSource +import kotlinx.io.buffered +import kotlinx.io.bytestring.ByteStringBuilder +import kotlinx.io.readByteArray +import platform.CoreBluetooth.CBATTErrorSuccess +import platform.CoreBluetooth.CBATTRequest +import platform.CoreBluetooth.CBPeripheralManager +import platform.CoreBluetooth.CBCharacteristic +import platform.CoreBluetooth.CBPeripheralManagerDelegateProtocol +import platform.CoreBluetooth.CBPeripheralManagerStatePoweredOn +import platform.CoreBluetooth.CBAdvertisementDataServiceUUIDsKey +import platform.CoreBluetooth.CBCharacteristicPropertyWriteWithoutResponse +import platform.CoreBluetooth.CBCharacteristicPropertyNotify +import platform.CoreBluetooth.CBAttributePermissionsReadable +import platform.CoreBluetooth.CBAttributePermissionsWriteable +import platform.CoreBluetooth.CBCharacteristicPropertyRead +import platform.CoreBluetooth.CBL2CAPChannel +import platform.CoreBluetooth.CBL2CAPPSM +import platform.CoreBluetooth.CBUUID +import platform.CoreBluetooth.CBMutableService +import platform.CoreBluetooth.CBMutableCharacteristic +import platform.Foundation.NSArray +import platform.Foundation.NSError +import platform.darwin.NSObject +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.math.min + +internal class BlePeripheralManagerIos: BlePeripheralManager { + companion object { + private const val TAG = "BlePeripheralManagerIos" + private const val L2CAP_CHUNK_SIZE = 512 + } + + private lateinit var stateCharacteristicUuid: UUID + private lateinit var client2ServerCharacteristicUuid: UUID + private lateinit var server2ClientCharacteristicUuid: UUID + private var identCharacteristicUuid: UUID? = null + private var l2capCharacteristicUuid: UUID? = null + + override fun setUuids( + stateCharacteristicUuid: UUID, + client2ServerCharacteristicUuid: UUID, + server2ClientCharacteristicUuid: UUID, + identCharacteristicUuid: UUID?, + l2capCharacteristicUuid: UUID? + ) { + this.stateCharacteristicUuid = stateCharacteristicUuid + this.client2ServerCharacteristicUuid = client2ServerCharacteristicUuid + this.server2ClientCharacteristicUuid = server2ClientCharacteristicUuid + this.identCharacteristicUuid = identCharacteristicUuid + this.l2capCharacteristicUuid = l2capCharacteristicUuid + } + + private lateinit var onError: (error: Throwable) -> Unit + private lateinit var onClosed: () -> Unit + + override fun setCallbacks( + onError: (Throwable) -> Unit, + onClosed: () -> Unit + ) { + this.onError = onError + this.onClosed = onClosed + } + + internal enum class WaitState { + POWER_ON, + STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, + CHARACTERISTIC_READY_TO_WRITE, + PUBLISH_L2CAP_CHANNEL, + } + + private data class WaitFor( + val state: WaitState, + val continuation: CancellableContinuation, + val characteristic: CBCharacteristic? = null, + ) + + private var waitFor: WaitFor? = null + + private fun setWaitCondition( + state: WaitState, + continuation: CancellableContinuation, + characteristic: CBCharacteristic? = null + ) { + check(waitFor == null) + waitFor = WaitFor( + state, + continuation, + characteristic + ) + } + + private fun resumeWait() { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resume(true) + } + + private fun resumeWaitWithException(exception: Throwable) { + val continuation = waitFor!!.continuation + waitFor = null + continuation.resumeWithException(exception) + } + + private var maxCharacteristicSize = -1 + + private var incomingMessage = ByteStringBuilder() + + override val incomingMessages = Channel(Channel.UNLIMITED) + + private fun handleIncomingData(chunk: ByteArray) { + if (chunk.size < 1) { + throw Error("Invalid data length ${chunk.size} for Client2Server characteristic") + } + incomingMessage.append(chunk, 1, chunk.size) + when { + chunk[0].toInt() == 0x00 -> { + // Last message. + val newMessage = incomingMessage.toByteString().toByteArray() + incomingMessage = ByteStringBuilder() + runBlocking { + incomingMessages.send(newMessage) + } + } + + chunk[0].toInt() == 0x01 -> { + if (chunk.size != maxCharacteristicSize) { + Logger.w(TAG, "Client2Server received ${chunk.size} bytes which is not the " + + "expected $maxCharacteristicSize bytes") + } + } + + else -> { + throw Error("Invalid first byte ${chunk[0]} in Client2Server data chunk, " + + "expected 0 or 1") + } + } + } + + private val peripheralManager: CBPeripheralManager + + private val peripheralManagerDelegate = object : NSObject(), CBPeripheralManagerDelegateProtocol { + override fun peripheralManagerDidUpdateState(peripheral: CBPeripheralManager) { + if (waitFor?.state == WaitState.POWER_ON) { + if (peripheralManager.state == CBPeripheralManagerStatePoweredOn) { + resumeWait() + } else { + resumeWaitWithException(Error("Excepted poweredOn, got ${peripheralManager.state}")) + } + } + } + + override fun peripheralManager( + peripheral: CBPeripheralManager, + didReceiveWriteRequests: List<*> + ) { + for (attRequest in didReceiveWriteRequests) { + attRequest as CBATTRequest + + if (attRequest.characteristic == stateCharacteristic) { + val data = attRequest.value?.toByteArray() ?: byteArrayOf() + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + if (!(data contentEquals byteArrayOf( + BleTransportConstants.STATE_CHARACTERISTIC_START.toByte() + ))) { + resumeWaitWithException( + Error("Expected 0x01 to be written to state characteristic, got ${data.toHex()}") + ) + } else { + // Now that the central connected, figure out how big the buffer is for writes. + val maximumUpdateValueLength = attRequest.central.maximumUpdateValueLength.toInt() + maxCharacteristicSize = min(maximumUpdateValueLength, 512) + Logger.i(TAG, "Using $maxCharacteristicSize as maximum data size for characteristics") + + // Since the central found us, we can stop advertising.... + peripheralManager.stopAdvertising() + + resumeWait() + } + } else { + if (data contentEquals byteArrayOf(BleTransportConstants.STATE_CHARACTERISTIC_END.toByte())) { + Logger.i(TAG, "Received transport-specific termination message") + runBlocking { + incomingMessages.send(byteArrayOf()) + } + } else { + Logger.w(TAG, "Got write to state characteristics without waiting for it") + } + } + } else if (attRequest.characteristic == readCharacteristic) { + val data = attRequest.value?.toByteArray() ?: byteArrayOf() + try { + handleIncomingData(data) + } catch (e: Throwable) { + onError(Error("Error processing incoming data", e)) + } + } else { + Logger.w(TAG, "Unexpected write to characteristic with UUID " + + attRequest.characteristic.UUID.UUIDString) + } + } + } + + override fun peripheralManager(peripheral: CBPeripheralManager, didReceiveReadRequest: CBATTRequest) { + val attRequest = didReceiveReadRequest + if (attRequest.characteristic == identCharacteristic) { + if (identValue == null) { + onError(Error("Received request for ident before it's set..")) + } else { + attRequest.value = identValue!!.toNSData() + peripheralManager.respondToRequest(attRequest, CBATTErrorSuccess) + } + } + } + + override fun peripheralManagerIsReadyToUpdateSubscribers(peripheral: CBPeripheralManager) { + if (waitFor?.state == WaitState.CHARACTERISTIC_READY_TO_WRITE) { + resumeWait() + } + } + + override fun peripheralManager( + peripheral: CBPeripheralManager, + didPublishL2CAPChannel: CBL2CAPPSM, + error: NSError? + ) { + Logger.i(TAG, "peripheralManager didPublishL2CAPChannel") + if (waitFor?.state == WaitState.PUBLISH_L2CAP_CHANNEL) { + if (error != null) { + resumeWaitWithException(Error("peripheralManager didPublishL2CAPChannel failed", error.toKotlinError())) + } else { + _l2capPsm = didPublishL2CAPChannel.toInt() + resumeWait() + } + } else { + Logger.w(TAG, "peripheralManager didPublishL2CAPChannel but not waiting") + } + } + + override fun peripheralManager( + peripheral: CBPeripheralManager, + didOpenL2CAPChannel: CBL2CAPChannel?, + error: NSError? + ) { + Logger.i(TAG, "peripheralManager didOpenL2CAPChannel") + + if (error != null) { + Logger.w(TAG, "peripheralManager didOpenL2CAPChannel error=$error") + } else { + if (waitFor?.state == WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT) { + // Since the central found us, we can stop advertising.... + peripheralManager.stopAdvertising() + + l2capChannel = didOpenL2CAPChannel + l2capSink = l2capChannel!!.outputStream!!.asSink().buffered() + l2capSource = l2capChannel!!.inputStream!!.asSource().buffered() + + // Start reading in a coroutine + CoroutineScope(Dispatchers.IO).launch { + l2capReadChannel() + } + + resumeWait() + } else { + Logger.w(TAG, "Ignoring incoming L2CAP connection since we're not waiting") + } + } + } + + } + + init { + peripheralManager = CBPeripheralManager( + delegate = peripheralManagerDelegate, + queue = null, + options = null + ) + } + + override suspend fun waitForPowerOn() { + if (peripheralManager.state != CBPeripheralManagerStatePoweredOn) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.POWER_ON, continuation) + } + } + } + + private var service: CBMutableService? = null + + private var readCharacteristic: CBMutableCharacteristic? = null + private var writeCharacteristic: CBMutableCharacteristic? = null + private var stateCharacteristic: CBMutableCharacteristic? = null + private var identCharacteristic: CBMutableCharacteristic? = null + private var l2capCharacteristic: CBMutableCharacteristic? = null + private var identValue: ByteArray? = null + + override suspend fun advertiseService(uuid: UUID) { + service = CBMutableService( + type = CBUUID.UUIDWithString(uuid.toString()), + primary = true + ) + stateCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(stateCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyNotify + + CBCharacteristicPropertyWriteWithoutResponse, + value = null, + permissions = CBAttributePermissionsWriteable, + ) + readCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(client2ServerCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyWriteWithoutResponse, + value = null, + permissions = CBAttributePermissionsWriteable, + ) + writeCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(server2ClientCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyNotify, + value = null, + permissions = CBAttributePermissionsReadable + CBAttributePermissionsWriteable, + ) + if (identCharacteristicUuid != null) { + identCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(identCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyRead, + value = null, + permissions = CBAttributePermissionsReadable, + ) + } + if (l2capCharacteristicUuid != null) { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.PUBLISH_L2CAP_CHANNEL, continuation) + peripheralManager.publishL2CAPChannelWithEncryption(false) + } + Logger.i(TAG, "Listening on PSM $_l2capPsm") + val bsb = ByteStringBuilder() + bsb.apply { + append((_l2capPsm!! shr 24).and(0xff).toByte()) + append((_l2capPsm!! shr 16).and(0xff).toByte()) + append((_l2capPsm!! shr 8).and(0xff).toByte()) + append((_l2capPsm!! shr 0).and(0xff).toByte()) + } + val encodedPsm = bsb.toByteString().toByteArray() + l2capCharacteristic = CBMutableCharacteristic( + type = CBUUID.UUIDWithString(l2capCharacteristicUuid.toString()), + properties = CBCharacteristicPropertyRead, + value = encodedPsm.toNSData(), + permissions = CBAttributePermissionsReadable, + ) + } + + service!!.setCharacteristics(( + service!!.characteristics ?: listOf()) + + listOf( + stateCharacteristic, + readCharacteristic, + writeCharacteristic, + ) + + (if (identCharacteristic != null) listOf(identCharacteristic) else listOf()) + + (if (l2capCharacteristic != null) listOf(l2capCharacteristic) else listOf()) + ) + peripheralManager.addService(service!!) + peripheralManager.startAdvertising( + mapOf( + CBAdvertisementDataServiceUUIDsKey to + (listOf(CBUUID.UUIDWithString(uuid.toString())) as NSArray) + ) + ) + } + + override suspend fun setESenderKey(eSenderKey: EcPublicKey) { + val ikm = Cbor.encode(Tagged(24, Bstr(Cbor.encode(eSenderKey.toCoseKey().toDataItem())))) + val info = "BLEIdent".encodeToByteArray() + val salt = byteArrayOf() + identValue = Crypto.hkdf(Algorithm.HMAC_SHA256, ikm, salt, info, 16) + } + + override suspend fun waitForStateCharacteristicWriteOrL2CAPClient() { + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.STATE_CHARACTERISTIC_WRITTEN_OR_L2CAP_CLIENT, continuation) + } + } + + private suspend fun writeToCharacteristic( + characteristic: CBMutableCharacteristic, + value: ByteArray, + ) { + while (true) { + val wasSent = peripheralManager.updateValue( + value = value.toNSData(), + forCharacteristic = characteristic, + onSubscribedCentrals = null + ) + if (wasSent) { + Logger.i(TAG, "Wrote to characteristic ${characteristic.UUID}") + break + } + Logger.i(TAG, "Not ready to send to characteristic ${characteristic.UUID}, waiting") + suspendCancellableCoroutine { continuation -> + setWaitCondition(WaitState.CHARACTERISTIC_READY_TO_WRITE, continuation) + } + } + } + + override suspend fun writeToStateCharacteristic(value: Int) { + writeToCharacteristic(stateCharacteristic!!, byteArrayOf(value.toByte())) + } + + override suspend fun sendMessage(message: ByteArray) { + if (l2capChannel != null) { + l2capSendMessage(message) + return + } + Logger.i(TAG, "sendMessage ${message.size} length") + val maxChunkSize = maxCharacteristicSize - 1 // Need room for the leading 0x00 or 0x01 + val offsets = 0 until message.size step maxChunkSize + for (offset in offsets) { + val moreDataComing = (offset != offsets.last) + val size = min(maxChunkSize, message.size - offset) + + val builder = ByteStringBuilder(size + 1) + builder.append(if (moreDataComing) 0x01 else 0x00) + builder.append(message, offset, offset + size) + val chunk = builder.toByteString().toByteArray() + + writeToCharacteristic(writeCharacteristic!!, chunk) + } + Logger.i(TAG, "sendMessage completed") + } + + override fun close() { + // Delayed closed because there's no way to flush L2CAP connections... + _l2capPsm?.let { + CoroutineScope(Dispatchers.IO).launch { + delay(5000) + peripheralManager.unpublishL2CAPChannel(it.toUShort()) + } + } + peripheralManager.stopAdvertising() + peripheralManager.removeAllServices() + service = null + peripheralManager.delegate = null + incomingMessages.close() + l2capSink?.close() + l2capSink = null + l2capSource?.close() + l2capSource = null + l2capChannel = null + } + + override val l2capPsm: Int? + get() = _l2capPsm + + private var _l2capPsm: Int? = null + + private var l2capChannel: CBL2CAPChannel? = null + private var l2capSink: Sink? = null + private var l2capSource: Source? = null + + override val usingL2cap: Boolean + get() = (l2capChannel != null) + + private suspend fun l2capReadChannel() { + try { + while (true) { + val length = l2capSource!!.readInt() + val message = l2capSource!!.readByteArray(length) + incomingMessages.send(message) + } + } catch (e: Throwable) { + onError(Error("Reading from L2CAP channel failed", e)) + } + } + + private suspend fun l2capSendMessage(message: ByteArray) { + Logger.i(TAG, "l2capSendMessage ${message.size} length") + val bsb = ByteStringBuilder() + val length = message.size.toUInt() + bsb.apply { + append((length shr 24).and(0xffU).toByte()) + append((length shr 16).and(0xffU).toByte()) + append((length shr 8).and(0xffU).toByte()) + append((length shr 0).and(0xffU).toByte()) + } + bsb.append(message) + val messageWithHeader = bsb.toByteString().toByteArray() + + // NOTE: for some reason, iOS just fills in zeroes near the end if we send a + // really large message. Chunking it up in individual writes fixes this. We use + // the constant value L2CAP_CHUNK_SIZE for the chunk size. + // + //l2capSink?.write(payload) + //l2capSink?.emit() + + for (offset in 0 until messageWithHeader.size step L2CAP_CHUNK_SIZE) { + val size = min(L2CAP_CHUNK_SIZE, messageWithHeader.size - offset) + val chunk = messageWithHeader.slice(IntRange(offset, offset + size - 1)).toByteArray() + l2capSink?.write(chunk) + l2capSink?.flush() + } + } + +} + diff --git a/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt new file mode 100644 index 000000000..1a16e5314 --- /dev/null +++ b/identity-mdoc/src/iosMain/kotlin/com/android/identity/mdoc/transport/MdocTransportFactory.ios.kt @@ -0,0 +1,68 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle + +actual class MdocTransportFactory { + actual companion object { + actual fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions + ): MdocTransport { + when (connectionMethod) { + is ConnectionMethodBle -> { + if (connectionMethod.supportsCentralClientMode && + connectionMethod.supportsPeripheralServerMode) { + throw IllegalArgumentException( + "Only Central Client or Peripheral Server mode is supported at one time, not both" + ) + } else if (connectionMethod.supportsCentralClientMode) { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportCentralMdoc( + role, + options, + BleCentralManagerIos(), + connectionMethod.centralClientModeUuid!!, + connectionMethod.peripheralServerModePsm + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportCentralMdocReader( + role, + options, + BlePeripheralManagerIos(), + connectionMethod.centralClientModeUuid!! + ) + } + } + } else { + return when (role) { + MdocTransport.Role.MDOC -> { + BleTransportPeripheralMdoc( + role, + options, + BlePeripheralManagerIos(), + connectionMethod.peripheralServerModeUuid!! + ) + } + MdocTransport.Role.MDOC_READER -> { + BleTransportPeripheralMdocReader( + role, + options, + BleCentralManagerIos(), + connectionMethod.peripheralServerModeUuid!!, + connectionMethod.peripheralServerModePsm, + ) + } + } + } + } + else -> { + throw IllegalArgumentException("$connectionMethod is not supported") + } + } + } + } +} \ No newline at end of file diff --git a/identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt b/identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt new file mode 100644 index 000000000..6be674d39 --- /dev/null +++ b/identity-mdoc/src/jvmMain/kotlin/com/android/identity/mdoc/transport/TransportFactory.jvm.kt @@ -0,0 +1,15 @@ +package com.android.identity.mdoc.transport + +import com.android.identity.mdoc.connectionmethod.ConnectionMethod + +actual class MdocTransportFactory { + actual companion object { + actual fun createTransport( + connectionMethod: ConnectionMethod, + role: MdocTransport.Role, + options: MdocTransportOptions, + ): MdocTransport { + throw NotImplementedError("MdocTransportFactory is not available for JVM") + } + } +} \ No newline at end of file diff --git a/identity/build.gradle.kts b/identity/build.gradle.kts index 16364b444..f6e7dbb88 100644 --- a/identity/build.gradle.kts +++ b/identity/build.gradle.kts @@ -1,7 +1,12 @@ +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.get +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.konan.target.HostManager plugins { alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) alias(libs.plugins.ksp) id("maven-publish") } @@ -14,6 +19,13 @@ kotlin { jvm() + androidTarget { + @OptIn(ExperimentalKotlinGradlePluginApi::class) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + listOf( iosX64(), iosArm64(), @@ -47,6 +59,8 @@ kotlin { } } + applyDefaultHierarchyTemplate() + sourceSets { val commonMain by getting { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin") @@ -75,6 +89,16 @@ kotlin { implementation(libs.tink) } } + + val androidMain by getting { + dependsOn(jvmMain) + dependencies { + implementation(libs.bouncy.castle.bcprov) + implementation(libs.bouncy.castle.bcpkix) + implementation(libs.tink) + } + } + } } @@ -98,6 +122,31 @@ tasks["iosSimulatorArm64SourcesJar"].dependsOn("kspCommonMainKotlinMetadata") tasks["jvmSourcesJar"].dependsOn("kspCommonMainKotlinMetadata") tasks["sourcesJar"].dependsOn("kspCommonMainKotlinMetadata") +android { + namespace = "com.android.identity" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + dependencies { + } + + packaging { + resources { + excludes += listOf("/META-INF/{AL2.0,LGPL2.1}") + excludes += listOf("/META-INF/versions/9/OSGI-INF/MANIFEST.MF") + } + } +} + + group = "com.android.identity" version = projectVersionName diff --git a/identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt b/identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt new file mode 100644 index 000000000..86f701283 --- /dev/null +++ b/identity/src/androidMain/kotlin/com/android/identity/util/AndroidInitializer.kt @@ -0,0 +1,13 @@ +package com.android.identity.util + +import android.content.Context + +class AndroidInitializer { + companion object { + lateinit var applicationContext: Context + + fun initialize(applicationContext: Context) { + AndroidInitializer.applicationContext = applicationContext + } + } +} diff --git a/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt b/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt index f1da2f7d1..4b33991ea 100644 --- a/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt +++ b/identity/src/iosMain/kotlin/com/android/identity/crypto/CryptoIos.kt @@ -5,6 +5,8 @@ import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.SecureEnclaveKeyUnlockData import com.android.identity.util.UUID +import com.android.identity.util.toByteArray +import com.android.identity.util.toNSData import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.addressOf import kotlinx.cinterop.allocArrayOf @@ -320,17 +322,3 @@ actual object Crypto { )?.toByteArray() ?: throw KeyLockedException("Unable to unlock key") } } - -@OptIn(ExperimentalForeignApi::class) -internal fun NSData.toByteArray(): ByteArray { - return ByteArray(length.toInt()).apply { - usePinned { - memcpy(it.addressOf(0), bytes, length) - } - } -} - -@OptIn(ExperimentalForeignApi::class) -internal fun ByteArray.toNSData(): NSData = memScoped { - NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.toULong()) -} \ No newline at end of file diff --git a/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt b/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt index 1cd2eb54b..dd2d54bc0 100644 --- a/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt +++ b/identity/src/iosMain/kotlin/com/android/identity/crypto/X509CertIos.kt @@ -3,6 +3,8 @@ package com.android.identity.crypto import com.android.identity.cbor.Bstr import com.android.identity.cbor.DataItem import com.android.identity.SwiftBridge +import com.android.identity.util.toByteArray +import com.android.identity.util.toNSData import kotlinx.cinterop.ExperimentalForeignApi import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi diff --git a/identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt b/identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt new file mode 100644 index 000000000..1a3228530 --- /dev/null +++ b/identity/src/iosMain/kotlin/com/android/identity/util/IosUtil.kt @@ -0,0 +1,32 @@ +package com.android.identity.util + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.allocArrayOf +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSError +import platform.Foundation.create +import platform.posix.memcpy + +// Various iOS related utilities +// + +@OptIn(ExperimentalForeignApi::class) +fun NSData.toByteArray(): ByteArray { + return ByteArray(length.toInt()).apply { + usePinned { + memcpy(it.addressOf(0), bytes, length) + } + } +} + +@OptIn(ExperimentalForeignApi::class) +fun ByteArray.toNSData(): NSData = memScoped { + NSData.create(bytes = allocArrayOf(this@toNSData), length = this@toNSData.size.toULong()) +} + +fun NSError.toKotlinError(): Error { + return Error("NSError domain=${this.domain} code=${this.code}: ${this.localizedDescription}") +} \ No newline at end of file diff --git a/identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt b/identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt new file mode 100644 index 000000000..49315a9ed --- /dev/null +++ b/identity/src/iosMain/kotlin/com/android/identity/util/UUID.ios.kt @@ -0,0 +1,13 @@ +package com.android.identity.util + +import platform.Foundation.NSUUID + +// TODO: this could probably be faster if we use blobs instead of strings. + +fun UUID.toNSUUID(): NSUUID { + return NSUUID(this.toString()) +} + +fun UUID.Companion.fromNSUUID(nsUUID: NSUUID): UUID { + return UUID.fromString(nsUUID.UUIDString) +} diff --git a/identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt b/identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt new file mode 100644 index 000000000..c57740e54 --- /dev/null +++ b/identity/src/iosTest/kotlin/com/android/identity/util/UUIDIosTest.kt @@ -0,0 +1,15 @@ +package com.android.identity.util + +import kotlin.test.Test +import kotlin.test.assertEquals +import platform.Foundation.NSUUID + +class UUIDIosTest { + @Test + fun testToFrom() { + val uuid = UUID.randomUUID() + val nsUuid: NSUUID = uuid.toNSUUID() + assertEquals(uuid.toString().lowercase(), nsUuid.toString().lowercase()) + assertEquals(UUID.fromNSUUID(nsUuid), uuid) + } +} \ No newline at end of file diff --git a/identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt b/identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt new file mode 100644 index 000000000..d327d92f0 --- /dev/null +++ b/identity/src/jvmTest/kotlin/com/android/identity/util/UUIDJvmTest.kt @@ -0,0 +1,14 @@ +package com.android.identity.util + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UUIDJvmTest { + @Test + fun testToFrom() { + val uuid = UUID.randomUUID() + val javaUuid: java.util.UUID = uuid.toJavaUuid() + assertEquals(uuid.toString().lowercase(), javaUuid.toString().lowercase()) + assertEquals(UUID.fromJavaUuid(javaUuid), uuid) + } +} \ No newline at end of file diff --git a/samples/testapp/build.gradle.kts b/samples/testapp/build.gradle.kts index 5b469f652..78c0d8d46 100644 --- a/samples/testapp/build.gradle.kts +++ b/samples/testapp/build.gradle.kts @@ -39,11 +39,16 @@ kotlin { sourceSets { + iosMain.dependencies { + implementation(libs.ktor.client.darwin) + } + androidMain.dependencies { implementation(compose.preview) implementation(libs.androidx.activity.compose) implementation(libs.bouncy.castle.bcprov) implementation(libs.androidx.biometrics) + implementation(libs.ktor.client.android) implementation(project(":identity-android")) implementation(project(":identity-android-csa")) } @@ -58,7 +63,9 @@ kotlin { implementation(compose.materialIconsExtended) implementation(libs.jetbrains.navigation.compose) implementation(libs.jetbrains.navigation.runtime) - + implementation(libs.jetbrains.lifecycle.viewmodel.compose) + implementation(libs.ktor.client.core) + implementation(libs.ktor.network) implementation(project(":identity")) implementation(project(":identity-mdoc")) implementation(project(":identity-appsupport")) diff --git a/samples/testapp/iosApp/TestApp/Info.plist b/samples/testapp/iosApp/TestApp/Info.plist index ede320772..4c49f3536 100644 --- a/samples/testapp/iosApp/TestApp/Info.plist +++ b/samples/testapp/iosApp/TestApp/Info.plist @@ -2,6 +2,8 @@ + NSBluetoothAlwaysUsageDescription + Bluetooth permission is required for proximity presentations NSCameraUsageDescription This app uses the camera to read QR codes NSFaceIDUsageDescription diff --git a/samples/testapp/src/androidMain/AndroidManifest.xml b/samples/testapp/src/androidMain/AndroidManifest.xml index 589ff97b3..84bea4c3d 100644 --- a/samples/testapp/src/androidMain/AndroidManifest.xml +++ b/samples/testapp/src/androidMain/AndroidManifest.xml @@ -8,6 +8,12 @@ + + + + + ConsentModalBottomSheet ConsentModalBottomSheet use-cases QR code generation and scanning + ISO mdoc Proximity Sharing + ISO mdoc Proximity Reading + ISO mdoc Multi-Device Testing \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt index 28ceffa8c..352b5855c 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/App.kt @@ -31,6 +31,9 @@ import com.android.identity.testapp.ui.AboutScreen import com.android.identity.testapp.ui.AndroidKeystoreSecureAreaScreen import com.android.identity.testapp.ui.ConsentModalBottomSheetListScreen import com.android.identity.testapp.ui.ConsentModalBottomSheetScreen +import com.android.identity.testapp.ui.IsoMdocMultiDeviceTestingScreen +import com.android.identity.testapp.ui.IsoMdocProximityReadingScreen +import com.android.identity.testapp.ui.IsoMdocProximitySharingScreen import com.android.identity.testapp.ui.PassphraseEntryFieldScreen import com.android.identity.testapp.ui.QrCodesScreen import com.android.identity.testapp.ui.SecureEnclaveSecureAreaScreen @@ -95,7 +98,10 @@ class App { onClickSecureEnclaveSecureArea = { navController.navigate(SecureEnclaveSecureAreaDestination.route) }, onClickPassphraseEntryField = { navController.navigate(PassphraseEntryFieldDestination.route) }, onClickConsentSheetList = { navController.navigate(ConsentModalBottomSheetListDestination.route) }, - onClickQrCodes = { navController.navigate(QrCodesDestination.route) } + onClickQrCodes = { navController.navigate(QrCodesDestination.route) }, + onClickIsoMdocProximitySharing = { navController.navigate(IsoMdocProximitySharingDestination.route) }, + onClickIsoMdocProximityReading = { navController.navigate(IsoMdocProximityReadingDestination.route) }, + onClickMdocTransportMultiDeviceTesting = { navController.navigate(IsoMdocMultiDeviceTestingDestination.route) }, ) } composable(route = AboutDestination.route) { @@ -148,6 +154,21 @@ class App { showToast = { message -> showToast(message) } ) } + composable(route = IsoMdocProximitySharingDestination.route) { + IsoMdocProximitySharingScreen( + showToast = { message -> showToast(message) } + ) + } + composable(route = IsoMdocProximityReadingDestination.route) { + IsoMdocProximityReadingScreen( + showToast = { message -> showToast(message) } + ) + } + composable(route = IsoMdocMultiDeviceTestingDestination.route) { + IsoMdocMultiDeviceTestingScreen( + showToast = { message -> showToast(message) } + ) + } } } } diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt index 9ccae185d..782c6ac35 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Destinations.kt @@ -8,6 +8,9 @@ import identitycredential.samples.testapp.generated.resources.android_keystore_s import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_screen_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_multi_device_testing_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_reading_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_sharing_title import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title import identitycredential.samples.testapp.generated.resources.qr_codes_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title @@ -77,6 +80,21 @@ data object QrCodesDestination : Destination { override val title = Res.string.qr_codes_screen_title } +data object IsoMdocProximitySharingDestination : Destination { + override val route = "iso_mdoc_proximity_sharing" + override val title = Res.string.iso_mdoc_proximity_sharing_title +} + +data object IsoMdocProximityReadingDestination : Destination { + override val route = "iso_mdoc_proximity_reading" + override val title = Res.string.iso_mdoc_proximity_reading_title +} + +data object IsoMdocMultiDeviceTestingDestination : Destination { + override val route = "iso_mdoc_multi_device_testing" + override val title = Res.string.iso_mdoc_multi_device_testing_title +} + val appDestinations = listOf( StartDestination, AboutDestination, @@ -88,4 +106,7 @@ val appDestinations = listOf( ConsentModalBottomSheetListDestination, ConsentModalBottomSheetDestination, QrCodesDestination, + IsoMdocProximitySharingDestination, + IsoMdocProximityReadingDestination, + IsoMdocMultiDeviceTestingDestination, ) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt index acd5d611d..d6fb008bd 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt @@ -6,3 +6,5 @@ enum class Platform { } expect val platform: Platform + +expect fun getLocalIpAddress(): String diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt new file mode 100644 index 000000000..3cb553a7b --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt @@ -0,0 +1,302 @@ +package com.android.identity.testapp + +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborArray +import com.android.identity.cbor.DataItem +import com.android.identity.cbor.Simple +import com.android.identity.cbor.Tagged +import com.android.identity.cbor.toDataItem +import com.android.identity.cose.Cose +import com.android.identity.cose.CoseLabel +import com.android.identity.cose.CoseNumberLabel +import com.android.identity.credential.CredentialFactory +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.EcCurve +import com.android.identity.crypto.EcPrivateKey +import com.android.identity.crypto.EcPublicKey +import com.android.identity.crypto.X509Cert +import com.android.identity.crypto.X509CertChain +import com.android.identity.document.DocumentStore +import com.android.identity.document.NameSpacedData +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.credential.MdocCredential +import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator +import com.android.identity.mdoc.mso.MobileSecurityObjectParser +import com.android.identity.mdoc.mso.StaticAuthDataGenerator +import com.android.identity.mdoc.mso.StaticAuthDataParser +import com.android.identity.mdoc.request.DeviceRequestGenerator +import com.android.identity.mdoc.response.DeviceResponseGenerator +import com.android.identity.mdoc.response.DocumentGenerator +import com.android.identity.mdoc.util.MdocUtil +import com.android.identity.securearea.KeyPurpose +import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaRepository +import com.android.identity.securearea.software.SoftwareCreateKeySettings +import com.android.identity.securearea.software.SoftwareSecureArea +import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.StorageEngine +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import kotlinx.datetime.Clock +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator +import kotlin.random.Random +import kotlin.time.Duration.Companion.hours + +object TestAppUtils { + private const val TAG = "TestAppUtils" + + fun generateEncodedDeviceRequest( + request: DocumentWellKnownRequest, + encodedSessionTranscript: ByteArray + ): ByteArray { + val mdocRequest = request.mdocRequest!! + val itemsToRequest = mutableMapOf>() + for (ns in mdocRequest.namespacesToRequest) { + for ((de, intentToRetain) in ns.dataElementsToRequest) { + itemsToRequest.getOrPut(ns.namespace) { mutableMapOf() } + .put(de.attribute.identifier, intentToRetain) + } + } + + val deviceRequestGenerator = DeviceRequestGenerator(encodedSessionTranscript) + deviceRequestGenerator.addDocumentRequest( + docType = mdocRequest.docType, + itemsToRequest = itemsToRequest, + requestInfo = null, + readerKey = null, + signatureAlgorithm = Algorithm.UNSET, + readerKeyCertificateChain = null, + ) + return deviceRequestGenerator.generate() + } + + fun generateEncodedSessionTranscript( + encodedDeviceEngagement: ByteArray, + eReaderKey: EcPublicKey + ): ByteArray { + val handover = Simple.NULL + val encodedEReaderKey = Cbor.encode(eReaderKey.toCoseKey().toDataItem()) + return Cbor.encode( + CborArray.builder() + .add(Tagged(24, Bstr(encodedDeviceEngagement))) + .add(Tagged(24, Bstr(encodedEReaderKey))) + .add(handover) + .end() + .build() + ) + } + + fun generateEncodedDeviceResponse( + consentFields: List, + encodedSessionTranscript: ByteArray + ): ByteArray { + val nsAndDataElements = mutableMapOf>() + consentFields.forEach { + nsAndDataElements.getOrPut(it.namespaceName, { mutableListOf() }).add(it.dataElementName) + } + + val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData).parse() + + val mergedIssuerNamespaces = MdocUtil.mergeIssuerNamesSpaces( + nsAndDataElements, + documentData, + staticAuthData + ) + val issuerAuthCoseSign1 = Cbor.decode(staticAuthData.issuerAuth).asCoseSign1 + val encodedMsoBytes = Cbor.decode(issuerAuthCoseSign1.payload!!) + val encodedMso = Cbor.encode(encodedMsoBytes.asTaggedEncodedCbor) + val mso = MobileSecurityObjectParser(encodedMso).parse() + + val documentGenerator = DocumentGenerator( + mso.docType, + staticAuthData.issuerAuth, + encodedSessionTranscript, + ) + documentGenerator.setIssuerNamespaces(mergedIssuerNamespaces) + + documentGenerator.setDeviceNamespacesSignature( + NameSpacedData.Builder().build(), + mdocCredential.secureArea, + mdocCredential.alias, + null, + Algorithm.ES256, + ) + + val deviceResponseGenerator = DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK) + deviceResponseGenerator.addDocument(documentGenerator.generate()) + return deviceResponseGenerator.generate() + } + + private lateinit var documentData: NameSpacedData + lateinit var mdocCredential: MdocCredential + + private lateinit var storageEngine: StorageEngine + private lateinit var secureArea: SecureArea + private lateinit var secureAreaRepository: SecureAreaRepository + private lateinit var credentialFactory: CredentialFactory + lateinit var documentTypeRepository: DocumentTypeRepository + lateinit var dsKeyPub: EcPublicKey + + init { + storageEngine = EphemeralStorageEngine() + secureAreaRepository = SecureAreaRepository() + secureArea = SoftwareSecureArea(storageEngine) + secureAreaRepository.addImplementation(secureArea) + credentialFactory = CredentialFactory() + credentialFactory.addCredentialImplementation(MdocCredential::class) { + document, dataItem -> MdocCredential(document, dataItem) + } + provisionDocument() + documentTypeRepository = DocumentTypeRepository() + documentTypeRepository.addDocumentType(DrivingLicense.getDocumentType()) + } + + private fun provisionDocument() { + val documentStore = DocumentStore( + storageEngine, + secureAreaRepository, + credentialFactory + ) + + // Create the document... + val document = documentStore.createDocument( + "testDocument" + ) + + val nsdBuilder = NameSpacedData.Builder() + for ((nsName, ns) in DrivingLicense.getDocumentType().mdocDocumentType?.namespaces!!) { + for ((deName, de) in ns.dataElements) { + val sampleValue = de.attribute.sampleValue + if (sampleValue != null) { + nsdBuilder.putEntry(nsName, deName, Cbor.encode(sampleValue)) + } else { + Logger.w(TAG, "No sample value for data element $deName") + } + } + } + documentData = nsdBuilder.build() + + document.applicationData.setNameSpacedData("documentData", documentData) + val overrides: MutableMap> = HashMap() + val exceptions: MutableMap> = HashMap() + + // Create an authentication key... make sure the authKey used supports both + // mdoc ECDSA and MAC authentication. + val now = Clock.System.now() + val timeSigned = now - 1.hours + val timeValidityBegin = now - 1.hours + val timeValidityEnd = now + 24.hours + mdocCredential = MdocCredential( + document, + null, + "AuthKeyDomain", + secureArea, + SoftwareCreateKeySettings.Builder() + .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) + .build(), + "org.iso.18013.5.1.mDL" + ) + + // Generate an MSO and issuer-signed data for this authentication key. + val msoGenerator = MobileSecurityObjectGenerator( + "SHA-256", + DrivingLicense.MDL_DOCTYPE, + mdocCredential.attestation.publicKey + ) + msoGenerator.setValidityInfo(timeSigned, timeValidityBegin, timeValidityEnd, null) + val issuerNameSpaces = MdocUtil.generateIssuerNameSpaces( + documentData, + Random, + 16, + overrides + ) + for (nameSpaceName in issuerNameSpaces.keys) { + val digests = MdocUtil.calculateDigestsForNameSpace( + nameSpaceName, + issuerNameSpaces, + Algorithm.SHA256 + ) + msoGenerator.addDigestIdsForNamespace(nameSpaceName, digests) + } + val validFrom = Clock.System.now() - 1.hours + val validUntil = validFrom + 24.hours + + val documentSignerKeyPub = EcPublicKey.fromPem( +"""-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnmiWAMGIeo2E3usWRLL/EPfh1Bw5 +JHgq8RYzJvraMj5QZSh94CL/nlEi3vikGxDP34HjxZcjzGEimGg03sB6Ng== +-----END PUBLIC KEY-----""", + EcCurve.P256 + ) + dsKeyPub = documentSignerKeyPub + + val documentSignerKey = EcPrivateKey.fromPem( + """-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg/ANvinTxJAdR8nQ0 +NoUdBMcRJz+xLsb0kmhyMk+lkkGhRANCAASeaJYAwYh6jYTe6xZEsv8Q9+HUHDkk +eCrxFjMm+toyPlBlKH3gIv+eUSLe+KQbEM/fgePFlyPMYSKYaDTewHo2 +-----END PRIVATE KEY-----""", + documentSignerKeyPub + ) + + val documentSignerCert = X509Cert.fromPem( +"""-----BEGIN CERTIFICATE----- +MIIBITCBx6ADAgECAgEBMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMMD1N0YXRlIE9mIFV0b3BpYTAe +Fw0yNDExMDcyMTUzMDdaFw0zNDExMDUyMTUzMDdaMBoxGDAWBgNVBAMMD1N0YXRlIE9mIFV0b3Bp +YTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ5olgDBiHqNhN7rFkSy/xD34dQcOSR4KvEWMyb6 +2jI+UGUofeAi/55RIt74pBsQz9+B48WXI8xhIphoNN7AejYwCgYIKoZIzj0EAwIDSQAwRgIhALkq +UIVeaSW0xhLuMdwHyjiwTV8USD4zq68369ZW6jBvAiEAj2smZAXJB04x/s3exzjnI5BQprUOSfYE +uku1Jv7gA+A= +-----END CERTIFICATE-----"""" + ) + + val mso = msoGenerator.generate() + val taggedEncodedMso = Cbor.encode(Tagged(24, Bstr(mso))) + + // IssuerAuth is a COSE_Sign1 where payload is MobileSecurityObjectBytes + // + // MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + // + val protectedHeaders = mapOf( + Pair( + CoseNumberLabel(Cose.COSE_LABEL_ALG), + Algorithm.ES256.coseAlgorithmIdentifier.toDataItem() + ) + ) + val unprotectedHeaders = mapOf( + Pair( + CoseNumberLabel(Cose.COSE_LABEL_X5CHAIN), + X509CertChain(listOf(documentSignerCert)).toDataItem() + ) + ) + val encodedIssuerAuth = Cbor.encode( + Cose.coseSign1Sign( + documentSignerKey, + taggedEncodedMso, + true, + Algorithm.ES256, + protectedHeaders, + unprotectedHeaders + ).toDataItem() + ) + val issuerProvidedAuthenticationData = StaticAuthDataGenerator( + MdocUtil.stripIssuerNameSpaces(issuerNameSpaces, exceptions), + encodedIssuerAuth + ).generate() + + // Now that we have issuer-provided authentication data we certify the authentication key. + mdocCredential.certify( + issuerProvidedAuthenticationData, + timeValidityBegin, + timeValidityEnd + ) + } + + +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt new file mode 100644 index 000000000..94fe6c297 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsClient.kt @@ -0,0 +1,186 @@ +package com.android.identity.testapp.multidevicetests + +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.engagement.EngagementParser +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.fromBase64Url +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import kotlinx.datetime.Clock +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +class MultiDeviceTestsClient( + val job: Job, + val socket: Socket, +) { + companion object { + private const val TAG = "MultiDeviceTestsClient" + private val LINE_LIMIT = 4096 + } + + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + suspend fun run() { + // Client listens for commands from server. + while (true) { + val cmd = receiveChannel.readUTF8Line(LINE_LIMIT) + Logger.i(TAG, "Received command '$cmd'") + if (cmd == null) { + throw Error("Receive channel was closed") + } else if (cmd == "Done") { + break + } else if (cmd.startsWith("TestPresentationPrepare ")) { + val parts = cmd.split(" ") + val iterationNumber = parts[1].toInt() + val numIterationsTotal = parts[2].toInt() + val testName = parts[3] + val usePrewarming = if (parts[4] == "true") true else false + val bleUseL2CAP = if (parts[5] == "true") true else false + val getPsmFromReader = if (parts[6] == "true") true else false + val encodedDeviceEngagement = parts[7].fromBase64Url() + val test = Test.valueOf(testName) + val options = MdocTransportOptions(bleUseL2CAP = bleUseL2CAP) + + Logger.i(TAG, "====== STARTING ITERATION ${iterationNumber} OF ${numIterationsTotal} ======") + Logger.i(TAG, "Test: $test") + Logger.iHex(TAG, "DeviceEngagement from server", encodedDeviceEngagement) + val deviceEngagement = + EngagementParser(encodedDeviceEngagement).parse() + val connectionMethod = deviceEngagement.connectionMethods[0] + val eDeviceKey = deviceEngagement.eSenderKey + val transport = MdocTransportFactory.createTransport( + connectionMethod, + MdocTransport.Role.MDOC_READER, + options + ) + Logger.i(TAG, "usePrewarming: $usePrewarming") + if (usePrewarming) { + transport.advertise() + } + if (getPsmFromReader) { + val cm = transport.connectionMethod as ConnectionMethodBle + sendChannel.writeStringUtf8("${cm.peripheralServerModePsm!!}\n") + } + val nextCmd = receiveChannel.readUTF8Line(LINE_LIMIT) + if (nextCmd != "TestPresentationStart") { + throw IllegalStateException("Expected 'TestPresentationStart' got $nextCmd") + } + Logger.i(TAG, "Starting") + try { + withTimeout(15.seconds) { + val timeStart = Clock.System.now() + transport.open(eDeviceKey) + + val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) + val encodedSessionTranscript = byteArrayOf(0x01, 0x02) + val sessionEncryption = SessionEncryption( + role = SessionEncryption.Role.MDOC_READER, + eSelfKey = eReaderKey, + remotePublicKey = eDeviceKey, + encodedSessionTranscript = encodedSessionTranscript + ) + val deviceRequest = ByteArray(2 * 1024) + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceRequest, + statusCode = null + ) + ) + val response = transport.waitForMessage() + val (deviceResponse, statusCode) = sessionEncryption.decryptMessage(response) + when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + // Expects termination in initial message + if (statusCode != Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + throw Error("Expected status 20, got $statusCode") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + val (deviceResponse, statusCode) = sessionEncryption.decryptMessage( + transport.waitForMessage() + ) + if (deviceResponse != null || + statusCode != Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + throw Error("Expected empty message and status 20") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + // Expects a termination via BLE + val sessionData = transport.waitForMessage() + if (sessionData.isNotEmpty()) { + throw Error("Expected transport-specific termination, got non-empty message") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + // Expects reader to terminate via message + transport.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE -> { + if (statusCode != null) { + throw Error("Expected status to be unset got $statusCode") + } + // Expects reader to terminate via BLE + transport.sendMessage(byteArrayOf()) + } + } + + val timeEnd = Clock.System.now() + val timeEngagementToResponseMsec = (timeEnd - timeStart).toInt(DurationUnit.MILLISECONDS) + val scanningTimeMsec = transport.scanningTime?.toInt(DurationUnit.MILLISECONDS) ?: 0 + sendChannel.writeStringUtf8("TestPresentationSuccess $timeEngagementToResponseMsec " + + "$scanningTimeMsec\n") + } + } catch (e: TimeoutCancellationException) { + sendChannel.writeStringUtf8("TestPresentationTimeout\n") + } catch (e: Throwable) { + Logger.w(TAG, "Iteration failed", e) + e.printStackTrace() + sendChannel.writeStringUtf8("TestPresentationFailed\n") + } finally { + transport.close() + } + + } else { + throw Error("Unknown command $cmd") + } + } + } +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt new file mode 100644 index 000000000..22194bbe5 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/MultiDeviceTestsServer.kt @@ -0,0 +1,377 @@ +package com.android.identity.testapp.multidevicetests + +import androidx.compose.runtime.MutableState +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.engagement.EngagementGenerator +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toBase64Url +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.writeStringUtf8 +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withTimeout +import kotlin.math.sqrt +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + +class MultiDeviceTestsServer( + val job: Job, + val socket: Socket, + val plan: Plan, + val multiDeviceTestsResults: MutableState +) { + companion object { + private const val TAG = "MultiDeviceTestsServer" + private val LINE_LIMIT = 4096 + } + + val receiveChannel = socket.openReadChannel() + val sendChannel = socket.openWriteChannel(autoFlush = true) + + private var numIterationsTotal = 0 + private var numIterations = 0 + private var numSuccess = 0 + private var failedTransactions = mutableListOf() + private var numHolderTimeouts = 0 + private var numHolderErrors = 0 + private var numReaderTimeouts = 0 + private var numReaderErrors = 0 + private var recordedTimeEngagementToResponseMsec = mutableListOf() + private var recordedScanningTimeMsec = mutableListOf() + + suspend fun run() { + numIterationsTotal = plan.tests.sumOf { it.second } + multiDeviceTestsResults.value = Results( + plan = plan, + numIterationsTotal = numIterationsTotal, + ) + for ((test, numIterationsForTest) in plan.tests) { + for (n in IntRange(1, numIterationsForTest)) { + testPresentation(test) + } + } + + updateResults(null) + + sendChannel.writeStringUtf8("Done\n") + } + + private suspend fun testPresentation(test: Test) { + updateResults(test) + val connectionMethod = when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT -> { + ConnectionMethodBle( + supportsPeripheralServerMode = false, + supportsCentralClientMode = true, + peripheralServerModeUuid = null, + centralClientModeUuid = UUID.randomUUID(), + ) + } + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + ConnectionMethodBle( + supportsPeripheralServerMode = true, + supportsCentralClientMode = false, + peripheralServerModeUuid = UUID.randomUUID(), + centralClientModeUuid = null, + ) + } + } + + val bleUseL2CAP = when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE -> { + false + } + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + true + } + } + val options = MdocTransportOptions(bleUseL2CAP = bleUseL2CAP) + + var holderSuccess = false + var readerSuccess = false + var holderScanningTimeMsec = 0 + var readerScanningTimeMsec = 0 + + val iterationNumber = numIterations + 1 + Logger.i(TAG, "====== STARTING ITERATION ${iterationNumber} OF ${numIterationsTotal} ======") + + var transport = MdocTransportFactory.createTransport( + connectionMethod = connectionMethod, + role = MdocTransport.Role.MDOC, + options = options + ) + try { + if (plan.prewarm) { + transport.advertise() + } + val eDeviceKey = Crypto.createEcPrivateKey(EcCurve.P256) + val engagementGenerator = EngagementGenerator( + eSenderKey = eDeviceKey.publicKey, + version = "1.0" + ) + engagementGenerator.addConnectionMethods(listOf(transport.connectionMethod)) + val encodedDeviceEngagement = engagementGenerator.generate() + val getPsmFromReader = (test == Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT) + sendChannel.writeStringUtf8("TestPresentationPrepare ${iterationNumber} ${numIterationsTotal} " + + "${test.name} ${plan.prewarm} ${bleUseL2CAP} ${getPsmFromReader} " + + "${encodedDeviceEngagement.toBase64Url()}\n") + val prewarmDuration = 6.seconds + Logger.i(TAG, "Waiting $prewarmDuration for pre-warming connection") + if (getPsmFromReader) { + val psmFromReader = receiveChannel.readUTF8Line(LINE_LIMIT)!!.toInt() + Logger.i(TAG, "psmFromReader: $psmFromReader") + val connectionMethodWithPsm = connectionMethod + connectionMethodWithPsm.peripheralServerModePsm = psmFromReader + transport = MdocTransportFactory.createTransport( + connectionMethod = connectionMethod, + role = MdocTransport.Role.MDOC, + options = options + ) + } + delay(prewarmDuration) + Logger.i(TAG, "Done waiting") + withTimeout(15.seconds) { + sendChannel.writeStringUtf8("TestPresentationStart\n") + transport.open(eDeviceKey.publicKey) + val sessionEstablishmentMessage = transport.waitForMessage() + val eReaderKey = SessionEncryption.getEReaderKey(sessionEstablishmentMessage) + val encodedSessionTranscript = byteArrayOf(0x01, 0x02) + val sessionEncryption = SessionEncryption( + role = SessionEncryption.Role.MDOC, + eSelfKey = eDeviceKey, + remotePublicKey = eReaderKey, + encodedSessionTranscript = encodedSessionTranscript + ) + val (deviceRequest, statusCode) = sessionEncryption.decryptMessage(sessionEstablishmentMessage) + val deviceResponse = ByteArray(20 * 1024) + + when (test) { + Test.MDOC_CENTRAL_CLIENT_MODE, + Test.MDOC_PERIPHERAL_SERVER_MODE, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT -> { + // Sends termination in initial message + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = Constants.SESSION_DATA_STATUS_SESSION_TERMINATION + ) + ) + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG -> { + // Sends a separate termination message + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + transport.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + } + Test.MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE -> { + // Sends termination via BLE + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + transport.sendMessage(byteArrayOf()) + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG, + Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG, + Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG -> { + // Expects reader to terminate via message + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + val (deviceRequest, statusCode) = sessionEncryption.decryptMessage(transport.waitForMessage()) + if (deviceRequest != null || statusCode != Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + throw Error("Expected empty message and status 20") + } + } + Test.MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE, + Test.MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE -> { + // Expects reader to terminate via BLE + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = deviceResponse, + statusCode = null + ) + ) + val sessionData = transport.waitForMessage() + if (sessionData.isNotEmpty()) { + throw Error("Expected transport-specific termination, got non-empty message") + } + } + } + + holderSuccess = true + holderScanningTimeMsec = transport.scanningTime?.toInt(DurationUnit.MILLISECONDS) ?: 0 + } + } catch (e: TimeoutCancellationException) { + Logger.w(TAG, "Iteration timeout") + numHolderTimeouts += 1 + } catch (e: Throwable) { + Logger.w(TAG, "Iteration failed", e) + e.printStackTrace() + numHolderErrors += 1 + } finally { + transport.close() + } + + val response = receiveChannel.readUTF8Line(LINE_LIMIT) + Logger.i(TAG, "Response from client: $response") + if (response?.startsWith("TestPresentationSuccess ") == true) { + readerSuccess = true + val parts = response.split(" ") + val timeEngagementToResponseMsec = parts[1].toInt() + recordedTimeEngagementToResponseMsec.add(timeEngagementToResponseMsec.toDouble()) + readerScanningTimeMsec = parts[2].toInt() + } else if (response == "TestPresentationTimeout") { + numReaderTimeouts += 1 + } else if (response == "TestPresentationFailed") { + numReaderErrors += 1 + } else { + throw Error("Unexpected TestPresentation response '$response'") + } + + if (holderScanningTimeMsec > 0 && readerScanningTimeMsec > 0) { + Logger.w(TAG, "Both holder and reader scanning times are non-zero") + } + if (holderScanningTimeMsec > 0) { + recordedScanningTimeMsec.add(holderScanningTimeMsec.toDouble()) + } else if (readerScanningTimeMsec > 0) { + recordedScanningTimeMsec.add(readerScanningTimeMsec.toDouble()) + } + + numIterations += 1 + if (readerSuccess && holderSuccess) { + numSuccess += 1 + } else { + failedTransactions.add(iterationNumber) + } + } + + private fun updateResults(currentTest: Test?) { + Logger.i(TAG, "it=$numIterations success=$numSuccess " + + "hTO=$numHolderTimeouts hErr=$numHolderErrors " + + "rTO=$numReaderTimeouts rErr=$numReaderErrors " + + "| time: " + + "min=${recordedTimeEngagementToResponseMsec.minOrZero().toInt()} " + + "max=${recordedTimeEngagementToResponseMsec.maxOrZero().toInt()} " + + "avg=${recordedTimeEngagementToResponseMsec.averageOrZero().toInt()} " + + "stddev=${recordedTimeEngagementToResponseMsec.stdDevOrZero().toInt()} " + + "| scanning: " + + "min=${recordedScanningTimeMsec.minOrZero().toInt()} " + + "max=${recordedScanningTimeMsec.maxOrZero().toInt()} " + + "avg=${recordedScanningTimeMsec.averageOrZero().toInt()} " + + "stddev=${recordedScanningTimeMsec.stdDevOrZero().toInt()}") + + multiDeviceTestsResults.value = Results( + plan = plan, + currentTest = currentTest, + numIterationsTotal = numIterationsTotal, + numIterationsCompleted = numIterations, + numIterationsSuccessful = numSuccess, + failedIterations = failedTransactions.toList(), + transactionTime = Timing( + min = recordedTimeEngagementToResponseMsec.minOrZero().toInt(), + max = recordedTimeEngagementToResponseMsec.maxOrZero().toInt(), + avg = recordedTimeEngagementToResponseMsec.averageOrZero().toInt(), + stdDev = recordedTimeEngagementToResponseMsec.stdDevOrZero().toInt(), + ), + scanningTime = Timing( + min = recordedScanningTimeMsec.minOrZero().toInt(), + max = recordedScanningTimeMsec.maxOrZero().toInt(), + avg = recordedScanningTimeMsec.averageOrZero().toInt(), + stdDev = recordedScanningTimeMsec.stdDevOrZero().toInt(), + ), + ) + } +} + +private fun List.minOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + return this.min() +} + +private fun List.maxOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + return this.max() +} + +private fun List.averageOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + return this.average() +} + +private fun List.stdDevOrZero(): Double { + if (isEmpty()) { + return 0.0 + } + val mean = average() + val sumOfSquaredDeviations = sumOf { (it - mean) * (it - mean) } + return sqrt(sumOfSquaredDeviations / size) +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt new file mode 100644 index 000000000..34a7cd614 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Plan.kt @@ -0,0 +1,71 @@ +package com.android.identity.testapp.multidevicetests + +enum class Plan( + val description: String, + val tests: List>, + val prewarm: Boolean, +) { + ALL_TESTS( + description = "All Tests & Corner-cases", + tests = Test.entries.toList().map { Pair(it, 1) }, + prewarm = true, + ), + HAPPY_FLOW_SHORT( + description = "Happy Flow Short", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 10), + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 10) + ), + prewarm = true, + ), + HAPPY_FLOW_LONG( + description = "Happy Flow Long", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 50), + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 50) + ), + prewarm = true, + ), + BLE_CENTRAL_CLIENT_MODE_ONLY( + description = "BLE Central Client Mode", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 40), + ), + prewarm = true, + ), + BLE_CENTRAL_CLIENT_MODE_ONLY_NO_PREWARM( + description = "BLE Central Client Mode w/o prewarm", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE, 40), + ), + prewarm = false, + ), + BLE_PERIPHERAL_SERVER_MODE( + description = "BLE Peripheral Server Mode", + tests = listOf( + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 40), + ), + prewarm = true, + ), + BLE_PERIPHERAL_SERVER_MODE_NO_PREWARM( + description = "BLE Peripheral Server Mode w/o prewarm", + tests = listOf( + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE, 40), + ), + prewarm = false, + ), + BLE_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT( + description = "BLE Central Client L2CAP w/ PSM in Engagement", + tests = listOf( + Pair(Test.MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT, 40), + ), + prewarm = true, + ), + BLE_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT( + description = "BLE Peripheral Server L2CAP w/ PSM in Engagement", + tests = listOf( + Pair(Test.MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT, 40), + ), + prewarm = true, + ), +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt new file mode 100644 index 000000000..5d530f689 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Results.kt @@ -0,0 +1,12 @@ +package com.android.identity.testapp.multidevicetests + +data class Results( + val plan: Plan = Plan.ALL_TESTS, + val currentTest: Test? = null, + val numIterationsTotal: Int = 0, + val numIterationsCompleted: Int = 0, + val numIterationsSuccessful: Int = 0, + val failedIterations: List = listOf(), + val transactionTime: Timing = Timing(), + val scanningTime: Timing = Timing(), +) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt new file mode 100644 index 000000000..1b5d6ecff --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Test.kt @@ -0,0 +1,67 @@ +package com.android.identity.testapp.multidevicetests + +enum class Test(val description: String) { + // holder terminates by including status 0x20 in same message as DeviceResponse + // + MDOC_CENTRAL_CLIENT_MODE("mdoc central client mode"), + MDOC_PERIPHERAL_SERVER_MODE("mdoc peripheral server mode"), + + // holder terminates with separate message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_MSG("mdoc cc (holder term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_MSG("mdoc ps (holder term MSG)"), + + // holder terminates with BLE specific termination + // + MDOC_CENTRAL_CLIENT_MODE_HOLDER_TERMINATION_BLE("mdoc cc (holder term BLE)"), + MDOC_PERIPHERAL_SERVER_MODE_HOLDER_TERMINATION_BLE("mdoc ps (holder term BLE)"), + + // reader terminates with message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_MSG("mdoc cc (reader term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_MSG("mdoc ps (reader term MSG)"), + + // reader terminates with BLE specific termination + // + MDOC_CENTRAL_CLIENT_MODE_READER_TERMINATION_BLE("mdoc cc (reader term BLE)"), + MDOC_PERIPHERAL_SERVER_MODE_READER_TERMINATION_BLE("mdoc ps (reader term BLE)"), + + + /////////////////////////////// + // L2CAP with PSM from GATT + /////////////////////////////// + + // holder L2CAP terminates by including status 0x20 in same message as DeviceResponse + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP("mdoc cc L2CAP"), + MDOC_PERIPHERAL_SERVER_MODE_L2CAP("mdoc psm L2CAP"), + + // holder L2CAP terminates with separate message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP_HOLDER_TERMINATION_MSG("mdoc cc L2CAP (holder term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_L2CAP_HOLDER_TERMINATION_MSG("mdoc ps L2CAP (holder term MSG)"), + + // holder L2CAP terminates with BLE specific termination: N/A + // + + // reader L2CAP terminates with message with status 0x20 + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP_READER_TERMINATION_MSG("mdoc cc L2CAP (reader term MSG)"), + MDOC_PERIPHERAL_SERVER_MODE_L2CAP_READER_TERMINATION_MSG("mdoc ps L2CAP (reader term MSG)"), + + // reader L2CAP terminates with BLE specific termination: N/A + // + + /////////////////////////////// + // L2CAP with PSM in engagement (18013-5 Amendment 1) + /////////////////////////////// + + // PSM is conveyed from reader during two-way engagement + // + MDOC_CENTRAL_CLIENT_MODE_L2CAP_PSM_IN_TWO_WAY_ENGAGEMENT("mdoc cc L2CAP w/ PSM in two-way-eng"), + + // PSM is in Device Engagement + // + MDOC_PERIPHERAL_SERVER_MODE_L2CAP_PSM_IN_DEVICE_ENGAGEMENT("mdoc psm L2CAP w/ PSM in DE"), + +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt new file mode 100644 index 000000000..c0b7e6147 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/multidevicetests/Timing.kt @@ -0,0 +1,8 @@ +package com.android.identity.testapp.multidevicetests + +data class Timing( + val min: Int = 0, + val max: Int = 0, + val avg: Int = 0, + val stdDev: Int = 0, +) \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt new file mode 100644 index 000000000..5ffcb7315 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocMultiDeviceTestingScreen.kt @@ -0,0 +1,304 @@ +package com.android.identity.testapp.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.permissions.rememberBluetoothPermissionState +import com.android.identity.appsupport.ui.qrcode.ScanQrCodeDialog +import com.android.identity.appsupport.ui.qrcode.ShowQrCodeDialog +import com.android.identity.testapp.getLocalIpAddress +import com.android.identity.testapp.multidevicetests.MultiDeviceTestsClient +import com.android.identity.testapp.multidevicetests.MultiDeviceTestsServer +import com.android.identity.testapp.multidevicetests.Plan +import com.android.identity.testapp.multidevicetests.Results +import com.android.identity.util.Logger +import io.ktor.network.selector.SelectorManager +import io.ktor.network.sockets.InetSocketAddress +import io.ktor.network.sockets.Socket +import io.ktor.network.sockets.aSocket +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration.Companion.seconds + +private const val TAG = "MdocTransportMultiDeviceTestingScreen" + +private /* const */ val PREWARMING_DURATION = 6.seconds + +@Composable +fun IsoMdocMultiDeviceTestingScreen( + showToast: (message: String) -> Unit, +) { + val blePermissionState = rememberBluetoothPermissionState() + + val coroutineScope = rememberCoroutineScope() + + val multiDeviceTestsServerShowButton = remember { mutableStateOf(null) } + val multiDeviceTestsClientShowButton = remember { mutableStateOf(false) } + + val multiDeviceTestClient = remember { mutableStateOf(null) } + val multiDeviceTestServer = remember { mutableStateOf(null) } + val multiDeviceTestResults = remember { mutableStateOf(Results()) } + + if (multiDeviceTestsServerShowButton.value != null) { + val url = multiDeviceTestsServerShowButton.value!! + Logger.i(TAG, "URL is $url") + ShowQrCodeDialog( + title = { Text(text = "Scan QR code") }, + text = { Text(text = "Scan this QR code on another device to run multi-device tests") }, + dismissButton = "Close", + data = url, + onDismiss = { + multiDeviceTestsServerShowButton.value = null + } + ) + } + + if (multiDeviceTestsClientShowButton.value) { + val uriScheme = "owf-ic-testing://" + ScanQrCodeDialog( + title = { Text ("Scan QR code") }, + text = { Text ("Scan code from another device to run multi-device tests.") }, + dismissButton = "Close", + onCodeScanned = { data -> + if (data.startsWith(uriScheme)) { + val serverAndPort = data.substring(uriScheme.length) + val colonPos = serverAndPort.indexOf(':') + val serverAddress = serverAndPort.substring(0, colonPos) + val serverPort = serverAndPort.substring(colonPos + 1, serverAndPort.length).toInt() + Logger.i(TAG, "Connect to $serverAddress port $serverPort") + + coroutineScope.launch { + withContext(Dispatchers.IO) { + try { + val selectorManager = SelectorManager(Dispatchers.IO) + val socket = aSocket(selectorManager).tcp().connect(serverAddress, serverPort) + runMultiDeviceTestsClient( + socket, + multiDeviceTestClient, + showToast + ) + } catch (error: Throwable) { + showToast("Error: ${error.message}") + } + } + } + multiDeviceTestsClientShowButton.value = false + true + } else { + false + } + }, + onDismiss = { + multiDeviceTestsClientShowButton.value = false + } + ) + } + + fun multiDeviceTestListen(plan: Plan) { + coroutineScope.launch { + withContext(Dispatchers.IO) { + try { + val localAddress = getLocalIpAddress() + val selectorManager = SelectorManager(Dispatchers.IO) + val serverSocket = aSocket(selectorManager).tcp().bind(localAddress, 0) + val localAddressPort = (serverSocket.localAddress as InetSocketAddress).port + Logger.i(TAG, "Accepting connections at $localAddress port $localAddressPort") + multiDeviceTestsServerShowButton.value = + "owf-ic-testing://${localAddress}:${localAddressPort}" + val socket = serverSocket.accept() + multiDeviceTestsServerShowButton.value = null + multiDeviceTestResults.value = Results() + runMultiDeviceTestsServer( + socket, + plan, + multiDeviceTestServer, + multiDeviceTestResults, + showToast + ) + } catch (error: Throwable) { + showToast("Error: ${error.message}") + } + } + } + } + + + if (!blePermissionState.isGranted) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { blePermissionState.launchPermissionRequest() } + ) { + Text("Request BLE permissions") + } + } + } else { + if (multiDeviceTestServer.value != null) { + val r = multiDeviceTestResults.value + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Running Multi-Device-Test as Server", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Text("Plan: ${r.plan.description}") + if (r.currentTest != null) { + Text("Currently Testing: ${r.currentTest.description}") + } + Spacer(modifier = Modifier.height(10.dp)) + + Text("Number Iterations Completed: ${r.numIterationsCompleted} of ${r.numIterationsTotal}") + Text("Number Iterations Successful: ${r.numIterationsSuccessful}") + Text("Failed Iterations: ${r.failedIterations}") + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Transaction Time Avg: ${r.transactionTime.avg} msec", + fontWeight = FontWeight.Bold, + ) + Text("min/max/σ: ${r.transactionTime.min}/${r.transactionTime.max}/${r.transactionTime.stdDev}") + Spacer(modifier = Modifier.height(10.dp)) + + Text( + text = "Scanning Time Avg: ${r.scanningTime.avg} msec", + fontWeight = FontWeight.Bold, + ) + Text("min/max/σ: ${r.scanningTime.min}/${r.scanningTime.max}/${r.scanningTime.stdDev}") + + Spacer(modifier = Modifier.height(20.dp)) + + Button( + onClick = { + multiDeviceTestServer.value?.job?.cancel() + multiDeviceTestServer.value = null + } + ) { + if (r.numIterationsTotal > 0 && r.numIterationsCompleted == r.numIterationsTotal) { + Text("Close") + } else { + Text("Cancel") + } + } + } + } else if (multiDeviceTestClient.value != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Running Multi-Device-Test as Client", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(10.dp)) + Button( + onClick = { + multiDeviceTestClient.value?.job?.cancel() + multiDeviceTestClient.value = null + } + ) { + Text("Cancel") + } + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + TextButton( + onClick = { + multiDeviceTestsClientShowButton.value = true + }, + content = { Text("Run Multi-Device Tests as Client") } + ) + } + + for (plan in Plan.entries) { + val numIt = plan.tests.sumOf { it.second } + item { + TextButton( + onClick = { multiDeviceTestListen(plan) }, + content = { + Text("Plan: ${plan.description} ($numIt iterations)") + } + ) + } + } + } + } + } +} + +private suspend fun runMultiDeviceTestsServer( + socket: Socket, + plan: Plan, + multiDeviceTestsServer: MutableState, + multiDeviceTestsResults: MutableState, + showToast: (message: String) -> Unit +) { + val server = MultiDeviceTestsServer( + coroutineContext.job, + socket, + plan, + multiDeviceTestsResults + ) + multiDeviceTestsServer.value = server + try { + server.run() + } catch (error: Throwable) { + Logger.i(TAG, "Multi-Device Tests failed", error) + error.printStackTrace() + showToast("Multi-Device-Tests failed: ${error.message}") + } + // Keep multiDeviceTestServer around so user has a chance to review the results... + // multiDeviceTestsServer.value = null +} + +private suspend fun runMultiDeviceTestsClient( + socket: Socket, + multiDeviceTestsClient: MutableState, + showToast: (message: String) -> Unit +) { + val client = MultiDeviceTestsClient( + coroutineContext.job, + socket, + ) + multiDeviceTestsClient.value = client + try { + client.run() + } catch (error: Throwable) { + Logger.i(TAG, "Multi-Device Tests failed", error) + error.printStackTrace() + showToast("Multi-Device-Tests failed: ${error.message}") + } + multiDeviceTestsClient.value = null +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt new file mode 100644 index 000000000..fb2c5ec40 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximityReadingScreen.kt @@ -0,0 +1,747 @@ +package com.android.identity.testapp.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.content.MediaType.Companion.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.decodeImage +import com.android.identity.appsupport.ui.permissions.rememberBluetoothPermissionState +import com.android.identity.appsupport.ui.qrcode.ScanQrCodeDialog +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.DiagnosticOption +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.documenttype.DocumentAttributeType +import com.android.identity.documenttype.DocumentTypeRepository +import com.android.identity.documenttype.DocumentWellKnownRequest +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.engagement.EngagementParser +import com.android.identity.mdoc.response.DeviceResponseParser +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportClosedException +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.testapp.TestAppUtils +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.fromBase64Url +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.format +import kotlinx.datetime.toLocalDateTime + +private const val TAG = "IsoMdocProximityReadingScreen" + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) +@Composable +fun IsoMdocProximityReadingScreen( + showToast: (message: String) -> Unit, +) { + val availableRequests = DrivingLicense.getDocumentType().sampleRequests + var dropdownExpanded = remember { mutableStateOf(false) } + var selectedRequest = remember { mutableStateOf(availableRequests[0]) } + + val blePermissionState = rememberBluetoothPermissionState() + + val coroutineScope = rememberCoroutineScope() + + val readerShowQrScanner = remember { mutableStateOf(false) } + var readerAutoCloseConnection by remember { mutableStateOf(true) } + var readerBleUseL2CAP by remember { mutableStateOf(false) } + var readerJob by remember { mutableStateOf(null) } + var readerTransport = remember { mutableStateOf(null) } + var readerSessionEncryption = remember { mutableStateOf(null) } + var readerSessionTranscript = remember { mutableStateOf(null) } + var readerMostRecentDeviceResponse = remember { mutableStateOf(null) } + + if (readerShowQrScanner.value) { + ScanQrCodeDialog( + title = { Text(text = "Scan QR code") }, + text = { Text(text = "Scan this QR code on another device") }, + additionalContent = { + Column() { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = readerAutoCloseConnection, + onCheckedChange = { readerAutoCloseConnection = it } + ) + Text(text = "Close transport after receiving first response") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = readerBleUseL2CAP, + onCheckedChange = { readerBleUseL2CAP = it } + ) + Text(text = "Use L2CAP if available") + } + } + }, + dismissButton = "Close", + onCodeScanned = { data -> + if (data.startsWith("mdoc:")) { + readerShowQrScanner.value = false + readerJob = coroutineScope.launch() { + doReaderFlow( + encodedDeviceEngagement = data.substring(5).fromBase64Url(), + autoCloseConnection = readerAutoCloseConnection, + bleUseL2CAP = readerBleUseL2CAP, + showToast = showToast, + readerTransport = readerTransport, + readerSessionEncryption = readerSessionEncryption, + readerSessionTranscript = readerSessionTranscript, + readerMostRecentDeviceResponse = readerMostRecentDeviceResponse, + selectedRequest = selectedRequest, + ) + readerJob = null + } + true + } else { + false + } + }, + onDismiss = { readerShowQrScanner.value = false } + ) + } + + val readerTransportState = readerTransport.value?.state?.collectAsState() + + if (!blePermissionState.isGranted) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { blePermissionState.launchPermissionRequest() } + ) { + Text("Request BLE permissions") + } + } + } else { + if (readerJob != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + ShowReaderResults(readerMostRecentDeviceResponse, readerSessionTranscript) + } + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = "Connection State: ${readerTransportState?.value}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + coroutineScope.launch { + try { + val encodedDeviceRequest = + TestAppUtils.generateEncodedDeviceRequest( + selectedRequest.value, + readerSessionTranscript.value!! + ) + readerMostRecentDeviceResponse.value = byteArrayOf() + readerTransport.value!!.sendMessage( + readerSessionEncryption.value!!.encryptMessage( + messagePlaintext = encodedDeviceRequest, + statusCode = null + ) + ) + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Send Another Request") + } + Button( + onClick = { + coroutineScope.launch { + try { + readerTransport.value!!.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + readerTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Message)") + } + Button( + onClick = { + coroutineScope.launch { + try { + readerTransport.value!!.sendMessage(byteArrayOf()) + readerTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Transport-Specific)") + } + Button( + onClick = { + coroutineScope.launch { + try { + readerTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (None)") + } + } + } + } else if (readerMostRecentDeviceResponse.value != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Column( + modifier = Modifier.weight(1.0f), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + ShowReaderResults(readerMostRecentDeviceResponse, readerSessionTranscript) + } + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { readerMostRecentDeviceResponse.value = null } + ) { + Text("Close") + } + } + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + RequestPicker( + availableRequests, + selectedRequest, + dropdownExpanded, + onRequestSelected = {} + ) + } + item { + TextButton( + onClick = { + readerShowQrScanner.value = true + readerMostRecentDeviceResponse.value = null + }, + content = { Text("Request mDL via QR Code") } + ) + } + } + } + } +} + +private suspend fun doReaderFlow( + encodedDeviceEngagement: ByteArray, + autoCloseConnection: Boolean, + bleUseL2CAP: Boolean, + showToast: (message: String) -> Unit, + readerTransport: MutableState, + readerSessionEncryption: MutableState, + readerSessionTranscript: MutableState, + readerMostRecentDeviceResponse: MutableState, + selectedRequest: MutableState, +) { + val deviceEngagement = EngagementParser(encodedDeviceEngagement).parse() + val connectionMethod = deviceEngagement.connectionMethods[0] + val eDeviceKey = deviceEngagement.eSenderKey + val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) + + val transport = MdocTransportFactory.createTransport( + connectionMethod, + MdocTransport.Role.MDOC_READER, + MdocTransportOptions(bleUseL2CAP = bleUseL2CAP) + ) + readerTransport.value = transport + val encodedSessionTranscript = TestAppUtils.generateEncodedSessionTranscript( + encodedDeviceEngagement, + eReaderKey.publicKey + ) + val sessionEncryption = SessionEncryption( + SessionEncryption.Role.MDOC_READER, + eReaderKey, + eDeviceKey, + encodedSessionTranscript, + ) + readerSessionEncryption.value = sessionEncryption + readerSessionTranscript.value = encodedSessionTranscript + val encodedDeviceRequest = TestAppUtils.generateEncodedDeviceRequest( + selectedRequest.value, + encodedSessionTranscript + ) + try { + transport.open(eDeviceKey) + transport.sendMessage( + sessionEncryption.encryptMessage( + messagePlaintext = encodedDeviceRequest, + statusCode = null + ) + ) + while (true) { + val sessionData = transport.waitForMessage() + if (sessionData.isEmpty()) { + showToast("Received transport-specific session termination message from holder") + transport.close() + break + } + + val (message, status) = sessionEncryption.decryptMessage(sessionData) + Logger.i(TAG, "Holder sent ${message?.size} bytes status $status") + if (message != null) { + readerMostRecentDeviceResponse.value = message + } + if (status == Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + showToast("Received session termination message from holder") + Logger.i(TAG, "Holder indicated they closed the connection. " + + "Closing and ending reader loop") + transport.close() + break + } + if (autoCloseConnection) { + showToast("Response received, autoclosing connection") + Logger.i(TAG, "Holder did not indicate they are closing the connection. " + + "Auto-close is enabled, so sending termination message, closing, and " + + "ending reader loop") + transport.sendMessage(SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION)) + transport.close() + break + } + showToast("Response received, keeping connection open") + Logger.i(TAG, "Holder did not indicate they are closing the connection. " + + "Auto-close is not enabled so waiting for message from holder") + // "Send additional request" and close buttons will act further on `transport` + } + } catch (_: MdocTransportClosedException) { + // Nothing to do, this is thrown when transport.close() is called from another coroutine, that + // is, the onClick handlers for the close buttons. + Logger.i(TAG, "Ending reader flow due to MdocTransportClosedException") + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } finally { + transport.close() + readerTransport.value = null + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun RequestPicker( + availableRequests: List, + comboBoxSelected: MutableState, + comboBoxExpanded: MutableState, + onRequestSelected: (request: DocumentWellKnownRequest) -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp) + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + + Text( + modifier = Modifier.padding(end = 16.dp), + text = "mDL Data Elements to Request" + ) + + ExposedDropdownMenuBox( + expanded = comboBoxExpanded.value, + onExpandedChange = { + comboBoxExpanded.value = !comboBoxExpanded.value + } + ) { + TextField( + value = comboBoxSelected.value.displayName, + onValueChange = {}, + readOnly = true, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = comboBoxExpanded.value) }, + modifier = Modifier.menuAnchor() + ) + + ExposedDropdownMenu( + expanded = comboBoxExpanded.value, + onDismissRequest = { comboBoxExpanded.value = false } + ) { + availableRequests.forEach { item -> + DropdownMenuItem( + text = { Text(text = item.displayName) }, + onClick = { + comboBoxSelected.value = item + comboBoxExpanded.value = false + onRequestSelected(comboBoxSelected.value) + } + ) + } + } + } + } + } +} + +@Composable +private fun ShowReaderResults( + readerMostRecentDeviceResponse: MutableState, + readerSessionTranscript: MutableState, +) { + val deviceResponse = readerMostRecentDeviceResponse.value + if (deviceResponse == null || deviceResponse.isEmpty()) { + Text( + text = "Waiting for data", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } else { + val parser = DeviceResponseParser(deviceResponse, readerSessionTranscript.value!!) + val deviceResponse = parser.parse() + if (deviceResponse.documents.isEmpty()) { + Text( + text = "No documents in response", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + } else { + // TODO: show multiple documents + val documentData = DocumentData.fromMdocDeviceResponseDocument( + deviceResponse.documents[0], + TestAppUtils.documentTypeRepository + ) + ShowDocumentData(documentData, 0, deviceResponse.documents.size) + } + } +} + +@Composable +private fun ShowKeyValuePair(kvPair: DocumentKeyValuePair) { + Column( + Modifier + .padding(8.dp) + .fillMaxWidth()) { + Text( + text = kvPair.key, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + Text( + text = kvPair.textValue, + style = MaterialTheme.typography.bodyMedium + ) + if (kvPair.bitmap != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Image( + bitmap = kvPair.bitmap, + modifier = Modifier.size(200.dp), + contentDescription = null + ) + } + + } + } +} + +@Composable +private fun ShowDocumentData( + documentData: DocumentData, + documentIndex: Int, + numDocuments: Int +) { + Column( + Modifier + .padding(8.dp) + .verticalScroll(rememberScrollState()) + ) { + + for (text in documentData.infoTexts) { + InfoCard(text) + } + for (text in documentData.warningTexts) { + WarningCard(text) + } + + if (numDocuments > 1) { + ShowKeyValuePair( + DocumentKeyValuePair( + "Document Number", + "${documentIndex + 1} of $numDocuments" + ) + ) + } + + for (kvPair in documentData.kvPairs) { + ShowKeyValuePair(kvPair) + } + + } +} + +@Composable +private fun WarningCard(text: String) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.errorContainer), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Warning, + contentDescription = "An error icon", + tint = MaterialTheme.colorScheme.onErrorContainer + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } +} + +@Composable +private fun InfoCard(text: String) { + Column( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + horizontalAlignment = Alignment.Start + ) { + Row( + modifier = Modifier.padding(16.dp), + ) { + Icon( + modifier = Modifier.padding(end = 16.dp), + imageVector = Icons.Filled.Info, + contentDescription = "An info icon", + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + + Text( + text = text, + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } +} + +// TODO: +// - add infos/warnings according to TrustManager (need to port TrustManager to KMP), that is +// add a warning if the issuer isn't well-known. +// - move to identity-appsupport +// - add fromSdJwtVcResponse() +private data class DocumentData( + val infoTexts: List, + val warningTexts: List, + val kvPairs: List +) { + companion object { + + fun fromMdocDeviceResponseDocument( + document: DeviceResponseParser.Document, + documentTypeRepository: DocumentTypeRepository, + ): DocumentData { + val infos = mutableListOf() + val warnings = mutableListOf() + val kvPairs = mutableListOf() + + if (document.issuerSignedAuthenticated) { + // TODO: Take a [TrustManager] instance and use that to determine trust relationship + if (document.issuerCertificateChain.certificates.last().ecPublicKey == TestAppUtils.dsKeyPub) { + infos.add("Issuer is in a trust list") + } else { + warnings.add("Issuer is not in trust list") + } + } + if (!document.deviceSignedAuthenticated) { + warnings.add("Device Authentication failed") + } + if (!document.issuerSignedAuthenticated) { + warnings.add("Issuer Authentication failed") + } + if (document.numIssuerEntryDigestMatchFailures > 0) { + warnings.add("One or more issuer provided data elements failed to authenticate") + } + val now = Clock.System.now() + if (now < document.validityInfoValidFrom || now > document.validityInfoValidUntil) { + warnings.add("Document information is not valid at this point in time.") + } + + kvPairs.add(DocumentKeyValuePair("Type", "ISO mdoc (ISO/IEC 18013-5:2021)")) + kvPairs.add(DocumentKeyValuePair("DocType", document.docType)) + kvPairs.add(DocumentKeyValuePair("Valid From", formatTime(document.validityInfoValidFrom))) + kvPairs.add(DocumentKeyValuePair("Valid Until", formatTime(document.validityInfoValidUntil))) + kvPairs.add(DocumentKeyValuePair("Signed At", formatTime(document.validityInfoSigned))) + kvPairs.add(DocumentKeyValuePair( + "Expected Update", + document.validityInfoExpectedUpdate?.let { formatTime(it) } ?: "Not Set" + )) + + val mdocType = documentTypeRepository.getDocumentTypeForMdoc(document.docType)?.mdocDocumentType + + // TODO: Handle DeviceSigned data + for (namespaceName in document.issuerNamespaces) { + val mdocNamespace = if (mdocType !=null) { + mdocType.namespaces.get(namespaceName) + } else { + // Some DocTypes not known by [documentTypeRepository] - could be they are + // private or was just never added - may use namespaces from existing + // DocTypes... support that as well. + // + documentTypeRepository.getDocumentTypeForMdocNamespace(namespaceName) + ?.mdocDocumentType?.namespaces?.get(namespaceName) + } + + kvPairs.add(DocumentKeyValuePair("Namespace", namespaceName)) + for (dataElementName in document.getIssuerEntryNames(namespaceName)) { + val mdocDataElement = mdocNamespace?.dataElements?.get(dataElementName) + val encodedDataElementValue = document.getIssuerEntryData(namespaceName, dataElementName) + val dataElement = Cbor.decode(encodedDataElementValue) + var bitmap: ImageBitmap? = null + val (key, value) = if (mdocDataElement != null) { + if (dataElement is Bstr && + mdocDataElement.attribute.type == DocumentAttributeType.Picture) { + try { + bitmap = decodeImage((dataElement as Bstr).value) + } catch (e: Throwable) { + Logger.w(TAG, "Error decoding image for data element $dataElement in " + + "namespace $namespaceName", e) + } + } + Pair( + mdocDataElement.attribute.displayName, + mdocDataElement.renderValue(dataElement) + ) + } else { + Pair( + dataElementName, + Cbor.toDiagnostics( + dataElement, setOf( + DiagnosticOption.PRETTY_PRINT, + DiagnosticOption.EMBEDDED_CBOR, + DiagnosticOption.BSTR_PRINT_LENGTH, + ) + ) + ) + } + kvPairs.add(DocumentKeyValuePair(key, value, bitmap = bitmap)) + } + } + return DocumentData(infos, warnings, kvPairs) + } + } +} + +private data class DocumentKeyValuePair( + val key: String, + val textValue: String, + val bitmap: ImageBitmap? = null +) + +private fun formatTime(instant: Instant): String { + val tz = TimeZone.currentSystemDefault() + val isoStr = instant.toLocalDateTime(tz).format(LocalDateTime.Formats.ISO) + // Get rid of the middle 'T' + return isoStr.substring(0, 10) + " " + isoStr.substring(11) +} + + diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt new file mode 100644 index 000000000..b5cf506ba --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/IsoMdocProximitySharingScreen.kt @@ -0,0 +1,492 @@ +package com.android.identity.testapp.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.android.identity.appsupport.ui.consent.ConsentDocument +import com.android.identity.appsupport.ui.consent.ConsentModalBottomSheet +import com.android.identity.appsupport.ui.consent.ConsentRelyingParty +import com.android.identity.appsupport.ui.consent.MdocConsentField +import com.android.identity.appsupport.ui.permissions.rememberBluetoothPermissionState +import com.android.identity.appsupport.ui.qrcode.ShowQrCodeDialog +import com.android.identity.crypto.Crypto +import com.android.identity.crypto.EcCurve +import com.android.identity.documenttype.knowntypes.DrivingLicense +import com.android.identity.mdoc.connectionmethod.ConnectionMethod +import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle +import com.android.identity.mdoc.engagement.EngagementGenerator +import com.android.identity.mdoc.request.DeviceRequestParser +import com.android.identity.mdoc.response.DeviceResponseGenerator +import com.android.identity.mdoc.sessionencryption.SessionEncryption +import com.android.identity.mdoc.transport.MdocTransport +import com.android.identity.mdoc.transport.MdocTransportClosedException +import com.android.identity.mdoc.transport.MdocTransportFactory +import com.android.identity.mdoc.transport.MdocTransportOptions +import com.android.identity.testapp.TestAppUtils +import com.android.identity.util.Constants +import com.android.identity.util.Logger +import com.android.identity.util.UUID +import com.android.identity.util.toBase64Url +import identitycredential.samples.testapp.generated.resources.Res +import identitycredential.samples.testapp.generated.resources.driving_license_card_art +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.getDrawableResourceBytes +import org.jetbrains.compose.resources.getSystemResourceEnvironment + +private const val TAG = "IsoMdocProximitySharingScreen" + +private data class ConsentSheetData( + val showConsentPrompt: Boolean, + val continuation: CancellableContinuation, + val document: ConsentDocument, + val consentFields: List, + val relyingParty: ConsentRelyingParty, +) + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) +@Composable +fun IsoMdocProximitySharingScreen( + showToast: (message: String) -> Unit, +) { + val blePermissionState = rememberBluetoothPermissionState() + + val coroutineScope = rememberCoroutineScope() + + var holderAutoCloseConnection = remember { mutableStateOf(true) } + var holderJob by remember { mutableStateOf(null) } + var holderTransport = remember { mutableStateOf(null) } + var holderEncodedDeviceEngagement = remember { mutableStateOf(null) } + + val holderConsentSheetData = remember { mutableStateOf(null)} + + if (holderConsentSheetData.value != null && holderConsentSheetData.value!!.showConsentPrompt) { + // TODO: use sheetGesturesEnabled=false when available - see + // https://issuetracker.google.com/issues/288211587 for details + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + ConsentModalBottomSheet( + sheetState = sheetState, + consentFields = holderConsentSheetData.value!!.consentFields, + document = holderConsentSheetData.value!!.document, + relyingParty = holderConsentSheetData.value!!.relyingParty, + onConfirm = { + coroutineScope.launch { + sheetState.hide() + holderConsentSheetData.value!!.continuation.resume(true) + holderConsentSheetData.value = null + } + }, + onCancel = { + coroutineScope.launch { + sheetState.hide() + holderConsentSheetData.value!!.continuation.resume(false) + holderConsentSheetData.value = null + } + } + ) + + } + + val holderTransportState = holderTransport.value?.state?.collectAsState() + + val holderWaitingForRemotePeer = when (holderTransportState?.value) { + MdocTransport.State.IDLE, + MdocTransport.State.ADVERTISING, + MdocTransport.State.SCANNING -> { + true + } + else -> { + false + } + } + Logger.i(TAG, "holderTransportState=$holderTransportState holderWaitingForRemotePeer=$holderWaitingForRemotePeer") + if (holderTransport != null && holderWaitingForRemotePeer) { + val deviceEngagementQrCode = "mdoc:" + holderEncodedDeviceEngagement.value!!.toBase64Url() + ShowQrCodeDialog( + title = { Text(text = "Scan QR code") }, + text = { Text(text = "Scan this QR code on another device") }, + additionalContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = holderAutoCloseConnection.value, + onCheckedChange = { holderAutoCloseConnection.value = it } + ) + Text(text = "Close transport after first response") + } + }, + dismissButton = "Close", + data = deviceEngagementQrCode, + onDismiss = { + holderJob?.cancel() + } + ) + } + + if (!blePermissionState.isGranted) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Button( + onClick = { blePermissionState.launchPermissionRequest() } + ) { + Text("Request BLE permissions") + } + } + } else { + if (holderTransport.value != null) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Connection State: ${holderTransportState?.value}", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.SpaceEvenly, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Button( + onClick = { + coroutineScope.launch { + try { + holderTransport.value!!.sendMessage( + SessionEncryption.encodeStatus(Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) + ) + holderTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Message)") + } + Button( + onClick = { + coroutineScope.launch { + try { + holderTransport.value!!.sendMessage(byteArrayOf()) + holderTransport.value!!.close() + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + } + ) { + Text("Close (Transport-Specific)") + } + Button( + onClick = { + try { + coroutineScope.launch { + holderTransport.value!!.close() + } + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } + } + ) { + Text("Close (None)") + } + } + } + } else { + LazyColumn( + modifier = Modifier.padding(8.dp) + ) { + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = false, + supportsCentralClientMode = true, + peripheralServerModeUuid = null, + centralClientModeUuid = UUID.randomUUID(), + ), + options = MdocTransportOptions(), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc central client mode)") } + ) + } + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = true, + supportsCentralClientMode = false, + peripheralServerModeUuid = UUID.randomUUID(), + centralClientModeUuid = null, + ), + options = MdocTransportOptions(), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc peripheral server mode)") } + ) + } + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = false, + supportsCentralClientMode = true, + peripheralServerModeUuid = null, + centralClientModeUuid = UUID.randomUUID(), + ), + options = MdocTransportOptions(bleUseL2CAP = true), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc central client mode w/ L2CAP)") } + ) + } + item { + TextButton( + onClick = { + holderJob = coroutineScope.launch() { + doHolderFlow( + connectionMethod = ConnectionMethodBle( + supportsPeripheralServerMode = true, + supportsCentralClientMode = false, + peripheralServerModeUuid = UUID.randomUUID(), + centralClientModeUuid = null, + ), + options = MdocTransportOptions(bleUseL2CAP = true), + autoCloseConnection = holderAutoCloseConnection, + showToast = showToast, + holderTransport = holderTransport, + encodedDeviceEngagement = holderEncodedDeviceEngagement, + holderConsentSheetData = holderConsentSheetData, + ) + } + }, + content = { Text("Share via QR (mdoc peripheral server mode w/ L2CAP)") } + ) + } + } + } + } +} + +private suspend fun doHolderFlow( + connectionMethod: ConnectionMethod, + options: MdocTransportOptions, + autoCloseConnection: MutableState, + showToast: (message: String) -> Unit, + holderTransport: MutableState, + encodedDeviceEngagement: MutableState, + holderConsentSheetData: MutableState, +) { + val transport = MdocTransportFactory.createTransport( + connectionMethod, + MdocTransport.Role.MDOC, + options + ) + holderTransport.value = transport + val eDeviceKey = Crypto.createEcPrivateKey(EcCurve.P256) + val engagementGenerator = EngagementGenerator( + eSenderKey = eDeviceKey.publicKey, + version = "1.0" + ) + engagementGenerator.addConnectionMethods(listOf(transport!!.connectionMethod)) + encodedDeviceEngagement.value = engagementGenerator.generate() + try { + transport.open(eDeviceKey.publicKey) + + var sessionEncryption: SessionEncryption? = null + var encodedSessionTranscript: ByteArray? = null + while (true) { + Logger.i(TAG, "Waiting for message from reader...") + val sessionData = transport!!.waitForMessage() + if (sessionData.isEmpty()) { + showToast("Received transport-specific session termination message from reader") + transport.close() + break + } + + if (sessionEncryption == null) { + val eReaderKey = SessionEncryption.getEReaderKey(sessionData) + encodedSessionTranscript = TestAppUtils.generateEncodedSessionTranscript( + encodedDeviceEngagement.value!!, + eReaderKey + ) + sessionEncryption = SessionEncryption( + SessionEncryption.Role.MDOC, + eDeviceKey, + eReaderKey, + encodedSessionTranscript, + ) + } + val (encodedDeviceRequest, status) = sessionEncryption.decryptMessage(sessionData) + + if (status == Constants.SESSION_DATA_STATUS_SESSION_TERMINATION) { + showToast("Received session termination message from reader") + transport.close() + break + } + + val consentFields = showConsentPrompt( + encodedDeviceRequest!!, + encodedSessionTranscript!!, + holderConsentSheetData + ) + + val encodedDeviceResponse = if (consentFields == null) { + DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK).generate() + } else { + TestAppUtils.generateEncodedDeviceResponse( + consentFields, + encodedSessionTranscript + ) + } + transport.sendMessage( + sessionEncryption.encryptMessage( + encodedDeviceResponse, + if (autoCloseConnection.value) { + Constants.SESSION_DATA_STATUS_SESSION_TERMINATION + } else { + null + } + ) + ) + if (autoCloseConnection.value) { + showToast("Response sent, autoclosing connection") + transport.close() + break + } else { + showToast("Response sent, keeping connection open") + } + } + } catch (_: MdocTransportClosedException) { + // Nothing to do, this is thrown when transport.close() is called from another coroutine, that + // is, the onClick handlers for the close buttons. + Logger.i(TAG, "Ending holderJob due to MdocTransportClosedException") + } catch (error: Throwable) { + Logger.e(TAG, "Caught exception", error) + error.printStackTrace() + showToast("Error: ${error.message}") + } finally { + transport.close() + holderTransport.value = null + encodedDeviceEngagement.value = null + } +} + +// Returns consent-fields if confirmed by the user, false otherwise. +// +// Throws if there is no mDL docrequest. +// +@OptIn(ExperimentalResourceApi::class) +private suspend fun showConsentPrompt( + encodedDeviceRequest: ByteArray, + encodedSessionTranscript: ByteArray, + holderConsentSheetData: MutableState, +): List? { + val deviceRequest = DeviceRequestParser( + encodedDeviceRequest, + encodedSessionTranscript, + ).parse() + for (docRequest in deviceRequest.docRequests) { + if (docRequest.docType == DrivingLicense.MDL_DOCTYPE) { + val cardArt = getDrawableResourceBytes( + getSystemResourceEnvironment(), + Res.drawable.driving_license_card_art + ) + val consentFields = MdocConsentField.generateConsentFields( + docRequest, + TestAppUtils.documentTypeRepository, + TestAppUtils.mdocCredential + ) + if (suspendCancellableCoroutine { continuation -> + holderConsentSheetData.value = ConsentSheetData( + showConsentPrompt = true, + continuation = continuation, + document = ConsentDocument( + name = "Erika's Driving License", + cardArt = cardArt, + description = "Driving License", + ), + consentFields = consentFields, + relyingParty = ConsentRelyingParty( + trustPoint = null, + websiteOrigin = null, + ) + ) + }) { + return consentFields + } else { + return null + } + + } + } + throw Error("DeviceRequest doesn't contain a docRequest for mDL") +} diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt index 0d57d9c6c..fdfb92259 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/QrCodesScreen.kt @@ -22,8 +22,9 @@ fun QrCodesScreen( if (showMdocQrCodeDialog.value) { ShowQrCodeDialog( - title = "Scan code on reader", - description = "Your personal information won't be shared yet. You don't need to hand your phone to anyone to share your ID.", + title = { Text(text = "Scan code on reader") }, + text = { Text(text = "Your personal information won't be shared yet. You don't need to hand your " + + "phone to anyone to share your ID.") }, dismissButton = "Close", // This is the DeviceEngagement test vector from ISO/IEC 18013-5:2021 Annex D encoded // as specified in clause 8.2.2.3. @@ -35,8 +36,8 @@ fun QrCodesScreen( if (showUrlQrCodeDialog.value) { ShowQrCodeDialog( - title = "Scan code with phone", - description = "This is a QR code for https://github.com/openwallet-foundation-labs/identity-credential", + title = { Text(text = "Scan code with phone") }, + text = { Text(text = "This is a QR code for https://github.com/openwallet-foundation-labs/identity-credential") }, dismissButton = "Close", data = "https://github.com/openwallet-foundation-labs/identity-credential", onDismiss = { showUrlQrCodeDialog.value = false } @@ -45,9 +46,9 @@ fun QrCodesScreen( if (showQrScanDialog.value) { ScanQrCodeDialog( - title = "Scan code", - description = "Ask the person you wish to request identity attributes from to present" + - " a QR code. This is usually in their identity wallet.", + title = { Text ("Scan code") }, + text = { Text ("Ask the person you wish to request identity attributes from to present" + + " a QR code. This is usually in their identity wallet.") }, dismissButton = "Close", onCodeScanned = { data -> if (data.startsWith("mdoc:")) { diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt index 81c61a0b8..eef265e22 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/StartScreen.kt @@ -18,6 +18,9 @@ import identitycredential.samples.testapp.generated.resources.android_keystore_s import identitycredential.samples.testapp.generated.resources.passphrase_entry_field_screen_title import identitycredential.samples.testapp.generated.resources.cloud_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.consent_modal_bottom_sheet_list_screen_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_multi_device_testing_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_reading_title +import identitycredential.samples.testapp.generated.resources.iso_mdoc_proximity_sharing_title import identitycredential.samples.testapp.generated.resources.qr_codes_screen_title import identitycredential.samples.testapp.generated.resources.secure_enclave_secure_area_screen_title import identitycredential.samples.testapp.generated.resources.software_secure_area_screen_title @@ -33,6 +36,9 @@ fun StartScreen( onClickPassphraseEntryField: () -> Unit = {}, onClickConsentSheetList: () -> Unit = {}, onClickQrCodes: () -> Unit = {}, + onClickIsoMdocProximitySharing: () -> Unit = {}, + onClickIsoMdocProximityReading: () -> Unit = {}, + onClickMdocTransportMultiDeviceTesting: () -> Unit = {}, ) { Surface( modifier = Modifier.fillMaxSize(), @@ -93,6 +99,24 @@ fun StartScreen( Text(stringResource(Res.string.qr_codes_screen_title)) } } + + item { + TextButton(onClick = onClickIsoMdocProximitySharing) { + Text(stringResource(Res.string.iso_mdoc_proximity_sharing_title)) + } + } + + item { + TextButton(onClick = onClickIsoMdocProximityReading) { + Text(stringResource(Res.string.iso_mdoc_proximity_reading_title)) + } + } + + item { + TextButton(onClick = onClickMdocTransportMultiDeviceTesting) { + Text(stringResource(Res.string.iso_mdoc_multi_device_testing_title)) + } + } } } } diff --git a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt index 3e7fe058b..2b155641f 100644 --- a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt +++ b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt @@ -1,3 +1,65 @@ package com.android.identity.testapp +import kotlinx.cinterop.ByteVar +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.NativePlacement +import kotlinx.cinterop.allocArray +import kotlinx.cinterop.allocPointerTo +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.pointed +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.toKString +import kotlinx.cinterop.value +import platform.darwin.freeifaddrs +import platform.darwin.getifaddrs +import platform.darwin.ifaddrs +import platform.darwin.inet_ntop +import platform.posix.AF_INET +import platform.posix.AF_INET6 +import platform.posix.INET_ADDRSTRLEN +import platform.posix.INET6_ADDRSTRLEN +import platform.posix.sa_family_t +import platform.posix.sockaddr_in +import platform.posix.sockaddr_in6 + actual val platform = Platform.IOS + +@OptIn(ExperimentalForeignApi::class) +actual fun getLocalIpAddress(): String { + val (status, interfaces) = memScoped { + val ifap = allocPointerTo() + getifaddrs(ifap.ptr) to ifap.value + } + if (status != 0) { + freeifaddrs(interfaces) + throw IllegalStateException("getifaddrs() returned $status, expected 0") + } + val addresses = try { + generateSequence(interfaces) { it.pointed.ifa_next } + .mapNotNull { it.pointed.ifa_addr } + .mapNotNull { + val addr = when (it.pointed.sa_family) { + AF_INET.convert() -> it.reinterpret().pointed.sin_addr + AF_INET6.convert() -> it.reinterpret().pointed.sin6_addr + else -> return@mapNotNull null + } + memScoped { + val len = maxOf(INET_ADDRSTRLEN, INET6_ADDRSTRLEN) + val dst = allocArray(len) + inet_ntop(it.pointed.sa_family.convert(), addr.ptr, dst, len.convert())?.toKString() + } + } + .toList() + } finally { + freeifaddrs(interfaces) + } + + for (address in addresses) { + if (address.startsWith("192.168") || address.startsWith("10.") || address.startsWith("172.")) { + return address + } + } + throw IllegalStateException("Unable to determine local address") +} diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt index dfdf59ee0..5834d131c 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/addtowallet/AddToWalletScreen.kt @@ -115,8 +115,10 @@ fun AddToWalletScreen( // compose ScanQrDialog when user taps on "Scan Credential Offer" if (showQrScannerDialog.value) { ScanQrCodeDialog( - title = stringResource(R.string.credential_offer_scan), - description = stringResource(id = R.string.credential_offer_details), + title = @Composable { Text(text = stringResource(R.string.credential_offer_scan)) }, + text = @Composable { Text( + text = stringResource(id = R.string.credential_offer_details) + )}, onCodeScanned = { qrCodeTextUrl -> // filter only for OID4VCI Url schemes. if (qrCodeTextUrl.startsWith(WalletApplication.OID4VCI_CREDENTIAL_OFFER_URL_SCHEME)) { diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt index 4b002b2dd..f7803603d 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/reader/ReaderScreen.kt @@ -174,8 +174,8 @@ private fun WaitForEngagement( if (showQrScannerDialog.value) { ScanQrCodeDialog( - title = stringResource(R.string.reader_screen_scan_qr_dialog_title), - description = stringResource(R.string.reader_screen_scan_qr_dialog_text), + title = @Composable { Text(text = stringResource(R.string.reader_screen_scan_qr_dialog_title)) }, + text = @Composable { Text(text = stringResource(R.string.reader_screen_scan_qr_dialog_text)) }, onCodeScanned = { qrCodeText -> model.setQrCode(qrCodeText) true