Add USB U2F support

This commit is contained in:
Jonas Lochmann 2022-08-08 02:00:00 +02:00
parent 651d1ea9b1
commit a040e902d1
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
32 changed files with 1512 additions and 128 deletions

View file

@ -43,6 +43,7 @@
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<application
android:banner="@drawable/banner"

View file

@ -0,0 +1,23 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.extensions
inline fun <T> Collection<T>.some(predicate: (T) -> Boolean): Boolean {
for (element in this) if (predicate(element)) return true
return false
}

View file

@ -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) {

View file

@ -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<DeviceFoundListener>()

View file

@ -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,6 +48,7 @@ class NFCU2FManager (val parent: U2fManager, context: Context) {
private val nfcReceiver = object: BroadcastReceiver() {
override fun onReceive(p0: Context?, intent: Intent?) {
try {
val tagFromIntent: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return
else
@ -56,9 +57,14 @@ class NFCU2FManager (val parent: U2fManager, context: Context) {
val isoDep: IsoDep = IsoDep.get(tagFromIntent) ?: return
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,

View file

@ -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
}
}
}
}

View file

@ -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
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -0,0 +1,24 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.u2f.usb
class DisconnectReporter {
private var didDisconnectInternal = false
val didDisconnect get() = didDisconnectInternal
fun reportDisconnect() { didDisconnectInternal = true }
}

View file

@ -0,0 +1,23 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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")
}

View file

@ -0,0 +1,32 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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) }

View file

@ -0,0 +1,54 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<String, MutableList<CancellableContinuation<Boolean>>>()
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<Boolean>) = synchronized(pendingRequests) {
pendingRequests.getOrPut(device.deviceName) { mutableListOf() }.add(listener)
}
private fun removePendingRequest(device: UsbDevice, listener: CancellableContinuation<Boolean>) = synchronized(pendingRequests) {
pendingRequests[device.deviceName]?.remove(listener)
if (pendingRequests[device.deviceName]?.isEmpty() == true) {
pendingRequests.remove(device.deviceName)
}
}
}

View file

@ -0,0 +1,204 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}

View file

@ -0,0 +1,361 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<UsbRequest>()
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()
}
}

View file

@ -0,0 +1,117 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<UsbManager>()
private val disconnectReporters = mutableMapOf<String, DisconnectReporter>()
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)
}
}

View file

@ -0,0 +1,68 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.u2f.usb.descriptors
import io.timelimit.android.u2f.usb.UsbException
data class ConfigurationDescriptor(val interfaces: List<InterfaceDescriptor>) {
companion object {
const val DESCRIPTOR_TYPE = 2.toByte()
fun parse(input: ByteArray): Pair<ConfigurationDescriptor, ByteArray> {
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<InterfaceDescriptor>()
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)
}
}
}

View file

@ -0,0 +1,56 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.u2f.usb.descriptors
import io.timelimit.android.u2f.usb.UsbException
data class DeviceDescriptor(val configurations: List<ConfigurationDescriptor>) {
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<ConfigurationDescriptor>()
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)
}
}
}

View file

@ -0,0 +1,44 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<EndpointDescriptor, ByteArray> {
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)
}
}
}

View file

@ -0,0 +1,40 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<HidDescriptor, ByteArray> {
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)
}
}
}

View file

@ -0,0 +1,84 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<EndpointDescriptor>
) {
companion object {
const val DESCRIPTOR_TYPE = 4.toByte()
fun parse(input: ByteArray): Pair<InterfaceDescriptor, ByteArray> {
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<EndpointDescriptor>()
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
}
}
}

View file

@ -0,0 +1,127 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.u2f.usb.descriptors
import io.timelimit.android.u2f.usb.UsbException
data class ReportDescriptor (val items: List<Item>) {
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<Item, ByteArray> {
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<Item> {
val result = mutableListOf<Item>()
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
}
}

View file

@ -0,0 +1,29 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -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")
}

View file

@ -0,0 +1,25 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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) }
}

View file

@ -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() }
}

View file

@ -45,7 +45,7 @@ object AuthTokenLoginProcessor {
runAsync {
try {
val session = device.connect()
device.connect().use { session ->
val keys = Threads.database.executeAndWait { model.logic.database.u2f().getAllSync() }
val random = SecureRandom()
val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL)
@ -137,6 +137,7 @@ object AuthTokenLoginProcessor {
}
toast(R.string.u2f_login_error_unknown)
}
} catch (ex: U2FException.DisconnectedException) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "disconnected", ex)

View file

@ -35,6 +35,8 @@ class Adapter() : RecyclerView.Adapter<Adapter.Holder>() {
var items: List<U2FKeyListItem> 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)

View file

@ -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)

View file

