diff --git a/play-services-core/src/main/kotlin/org/microg/gms/auth/login/FidoHandler.kt b/play-services-core/src/main/kotlin/org/microg/gms/auth/login/FidoHandler.kt index 24a12116a0..eff9243807 100644 --- a/play-services-core/src/main/kotlin/org/microg/gms/auth/login/FidoHandler.kt +++ b/play-services-core/src/main/kotlin/org/microg/gms/auth/login/FidoHandler.kt @@ -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 @@ -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 ) } @@ -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 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..57278cd9c4 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..e170888378 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 @@ -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) @@ -108,7 +102,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 +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 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..3d2312f294 --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridBleAdvertiser.kt @@ -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) + } +} \ 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..2b7b7ef5c0 --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/ble/HybridClientScan.kt @@ -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}")) + } + } + } +} \ 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..398d73226d --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/controller/HybridAuthenticatorController.kt @@ -0,0 +1,336 @@ +/** + * 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.AuthenticatorGetInfoResponse +import org.microg.gms.fido.core.protocol.msgs.AuthenticatorMakeCredentialRequest +import org.microg.gms.fido.core.protocol.msgs.Ctap2CommandCode +import org.microg.gms.fido.core.protocol.msgs.Ctap2Request +import org.microg.gms.fido.core.protocol.msgs.Ctap2Response +import java.io.ByteArrayInputStream +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, onFailure: (Int) -> Unit) { + bleAdvertiser = bleAdvertiser ?: HybridBleAdvertiser(adapter, onAdvertiseFailure = onFailure) + bleAdvertiser!!.startAdvertising(CryptoHelper.generateEid(eidKey, plaintext)) + } + + @RequiresPermission(Manifest.permission.BLUETOOTH_ADVERTISE) + suspend fun startAuth(qrCodeData: QrCodeData, handleAuthenticator: suspend (Ctap2Request) -> Ctap2Response?, 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) { errorCode -> + Log.w(TAG, "BLE advertise failed (errorCode=$errorCode), aborting connection") + if (!cont.isCompleted) cont.tryResumeWithError( + RequestHandlingException(ErrorCode.UNKNOWN_ERR, "BLE advertising failed: errorCode=$errorCode") + ) + } + 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 { bleAdvertiser?.stopAdvertising() } + 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 getInfoBytes = AuthenticatorGetInfoResponse( + versions = arrayListOf("FIDO_2_0", "FIDO_2_1"), + aaguid = ByteArray(16), + extensions = listOf("prf"), + options = AuthenticatorGetInfoResponse.Companion.Options( + residentKey = true, + userPresence = true, + userVerification = true, + platformDevice = false), + transports = listOf("internal", "cable") + ).encodePayload() + + Log.d(TAG, "GetInfo response size: ${getInfoBytes.size} bytes") + Log.d(TAG, "GetInfo response (hex): ${getInfoBytes.hex()}") + + val getInfoByteString = CBORObject.FromObject(getInfoBytes) + + 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) -> Ctap2Response?): 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) { + if (decrypted.size < 2) { + ws?.sendCtapResponse(byteArrayOf(CtapError.INVALID_CBOR.value)) + return null + } + val commandCode = Ctap2CommandCode.entries.find { it.byte == decrypted[1] } + if (commandCode == null) { + ws?.sendCtapResponse(byteArrayOf(CtapError.INVALID_COMMAND.value)) + return null + } + val params = if (decrypted.size > 2) { + try { + Log.d(TAG, "CBOR size: ${decrypted.size - 2} bytes") + CBORObject.Read(ByteArrayInputStream(decrypted, 2, decrypted.size - 2)) + } catch (e: Exception) { + Log.e(TAG, "Failed to parse CBOR parameters", e) + null + } + } else { + null + } + Log.d(TAG, "Command: $commandCode, parameters: $params") + val request = when (commandCode) { + Ctap2CommandCode.AuthenticatorMakeCredential -> { + if (params == null) { + ws?.sendCtapResponse(byteArrayOf(CtapError.MISSING_PARAMETER.value)) + return null + } + AuthenticatorMakeCredentialRequest.decodeFromCbor(params) + } + + Ctap2CommandCode.AuthenticatorGetAssertion -> { + if (params == null) { + ws?.sendCtapResponse(byteArrayOf(CtapError.MISSING_PARAMETER.value)) + return null + } + AuthenticatorGetAssertionRequest.decodeFromCbor(params) + } + + Ctap2CommandCode.AuthenticatorGetInfo -> { + AuthenticatorGetInfoRequest() + } + + else -> { + ws?.sendCtapResponse(byteArrayOf(CtapError.INVALID_COMMAND.value)) + return null + } + } + try { + val response = handleAuthenticator(request) + Log.d(TAG, "Request: $request") + Log.d(TAG, "Response: $response") + if (response == null) { + ws?.sendCtapResponse(byteArrayOf(CtapError.OTHER_ERROR.value)) + return null + } + val responseBytes = byteArrayOf(0) + response.encodePayloadAsCbor().EncodeToBytes() + ws?.sendCtapResponse(responseBytes) + return responseBytes + } catch (e: Exception) { + Log.w(TAG, "error handling request: ", e) + ws?.sendCtapResponse(byteArrayOf(CtapError.OTHER_ERROR.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) + } + } +} 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..24cd5a01aa --- /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(seed: ByteArray) = suspendCancellableCoroutine { cont -> + scan = HybridClientScan(adapter, seed, 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..2fbcd8107b --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/hybrid/model/QrCodeData.kt @@ -0,0 +1,277 @@ +/** + * 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 { + if (it.isEmpty()) return null + val cbor = runCatching { CBORObject.DecodeFromBytes(it) }.getOrNull() + if (cbor == null || 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/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/AttestationObject.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/AttestationObject.kt index 6e8a118e5c..352a834f8e 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/AttestationObject.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/AttestationObject.kt @@ -17,6 +17,18 @@ abstract class AttestationObject(val authData: ByteArray) { set("attStmt", attStmt) set("authData", authData.encodeAsCbor()) }.EncodeToBytes(CBOREncodeOptions.DefaultCtap2Canonical) + + companion object { + fun decode(bytes: ByteArray): AttestationObject = decodeFromCbor(CBORObject.DecodeFromBytes(bytes)) + + fun decodeFromCbor(obj: CBORObject): AttestationObject { + return AnyAttestationObject( + authData = obj["authData"].GetByteString(), + fmt = obj["fmt"]?.AsString() ?: "none", + attStmt = obj["attStmt"] + ) + } + } } class AnyAttestationObject(authData: ByteArray, override val fmt: String, override val attStmt: CBORObject) : AttestationObject(authData) 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..645a1cbca5 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,11 +44,17 @@ 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()) + set("name", name.encodeAsCbor()) if (!icon.isNullOrBlank()) set("icon", icon!!.encodeAsCbor()) - if (!displayName.isNullOrBlank()) set("displayName", displayName.encodeAsCbor()) + set("displayName", displayName.encodeAsCbor()) } fun CBORObject.decodeAsPublicKeyCredentialUserEntity() = PublicKeyCredentialUserEntity( diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/CredentialId.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/CredentialId.kt index 4f0bc5471c..23b5078139 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/CredentialId.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/CredentialId.kt @@ -10,6 +10,7 @@ import org.microg.gms.fido.core.digest import org.microg.gms.utils.toBase64 import java.nio.ByteBuffer import java.security.PublicKey +import java.util.Objects class CredentialId(val type: Byte, val data: ByteArray, val rpId: String, val publicKey: PublicKey) { fun encode(): ByteArray = ByteBuffer.allocate(1 + data.size + 32).apply { @@ -20,6 +21,22 @@ class CredentialId(val type: Byte, val data: ByteArray, val rpId: String, val pu fun toBase64(): String = encode().toBase64(Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CredentialId) return false + + if (type != other.type) return false + if (!data.contentEquals(other.data)) return false + if (rpId != other.rpId) return false + if (publicKey != other.publicKey) return false + + return true + } + + override fun hashCode(): Int { + return Objects.hashCode(arrayOf(type, data.contentHashCode(), rpId, publicKey)) + } + companion object { fun decodeTypeAndDataByBase64(base64: String): Pair { diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorClientPIN.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorClientPIN.kt index 9b0ee44164..2738306ff8 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorClientPIN.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/protocol/msgs/AuthenticatorClientPIN.kt @@ -22,7 +22,7 @@ class AuthenticatorClientPINRequest( val pinAuth: ByteArray? = null, val newPinEnc: ByteArray? = null, val pinHashEnc: ByteArray? = null -) : Ctap2Request(0x06, CBORObject.NewMap().apply { +) : Ctap2Request(Ctap2CommandCode.AuthenticatorClientPIN, CBORObject.NewMap().apply { set(0x01, pinProtocol.encodeAsCbor()) set(0x02, subCommand.encodeAsCbor()) if (keyAgreement != null) set(0x03, keyAgreement.encodeAsCbor()) @@ -55,7 +55,16 @@ class AuthenticatorClientPINResponse( val keyAgreement: CoseKey?, val pinToken: ByteArray?, val retries: Int? -) : Ctap2Response { +) : Ctap2Response() { + + override fun encodePayloadAsCbor(): CBORObject { + return CBORObject.NewMap().apply { + if (keyAgreement != null) set(0x01, keyAgreement.encodeAsCbor()) + if (pinToken != null) set(0x02, pinToken.encodeAsCbor()) + if (retries != null) set(0x02, retries.encodeAsCbor()) + } + } + companion object { fun decodeFromCbor(obj: CBORObject) = AuthenticatorClientPINResponse( obj.get(0x01)?.decodeAsCoseKey(), @@ -63,4 +72,4 @@ class AuthenticatorClientPINResponse( obj.get(0x03)?.AsInt32Value() ) } -} \ No newline at end of file +} 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..1735c8123d 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(Ctap2CommandCode.AuthenticatorGetAssertion, CBORObject.NewMap().apply { set(0x01, rpId.encodeAsCbor()) set(0x02, clientDataHash.encodeAsCbor()) if (allowList.isNotEmpty()) set(0x03, allowList.encodeAsCbor { it.encodeAsCbor() }) @@ -58,6 +58,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, + ) + }) } } @@ -67,7 +78,15 @@ class AuthenticatorGetAssertionResponse( val signature: ByteArray, val user: PublicKeyCredentialUserEntity?, val numberOfCredentials: Int? -) : Ctap2Response { +) : Ctap2Response() { + + override fun encodePayloadAsCbor() = CBORObject.NewMap().apply { + if (credential != null) set(0x01, credential.encodeAsCbor()) + set(0x02, authData.encodeAsCbor()) + set(0x03, signature.encodeAsCbor()) + if (user != null) set(0x04, user.encodeAsCbor()) + if (numberOfCredentials != null) set(0x05, numberOfCredentials.encodeAsCbor()) + } companion object { fun decodeFromCbor(obj: CBORObject) = AuthenticatorGetAssertionResponse( 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..0593e79a05 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,60 +10,113 @@ 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 : Ctap2Request(Ctap2CommandCode.AuthenticatorGetInfo) class AuthenticatorGetInfoResponse( - val versions: List, - val extensions: List, - val aaguid: ByteArray, - val options: Options, - val maxMsgSize: Int?, - val pinUvAuthProtocols: List, - val maxCredentialCountInList: Int?, - val maxCredentialIdLength: Int?, - val transports: List?, - val algorithms: List?, - val maxSerializedLargeBlobArray: Int?, - val forcePINChange: Boolean, - val minPINLength: Int?, - val firmwareVersion: Int?, - val maxCredBlobLength: Int?, - val maxRPIDsForSetMinPINLength: Int?, - val preferredPlatformUvAttempts: Int?, - val uvModality: Int?, - val certifications: Map?, - val remainingDiscoverableCredentials: Int?, - val vendorPrototypeConfigCommands: List?, -) : Ctap2Response { + val versions: List? = null, + val extensions: List? = null, + val aaguid: ByteArray? = null, + val options: Options? = null, + val maxMsgSize: Int? = null, + val pinUvAuthProtocols: List? = null, + val maxCredentialCountInList: Int? = null, + val maxCredentialIdLength: Int? = null, + val transports: List? = null, + val algorithms: List? = null, + val maxSerializedLargeBlobArray: Int? = null, + val forcePINChange: Boolean? = null, + val minPINLength: Int? = null, + val firmwareVersion: Int? = null, + val maxCredBlobLength: Int? = null, + val maxRPIDsForSetMinPINLength: Int? = null, + val preferredPlatformUvAttempts: Int? = null, + val uvModality: Int? = null, + val certifications: Map? = null, + val remainingDiscoverableCredentials: Int? = null, + val vendorPrototypeConfigCommands: List? = null, +) : Ctap2Response() { + + override fun encodePayloadAsCbor(): CBORObject = CBORObject.NewMap().apply { + versions?.encodeAsCbor { it.encodeAsCbor() }?.let { set(0x01, it) } + extensions?.encodeAsCbor { it.encodeAsCbor() }?.let { set(0x02, it) } + aaguid?.encodeAsCbor()?.let { set(0x03, it) } + options?.encodeAsCbor()?.let { set(0x04, it) } + maxMsgSize?.let { set(0x05, it.encodeAsCbor()) } + pinUvAuthProtocols?.encodeAsCbor { it.encodeAsCbor() }?.let { set(0x06, it) } + maxCredentialCountInList?.let { set(0x07, it.encodeAsCbor()) } + maxCredentialIdLength?.let { set(0x08, it.encodeAsCbor()) } + transports?.encodeAsCbor { it.encodeAsCbor() }?.let { set(0x09, it) } + algorithms?.encodeAsCbor { it.encodeAsCbor() }?.let { set(0x0a, it) } + maxSerializedLargeBlobArray?.let { set(0x0b, it.encodeAsCbor()) } + forcePINChange?.let { set(0x0c, it.encodeAsCbor()) } + minPINLength?.let { set(0x0d, it.encodeAsCbor()) } + firmwareVersion?.let { set(0x0e, it.encodeAsCbor()) } + maxCredBlobLength?.let { set(0x0f, it.encodeAsCbor()) } + maxRPIDsForSetMinPINLength?.let { set(0x10, it.encodeAsCbor()) } + preferredPlatformUvAttempts?.let { set(0x11, it.encodeAsCbor()) } + uvModality?.let { set(0x12, it.encodeAsCbor()) } + certifications?.let { map -> + CBORObject.NewMap().apply { + map.forEach { (key, value) -> + set(key.encodeAsCbor(), value.encodeAsCbor()) + } + } + }?.let { set(0x13, it) } + remainingDiscoverableCredentials?.let { set(0x14, it.encodeAsCbor()) } + vendorPrototypeConfigCommands?.encodeAsCbor { it.encodeAsCbor() }?.let { set(0x15, it) } + } companion object { class Options( - val platformDevice: Boolean, - val residentKey: Boolean, - val clientPin: Boolean?, - val userPresence: Boolean, - val userVerification: Boolean?, - val pinUvAuthToken: Boolean?, - val noMcGaPermissionsWithClientPin: Boolean, - val largeBlobs: Boolean?, - val enterpriseAttestation: Boolean?, - val bioEnroll: Boolean?, - val userVerificationMgmtPreview: Boolean?, - val uvBioEnroll: Boolean?, - val authenticatorConfigSupported: Boolean?, - val uvAcfg: Boolean?, - val credentialManagementSupported: Boolean?, - val credentialMgmtPreview: Boolean?, - val setMinPINLengthSupported: Boolean?, - val makeCredUvNotRqd: Boolean, - val alwaysUv: Boolean?, + val platformDevice: Boolean? = null, + val residentKey: Boolean? = null, + val clientPin: Boolean? = null, + val userPresence: Boolean? = null, + val userVerification: Boolean? = null, + val pinUvAuthToken: Boolean? = null, + val noMcGaPermissionsWithClientPin: Boolean? = null, + val largeBlobs: Boolean? = null, + val enterpriseAttestation: Boolean? = null, + val bioEnroll: Boolean? = null, + val userVerificationMgmtPreview: Boolean? = null, + val uvBioEnroll: Boolean? = null, + val authenticatorConfigSupported: Boolean? = null, + val uvAcfg: Boolean? = null, + val credentialManagementSupported: Boolean? = null, + val credentialMgmtPreview: Boolean? = null, + val setMinPINLengthSupported: Boolean? = null, + val makeCredUvNotRqd: Boolean? = null, + val alwaysUv: Boolean? = null, ) { + fun encodeAsCbor(): CBORObject = CBORObject.NewMap().apply { + if (platformDevice != null) set("plat", platformDevice.encodeAsCbor()) + if (residentKey != null) set("rk", residentKey.encodeAsCbor()) + if (clientPin != null) set("clientPin", clientPin.encodeAsCbor()) + if (userPresence != null) set("up", userPresence.encodeAsCbor()) + if (userVerification != null) set("uv", userVerification.encodeAsCbor()) + if (pinUvAuthToken != null) set("pinUvAuthToken", pinUvAuthToken.encodeAsCbor()) + if (noMcGaPermissionsWithClientPin != null) set("noMcGaPermissionsWithClientPin", noMcGaPermissionsWithClientPin.encodeAsCbor()) + if (largeBlobs != null) set("largeBlobs", largeBlobs.encodeAsCbor()) + if (enterpriseAttestation != null) set("ep", enterpriseAttestation.encodeAsCbor()) + if (bioEnroll != null) set("bioEnroll", bioEnroll.encodeAsCbor()) + if (userVerificationMgmtPreview != null) set("userVerificationMgmtPreview", userVerificationMgmtPreview.encodeAsCbor()) + if (uvBioEnroll != null) set("uvBioEnroll", uvBioEnroll.encodeAsCbor()) + if (authenticatorConfigSupported != null) set("authnrCfg", authenticatorConfigSupported.encodeAsCbor()) + if (uvAcfg != null) set("uvAcfg", uvAcfg.encodeAsCbor()) + if (credentialManagementSupported != null) set("credMgmt", credentialManagementSupported.encodeAsCbor()) + if (credentialMgmtPreview != null) set("credentialMgmtPreview", credentialMgmtPreview.encodeAsCbor()) + if (setMinPINLengthSupported != null) set("setMinPINLength", setMinPINLengthSupported.encodeAsCbor()) + if (makeCredUvNotRqd != null) set("makeCredUvNotRqd", makeCredUvNotRqd.encodeAsCbor()) + if (alwaysUv != null) set("alwaysUv", alwaysUv.encodeAsCbor()) + } + companion object { fun decodeFromCbor(map: CBORObject?) = Options( platformDevice = map?.get("plat")?.AsBoolean() == true, 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..012a89b7cb 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 @@ -29,9 +33,11 @@ class AuthenticatorMakeCredentialRequest( val excludeList: List = emptyList(), val extensions: Map = emptyMap(), val options: Options? = null, - val pinAuth: ByteArray? = null, - val pinProtocol: Int? = null -) : Ctap2Request(0x01, CBORObject.NewMap().apply { + val pinUvAuthParam: ByteArray? = null, + val pinUvAuthProtocol: Int? = null, + val enterpriseAttestation: Int? = null, + val attestationFormatsPreference: List = emptyList(), +) : Ctap2Request(Ctap2CommandCode.AuthenticatorMakeCredential, CBORObject.NewMap().apply { set(0x01, clientDataHash.encodeAsCbor()) set(0x02, rp.encodeAsCbor()) set(0x03, user.encodeAsCbor()) @@ -39,25 +45,49 @@ class AuthenticatorMakeCredentialRequest( if (excludeList.isNotEmpty()) set(0x05, excludeList.encodeAsCbor { it.encodeAsCbor() }) if (extensions.isNotEmpty()) set(0x06, extensions.encodeAsCbor { it }) if (options != null) set(0x07, options.encodeAsCbor()) - if (pinAuth != null) set(0x08, pinAuth.encodeAsCbor()) - if (pinProtocol != null) set(0x09, pinProtocol.encodeAsCbor()) + if (pinUvAuthParam != null) set(0x08, pinUvAuthParam.encodeAsCbor()) + if (pinUvAuthProtocol != null) set(0x09, pinUvAuthProtocol.encodeAsCbor()) + if (enterpriseAttestation != null) set(0x0A, enterpriseAttestation.encodeAsCbor()) + if (attestationFormatsPreference.isNotEmpty()) set(0x0B, attestationFormatsPreference.encodeAsCbor { it.encodeAsCbor() }) }) { override fun toString() = "AuthenticatorMakeCredentialRequest(clientDataHash=0x${clientDataHash.toBase64(Base64.NO_WRAP)}, " + "rp=$rp,user=$user,pubKeyCredParams=[${pubKeyCredParams.joinToString()}]," + "excludeList=[${excludeList.joinToString()}],extensions=[${extensions.entries.joinToString()}]," + - "options=$options,pinAuth=${pinAuth?.toBase64(Base64.NO_WRAP)},pinProtocol=$pinProtocol)" + "options=$options,pinAuth=${pinUvAuthParam?.toBase64(Base64.NO_WRAP)},pinProtocol=$pinUvAuthProtocol)" companion object { class Options( val residentKey: Boolean = false, + val userPresence: Boolean = true, val userVerification: Boolean = false ) { fun encodeAsCbor() = CBORObject.NewMap().apply { // Only encode non-default values if (residentKey) set("rk", residentKey.encodeAsCbor()) + if (!userPresence) set("up", userPresence.encodeAsCbor()) 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() }.orEmpty(), + extensions = obj.get(0x06)?.let { extObj -> extObj.keys.associate { it.AsString() to extObj.get(it) } }.orEmpty(), + options = obj.get(0x07)?.let { optObj -> + Options( + residentKey = optObj["rk"]?.AsBoolean() ?: false, + userPresence = optObj["up"]?.AsBoolean() ?: true, + userVerification = optObj["uv"]?.AsBoolean() ?: false, + ) + }, + pinUvAuthParam = obj.get(0x08)?.GetByteString(), + pinUvAuthProtocol = obj.get(0x09)?.AsInt32Value(), + enterpriseAttestation = obj.get(0x0A)?.AsInt32Value(), + attestationFormatsPreference = obj.get(0x0B)?.values?.map { it.AsString() }.orEmpty(), + ) } } @@ -65,7 +95,13 @@ class AuthenticatorMakeCredentialResponse( val authData: ByteArray, val fmt: String, val attStmt: CBORObject -) : Ctap2Response { +) : Ctap2Response() { + override fun encodePayloadAsCbor() = CBORObject.NewMap().apply { + set(0x01, fmt.encodeAsCbor()) + set(0x02, authData.encodeAsCbor()) + 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..f8b065ed7e 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 @@ -5,10 +5,8 @@ package org.microg.gms.fido.core.protocol.msgs -import android.util.Base64 import com.upokecenter.cbor.CBOREncodeOptions import com.upokecenter.cbor.CBORObject -import org.microg.gms.utils.toBase64 import java.io.ByteArrayInputStream import java.io.InputStream @@ -23,11 +21,33 @@ abstract class Ctap2Command(val request: Q) { abstract fun decodeResponse(obj: CBORObject): R } -interface Ctap2Response +abstract class Ctap2Response { + abstract fun encodePayloadAsCbor(): CBORObject + fun encodePayload(): ByteArray = encodePayloadAsCbor().EncodeToBytes() -abstract class Ctap2Request(val commandByte: Byte, val parameters: CBORObject? = null) { - val payload: ByteArray = parameters?.EncodeToBytes(CBOREncodeOptions.DefaultCtap2Canonical) ?: ByteArray(0) + override fun toString(): String = "Ctap2Response(${encodePayloadAsCbor()})" +} + +abstract class Ctap2Request(val commandCode: Ctap2CommandCode, val parameters: CBORObject? = null) { + val commandByte: Byte + get() = commandCode.byte + + open fun encodeParametersAsCbor() = parameters + fun encodeParameters(): ByteArray = encodeParametersAsCbor()?.EncodeToBytes(CBOREncodeOptions.DefaultCtap2Canonical) ?: ByteArray(0) + + override fun toString(): String = "Ctap2Request(command=0x${commandByte.toString(16)}, parameters=$parameters)" +} - override fun toString(): String = "Ctap2Request(command=0x${commandByte.toString(16)}, " + - "payload=${payload.toBase64(Base64.NO_WRAP)})" +enum class Ctap2CommandCode(val byte: Byte) { + AuthenticatorMakeCredential(0x01), + AuthenticatorGetAssertion(0x02), + AuthenticatorGetNextAssertion(0x08), + AuthenticatorGetInfo(0x04), + AuthenticatorClientPIN(0x06), + AuthenticatorReset(0x07), + AuthenticatorBioEnrollment(0x09), + AuthenticatorCredentialManagement(0x0a), + AuthenticatorSelection(0x0b), + AuthenticatorLargeBlobs(0x0b), + AuthenticatorConfig(0x0d), } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/Transport.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/Transport.kt index 8cd45f11a2..38fba7bcb9 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/Transport.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/Transport.kt @@ -9,5 +9,6 @@ enum class Transport { BLUETOOTH, NFC, USB, - SCREEN_LOCK + SCREEN_LOCK, + HYBRID } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/TransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/TransportHandler.kt index 222c18b4ba..d1b4a664fa 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/TransportHandler.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/TransportHandler.kt @@ -8,7 +8,6 @@ package org.microg.gms.fido.core.transport import android.content.Context import android.os.Build.VERSION.SDK_INT import android.os.Bundle -import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Log import androidx.annotation.RequiresApi @@ -40,11 +39,13 @@ import javax.crypto.Mac import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +class AuthenticatorResponseWithUser(val response: T, val user: PublicKeyCredentialUserEntity?) + abstract class TransportHandler(val transport: Transport, val callback: TransportHandlerCallback?) { open val isSupported: Boolean get() = false - open suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean = false, pin: String? = null, userInfo: String? = null): AuthenticatorResponse = + open suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean = false, pin: String? = null, user: PublicKeyCredentialUserEntity? = null): AuthenticatorResponseWithUser<*> = throw RequestHandlingException(ErrorCode.NOT_SUPPORTED_ERR) open fun shouldBeUsedInstantly(options: RequestOptions): Boolean = false @@ -87,8 +88,8 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor val ctap2RequireVerification = requireUserVerification && (pinToken == null) val reqOptions = AuthenticatorMakeCredentialRequest.Companion.Options( - requireResidentKey, - ctap2RequireVerification + residentKey = requireResidentKey, + userVerification = ctap2RequireVerification ) val extensions = mutableMapOf() @@ -197,7 +198,7 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor callerPackage: String, pinRequested: Boolean, pin: String? - ): AuthenticatorAttestationResponse { + ): AuthenticatorResponseWithUser { val (clientData, clientDataHash) = getClientDataAndHash(context, options, callerPackage) val requireResidentKey = when (options.registerOptions.authenticatorSelection?.residentKeyRequirement) { @@ -253,11 +254,14 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor connection.hasCtap1Support -> ctap1register(connection, options, clientDataHash) else -> throw IllegalStateException() } - return AuthenticatorAttestationResponse( - keyHandle ?: ByteArray(0).also { Log.w(TAG, "keyHandle was null") }, - clientData, - AnyAttestationObject(response.authData, response.fmt, response.attStmt).encode(), - connection.transports.toTypedArray() + return AuthenticatorResponseWithUser( + AuthenticatorAttestationResponse( + keyHandle ?: ByteArray(0).also { Log.w(TAG, "keyHandle was null") }, + clientData, + AnyAttestationObject(response.authData, response.fmt, response.attStmt).encode(), + connection.transports.toTypedArray() + ), + options.registerOptions.user ) } @@ -451,7 +455,7 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor callerPackage: String, pinRequested: Boolean, pin: String? - ): AuthenticatorAssertionResponse { + ): AuthenticatorResponseWithUser { val (clientData, clientDataHash) = getClientDataAndHash(context, options, callerPackage) val (response, credentialId) = when { @@ -512,11 +516,14 @@ abstract class TransportHandler(val transport: Transport, val callback: Transpor connection.hasCtap1Support -> ctap1sign(connection, options, clientDataHash) else -> throw IllegalStateException() } - return AuthenticatorAssertionResponse( - credentialId ?: ByteArray(0).also { Log.w(TAG, "keyHandle was null") }, - clientData, - response.authData, - response.signature, + return AuthenticatorResponseWithUser( + AuthenticatorAssertionResponse( + credentialId ?: ByteArray(0).also { Log.w(TAG, "keyHandle was null") }, + clientData, + response.authData, + response.signature, + null + ), null ) } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/hybrid/HybridTransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/hybrid/HybridTransportHandler.kt new file mode 100644 index 0000000000..ddafc4019d --- /dev/null +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/hybrid/HybridTransportHandler.kt @@ -0,0 +1,143 @@ +/* + * SPDX-FileCopyrightText: 2026 microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.fido.core.transport.hybrid + +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 androidx.core.content.getSystemService +import androidx.core.os.bundleOf +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.PublicKeyCredentialUserEntity +import com.google.android.gms.fido.fido2.api.common.RequestOptions +import com.google.android.gms.fido.fido2.api.common.UserVerificationRequirement +import com.upokecenter.cbor.CBORObject +import org.microg.gms.fido.core.RequestOptionsType +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.protocol.AnyAttestationObject +import org.microg.gms.fido.core.protocol.AuthenticatorData +import org.microg.gms.fido.core.protocol.msgs.AuthenticatorGetAssertionRequest +import org.microg.gms.fido.core.protocol.msgs.AuthenticatorGetAssertionResponse +import org.microg.gms.fido.core.protocol.msgs.AuthenticatorMakeCredentialRequest +import org.microg.gms.fido.core.protocol.msgs.AuthenticatorMakeCredentialResponse +import org.microg.gms.fido.core.registerOptions +import org.microg.gms.fido.core.signOptions +import org.microg.gms.fido.core.transport.AuthenticatorResponseWithUser +import org.microg.gms.fido.core.transport.Transport +import org.microg.gms.fido.core.transport.TransportHandler +import org.microg.gms.fido.core.transport.TransportHandlerCallback +import org.microg.gms.fido.core.type + +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +class HybridTransportHandler(private val context: Context, callback: TransportHandlerCallback? = null) : + TransportHandler(Transport.HYBRID, callback) { + override val isSupported: Boolean + get() = context.getSystemService()?.adapter != null + + @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN) + override suspend fun start( + options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity? + ): AuthenticatorResponseWithUser<*> { + val staticKey = generateEcKeyPair() + val hybridClientController = HybridClientController(context, staticKey) + try { + callback?.onStatusChanged( + Transport.HYBRID, "QR_CODE_READY", + bundleOf("qrCodeBitmap" to QrCodeData.generateQrCode(staticKey.first, options.challenge)) + ) + val eid = hybridClientController.startBluetoothScan(options.challenge) + callback?.onStatusChanged(Transport.HYBRID, "CONNECTING", null) + + val (clientData, clientDataHash) = getClientDataAndHash(context, options, callerPackage) + val tunnelResp = hybridClientController.startClientTunnel(eid, options.challenge) { + Log.d(TAG, "start: options: $options") + val request = when (options.type) { + RequestOptionsType.REGISTER -> { + val reqOptions = options.registerOptions.authenticatorSelection?.let { + val rk = (it.requireResidentKey == true || it.residentKeyRequirement?.toString() == UserVerificationRequirement.REQUIRED.name) + val uv = (it.requireUserVerification == UserVerificationRequirement.REQUIRED) + AuthenticatorMakeCredentialRequest.Companion.Options(residentKey = rk, userVerification = uv) + } + AuthenticatorMakeCredentialRequest( + clientDataHash = clientDataHash, + rp = options.registerOptions.rp, + user = options.registerOptions.user, + pubKeyCredParams = options.registerOptions.parameters, + excludeList = options.registerOptions.excludeList ?: emptyList(), + options = reqOptions, + ) + } + + RequestOptionsType.SIGN -> { + AuthenticatorGetAssertionRequest( + rpId = options.signOptions.rpId, + clientDataHash = clientDataHash, + allowList = options.signOptions.allowList.orEmpty(), + options = if (options.signOptions.requireUserVerification == UserVerificationRequirement.REQUIRED) { + AuthenticatorGetAssertionRequest.Companion.Options(userVerification = true) + } else null + ) + } + + } + request.let { byteArrayOf(0x01, it.commandByte) + it.encodeParameters() } + } + + 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): AuthenticatorResponseWithUser<*> { + 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.type) { + RequestOptionsType.REGISTER -> { + val result = AuthenticatorMakeCredentialResponse.decodeFromCbor(CBORObject.DecodeFromBytes(cbor)) + val credentialId = AuthenticatorData.decode(result.authData).attestedCredentialData?.id + AuthenticatorResponseWithUser( + AuthenticatorAttestationResponse( + credentialId ?: ByteArray(0), + clientData, + AnyAttestationObject(result.authData, result.fmt, result.attStmt).encode(), + arrayOf("cable", "internal") + ), + options.registerOptions.user + ) + } + RequestOptionsType.SIGN -> { + val result = AuthenticatorGetAssertionResponse.decodeFromCbor(CBORObject.DecodeFromBytes(cbor)) + AuthenticatorResponseWithUser( + AuthenticatorAssertionResponse( + result.credential!!.id, + clientData, + result.authData, + result.signature, + result.user?.id + ), + result.user + ) + } + } + } +} diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/CtapNfcConnection.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/CtapNfcConnection.kt index 37a5f4bcbe..7c2b647076 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/CtapNfcConnection.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/CtapNfcConnection.kt @@ -37,7 +37,7 @@ class CtapNfcConnection( override suspend fun runCommand(command: Ctap2Command): S { require(hasCtap2Support) - val request = encodeCommandApdu(0x80.toByte(), 0x10, 0x00, 0x00, byteArrayOf(command.request.commandByte) + command.request.payload, extended = true) + val request = encodeCommandApdu(0x80.toByte(), 0x10, 0x00, 0x00, byteArrayOf(command.request.commandByte) + command.request.encodeParameters(), extended = true) Log.d(TAG, "Send CTAP2 command: ${request.toBase64(Base64.NO_WRAP)} (${command.request.commandByte} - ${command.request.parameters})") var (statusCode, payload) = decodeResponseApdu(isoDep.transceive(request)) Log.d(TAG, "Received CTAP2 response(${(statusCode.toInt() and 0xffff).toString(16)}): ${payload.toBase64(Base64.NO_WRAP)}") @@ -74,10 +74,10 @@ class CtapNfcConnection( val response = runCommand(AuthenticatorGetInfoCommand()) Log.d(TAG, "Got info: $response") capabilities = capabilities or CAPABILITY_CTAP_2 or - (if (response.versions.contains("FIDO_2_1")) CAPABILITY_CTAP_2_1 else 0) or - (if (response.options.clientPin == true) CAPABILITY_CLIENT_PIN else 0) or - (if (response.options.userVerification == true) CAPABILITY_USER_VERIFICATION else 0) or - (if (response.options.residentKey == true) CAPABILITY_RESIDENT_KEY else 0) + (if (response.versions?.contains("FIDO_2_1") == true) CAPABILITY_CTAP_2_1 else 0) or + (if (response.options?.clientPin == true) CAPABILITY_CLIENT_PIN else 0) or + (if (response.options?.userVerification == true) CAPABILITY_USER_VERIFICATION else 0) or + (if (response.options?.residentKey == true) CAPABILITY_RESIDENT_KEY else 0) if (response.transports != null) transports = response.transports } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt index da0e05b3b2..dceb7189e7 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/nfc/NfcTransportHandler.kt @@ -21,12 +21,14 @@ import androidx.core.util.Consumer 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.PublicKeyCredentialUserEntity import com.google.android.gms.fido.fido2.api.common.RequestOptions import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import org.microg.gms.fido.core.MissingPinException import org.microg.gms.fido.core.RequestOptionsType import org.microg.gms.fido.core.WrongPinException +import org.microg.gms.fido.core.transport.AuthenticatorResponseWithUser import org.microg.gms.fido.core.transport.Transport import org.microg.gms.fido.core.transport.TransportHandler import org.microg.gms.fido.core.transport.TransportHandlerCallback @@ -70,7 +72,7 @@ class NfcTransportHandler(private val activity: Activity, callback: TransportHan tag: Tag, pinRequested: Boolean, pin: String? - ): AuthenticatorAttestationResponse { + ): AuthenticatorResponseWithUser { return CtapNfcConnection(activity, tag).open { register(it, activity, options, callerPackage, pinRequested, pin) } @@ -82,7 +84,7 @@ class NfcTransportHandler(private val activity: Activity, callback: TransportHan tag: Tag, pinRequested: Boolean, pin: String? - ): AuthenticatorAssertionResponse { + ): AuthenticatorResponseWithUser { return CtapNfcConnection(activity, tag).open { sign(it, activity, options, callerPackage, pinRequested, pin) } @@ -95,7 +97,7 @@ class NfcTransportHandler(private val activity: Activity, callback: TransportHan tag: Tag, pinRequested: Boolean, pin: String? - ): AuthenticatorResponse { + ): AuthenticatorResponseWithUser<*> { return when (options.type) { RequestOptionsType.REGISTER -> register(options, callerPackage, tag, pinRequested, pin) RequestOptionsType.SIGN -> sign(options, callerPackage, tag, pinRequested, pin) @@ -103,7 +105,7 @@ class NfcTransportHandler(private val activity: Activity, callback: TransportHan } - override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, userInfo: String?): AuthenticatorResponse { + override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?): AuthenticatorResponseWithUser<*> { val adapter = NfcAdapter.getDefaultAdapter(activity) val newIntentListener = Consumer { if (it?.action != NfcAdapter.ACTION_TECH_DISCOVERED) return@Consumer diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockTransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockTransportHandler.kt index ff72cf5569..04ba68c66b 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockTransportHandler.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/screenlock/ScreenLockTransportHandler.kt @@ -20,6 +20,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine import org.microg.gms.common.Constants import org.microg.gms.fido.core.* import org.microg.gms.fido.core.protocol.* +import org.microg.gms.fido.core.transport.AuthenticatorResponseWithUser import org.microg.gms.fido.core.transport.Transport import org.microg.gms.fido.core.transport.TransportHandler import org.microg.gms.fido.core.transport.TransportHandlerCallback @@ -107,7 +108,7 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac suspend fun register( options: RequestOptions, callerPackage: String - ): AuthenticatorAttestationResponse { + ): AuthenticatorResponseWithUser { if (options.type != RequestOptionsType.REGISTER) throw RequestHandlingException(ErrorCode.INVALID_STATE_ERR) val knownRegistrationInfo = database.getKnownRegistrationInfo(options.rpId) for (descriptor in options.registerOptions.excludeList.orEmpty()) { @@ -151,11 +152,14 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac } } - return AuthenticatorAttestationResponse( - credentialId.encode(), - clientData, - attestationObject.encode(), - arrayOf("internal") + return AuthenticatorResponseWithUser( + AuthenticatorAttestationResponse( + credentialId.encode(), + clientData, + attestationObject.encode(), + arrayOf("internal") + ), + options.registerOptions.user ) } @@ -194,30 +198,24 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac suspend fun sign( options: RequestOptions, callerPackage: String, - userInfo: String? - ): AuthenticatorAssertionResponse { + user: PublicKeyCredentialUserEntity? + ): AuthenticatorResponseWithUser { if (options.type != RequestOptionsType.SIGN) throw RequestHandlingException(ErrorCode.INVALID_STATE_ERR) - val candidates = mutableListOf() - for (descriptor in options.signOptions.allowList.orEmpty()) { - try { - val (type, data) = CredentialId.decodeTypeAndData(descriptor.id) - if (type == 1.toByte() && store.containsKey(options.rpId, data)) { - candidates.add(CredentialId(type, data, options.rpId, store.getPublicKey(options.rpId, data)!!)) - } - } catch (e: Exception) { - // Not in store or unknown id - } - } + if (!options.signOptions.allowList.isNullOrEmpty() && user != null) throw RequestHandlingException(ErrorCode.NOT_ALLOWED_ERR) val knownRegistrationInfo = database.getKnownRegistrationInfo(options.rpId) - candidates.ifEmpty { - knownRegistrationInfo.mapNotNull { - val (type, data) = CredentialId.decodeTypeAndDataByBase64(it.credential) - if (type == 1.toByte() && store.containsKey(options.rpId, data)) { - CredentialId(type, data, options.rpId, store.getPublicKey(options.rpId, data)!!) - } else null - }.forEach { - candidates.add(it) - } + .filter { it.transport == Transport.SCREEN_LOCK } + .associateBy { runCatching { CredentialId.decodeTypeAndDataByBase64(it.credential) }.getOrNull() } + .filterKeys { it != null && it.first == 1.toByte() && store.containsKey(options.rpId, it.second) } + .mapKeys { CredentialId(it.key!!.first, it.key!!.second, options.rpId, store.getPublicKey(options.rpId, it.key!!.second)!!) } + val candidates = if (options.signOptions.allowList.isNullOrEmpty()) { + knownRegistrationInfo + .filterValues { user == null || PublicKeyCredentialUserEntity.parseJson(it.userJson).id.contentEquals(user.id) } + } else { + options.signOptions.allowList.orEmpty() + .mapNotNull { runCatching { CredentialId.decodeTypeAndData(it.id) }.getOrNull() } + .filter { it.first == 1.toByte() && store.containsKey(options.rpId, it.second) } + .map { CredentialId(it.first, it.second, options.rpId, store.getPublicKey(options.rpId, it.second)!!) } + .associateWith { knownRegistrationInfo[it] } } if (candidates.isEmpty()) { // Show a biometric prompt even if no matching key to effectively rate-limit @@ -227,13 +225,9 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac "Cannot find credential in local KeyStore or database" ) } - + val (credentialId, credentialUserInfo) = candidates.entries.first() + val actualUser = credentialUserInfo?.let { PublicKeyCredentialUserEntity.parseJson(it.userJson) } val (clientData, clientDataHash) = getClientDataAndHash(activity, options, callerPackage) - val credentialUserInfo = if (userInfo != null) { - knownRegistrationInfo.firstOrNull { it.userJson == userInfo } - } else knownRegistrationInfo.firstOrNull() - val userHandle = credentialUserInfo?.let { PublicKeyCredentialUserEntity.parseJson(it.userJson).id } - val credentialId = candidates.firstOrNull { credentialUserInfo?.credential != null && credentialUserInfo.credential == it.toBase64() } ?: candidates.first() val keyId = credentialId.data val authenticatorData = getAuthenticatorData(options.rpId, null) @@ -241,20 +235,23 @@ class ScreenLockTransportHandler(private val activity: FragmentActivity, callbac signature.update(authenticatorData.encode() + clientDataHash) val sig = signature.sign() - return AuthenticatorAssertionResponse( - credentialId.encode(), - clientData, - authenticatorData.encode(), - sig, - userHandle + return AuthenticatorResponseWithUser( + AuthenticatorAssertionResponse( + credentialId.encode(), + clientData, + authenticatorData.encode(), + sig, + actualUser?.id + ), + actualUser ) } @RequiresApi(24) - override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, userInfo: String?): AuthenticatorResponse = + override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?): AuthenticatorResponseWithUser<*> = when (options.type) { RequestOptionsType.REGISTER -> register(options, callerPackage) - RequestOptionsType.SIGN -> sign(options, callerPackage, userInfo) + RequestOptionsType.SIGN -> sign(options, callerPackage, user) } override fun shouldBeUsedInstantly(options: RequestOptions): Boolean { diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbTransportHandler.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbTransportHandler.kt index 9074d74f9c..f7db2bba45 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbTransportHandler.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/UsbTransportHandler.kt @@ -23,6 +23,7 @@ import com.google.android.gms.fido.fido2.api.common.* import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CompletableDeferred import org.microg.gms.fido.core.* +import org.microg.gms.fido.core.transport.AuthenticatorResponseWithUser import org.microg.gms.fido.core.transport.Transport import org.microg.gms.fido.core.transport.TransportHandler import org.microg.gms.fido.core.transport.TransportHandlerCallback @@ -80,7 +81,7 @@ class UsbTransportHandler(private val context: Context, callback: TransportHandl iface: UsbInterface, pinRequested: Boolean, pin: String? - ): AuthenticatorAttestationResponse { + ): AuthenticatorResponseWithUser { return CtapHidConnection(context, device, iface).open { register(it, context, options, callerPackage, pinRequested, pin) } @@ -93,7 +94,7 @@ class UsbTransportHandler(private val context: Context, callback: TransportHandl iface: UsbInterface, pinRequested: Boolean, pin: String? - ): AuthenticatorAssertionResponse { + ): AuthenticatorResponseWithUser { return CtapHidConnection(context, device, iface).open { sign(it, context, options, callerPackage, pinRequested, pin) } @@ -122,7 +123,7 @@ class UsbTransportHandler(private val context: Context, callback: TransportHandl iface: UsbInterface, pinRequested: Boolean, pin: String? - ): AuthenticatorResponse { + ): AuthenticatorResponseWithUser<*> { Log.d(TAG, "Trying to use ${device.productName} for ${options.type}") invokeStatusChanged( TransportHandlerCallback.STATUS_WAITING_FOR_USER, @@ -137,7 +138,7 @@ class UsbTransportHandler(private val context: Context, callback: TransportHandl } } - override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, userInfo: String?): AuthenticatorResponse { + override suspend fun start(options: RequestOptions, callerPackage: String, pinRequested: Boolean, pin: String?, user: PublicKeyCredentialUserEntity?): AuthenticatorResponseWithUser<*> { for (device in context.usbManager?.deviceList?.values.orEmpty()) { val iface = getCtapHidInterface(device) ?: continue try { diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidConnection.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidConnection.kt index 16bd25cca5..5193a266ee 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidConnection.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidConnection.kt @@ -76,10 +76,10 @@ class CtapHidConnection( val response = runCommand(AuthenticatorGetInfoCommand()) Log.d(TAG, "Got info: $response") capabilities = capabilities or CAPABILITY_CTAP_2 or - (if (response.versions.contains("FIDO_2_1")) CAPABILITY_CTAP_2_1 else 0) or - (if (response.options.clientPin == true) CAPABILITY_CLIENT_PIN else 0) or - (if (response.options.userVerification == true) CAPABILITY_USER_VERIFICATION else 0) or - (if (response.options.residentKey == true) CAPABILITY_RESIDENT_KEY else 0) + (if (response.versions?.contains("FIDO_2_1") == true) CAPABILITY_CTAP_2_1 else 0) or + (if (response.options?.clientPin == true) CAPABILITY_CLIENT_PIN else 0) or + (if (response.options?.userVerification == true) CAPABILITY_USER_VERIFICATION else 0) or + (if (response.options?.residentKey == true) CAPABILITY_RESIDENT_KEY else 0) if (response.transports != null) transports = response.transports } diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidRequest.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidRequest.kt index 5883b64d81..0f4a789b2e 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidRequest.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/transport/usb/ctaphid/CtapHidRequest.kt @@ -43,7 +43,7 @@ class CtapHidWinkRequest : CtapHidRequest(0x08) { } class CtapHidCborRequest(val request: Ctap2Request) : - CtapHidRequest(0x10, byteArrayOf(request.commandByte) + request.payload) { + CtapHidRequest(0x10, byteArrayOf(request.commandByte) + request.encodeParameters()) { override fun toString(): String = "CtapHidCborRequest(${request})" } 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..2afa3d04af 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 @@ -31,6 +31,7 @@ import org.microg.gms.fido.core.transport.Transport.* import org.microg.gms.fido.core.transport.TransportHandler 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 @@ -47,12 +48,18 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { BrowserPublicKeyCredentialCreationOptions.deserializeFromBytes(intent.getByteArrayExtra(KEY_OPTIONS)) SOURCE_BROWSER to TYPE_SIGN -> BrowserPublicKeyCredentialRequestOptions.deserializeFromBytes(intent.getByteArrayExtra(KEY_OPTIONS)) + SOURCE_HYBRID to TYPE_REGISTER -> + BrowserPublicKeyCredentialCreationOptions.deserializeFromBytes(intent.getByteArrayExtra(KEY_OPTIONS)) + SOURCE_HYBRID to TYPE_SIGN -> + BrowserPublicKeyCredentialRequestOptions.deserializeFromBytes(intent.getByteArrayExtra(KEY_OPTIONS)) SOURCE_APP to TYPE_REGISTER -> PublicKeyCredentialCreationOptions.deserializeFromBytes(intent.getByteArrayExtra(KEY_OPTIONS)) SOURCE_APP to TYPE_SIGN -> PublicKeyCredentialRequestOptions.deserializeFromBytes(intent.getByteArrayExtra(KEY_OPTIONS)) else -> null } + val source: String? + get() = intent.getStringExtra(KEY_SOURCE) private val service: GmsService get() = GmsService.byServiceId(intent.getIntExtra(KEY_SERVICE, GmsService.UNKNOWN.SERVICE_ID)) @@ -61,6 +68,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { setOfNotNull( BluetoothTransportHandler(this, this), NfcTransportHandler(this, this), + if (SDK_INT >= 21) HybridTransportHandler(this, this) else null, if (SDK_INT >= 21) UsbTransportHandler(this, this) else null, if (SDK_INT >= 23) ScreenLockTransportHandler(this, this) else null ) @@ -81,7 +89,10 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { try { - val callerPackage = (if (callingActivity?.packageName == packageName && intent.hasExtra(KEY_CALLER)) intent.getStringExtra(KEY_CALLER) else callingActivity?.packageName) ?: return finish() + val callerPackage = (if (callingActivity?.packageName == packageName && intent.hasExtra(KEY_CALLER)) intent.getStringExtra(KEY_CALLER) else callingActivity?.packageName) ?: return finishWithError(UNKNOWN_ERR, "Unknown caller") + if (source == SOURCE_HYBRID && callerPackage != packageName) { + return finishWithError(UNKNOWN_ERR, "Hybrid source only available from internal intent") + } if (!intent.extras?.keySet().orEmpty().containsAll(REQUIRED_EXTRAS)) { return finishWithError(UNKNOWN_ERR, "Extra missing from request") } @@ -96,7 +107,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { Log.d(TAG, "onCreate caller=$callerPackage options=$options") val requiresPrivilege = - options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature) + source == SOURCE_BROWSER && !database.isPrivileged(callerPackage, callerSignature) // Check if we can directly open screen lock handling if (!requiresPrivilege) { @@ -131,12 +142,14 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { val callerName = packageManager.getApplicationLabel(callerPackage).toString() val requiresPrivilege = - options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature) + callerPackage != packageName && options is BrowserRequestOptions && !database.isPrivileged(callerPackage, callerSignature) 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) @@ -144,24 +157,25 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { } } + val effectiveTransports = if (source == SOURCE_HYBRID) IMPLEMENTED_TRANSPORTS - HYBRID else IMPLEMENTED_TRANSPORTS val arguments = AuthenticatorActivityFragmentData().apply { this.appName = appName this.isFirst = true this.privilegedCallerName = callerName.takeIf { options is BrowserRequestOptions } this.requiresPrivilege = requiresPrivilege - this.supportedTransports = transportHandlers.filter { it.isSupported }.map { it.transport }.toSet() + this.supportedTransports = transportHandlers.filter { it.isSupported }.map { it.transport } + .filter { it in effectiveTransports }.toSet() }.arguments val next = if (!requiresPrivilege) { val knownRegistrationTransports = mutableSetOf() val allowedTransports = mutableSetOf() - val localSavedUserKey = mutableSetOf() if (options.type == RequestOptionsType.SIGN) { for (descriptor in options.signOptions.allowList.orEmpty()) { val knownTransport = database.getKnownRegistrationTransport(options.rpId, descriptor.id.toBase64(Base64.URL_SAFE, Base64.NO_WRAP, Base64.NO_PADDING)) - if (knownTransport != null && knownTransport in IMPLEMENTED_TRANSPORTS) + if (knownTransport != null && knownTransport in effectiveTransports) knownRegistrationTransports.add(knownTransport) if (descriptor.transports.isNullOrEmpty()) { - allowedTransports.addAll(Transport.values()) + allowedTransports.addAll(effectiveTransports) } else { for (transport in descriptor.transports.orEmpty()) { val allowedTransport = when (transport) { @@ -170,22 +184,22 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { com.google.android.gms.fido.common.Transport.NFC -> NFC com.google.android.gms.fido.common.Transport.USB -> USB com.google.android.gms.fido.common.Transport.INTERNAL -> SCREEN_LOCK + com.google.android.gms.fido.common.Transport.HYBRID -> HYBRID else -> null } - if (allowedTransport != null && allowedTransport in IMPLEMENTED_TRANSPORTS) + if (allowedTransport != null && allowedTransport in effectiveTransports) allowedTransports.add(allowedTransport) } } } - 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) { + when (preselectedTransport) { USB -> R.id.usbFragment NFC -> R.id.nfcFragment + HYBRID -> R.id.hybridFragment + SCREEN_LOCK -> R.id.signInSelectionFragment else -> R.id.transportSelectionFragment } } else { @@ -202,6 +216,7 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { if (next != null) { navGraph.setStartDestination(next) } + Log.d(TAG, "arguments=$arguments") navHostFragment.navController.setGraph(navGraph, arguments) } } @@ -220,9 +235,14 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { ) } - fun finishWithSuccessResponse(response: AuthenticatorResponse, transport: Transport) { + fun finishWithSuccessResponse(response: AuthenticatorResponse, transport: Transport, user: PublicKeyCredentialUserEntity? = null) { Log.d(TAG, "Finish with success response: $response") - if (options is BrowserRequestOptions) database.insertPrivileged(callerPackage, callerSignature) + + val shouldPersist = transport != HYBRID + if (options is BrowserRequestOptions && shouldPersist) { + database.insertPrivileged(callerPackage, callerSignature) + } + val rpId = options?.rpId val rawId = when(response) { is AuthenticatorAttestationResponse -> response.keyHandle @@ -231,8 +251,8 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { } val id = rawId?.toBase64(Base64.URL_SAFE, Base64.NO_WRAP, Base64.NO_PADDING) - if (rpId != null && id != null) { - database.insertKnownRegistration(rpId, id, transport, options?.user) + if (shouldPersist && rpId != null && id != null) { + database.insertKnownRegistration(rpId, id, transport, user?.toJson()) } val prfFirst = rawId?.let { java.security.MessageDigest.getInstance("SHA-256").digest(it) }?.copyOf(32) @@ -254,12 +274,15 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { .setAuthenticationExtensionsClientOutputs(clientExtResults) .build() - finishWithCredential(pkc) + finishWithCredential(pkc, user) } - private fun finishWithCredential(publicKeyCredential: PublicKeyCredential) { + private fun finishWithCredential(publicKeyCredential: PublicKeyCredential, user: PublicKeyCredentialUserEntity? = null) { val intent = Intent() intent.putExtra(FIDO2_KEY_CREDENTIAL_EXTRA, publicKeyCredential.serializeToBytes()) + if (source == SOURCE_HYBRID && user != null) { + intent.putExtra(KEY_USER_JSON, user.toJson()) + } val response: AuthenticatorResponse = publicKeyCredential.response if (response is AuthenticatorErrorResponse) { intent.putExtra(FIDO2_KEY_ERROR_EXTRA, response.serializeToBytes()) @@ -279,10 +302,11 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { } @RequiresApi(24) - fun startTransportHandling(transport: Transport, instant: Boolean = false, pinRequested: Boolean = false, authenticatorPin: String? = null, userInfo: String? = null): Job = lifecycleScope.launchWhenResumed { + fun startTransportHandling(transport: Transport, instant: Boolean = false, pinRequested: Boolean = false, authenticatorPin: String? = null, user: PublicKeyCredentialUserEntity? = null): Job = lifecycleScope.launchWhenResumed { val options = options ?: return@launchWhenResumed try { - finishWithSuccessResponse(getTransportHandler(transport)!!.start(options, callerPackage, pinRequested, authenticatorPin, userInfo), transport) + val result = getTransportHandler(transport)!!.start(options, callerPackage, pinRequested, authenticatorPin, user) + finishWithSuccessResponse(result.response, transport, result.user) } catch (e: SecurityException) { Log.w(TAG, e) if (instant) { @@ -347,9 +371,11 @@ class AuthenticatorActivity : AppCompatActivity(), TransportHandlerCallback { const val KEY_SOURCE = "source" const val KEY_TYPE = "type" const val KEY_OPTIONS = "options" - val REQUIRED_EXTRAS = setOf(KEY_SERVICE, KEY_SOURCE, KEY_TYPE, KEY_OPTIONS) + const val KEY_USER_JSON = "userInfo" + val REQUIRED_EXTRAS = setOf(KEY_SOURCE, KEY_TYPE, KEY_OPTIONS) const val SOURCE_BROWSER = "browser" + const val SOURCE_HYBRID = "hybrid" const val SOURCE_APP = "app" const val TYPE_REGISTER = "register" @@ -357,9 +383,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, HYBRID) val INSTANT_SUPPORTED_TRANSPORTS = setOf(SCREEN_LOCK) } } - - diff --git a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivityFragment.kt b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivityFragment.kt index c8718dbabf..4b00a17a88 100644 --- a/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivityFragment.kt +++ b/play-services-fido/core/src/main/kotlin/org/microg/gms/fido/core/ui/AuthenticatorActivityFragment.kt @@ -16,6 +16,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.gms.fido.fido2.api.common.ErrorCode +import com.google.android.gms.fido.fido2.api.common.PublicKeyCredentialUserEntity import com.google.android.gms.fido.fido2.api.common.RequestOptions import org.microg.gms.fido.core.* import org.microg.gms.fido.core.transport.Transport @@ -30,8 +31,8 @@ abstract class AuthenticatorActivityFragment : Fragment() { val options: RequestOptions? get() = authenticatorActivity?.options - fun startTransportHandling(transport: Transport, userInfo: String? = null) = - authenticatorActivity?.startTransportHandling(transport, pinRequested = pinViewModel.pinRequest, authenticatorPin = pinViewModel.pin, userInfo = userInfo) + fun startTransportHandling(transport: Transport, user: PublicKeyCredentialUserEntity? = null) = + authenticatorActivity?.startTransportHandling(transport, pinRequested = pinViewModel.pinRequest, authenticatorPin = pinViewModel.pin, user = user) fun shouldStartTransportInstantly(transport: Transport) = authenticatorActivity?.shouldStartTransportInstantly(transport) == true abstract override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? 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..b5587f5446 --- /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