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.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" 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.nfc" android:required="false" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<application <application
android:banner="@drawable/banner" 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 UPDATE_STATUS = 5
const val OPEN_UPDATER = 6 const val OPEN_UPDATER = 6
const val U2F_NFC_DISCOVERY = 7 const val U2F_NFC_DISCOVERY = 7
const val U2F_USB_RESPONSE = 8
val DYNAMIC_NOTIFICATION_RANGE = 100..10000 val DYNAMIC_NOTIFICATION_RANGE = 100..10000
val PENDING_INTENT_FLAGS = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { 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 androidx.fragment.app.FragmentActivity
import io.timelimit.android.u2f.nfc.NFCU2FManager import io.timelimit.android.u2f.nfc.NFCU2FManager
import io.timelimit.android.u2f.protocol.U2FDevice import io.timelimit.android.u2f.protocol.U2FDevice
import io.timelimit.android.u2f.usb.UsbU2FManager
class U2fManager (context: Context) { class U2fManager (context: Context) {
companion object { companion object {
@ -41,6 +42,7 @@ class U2fManager (context: Context) {
} }
private val nfc = NFCU2FManager(this, context) private val nfc = NFCU2FManager(this, context)
private val usb = UsbU2FManager(this, context)
private val deviceFoundListeners = mutableListOf<DeviceFoundListener>() private val deviceFoundListeners = mutableListOf<DeviceFoundListener>()

View file

@ -32,11 +32,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.integration.platform.android.PendingIntentIds import io.timelimit.android.integration.platform.android.PendingIntentIds
import io.timelimit.android.livedata.liveDataFromFunction import io.timelimit.android.livedata.liveDataFromFunction
import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.livedata.liveDataFromNonNullValue
import io.timelimit.android.u2f.U2fManager import io.timelimit.android.u2f.U2fManager
import io.timelimit.android.u2f.util.U2FId
class NFCU2FManager (val parent: U2fManager, context: Context) { class NFCU2FManager (val parent: U2fManager, context: Context) {
companion object { companion object {
@ -48,17 +48,23 @@ class NFCU2FManager (val parent: U2fManager, context: Context) {
private val nfcReceiver = object: BroadcastReceiver() { private val nfcReceiver = object: BroadcastReceiver() {
override fun onReceive(p0: Context?, intent: Intent?) { override fun onReceive(p0: Context?, intent: Intent?) {
val tagFromIntent: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) try {
intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return val tagFromIntent: Tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
else intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) ?: return
intent?.getParcelableExtra(NfcAdapter.EXTRA_TAG) ?: 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( private val nfcReceiverIntent = PendingIntent.getBroadcast(
context, context,
PendingIntentIds.U2F_NFC_DISCOVERY, PendingIntentIds.U2F_NFC_DISCOVERY,

View file

@ -20,7 +20,6 @@ import android.nfc.tech.IsoDep
import android.util.Log import android.util.Log
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.u2f.*
import io.timelimit.android.u2f.protocol.U2FDeviceSession import io.timelimit.android.u2f.protocol.U2FDeviceSession
import io.timelimit.android.u2f.protocol.U2FRequest import io.timelimit.android.u2f.protocol.U2FRequest
import io.timelimit.android.u2f.protocol.U2fRawResponse 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 { override suspend fun execute(request: U2FRequest): U2fRawResponse = U2FThread.nfc.executeAndWait {
try { try {
var response = U2fRawResponse.decode(tag.transceive(request.encode())) var response = U2fRawResponse.decode(tag.transceive(request.encodeShort()))
var fullPayload = response.payload var fullPayload = response.payload
// https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-u2f-nfc-protocol-v1.2-ps-20170411.html // 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) response = response.copy(payload = fullPayload)
if (response.status == 0x6A80.toUShort()) throw U2FException.BadKeyHandleException() response.throwIfNoSuccess()
if (response.status == 0x6985.toUShort()) throw U2FException.UserInteractionRequired()
if (response.status != 0x9000.toUShort()) throw U2FException.DeviceException()
response response
} catch (ex: TagLostException) { } catch (ex: TagLostException) {
@ -75,4 +72,14 @@ class NfcU2FDeviceSession(private val tag: IsoDep): U2FDeviceSession {
throw U2FException.CommunicationException() 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 package io.timelimit.android.u2f.protocol
interface U2FDeviceSession { import java.io.Closeable
interface U2FDeviceSession: Closeable {
suspend fun execute(request: U2FRequest): U2fRawResponse suspend fun execute(request: U2FRequest): U2fRawResponse
} }

View file

@ -23,7 +23,7 @@ sealed class U2FRequest {
abstract val p2: Byte abstract val p2: Byte
abstract val payload: ByteArray abstract val payload: ByteArray
fun encode(): ByteArray { fun encodeShort(): ByteArray {
val cla: Byte = 0 val cla: Byte = 0
if (payload.size > 255) { if (payload.size > 255) {
@ -39,6 +39,23 @@ sealed class U2FRequest {
) + payload + byteArrayOf(0) ) + 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( data class Register(
val challenge: ByteArray, val challenge: ByteArray,
val applicationId: 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 package io.timelimit.android.u2f.util
sealed class U2FException: RuntimeException() { sealed class U2FException(message: String): RuntimeException(message) {
class CommunicationException: U2FException() class CommunicationException: U2FException("communication error")
class DisconnectedException: U2FException() class DisconnectedException: U2FException("disconnected error")
class InvalidDataException: U2FException() class InvalidDataException: U2FException("invalid data")
class DeviceException: U2FException() class DeviceException(status: UShort): U2FException("device reported error $status")
class BadKeyHandleException: U2FException() class BadKeyHandleException: U2FException("bad key handle")
class UserInteractionRequired: U2FException() 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 { object U2FThread {
val nfc by lazy { Executors.newFixedThreadPool(2) } val nfc by lazy { Executors.newFixedThreadPool(2) }
val usb by lazy { Executors.newFixedThreadPool(2) }
val crypto by lazy { Executors.newSingleThreadExecutor() } val crypto by lazy { Executors.newSingleThreadExecutor() }
} }

View file

@ -45,98 +45,99 @@ object AuthTokenLoginProcessor {
runAsync { runAsync {
try { try {
val session = device.connect() device.connect().use { session ->
val keys = Threads.database.executeAndWait { model.logic.database.u2f().getAllSync() } val keys = Threads.database.executeAndWait { model.logic.database.u2f().getAllSync() }
val random = SecureRandom() val random = SecureRandom()
val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL) 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
)
)
for (key in keys) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "got response $response") Log.d(LOG_TAG, "try key $key")
} }
val signatureValid = U2FThread.crypto.executeAndWait { val challenge = ByteArray(32).also { random.nextBytes(it) }
U2FSignatureValidation.validate(
applicationId = applicationId, try {
challenge = challenge, val response = session.login(
response = response, U2FRequest.Login(
publicKey = key.publicKey mode = U2FRequest.Login.Mode.DoNotEnforcePresence,
challenge = challenge,
applicationId = applicationId,
keyHandle = key.keyHandle
)
) )
}
if (!signatureValid) { if (BuildConfig.DEBUG) {
toast(R.string.u2f_login_error_invalid) 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 (!signatureValid) {
if ( toast(R.string.u2f_login_error_invalid)
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 (userEntry == null) { break
toast(R.string.u2f_login_error_invalid) }
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 { if (userEntry == null) {
AllowUserLoginStatusUtil.calculateSync( toast(R.string.u2f_login_error_invalid)
logic = model.logic,
userId = userEntry.id, break
didSync = false }
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) { } catch (ex: U2FException.DisconnectedException) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "disconnected", ex) 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 items: List<U2FKeyListItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
var listener: Handlers? = null var listener: Handlers? = null
init { setHasStableIds(true) }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = when (viewType) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder = when (viewType) {
TYPE_ADD -> Holder.Add(AddItemViewBinding.inflate(LayoutInflater.from(parent.context), parent, false)).also { 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) 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.ConnectionInterrupted -> getString(R.string.manage_parent_u2f_status_interrupted)
AddU2FModel.Status.RequestFailed -> getString(R.string.manage_parent_u2f_status_failed) 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.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 -> { is AddU2FModel.Status.Done -> {
if (!status.commited) { if (!status.commited) {
activityModel.tryDispatchParentAction(status.action) 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.login
import io.timelimit.android.u2f.protocol.register import io.timelimit.android.u2f.protocol.register
import io.timelimit.android.u2f.util.U2FException import io.timelimit.android.u2f.util.U2FException
import kotlinx.coroutines.delay
import java.security.SecureRandom import java.security.SecureRandom
class AddU2FModel(application: Application): AndroidViewModel(application), U2fManager.DeviceFoundListener { class AddU2FModel(application: Application): AndroidViewModel(application), U2fManager.DeviceFoundListener {
@ -59,36 +60,56 @@ class AddU2FModel(application: Application): AndroidViewModel(application), U2fM
try { try {
val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL) 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 {
for (key in currentKeys) { session.login(
try { U2FRequest.Login(
session.login( mode = U2FRequest.Login.Mode.CheckOnly,
U2FRequest.Login( challenge = ByteArray(32).also { SecureRandom().nextBytes(it) },
mode = U2FRequest.Login.Mode.CheckOnly, applicationId = applicationId,
challenge = ByteArray(32).also { SecureRandom().nextBytes(it) }, keyHandle = key.keyHandle
applicationId = applicationId, )
keyHandle = key.keyHandle
) )
) } catch (ex: U2FException.BadKeyHandleException) {
} catch (ex: U2FException.BadKeyHandleException) { continue
continue } catch (ex: U2FException.UserInteractionRequired) {
} catch (ex: U2FException.UserInteractionRequired) { statusInternal.value = Status.AlreadyLinked
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) { } catch (ex: U2FException.DisconnectedException) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "connection interrupted", ex) Log.d(LOG_TAG, "connection interrupted", ex)
@ -111,6 +132,7 @@ class AddU2FModel(application: Application): AndroidViewModel(application), U2fM
object ConnectionInterrupted: Status() object ConnectionInterrupted: Status()
object RequestFailed: Status() object RequestFailed: Status()
object AlreadyLinked: Status() object AlreadyLinked: Status()
object NeedsUserInteraction: Status()
data class Done(val action: AddParentU2FKey, var commited: Boolean = false): 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_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_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"> <string name="manage_parent_restore_password_text_require_mail">
Dies ermöglicht es, ein neues Passwort festzulegen, ohne das alte Passwort zu kennen. 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="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_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_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_disabled">ist deaktiviert</string>
<string name="manage_parent_u2f_status_wait_key_nfc_unsupported">wird von Ihrem Gerät nicht unterstützt</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_interrupted">Verbindung unterbrochen</string>
<string name="manage_parent_u2f_status_failed">Anfrage fehlgeschlagen</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_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_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> <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_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_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_title" translatable="false">@string/restore_parent_password_title</string>
<string name="manage_parent_restore_password_text_require_mail"> <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="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_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_enabled">enabled</string>
<string name="manage_parent_u2f_status_wait_key_nfc_disabled">disabled</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> <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_interrupted">Connection was interrupted</string>
<string name="manage_parent_u2f_status_failed">The Request Failed</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_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_status_done">The Device was linked</string>
<string name="manage_parent_u2f_remove_key_text">Would you like to remove this key?</string> <string name="manage_parent_u2f_remove_key_text">Would you like to remove this key?</string>