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?