Skip to content

Commit 67cb36c

Browse files
FIDO: Add Hybrid connection (#3194)
Co-authored-by: Marvin W <git@larma.de>
1 parent f6014d2 commit 67cb36c

56 files changed

Lines changed: 3361 additions & 191 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

play-services-core/src/main/kotlin/org/microg/gms/auth/login/FidoHandler.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import org.microg.gms.fido.core.RequestHandlingException
1818
import org.microg.gms.fido.core.transport.Transport
1919
import org.microg.gms.fido.core.transport.TransportHandlerCallback
2020
import org.microg.gms.fido.core.transport.bluetooth.BluetoothTransportHandler
21+
import org.microg.gms.fido.core.transport.hybrid.HybridTransportHandler
2122
import org.microg.gms.fido.core.transport.nfc.NfcTransportHandler
2223
import org.microg.gms.fido.core.transport.screenlock.ScreenLockTransportHandler
2324
import org.microg.gms.fido.core.transport.usb.UsbTransportHandler
@@ -35,6 +36,7 @@ class FidoHandler(private val activity: LoginActivity) : TransportHandlerCallbac
3536
BluetoothTransportHandler(activity, this),
3637
NfcTransportHandler(activity, this),
3738
if (SDK_INT >= 21) UsbTransportHandler(activity, this) else null,
39+
if (SDK_INT >= 21) HybridTransportHandler(activity, this) else null,
3840
if (SDK_INT >= 23) ScreenLockTransportHandler(activity, this) else null
3941
)
4042
}
@@ -149,7 +151,7 @@ class FidoHandler(private val activity: LoginActivity) : TransportHandlerCallbac
149151
activity.lifecycleScope.launchWhenStarted {
150152
val options = requestOptions
151153
try {
152-
sendSuccessResult(transportHandler.start(options, activity.packageName), transport)
154+
sendSuccessResult(transportHandler.start(options, activity.packageName).response, transport)
153155
} catch (e: CancellationException) {
154156
Log.w(TAG, e)
155157
// Ignoring cancellation here

play-services-fido/core/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ dependencies {
3232
implementation "com.android.volley:volley:$volleyVersion"
3333
implementation 'com.upokecenter:cbor:4.5.2'
3434
implementation 'com.google.guava:guava:31.1-android'
35+
36+
implementation 'com.google.zxing:core:3.5.2'
37+
implementation 'com.squareup.okhttp3:okhttp:4.12.0'
3538
}
3639

3740
android {

play-services-fido/core/src/main/AndroidManifest.xml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@
1616
<uses-permission android:name="android.permission.START_ACTIVITIES_FROM_BACKGROUND"
1717
tools:ignore="ProtectedPermissions" />
1818

19+
<!-- Bluetooth permissions for FIDO2 cross-device authentication -->
20+
<uses-permission android:name="android.permission.BLUETOOTH" />
21+
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
22+
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
23+
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
24+
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
25+
<!-- Bluetooth hardware features for FIDO2 -->
26+
<uses-feature
27+
android:name="android.hardware.bluetooth"
28+
android:required="false" />
29+
<uses-feature
30+
android:name="android.hardware.bluetooth_le"
31+
android:required="false" />
32+
1933
<application>
2034
<service
2135
android:name=".privileged.Fido2PrivilegedService"
@@ -44,5 +58,24 @@
4458
<category android:name="android.intent.category.DEFAULT" />
4559
</intent-filter>
4660
</activity>
61+
<activity
62+
android:theme="@style/Theme.Translucent"
63+
android:name=".ui.hybrid.HybridAuthenticateActivity"
64+
android:enabled="true"
65+
android:exported="true"
66+
android:process=":ui"
67+
android:excludeFromRecents="true"
68+
android:configChanges="smallestScreenSize|screenSize|uiMode|screenLayout|orientation|keyboardHidden|keyboard"
69+
android:launchMode="singleTask"
70+
android:noHistory="true"
71+
tools:targetApi="23">
72+
<intent-filter android:icon="@drawable/ic_fido_passkey">
73+
<action android:name="android.intent.action.VIEW"/>
74+
<category android:name="android.intent.category.DEFAULT"/>
75+
<category android:name="android.intent.category.BROWSABLE"/>
76+
<data android:scheme="fido"/>
77+
<data android:scheme="FIDO" tools:ignore="AppLinkUrlError" />
78+
</intent-filter>
79+
</activity>
4780
</application>
4881
</manifest>

play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/Database.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,13 @@ class Database(context: Context) : SQLiteOpenHelper(context, "fido.db", null, VE
3535

3636
fun getKnownRegistrationInfo(rpId: String) = readableDatabase.use {
3737
val cursor = it.query(
38-
TABLE_KNOWN_REGISTRATIONS, arrayOf(COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT), "$COLUMN_RP_ID=?", arrayOf(rpId), null, null, null
38+
TABLE_KNOWN_REGISTRATIONS,
39+
arrayOf(COLUMN_CREDENTIAL_ID, COLUMN_REGISTER_USER, COLUMN_TRANSPORT),
40+
"$COLUMN_RP_ID=?",
41+
arrayOf(rpId),
42+
null,
43+
null,
44+
"$COLUMN_TIMESTAMP DESC"
3945
)
4046
val result = mutableListOf<CredentialUserInfo>()
4147
cursor.use { c ->

play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/RequestHandling.kt

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,6 @@ val RequestOptions.rpId: String
7373
SIGN -> signOptions.rpId
7474
}
7575

76-
val RequestOptions.user: String?
77-
get() = when (type) {
78-
REGISTER -> registerOptions.user.toJson()
79-
SIGN -> null
80-
}
81-
8276
val PublicKeyCredentialCreationOptions.skipAttestation: Boolean
8377
get() = attestationConveyancePreference in setOf(AttestationConveyancePreference.NONE, null)
8478

@@ -108,7 +102,10 @@ private suspend fun isFacetIdTrusted(context: Context, facetIds: Set<String>, ap
108102
return facetIds.any { trustedFacets.contains(it) }
109103
}
110104

111-
private const val ASSET_LINK_REL = "delegate_permission/common.get_login_creds"
105+
private val ASSET_LINK_REL = listOf(
106+
"delegate_permission/common.get_login_creds",
107+
"delegate_permission/common.handle_all_urls"
108+
)
112109
private suspend fun isAssetLinked(context: Context, rpId: String, fp: String, packageName: String?): Boolean {
113110
try {
114111
val deferred = CompletableDeferred<JSONArray>()
@@ -118,7 +115,7 @@ private suspend fun isAssetLinked(context: Context, rpId: String, fp: String, pa
118115
.add(JsonArrayRequest(url, { deferred.complete(it) }, { deferred.completeExceptionally(it) }))
119116
val arr = deferred.await()
120117
for (obj in arr.map(JSONArray::getJSONObject)) {
121-
if (!obj.getJSONArray("relation").map(JSONArray::getString).contains(ASSET_LINK_REL)) continue
118+
if (obj.getJSONArray("relation").map(JSONArray::getString).none { ASSET_LINK_REL.contains(it) }) continue
122119
val target = obj.getJSONObject("target")
123120
if (target.getString("namespace") != "android_app") continue
124121
if (packageName != null && target.getString("package_name") != packageName) continue
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.fido.core.hybrid.ble
7+
8+
import android.Manifest
9+
import android.bluetooth.BluetoothAdapter
10+
import android.bluetooth.le.AdvertiseCallback
11+
import android.bluetooth.le.AdvertiseData
12+
import android.bluetooth.le.AdvertiseSettings
13+
import android.os.Build
14+
import android.util.Log
15+
import androidx.annotation.RequiresApi
16+
import androidx.annotation.RequiresPermission
17+
import org.microg.gms.fido.core.hybrid.UUID_ANDROID
18+
import android.os.Handler
19+
import android.os.Looper
20+
import java.util.concurrent.atomic.AtomicBoolean
21+
22+
private const val TAG = "HybridBleAdvertiser"
23+
private const val ADVERTISE_TIMEOUT_MS = 20000L
24+
25+
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
26+
class HybridBleAdvertiser(
27+
private val bluetoothLeAdapter: BluetoothAdapter?,
28+
private val onAdvertiseFailure: ((Int) -> Unit)? = null
29+
) : AdvertiseCallback() {
30+
private val advertiserStatus = AtomicBoolean(false)
31+
private val handler = Handler(Looper.getMainLooper())
32+
private val stopRunnable = object : Runnable {
33+
@RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
34+
override fun run() {
35+
stopAdvertising()
36+
}
37+
}
38+
39+
private val bluetoothLeAdvertiser by lazy {
40+
if (bluetoothLeAdapter != null) {
41+
bluetoothLeAdapter.bluetoothLeAdvertiser
42+
} else {
43+
Log.d(TAG, "BLE_HARDWARE ERROR")
44+
null
45+
}
46+
}
47+
48+
@RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
49+
fun startAdvertising(eid: ByteArray) {
50+
if (advertiserStatus.compareAndSet(false, true)) {
51+
val settings = AdvertiseSettings.Builder()
52+
.setAdvertiseMode(AdvertiseSettings.ADVERTISE_MODE_LOW_LATENCY)
53+
.setTxPowerLevel(AdvertiseSettings.ADVERTISE_TX_POWER_HIGH)
54+
.setConnectable(false)
55+
.setTimeout(ADVERTISE_TIMEOUT_MS.toInt())
56+
.build()
57+
58+
val data = AdvertiseData.Builder()
59+
.addServiceUuid(UUID_ANDROID)
60+
.addServiceData(UUID_ANDROID, eid)
61+
.setIncludeDeviceName(false)
62+
.setIncludeTxPowerLevel(false)
63+
.build()
64+
65+
bluetoothLeAdvertiser?.startAdvertising(settings, data, this)
66+
67+
handler.postDelayed(stopRunnable, ADVERTISE_TIMEOUT_MS)
68+
}
69+
}
70+
71+
@RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE)
72+
fun stopAdvertising() {
73+
if (this.advertiserStatus.compareAndSet(true, false)) {
74+
handler.removeCallbacks(stopRunnable)
75+
Log.d(TAG, "BLE_ADVERTISING_STOP")
76+
bluetoothLeAdvertiser?.stopAdvertising(this)
77+
}
78+
}
79+
80+
override fun onStartSuccess(settingsInEffect: AdvertiseSettings?) {
81+
super.onStartSuccess(settingsInEffect)
82+
Log.d(TAG, String.format("BLE advertising onStartSuccess: %s", settingsInEffect))
83+
}
84+
85+
override fun onStartFailure(errorCode: Int) {
86+
super.onStartFailure(errorCode)
87+
Log.d(TAG, String.format("BLE advertising onStartFailure: %d", errorCode))
88+
advertiserStatus.set(false)
89+
handler.removeCallbacks(stopRunnable)
90+
onAdvertiseFailure?.invoke(errorCode)
91+
}
92+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 microG Project Team
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
package org.microg.gms.fido.core.hybrid.ble
7+
8+
import android.Manifest
9+
import android.annotation.SuppressLint
10+
import android.bluetooth.BluetoothAdapter
11+
import android.bluetooth.le.ScanCallback
12+
import android.bluetooth.le.ScanFilter
13+
import android.bluetooth.le.ScanResult
14+
import android.bluetooth.le.ScanSettings
15+
import android.os.Build
16+
import android.util.Log
17+
import androidx.annotation.RequiresApi
18+
import androidx.annotation.RequiresPermission
19+
import com.google.android.gms.fido.fido2.api.common.ErrorCode
20+
import org.microg.gms.fido.core.RequestHandlingException
21+
import org.microg.gms.fido.core.hybrid.EMPTY_SERVICE_DATA
22+
import org.microg.gms.fido.core.hybrid.EMPTY_SERVICE_DATA_MASK
23+
import org.microg.gms.fido.core.hybrid.UUID_ANDROID
24+
import org.microg.gms.fido.core.hybrid.UUID_IOS
25+
import org.microg.gms.fido.core.hybrid.utils.CryptoHelper
26+
import java.util.concurrent.atomic.AtomicBoolean
27+
28+
private const val TAG = "HybridClientScan"
29+
30+
@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
31+
class HybridClientScan(
32+
private val bluetoothLeAdapter: BluetoothAdapter?,
33+
private val seed: ByteArray,
34+
private val onScanSuccess: (ByteArray) -> Unit,
35+
private val onScanFailed: (Throwable) -> Unit
36+
) : ScanCallback() {
37+
38+
private val scanStatus = AtomicBoolean(false)
39+
40+
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
41+
override fun onScanResult(callbackType: Int, result: ScanResult) {
42+
val scanRecord = result.scanRecord
43+
if (scanRecord == null) {
44+
Log.d(TAG, "processDevice: ScanResult is missing ScanRecord")
45+
return
46+
}
47+
var serviceData = scanRecord.getServiceData(UUID_ANDROID)
48+
if (serviceData == null) {
49+
serviceData = scanRecord.getServiceData(UUID_IOS)
50+
}
51+
if (serviceData == null || serviceData.size != 20) {
52+
Log.d(TAG, "processDevice: Invalid service data, skipping")
53+
return
54+
}
55+
Log.d(TAG, "Target device with EID: ${serviceData.joinToString("") { "%02x".format(it) }}")
56+
if (CryptoHelper.decryptEid(serviceData, seed) == null) {
57+
Log.d(TAG, "EID verification failed, not matching current session, continuing scan")
58+
return
59+
}
60+
stopScanning()
61+
onScanSuccess(serviceData)
62+
}
63+
64+
override fun onScanFailed(errorCode: Int) {
65+
onScanFailed(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "BLE scan failed: $errorCode"))
66+
}
67+
68+
@SuppressLint("MissingPermission")
69+
fun startScanning() {
70+
if (scanStatus.compareAndSet(false, true)) {
71+
try {
72+
val adapter = bluetoothLeAdapter ?: throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR, "BluetoothAdapter null")
73+
if (!adapter.isEnabled) {
74+
val enabled = adapter.enable()
75+
if (!enabled) {
76+
throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR, "Unable to enable Bluetooth")
77+
}
78+
}
79+
val scanner = adapter.bluetoothLeScanner ?: throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR, "BluetoothLeScanner null")
80+
81+
val filters = listOf(
82+
ScanFilter.Builder().setServiceData(UUID_ANDROID, EMPTY_SERVICE_DATA, EMPTY_SERVICE_DATA_MASK).build(),
83+
ScanFilter.Builder().setServiceData(UUID_IOS, EMPTY_SERVICE_DATA, EMPTY_SERVICE_DATA_MASK).build()
84+
)
85+
val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build()
86+
scanner.startScan(filters, settings, this)
87+
Log.d(TAG, "BLE scanning started")
88+
} catch (t: Throwable) {
89+
onScanFailed(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "startScan failed: ${t.message}"))
90+
}
91+
}
92+
}
93+
94+
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
95+
fun stopScanning() {
96+
if (scanStatus.compareAndSet(true, false)) {
97+
try {
98+
val bluetoothLeScanner = bluetoothLeAdapter?.bluetoothLeScanner
99+
bluetoothLeScanner?.stopScan(this)
100+
Log.d(TAG, "BLE scanning stopped")
101+
} catch (t: Throwable) {
102+
onScanFailed(RequestHandlingException(ErrorCode.UNKNOWN_ERR, "stopScan failed: ${t.message}"))
103+
}
104+
}
105+
}
106+
}

0 commit comments

Comments
 (0)