Browse Source

feat: USB CCID reader support [1/n]

Peter Cai 1 year ago
parent
commit
803b88f74e

+ 20 - 0
app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelFactory.kt

@@ -1,14 +1,23 @@
 package im.angry.openeuicc.core
 
 import android.content.Context
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbInterface
+import android.hardware.usb.UsbManager
 import android.se.omapi.SEService
 import android.util.Log
+import im.angry.openeuicc.core.usb.UsbApduInterface
+import im.angry.openeuicc.core.usb.getIoEndpoints
 import im.angry.openeuicc.util.*
 import java.lang.IllegalArgumentException
 
 open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccChannelFactory {
     private var seService: SEService? = null
 
+    private val usbManager by lazy {
+        context.getSystemService(Context.USB_SERVICE) as UsbManager
+    }
+
     private suspend fun ensureSEService() {
         if (seService == null || !seService!!.isConnected) {
             seService = connectSEService(context)
@@ -36,6 +45,17 @@ open class DefaultEuiccChannelFactory(protected val context: Context) : EuiccCha
         return null
     }
 
+    override fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel? {
+        val (bulkIn, bulkOut) = usbInterface.getIoEndpoints()
+        if (bulkIn == null || bulkOut == null) return null
+        val conn = usbManager.openDevice(usbDevice) ?: return null
+        if (!conn.claimInterface(usbInterface, true)) return null
+        return EuiccChannel(
+            FakeUiccPortInfoCompat(FakeUiccCardInfoCompat(EuiccChannelManager.USB_CHANNEL_ID)),
+            UsbApduInterface(conn, bulkIn, bulkOut)
+        )
+    }
+
     override fun cleanup() {
         seService?.shutdown()
         seService = null

+ 44 - 0
app-common/src/main/java/im/angry/openeuicc/core/DefaultEuiccChannelManager.kt

@@ -1,8 +1,11 @@
 package im.angry.openeuicc.core
 
 import android.content.Context
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbManager
 import android.telephony.SubscriptionManager
 import android.util.Log
+import im.angry.openeuicc.core.usb.getSmartCardInterface
 import im.angry.openeuicc.di.AppContainer
 import im.angry.openeuicc.util.*
 import kotlinx.coroutines.Dispatchers
@@ -23,12 +26,18 @@ open class DefaultEuiccChannelManager(
 
     private val channelCache = mutableListOf<EuiccChannel>()
 
+    private var usbChannel: EuiccChannel? = null
+
     private val lock = Mutex()
 
     protected val tm by lazy {
         appContainer.telephonyManager
     }
 
+    private val usbManager by lazy {
+        context.getSystemService(Context.USB_SERVICE) as UsbManager
+    }
+
     private val euiccChannelFactory by lazy {
         appContainer.euiccChannelFactory
     }
@@ -38,6 +47,15 @@ open class DefaultEuiccChannelManager(
 
     private suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel? {
         lock.withLock {
+            if (port.card.physicalSlotIndex == EuiccChannelManager.USB_CHANNEL_ID) {
+                return if (usbChannel != null && usbChannel!!.valid) {
+                    usbChannel
+                } else {
+                    usbChannel = null
+                    null
+                }
+            }
+
             val existing =
                 channelCache.find { it.slotId == port.card.physicalSlotIndex && it.portId == port.portIndex }
             if (existing != null) {
@@ -162,11 +180,37 @@ open class DefaultEuiccChannelManager(
             }
         }
 
+    override suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?> =
+        withContext(Dispatchers.IO) {
+            usbManager.deviceList.values.forEach { device ->
+                Log.i(TAG, "Scanning USB device ${device.deviceId}:${device.vendorId}")
+                val iface = device.getSmartCardInterface() ?: return@forEach
+                // If we don't have permission, tell UI code that we found a candidate device, but we
+                // need permission to be able to do anything with it
+                if (!usbManager.hasPermission(device)) return@withContext Pair(device, null)
+                Log.i(TAG, "Found CCID interface on ${device.deviceId}:${device.vendorId}, and has permission; trying to open channel")
+                try {
+                    val channel = euiccChannelFactory.tryOpenUsbEuiccChannel(device, iface)
+                    if (channel != null && channel.lpa.valid) {
+                        usbChannel = channel
+                        return@withContext Pair(device, channel)
+                    }
+                } catch (e: Exception) {
+                    // Ignored -- skip forward
+                    e.printStackTrace()
+                }
+                Log.i(TAG, "No valid eUICC channel found on USB device ${device.deviceId}:${device.vendorId}")
+            }
+            return@withContext Pair(null, null)
+        }
+
     override fun invalidate() {
         for (channel in channelCache) {
             channel.close()
         }
 
+        usbChannel?.close()
+        usbChannel = null
         channelCache.clear()
         euiccChannelFactory.cleanup()
     }

+ 4 - 0
app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelFactory.kt

@@ -1,5 +1,7 @@
 package im.angry.openeuicc.core
 
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbInterface
 import im.angry.openeuicc.util.*
 
 // This class is here instead of inside DI because it contains a bit more logic than just
@@ -7,6 +9,8 @@ import im.angry.openeuicc.util.*
 interface EuiccChannelFactory {
     suspend fun tryOpenEuiccChannel(port: UiccPortInfoCompat): EuiccChannel?
 
+    fun tryOpenUsbEuiccChannel(usbDevice: UsbDevice, usbInterface: UsbInterface): EuiccChannel?
+
     /**
      * Release all resources used by this EuiccChannelFactory
      * Note that the same instance may be reused; any resources allocated must be automatically

+ 15 - 1
app-common/src/main/java/im/angry/openeuicc/core/EuiccChannelManager.kt

@@ -1,5 +1,7 @@
 package im.angry.openeuicc.core
 
+import android.hardware.usb.UsbDevice
+
 /**
  * EuiccChannelManager holds references to, and manages the lifecycles of, individual
  * APDU channels to SIM cards. The find* methods will create channels when needed, and
@@ -11,13 +13,25 @@ package im.angry.openeuicc.core
  * Holding references independent of EuiccChannelManagerService is unsupported.
  */
 interface EuiccChannelManager {
+    companion object {
+        const val USB_CHANNEL_ID = 99
+    }
+
     /**
-     * Scan all possible sources for EuiccChannels, return them and have all
+     * Scan all possible _device internal_ sources for EuiccChannels, return them and have all
      * scanned channels cached; these channels will remain open for the entire lifetime of
      * this EuiccChannelManager object, unless disconnected externally or invalidate()'d
      */
     suspend fun enumerateEuiccChannels(): List<EuiccChannel>
 
+    /**
+     * Scan all possible USB devices for CCID readers that may contain eUICC cards.
+     * If found, try to open it for access, and add it to the internal EuiccChannel cache
+     * as a "port" with id 99. When user interaction is required to obtain permission
+     * to interact with the device, the second return value (EuiccChannel) will be null.
+     */
+    suspend fun enumerateUsbEuiccChannel(): Pair<UsbDevice?, EuiccChannel?>
+
     /**
      * Wait for a slot + port to reconnect (i.e. become valid again)
      * If the port is currently valid, this function will return immediately.

+ 37 - 0
app-common/src/main/java/im/angry/openeuicc/core/usb/UsbApduInterface.kt

@@ -0,0 +1,37 @@
+package im.angry.openeuicc.core.usb
+
+import android.hardware.usb.UsbDeviceConnection
+import android.hardware.usb.UsbEndpoint
+import net.typeblog.lpac_jni.ApduInterface
+
+class UsbApduInterface(
+    private val conn: UsbDeviceConnection,
+    private val bulkIn: UsbEndpoint,
+    private val bulkOut: UsbEndpoint
+): ApduInterface {
+    private lateinit var ccidDescription: UsbCcidDescription
+
+    override fun connect() {
+        ccidDescription = UsbCcidDescription.fromRawDescriptors(conn.rawDescriptors)!!
+        ccidDescription.checkTransportProtocol()
+    }
+
+    override fun disconnect() {
+        conn.close()
+    }
+
+    override fun logicalChannelOpen(aid: ByteArray): Int {
+        return 0
+    }
+
+    override fun logicalChannelClose(handle: Int) {
+
+    }
+
+    override fun transmit(tx: ByteArray): ByteArray {
+        return byteArrayOf()
+    }
+
+    override val valid: Boolean
+        get() = true
+}

+ 106 - 0
app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidDescription.kt

@@ -0,0 +1,106 @@
+package im.angry.openeuicc.core.usb
+
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+data class UsbCcidDescription(
+    private val bMaxSlotIndex: Byte,
+    private val bVoltageSupport: Byte,
+    private val dwProtocols: Int,
+    private val dwFeatures: Int
+) {
+    companion object {
+        private const val DESCRIPTOR_LENGTH: Byte = 0x36
+        private const val DESCRIPTOR_TYPE: Byte = 0x21
+
+        // dwFeatures Masks
+        private const val FEATURE_AUTOMATIC_VOLTAGE = 0x00008
+        private const val FEATURE_AUTOMATIC_PPS = 0x00080
+
+        private const val FEATURE_EXCHANGE_LEVEL_TPDU = 0x10000
+        private const val FEATURE_EXCHANGE_LEVEL_SHORT_APDU = 0x20000
+        private const val FEATURE_EXCHAGE_LEVEL_EXTENDED_APDU = 0x40000
+
+        // bVoltageSupport Masks
+        private const val VOLTAGE_5V: Byte = 1
+        private const val VOLTAGE_3V: Byte = 2
+        private const val VOLTAGE_1_8V: Byte = 4
+
+        private const val SLOT_OFFSET = 4
+        private const val FEATURES_OFFSET = 40
+        private const val MASK_T0_PROTO = 1
+        private const val MASK_T1_PROTO = 2
+
+        fun fromRawDescriptors(desc: ByteArray): UsbCcidDescription? {
+            var dwProtocols = 0
+            var dwFeatures = 0
+            var bMaxSlotIndex: Byte = 0
+            var bVoltageSupport: Byte = 0
+
+            var hasCcidDescriptor = false
+
+            val byteBuffer = ByteBuffer.wrap(desc).order(ByteOrder.LITTLE_ENDIAN)
+
+            while (byteBuffer.hasRemaining()) {
+                byteBuffer.mark()
+                val len = byteBuffer.get()
+                val type = byteBuffer.get()
+                if (type == DESCRIPTOR_TYPE && len == DESCRIPTOR_LENGTH) {
+                    byteBuffer.reset()
+                    byteBuffer.position(byteBuffer.position() + SLOT_OFFSET)
+                    bMaxSlotIndex = byteBuffer.get()
+                    bVoltageSupport = byteBuffer.get()
+                    dwProtocols = byteBuffer.int
+                    byteBuffer.reset()
+                    byteBuffer.position(byteBuffer.position() + FEATURES_OFFSET)
+                    dwFeatures = byteBuffer.int
+                    hasCcidDescriptor = true
+                    break
+                } else {
+                    byteBuffer.position(byteBuffer.position() + len - 2)
+                }
+            }
+
+            return if (hasCcidDescriptor) {
+                UsbCcidDescription(bMaxSlotIndex, bVoltageSupport, dwProtocols, dwFeatures)
+            } else {
+                null
+            }
+        }
+    }
+
+    enum class Voltage(powerOnValue: Int, mask: Int) {
+        AUTO(0, 0), _5V(1, VOLTAGE_5V.toInt()), _3V(2, VOLTAGE_3V.toInt()), _1_8V(
+            3,
+            VOLTAGE_1_8V.toInt()
+        );
+
+        val mask = powerOnValue.toByte()
+        val powerOnValue = mask.toByte()
+    }
+
+    private fun hasFeature(feature: Int): Boolean =
+        (dwFeatures and feature) != 0
+
+    val voltages: Array<Voltage>
+        get() =
+            if (hasFeature(FEATURE_AUTOMATIC_VOLTAGE)) {
+                arrayOf(Voltage.AUTO)
+            } else {
+                Voltage.values().mapNotNull {
+                    if ((it.mask.toInt() and bVoltageSupport.toInt()) != 0) {
+                        it
+                    } else {
+                        null
+                    }
+                }.toTypedArray()
+            }
+
+    val hasAutomaticPps: Boolean = hasFeature(FEATURE_AUTOMATIC_PPS)
+
+    fun checkTransportProtocol() {
+        val hasT1Protocol = dwProtocols and MASK_T1_PROTO != 0
+        val hasT0Protocol = dwProtocols and MASK_T0_PROTO != 0
+        android.util.Log.d("CcidDescription", "hasT1Protocol = $hasT1Protocol, hasT0Protocol = $hasT0Protocol")
+    }
+}

+ 34 - 0
app-common/src/main/java/im/angry/openeuicc/core/usb/UsbCcidUtils.kt

@@ -0,0 +1,34 @@
+// Adapted from <https://github.com/open-keychain/open-keychain/blob/master/OpenKeychain/src/main/java/org/sufficientlysecure/keychain/securitytoken/usb>
+package im.angry.openeuicc.core.usb
+
+import android.hardware.usb.UsbConstants
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbEndpoint
+import android.hardware.usb.UsbInterface
+
+fun UsbInterface.getIoEndpoints(): Pair<UsbEndpoint?, UsbEndpoint?> {
+    var bulkIn: UsbEndpoint? = null
+    var bulkOut: UsbEndpoint? = null
+    for (i in 0 until endpointCount) {
+        val endpoint = getEndpoint(i)
+        if (endpoint.type != UsbConstants.USB_ENDPOINT_XFER_BULK) {
+            continue
+        }
+        if (endpoint.direction == UsbConstants.USB_DIR_IN) {
+            bulkIn = endpoint
+        } else if (endpoint.direction == UsbConstants.USB_DIR_OUT) {
+            bulkOut = endpoint
+        }
+    }
+    return Pair(bulkIn, bulkOut)
+}
+
+fun UsbDevice.getSmartCardInterface(): UsbInterface? {
+    for (i in 0 until interfaceCount) {
+        val anInterface = getInterface(i)
+        if (anInterface.interfaceClass == UsbConstants.USB_CLASS_CSCID) {
+            return anInterface
+        }
+    }
+    return null
+}

+ 83 - 2
app-common/src/main/java/im/angry/openeuicc/ui/MainActivity.kt

@@ -1,6 +1,14 @@
 package im.angry.openeuicc.ui
 
+import android.annotation.SuppressLint
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
 import android.content.Intent
+import android.content.IntentFilter
+import android.hardware.usb.UsbDevice
+import android.hardware.usb.UsbManager
+import android.os.Build
 import android.os.Bundle
 import android.telephony.TelephonyManager
 import android.util.Log
@@ -12,6 +20,7 @@ import android.widget.ArrayAdapter
 import android.widget.Spinner
 import androidx.lifecycle.lifecycleScope
 import im.angry.openeuicc.common.R
+import im.angry.openeuicc.core.EuiccChannel
 import im.angry.openeuicc.util.*
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
@@ -20,6 +29,7 @@ import kotlinx.coroutines.withContext
 open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
     companion object {
         const val TAG = "MainActivity"
+        const val ACTION_USB_PERMISSION = "im.angry.openeuicc.USB_PERMISSION"
     }
 
     private lateinit var spinnerAdapter: ArrayAdapter<String>
@@ -30,6 +40,28 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
 
     protected lateinit var tm: TelephonyManager
 
+    private val usbManager: UsbManager by lazy {
+        getSystemService(USB_SERVICE) as UsbManager
+    }
+
+    private var usbDevice: UsbDevice? = null
+    private var usbChannel: EuiccChannel? = null
+
+    private lateinit var usbPendingIntent: PendingIntent
+
+    private val usbPermissionReceiver = object : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent?) {
+            if (intent?.action == ACTION_USB_PERMISSION) {
+                if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
+                    lifecycleScope.launch(Dispatchers.Main) {
+                        switchToUsbFragmentIfPossible()
+                    }
+                }
+            }
+        }
+    }
+
+    @SuppressLint("WrongConstant", "UnspecifiedRegisterReceiverFlag")
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
@@ -43,6 +75,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
         tm = telephonyManager
 
         spinnerAdapter = ArrayAdapter<String>(this, R.layout.spinner_item)
+
+        usbPendingIntent = PendingIntent.getBroadcast(this, 0,
+            Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE)
+        val filter = IntentFilter(ACTION_USB_PERMISSION)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+            registerReceiver(usbPermissionReceiver, filter, Context.RECEIVER_EXPORTED)
+        } else {
+            registerReceiver(usbPermissionReceiver, filter)
+        }
     }
 
     override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -62,8 +103,15 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
                     position: Int,
                     id: Long
                 ) {
-                    supportFragmentManager.beginTransaction()
-                        .replace(R.id.fragment_root, fragments[position]).commit()
+                    if (position < fragments.size) {
+                        supportFragmentManager.beginTransaction()
+                            .replace(R.id.fragment_root, fragments[position]).commit()
+                    } else if (position == fragments.size) {
+                        // If we are at the last position, this is the USB device
+                        lifecycleScope.launch(Dispatchers.Main) {
+                            switchToUsbFragmentIfPossible()
+                        }
+                    }
                 }
 
                 override fun onNothingSelected(parent: AdapterView<*>?) {
@@ -106,12 +154,22 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
             }
         }
 
+        withContext(Dispatchers.IO) {
+            val res = euiccChannelManager.enumerateUsbEuiccChannel()
+            usbDevice = res.first
+            usbChannel = res.second
+        }
+
         withContext(Dispatchers.Main) {
             knownChannels.sortedBy { it.logicalSlotId }.forEach { channel ->
                 spinnerAdapter.add(getString(R.string.channel_name_format, channel.logicalSlotId))
                 fragments.add(appContainer.uiComponentFactory.createEuiccManagementFragment(channel))
             }
 
+            // If USB readers exist, add them at the very last
+            // The adapter logic depends on this assumption
+            usbDevice?.let { spinnerAdapter.add(it.productName) }
+
             if (fragments.isNotEmpty()) {
                 if (this@MainActivity::spinner.isInitialized) {
                     spinnerItem.isVisible = true
@@ -120,4 +178,27 @@ open class MainActivity : BaseEuiccAccessActivity(), OpenEuiccContextMarker {
             }
         }
     }
+
+    private suspend fun switchToUsbFragmentIfPossible() {
+        if (usbDevice != null && usbChannel == null) {
+            if (!usbManager.hasPermission(usbDevice)) {
+                usbManager.requestPermission(usbDevice, usbPendingIntent)
+                return
+            }  else {
+               val (device, channel) = withContext(Dispatchers.IO) {
+                    euiccChannelManager.enumerateUsbEuiccChannel()
+                }
+
+                if (device != null && channel != null) {
+                    usbDevice = device
+                    usbChannel = channel
+                }
+            }
+        }
+
+        if (usbChannel != null) {
+            supportFragmentManager.beginTransaction().replace(R.id.fragment_root,
+                appContainer.uiComponentFactory.createEuiccManagementFragment(usbChannel!!)).commit()
+        }
+    }
 }