Skip to content

Commit

Permalink
Add multiplatform BLE stack for ISO/IEC 18013-5:2021 proximity.
Browse files Browse the repository at this point in the history
This adds new multiplatform API to the identity-mdoc library for
ISO/IEC 18013-5:2021 proximity presentations. It works on both Android
and iOS and both operating systems share a lot of common code.

Also add support two new pages - "ISO mdoc Proximity Sharing" and "ISO
mdoc Proximity Reading" - to the multi-platform test app. This only
includes QR engagement, NFC engagement will be added in a future
change. These two pages represent feature complete mDL and mDL reader
functionality and are designed to work with other implementations as
well as with itself.

Also introduce `rememberBluetoothPermissionState` composable function
and `BluetoothPermissionState` object which can be used to obtain the
required permissions on BLE on both Android and iOS.

Also devise a new multi-testing framework for automatically testing a
number of mDL presentations between two devices. This is available in
a new "ISO mdc Multi-Device Testing" page in the test app and the way
it works is that the 1st device offers a QR code that the 2nd device
scans. This sets up a TCP/IP control plane used for initiating tests,
including sharing `DeviceEngagement`. This helps ensure the code is
robust and well-tested both for happy paths and with all the various
termination options available in the standard.

Also modify existing L2CAP support so it follows the latest draft of
the upcoming second edition of ISO 18013-5:2021. In particular, this
means framing the `SessionData` / `SessionEstablishment` packets sent
over the L2CAP socket.

Modify `ScanQrCodeDialog` and `ShowQrCodeDialog` so they are easy to
extend.

Include portrait ans signature images in sample data in
identity-doctypes.

Test: Manually tested against wallet app and well-known readers.

Signed-off-by: David Zeuthen <[email protected]>
  • Loading branch information
davidz25 committed Dec 10, 2024
1 parent a366d57 commit 91b9c7c
Show file tree
Hide file tree
Showing 75 changed files with 7,014 additions and 130 deletions.
6 changes: 5 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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" }
Expand All @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -194,4 +197,22 @@ internal class L2CAPClient(private val context: Context, val listener: Listener)
companion object {
private const val TAG = "L2CAPClient"
}
}
}

// 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 -= buf.size.toUInt()
}
return bsb.toByteString().toByteArray()
}

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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() {
Expand Down
12 changes: 2 additions & 10 deletions identity-appsupport/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import org.gradle.kotlin.dsl.implementation
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

Expand All @@ -11,8 +12,6 @@ plugins {
kotlin {
jvmToolchain(17)

jvm()

androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -31,4 +34,8 @@ actual fun AppTheme(content: @Composable () -> Unit) {
colorScheme = colorScheme,
content = content
)
}
}

actual fun decodeImage(encodedData: ByteArray): ImageBitmap {
return BitmapFactory.decodeByteArray(encodedData, 0, encodedData.size).asImageBitmap()
}
Original file line number Diff line number Diff line change
@@ -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,
)
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -16,3 +17,5 @@ fun AppThemeDefault(content: @Composable () -> Unit) {
content = content
)
}

expect fun decodeImage(encodedData: ByteArray): ImageBitmap
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -27,21 +28,22 @@ 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,
modifier: Modifier? = null
) {
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),
Expand All @@ -50,6 +52,8 @@ fun ScanQrCodeDialog(
},
types = listOf(CodeType.QR)
)

additionalContent?.invoke()
}
},
onDismissRequest = onDismiss,
Expand Down
Loading

0 comments on commit 91b9c7c

Please sign in to comment.