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