diff --git a/play-services-fido/core/build.gradle b/play-services-fido/core/build.gradle
index 157e0643d8..f54573dd1a 100644
--- a/play-services-fido/core/build.gradle
+++ b/play-services-fido/core/build.gradle
@@ -32,6 +32,9 @@ dependencies {
implementation "com.android.volley:volley:$volleyVersion"
implementation 'com.upokecenter:cbor:4.5.2'
implementation 'com.google.guava:guava:31.1-android'
+
+ implementation 'com.google.zxing:core:3.5.2'
+ implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}
android {
diff --git a/play-services-fido/core/src/main/AndroidManifest.xml b/play-services-fido/core/src/main/AndroidManifest.xml
index 7a266c5600..37c7673d3e 100644
--- a/play-services-fido/core/src/main/AndroidManifest.xml
+++ b/play-services-fido/core/src/main/AndroidManifest.xml
@@ -16,6 +16,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt
index 40cb964b72..521beb5c1f 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt
@@ -35,7 +35,13 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
fun getKnownRegistrationInfo(rpId: String) = readableDatabase.use {
val cursor = it.query(
- TABLE_KNOWN_REGISTRATIONS, arrayOf(COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT), "$COLUMN_RP_ID=?", arrayOf(rpId), null, null, null
+ TABLE_KNOWN_REGISTRATIONS,
+ arrayOf(COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT),
+ "$COLUMN_RP_ID=?",
+ arrayOf(rpId),
+ null,
+ null,
+ "$COLUMN_TIMESTAMP DESC"
)
val result = mutableListOf()
cursor.use { c ->
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt
index e3bb3f6bbc..ed12859ad9 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt
@@ -108,7 +108,10 @@ private suspend fun isFacetIdTrusted(context: Context, facetIds: Set, ap
return facetIds.any { trustedFacets.contains(it) }
}
-private const val ASSET_LINK_REL = "delegate_permission/common.get_login_creds"
+private val ASSET_LINK_REL = listOf(
+ "delegate_permission/common.get_login_creds",
+ "delegate_permission/common.handle_all_urls"
+)
private suspend fun isAssetLinked(context: Context, rpId: String, fp: String, packageName: String?): Boolean {
try {
val deferred = CompletableDeferred()
@@ -118,7 +121,7 @@ private suspend fun isAssetLinked(context: Context, rpId: String, fp: String, pa
.add(JsonArrayRequest(url, { deferred.complete(it) }, { deferred.completeExceptionally(it) }))
val arr = deferred.await()
for (obj in arr.map(JSONArray::getJSONObject)) {
- if (!obj.getJSONArray("relation").map(JSONArray::getString).contains(ASSET_LINK_REL)) continue
+ if (obj.getJSONArray("relation").map(JSONArray::getString).none { ASSET_LINK_REL.contains(it) }) continue
val target = obj.getJSONObject("target")
if (target.getString("namespace") != "android_app") continue
if (packageName != null && target.getString("package_name") != packageName) continue
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridBleAdvertiser.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridBleAdvertiser.kt
new file mode 100644
index 0000000000..0522cc0a15
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridBleAdvertiser.kt
@@ -0,0 +1,87 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.ble
+
+import android.Manifest
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.le.AdvertiseCallback
+import android.bluetooth.le.AdvertiseData
+import android.bluetooth.le.AdvertiseSettings
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresPermission
+import org.microg.gms.fido.core.hybrid.UUID_ANDROID
+import java.util.Timer
+import java.util.TimerTask
+import java.util.concurrent.atomic.AtomicBoolean
+
+private const val TAG = "HybridBleAdvertiser"
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class HybridBleAdvertiser(
+ private val bluetoothLeAdapter: BluetoothAdapter?,
+) : AdvertiseCallback() {
+ private val advertiserStatus = AtomicBoolean(false)
+ private val timer = Timer()
+ private val stopTimeTask = object : TimerTask() {
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ override fun run() {
+ stopAdvertising()
+ }
+ }
+
+ private val bluetoothLeAdvertiser by lazy {
+ if (bluetoothLeAdapter != null) {
+ bluetoothLeAdapter.bluetoothLeAdvertiser
+ } else {
+ Log.d(TAG, "BLE_HARDWARE ERROR")
+ null
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ fun startAdvertising(eid: ByteArray) {
+ if (advertiserStatus.compareAndSet(false, true)) {
+ val settings = AdvertiseSettings.Builder()
+ .setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
+ .setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
+ .setConnectable(false)
+ .setTimeout(0)
+ .build()
+
+ val data = AdvertiseData.Builder()
+ .addServiceUuid(UUID_ANDROID)
+ .addServiceData(UUID_ANDROID, eid)
+ .setIncludeDeviceName(false)
+ .setIncludeTxPowerLevel(false)
+ .build()
+
+ bluetoothLeAdvertiser?.startAdvertising(settings, data, this)
+
+ timer.schedule(stopTimeTask, 10000L)
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ fun stopAdvertising() {
+ if (this.advertiserStatus.compareAndSet(true, false)) {
+ timer.cancel()
+ Log.d(TAG, "BLE_ADVERTISING_STOP")
+ bluetoothLeAdvertiser?.stopAdvertising(this)
+ }
+ }
+
+ override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
+ super.onStartSuccess(settingsInEffect)
+ Log.d(TAG, String.format("BLE advertising onStartSuccess: %s", settingsInEffect))
+ }
+
+ override fun onStartFailure(errorCode: Int) {
+ super.onStartFailure(errorCode)
+ Log.d(TAG, String.format("BLE advertising onStartFailure: %d", errorCode))
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridClientScan.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridClientScan.kt
new file mode 100644
index 0000000000..2586ca57c5
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridClientScan.kt
@@ -0,0 +1,103 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.ble
+
+import android.Manifest
+import android.annotation.SuppressLint
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.le.ScanCallback
+import android.bluetooth.le.ScanFilter
+import android.bluetooth.le.ScanResult
+import android.bluetooth.le.ScanSettings
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresPermission
+import com.google.android.gms.fido.fido2.api.common.ErrorCode
+import org.microg.gms.fido.core.RequestHandlingException
+import org.microg.gms.fido.core.hybrid.EMPTY_SERVICE_DATA
+import org.microg.gms.fido.core.hybrid.EMPTY_SERVICE_DATA_MASK
+import org.microg.gms.fido.core.hybrid.UUID_ANDROID
+import org.microg.gms.fido.core.hybrid.UUID_IOS
+import java.util.concurrent.atomic.AtomicBoolean
+
+private const val TAG = "HybridClientScan"
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class HybridClientScan(
+ private val bluetoothLeAdapter: BluetoothAdapter?, private val onScanSuccess: (ByteArray) -> Unit, private val onScanFailed: (Throwable) -> Unit
+) : ScanCallback() {
+
+ private val scanStatus = AtomicBoolean(false)
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
+ stopScanning()
+ val scanRecord = result.scanRecord
+ if (scanRecord == null) {
+ Log.d(TAG, "processDevice: ScanResult is missing ScanRecord")
+ return onScanFailed(RequestHandlingException(ErrorCode.DATA_ERR, "ScanResult is missing ScanRecord."))
+ }
+ var serviceData = scanRecord.getServiceData(UUID_ANDROID)
+ if (serviceData == null) {
+ Log.d(TAG, "processDevice: No service data, checking iOS UUID")
+ serviceData = scanRecord.getServiceData(UUID_IOS)
+ }
+ if (serviceData == null) {
+ Log.d(TAG, "processDevice: ScanRecord does not contain service data.")
+ return onScanFailed(RequestHandlingException(ErrorCode.DATA_ERR, "ScanRecord does not contain service data."))
+ }
+ if (serviceData.size != 20) {
+ Log.d(TAG, "processDevice: Service data is incorrect size.")
+ return onScanFailed(RequestHandlingException(ErrorCode.DATA_ERR, "Service data is incorrect size."))
+ }
+ Log.d(TAG, "Target device with EID: ${serviceData.joinToString("") { "%02x".format(it) }}")
+ onScanSuccess(serviceData)
+ }
+
+ override fun onScanFailed(errorCode: Int) {
+ onScanFailed(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "BLE scan failed: $errorCode"))
+ }
+
+ @SuppressLint("MissingPermission")
+ fun startScanning() {
+ if (scanStatus.compareAndSet(false, true)) {
+ try {
+ val adapter = bluetoothLeAdapter ?: throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR, "BluetoothAdapter null")
+ if (!adapter.isEnabled) {
+ val enabled = adapter.enable()
+ if (!enabled) {
+ throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR, "Unable to enable Bluetooth")
+ }
+ }
+ val scanner = adapter.bluetoothLeScanner ?: throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR, "BluetoothLeScanner null")
+
+ val filters = listOf(
+ ScanFilter.Builder().setServiceData(UUID_ANDROID, EMPTY_SERVICE_DATA, EMPTY_SERVICE_DATA_MASK).build(),
+ ScanFilter.Builder().setServiceData(UUID_IOS, EMPTY_SERVICE_DATA, EMPTY_SERVICE_DATA_MASK).build()
+ )
+ val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
+ scanner.startScan(filters, settings, this)
+ Log.d(TAG, "BLE scanning started")
+ } catch (t: Throwable) {
+ onScanFailed(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "startScan failed: ${t.message}"))
+ }
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ fun stopScanning() {
+ if (scanStatus.compareAndSet(true, false)) {
+ try {
+ val bluetoothLeScanner = bluetoothLeAdapter?.bluetoothLeScanner
+ bluetoothLeScanner?.stopScan(this)
+ Log.d(TAG, "BLE scanning stopped")
+ } catch (t: Throwable) {
+ onScanFailed(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "stopScan failed: ${t.message}"))
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridAuthenticatorController.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridAuthenticatorController.kt
new file mode 100644
index 0000000000..6a74246838
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridAuthenticatorController.kt
@@ -0,0 +1,329 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.controller
+
+import android.Manifest
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresPermission
+import com.google.android.gms.fido.fido2.api.common.ErrorCode
+import com.upokecenter.cbor.CBORObject
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.microg.gms.fido.core.RequestHandlingException
+import org.microg.gms.fido.core.hybrid.CtapError
+import org.microg.gms.fido.core.hybrid.HandshakePhase
+import org.microg.gms.fido.core.hybrid.ble.HybridBleAdvertiser
+import org.microg.gms.fido.core.hybrid.generateEcKeyPair
+import org.microg.gms.fido.core.hybrid.hex
+import org.microg.gms.fido.core.hybrid.model.QrCodeData
+import org.microg.gms.fido.core.hybrid.transport.AuthenticatorTunnelTransport
+import org.microg.gms.fido.core.hybrid.transport.TunnelCallback
+import org.microg.gms.fido.core.hybrid.tryResumeData
+import org.microg.gms.fido.core.hybrid.tryResumeWithError
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelException
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebsocket
+import org.microg.gms.fido.core.hybrid.utils.CryptoHelper
+import org.microg.gms.fido.core.hybrid.utils.NoiseCrypter
+import org.microg.gms.fido.core.hybrid.utils.NoiseHandshakeState
+import org.microg.gms.fido.core.protocol.msgs.AuthenticatorGetAssertionRequest
+import org.microg.gms.fido.core.protocol.msgs.AuthenticatorGetInfoRequest
+import org.microg.gms.fido.core.protocol.msgs.AuthenticatorMakeCredentialRequest
+import org.microg.gms.fido.core.protocol.msgs.Ctap2Request
+import java.security.interfaces.ECPrivateKey
+import java.security.interfaces.ECPublicKey
+
+private const val TAG = "AuthenticatorController"
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class HybridAuthenticatorController(context: Context) {
+ private val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+ private var bleAdvertiser: HybridBleAdvertiser? = null
+ private var transport: AuthenticatorTunnelTransport? = null
+ private var handshakePhase = HandshakePhase.NONE
+ private var noiseState: NoiseHandshakeState? = null
+ private var crypter: NoiseCrypter? = null
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ fun release() {
+ runCatching { bleAdvertiser?.stopAdvertising() }.onFailure {
+ Log.w(TAG, "release: stopAdvertising failed", it)
+ }
+ bleAdvertiser = null
+
+ runCatching { transport?.stopConnecting() }.onFailure {
+ Log.w(TAG, "release: websocket close failed", it)
+ }
+ transport = null
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ private fun startBleAdvertiser(eidKey: ByteArray, plaintext: ByteArray) {
+ bleAdvertiser = bleAdvertiser ?: HybridBleAdvertiser(adapter)
+ bleAdvertiser!!.startAdvertising(CryptoHelper.generateEid(eidKey, plaintext))
+ }
+
+ suspend fun startAuth(qrCodeData: QrCodeData, handleAuthenticator: suspend (Ctap2Request) -> ByteArray?, completed: (Boolean) -> Unit) = suspendCancellableCoroutine { cont ->
+ val randomSeed = qrCodeData.randomSeed
+ val peerPublicKey = qrCodeData.peerPublicKey
+ val tunnelId = CryptoHelper.endif(ikm = randomSeed, salt = ByteArray(0), info = byteArrayOf(2, 0, 0, 0), length = 16)
+ val eidKey = CryptoHelper.endif(ikm = randomSeed, salt = ByteArray(0), info = byteArrayOf(1, 0, 0, 0), length = 64)
+
+ transport = AuthenticatorTunnelTransport(tunnelId, object : TunnelCallback {
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ override fun onSocketConnect(websocket: TunnelWebsocket?, bytes: ByteArray) {
+ runCatching {
+ val generatedPlaintext = CryptoHelper.generatedSeed(bytes)
+ startBleAdvertiser(eidKey, generatedPlaintext)
+ noiseState = NoiseHandshakeState(mode = 3).apply {
+ mixHash(byteArrayOf(1))
+ mixHash(CryptoHelper.uncompress(peerPublicKey))
+ mixKeyAndHash(CryptoHelper.endif(ikm = randomSeed, salt = generatedPlaintext, info = byteArrayOf(3, 0, 0, 0), length = 32))
+ }
+ handshakePhase = HandshakePhase.CLIENT_HELLO_SENT
+ }.onFailure {
+ if (!cont.isCompleted) cont.tryResumeWithError(it)
+ }
+ }
+
+ override fun onSocketError(error: TunnelException) {
+ if (!cont.isCompleted) cont.tryResumeWithError(RequestHandlingException(ErrorCode.UNKNOWN_ERR, error.message ?: "error"))
+ }
+
+ override fun onSocketClose() {
+ if (!cont.isCompleted) cont.tryResumeWithError(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "Tunnel closed"))
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ override fun onMessage(websocket: TunnelWebsocket?, data: ByteArray) {
+ Log.d(TAG, "Received ${data.size} bytes (phase=$handshakePhase)")
+ runBlocking {
+ runCatching {
+ when (handshakePhase) {
+ HandshakePhase.CLIENT_HELLO_SENT -> if (data.size >= 65) handleClientHello(websocket, data, peerPublicKey) else error("Unexpected handshake payload size")
+ HandshakePhase.READY -> handleCtapRequest(websocket, data, handleAuthenticator).also {
+ completed.invoke(it != null)
+ if (!cont.isCompleted) cont.tryResumeData(it)
+ }
+
+ else -> error("Data received in invalid phase=$handshakePhase")
+ }
+ }.onFailure {
+ if (!cont.isCompleted) cont.tryResumeWithError(it)
+ }
+ }
+ }
+ })
+
+ transport!!.startConnecting()
+
+ cont.invokeOnCancellation { runCatching { transport?.stopConnecting() } }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
+ private fun handleClientHello(ws: TunnelWebsocket?, data: ByteArray, peerPublicKey: ECPublicKey) {
+ bleAdvertiser?.stopAdvertising()
+ val state = noiseState ?: error("Noise state not initialized")
+ val pcEphemeralPubKey = data.copyOfRange(0, 65)
+ val clientHelloPayload = data.copyOfRange(65, data.size)
+ state.mixHash(pcEphemeralPubKey)
+ state.mixKey(pcEphemeralPubKey)
+ val decryptedPayload = state.decryptAndHash(clientHelloPayload)
+ if (decryptedPayload.isNotEmpty()) {
+ Log.w(TAG, "ClientHello has non-empty payload: ${decryptedPayload.hex()}")
+ }
+ val ephemeralKeyPair: Pair = generateEcKeyPair()
+ val phoneEphemeralPubKey = CryptoHelper.uncompress(ephemeralKeyPair.first)
+ state.mixHash(phoneEphemeralPubKey)
+ state.mixKey(phoneEphemeralPubKey)
+ val peDh = CryptoHelper.recd(ephemeralKeyPair.second, pcEphemeralPubKey)
+ state.mixKey(peDh)
+ val pcStaticPubKey = CryptoHelper.uncompress(peerPublicKey)
+ val psDh = CryptoHelper.recd(ephemeralKeyPair.second, pcStaticPubKey)
+ state.mixKey(psDh)
+ val serverHelloPayload = state.encryptAndHash(ByteArray(0))
+ val serverHello = phoneEphemeralPubKey + serverHelloPayload
+ ws?.send(serverHello)
+
+ val (rxKey, txKey) = state.splitSessionKeys()
+ crypter = NoiseCrypter(rxKey, txKey)
+ handshakePhase = HandshakePhase.READY
+
+ val message = encryptGetInfoPayload() ?: error("Failed to encrypt post-handshake message")
+
+ Log.d(TAG, "Encrypted post-handshake message size: ${message.size} bytes")
+
+ ws?.send(message)
+ Log.d(TAG, "✓ Post-handshake message sent successfully")
+ }
+
+ private fun encryptGetInfoPayload(): ByteArray? {
+ val crypter = this.crypter ?: error("Crypter not initialized, cannot send post-handshake message")
+
+ val payload = AuthenticatorGetInfoRequest(
+ versions = listOf("FIDO_2_0", "FIDO_2_1"),
+ extensions = listOf("prf"),
+ clientDataHash = ByteArray(16),
+ options = AuthenticatorGetInfoRequest.Options(residentKey = true, userPresence = true, userVerification = true),
+ transports = listOf("internal", "hybrid")
+ ).payload
+
+ Log.d(TAG, "GetInfo response size: ${payload.size} bytes")
+ Log.d(TAG, "GetInfo response (hex): ${payload.hex()}")
+
+ val getInfoByteString = CBORObject.FromObject(payload)
+ val postHandshakeMessage = CBORObject.NewMap().apply {
+ set(1, getInfoByteString)
+ set(3, CBORObject.NewArray().apply {
+ Add("dc")
+ Add("ctap")
+ })
+ }
+
+ val messageBytes = postHandshakeMessage.EncodeToBytes()
+ Log.d(TAG, "Post-handshake message size: ${messageBytes.size} bytes")
+ Log.d(TAG, "Post-handshake message (hex): ${messageBytes.hex()}")
+ return crypter.encrypt(messageBytes)
+ }
+
+ private suspend fun handleCtapRequest(ws: TunnelWebsocket?, data: ByteArray, handleAuthenticator: suspend (Ctap2Request) -> ByteArray?): ByteArray? {
+ val crypt = this.crypter ?: error("Crypter not initialized (handshake incomplete)")
+ val decrypted = crypt.decrypt(data) ?: error("Failed to decrypt CTAP request")
+ if (decrypted.isEmpty()) {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.INVALID_LENGTH.value))
+ return null
+ }
+ val frameType = decrypted[0].toInt() and 0xFF
+ if (frameType == 0x00) {
+ Log.d(TAG, "Received post-handshake response from initiator")
+ if (decrypted.size > 1) {
+ try {
+ val payload = decrypted.copyOfRange(1, decrypted.size)
+ val cbor = CBORObject.DecodeFromBytes(payload)
+ Log.d(TAG, "Post-handshake payload: $cbor")
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not parse post-handshake payload (may be empty)", e)
+ }
+ } else {
+ Log.d(TAG, "Post-handshake response has no payload (acknowledgment only)")
+ }
+ return null
+ }
+ if (frameType == 0x01) {
+ val ctapMessage = decrypted.copyOfRange(1, decrypted.size)
+ if (ctapMessage.isEmpty()) {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.INVALID_CBOR.value))
+ return null
+ }
+ val params = if (ctapMessage.size > 1) {
+ try {
+ val cborBytes = ctapMessage.copyOfRange(1, ctapMessage.size)
+ Log.d(TAG, "CBOR size: ${cborBytes.size} bytes")
+ CBORObject.DecodeFromBytes(cborBytes)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to parse CBOR parameters", e)
+ null
+ }
+ } else {
+ null
+ }
+ when (ctapMessage[0]) {
+ AuthenticatorMakeCredentialRequest.COMMAND -> {
+ if (params == null) {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.MISSING_PARAMETER.value))
+ } else {
+ val request = AuthenticatorMakeCredentialRequest.decodeFromCbor(params)
+ try {
+ val response = handleAuthenticator(request)
+ if (response == null) {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.OTHER_ERROR.value))
+ return null
+ }
+ ws?.sendCtapResponse(response)
+ return response
+ } catch (e: Exception) {
+ Log.w(TAG, "handleAuthenticatorMakeCredentialRequest: ", e)
+ ws?.sendCtapResponse(byteArrayOf(CtapError.OTHER_ERROR.value))
+ }
+ }
+ }
+
+ AuthenticatorGetAssertionRequest.COMMAND -> {
+ if (params == null) {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.MISSING_PARAMETER.value))
+ } else {
+ val request = AuthenticatorGetAssertionRequest.decodeFromCbor(params)
+ try {
+ val response = handleAuthenticator(request)
+ if (response == null) {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.OTHER_ERROR.value))
+ return null
+ }
+ ws?.sendCtapResponse(response)
+ return response
+ } catch (e: Exception) {
+ Log.w(TAG, "handleAuthenticatorGetAssertionRequest: ", e)
+ ws?.sendCtapResponse(byteArrayOf(CtapError.OTHER_ERROR.value))
+ }
+ }
+ }
+
+ AuthenticatorGetInfoRequest.COMMAND -> {
+ val payload = AuthenticatorGetInfoRequest(
+ versions = arrayListOf("FIDO_2_0", "FIDO_2_1"),
+ clientDataHash = ByteArray(16),
+ options = AuthenticatorGetInfoRequest.Options(residentKey = true, userPresence = true, userVerification = true)
+ ).payload
+ Log.d(TAG, "GetInfo response: ${payload.size} bytes")
+ val ctapResponse = byteArrayOf(0x00) + payload
+ ws?.sendCtapResponse(ctapResponse)
+ }
+
+ else -> {
+ ws?.sendCtapResponse(byteArrayOf(CtapError.INVALID_COMMAND.value))
+ }
+ }
+ return null
+ }
+ if (frameType == 0x02) {
+ Log.d(TAG, "Received UPDATE message")
+ if (decrypted.size > 1) {
+ val payload = decrypted.copyOfRange(1, decrypted.size)
+ Log.d(TAG, "UPDATE payload: ${payload.hex()}")
+ }
+ return null
+ }
+ if (frameType == 0x03) {
+ Log.d(TAG, "Received JSON message")
+ if (decrypted.size > 1) {
+ val payload = decrypted.copyOfRange(1, decrypted.size)
+ try {
+ val jsonString = String(payload, Charsets.UTF_8)
+ Log.d(TAG, "JSON: $jsonString")
+ } catch (e: Exception) {
+ Log.w(TAG, "Could not parse JSON payload", e)
+ }
+ }
+ return null
+ }
+ return null
+ }
+
+ private fun TunnelWebsocket.sendCtapResponse(ctapResponse: ByteArray) {
+ try {
+ val crypter = crypter ?: error("Crypter not initialized (handshake incomplete)")
+ val framedMessage = byteArrayOf(0x01) + ctapResponse
+ val encrypted = crypter.encrypt(framedMessage) ?: error("Failed to encrypt CTAP response")
+ Log.d(TAG, "Sending encrypted CTAP response: ${encrypted.size} bytes (framed)")
+ send(encrypted)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send CTAP response", e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridClientController.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridClientController.kt
new file mode 100644
index 0000000000..2601c8b345
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridClientController.kt
@@ -0,0 +1,183 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.controller
+
+import android.Manifest
+import android.bluetooth.BluetoothManager
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresPermission
+import com.google.android.gms.fido.fido2.api.common.ErrorCode
+import kotlinx.coroutines.suspendCancellableCoroutine
+import org.microg.gms.fido.core.RequestHandlingException
+import org.microg.gms.fido.core.hybrid.HandshakePhase
+import org.microg.gms.fido.core.hybrid.ble.HybridClientScan
+import org.microg.gms.fido.core.hybrid.generateEcKeyPair
+import org.microg.gms.fido.core.hybrid.transport.ClientTunnelTransport
+import org.microg.gms.fido.core.hybrid.transport.TunnelCallback
+import org.microg.gms.fido.core.hybrid.tryResumeData
+import org.microg.gms.fido.core.hybrid.tryResumeWithError
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelException
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebsocket
+import org.microg.gms.fido.core.hybrid.utils.CryptoHelper
+import org.microg.gms.fido.core.hybrid.utils.NoiseCrypter
+import org.microg.gms.fido.core.hybrid.utils.NoiseHandshakeState
+import java.security.interfaces.ECPrivateKey
+import java.security.interfaces.ECPublicKey
+import java.util.concurrent.atomic.AtomicBoolean
+
+private const val TAG = "HybridClientController"
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class HybridClientController(context: Context, val staticKey: Pair) {
+ private val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
+
+ private var ephemeralKeyPair: Pair? = null
+ private var noise: NoiseHandshakeState? = null
+ private var crypter: NoiseCrypter? = null
+
+ private var phase = HandshakePhase.NONE
+ private var scan: HybridClientScan? = null
+ private var transport: ClientTunnelTransport? = null
+
+ private var postHandshakeDone = false
+ private val firstSend = AtomicBoolean(false)
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ fun release() {
+ runCatching { scan?.stopScanning() }.onFailure {
+ Log.w(TAG, "release: stopScanning failed", it)
+ }
+ scan = null
+
+ runCatching { transport?.stopConnecting() }.onFailure {
+ Log.w(TAG, "release: websocket close failed", it)
+ }
+ transport = null
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ suspend fun startClientTunnel(
+ eid: ByteArray, seed: ByteArray, frameBuilder: () -> ByteArray?
+ ) = suspendCancellableCoroutine { cont ->
+ transport = ClientTunnelTransport(eid, seed, object : TunnelCallback {
+ override fun onSocketConnect(websocket: TunnelWebsocket?, bytes: ByteArray) {
+ runCatching { sendNoiseHello(websocket, bytes) }.onFailure { if (!cont.isCompleted) cont.tryResumeWithError(it) }
+ }
+
+ override fun onSocketError(error: TunnelException) {
+ if (!cont.isCompleted) cont.tryResumeWithError(RequestHandlingException(ErrorCode.UNKNOWN_ERR, error.message ?: "error"))
+ }
+
+ override fun onSocketClose() {
+ if (!cont.isCompleted) cont.tryResumeWithError(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "Tunnel closed"))
+ }
+
+ override fun onMessage(websocket: TunnelWebsocket?, data: ByteArray) {
+ runCatching {
+ when (phase) {
+ HandshakePhase.CLIENT_HELLO_SENT -> if (data.size == 81) handleServerHello(websocket, data, frameBuilder())
+ else error("Unexpected handshake payload size")
+
+ HandshakePhase.READY -> handlePostHandshake(websocket, data, frameBuilder())?.also { if (!cont.isCompleted) cont.tryResumeData(it) }
+
+ else -> error("Data received in invalid phase=$phase")
+ }
+ }.onFailure {
+ if (!cont.isCompleted) cont.tryResumeWithError(it)
+ }
+ }
+ })
+
+ transport!!.startConnecting()
+
+ cont.invokeOnCancellation { runCatching { transport?.stopConnecting() } }
+ }
+
+ private fun sendNoiseHello(ws: TunnelWebsocket?, socketHash: ByteArray) {
+ val nh = NoiseHandshakeState(mode = 3).also { noise = it }
+ nh.mixHash(byteArrayOf(1))
+ nh.mixHash(CryptoHelper.uncompress(staticKey.first))
+ nh.mixKeyAndHash(socketHash)
+
+ ephemeralKeyPair = generateEcKeyPair()
+ val ephPub = CryptoHelper.uncompress(ephemeralKeyPair!!.first)
+
+ nh.mixHash(ephPub)
+ nh.mixKey(ephPub)
+
+ val ciphertext = nh.encryptAndHash(ByteArray(0))
+ ws?.send(ephPub + ciphertext)
+
+ phase = HandshakePhase.CLIENT_HELLO_SENT
+ Log.d(TAG, ">> ClientHello sent")
+ }
+
+ private fun handleServerHello(ws: TunnelWebsocket?, msg: ByteArray, rawFrame: ByteArray?) {
+ val frame = rawFrame ?: error("Frame null")
+ val nh = noise ?: error("Noise state null")
+ val eph = ephemeralKeyPair ?: error("Ephemeral missing")
+
+ val serverPub = msg.sliceArray(0..64)
+
+ nh.mixHash(serverPub)
+ nh.mixKey(serverPub)
+ nh.mixKey(CryptoHelper.recd(eph.second, serverPub))
+ nh.mixKey(CryptoHelper.recd(staticKey.second, serverPub))
+
+ val (send, recv) = nh.splitSessionKeys()
+ crypter = NoiseCrypter(recv, send)
+
+ phase = HandshakePhase.READY
+ Log.d(TAG, "✓ Handshake done")
+
+ trySendEncrypted(ws, frame)
+ }
+
+ private fun handlePostHandshake(ws: TunnelWebsocket?, raw: ByteArray, plainFrame: ByteArray?): ByteArray? {
+ val plain = crypter?.decrypt(raw) ?: error("Decrypt failed")
+ require(plain.isNotEmpty()) { "Decrypted empty" }
+
+ val type = plain[0].toInt() and 0xFF
+ val payload = plain.copyOfRange(1, plain.size)
+
+ return when (type) {
+ 0x01 -> handleSuccessFrame(payload)
+ in 0xA0..0xBF -> handlePostHandshakeFrame(ws, plainFrame)
+ else -> error("Unexpected frame: 0x${type.toString(16)}")
+ }
+ }
+
+ private fun handleSuccessFrame(b: ByteArray): ByteArray {
+ val first = b.firstOrNull()?.toInt() ?: return b
+ return if (first in listOf(0x01, 0x02)) b.drop(1).toByteArray() else b
+ }
+
+ private fun handlePostHandshakeFrame(ws: TunnelWebsocket?, frame: ByteArray?): ByteArray? {
+ if (!postHandshakeDone) {
+ postHandshakeDone = true
+ trySendEncrypted(ws, frame ?: error("Frame null"))
+ }
+ return null
+ }
+
+ private fun trySendEncrypted(ws: TunnelWebsocket?, frame: ByteArray) {
+ if (firstSend.compareAndSet(false, true)) {
+ ws?.send(crypter?.encrypt(frame) ?: error("Encrypt failed"))
+ }
+ }
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ suspend fun startBluetoothScan() = suspendCancellableCoroutine { cont ->
+ scan = HybridClientScan(adapter, onScanSuccess = { eid ->
+ cont.tryResumeData(eid)
+ }, onScanFailed = { cont.tryResumeWithError(it) })
+ scan!!.startScanning()
+ cont.invokeOnCancellation { runCatching { scan?.stopScanning() } }
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/extensions.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/extensions.kt
new file mode 100644
index 0000000000..c89272b224
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/extensions.kt
@@ -0,0 +1,97 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid
+
+import android.os.ParcelUuid
+import kotlinx.coroutines.CancellableContinuation
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.interfaces.ECPrivateKey
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+import java.util.Locale
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+enum class HandshakePhase { NONE, CLIENT_HELLO_SENT, READY }
+enum class CtapError(val value: Byte) {
+ SUCCESS(0x00), INVALID_COMMAND(0x01), INVALID_LENGTH(0x03), INVALID_CBOR(0x12), MISSING_PARAMETER(0x14), OTHER_ERROR(0x7F),
+}
+
+const val HKDF_ALGORITHM = "HmacSHA256"
+const val AEMK_ALGORITHM = "AES"
+const val EC_ALGORITHM = "EC"
+val UUID_ANDROID: ParcelUuid = ParcelUuid.fromString("0000fff9-0000-1000-8000-00805f9b34fb")
+val UUID_IOS: ParcelUuid = ParcelUuid.fromString("0000fde2-0000-1000-8000-00805f9b34fb")
+val EMPTY_SERVICE_DATA = ByteArray(20)
+val EMPTY_SERVICE_DATA_MASK = ByteArray(20)
+
+val FIXED_SERVER_HOSTS = arrayOf("cable.ua5v.com", "cable.auth.com")
+val DOMAIN_SUFFIXES = arrayOf(".com", ".org", ".net", ".info")
+val SERVER_BANNER_BYTES = byteArrayOf(99, 97, 66, 76, 69, 118, 50, 32, 116, 117, 110, 110, 101, 108, 32, 115, 101, 114, 118, 101, 114, 32, 100, 111, 109, 97, 105, 110)
+val BASE32_ALPHABET = charArrayOf('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '2', '3', '4', '5', '6', '7')
+
+fun ByteArray.hex() = joinToString("") { "%02x".format(it) }
+
+fun generateEcKeyPair(): Pair {
+ val kpg = KeyPairGenerator.getInstance(EC_ALGORITHM).apply {
+ initialize(ECGenParameterSpec("secp256r1"))
+ }
+ val kp = kpg.generateKeyPair()
+ return (kp.public as ECPublicKey) to (kp.private as ECPrivateKey)
+}
+
+private fun generateDomain(domainId: Int): String {
+ if (domainId < 2) {
+ return FIXED_SERVER_HOSTS[domainId]
+ }
+ require(domainId < 256) {
+ String.format(Locale.US, "This domainId: %d was an unrecognized assigned domain value.", domainId)
+ }
+ val buffer = ByteBuffer.allocate(31).apply {
+ order(ByteOrder.LITTLE_ENDIAN)
+ put(SERVER_BANNER_BYTES)
+ putShort(domainId.toShort())
+ put(0)
+ }
+ val digest = MessageDigest.getInstance("SHA-256").digest(buffer.array())
+ val hash = ByteBuffer.wrap(digest.copyOf(8)).order(ByteOrder.LITTLE_ENDIAN).long
+ val suffixIndex = (hash and 3).toInt()
+ val sb = StringBuilder("cable.")
+ var body = hash ushr 2
+ while (body != 0L) {
+ sb.append(BASE32_ALPHABET[(body and 31).toInt()])
+ body = body ushr 5
+ }
+ sb.append(DOMAIN_SUFFIXES[suffixIndex])
+ return sb.toString()
+}
+
+fun buildWebSocketConnectUrl(domainId: Int, routingId: ByteArray, tunnelId: ByteArray) = buildString {
+ append("wss://")
+ append(generateDomain(domainId))
+ append("/cable/connect/")
+ append(routingId.hex())
+ append("/")
+ append(tunnelId.hex())
+}
+
+fun buildWebSocketNewUrl(tunnelId: ByteArray) = buildString {
+ append("wss://")
+ append(generateDomain(0))
+ append("/cable/new/")
+ append(tunnelId.hex())
+}
+
+fun CancellableContinuation.tryResumeData(value: T) {
+ if (!isCompleted) resume(value)
+}
+
+fun CancellableContinuation.tryResumeWithError(e: Throwable) {
+ if (!isCompleted) resumeWithException(e)
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/model/QrCodeData.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/model/QrCodeData.kt
new file mode 100644
index 0000000000..a89bd5c21f
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/model/QrCodeData.kt
@@ -0,0 +1,276 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.model
+
+import android.graphics.Bitmap
+import android.util.Log
+import androidx.core.graphics.createBitmap
+import androidx.core.graphics.set
+import com.google.zxing.BarcodeFormat
+import com.google.zxing.qrcode.QRCodeWriter
+import com.upokecenter.cbor.CBORObject
+import com.upokecenter.cbor.CBORType
+import org.microg.gms.fido.core.hybrid.EC_ALGORITHM
+import java.math.BigInteger
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.security.KeyFactory
+import java.security.KeyPairGenerator
+import java.security.SecureRandom
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECPoint
+import java.security.spec.ECPublicKeySpec
+
+private const val TAG = "HybridQrCodeData"
+
+data class QrCodeData(
+ val peerPublicKey: ECPublicKey, // PC's static public key
+ val randomSeed: ByteArray, // 16-byte random seed (IKM for key derivation)
+ val version: Int, // Protocol version
+ val timestamp: Long, // Timestamp in seconds
+ val isLinkingFlow: Boolean, // Whether this is a linking flow
+ val flowIdentifier: String? // Optional flow type identifier
+) {
+ companion object {
+ const val PREFIX_FIDO = "FIDO:/"
+ private val PADDING_TABLE = intArrayOf(0, 3, 5, 8, 10, 13, 15)
+
+ fun parse(data: String): QrCodeData? {
+ val encoded = data.substringAfter(PREFIX_FIDO, "")
+ Log.d(TAG, "encoded: $encoded")
+ val qrCodeDataByte = resolveQrCodeData(encoded)
+ Log.d(TAG, "qrCodeDataByte: $qrCodeDataByte")
+ return qrCodeDataByte?.let {
+ val cbor = CBORObject.DecodeFromBytes(it)
+ if (cbor.type != CBORType.Map) return null
+
+ val publicKeyBytes = cbor[0]?.GetByteString() ?: return null
+ val randomSeed = cbor[1]?.GetByteString() ?: return null
+ val publicKey = decompressECPublicKey(publicKeyBytes) ?: return null
+
+ QrCodeData(
+ peerPublicKey = publicKey,
+ randomSeed = randomSeed,
+ version = cbor[2]?.AsInt32() ?: 0,
+ timestamp = cbor[3]?.AsInt64() ?: 0L,
+ isLinkingFlow = cbor[4]?.AsBoolean() ?: false,
+ flowIdentifier = cbor[5]?.AsString()
+ )
+ }
+ }
+
+ fun generateQrCode(staticPublicKey: ECPublicKey, randomSeed: ByteArray): Bitmap {
+ val content = buildQrCborPayload(staticPublicKey, randomSeed)
+ val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 512, 512)
+ return createBitmap(matrix.width, matrix.height, Bitmap.Config.RGB_565).also { bmp ->
+ for (x in 0 until matrix.width) {
+ for (y in 0 until matrix.height) {
+ bmp[x, y] = if (matrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt()
+ }
+ }
+ }
+ }
+
+ private fun buildQrCborPayload(staticPublicKey: ECPublicKey, randomSeed16: ByteArray): String {
+ val cbor = CBORObject.NewOrderedMap().apply {
+ compressECPublicKey(staticPublicKey).let {
+ this[0] = CBORObject.FromObject(it)
+ }
+ val secret = randomSeed16.takeIf { it.isNotEmpty() } ?: ByteArray(16).also { SecureRandom().nextBytes(it) }
+ this[1] = CBORObject.FromObject(secret)
+ this[2] = CBORObject.FromObject(2)
+ this[3] = CBORObject.FromObject(System.currentTimeMillis() / 1000L)
+ this[4] = CBORObject.False
+ }
+ val bytes = cbor.EncodeToBytes()
+ val gmsBase34 = encodeGmsBase34(bytes)
+ return "$PREFIX_FIDO$gmsBase34"
+ }
+
+ private fun encodeGmsBase34(data: ByteArray): String {
+ val sb = StringBuilder((data.size / 7 + 1) * 17)
+ var v = 0L
+ var i = 0
+ var rem = 0
+ while (i < data.size) {
+ v = v or ((data[i].toLong() and 0xFFL) shl (rem * 8))
+ i++
+ rem = i % 7
+ if (rem == 0) {
+ val s = v.toString()
+ if (s.length < 17) sb.append("0".repeat(17 - s.length))
+ sb.append(s)
+ v = 0
+ }
+ }
+ if (rem != 0) {
+ val s = v.toString()
+ val padTable = intArrayOf(0, 3, 5, 8, 10, 13, 15)
+ val need = padTable[rem] - s.length
+ if (need > 0) sb.append("0".repeat(need))
+ sb.append(s)
+ }
+ return sb.toString()
+ }
+
+ private fun compressECPublicKey(pub: ECPublicKey): ByteArray {
+ val p = pub.w
+ val x = p.affineX.toByteArray().takeLast(32).toByteArray()
+ val y = p.affineY.toByteArray().takeLast(32).toByteArray()
+ val prefix = if ((y.last().toInt() and 1) == 0) 0x02 else 0x03
+ return byteArrayOf(prefix.toByte()) + x
+ }
+
+ private fun decompressECPublicKey(compressed: ByteArray): ECPublicKey? {
+ try {
+ if (compressed.size != 33) {
+ Log.e(TAG, "Invalid compressed key size: ${compressed.size}")
+ return null
+ }
+
+ val prefix = compressed[0].toInt()
+ if (prefix != 0x02 && prefix != 0x03) {
+ Log.e(TAG, "Invalid compressed key prefix: $prefix")
+ return null
+ }
+
+ // Extract x-coordinate
+ val xBytes = compressed.copyOfRange(1, 33)
+ val x = BigInteger(1, xBytes)
+
+ // Recover y-coordinate using curve equation: y² = x³ - 3x + b (mod p)
+ val spec = java.security.spec.ECGenParameterSpec("secp256r1")
+ val kpg = KeyPairGenerator.getInstance(EC_ALGORITHM)
+ kpg.initialize(spec)
+ val params = (kpg.generateKeyPair().public as ECPublicKey).params
+
+ val p = params.curve.field.fieldSize.let {
+ BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", 16)
+ }
+ val b = params.curve.b
+
+ // Calculate y² = x³ - 3x + b
+ val x3 = x.modPow(BigInteger.valueOf(3), p)
+ val ax = x.multiply(BigInteger.valueOf(3)).mod(p)
+ val ySquared = x3.subtract(ax).add(b).mod(p)
+
+ // Calculate y = sqrt(y²) mod p using Tonelli-Shanks
+ val y = modSqrt(ySquared, p) ?: run {
+ Log.e(TAG, "Failed to calculate square root")
+ return null
+ }
+
+ // Choose correct y based on prefix (even/odd)
+ val yFinal = if ((y.testBit(0) && prefix == 0x03) || (!y.testBit(0) && prefix == 0x02)) {
+ y
+ } else {
+ p.subtract(y)
+ }
+
+ // Create ECPublicKey
+ val point = ECPoint(x, yFinal)
+ val keySpec = ECPublicKeySpec(point, params)
+ val keyFactory = KeyFactory.getInstance(EC_ALGORITHM)
+ return keyFactory.generatePublic(keySpec) as ECPublicKey
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to decompress EC public key", e)
+ return null
+ }
+ }
+
+ private fun modSqrt(n: BigInteger, p: BigInteger): BigInteger? {
+ // For p ≡ 3 (mod 4), sqrt(n) = n^((p+1)/4) mod p
+ val exponent = p.add(BigInteger.ONE).divide(BigInteger.valueOf(4))
+ val result = n.modPow(exponent, p)
+
+ // Verify result
+ return if (result.modPow(BigInteger.valueOf(2), p) == n.mod(p)) {
+ result
+ } else {
+ null
+ }
+ }
+
+ private fun resolveQrCodeData(encoded: String): ByteArray? {
+ try {
+ val length = encoded.length
+ val fullBlocks = length / 17
+ val remainder = length % 17
+
+ // Validate remainder using padding table
+ val remainingBytes = PADDING_TABLE.indexOfFirst { length % 17 == it }
+ if (remainingBytes == -1) {
+ Log.e(TAG, "Invalid Base17 length: $length (remainder: $remainder)")
+ return null
+ }
+
+ val totalBytes = fullBlocks * 7 + remainingBytes
+ val result = ByteArray(totalBytes)
+ val buffer = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
+
+ // Decode full blocks (17 digits → 7 bytes)
+ for (i in 0 until fullBlocks) {
+ val digitGroup = encoded.substring(i * 17, (i + 1) * 17)
+ val longValue = digitGroup.toLongOrNull() ?: throw IllegalArgumentException("Invalid digit group: $digitGroup")
+
+ buffer.rewind()
+ buffer.putLong(longValue)
+ buffer.rewind()
+ buffer.get(result, i * 7, 7)
+
+ // Verify high byte is 0
+ if (buffer.get() != 0.toByte()) {
+ throw IllegalArgumentException("Decoded long does not fit in 7 bytes")
+ }
+ }
+
+ // Decode remaining digits
+ if (remainder > 0) {
+ val remainingDigits = encoded.substring(fullBlocks * 17)
+ val longValue = remainingDigits.toLongOrNull() ?: throw IllegalArgumentException("Invalid remaining digits: $remainingDigits")
+
+ buffer.rewind()
+ buffer.putLong(longValue)
+ buffer.rewind()
+ buffer.get(result, totalBytes - remainingBytes, remainingBytes)
+
+ // Verify remaining bytes are 0
+ while (buffer.hasRemaining()) {
+ if (buffer.get() != 0.toByte()) {
+ throw IllegalArgumentException("Decoded long does not fit in remaining bytes")
+ }
+ }
+ }
+
+ return result
+ } catch (e: Exception) {
+ Log.e(TAG, "Base17 decoding failed", e)
+ return null
+ }
+ }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+ other as QrCodeData
+ return peerPublicKey == other.peerPublicKey && randomSeed.contentEquals(other.randomSeed) && version == other.version && timestamp == other.timestamp && isLinkingFlow == other.isLinkingFlow && flowIdentifier == other.flowIdentifier
+ }
+
+ override fun hashCode(): Int {
+ var result = peerPublicKey.hashCode()
+ result = 31 * result + randomSeed.contentHashCode()
+ result = 31 * result + version
+ result = 31 * result + timestamp.hashCode()
+ result = 31 * result + isLinkingFlow.hashCode()
+ result = 31 * result + (flowIdentifier?.hashCode() ?: 0)
+ return result
+ }
+
+ override fun toString(): String {
+ return "QrCodeData(version=$version, timestamp=$timestamp, " + "isLinking=$isLinkingFlow, flow=$flowIdentifier, " + "seedSize=${randomSeed.size})"
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/AuthenticatorTunnelTransport.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/AuthenticatorTunnelTransport.kt
new file mode 100644
index 0000000000..f43fee6e52
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/AuthenticatorTunnelTransport.kt
@@ -0,0 +1,61 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.transport
+
+import android.util.Log
+import okhttp3.Response
+import okio.ByteString.Companion.decodeHex
+import org.microg.gms.fido.core.hybrid.buildWebSocketNewUrl
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelException
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebCallback
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebsocket
+
+private const val TAG = "AuthenticatorTransport"
+
+class AuthenticatorTunnelTransport(val tunnelId: ByteArray, val callback: TunnelCallback) : TunnelWebCallback {
+
+ private var websocket: TunnelWebsocket? = null
+
+ fun startConnecting() {
+ Log.d(TAG, "startConnecting: ")
+ val webSocketConnectUrl = buildWebSocketNewUrl(tunnelId)
+ Log.d(TAG, "startConnecting: webSocketConnectUrl=$webSocketConnectUrl")
+ if (websocket == null) {
+ websocket = TunnelWebsocket(webSocketConnectUrl, this)
+ }
+ websocket?.connect()
+ }
+
+ fun stopConnecting() {
+ Log.d(TAG, "stopConnecting: ")
+ websocket?.close()
+ }
+
+ override fun disconnected() {
+ Log.d(TAG, "disconnected: ")
+ callback.onSocketClose()
+ }
+
+ override fun error(error: TunnelException) {
+ Log.d(TAG, "error: ", error)
+ callback.onSocketError(error)
+ }
+
+ override fun connected(response: Response) {
+ val routingId = runCatching { response.header("X-Cable-Routing-Id")?.decodeHex()?.toByteArray() }.getOrNull()
+ Log.d(TAG, "Routing ID from server: (${routingId?.size} bytes)")
+ if (routingId == null || routingId.size < 3) {
+ callback.onSocketError(TunnelException("routingId is null"))
+ return
+ }
+ callback.onSocketConnect(websocket, routingId)
+ }
+
+ override fun message(data: ByteArray) {
+ callback.onMessage(websocket, data)
+ }
+
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/ClientTunnelTransport.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/ClientTunnelTransport.kt
new file mode 100644
index 0000000000..810d0e70a5
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/ClientTunnelTransport.kt
@@ -0,0 +1,76 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.transport
+
+import android.util.Log
+import com.google.android.gms.fido.fido2.api.common.ErrorCode
+import okhttp3.Response
+import org.microg.gms.fido.core.RequestHandlingException
+import org.microg.gms.fido.core.hybrid.buildWebSocketConnectUrl
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelException
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebCallback
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebsocket
+import org.microg.gms.fido.core.hybrid.utils.CryptoHelper
+
+private const val TAG = "ClientTunnelTransport"
+
+class ClientTunnelTransport(val eid: ByteArray, val randomSeed: ByteArray, val callback: TunnelCallback) : TunnelWebCallback {
+
+ private var websocket: TunnelWebsocket? = null
+ private var decryptEid: ByteArray? = null
+
+ fun startConnecting() {
+ Log.d(TAG, "startConnecting: ")
+ decryptEid = decryptEid()
+ val routingId = decryptEid!!.sliceArray(11..13)
+ val domainId = ((decryptEid!![15].toInt() and 0xFF) shl 8) or (decryptEid!![14].toInt() and 0xFF)
+ val tunnelId = CryptoHelper.endif(ikm = randomSeed, salt = ByteArray(0), info = byteArrayOf(2, 0, 0, 0), length = 16)
+
+ val webSocketConnectUrl = buildWebSocketConnectUrl(domainId, routingId, tunnelId)
+ Log.d(TAG, "startConnecting: webSocketConnectUrl=$webSocketConnectUrl")
+ if (websocket == null) {
+ websocket = TunnelWebsocket(webSocketConnectUrl, this)
+ }
+ websocket?.connect()
+ }
+
+ private fun decryptEid(): ByteArray {
+ val decryptEid = CryptoHelper.decryptEid(eid, randomSeed) ?: throw RequestHandlingException(ErrorCode.UNKNOWN_ERR, "EID decrypt failed")
+ if (decryptEid.size != 16 || decryptEid[0] != 0.toByte()) {
+ throw RequestHandlingException(ErrorCode.UNKNOWN_ERR, "EID structure invalid")
+ }
+ return decryptEid
+ }
+
+ fun stopConnecting() {
+ Log.d(TAG, "stopConnecting: ")
+ websocket?.close()
+ }
+
+ override fun disconnected() {
+ Log.d(TAG, "disconnected: ")
+ callback.onSocketClose()
+ }
+
+ override fun error(error: TunnelException) {
+ Log.d(TAG, "error: ", error)
+ callback.onSocketError(error)
+ }
+
+ override fun connected(response: Response) {
+ val pt = decryptEid ?: decryptEid()
+
+ Log.d(TAG, "connected: $pt response: $response")
+
+ val socketHashKey = CryptoHelper.endif(ikm = randomSeed, salt = pt, info = byteArrayOf(3, 0, 0, 0), length = 32)
+ callback.onSocketConnect(websocket, socketHashKey)
+ }
+
+ override fun message(data: ByteArray) {
+ callback.onMessage(websocket, data)
+ }
+
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/TunnelCallback.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/TunnelCallback.kt
new file mode 100644
index 0000000000..2725e30996
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/transport/TunnelCallback.kt
@@ -0,0 +1,16 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.transport
+
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelException
+import org.microg.gms.fido.core.hybrid.tunnel.TunnelWebsocket
+
+interface TunnelCallback {
+ fun onSocketConnect(websocket: TunnelWebsocket?, bytes: ByteArray)
+ fun onSocketError(error: TunnelException)
+ fun onSocketClose()
+ fun onMessage(websocket: TunnelWebsocket?, data: ByteArray)
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/tunnel/TunnelWebsocket.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/tunnel/TunnelWebsocket.kt
new file mode 100644
index 0000000000..1bde5807bf
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/tunnel/TunnelWebsocket.kt
@@ -0,0 +1,150 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.tunnel
+
+import android.util.Log
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.WebSocket
+import okhttp3.WebSocketListener
+import okio.ByteString
+import java.io.IOException
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+private const val TAG = "TunnelWebsocket"
+
+enum class SocketStatus {
+ NONE, CONNECTING, CONNECTED, DISCONNECTED
+}
+
+interface TunnelWebCallback {
+ fun disconnected()
+ fun error(error: TunnelException)
+ fun connected(response: Response)
+ fun message(data: ByteArray)
+}
+
+data class TunnelException(val msg: String, val th: Throwable? = null) : RuntimeException(msg, th)
+
+class TunnelWebsocket(val url: String, val callback: TunnelWebCallback) {
+ private val threadPool = Executors.defaultThreadFactory()
+ private var submitThread: Thread? = null
+
+ @Volatile
+ private var socketStatus = SocketStatus.NONE
+
+ @Volatile
+ private var socket: WebSocket? = null
+
+ private val client: OkHttpClient by lazy {
+ OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS).readTimeout(10, TimeUnit.SECONDS).writeTimeout(10, TimeUnit.SECONDS).build()
+ }
+
+ @Synchronized
+ fun close() {
+ Log.d(TAG, "close() with state= $socketStatus")
+ val ordinal = socketStatus.ordinal
+ if (ordinal == 0) {
+ socketStatus = SocketStatus.DISCONNECTED
+ return
+ }
+ closeWebsocket()
+ }
+
+ @Synchronized
+ fun closeWebsocket() {
+ if (socketStatus == SocketStatus.DISCONNECTED) {
+ return
+ }
+ Log.d(TAG, "closeWebsocket: ")
+ if (this.socket != null) {
+ try {
+ this.socket!!.close(1000, "Done")
+ } catch (e: IOException) {
+ throw TunnelException("Socket failed to close", e)
+ }
+ }
+ this.socketStatus = SocketStatus.DISCONNECTED
+ this.callback.disconnected()
+ }
+
+ @Synchronized
+ fun connect() {
+ Log.d(TAG, "connect() with state= $socketStatus")
+ if (this.socketStatus != SocketStatus.NONE) {
+ Log.d(TAG, "connect() has already been called")
+ this.callback.error(TunnelException("connect() has already been called"))
+ close()
+ return
+ }
+ val threadNewThread = threadPool.newThread {
+ Log.d(TAG, "runReader()")
+ try {
+ synchronized(this) {
+ if (socket != null && socketStatus == SocketStatus.DISCONNECTED) {
+ try {
+ Log.d(TAG, "runReader() called when websocket is disconnected")
+ close()
+ } catch (e: IOException) {
+ Log.w(TAG, "connect: Socket failed to close", e)
+ }
+ } else {
+ val request = Request.Builder().url(url).header("Sec-WebSocket-Protocol", "fido.cable").build()
+
+ Log.d(TAG, "connect: request: $request")
+
+ socket = client.newWebSocket(request, object : WebSocketListener() {
+ override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ closeWebsocket()
+ }
+
+ override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ Log.e(TAG, "Tunnel failure: ${t.message}", t)
+ callback.error(TunnelException("Websocket failed", t))
+ }
+
+ override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
+ Log.d(TAG, "Received ${bytes.size} bytes")
+ callback.message(bytes.toByteArray())
+ }
+
+ override fun onOpen(webSocket: WebSocket, response: Response) {
+ socketStatus = SocketStatus.CONNECTED
+ callback.connected(response)
+ }
+ })
+ }
+ }
+ } catch (e: Exception) {
+ Log.d(TAG, "connect: ", e)
+ callback.error(TunnelException("Websocket connect failed", e))
+ }
+ }
+ this.submitThread = threadNewThread
+ threadNewThread.setName("TunnelWebSocket")
+ this.socketStatus = SocketStatus.CONNECTING
+ this.submitThread?.start()
+ }
+
+ @Synchronized
+ fun send(bArr: ByteArray) {
+ Log.d(TAG, "send() with state= $socketStatus")
+ if (this.socketStatus != SocketStatus.CONNECTED) {
+ Log.d(TAG, "send() called when websocket is not connected")
+ this.callback.error(TunnelException("sending data error: websocket is not connected"))
+ return
+ }
+ try {
+ socket?.send(ByteString.of(*bArr))
+ } catch (e: Exception) {
+ Log.d(TAG, "Failed to send frame")
+ this.callback.error(TunnelException("Failed to send frame", e))
+ close()
+ }
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/CryptoHelper.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/CryptoHelper.kt
new file mode 100644
index 0000000000..99951e01f9
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/CryptoHelper.kt
@@ -0,0 +1,183 @@
+/*
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.utils
+
+import android.util.Log
+import org.microg.gms.fido.core.hybrid.AEMK_ALGORITHM
+import org.microg.gms.fido.core.hybrid.EC_ALGORITHM
+import org.microg.gms.fido.core.hybrid.HKDF_ALGORITHM
+import org.microg.gms.fido.core.hybrid.hex
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.security.KeyPairGenerator
+import java.security.MessageDigest
+import java.security.interfaces.ECPrivateKey
+import java.security.interfaces.ECPublicKey
+import java.security.spec.ECGenParameterSpec
+import java.security.spec.ECPoint
+import javax.crypto.Cipher
+import javax.crypto.Mac
+import javax.crypto.spec.IvParameterSpec
+import javax.crypto.spec.SecretKeySpec
+import kotlin.math.ceil
+
+object CryptoHelper {
+
+ private const val TAG = "CryptoHelper"
+
+ fun uncompress(pub: ECPublicKey): ByteArray {
+ val p = pub.w
+ val x = p.affineX.toByteArray()
+ val y = p.affineY.toByteArray()
+ return ByteArray(65).apply {
+ this[0] = 0x04
+ System.arraycopy(x, (x.size - 32).coerceAtLeast(0), this, 1 + (32 - x.size).coerceAtLeast(0), x.size.coerceAtMost(32))
+ System.arraycopy(y, (y.size - 32).coerceAtLeast(0), this, 33 + (32 - y.size).coerceAtLeast(0), y.size.coerceAtMost(32))
+ }
+ }
+
+ fun recd(privy: ECPrivateKey, peerUncompressedOrDer: ByteArray): ByteArray {
+ val pub = try {
+ if (peerUncompressedOrDer.size == 65 && peerUncompressedOrDer[0] == 0x04.toByte()) {
+ val x = peerUncompressedOrDer.sliceArray(1..32)
+ val y = peerUncompressedOrDer.sliceArray(33..64)
+ val kg = KeyPairGenerator.getInstance(EC_ALGORITHM).apply { initialize(ECGenParameterSpec("secp256r1")) }
+ val tmp = (kg.generateKeyPair().public as ECPublicKey).params
+ val spec = java.security.spec.ECPublicKeySpec(
+ ECPoint(java.math.BigInteger(1, x), java.math.BigInteger(1, y)), tmp
+ )
+ java.security.KeyFactory.getInstance(EC_ALGORITHM).generatePublic(spec) as ECPublicKey
+ } else {
+ java.security.KeyFactory.getInstance(EC_ALGORITHM).generatePublic(
+ java.security.spec.X509EncodedKeySpec(peerUncompressedOrDer)
+ ) as ECPublicKey
+ }
+ } catch (_: Throwable) {
+ val derPrefix = byteArrayOf(
+ 0x30, 89, 0x30, 19, 0x06, 7, 0x2a, 0x86.toByte(), 0x48, 0xce.toByte(), 0x3d, 0x02, 0x01, 0x06, 8, 0x2a, 0x86.toByte(), 0x48, 0xce.toByte(), 0x3d, 0x03, 0x01, 0x07, 0x03, 66, 0
+ )
+ val full = derPrefix + peerUncompressedOrDer
+ java.security.KeyFactory.getInstance(EC_ALGORITHM).generatePublic(
+ java.security.spec.X509EncodedKeySpec(full)
+ ) as ECPublicKey
+ }
+
+ val ka = javax.crypto.KeyAgreement.getInstance("ECDH")
+ ka.init(privy)
+ ka.doPhase(pub, true)
+ return ka.generateSecret()
+ }
+
+ fun endif(ikm: ByteArray, salt: ByteArray, info: ByteArray, length: Int): ByteArray {
+ val prk = endifExtract(salt, ikm)
+ return endifExpand(prk, info, length)
+ }
+
+ fun endifExtract(salt: ByteArray, ikm: ByteArray): ByteArray {
+ val s = if (salt.isEmpty()) ByteArray(32) else salt
+ val mac = Mac.getInstance(HKDF_ALGORITHM).apply { init(SecretKeySpec(s, HKDF_ALGORITHM)) }
+ return mac.doFinal(ikm)
+ }
+
+ private fun endifExpand(prk: ByteArray, info: ByteArray, length: Int): ByteArray {
+ val glen = 32
+ val rounds = ceil(length / glen.toDouble()).toInt()
+ require(rounds <= 255) { "hkdf expand too long" }
+ val mac = Mac.getInstance(HKDF_ALGORITHM).apply { init(SecretKeySpec(prk, HKDF_ALGORITHM)) }
+
+ val out = ByteArray(length)
+ var prev = ByteArray(0)
+ repeat(rounds) { i ->
+ mac.reset()
+ if (prev.isNotEmpty()) mac.update(prev)
+ mac.update(info)
+ mac.update((i + 1).toByte())
+ prev = mac.doFinal()
+ val copy = minOf(glen, length - i * glen)
+ System.arraycopy(prev, 0, out, i * glen, copy)
+ }
+ return out
+ }
+
+ fun decryptEid(eid: ByteArray, seed: ByteArray): ByteArray? {
+ Log.d(TAG, "decryptEid: eid=${eid.hex()}, seed=${seed.hex()}")
+ if (eid.size != 20) {
+ Log.e(TAG, "decryptEid: Invalid EID size: ${eid.size}")
+ return null
+ }
+ val info = byteArrayOf(1, 0, 0, 0)
+ val derived = endif(ikm = seed, salt = ByteArray(0), info = info, length = 64)
+ val aesKey = derived.copyOfRange(0, 32)
+ val hmacKey = derived.copyOfRange(32, 64)
+ Log.d(TAG, "decryptEid: aesKey=${aesKey.hex()}, hmacKey=${hmacKey.hex()}")
+ val ct = eid.copyOfRange(0, 16)
+ val tag4 = eid.copyOfRange(16, 20)
+ Log.d(TAG, "decryptEid: ct=${ct.hex()}, tag4=${tag4.hex()}")
+ val mac = Mac.getInstance(HKDF_ALGORITHM).apply { init(SecretKeySpec(hmacKey, HKDF_ALGORITHM)) }
+ val expect = mac.doFinal(ct).copyOf(4)
+ Log.d(TAG, "decryptEid: expected tag=${expect.hex()}, actual tag=${tag4.hex()}")
+ if (!MessageDigest.isEqual(expect, tag4)) {
+ Log.w(TAG, "decryptEid: HMAC verification failed!")
+ return null
+ }
+ val cipher = Cipher.getInstance("AES/CBC/NoPadding").apply {
+ init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, AEMK_ALGORITHM), IvParameterSpec(ByteArray(16)))
+ }
+ val pt = cipher.doFinal(ct)
+ Log.d(TAG, "decryptEid: decrypted pt=${pt.hex()}, first byte=${pt.first()}")
+ if (pt.first() != 0.toByte()) {
+ Log.w(TAG, "decryptEid: Invalid first byte!")
+ return null
+ }
+ Log.d(TAG, "decryptEid: SUCCESS!")
+ return pt
+ }
+
+ fun generateEid(eidKey: ByteArray, seed: ByteArray): ByteArray {
+ val aesKey = eidKey.copyOfRange(0, 32)
+ val hmacKey = eidKey.copyOfRange(32, 64)
+
+ val ciphertext = try {
+ val cipher = Cipher.getInstance("AES/CBC/NoPadding")
+ cipher.init(
+ Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(ByteArray(16))
+ )
+ cipher.doFinal(seed)
+ } catch (e: Exception) {
+ Log.e(TAG, "AES encryption failed", e)
+ ByteArray(16)
+ }
+
+ val mac = try {
+ val hmac = Mac.getInstance("HmacSHA256")
+ hmac.init(SecretKeySpec(hmacKey, "HmacSHA256"))
+ hmac.doFinal(ciphertext)
+ } catch (e: Exception) {
+ Log.e(TAG, "HMAC calculation failed", e)
+ ByteArray(32)
+ }
+ val tag = mac.copyOf(4)
+
+ val eid = ByteArray(20)
+ System.arraycopy(ciphertext, 0, eid, 0, 16)
+ System.arraycopy(tag, 0, eid, 16, 4)
+ return eid
+ }
+
+ fun generatedSeed(routingId: ByteArray): ByteArray {
+ val seed = ByteArray(16).apply {
+ this[0] = 0x00
+ val timestamp = System.currentTimeMillis()
+ val buffer = ByteBuffer.allocate(8).order(ByteOrder.BIG_ENDIAN)
+ buffer.putLong(timestamp)
+ System.arraycopy(buffer.array(), 0, this, 1, 8)
+ System.arraycopy(routingId, 0, this, 11, 3)
+ this[14] = 0x00
+ this[15] = 0x00
+ }
+ return seed.copyOf()
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/CtapProtocol.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/CtapProtocol.kt
new file mode 100644
index 0000000000..92378f900c
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/CtapProtocol.kt
@@ -0,0 +1,263 @@
+/*
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.utils
+
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorAssertionResponse
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorAttestationResponse
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorResponse
+import com.google.android.gms.fido.fido2.api.common.ErrorCode
+import com.upokecenter.cbor.CBORObject
+import com.upokecenter.cbor.CBORType
+import org.microg.gms.fido.core.RequestHandlingException
+
+object CtapProtocol {
+
+ fun parseMakeCredentialResponse(clientDataJson: ByteArray, responseBytes: ByteArray): AuthenticatorResponse {
+ val result = Decoder.tryDecodeMake(responseBytes) ?: Decoder.manualFallbackMake(responseBytes)
+ return AuthenticatorAttestationResponse(
+ result.credentialId ?: ByteArray(0), clientDataJson, result.attestationObject, arrayOf("cable", "internal")
+ )
+ }
+
+ fun parseGetAssertionResponse(clientDataJson: ByteArray, responseBytes: ByteArray): AuthenticatorResponse {
+ val result = Decoder.tryDecodeGet(responseBytes) ?: Decoder.manualFallbackGet(responseBytes)
+ return AuthenticatorAssertionResponse(
+ result.credentialId, clientDataJson, result.authenticatorData, result.signature, result.userHandle
+ )
+ }
+
+ private object Decoder {
+
+ fun tryDecodeMake(bytes: ByteArray): CtapResult.Make? {
+ return try {
+ val map = CBORObject.DecodeFromBytes(bytes)
+ if (map.type != CBORType.Map) return null
+
+ val format = map[1]?.AsString() ?: return null
+ val authenticatorData = map[2]?.GetByteString() ?: return null
+ val attestationStatement = map[3] ?: CBORObject.NewMap()
+
+ val attObj = buildAttestationObject(format, authenticatorData, attestationStatement)
+ val credentialId = extractCredentialId(authenticatorData)
+
+ CtapResult.Make(credentialId, attObj)
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
+ fun manualFallbackMake(raw: ByteArray): CtapResult.Make {
+ val reader = RawReader(raw)
+
+ val mapHeader = reader.readUInt8()
+ val entryCount = mapHeader and 0x1f
+
+ var format: String? = null
+ var authenticatorData: ByteArray? = null
+
+ repeat(entryCount) {
+ val key = reader.readUInt8()
+ when (key) {
+ 0x01 -> format = reader.readTextString()
+ 0x02 -> authenticatorData = reader.readByteString()
+ else -> reader.skipCborField(key)
+ }
+ }
+
+ val fmt = format ?: error("Missing fmt")
+ val auth = authenticatorData ?: error("Missing authData")
+
+ val attObj = buildAttestationObject(fmt, auth)
+ val credentialId = extractCredentialId(auth)
+
+ return CtapResult.Make(credentialId, attObj)
+ }
+
+ fun tryDecodeGet(bytes: ByteArray): CtapResult.Get? {
+ return try {
+ val map = CBORObject.DecodeFromBytes(bytes)
+ if (map.type != CBORType.Map) return null
+
+ val credentialMap = map[1]
+ val credentialId = credentialMap?.getMapItem("id")?.GetByteString() ?: return null
+
+ val authenticatorData = map[2]?.GetByteString() ?: return null
+ val signature = map[3]?.GetByteString() ?: return null
+ val userHandle = map[4]?.getMapItem("id")?.GetByteString()
+
+ CtapResult.Get(credentialId, authenticatorData, signature, userHandle)
+ } catch (_: Throwable) {
+ null
+ }
+ }
+
+ fun manualFallbackGet(raw: ByteArray): CtapResult.Get {
+ val reader = RawReader(raw)
+
+ val mapHeader = reader.readUInt8()
+ val entryCount = mapHeader and 0x0f
+
+ var credentialId: ByteArray? = null
+ var authenticatorData: ByteArray? = null
+ var signature: ByteArray? = null
+ var userHandle: ByteArray? = null
+
+ repeat(entryCount) {
+ when (reader.readUInt8()) {
+ 1 -> {
+ val subCount = reader.readUInt8() and 0x0f
+ repeat(subCount) {
+ when (reader.readTextString()) {
+ "id" -> credentialId = reader.readByteString()
+ else -> reader.skipCborNext()
+ }
+ }
+ }
+
+ 2 -> authenticatorData = reader.readByteString()
+ 3 -> signature = reader.readByteString()
+ 4 -> {
+ val subCount = reader.readUInt8() and 0x0f
+ repeat(subCount) {
+ when (reader.readTextString()) {
+ "id" -> userHandle = reader.readByteString()
+ else -> reader.skipCborNext()
+ }
+ }
+ }
+
+ else -> reader.skipCborNext()
+ }
+ }
+
+ if (credentialId == null || authenticatorData == null || signature == null) throw RequestHandlingException(ErrorCode.DATA_ERR, "Missing required fields")
+
+ return CtapResult.Get(credentialId!!, authenticatorData!!, signature!!, userHandle)
+ }
+
+ private fun buildAttestationObject(
+ format: String, authenticatorData: ByteArray, attestationStatement: CBORObject = CBORObject.NewMap()
+ ): ByteArray = CBORObject.NewOrderedMap().apply {
+ this["fmt"] = CBORObject.FromObject(format)
+ this["attStmt"] = attestationStatement
+ this["authData"] = CBORObject.FromObject(authenticatorData)
+ }.EncodeToBytes()
+
+ private fun CBORObject.getMapItem(key: String): CBORObject? = if (this.type == CBORType.Map) this[key] else null
+
+ private fun extractCredentialId(authData: ByteArray): ByteArray? {
+ if (authData.size < 37) return null
+
+ var offset = 32
+ val flags = authData[offset++].toInt()
+ offset += 4
+
+ if ((flags and 0x40) == 0) return null
+ offset += 16
+
+ val length = ((authData[offset].toInt() and 0xFF) shl 8) or (authData[offset + 1].toInt() and 0xFF)
+
+ offset += 2
+
+ return if (offset + length <= authData.size) authData.copyOfRange(offset, offset + length)
+ else null
+ }
+ }
+
+ private class RawReader(private val source: ByteArray) {
+ private var position = 0
+
+ fun readUInt8(): Int = source[position++].toInt() and 0xFF
+
+ fun readBytes(length: Int): ByteArray = source.copyOfRange(position, position + length).also {
+ position += length
+ }
+
+ fun readByteString(): ByteArray {
+ val header = readUInt8()
+ return when (header) {
+ in 0x40..0x57 -> readBytes(header - 0x40)
+ 0x58 -> readBytes(readUInt8())
+ 0x59 -> {
+ val length = (readUInt8() shl 8) or readUInt8()
+ readBytes(length)
+ }
+
+ else -> error("Invalid bstr header=0x${header.toString(16)}")
+ }
+ }
+
+ fun readTextString(): String {
+ val header = readUInt8()
+ return when (header) {
+ in 0x60..0x77 -> String(readBytes(header - 0x60), Charsets.UTF_8)
+ 0x78 -> String(readBytes(readUInt8()), Charsets.UTF_8)
+ else -> error("Invalid tstr header=0x${header.toString(16)}")
+ }
+ }
+
+ fun skipCborField(header: Int) {
+ when (header) {
+ in 0x40..0x57 -> readBytes(header - 0x40)
+ in 0x60..0x77 -> readBytes(header - 0x60)
+ else -> {}
+ }
+ }
+
+ fun skipCborNext() {
+ val h = readUInt8()
+ skipCborField(h)
+ }
+ }
+
+ private sealed interface CtapResult {
+ data class Make(val credentialId: ByteArray?, val attestationObject: ByteArray) : CtapResult {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Make
+
+ if (!credentialId.contentEquals(other.credentialId)) return false
+ if (!attestationObject.contentEquals(other.attestationObject)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = credentialId?.contentHashCode() ?: 0
+ result = 31 * result + attestationObject.contentHashCode()
+ return result
+ }
+ }
+
+ data class Get(
+ val credentialId: ByteArray, val authenticatorData: ByteArray, val signature: ByteArray, val userHandle: ByteArray?
+ ) : CtapResult {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as Get
+
+ if (!credentialId.contentEquals(other.credentialId)) return false
+ if (!authenticatorData.contentEquals(other.authenticatorData)) return false
+ if (!signature.contentEquals(other.signature)) return false
+ if (!userHandle.contentEquals(other.userHandle)) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = credentialId.contentHashCode()
+ result = 31 * result + authenticatorData.contentHashCode()
+ result = 31 * result + signature.contentHashCode()
+ result = 31 * result + (userHandle?.contentHashCode() ?: 0)
+ return result
+ }
+ }
+ }
+}
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/NoiseCrypter.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/NoiseCrypter.kt
new file mode 100644
index 0000000000..77f85d9aeb
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/NoiseCrypter.kt
@@ -0,0 +1,60 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.utils
+
+import org.microg.gms.fido.core.hybrid.AEMK_ALGORITHM
+import javax.crypto.Cipher
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+class NoiseCrypter(private val rKey: ByteArray, private val wKey: ByteArray) {
+ private var rCtr = 0
+ private var wCtr = 0
+
+ fun encrypt(plain: ByteArray): ByteArray? = try {
+ val padded = pad32(plain)
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(
+ Cipher.ENCRYPT_MODE, SecretKeySpec(wKey, AEMK_ALGORITHM), GCMParameterSpec(128, nonce(wCtr++))
+ )
+ c.doFinal(padded)
+ } catch (_: Throwable) {
+ null
+ }
+
+ fun decrypt(cipher: ByteArray): ByteArray? = try {
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ c.init(
+ Cipher.DECRYPT_MODE, SecretKeySpec(rKey, AEMK_ALGORITHM), GCMParameterSpec(128, nonce(rCtr++))
+ )
+ val padded = c.doFinal(cipher)
+ unpad32(padded)
+ } catch (_: Throwable) {
+ null
+ }
+
+ private fun pad32(src: ByteArray): ByteArray {
+ val block = 32
+ val rem = src.size % block
+ val pad = if (rem == 0) block else (block - rem)
+ val out = ByteArray(src.size + pad)
+ System.arraycopy(src, 0, out, 0, src.size)
+ out[out.lastIndex] = (pad - 1).toByte()
+ return out
+ }
+
+ private fun nonce(c: Int) = byteArrayOf(
+ 0, 0, 0, 0, 0, 0, 0, 0, ((c ushr 24) and 0xFF).toByte(), ((c ushr 16) and 0xFF).toByte(), ((c ushr 8) and 0xFF).toByte(), (c and 0xFF).toByte()
+ )
+
+ private fun unpad32(padded: ByteArray): ByteArray? {
+ if (padded.isEmpty()) return null
+ val padLen = (padded[padded.lastIndex].toInt() and 0xFF) + 1
+ if (padLen < 1 || padLen > 32 || padLen > padded.size) return null
+ val dataLen = padded.size - padLen
+ return padded.copyOfRange(0, dataLen)
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/NoiseProtocol.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/NoiseProtocol.kt
new file mode 100644
index 0000000000..79049d2c88
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/utils/NoiseProtocol.kt
@@ -0,0 +1,101 @@
+/*
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.hybrid.utils
+
+import org.microg.gms.fido.core.hybrid.AEMK_ALGORITHM
+import org.microg.gms.fido.core.hybrid.HKDF_ALGORITHM
+import java.security.MessageDigest
+import javax.crypto.Cipher
+import javax.crypto.Mac
+import javax.crypto.spec.GCMParameterSpec
+import javax.crypto.spec.SecretKeySpec
+
+class NoiseHandshakeState(mode: Int) {
+
+ private val protocolName = when (mode) {
+ 2 -> "Noise_NKpsk0_P256_AESGCM_SHA256"
+ 3 -> "Noise_KNpsk0_P256_AESGCM_SHA256"
+ else -> "Noise_NK_P256_AESGCM_SHA256"
+ }
+
+ private var handshakeHash: ByteArray = protocolName.toByteArray() + ByteArray(32 - protocolName.length)
+ private var chainingKey: ByteArray = handshakeHash.clone()
+ private var cipherKey: ByteArray? = null
+
+ fun mixHash(data: ByteArray) {
+ handshakeHash = MessageDigest.getInstance("SHA-256").run {
+ update(handshakeHash)
+ digest(data)
+ }
+ }
+
+ fun mixKey(inputKeyMaterial: ByteArray) {
+ val (ck, k) = endif(chainingKey, inputKeyMaterial, 2)
+ chainingKey = ck
+ cipherKey = k
+ }
+
+ fun mixKeyAndHash(inputKeyMaterial: ByteArray) {
+ val (ck, tempHash, k) = endif(chainingKey, inputKeyMaterial, 3)
+ chainingKey = ck
+ mixHash(tempHash)
+ cipherKey = k
+ }
+
+ fun encryptAndHash(plaintext: ByteArray): ByteArray {
+ val key = cipherKey ?: ByteArray(32)
+
+ val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply {
+ init(
+ Cipher.ENCRYPT_MODE, SecretKeySpec(key, AEMK_ALGORITHM), GCMParameterSpec(128, ByteArray(12))
+ )
+ updateAAD(handshakeHash)
+ }
+
+ return cipher.doFinal(plaintext).also { ciphertext ->
+ mixHash(ciphertext)
+ }
+ }
+
+ fun splitSessionKeys(): Pair {
+ val (k1, k2) = endif(chainingKey, ByteArray(0), 2)
+ return k1 to k2
+ }
+
+ fun decryptAndHash(ct: ByteArray): ByteArray {
+ val key = cipherKey ?: ByteArray(32)
+ val c = Cipher.getInstance("AES/GCM/NoPadding")
+ val gcm = GCMParameterSpec(128, ByteArray(12))
+ c.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, AEMK_ALGORITHM), gcm)
+ c.updateAAD(handshakeHash)
+ val pt = c.doFinal(ct)
+ mixHash(ct)
+ return pt
+ }
+
+ private fun endif(
+ chainingKey: ByteArray, inputKeyMaterial: ByteArray, outputs: Int
+ ): List {
+ val prk = CryptoHelper.endifExtract(chainingKey, inputKeyMaterial)
+
+ val mac = Mac.getInstance(HKDF_ALGORITHM).apply {
+ init(SecretKeySpec(prk, HKDF_ALGORITHM))
+ }
+
+ val result = ArrayList(outputs)
+ var previous = ByteArray(0)
+
+ repeat(outputs) { index ->
+ mac.reset()
+ if (previous.isNotEmpty()) mac.update(previous)
+ mac.update((index + 1).toByte())
+ previous = mac.doFinal()
+ result += previous
+ }
+
+ return result
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/Cbor.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/Cbor.kt
index fab19de8d2..7802b65547 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/Cbor.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/Cbor.kt
@@ -44,6 +44,12 @@ fun PublicKeyCredentialRpEntity.encodeAsCbor() = CBORObject.NewMap().apply {
if (!icon.isNullOrBlank()) set("icon", icon!!.encodeAsCbor())
}
+fun CBORObject.decodeAsPublicKeyCredentialRpEntity() = PublicKeyCredentialRpEntity(
+ get("id")?.AsString() ?: "".also { Log.w(TAG, "id was not present") },
+ get("name")?.AsString() ?: "".also { Log.w(TAG, "name was not present") },
+ get("icon")?.AsString() ?: "".also { Log.w(TAG, "icon was not present") },
+)
+
fun PublicKeyCredentialUserEntity.encodeAsCbor() = CBORObject.NewMap().apply {
set("id", id.encodeAsCbor())
if (!name.isNullOrBlank()) set("name", name.encodeAsCbor())
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetAssertion.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetAssertion.kt
index a4b1d58bba..667cb4fb38 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetAssertion.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetAssertion.kt
@@ -29,7 +29,7 @@ class AuthenticatorGetAssertionRequest(
val options: Options? = null,
val pinAuth: ByteArray? = null,
val pinProtocol: Int? = null
-) : Ctap2Request(0x02, CBORObject.NewMap().apply {
+) : Ctap2Request(COMMAND, CBORObject.NewMap().apply {
set(0x01, rpId.encodeAsCbor())
set(0x02, clientDataHash.encodeAsCbor())
if (allowList.isNotEmpty()) set(0x03, allowList.encodeAsCbor { it.encodeAsCbor() })
@@ -44,6 +44,7 @@ class AuthenticatorGetAssertionRequest(
"options=$options,pinAuth=${pinAuth?.toBase64(Base64.NO_WRAP)},pinProtocol=$pinProtocol)"
companion object {
+ const val COMMAND: Byte = 0x02
class Options(
val userPresence: Boolean = true,
val userVerification: Boolean = false
@@ -58,6 +59,17 @@ class AuthenticatorGetAssertionRequest(
return "(userPresence=$userPresence, userVerification=$userVerification)"
}
}
+
+ fun decodeFromCbor(obj: CBORObject) = AuthenticatorGetAssertionRequest(
+ rpId = obj[0x01]?.AsString() ?: "",
+ clientDataHash = obj[0x02].GetByteString(),
+ allowList = obj[0x03]?.values?.map { it.decodeAsPublicKeyCredentialDescriptor() } ?: emptyList(),
+ options = obj[0x05]?.let { optObj ->
+ Options(
+ userPresence = optObj["up"]?.AsBoolean() ?: true,
+ userVerification = optObj["uv"]?.AsBoolean() ?: false,
+ )
+ })
}
}
@@ -69,6 +81,14 @@ class AuthenticatorGetAssertionResponse(
val numberOfCredentials: Int?
) : Ctap2Response {
+ fun encodeAsCbor() = CBORObject.NewMap().apply {
+ set(0x01, credential?.encodeAsCbor())
+ set(0x02, authData.encodeAsCbor())
+ set(0x03, signature.encodeAsCbor())
+ set(0x04, user?.encodeAsCbor())
+ set(0x05, numberOfCredentials?.encodeAsCbor())
+ }
+
companion object {
fun decodeFromCbor(obj: CBORObject) = AuthenticatorGetAssertionResponse(
credential = obj.get(0x01)?.decodeAsPublicKeyCredentialDescriptor(),
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetInfo.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetInfo.kt
index c9d78b4935..99c7b0ba00 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetInfo.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorGetInfo.kt
@@ -10,13 +10,40 @@ import com.upokecenter.cbor.CBORObject
import org.microg.gms.fido.core.protocol.AsInt32Sequence
import org.microg.gms.fido.core.protocol.AsStringSequence
import org.microg.gms.fido.core.protocol.decodeAsPublicKeyCredentialParameters
+import org.microg.gms.fido.core.protocol.encodeAsCbor
import org.microg.gms.utils.ToStringHelper
class AuthenticatorGetInfoCommand : Ctap2Command(AuthenticatorGetInfoRequest()) {
override fun decodeResponse(obj: CBORObject) = AuthenticatorGetInfoResponse.decodeFromCbor(obj)
}
-class AuthenticatorGetInfoRequest : Ctap2Request(0x04)
+class AuthenticatorGetInfoRequest(
+ val versions: List? = null,
+ val extensions: List? = null,
+ val clientDataHash: ByteArray? = null,
+ val options: Options? = null,
+ val transports: List? = null,
+) : Ctap2Request(COMMAND, CBORObject.NewMap().apply {
+ if (versions != null) set(0x01, versions.encodeAsCbor { it -> it.encodeAsCbor() })
+ if (!extensions.isNullOrEmpty()) set(0x02, extensions.encodeAsCbor { it.encodeAsCbor() })
+ if (clientDataHash != null) set(0x03, clientDataHash.encodeAsCbor())
+ if (options != null) set(0x04, options.encodeAsCbor())
+ if (transports != null) set(0x09, transports.encodeAsCbor { it.encodeAsCbor() })
+}) {
+ class Options(
+ val residentKey: Boolean = false, val userPresence: Boolean = true, val userVerification: Boolean = false
+ ) {
+ fun encodeAsCbor(): CBORObject = CBORObject.NewMap().apply {
+ if (residentKey) set("rk", residentKey.encodeAsCbor())
+ if (!userPresence) set("up", userPresence.encodeAsCbor())
+ if (userVerification) set("uv", userVerification.encodeAsCbor())
+ }
+ }
+
+ companion object {
+ const val COMMAND: Byte = 0x04
+ }
+}
class AuthenticatorGetInfoResponse(
val versions: List,
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorMakeCredential.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorMakeCredential.kt
index 5fbd63ba20..1c0e1815c2 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorMakeCredential.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorMakeCredential.kt
@@ -11,6 +11,10 @@ import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialParameter
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRpEntity
import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity
import com.upokecenter.cbor.CBORObject
+import org.microg.gms.fido.core.protocol.decodeAsPublicKeyCredentialDescriptor
+import org.microg.gms.fido.core.protocol.decodeAsPublicKeyCredentialParameters
+import org.microg.gms.fido.core.protocol.decodeAsPublicKeyCredentialRpEntity
+import org.microg.gms.fido.core.protocol.decodeAsPublicKeyCredentialUserEntity
import org.microg.gms.fido.core.protocol.encodeAsCbor
import org.microg.gms.utils.toBase64
@@ -31,7 +35,7 @@ class AuthenticatorMakeCredentialRequest(
val options: Options? = null,
val pinAuth: ByteArray? = null,
val pinProtocol: Int? = null
-) : Ctap2Request(0x01, CBORObject.NewMap().apply {
+) : Ctap2Request(COMMAND, CBORObject.NewMap().apply {
set(0x01, clientDataHash.encodeAsCbor())
set(0x02, rp.encodeAsCbor())
set(0x03, user.encodeAsCbor())
@@ -48,6 +52,7 @@ class AuthenticatorMakeCredentialRequest(
"options=$options,pinAuth=${pinAuth?.toBase64(Base64.NO_WRAP)},pinProtocol=$pinProtocol)"
companion object {
+ const val COMMAND: Byte = 0x01
class Options(
val residentKey: Boolean = false,
val userVerification: Boolean = false
@@ -58,6 +63,19 @@ class AuthenticatorMakeCredentialRequest(
if (userVerification) set("uv", userVerification.encodeAsCbor())
}
}
+
+ fun decodeFromCbor(obj: CBORObject) = AuthenticatorMakeCredentialRequest(
+ clientDataHash = obj.get(0x01).GetByteString(),
+ rp = obj.get(0x02).decodeAsPublicKeyCredentialRpEntity(),
+ user = obj.get(0x03).decodeAsPublicKeyCredentialUserEntity(),
+ pubKeyCredParams = obj.get(0x04).values.map { it.decodeAsPublicKeyCredentialParameters() },
+ excludeList = obj.get(0x05).values.map { it.decodeAsPublicKeyCredentialDescriptor() },
+ options = obj.get(0x07)?.let { optObj ->
+ Options(
+ residentKey = optObj["rk"]?.AsBoolean() ?: false,
+ userVerification = optObj["uv"]?.AsBoolean() ?: false,
+ )
+ })
}
}
@@ -66,6 +84,12 @@ class AuthenticatorMakeCredentialResponse(
val fmt: String,
val attStmt: CBORObject
) : Ctap2Response {
+ fun encodeAsCbor() = CBORObject.NewMap().apply {
+ set(0x01, CBORObject.FromObject(fmt))
+ set(0x02, CBORObject.FromObject(authData))
+ set(0x03, attStmt)
+ }
+
companion object {
fun decodeFromCbor(obj: CBORObject) = AuthenticatorMakeCredentialResponse(
fmt = obj.get(0x01).AsString(),
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/Ctap2Command.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/Ctap2Command.kt
index 7369325318..11a01d1492 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/Ctap2Command.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/Ctap2Command.kt
@@ -28,6 +28,8 @@ interface Ctap2Response
abstract class Ctap2Request(val commandByte: Byte, val parameters: CBORObject? = null) {
val payload: ByteArray = parameters?.EncodeToBytes(CBOREncodeOptions.DefaultCtap2Canonical) ?: ByteArray(0)
+ fun encode(): ByteArray = byteArrayOf(0x01, commandByte) + payload
+
override fun toString(): String = "Ctap2Request(command=0x${commandByte.toString(16)}, " +
"payload=${payload.toBase64(Base64.NO_WRAP)})"
}
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/bluetooth/BluetoothTransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/bluetooth/BluetoothTransportHandler.kt
index c6e00b0279..f60b067b6c 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/bluetooth/BluetoothTransportHandler.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/bluetooth/BluetoothTransportHandler.kt
@@ -5,16 +5,105 @@
package org.microg.gms.fido.core.transport.bluetooth
+import android.Manifest
import android.bluetooth.BluetoothManager
import android.content.Context
-import android.os.Build.VERSION.SDK_INT
+import android.os.Build
+import android.util.Log
+import androidx.annotation.RequiresApi
+import androidx.annotation.RequiresPermission
import androidx.core.content.getSystemService
+import androidx.core.os.bundleOf
+import com.google.android.gms.fido.fido2.api.common.AuthenticatorResponse
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialCreationOptions
+import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialRequestOptions
+import com.google.android.gms.fido.fido2.api.common.RequestOptions
+import com.google.android.gms.fido.fido2.api.common.UserVerificationRequirement
+import org.microg.gms.fido.core.getClientDataAndHash
+import org.microg.gms.fido.core.hybrid.controller.HybridClientController
+import org.microg.gms.fido.core.hybrid.generateEcKeyPair
+import org.microg.gms.fido.core.hybrid.model.QrCodeData
+import org.microg.gms.fido.core.hybrid.utils.CtapProtocol
+import org.microg.gms.fido.core.protocol.msgs.AuthenticatorGetAssertionRequest
+import org.microg.gms.fido.core.protocol.msgs.AuthenticatorMakeCredentialRequest
import org.microg.gms.fido.core.transport.Transport
import org.microg.gms.fido.core.transport.TransportHandler
import org.microg.gms.fido.core.transport.TransportHandlerCallback
-class BluetoothTransportHandler(private val context: Context, callback: TransportHandlerCallback? = null) :
- TransportHandler(Transport.BLUETOOTH, callback) {
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class BluetoothTransportHandler(private val context: Context, callback: TransportHandlerCallback? = null) : TransportHandler(Transport.BLUETOOTH, callback) {
override val isSupported: Boolean
- get() = SDK_INT >= 18 && context.getSystemService()?.adapter != null
+ get() = context.getSystemService()?.adapter != null
+
+ @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
+ override suspend fun start(
+ options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, userInfo: String?
+ ): AuthenticatorResponse {
+ val staticKey = generateEcKeyPair()
+ val hybridClientController = HybridClientController(context, staticKey)
+ try {
+ callback?.onStatusChanged(
+ Transport.BLUETOOTH, "QR_CODE_READY", bundleOf("qrCodeBitmap" to QrCodeData.generateQrCode(staticKey.first, options.challenge))
+ )
+ val eid = hybridClientController.startBluetoothScan()
+ callback?.onStatusChanged(Transport.BLUETOOTH, "CONNECTING", null)
+
+ val (clientData, clientDataHash) = getClientDataAndHash(context, options, callerPackage)
+ val tunnelResp = hybridClientController.startClientTunnel(eid, options.challenge) {
+ Log.d(TAG, "start: options: $options")
+ when (options) {
+ is PublicKeyCredentialCreationOptions -> {
+ val reqOptions = options.authenticatorSelection?.let {
+ val rk = (it.requireResidentKey == true || it.residentKeyRequirement?.toString() == UserVerificationRequirement.REQUIRED.name)
+ val uv = (it.requireUserVerification == UserVerificationRequirement.REQUIRED)
+ AuthenticatorMakeCredentialRequest.Companion.Options(rk, uv)
+ }
+ AuthenticatorMakeCredentialRequest(
+ clientDataHash = clientDataHash,
+ rp = options.rp,
+ user = options.user,
+ pubKeyCredParams = options.parameters,
+ excludeList = options.excludeList ?: emptyList(),
+ options = reqOptions,
+ ).encode()
+ }
+
+ is PublicKeyCredentialRequestOptions -> {
+ AuthenticatorGetAssertionRequest(
+ rpId = options.rpId,
+ clientDataHash = clientDataHash,
+ allowList = options.allowList.orEmpty(),
+ options = if (options.requireUserVerification == UserVerificationRequirement.REQUIRED) {
+ AuthenticatorGetAssertionRequest.Companion.Options(userVerification = true)
+ } else null
+ ).encode()
+ }
+
+ else -> null
+ }
+ }
+
+ return parseResponse(options, tunnelResp, clientData)
+ } catch (e: Throwable) {
+ Log.w(TAG, "startHybrid error", e)
+ throw e
+ } finally {
+ hybridClientController.release()
+ }
+ }
+
+ private fun parseResponse(options: RequestOptions, data: ByteArray, clientData: ByteArray): AuthenticatorResponse {
+ if (data.isEmpty()) error("Empty CTAP data")
+
+ val status = data[0].toInt() and 0xFF
+ require(status == 0) { "CTAP error 0x${status.toString(16)}" }
+
+ val cbor = data.copyOfRange(1, data.size)
+
+ return when (options) {
+ is PublicKeyCredentialCreationOptions -> CtapProtocol.parseMakeCredentialResponse(clientData, cbor)
+ is PublicKeyCredentialRequestOptions -> CtapProtocol.parseGetAssertionResponse(clientData, cbor)
+ else -> error("Unknown RequestOptions")
+ }
+ }
}
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt
index 74bddc2c06..0d2829e197 100644
--- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivity.kt
@@ -59,8 +59,8 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
private val database by lazy { Database(this) }
private val transportHandlers by lazy {
setOfNotNull(
- BluetoothTransportHandler(this, this),
NfcTransportHandler(this, this),
+ if (SDK_INT >= 21) BluetoothTransportHandler(this, this) else null,
if (SDK_INT >= 21) UsbTransportHandler(this, this) else null,
if (SDK_INT >= 23) ScreenLockTransportHandler(this, this) else null
)
@@ -135,8 +135,10 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
Log.d(TAG, "origin=$origin, appName=$appName")
+ val noLocalUserForSignInstantBlock = options.type == RequestOptionsType.SIGN && database.getKnownRegistrationInfo(options.rpId).isEmpty()
+
// Check if we can directly open screen lock handling
- if (!requiresPrivilege && allowInstant) {
+ if (!requiresPrivilege && allowInstant && !noLocalUserForSignInstantBlock) {
val instantTransport = transportHandlers.firstOrNull { it.isSupported && it.shouldBeUsedInstantly(options) }
if (instantTransport != null && instantTransport.transport in INSTANT_SUPPORTED_TRANSPORTS) {
startTransportHandling(instantTransport.transport, true)
@@ -149,7 +151,14 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
this.isFirst = true
this.privilegedCallerName = callerName.takeIf { options is BrowserRequestOptions }
this.requiresPrivilege = requiresPrivilege
- this.supportedTransports = transportHandlers.filter { it.isSupported }.map { it.transport }.toSet()
+ val supported = transportHandlers.filter { it.isSupported }.map { it.transport }.toSet()
+ val forceBluetoothOnly = options.type == RequestOptionsType.SIGN && database.getKnownRegistrationInfo(options.rpId).isEmpty() && !requiresPrivilege
+
+ this.supportedTransports = if (forceBluetoothOnly && supported.contains(BLUETOOTH)) {
+ setOf(BLUETOOTH)
+ } else {
+ supported
+ }
}.arguments
val next = if (!requiresPrivilege) {
val knownRegistrationTransports = mutableSetOf()
@@ -179,17 +188,23 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
}
database.getKnownRegistrationInfo(options.rpId).forEach { localSavedUserKey.add(it.userJson) }
}
- val preselectedTransport = knownRegistrationTransports.singleOrNull() ?: allowedTransports.singleOrNull()
- if (database.wasUsed()) {
- if (localSavedUserKey.isNotEmpty()) {
- R.id.signInSelectionFragment
- } else when (preselectedTransport) {
- USB -> R.id.usbFragment
- NFC -> R.id.nfcFragment
- else -> R.id.transportSelectionFragment
- }
+ val forceBluetoothOnlyHere = options.type == RequestOptionsType.SIGN && localSavedUserKey.isEmpty()
+ if (forceBluetoothOnlyHere) {
+ R.id.transportSelectionFragment
} else {
- null
+ val preselectedTransport = knownRegistrationTransports.singleOrNull() ?: allowedTransports.singleOrNull()
+ if (database.wasUsed()) {
+ if (localSavedUserKey.isNotEmpty()) {
+ R.id.signInSelectionFragment
+ } else when (preselectedTransport) {
+ USB -> R.id.usbFragment
+ NFC -> R.id.nfcFragment
+ BLUETOOTH -> R.id.qrCodeFragment
+ else -> R.id.transportSelectionFragment
+ }
+ } else {
+ null
+ }
}
} else {
null
@@ -222,7 +237,12 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
fun finishWithSuccessResponse(response: AuthenticatorResponse, transport: Transport) {
Log.d(TAG, "Finish with success response: $response")
- if (options is BrowserRequestOptions) database.insertPrivileged(callerPackage, callerSignature)
+
+ val shouldPersist = transport != BLUETOOTH
+ if (options is BrowserRequestOptions && shouldPersist) {
+ database.insertPrivileged(callerPackage, callerSignature)
+ }
+
val rpId = options?.rpId
val rawId = when(response) {
is AuthenticatorAttestationResponse -> response.keyHandle
@@ -231,7 +251,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
}
val id = rawId?.toBase64(Base64.URL_SAFE, Base64.NO_WRAP, Base64.NO_PADDING)
- if (rpId != null && id != null) {
+ if (shouldPersist && rpId != null && id != null) {
database.insertKnownRegistration(rpId, id, transport, options?.user)
}
@@ -357,7 +377,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback {
const val KEY_CALLER = "caller"
- val IMPLEMENTED_TRANSPORTS = setOf(USB, SCREEN_LOCK, NFC)
+ val IMPLEMENTED_TRANSPORTS = setOf(USB, SCREEN_LOCK, NFC, BLUETOOTH)
val INSTANT_SUPPORTED_TRANSPORTS = setOf(SCREEN_LOCK)
}
}
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/QRBounceActivity.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/QRBounceActivity.kt
new file mode 100644
index 0000000000..ff70f8cccb
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/QRBounceActivity.kt
@@ -0,0 +1,38 @@
+/**
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import androidx.appcompat.app.AppCompatActivity
+import kotlin.also
+import kotlin.text.lowercase
+
+class QRBounceActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ onNewIntent(intent)
+ }
+
+ override fun onNewIntent(intent: Intent?) {
+ super.onNewIntent(intent)
+ var scheme: String? = null
+ val action = intent!!.action
+ val data = intent.data
+ if (action == null || (action != "android.intent.action.VIEW") || data == null || (data.scheme.also { scheme = it }) == null || (scheme!!.lowercase() != "fido")) {
+ Log.w(TAG, "Invalid data from scanning QR Code: $data")
+ finish()
+ return
+ }
+ val targetIntent = Intent()
+ targetIntent.setClassName(this, "org.microg.gms.fido.core.ui.hybrid.HybridAuthenticateActivity")
+ targetIntent.setData(data)
+ startActivity(targetIntent)
+ finish()
+ }
+}
\ No newline at end of file
diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/QrCodeFragment.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/QrCodeFragment.kt
new file mode 100644
index 0000000000..4868760546
--- /dev/null
+++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/QrCodeFragment.kt
@@ -0,0 +1,250 @@
+/*
+ * SPDX-FileCopyrightText: 2025 microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package org.microg.gms.fido.core.ui
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.os.Build.VERSION.SDK_INT
+import android.os.Bundle
+import android.provider.Settings
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.ProgressBar
+import android.widget.TextView
+import android.widget.Toast
+import androidx.annotation.RequiresApi
+import androidx.appcompat.app.AlertDialog
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
+import androidx.databinding.DataBindingUtil
+import androidx.fragment.app.Fragment
+import org.microg.gms.fido.core.R
+import org.microg.gms.fido.core.RequestOptionsType
+import org.microg.gms.fido.core.databinding.FidoQrCodeFragmentBinding
+import org.microg.gms.fido.core.transport.Transport
+import org.microg.gms.fido.core.transport.TransportHandlerCallback
+import org.microg.gms.fido.core.type
+import kotlin.apply
+import kotlin.collections.all
+import kotlin.collections.any
+import kotlin.collections.filterIndexed
+import kotlin.collections.getOrNull
+import kotlin.collections.isNotEmpty
+import kotlin.collections.toTypedArray
+import kotlin.run
+
+@RequiresApi(Build.VERSION_CODES.N)
+class QrCodeFragment : Fragment(), TransportHandlerCallback {
+
+ companion object {
+ private const val TAG = "QrCodeFragment"
+ private const val REQUEST_BLUETOOTH_PERMISSIONS = 1001
+ }
+
+ private lateinit var binding: FidoQrCodeFragmentBinding
+ private lateinit var activityHost: AuthenticatorActivity
+
+ override fun onCreateView(
+ inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
+ ): View {
+ activityHost = requireActivity() as AuthenticatorActivity
+ binding = DataBindingUtil.inflate(inflater, R.layout.fido_qr_code_fragment, container, false)
+
+ binding.data = AuthenticatorActivityFragmentData(requireArguments())
+
+ binding.root.findViewById