Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d8fbdc2
FIDO: Add Bluetooth connection
DaVinci9196 Dec 17, 2025
c00c100
update
DaVinci9196 Dec 17, 2025
7024510
Merge branch 'microg:master' into passkey_scan_code
DaVinci9196 Jan 28, 2026
05cc0d0
Added QR code scanning page redirection to FIDO
DaVinci9196 Feb 24, 2026
a93ce7b
Revert "Added QR code scanning page redirection to FIDO"
DaVinci9196 Feb 27, 2026
6391299
Merge branch 'master' into passkey_scan_code
DaVinci9196 Feb 27, 2026
68fe1af
Merge remote-tracking branch 'origin/passkey_scan_code' into passkey_…
DaVinci9196 Feb 27, 2026
c8c03a2
Request content changed
DaVinci9196 Feb 27, 2026
45b4618
Merge branch 'master' into passkey_scan_code
DaVinci9196 Mar 13, 2026
20733e7
Formatting code
DaVinci9196 Mar 13, 2026
7a2cd16
cleanCode
DaVinci9196 Mar 13, 2026
433ff65
Merge branch 'master' into passkey_scan_code
mar-v-in Mar 24, 2026
11ba085
Merge branch 'master' into passkey_scan_code
mar-v-in Mar 24, 2026
c852008
Merge branch 'master' into passkey_scan_code
mar-v-in Mar 24, 2026
a097a2f
Optimization: Avoid interference from non-target scanning
DaVinci9196 Mar 27, 2026
699fbc3
Fix AuthenticatorMakeCredentialRequest
mar-v-in Mar 24, 2026
6f757d5
Always encode name/displayName, even if empty
mar-v-in Mar 27, 2026
fc32122
Some more clean up
mar-v-in Mar 27, 2026
e93c1a7
Filter options by allowlist
mar-v-in Mar 27, 2026
df65854
Fido: Refactor
mar-v-in Mar 29, 2026
3dd2240
Avoid nested loops
DaVinci9196 Mar 30, 2026
c8d3124
The hybrid option should not be displayed when navigating to the lice…
DaVinci9196 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import org.microg.gms.fido.core.RequestHandlingException
import org.microg.gms.fido.core.transport.Transport
import org.microg.gms.fido.core.transport.TransportHandlerCallback
import org.microg.gms.fido.core.transport.bluetooth.BluetoothTransportHandler
import org.microg.gms.fido.core.transport.hybrid.HybridTransportHandler
import org.microg.gms.fido.core.transport.nfc.NfcTransportHandler
import org.microg.gms.fido.core.transport.screenlock.ScreenLockTransportHandler
import org.microg.gms.fido.core.transport.usb.UsbTransportHandler
Expand All @@ -35,6 +36,7 @@ class FidoHandler(private val activity: LoginActivity) : TransportHandlerCallbac
BluetoothTransportHandler(activity, this),
NfcTransportHandler(activity, this),
if (SDK_INT >= 21) UsbTransportHandler(activity, this) else null,
if (SDK_INT >= 21) HybridTransportHandler(activity, this) else null,
if (SDK_INT >= 23) ScreenLockTransportHandler(activity, this) else null
)
}
Expand Down Expand Up @@ -149,7 +151,7 @@ class FidoHandler(private val activity: LoginActivity) : TransportHandlerCallbac
activity.lifecycleScope.launchWhenStarted {
val options = requestOptions
try {
sendSuccessResult(transportHandler.start(options, activity.packageName), transport)
sendSuccessResult(transportHandler.start(options, activity.packageName).response, transport)
} catch (e: CancellationException) {
Log.w(TAG, e)
// Ignoring cancellation here
Expand Down
3 changes: 3 additions & 0 deletions play-services-fido/core/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 33 additions & 0 deletions play-services-fido/core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@
<uses-permission android:name="android.permission.START_ACTIVITIES_FROM_BACKGROUND"
tools:ignore="ProtectedPermissions" />

<!-- Bluetooth permissions for FIDO2 cross-device authentication -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<!-- Bluetooth hardware features for FIDO2 -->
<uses-feature
android:name="android.hardware.bluetooth"
android:required="false" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />

<application>
<service
android:name=".privileged.Fido2PrivilegedService"
Expand Down Expand Up @@ -44,5 +58,24 @@
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:theme="@style/Theme.Translucent"
android:name=".ui.hybrid.HybridAuthenticateActivity"
android:enabled="true"
android:exported="true"
android:process=":ui"
android:excludeFromRecents="true"
android:configChanges="smallestScreenSize|screenSize|uiMode|screenLayout|orientation|keyboardHidden|keyboard"
android:launchMode="singleTask"
android:noHistory="true"
tools:targetApi="23">
<intent-filter android:icon="@drawable/ic_fido_passkey">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fido"/>
<data android:scheme="FIDO" tools:ignore="AppLinkUrlError" />
</intent-filter>
</activity>
</application>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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<CredentialUserInfo>()
cursor.use { c ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,6 @@ val RequestOptions.rpId: String
SIGN -> signOptions.rpId
}

val RequestOptions.user: String?
get() = when (type) {
REGISTER -> registerOptions.user.toJson()
SIGN -> null
}

val PublicKeyCredentialCreationOptions.skipAttestation: Boolean
get() = attestationConveyancePreference in setOf(AttestationConveyancePreference.NONE, null)

Expand Down Expand Up @@ -108,7 +102,10 @@ private suspend fun isFacetIdTrusted(context: Context, facetIds: Set<String>, 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<JSONArray>()
Expand All @@ -118,7 +115,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/**
* 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 android.os.Handler
import android.os.Looper
import java.util.concurrent.atomic.AtomicBoolean

private const val TAG = "HybridBleAdvertiser"
private const val ADVERTISE_TIMEOUT_MS = 20000L

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class HybridBleAdvertiser(
private val bluetoothLeAdapter: BluetoothAdapter?,
private val onAdvertiseFailure: ((Int) -> Unit)? = null
) : AdvertiseCallback() {
private val advertiserStatus = AtomicBoolean(false)
private val handler = Handler(Looper.getMainLooper())
private val stopRunnable = object : Runnable {
@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(ADVERTISE_TIMEOUT_MS.toInt())
.build()

val data = AdvertiseData.Builder()
.addServiceUuid(UUID_ANDROID)
.addServiceData(UUID_ANDROID, eid)
.setIncludeDeviceName(false)
.setIncludeTxPowerLevel(false)
.build()

bluetoothLeAdvertiser?.startAdvertising(settings, data, this)

handler.postDelayed(stopRunnable, ADVERTISE_TIMEOUT_MS)
}
}

@RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
fun stopAdvertising() {
if (this.advertiserStatus.compareAndSet(true, false)) {
handler.removeCallbacks(stopRunnable)
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))
advertiserStatus.set(false)
handler.removeCallbacks(stopRunnable)
onAdvertiseFailure?.invoke(errorCode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* 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 org.microg.gms.fido.core.hybrid.utils.CryptoHelper
import java.util.concurrent.atomic.AtomicBoolean

private const val TAG = "HybridClientScan"

@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
class HybridClientScan(
private val bluetoothLeAdapter: BluetoothAdapter?,
private val seed: ByteArray,
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) {
val scanRecord = result.scanRecord
if (scanRecord == null) {
Log.d(TAG, "processDevice: ScanResult is missing ScanRecord")
return
}
var serviceData = scanRecord.getServiceData(UUID_ANDROID)
if (serviceData == null) {
serviceData = scanRecord.getServiceData(UUID_IOS)
}
if (serviceData == null || serviceData.size != 20) {
Log.d(TAG, "processDevice: Invalid service data, skipping")
return
}
Log.d(TAG, "Target device with EID: ${serviceData.joinToString("") { "%02x".format(it) }}")
if (CryptoHelper.decryptEid(serviceData, seed) == null) {
Log.d(TAG, "EID verification failed, not matching current session, continuing scan")
return
}
stopScanning()
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}"))
}
}
}
}
Loading