@ -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,9 +60,8 @@ class AddU2FModel(application: Application): AndroidViewModel(application), U2fM
try {
val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL)
val session = device.connect()
val currentKeys = Threads.database.executeAndWait { logic.database.u2f().getAllSync() }
device.connect().use { session ->
val currentKeys = Threads.database.executeAndWait {logic.database.u2f().getAllSync() }
for (key in currentKeys) {
try {
@ -84,11 +84,32 @@ class AddU2FModel(application: Application): AndroidViewModel(application), U2fM
val challenge = ByteArray(32).also { SecureRandom().nextBytes(it) }
val registerResponse = session.register(U2FRequest.Register(challenge = challenge, applicationId = applicationId))
while (true) {
try {
val registerResponse = session.register(
U2FRequest.Register(
challenge = challenge,
applicationId = applicationId
)
)
statusInternal.value = Status.Done(
AddParentU2FKey(keyHandle = registerResponse.keyHandle, publicKey = registerResponse.publicKey)
AddParentU2FKey(
keyHandle = registerResponse.keyHandle,
publicKey = registerResponse.publicKey
)
)
break
} catch (ex: U2FException.UserInteractionRequired) {
if (statusInternal.value != Status.NeedsUserInteraction) {
statusInternal.value = Status.NeedsUserInteraction
}
delay(50)
}
}
}
} 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()
}
}

View file

@ -1027,7 +1027,7 @@
<string name="manage_parent_change_password_toast_wrong_password">Das alte Passwort ist falsch</string>
<string name="manage_parent_u2f_title">U2F-Schlüssel</string>
<string name="manage_parent_u2f_description">NFC U2F-Tokens zum Authentifizieren einrichten</string>
<string name="manage_parent_u2f_description">U2F-Tokens (über USB oder NFC) zum Authentifizieren einrichten</string>
<string name="manage_parent_restore_password_text_require_mail">
Dies ermöglicht es, ein neues Passwort festzulegen, ohne das alte Passwort zu kennen.
@ -1657,7 +1657,7 @@
<string name="notify_permission_rejected_toast">abgelehnt; möglicherweise können Sie dies nur noch über die Systemeinstellungen erlauben</string>
<string name="manage_parent_u2f_add_key">Token hinzufügen</string>
<string name="manage_parent_u2f_status_wait_key">Warte auf einen Schlüssel; NFC %s</string>
<string name="manage_parent_u2f_status_wait_key">Warte auf einen Schlüssel; Verbinden Sie jetzt den Schlüssel; NFC %s</string>
<string name="manage_parent_u2f_status_wait_key_nfc_enabled">ist aktiviert</string>
<string name="manage_parent_u2f_status_wait_key_nfc_disabled">ist deaktiviert</string>
<string name="manage_parent_u2f_status_wait_key_nfc_unsupported">wird von Ihrem Gerät nicht unterstützt</string>
@ -1665,6 +1665,7 @@
<string name="manage_parent_u2f_status_interrupted">Verbindung unterbrochen</string>
<string name="manage_parent_u2f_status_failed">Anfrage fehlgeschlagen</string>
<string name="manage_parent_u2f_status_already_linked">Dieses Gerät ist bereits verknüpft</string>
<string name="manage_parent_u2f_status_needs_user_interaction">Warte auf Druck der Bestätigen-Taste am Schlüssel</string>
<string name="manage_parent_u2f_status_done">Das Gerät wurde verknüpft</string>
<string name="manage_parent_u2f_remove_key_text">Möchten sich diesen Schlüssel wirklich entfernen?</string>

View file

@ -1075,7 +1075,7 @@
<string name="manage_parent_change_password_toast_wrong_password">The old password is invalid</string>
<string name="manage_parent_u2f_title">U2F Keys</string>
<string name="manage_parent_u2f_description">Setup NFC U2F Tokens to authenticate</string>
<string name="manage_parent_u2f_description">Setup U2F Tokens (using USB or NFC) to authenticate</string>
<string name="manage_parent_restore_password_title" translatable="false">@string/restore_parent_password_title</string>
<string name="manage_parent_restore_password_text_require_mail">
@ -1709,7 +1709,7 @@
<string name="notify_permission_rejected_toast">rejected; it could be that you can only change this using the system settings</string>
<string name="manage_parent_u2f_add_key">Add Token</string>
<string name="manage_parent_u2f_status_wait_key">Waiting for your key; NFC is %s</string>
<string name="manage_parent_u2f_status_wait_key">Waiting for your key; Connect the key now; NFC is %s</string>
<string name="manage_parent_u2f_status_wait_key_nfc_enabled">enabled</string>
<string name="manage_parent_u2f_status_wait_key_nfc_disabled">disabled</string>
<string name="manage_parent_u2f_status_wait_key_nfc_unsupported">unsupported</string>
@ -1717,6 +1717,7 @@
<string name="manage_parent_u2f_status_interrupted">Connection was interrupted</string>
<string name="manage_parent_u2f_status_failed">The Request Failed</string>
<string name="manage_parent_u2f_status_already_linked">This device is already linked</string>
<string name="manage_parent_u2f_status_needs_user_interaction">Waiting for button press at the key</string>
<string name="manage_parent_u2f_status_done">The Device was linked</string>
<string name="manage_parent_u2f_remove_key_text">Would you like to remove this key?</string>