From a040e902d17a820e63dedc94abbefe1248efa41c Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 8 Aug 2022 02:00:00 +0200 Subject: [PATCH] Add USB U2F support --- app/src/main/AndroidManifest.xml | 1 + .../android/extensions/Collection.kt | 23 ++ .../platform/android/Notification.kt | 1 + .../io/timelimit/android/u2f/U2fManager.kt | 2 + .../android/u2f/nfc/NFCU2FManager.kt | 22 +- .../android/u2f/nfc/NfcU2FDeviceSession.kt | 17 +- .../android/u2f/protocol/U2FDeviceSession.kt | 4 +- .../android/u2f/protocol/U2FRequest.kt | 19 +- .../android/u2f/protocol/U2fRawResponse.kt | 7 + .../android/u2f/usb/DisconnectReporter.kt | 24 ++ .../timelimit/android/u2f/usb/UsbException.kt | 23 ++ .../android/u2f/usb/UsbExtensions.kt | 32 ++ .../u2f/usb/UsbPermissionRequestManager.kt | 54 +++ .../timelimit/android/u2f/usb/UsbU2FDevice.kt | 204 ++++++++++ .../android/u2f/usb/UsbU2FDeviceConnection.kt | 361 ++++++++++++++++++ .../android/u2f/usb/UsbU2FManager.kt | 117 ++++++ .../descriptors/ConfigurationDescriptor.kt | 68 ++++ .../u2f/usb/descriptors/DeviceDescriptor.kt | 56 +++ .../u2f/usb/descriptors/EndpointDescriptor.kt | 44 +++ .../u2f/usb/descriptors/HidDescriptor.kt | 40 ++ .../usb/descriptors/InterfaceDescriptor.kt | 84 ++++ .../u2f/usb/descriptors/ReportDescriptor.kt | 127 ++++++ .../u2f/usb/descriptors/UnknownDescriptor.kt | 29 ++ .../android/u2f/util/U2FException.kt | 15 +- .../io/timelimit/android/u2f/util/U2FId.kt | 25 ++ .../timelimit/android/u2f/util/U2FThread.kt | 1 + .../ui/login/AuthTokenLoginProcessor.kt | 155 ++++---- .../ui/manage/parent/u2fkey/Adapter.kt | 2 + .../parent/u2fkey/add/AddU2FDialogFragment.kt | 1 + .../manage/parent/u2fkey/add/AddU2FModel.kt | 72 ++-- app/src/main/res/values-de/strings.xml | 5 +- app/src/main/res/values/strings.xml | 5 +- 32 files changed, 1512 insertions(+), 128 deletions(-) create mode 100644 app/src/main/java/io/timelimit/android/extensions/Collection.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/DisconnectReporter.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/UsbException.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/UsbExtensions.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/UsbPermissionRequestManager.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDevice.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDeviceConnection.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FManager.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ConfigurationDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/DeviceDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/EndpointDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/HidDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/InterfaceDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ReportDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/usb/descriptors/UnknownDescriptor.kt create mode 100644 app/src/main/java/io/timelimit/android/u2f/util/U2FId.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 092ca49..5f31d7c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ + 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.extensions + +inline fun Collection.some(predicate: (T) -> Boolean): Boolean { + for (element in this) if (predicate(element)) return true + + return false +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt index 8fa266f..6595b0a 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt @@ -236,6 +236,7 @@ object PendingIntentIds { const val UPDATE_STATUS = 5 const val OPEN_UPDATER = 6 const val U2F_NFC_DISCOVERY = 7 + const val U2F_USB_RESPONSE = 8 val DYNAMIC_NOTIFICATION_RANGE = 100..10000 val PENDING_INTENT_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { diff --git a/app/src/main/java/io/timelimit/android/u2f/U2fManager.kt b/app/src/main/java/io/timelimit/android/u2f/U2fManager.kt index 671f35e..6f77bbc 100644 --- a/app/src/main/java/io/timelimit/android/u2f/U2fManager.kt +++ b/app/src/main/java/io/timelimit/android/u2f/U2fManager.kt @@ -19,6 +19,7 @@ import android.content.Context import androidx.fragment.app.FragmentActivity import io.timelimit.android.u2f.nfc.NFCU2FManager import io.timelimit.android.u2f.protocol.U2FDevice +import io.timelimit.android.u2f.usb.UsbU2FManager class U2fManager (context: Context) { companion object { @@ -41,6 +42,7 @@ class U2fManager (context: Context) { } private val nfc = NFCU2FManager(this, context) + private val usb = UsbU2FManager(this, context) private val deviceFoundListeners = mutableListOf() diff --git a/app/src/main/java/io/timelimit/android/u2f/nfc/NFCU2FManager.kt b/app/src/main/java/io/timelimit/android/u2f/nfc/NFCU2FManager.kt index 216dd48..20481ef 100644 --- a/app/src/main/java/io/timelimit/android/u2f/nfc/NFCU2FManager.kt +++ b/app/src/main/java/io/timelimit/android/u2f/nfc/NFCU2FManager.kt @@ -32,11 +32,11 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LiveData import io.timelimit.android.BuildConfig -import io.timelimit.android.data.IdGenerator import io.timelimit.android.integration.platform.android.PendingIntentIds import io.timelimit.android.livedata.liveDataFromFunction import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.u2f.U2fManager +import io.timelimit.android.u2f.util.U2FId class NFCU2FManager (val parent: U2fManager, context: Context) { companion object { @@ -48,17 +48,23 @@ class NFCU2FManager (val parent: U2fManager, context: Context) { private val nfcReceiver = object: BroadcastReceiver() { override fun onReceive(p0: Context?, intent: Intent?) { - val tagFromIntent: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) - intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return - else - intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return + try { + val tagFromIntent: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return + else + intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: return - val isoDep: IsoDep = IsoDep.get(tagFromIntent) ?: return + val isoDep: IsoDep = IsoDep.get(tagFromIntent) ?: return - parent.dispatchDeviceFound(NfcU2FDevice(isoDep)) + parent.dispatchDeviceFound(NfcU2FDevice(isoDep)) + } catch (ex: Exception) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "could not handle nfc broadcast", ex) + } + } } } - private val nfcReceiverAction = (0..6).map { IdGenerator.generateId() }.joinToString() + private val nfcReceiverAction = U2FId.generate() private val nfcReceiverIntent = PendingIntent.getBroadcast( context, PendingIntentIds.U2F_NFC_DISCOVERY, diff --git a/app/src/main/java/io/timelimit/android/u2f/nfc/NfcU2FDeviceSession.kt b/app/src/main/java/io/timelimit/android/u2f/nfc/NfcU2FDeviceSession.kt index b093c7f..c4bac8d 100644 --- a/app/src/main/java/io/timelimit/android/u2f/nfc/NfcU2FDeviceSession.kt +++ b/app/src/main/java/io/timelimit/android/u2f/nfc/NfcU2FDeviceSession.kt @@ -20,7 +20,6 @@ import android.nfc.tech.IsoDep import android.util.Log import io.timelimit.android.BuildConfig import io.timelimit.android.coroutines.executeAndWait -import io.timelimit.android.u2f.* import io.timelimit.android.u2f.protocol.U2FDeviceSession import io.timelimit.android.u2f.protocol.U2FRequest import io.timelimit.android.u2f.protocol.U2fRawResponse @@ -35,7 +34,7 @@ class NfcU2FDeviceSession(private val tag: IsoDep): U2FDeviceSession { override suspend fun execute(request: U2FRequest): U2fRawResponse = U2FThread.nfc.executeAndWait { try { - var response = U2fRawResponse.decode(tag.transceive(request.encode())) + var response = U2fRawResponse.decode(tag.transceive(request.encodeShort())) var fullPayload = response.payload // https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-u2f-nfc-protocol-v1.2-ps-20170411.html @@ -60,9 +59,7 @@ class NfcU2FDeviceSession(private val tag: IsoDep): U2FDeviceSession { response = response.copy(payload = fullPayload) - if (response.status == 0x6A80.toUShort()) throw U2FException.BadKeyHandleException() - if (response.status == 0x6985.toUShort()) throw U2FException.UserInteractionRequired() - if (response.status != 0x9000.toUShort()) throw U2FException.DeviceException() + response.throwIfNoSuccess() response } catch (ex: TagLostException) { @@ -75,4 +72,14 @@ class NfcU2FDeviceSession(private val tag: IsoDep): U2FDeviceSession { throw U2FException.CommunicationException() } } + + override fun close() { + U2FThread.nfc.submit { + try { + tag.close() + } catch (ex: IOException) { + // ignore + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/protocol/U2FDeviceSession.kt b/app/src/main/java/io/timelimit/android/u2f/protocol/U2FDeviceSession.kt index 85b66aa..44841a6 100644 --- a/app/src/main/java/io/timelimit/android/u2f/protocol/U2FDeviceSession.kt +++ b/app/src/main/java/io/timelimit/android/u2f/protocol/U2FDeviceSession.kt @@ -15,7 +15,9 @@ */ package io.timelimit.android.u2f.protocol -interface U2FDeviceSession { +import java.io.Closeable + +interface U2FDeviceSession: Closeable { suspend fun execute(request: U2FRequest): U2fRawResponse } diff --git a/app/src/main/java/io/timelimit/android/u2f/protocol/U2FRequest.kt b/app/src/main/java/io/timelimit/android/u2f/protocol/U2FRequest.kt index 4cebe0b..fdb9e6b 100644 --- a/app/src/main/java/io/timelimit/android/u2f/protocol/U2FRequest.kt +++ b/app/src/main/java/io/timelimit/android/u2f/protocol/U2FRequest.kt @@ -23,7 +23,7 @@ sealed class U2FRequest { abstract val p2: Byte abstract val payload: ByteArray - fun encode(): ByteArray { + fun encodeShort(): ByteArray { val cla: Byte = 0 if (payload.size > 255) { @@ -39,6 +39,23 @@ sealed class U2FRequest { ) + payload + byteArrayOf(0) } + fun encodeExtended(): ByteArray { + val cla: Byte = 0 + + if (payload.size > 65535) { + throw U2FException.CommunicationException() + } + + return byteArrayOf( + cla, + ins, + p1, + p2, + 0, + payload.size.ushr(8).toByte(), + payload.size.toByte() + ) + payload + byteArrayOf(1, 0) + } data class Register( val challenge: ByteArray, val applicationId: ByteArray diff --git a/app/src/main/java/io/timelimit/android/u2f/protocol/U2fRawResponse.kt b/app/src/main/java/io/timelimit/android/u2f/protocol/U2fRawResponse.kt index 5bb8de8..aae2a06 100644 --- a/app/src/main/java/io/timelimit/android/u2f/protocol/U2fRawResponse.kt +++ b/app/src/main/java/io/timelimit/android/u2f/protocol/U2fRawResponse.kt @@ -31,4 +31,11 @@ data class U2fRawResponse ( ) } } + + fun throwIfNoSuccess() { + if (status == 0x6A80.toUShort()) throw U2FException.BadKeyHandleException() + if (status == 0x6985.toUShort()) throw U2FException.UserInteractionRequired() + if (status == 0x6700.toUShort()) throw U2FException.BadRequestLength() + if (status != 0x9000.toUShort()) throw U2FException.DeviceException(status) + } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/DisconnectReporter.kt b/app/src/main/java/io/timelimit/android/u2f/usb/DisconnectReporter.kt new file mode 100644 index 0000000..e0dcbd7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/DisconnectReporter.kt @@ -0,0 +1,24 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +class DisconnectReporter { + private var didDisconnectInternal = false + + val didDisconnect get() = didDisconnectInternal + + fun reportDisconnect() { didDisconnectInternal = true } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/UsbException.kt b/app/src/main/java/io/timelimit/android/u2f/usb/UsbException.kt new file mode 100644 index 0000000..36c2fcf --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/UsbException.kt @@ -0,0 +1,23 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +sealed class UsbException(message: String): RuntimeException(message) { + class InvalidDescriptorLengthException: UsbException("invalid descriptor length") + class InvalidDescriptorTypeException: UsbException("invalid descriptor type") + class WrongCounterException(type: String, expected: Int, found: Int): UsbException("expected $expected but found $found $type") + class InvalidIndexException: UsbException("invalid index") +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/UsbExtensions.kt b/app/src/main/java/io/timelimit/android/u2f/usb/UsbExtensions.kt new file mode 100644 index 0000000..976a689 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/UsbExtensions.kt @@ -0,0 +1,32 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +import android.content.Intent +import android.hardware.usb.* +import android.os.Build + +val Intent.usbDevice + get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + this.getParcelableExtra(UsbManager.EXTRA_DEVICE, UsbDevice::class.java)!! + else + this.getParcelableExtra(UsbManager.EXTRA_DEVICE)!! + +val UsbDevice.interfaces + get() = (0 until this.interfaceCount).map { this.getInterface(it) } + +val UsbInterface.endpoints + get() = (0 until this.endpointCount).map { this.getEndpoint(it) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/UsbPermissionRequestManager.kt b/app/src/main/java/io/timelimit/android/u2f/usb/UsbPermissionRequestManager.kt new file mode 100644 index 0000000..2e115b2 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/UsbPermissionRequestManager.kt @@ -0,0 +1,54 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +import android.hardware.usb.UsbDevice +import kotlinx.coroutines.CancellableContinuation +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +class UsbPermissionRequestManager ( + private val sendRequest: (UsbDevice) -> Unit +) { + private val pendingRequests = mutableMapOf>>() + + fun reportResult(device: UsbDevice, granted: Boolean) { + synchronized(pendingRequests) { pendingRequests.remove(device.deviceName) } + ?.forEach { it.resume(granted) } + } + + suspend fun requestPermission(device: UsbDevice): Boolean { + return suspendCancellableCoroutine { continuation -> + continuation.invokeOnCancellation { removePendingRequest(device, continuation) } + + addPendingRequest(device, continuation) + + sendRequest(device) + } + } + + private fun addPendingRequest(device: UsbDevice, listener: CancellableContinuation) = synchronized(pendingRequests) { + pendingRequests.getOrPut(device.deviceName) { mutableListOf() }.add(listener) + } + + private fun removePendingRequest(device: UsbDevice, listener: CancellableContinuation) = synchronized(pendingRequests) { + pendingRequests[device.deviceName]?.remove(listener) + + if (pendingRequests[device.deviceName]?.isEmpty() == true) { + pendingRequests.remove(device.deviceName) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDevice.kt b/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDevice.kt new file mode 100644 index 0000000..e1b580e --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDevice.kt @@ -0,0 +1,204 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +import android.hardware.usb.UsbConstants +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.extensions.some +import io.timelimit.android.u2f.protocol.U2FDevice +import io.timelimit.android.u2f.protocol.U2FDeviceSession +import io.timelimit.android.u2f.usb.descriptors.DeviceDescriptor +import io.timelimit.android.u2f.usb.descriptors.ReportDescriptor +import io.timelimit.android.u2f.util.U2FException +import io.timelimit.android.u2f.util.U2FThread + +data class UsbU2FDevice ( + val device: UsbDevice, + val permissionRequestManager: UsbPermissionRequestManager, + val usbManager: UsbManager, + val disconnectReporter: DisconnectReporter +): U2FDevice { + companion object { + private const val LOG_TAG = "UsbU2FDevice" + private const val CLASS_HID = 3 + private const val ATTRIBUTES_INTERRUPT = 3 + private const val ATTRIBUTES_INTERRUPT_MASK = 3 + + // https://fidoalliance.org/specs/fido-u2f-v1.0-ps-20141009/fido-u2f-hid-protocol-ps-20141009.html + // http://esd.cs.ucr.edu/webres/usb11.pdf + // https://www.usb.org/sites/default/files/documents/hid1_11.pdf + fun from( + device: UsbDevice, + permissionRequestManager: UsbPermissionRequestManager, + usbManager: UsbManager, + disconnectReporter: DisconnectReporter + ): UsbU2FDevice? { + for (usbInterface in device.interfaces) { + // according to the U2F spec, there must be a input and output endpoint + // according to the HID spec, the endpoints must be data interrupt endpoints + // it looks like the U2F spec wants no subclass and protocol that is not zero + // it looks like the HID spec does not allow more than one endpoint per type + if ( + usbInterface.interfaceClass != CLASS_HID || + usbInterface.interfaceSubclass != 0 || + usbInterface.interfaceProtocol != 0 || + usbInterface.endpointCount != 2 + ) continue + + val hasInputEndpoint = usbInterface.endpoints.some { + it.attributes and ATTRIBUTES_INTERRUPT_MASK == ATTRIBUTES_INTERRUPT && + it.direction == UsbConstants.USB_DIR_IN + } + + val hasOutputEndpoint = usbInterface.endpoints.some { + it.attributes and ATTRIBUTES_INTERRUPT_MASK == ATTRIBUTES_INTERRUPT && + it.direction == UsbConstants.USB_DIR_OUT + } + + if (hasInputEndpoint && hasOutputEndpoint) { + return UsbU2FDevice( + device = device, + permissionRequestManager = permissionRequestManager, + usbManager = usbManager, + disconnectReporter = disconnectReporter + ) + } + } + + return null + } + } + + override suspend fun connect(): U2FDeviceSession { + if (!permissionRequestManager.requestPermission(device)) { + throw U2FException.CommunicationException() + } + + val connection = usbManager.openDevice(device) + + try { + val descriptors = DeviceDescriptor.parse(connection.rawDescriptors) + + for (configuration in descriptors.configurations) { + for (usbInterface in configuration.interfaces) { + // same as above + if ( + usbInterface.interfaceClass != CLASS_HID || + usbInterface.interfaceSubclass != 0 || + usbInterface.interfaceProtocol != 0 || + usbInterface.endpoints.size != 2 || + usbInterface.hid == null + ) continue + + val inputEndpoint = usbInterface.endpoints.find { + it.attributes and ATTRIBUTES_INTERRUPT_MASK == ATTRIBUTES_INTERRUPT && + it.address and UsbConstants.USB_ENDPOINT_DIR_MASK == UsbConstants.USB_DIR_IN + } + + val outputEndpoint = usbInterface.endpoints.find { + it.attributes and ATTRIBUTES_INTERRUPT_MASK == ATTRIBUTES_INTERRUPT && + it.address and UsbConstants.USB_ENDPOINT_DIR_MASK == UsbConstants.USB_DIR_OUT + } + + if (inputEndpoint == null || outputEndpoint == null) continue + + val inputEndpointIndex = usbInterface.endpoints.indexOf(inputEndpoint) + val outputEndpointIndex = usbInterface.endpoints.indexOf(outputEndpoint) + + val hidDescriptorRaw = U2FThread.usb.executeAndWait { + val buffer = ByteArray(usbInterface.hid.reportDescriptorSize) + + if (!connection.claimInterface( + device.getInterface(usbInterface.index), + true + ) + ) { + throw U2FException.CommunicationException() + } + + val size = connection.controlTransfer( + 1 /* interface */ or 0x80 /* data from device to host */, + 6, /* GET_DESCRIPTOR */ + 0x2200, /* first report descriptor (there is always a single one) */ + usbInterface.index, /* interface index */ + buffer, /* data */ + buffer.size, /* length */ + 100 /* timeout */ + ) + + if (size != buffer.size) + throw U2FException.CommunicationException() + + buffer + } + + val hidDescriptor = ReportDescriptor.parse(hidDescriptorRaw) + + if (BuildConfig.DEBUG) { + Log.d( + LOG_TAG, + "found device: $hidDescriptor; isU2F = ${hidDescriptor.isU2F}" + ) + } + + if (!hidDescriptor.isU2F) continue + + val u2FConnection = UsbU2FDeviceConnection( + inputEndpoint = device.getInterface(usbInterface.index) + .getEndpoint(inputEndpointIndex), + outputEndpoint = device.getInterface(usbInterface.index) + .getEndpoint(outputEndpointIndex), + connection = connection, + disconnectReporter = disconnectReporter + ) + + try { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "created connection") + } + + u2FConnection.ping(16) + u2FConnection.ping(128) + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "pings worked") + } + + return u2FConnection + } catch (ex: Exception) { + u2FConnection.cancelPendingRequests() + + throw ex + } + } + + break // does not support multiple configurations yet + } + + throw U2FException.CommunicationException() + } catch (ex: UsbException) { + throw U2FException.CommunicationException() + } catch (ex: Exception) { + connection.close() + + throw ex + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDeviceConnection.kt b/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDeviceConnection.kt new file mode 100644 index 0000000..b1c5915 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FDeviceConnection.kt @@ -0,0 +1,361 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +import android.hardware.usb.UsbDeviceConnection +import android.hardware.usb.UsbEndpoint +import android.hardware.usb.UsbRequest +import android.os.SystemClock +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.crypto.HexString +import io.timelimit.android.u2f.protocol.U2FDeviceSession +import io.timelimit.android.u2f.protocol.U2FRequest +import io.timelimit.android.u2f.protocol.U2fRawResponse +import io.timelimit.android.u2f.util.U2FException +import io.timelimit.android.u2f.util.U2FThread +import java.nio.ByteBuffer +import java.util.* + +class UsbU2FDeviceConnection ( + private val inputEndpoint: UsbEndpoint, + private val outputEndpoint: UsbEndpoint, + private val connection: UsbDeviceConnection, + private val disconnectReporter: DisconnectReporter +): U2FDeviceSession { + companion object { + private const val LOG_TAG = "UsbU2FDeviceConnection" + private const val CHANNEL_BROADCAST = -1 + private const val CMD_PING = 1.toByte() + private const val CMD_MSG = 3.toByte() + private const val CMD_INIT = 6.toByte() + private const val CMD_ERROR = 0x3f.toByte() + private const val TIMEOUT = 3000L + } + + internal data class ReceiveRequestClientData(val buffer: ByteBuffer) + + private var channelId: Int? = null + private val pendingRequests = mutableListOf() + + init { for (i in 0..8) enqueueReceivePacketRequest() } + + fun cancelPendingRequests() { + synchronized(pendingRequests) { + pendingRequests.forEach { it.close() } + pendingRequests.clear() + } + } + + private fun enqueueSendPacket(data: ByteArray) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "send packet: ${HexString.toHex(data)}") + } + + UsbRequest().also { + synchronized(pendingRequests) { pendingRequests.add(it) } + + it.initialize(connection, outputEndpoint) + it.queue(ByteBuffer.wrap(data)) + } + } + + private fun enqueueReceivePacketRequest() { + UsbRequest().also { + synchronized(pendingRequests) { pendingRequests.add(it) } + + val request = ReceiveRequestClientData(ByteBuffer.allocate(inputEndpoint.maxPacketSize)) + + it.initialize(connection, inputEndpoint) + it.clientData = request + it.queue(request.buffer) + } + } + + private suspend fun receiveResponsePacket(timeout: Long): ByteArray? { + val end = SystemClock.uptimeMillis() + timeout + + while (true) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "receiveResponsePacket()") + } + + if (disconnectReporter.didDisconnect) throw U2FException.DisconnectedException() + + val remaining = end - SystemClock.uptimeMillis(); if (remaining < 0) break + val response = U2FThread.usb.executeAndWait { connection.requestWait(remaining) } ?: continue + + synchronized(pendingRequests) { pendingRequests.remove(response) } + + response.clientData.let { request -> + if (request is ReceiveRequestClientData) { + enqueueReceivePacketRequest() + + request.buffer.rewind() + + return ByteArray(request.buffer.remaining()) + .also { request.buffer.get(it) } + .also { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got response packet: ${HexString.toHex(it)}") + } + } + } + } + } + + return null + } + + private fun sendRequest( + channelId: Int, + command: Byte, + payload: ByteArray + ) { + if (payload.size >= UShort.MAX_VALUE.toInt()) throw U2FException.CommunicationException() + + val messageSize = outputEndpoint.maxPacketSize; if (messageSize < 8) throw IllegalStateException() + val maxPayloadSize = messageSize - 7 + 128 * (messageSize - 5) + + if (payload.size > maxPayloadSize) throw U2FException.CommunicationException() + + var payloadOffset = 0 + + kotlin.run { // initial package + val buffer = ByteArray(messageSize) + + buffer[0] = channelId.ushr(24).toByte() + buffer[1] = channelId.ushr(16).toByte() + buffer[2] = channelId.ushr(8).toByte() + buffer[3] = channelId.toByte() + + buffer[4] = (command.toInt() or 0x80).toByte() // add init flag + + buffer[5] = payload.size.ushr(8).toByte() + buffer[6] = payload.size.toByte() + + payloadOffset = (messageSize - 7).coerceAtMost(payload.size) + + payload.copyInto( + destination = buffer, + destinationOffset = 7, + startIndex = 0, + endIndex = payloadOffset + ) + + enqueueSendPacket(buffer) + } + + var sequenceCounter = 0; while (payloadOffset < payload.size) { + val buffer = ByteArray(messageSize) + + buffer[0] = channelId.ushr(24).toByte() + buffer[1] = channelId.ushr(16).toByte() + buffer[2] = channelId.ushr(8).toByte() + buffer[3] = channelId.toByte() + + if (sequenceCounter < 0 || sequenceCounter > 0x7f) throw IllegalStateException() + buffer[4] = sequenceCounter++.toByte() + + val consumedBytes = (messageSize - 5).coerceAtMost(payload.size - payloadOffset) + + payload.copyInto( + destination = buffer, + destinationOffset = 5, + startIndex = payloadOffset, + endIndex = payloadOffset + consumedBytes + ) + + payloadOffset += consumedBytes + + enqueueSendPacket(buffer) + } + } + + internal data class Response(val channelId: Int, val command: Byte, val payload: ByteArray) + + private suspend fun receiveResponse(timeout: Long): Response? { + val end = SystemClock.uptimeMillis() + timeout + + val remaining1 = end - SystemClock.uptimeMillis(); if (remaining1 < 0) return null + val response = receiveResponsePacket(remaining1) ?: return null + + if (response.size < 8) throw U2FException.CommunicationException() + + val maxPayloadSize = response.size - 7 + 128 * (response.size - 5) + + val channelId = + (response[3].toUByte().toUInt() or + response[2].toUByte().toUInt().shl(8) or + response[1].toUByte().toUInt().shl(16) or + response[0].toUByte().toUInt().shl(24) + ).toInt() + + val command = response[4].toUByte().toInt() + val payloadSize = response[6].toUByte().toInt() or response[5].toUByte().toInt().shl(8) + + if (command and 0x80 != 0x80) return null // not at the start of a reponse + if (payloadSize > maxPayloadSize) throw U2FException.CommunicationException() + + val payload = ByteArray(payloadSize) + var payloadOffset = (response.size - 7).coerceAtMost(payloadSize) + + response.copyInto( + destination = payload, + destinationOffset = 0, + startIndex = 7, + endIndex = response.size.coerceAtMost(payloadSize + 7) + ) + + var sequenceCounter = 0; while (payloadOffset < payload.size) { + val remaining2 = end - SystemClock.uptimeMillis(); if (remaining2 < 0) return null + val response2 = receiveResponsePacket(remaining2) ?: return null + + if (response2.size != response.size) throw U2FException.CommunicationException() + + val channelId2 = + (response2[3].toUByte().toUInt() or + response2[2].toUByte().toUInt().shl(8) or + response2[1].toUByte().toUInt().shl(16) or + response2[0].toUByte().toUInt().shl(24) + ).toInt() + + val command2 = response2[4].toUByte().toInt() + + if (sequenceCounter < 0 || sequenceCounter > 0x7f) throw IllegalStateException() + val decodedSequenceCounterr = response2[4].toUByte().toInt() + + if (channelId != channelId2) continue + if (command2 and 0x80 == 0x80) return null // not at a continuation + if (sequenceCounter++ != decodedSequenceCounterr) return null // broken sequence + + val consumedBytes = (response.size - 5).coerceAtMost(payloadSize - payloadOffset) + + response2.copyInto( + destination = payload, + destinationOffset = payloadOffset, + startIndex = 5, + endIndex = response.size.coerceAtMost(consumedBytes + 5) + ) + + payloadOffset += consumedBytes + } + + return Response( + channelId = channelId, + command = (command xor 0x80).toByte(), + payload = payload + ).also { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got response: $it") + } + } + } + + private suspend fun sendRequestAndGetResponse( + channelId: Int, + command: Byte, + payload: ByteArray, + timeout: Long + ): Response? { + val end = SystemClock.uptimeMillis() + timeout + + sendRequest(channelId = channelId, command = command, payload = payload) + + while (true) { + val remaining = end - SystemClock.uptimeMillis(); if (remaining < 0) return null + val response = receiveResponse(remaining) ?: continue + if (response.channelId != channelId) continue + + return response + } + } + + private suspend fun allocateChannelId(): Int { + val end = SystemClock.uptimeMillis() + TIMEOUT + val nonce = ByteArray(8).also { Random().nextBytes(it) } + + sendRequest( + channelId = CHANNEL_BROADCAST, + command = CMD_INIT, + payload = nonce + ) + + while (true) { + val remaining = end - SystemClock.uptimeMillis(); if (remaining < 0) throw U2FException.CommunicationException() + val response = receiveResponse(remaining) ?: continue + + if (response.channelId != CHANNEL_BROADCAST) continue + if (response.command != CMD_INIT) continue + if (response.payload.size < 17) continue + if (!response.payload.sliceArray(0 until 8).contentEquals(nonce)) continue + + val channelId = + (response.payload[11].toUByte().toUInt() or + response.payload[10].toUByte().toUInt().shl(8) or + response.payload[9].toUByte().toUInt().shl(16) or + response.payload[8].toUByte().toUInt().shl(24) + ).toInt() + + if (channelId == CHANNEL_BROADCAST) throw U2FException.CommunicationException() + + return channelId + } + } + + private suspend fun getOwnChannelId(): Int { + channelId?.let { return it } + + allocateChannelId().let { channelId = it; return it } + } + + suspend fun ping(payload: ByteArray) { + val response = sendRequestAndGetResponse( + channelId = getOwnChannelId(), + command = CMD_PING, + payload = payload, + timeout = TIMEOUT + ) ?: throw U2FException.CommunicationException() + + if (response.command != CMD_PING) throw U2FException.CommunicationException() + if (!response.payload.contentEquals(payload)) throw U2FException.CommunicationException() + } + + suspend fun ping(length: Int) = ByteArray(length).also { + Random().nextBytes(it) + + ping(it) + } + + override suspend fun execute(request: U2FRequest): U2fRawResponse { + val response = sendRequestAndGetResponse( + channelId = getOwnChannelId(), + command = CMD_MSG, + payload = request.encodeExtended(), + timeout = TIMEOUT + ) ?: throw U2FException.CommunicationException() + + if (response.command != CMD_MSG) throw U2FException.CommunicationException() + + return U2fRawResponse.decode(response.payload).also { it.throwIfNoSuccess() } + } + + override fun close() { + cancelPendingRequests() + connection.close() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FManager.kt b/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FManager.kt new file mode 100644 index 0000000..7d3591d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/UsbU2FManager.kt @@ -0,0 +1,117 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb + +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.util.Log +import androidx.core.content.getSystemService +import io.timelimit.android.BuildConfig +import io.timelimit.android.integration.platform.android.PendingIntentIds +import io.timelimit.android.u2f.U2fManager +import io.timelimit.android.u2f.util.U2FId + +class UsbU2FManager (val parent: U2fManager, context: Context) { + companion object { + private const val LOG_TAG = "UsbU2FManager" + } + + private val usbManager = context.getSystemService() + private val disconnectReporters = mutableMapOf() + + private val usbConnectionListener = object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + try { + if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED) handleAddedDevice(intent.usbDevice) + else if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) handleRemovedDevice(intent.usbDevice) + } catch (ex: Exception) { + if (BuildConfig.DEBUG) { + Log.w(LOG_TAG, "error handling new/removed device", ex) + } + } + } + } + + private val permissionResponseAction = U2FId.generate() + + private val permissionResponseListener = object: BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + try { + if (intent.action == permissionResponseAction) { + val device = intent.usbDevice + val granted = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false) + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got permission $granted for $device") + } + + permissionRequestManager.reportResult(device, granted) + } + } catch (ex: Exception) { + if (BuildConfig.DEBUG) { + Log.w(LOG_TAG, "error handling permission response", ex) + } + } + } + } + + private val permissionResponseIntent = PendingIntent.getBroadcast( + context, + PendingIntentIds.U2F_USB_RESPONSE, + Intent(permissionResponseAction), + PendingIntent.FLAG_MUTABLE + ) + + private val permissionRequestManager = UsbPermissionRequestManager(sendRequest = { + usbManager?.requestPermission(it, permissionResponseIntent) + }) + + init { + context.registerReceiver(usbConnectionListener, IntentFilter(UsbManager.ACTION_USB_DEVICE_ATTACHED)) + context.registerReceiver(usbConnectionListener, IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)) + context.registerReceiver(permissionResponseListener, IntentFilter(permissionResponseAction)) + } + + private fun handleAddedDevice(device: UsbDevice) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "new device $device") + } + + val disconnectReporter = DisconnectReporter().also { disconnectReporters[device.deviceName] = it } + + val u2FDevice = UsbU2FDevice.from(device, permissionRequestManager, usbManager!!, disconnectReporter) + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "found u2f device $u2FDevice") + } + + u2FDevice?.also { parent.dispatchDeviceFound(it) } + } + + private fun handleRemovedDevice(device: UsbDevice) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "disconnected $device") + } + + disconnectReporters.remove(device.deviceName)?.reportDisconnect() + permissionRequestManager.reportResult(device, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ConfigurationDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ConfigurationDescriptor.kt new file mode 100644 index 0000000..0f38add --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ConfigurationDescriptor.kt @@ -0,0 +1,68 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +data class ConfigurationDescriptor(val interfaces: List) { + companion object { + const val DESCRIPTOR_TYPE = 2.toByte() + + fun parse(input: ByteArray): Pair { + val descriptorLength = input[0].toUByte().toInt() + + if (descriptorLength < 9 || input.size < descriptorLength) + throw UsbException.InvalidDescriptorLengthException() + + if (input[1] != DESCRIPTOR_TYPE) + throw UsbException.InvalidDescriptorTypeException() + + val totalLength = input[2].toUByte().toInt() or input[3].toUByte().toInt().shl(8) + + if (input.size < totalLength) + throw UsbException.InvalidDescriptorLengthException() + + val numInterfaces = input[4].toInt() + val interfaces = mutableListOf() + + var remaining = input.sliceArray(descriptorLength until totalLength) + + while (remaining.isNotEmpty()) { + if (remaining.size < 2) + throw UsbException.InvalidDescriptorLengthException() + + val type = remaining[1] + + if (type == InterfaceDescriptor.DESCRIPTOR_TYPE) { + val (newDescriptor, newRemaining) = InterfaceDescriptor.parse(remaining) + + if (newDescriptor.index != interfaces.size) + throw UsbException.InvalidIndexException() + + remaining = newRemaining + interfaces.add(newDescriptor) + } else remaining = UnknownDescriptor.parse(remaining) + } + + if (numInterfaces != interfaces.size) + throw UsbException.WrongCounterException("interfaces", numInterfaces, interfaces.size) + + return ConfigurationDescriptor( + interfaces = interfaces + ) to input.sliceArray(totalLength until input.size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/DeviceDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/DeviceDescriptor.kt new file mode 100644 index 0000000..9444b1b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/DeviceDescriptor.kt @@ -0,0 +1,56 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +data class DeviceDescriptor(val configurations: List) { + companion object { + private const val DESCRIPTOR_TYPE = 1.toByte() + + fun parse(input: ByteArray): DeviceDescriptor { + val descriptorLength = input[0].toUByte().toInt() + + if (descriptorLength < 17 || input.size < descriptorLength) + throw UsbException.InvalidDescriptorLengthException() + + if (input[1] != DESCRIPTOR_TYPE) + throw UsbException.InvalidDescriptorTypeException() + + val numConfigurations = input[17].toUByte().toInt() + val configurations = mutableListOf() + + var remaining = input.sliceArray(descriptorLength until input.size) + + while (remaining.isNotEmpty()) { + if (remaining.size < 2) + throw UsbException.InvalidDescriptorLengthException() + + if (remaining[1] == ConfigurationDescriptor.DESCRIPTOR_TYPE) { + val (newDescriptor, newRemaining) = ConfigurationDescriptor.parse(remaining) + + remaining = newRemaining + configurations.add(newDescriptor) + } else remaining = UnknownDescriptor.parse(remaining) + } + + if (numConfigurations != configurations.size) + throw UsbException.WrongCounterException("configuration", numConfigurations, configurations.size) + + return DeviceDescriptor(configurations = configurations) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/EndpointDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/EndpointDescriptor.kt new file mode 100644 index 0000000..b022be6 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/EndpointDescriptor.kt @@ -0,0 +1,44 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +data class EndpointDescriptor(val address: Int, val attributes: Int, val maxPacketSize: Int) { + companion object { + const val DESCRIPTOR_TYPE = 5.toByte() + + fun parse(input: ByteArray): Pair { + val descriptorLength = input[0].toUByte().toInt() + + if (descriptorLength < 7 || input.size < descriptorLength) + throw UsbException.InvalidDescriptorLengthException() + + if (input[1] != DESCRIPTOR_TYPE) + throw UsbException.InvalidDescriptorTypeException() + + val address = input[2].toUByte().toInt() + val attributes = input[3].toUByte().toInt() + val maxPacketSize = input[4].toUByte().toInt() or input[5].toUByte().toInt().shl(8) + + return EndpointDescriptor( + address = address, + attributes = attributes, + maxPacketSize = maxPacketSize + ) to input.sliceArray(descriptorLength until input.size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/HidDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/HidDescriptor.kt new file mode 100644 index 0000000..660fe21 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/HidDescriptor.kt @@ -0,0 +1,40 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +data class HidDescriptor(val reportDescriptorSize: Int) { + companion object { + const val DESCRIPTOR_TYPE = 33.toByte() + + fun parse(input: ByteArray): Pair { + val descriptorLength = input[0].toUByte().toInt() + + if (descriptorLength < 9 || input.size < descriptorLength) + throw UsbException.InvalidDescriptorLengthException() + + if (input[1] != DESCRIPTOR_TYPE) + throw UsbException.InvalidDescriptorTypeException() + + val reportDescriptorSize = input[7].toUByte().toInt() or input[8].toUByte().toInt().shl(8) + + return HidDescriptor( + reportDescriptorSize = reportDescriptorSize + ) to input.sliceArray(descriptorLength until input.size) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/InterfaceDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/InterfaceDescriptor.kt new file mode 100644 index 0000000..3313d65 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/InterfaceDescriptor.kt @@ -0,0 +1,84 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +data class InterfaceDescriptor( + val index: Int, + val interfaceClass: Int, + val interfaceSubclass: Int, + val interfaceProtocol: Int, + val hid: HidDescriptor?, + val endpoints: List +) { + companion object { + const val DESCRIPTOR_TYPE = 4.toByte() + + fun parse(input: ByteArray): Pair { + val descriptorLength = input[0].toUByte().toInt() + + if (descriptorLength < 9 || input.size < descriptorLength) + throw UsbException.InvalidDescriptorLengthException() + + if (input[1] != DESCRIPTOR_TYPE) + throw UsbException.InvalidDescriptorTypeException() + + val index = input[2].toUByte().toInt() + val numEndpoints = input[4].toUByte().toInt() + val interfaceClass = input[5].toUByte().toInt() + val interfaceSubclass = input[6].toUByte().toInt() + val interfaceProtocol = input[7].toUByte().toInt() + + var hid: HidDescriptor? = null + val endpoints = mutableListOf() + + var remaining = input.sliceArray(descriptorLength until input.size) + + while (remaining.isNotEmpty()) { + if (remaining.size < 2) + throw UsbException.InvalidDescriptorLengthException() + + val type = remaining[1] + + if (type == DESCRIPTOR_TYPE) break + else if (type == EndpointDescriptor.DESCRIPTOR_TYPE) { + val (newDescriptor, newRemaining) = EndpointDescriptor.parse(remaining) + + remaining = newRemaining + endpoints.add(newDescriptor) + } else if (type == HidDescriptor.DESCRIPTOR_TYPE) { + val (newDescriptor, newRemaining) = HidDescriptor.parse(remaining) + + remaining = newRemaining + hid = newDescriptor + } else remaining = UnknownDescriptor.parse(remaining) + } + + if (numEndpoints != endpoints.size) + throw UsbException.WrongCounterException("endpoints", numEndpoints, endpoints.size) + + return InterfaceDescriptor( + index = index, + interfaceClass = interfaceClass, + interfaceSubclass = interfaceSubclass, + interfaceProtocol = interfaceProtocol, + hid = hid, + endpoints = endpoints + ) to remaining + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ReportDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ReportDescriptor.kt new file mode 100644 index 0000000..c5b0ed3 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/ReportDescriptor.kt @@ -0,0 +1,127 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +data class ReportDescriptor (val items: List) { + companion object { + fun parse(input: ByteArray) = ReportDescriptor(Item.parseList(input)) + + data class Item( + val tag: Int, + val type: Int, + val data: ByteArray + ) { + companion object { + object Type { + const val MAIN = 0 + const val GLOBAL = 1 + const val LOCAL = 2 + } + + val usagePageFido = Item(tag = 0, type = Type.GLOBAL, data = byteArrayOf(0xd0.toByte(), 0xf1.toByte())) + val usageU2F = Item(tag = 0, type = Type.LOCAL, data = byteArrayOf(1)) + + fun parse(input: ByteArray): Pair { + if (input.isEmpty()) + throw UsbException.InvalidDescriptorLengthException() + + val baseTag = input[0].toUByte().toInt().ushr(4) + val tag: Int + val type = input[0].toUByte().toInt().ushr(2) and 3 + val dataLength: Int + val dataOffset: Int + + if (baseTag == 15) { + // long item + + if (input.size < 3) + throw UsbException.InvalidDescriptorLengthException() + + tag = input[2].toUByte().toInt() + dataLength = input[1].toUByte().toInt() + dataOffset = 3 + } else { + // regular item + tag = baseTag + dataLength = when (input[0].toUByte().toInt() and 3) { + 0 -> 0 + 1 -> 1 + 2 -> 2 + 3 -> 4 + else -> throw IllegalStateException() + } + dataOffset = 1 + } + + if (input.size < dataOffset + dataLength) + throw UsbException.InvalidDescriptorLengthException() + + val data = input.sliceArray(dataOffset until dataOffset + dataLength) + + return Item( + tag = tag, + type = type, + data = data + ) to input.sliceArray(dataOffset + dataLength until input.size) + } + + fun parseList(input: ByteArray): List { + val result = mutableListOf() + + var remaining = input + + while (!remaining.isEmpty()) { + val (newItem, newRemaining) = parse(remaining) + + remaining = newRemaining + result.add(newItem) + } + + return result + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as Item + + if (tag != other.tag) return false + if (type != other.type) return false + if (!data.contentEquals(other.data)) return false + + return true + } + + override fun hashCode(): Int { + var result = tag + result = 31 * result + type + result = 31 * result + data.contentHashCode() + return result + } + } + } + + val isU2F = kotlin.run { + val index1 = items.indexOf(Item.usagePageFido) + val index2 = items.indexOf(Item.usageU2F) + + -1 < index1 && index1 < index2 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/UnknownDescriptor.kt b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/UnknownDescriptor.kt new file mode 100644 index 0000000..1751610 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/usb/descriptors/UnknownDescriptor.kt @@ -0,0 +1,29 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.usb.descriptors + +import io.timelimit.android.u2f.usb.UsbException + +object UnknownDescriptor { + fun parse(raw: ByteArray): ByteArray { + val descriptorLength = raw[0].toUByte().toInt() + + if (raw.size < descriptorLength) + throw UsbException.InvalidDescriptorLengthException() + + return raw.sliceArray(descriptorLength until raw.size) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/util/U2FException.kt b/app/src/main/java/io/timelimit/android/u2f/util/U2FException.kt index 86b90b7..94b39dc 100644 --- a/app/src/main/java/io/timelimit/android/u2f/util/U2FException.kt +++ b/app/src/main/java/io/timelimit/android/u2f/util/U2FException.kt @@ -15,11 +15,12 @@ */ package io.timelimit.android.u2f.util -sealed class U2FException: RuntimeException() { - class CommunicationException: U2FException() - class DisconnectedException: U2FException() - class InvalidDataException: U2FException() - class DeviceException: U2FException() - class BadKeyHandleException: U2FException() - class UserInteractionRequired: U2FException() +sealed class U2FException(message: String): RuntimeException(message) { + class CommunicationException: U2FException("communication error") + class DisconnectedException: U2FException("disconnected error") + class InvalidDataException: U2FException("invalid data") + class DeviceException(status: UShort): U2FException("device reported error $status") + class BadKeyHandleException: U2FException("bad key handle") + class UserInteractionRequired: U2FException("user interaction required") + class BadRequestLength: U2FException("wrong request length") } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/util/U2FId.kt b/app/src/main/java/io/timelimit/android/u2f/util/U2FId.kt new file mode 100644 index 0000000..595d8c0 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/u2f/util/U2FId.kt @@ -0,0 +1,25 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.u2f.util + +import io.timelimit.android.crypto.HexString +import java.security.SecureRandom + +object U2FId { + private val random = SecureRandom() + + fun generate() = ByteArray(32).also { random.nextBytes(it) }.let { HexString.toHex(it) } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/u2f/util/U2FThread.kt b/app/src/main/java/io/timelimit/android/u2f/util/U2FThread.kt index 2dc1ee5..a35a730 100644 --- a/app/src/main/java/io/timelimit/android/u2f/util/U2FThread.kt +++ b/app/src/main/java/io/timelimit/android/u2f/util/U2FThread.kt @@ -19,5 +19,6 @@ import java.util.concurrent.Executors object U2FThread { val nfc by lazy { Executors.newFixedThreadPool(2) } + val usb by lazy { Executors.newFixedThreadPool(2) } val crypto by lazy { Executors.newSingleThreadExecutor() } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/login/AuthTokenLoginProcessor.kt b/app/src/main/java/io/timelimit/android/ui/login/AuthTokenLoginProcessor.kt index 8edae3e..c6ffe0d 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/AuthTokenLoginProcessor.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/AuthTokenLoginProcessor.kt @@ -45,98 +45,99 @@ object AuthTokenLoginProcessor { runAsync { try { - val session = device.connect() - val keys = Threads.database.executeAndWait { model.logic.database.u2f().getAllSync() } - val random = SecureRandom() - val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL) - - for (key in keys) { - if (BuildConfig.DEBUG) { - Log.d(LOG_TAG, "try key $key") - } - - val challenge = ByteArray(32).also { random.nextBytes(it) } - - try { - val response = session.login( - U2FRequest.Login( - mode = U2FRequest.Login.Mode.DoNotEnforcePresence, - challenge = challenge, - applicationId = applicationId, - keyHandle = key.keyHandle - ) - ) + device.connect().use { session -> + val keys = Threads.database.executeAndWait { model.logic.database.u2f().getAllSync() } + val random = SecureRandom() + val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL) + for (key in keys) { if (BuildConfig.DEBUG) { - Log.d(LOG_TAG, "got response $response") + Log.d(LOG_TAG, "try key $key") } - val signatureValid = U2FThread.crypto.executeAndWait { - U2FSignatureValidation.validate( - applicationId = applicationId, - challenge = challenge, - response = response, - publicKey = key.publicKey + val challenge = ByteArray(32).also { random.nextBytes(it) } + + try { + val response = session.login( + U2FRequest.Login( + mode = U2FRequest.Login.Mode.DoNotEnforcePresence, + challenge = challenge, + applicationId = applicationId, + keyHandle = key.keyHandle + ) ) - } - if (!signatureValid) { - toast(R.string.u2f_login_error_invalid) + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got response $response") + } - break - } + val signatureValid = U2FThread.crypto.executeAndWait { + U2FSignatureValidation.validate( + applicationId = applicationId, + challenge = challenge, + response = response, + publicKey = key.publicKey + ) + } - val userEntry = Threads.database.executeAndWait { - if ( - model.logic.database.u2f().updateCounter( - parentUserId = key.userId, - keyHandle = key.keyHandle, - publicKey = key.publicKey, - counter = response.counter.toLong() - ) > 0 - ) { - model.logic.database.user().getUserByIdSync(key.userId)!! - } else null - } + if (!signatureValid) { + toast(R.string.u2f_login_error_invalid) - if (userEntry == null) { - toast(R.string.u2f_login_error_invalid) + break + } - break - } + val userEntry = Threads.database.executeAndWait { + if ( + model.logic.database.u2f().updateCounter( + parentUserId = key.userId, + keyHandle = key.keyHandle, + publicKey = key.publicKey, + counter = response.counter.toLong() + ) > 0 + ) { + model.logic.database.user().getUserByIdSync(key.userId)!! + } else null + } - val allowLoginStatus = Threads.database.executeAndWait { - AllowUserLoginStatusUtil.calculateSync( - logic = model.logic, - userId = userEntry.id, - didSync = false + if (userEntry == null) { + toast(R.string.u2f_login_error_invalid) + + break + } + + val allowLoginStatus = Threads.database.executeAndWait { + AllowUserLoginStatusUtil.calculateSync( + logic = model.logic, + userId = userEntry.id, + didSync = false + ) + } + + val shouldSignIn = allowLoginStatus is AllowUserLoginStatus.Allow + + if (!shouldSignIn) { + toast(LoginDialogFragmentModel.formatAllowLoginStatusError(allowLoginStatus, model.getApplication())) + + return@runAsync + } + + model.setAuthenticatedUser( + AuthenticatedUser( + userId = key.userId, + authenticatedBy = AuthenticationMethod.KeyCode, + firstPasswordHash = userEntry.password, + secondPasswordHash = "u2f" + ) ) + + return@runAsync // no need to try more + } catch (ex: U2FException.BadKeyHandleException) { + // ignore and try the next one } - - val shouldSignIn = allowLoginStatus is AllowUserLoginStatus.Allow - - if (!shouldSignIn) { - toast(LoginDialogFragmentModel.formatAllowLoginStatusError(allowLoginStatus, model.getApplication())) - - return@runAsync - } - - model.setAuthenticatedUser( - AuthenticatedUser( - userId = key.userId, - authenticatedBy = AuthenticationMethod.KeyCode, - firstPasswordHash = userEntry.password, - secondPasswordHash = "u2f" - ) - ) - - return@runAsync // no need to try more - } catch (ex: U2FException.BadKeyHandleException) { - // ignore and try the next one } - } - toast(R.string.u2f_login_error_unknown) + toast(R.string.u2f_login_error_unknown) + } } catch (ex: U2FException.DisconnectedException) { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "disconnected", ex) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/Adapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/Adapter.kt index 0e5c339..ddb741d 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/Adapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/Adapter.kt @@ -35,6 +35,8 @@ class Adapter() : RecyclerView.Adapter() { var items: List by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() } var listener: Handlers? = null + init { setHasStableIds(true) } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = when (viewType) { TYPE_ADD -> Holder.Add(AddItemViewBinding.inflate(LayoutInflater.from(parent.context), parent, false)).also { it.binding.label = parent.context.getString(R.string.manage_parent_u2f_add_key) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FDialogFragment.kt index ac58598..2c3791a 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FDialogFragment.kt @@ -71,6 +71,7 @@ class AddU2FDialogFragment: BottomSheetDialogFragment() { AddU2FModel.Status.ConnectionInterrupted -> getString(R.string.manage_parent_u2f_status_interrupted) AddU2FModel.Status.RequestFailed -> getString(R.string.manage_parent_u2f_status_failed) AddU2FModel.Status.AlreadyLinked -> getString(R.string.manage_parent_u2f_status_already_linked) + AddU2FModel.Status.NeedsUserInteraction -> getString(R.string.manage_parent_u2f_status_needs_user_interaction) is AddU2FModel.Status.Done -> { if (!status.commited) { activityModel.tryDispatchParentAction(status.action) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FModel.kt index 48580e7..a3a6489 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/u2fkey/add/AddU2FModel.kt @@ -33,6 +33,7 @@ import io.timelimit.android.u2f.protocol.U2FRequest import io.timelimit.android.u2f.protocol.login import io.timelimit.android.u2f.protocol.register import io.timelimit.android.u2f.util.U2FException +import kotlinx.coroutines.delay import java.security.SecureRandom class AddU2FModel(application: Application): AndroidViewModel(application), U2fManager.DeviceFoundListener { @@ -59,36 +60,56 @@ class AddU2FModel(application: Application): AndroidViewModel(application), U2fM try { val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL) - val session = device.connect() + device.connect().use { session -> + val currentKeys = Threads.database.executeAndWait {logic.database.u2f().getAllSync() } - val currentKeys = Threads.database.executeAndWait { logic.database.u2f().getAllSync() } - - for (key in currentKeys) { - try { - session.login( - U2FRequest.Login( - mode = U2FRequest.Login.Mode.CheckOnly, - challenge = ByteArray(32).also { SecureRandom().nextBytes(it) }, - applicationId = applicationId, - keyHandle = key.keyHandle + for (key in currentKeys) { + try { + session.login( + U2FRequest.Login( + mode = U2FRequest.Login.Mode.CheckOnly, + challenge = ByteArray(32).also { SecureRandom().nextBytes(it) }, + applicationId = applicationId, + keyHandle = key.keyHandle + ) ) - ) - } catch (ex: U2FException.BadKeyHandleException) { - continue - } catch (ex: U2FException.UserInteractionRequired) { - statusInternal.value = Status.AlreadyLinked + } catch (ex: U2FException.BadKeyHandleException) { + continue + } catch (ex: U2FException.UserInteractionRequired) { + statusInternal.value = Status.AlreadyLinked - return@runAsync + return@runAsync + } + } + + val challenge = ByteArray(32).also { SecureRandom().nextBytes(it) } + + while (true) { + try { + val registerResponse = session.register( + U2FRequest.Register( + challenge = challenge, + applicationId = applicationId + ) + ) + + statusInternal.value = Status.Done( + AddParentU2FKey( + keyHandle = registerResponse.keyHandle, + publicKey = registerResponse.publicKey + ) + ) + + break + } catch (ex: U2FException.UserInteractionRequired) { + if (statusInternal.value != Status.NeedsUserInteraction) { + statusInternal.value = Status.NeedsUserInteraction + } + + delay(50) + } } } - - val challenge = ByteArray(32).also { SecureRandom().nextBytes(it) } - - val registerResponse = session.register(U2FRequest.Register(challenge = challenge, applicationId = applicationId)) - - statusInternal.value = Status.Done( - AddParentU2FKey(keyHandle = registerResponse.keyHandle, publicKey = registerResponse.publicKey) - ) } catch (ex: U2FException.DisconnectedException) { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "connection interrupted", ex) @@ -111,6 +132,7 @@ class AddU2FModel(application: Application): AndroidViewModel(application), U2fM object ConnectionInterrupted: Status() object RequestFailed: Status() object AlreadyLinked: Status() + object NeedsUserInteraction: Status() data class Done(val action: AddParentU2FKey, var commited: Boolean = false): Status() } } \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 4731917..efd8817 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -1027,7 +1027,7 @@ Das alte Passwort ist falsch U2F-Schlüssel - NFC U2F-Tokens zum Authentifizieren einrichten + U2F-Tokens (über USB oder NFC) zum Authentifizieren einrichten Dies ermöglicht es, ein neues Passwort festzulegen, ohne das alte Passwort zu kennen. @@ -1657,7 +1657,7 @@ abgelehnt; möglicherweise können Sie dies nur noch über die Systemeinstellungen erlauben Token hinzufügen - Warte auf einen Schlüssel; NFC %s + Warte auf einen Schlüssel; Verbinden Sie jetzt den Schlüssel; NFC %s ist aktiviert ist deaktiviert wird von Ihrem Gerät nicht unterstützt @@ -1665,6 +1665,7 @@ Verbindung unterbrochen Anfrage fehlgeschlagen Dieses Gerät ist bereits verknüpft + Warte auf Druck der Bestätigen-Taste am Schlüssel Das Gerät wurde verknüpft Möchten sich diesen Schlüssel wirklich entfernen? diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2f50582..87eae0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1075,7 +1075,7 @@ The old password is invalid U2F Keys - Setup NFC U2F Tokens to authenticate + Setup U2F Tokens (using USB or NFC) to authenticate @string/restore_parent_password_title @@ -1709,7 +1709,7 @@ rejected; it could be that you can only change this using the system settings Add Token - Waiting for your key; NFC is %s + Waiting for your key; Connect the key now; NFC is %s enabled disabled unsupported @@ -1717,6 +1717,7 @@ Connection was interrupted The Request Failed This device is already linked + Waiting for button press at the key The Device was linked Would you like to remove this key?