From ee12e1211f1a27827be58c12b918b83899b540dc Mon Sep 17 00:00:00 2001 From: Meet-hybrid Date: Sun, 5 Jul 2026 06:34:06 +0100 Subject: [PATCH] firebase-auth: add App Check token exchange for SafetyNet attestation Add AppCheckTokenProvider that performs SafetyNet attestation via DroidGuard and exchanges the JWS for a Firebase App Check token at firebaseappcheck.googleapis.com. Inject the token as the X-Firebase-AppCheck header in all Identity Toolkit API requests. Previously, IdentityToolkitClient sent requests without App Check headers, causing UNAUTHENTICATED rejections from Firebase projects with App Check enforcement enabled (e.g. com.ridedott.rider). Includes EXTRA_FIREBASE_APP_ID and EXTRA_PROJECT_NUMBER constants for the AIDL service binding to provide the required identifiers. --- firebase-auth/core/build.gradle | 4 + .../firebase/auth/AppCheckTokenProvider.kt | 171 ++++++++++++++++++ .../gms/firebase/auth/FirebaseAuthService.kt | 9 +- .../firebase/auth/IdentityToolkitClient.kt | 54 ++++-- .../microg/gms/firebase/auth/Constants.java | 2 + 5 files changed, 223 insertions(+), 17 deletions(-) create mode 100644 firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/AppCheckTokenProvider.kt diff --git a/firebase-auth/core/build.gradle b/firebase-auth/core/build.gradle index 974e386ffd..6631bff537 100644 --- a/firebase-auth/core/build.gradle +++ b/firebase-auth/core/build.gradle @@ -15,6 +15,10 @@ dependencies { implementation "androidx.appcompat:appcompat:$appcompatVersion" implementation project(':play-services-base-core') + implementation project(':play-services-safetynet-core') + implementation project(':play-services-droidguard') + implementation project(':play-services-droidguard-core') + implementation project(':play-services-tasks-ktx') implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion" diff --git a/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/AppCheckTokenProvider.kt b/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/AppCheckTokenProvider.kt new file mode 100644 index 0000000000..7c069ab485 --- /dev/null +++ b/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/AppCheckTokenProvider.kt @@ -0,0 +1,171 @@ +/* + * SPDX-FileCopyrightText: 2024, microG Project Team + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.microg.gms.firebase.auth + +import android.content.Context +import android.os.SystemClock +import android.util.Log +import com.android.volley.Request +import com.android.volley.toolbox.JsonObjectRequest +import com.android.volley.toolbox.Volley +import com.google.android.gms.droidguard.DroidGuardClient +import com.google.android.gms.tasks.await +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import org.microg.gms.safetynet.Attestation +import org.microg.gms.safetynet.SafetyNetPreferences +import org.microg.gms.utils.singleInstanceOf +import org.microg.gms.droidguard.core.DroidGuardPreferences +import java.security.SecureRandom +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +private const val TAG = "GmsFirebaseAppCheck" +private const val SAFETYNET_API_KEY = "AIzaSyDqVnJBjE5ymo--oBJt3On7HQx9xNm1RHA" + +/** + * Manages Firebase App Check token acquisition and caching for the + * IdentityToolkitClient. + * + * Flow: + * 1. Build a SafetyNet attestation payload for the calling app + * 2. Obtain a DroidGuard result (hardware-backed attestation) + * 3. Send the attestation request to Google's androidcheck API + * 4. Exchange the resulting JWS for a Firebase App Check token at + * firebaseappcheck.googleapis.com + * 5. Cache the token until it expires + * 6. The cached token is used as the X-Firebase-AppCheck header value + */ +class AppCheckTokenProvider( + private val context: Context, + private val apiKey: String, + /** Numeric Google Cloud project number (e.g. "123456789012") */ + private val projectNumber: String?, + /** Firebase App ID (e.g. "1:123456789012:android:abcdef1234567890") */ + private val firebaseAppId: String?, + private val packageName: String, +) { + private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } + private val random = SecureRandom() + + private var cachedToken: String? = null + private var tokenExpiryMs: Long = 0L + + /** + * Obtain a cached or fresh Firebase App Check token. + * + * Performs the full SafetyNet attestation + App Check exchange flow + * when no cached token is available or the cached token has expired. + * Returns null when App Check is unavailable or any step fails. + */ + suspend fun getToken(): String? { + if (cachedToken != null && SystemClock.elapsedRealtime() < tokenExpiryMs - 300_000L) { + return cachedToken + } + if (projectNumber == null || firebaseAppId == null) return null + if (!SafetyNetPreferences.isEnabled(context)) return null + if (!DroidGuardPreferences.isAvailable(context)) return null + + return try { + val jws = obtainSafetyNetAttestation() ?: return null + val token = exchangeForAppCheckToken(jws) ?: return null + cachedToken = token + token + } catch (e: Exception) { + Log.w(TAG, "Failed to obtain App Check token", e) + null + } + } + + /** + * Performs a SafetyNet attestation and returns the JWS result. + * + * Steps: + * 1. Build a SafetyNetData protobuf payload with app identity and nonce + * 2. DroidGuard-process the payload hash for hardware-backed proof + * 3. POST the combined data to Google's androidcheck API + */ + private suspend fun obtainSafetyNetAttestation(): String? { + return try { + val nonce = ByteArray(32).also { random.nextBytes(it) } + val attestation = Attestation(context, packageName) + attestation.buildPayload(nonce) + + val data = mapOf("contentBinding" to attestation.payloadHashBase64) + val dg = withContext(Dispatchers.IO) { + DroidGuardClient.getResults(context, "attest", data).await() + } + attestation.setDroidGuardResult(dg) + + withContext(Dispatchers.IO) { attestation.attest(SAFETYNET_API_KEY) } + } catch (e: Exception) { + Log.w(TAG, "SafetyNet attestation failed", e) + null + } + } + + /** + * Exchange a SafetyNet attestation JWS for a Firebase App Check token. + * + * POST to firebaseappcheck.googleapis.com with the SafetyNet JWS. + * The response contains an attestation_token (JWT) and a TTL. + */ + private suspend fun exchangeForAppCheckToken(safetyNetJws: String): String? { + val appId = firebaseAppId ?: return null + val projectNum = projectNumber ?: return null + + val url = ( + "https://firebaseappcheck.googleapis.com/v1/" + + "projects/$projectNum/apps/$appId:exchangeSafetyNetToken" + + "?key=$apiKey" + ) + + return suspendCoroutine { cont -> + queue.add(JsonObjectRequest( + Request.Method.POST, url, + JSONObject().apply { + put("safety_net_token", safetyNetJws) + put("version", 2) + }, + { response -> + val token = response.optString("attestationToken", null) + if (token != null && token.isNotEmpty()) { + val ttl = response.optString("ttl", "3600s") + tokenExpiryMs = SystemClock.elapsedRealtime() + parseTtlSeconds(ttl) * 1000L + Log.d(TAG, "App Check token acquired, TTL: $ttl") + } + cont.resume(token) + }, + { error -> + Log.w(TAG, "App Check exchange failed: ${error.networkResponse?.statusCode} " + + "${error.networkResponse?.data?.decodeToString() ?: error.message}") + cont.resume(null) + } + )) + } + } + + fun invalidate() { + cachedToken = null + tokenExpiryMs = 0L + } + + companion object { + private fun parseTtlSeconds(ttl: String): Long { + return try { + when { + ttl.endsWith("s") -> ttl.dropLast(1).toLong() + ttl.endsWith("m") -> ttl.dropLast(1).toLong() * 60 + ttl.endsWith("h") -> ttl.dropLast(1).toLong() * 3600 + else -> 3600L + } + } catch (e: NumberFormatException) { + 3600L + } + } + } +} diff --git a/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/FirebaseAuthService.kt b/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/FirebaseAuthService.kt index afcb0d4df2..158ddada29 100644 --- a/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/FirebaseAuthService.kt +++ b/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/FirebaseAuthService.kt @@ -76,16 +76,19 @@ class FirebaseAuthService : BaseService(TAG, GmsService.FIREBASE_AUTH) { PackageUtils.getAndCheckCallingPackage(this, request.packageName) val apiKey = request.extras?.getString(Constants.EXTRA_API_KEY) val libraryVersion = request.extras?.getString(Constants.EXTRA_LIBRARY_VERSION) + val firebaseAppId = request.extras?.getString(Constants.EXTRA_FIREBASE_APP_ID) + val projectNumber = request.extras?.getString(Constants.EXTRA_PROJECT_NUMBER) if (apiKey == null) { callback.onPostInitComplete(CommonStatusCodes.DEVELOPER_ERROR, null, null) } else { - callback.onPostInitComplete(0, FirebaseAuthServiceImpl(this, lifecycle, request.packageName, libraryVersion, apiKey).asBinder(), null) + callback.onPostInitComplete(0, FirebaseAuthServiceImpl(this, lifecycle, request.packageName, libraryVersion, apiKey, firebaseAppId, projectNumber).asBinder(), null) } } } -class FirebaseAuthServiceImpl(private val context: Context, override val lifecycle: Lifecycle, private val packageName: String, private val libraryVersion: String?, private val apiKey: String) : IFirebaseAuthService.Stub(), LifecycleOwner { - private val client by lazy { IdentityToolkitClient(context, apiKey, packageName, context.packageManager.getCertificates(packageName).firstOrNull()?.digest("SHA1")) } +class FirebaseAuthServiceImpl(private val context: Context, override val lifecycle: Lifecycle, private val packageName: String, private val libraryVersion: String?, private val apiKey: String, private val firebaseAppId: String? = null, private val projectNumber: String? = null) : IFirebaseAuthService.Stub(), LifecycleOwner { + private val appCheckTokenProvider = firebaseAppId?.let { AppCheckTokenProvider(context, apiKey, projectNumber, firebaseAppId, packageName) } + private val client by lazy { IdentityToolkitClient(context, apiKey, packageName, context.packageManager.getCertificates(packageName).firstOrNull()?.digest("SHA1"), appCheckTokenProvider) } private var authorizedDomain: String? = null private suspend fun getAuthorizedDomain(): String { diff --git a/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/IdentityToolkitClient.kt b/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/IdentityToolkitClient.kt index 072d4d9bf2..79ee893a79 100644 --- a/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/IdentityToolkitClient.kt +++ b/firebase-auth/core/src/main/kotlin/org/microg/gms/firebase/auth/IdentityToolkitClient.kt @@ -31,26 +31,45 @@ import kotlin.coroutines.suspendCoroutine private const val TAG = "GmsFirebaseAuthClient" -class IdentityToolkitClient(context: Context, private val apiKey: String, private val packageName: String? = null, private val certSha1Hash: ByteArray? = null) { +class IdentityToolkitClient( + context: Context, + private val apiKey: String, + private val packageName: String? = null, + private val certSha1Hash: ByteArray? = null, + private val appCheckTokenProvider: AppCheckTokenProvider? = null, +) { private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) } + private var appCheckToken: String? = null + private fun buildRelyingPartyUrl(method: String) = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/$method?key=$apiKey" private fun buildStsUrl(method: String) = "https://securetoken.googleapis.com/v1/$method?key=$apiKey" + private suspend fun refreshAppCheckToken() { + appCheckToken = appCheckTokenProvider?.getToken() + } + private fun getRequestHeaders(): Map = hashMapOf().apply { if (packageName != null) put("X-Android-Package", packageName) if (certSha1Hash != null) put("X-Android-Cert", certSha1Hash.toHexString().uppercase()) + val token = appCheckToken + if (token != null) { + put("X-Firebase-AppCheck", token) + } } - private suspend fun request(method: String, data: JSONObject): JSONObject = suspendCoroutine { continuation -> - queue.add(object : JsonObjectRequest(POST, buildRelyingPartyUrl(method), data, { - continuation.resume(it) - }, { - Log.d(TAG, "Error: ${it.networkResponse?.data?.decodeToString() ?: it.message}") - continuation.resumeWithException(RuntimeException(it)) - }) { - override fun getHeaders(): Map = getRequestHeaders() - }) + private suspend fun request(method: String, data: JSONObject): JSONObject { + refreshAppCheckToken() + return suspendCoroutine { continuation -> + queue.add(object : JsonObjectRequest(POST, buildRelyingPartyUrl(method), data, { + continuation.resume(it) + }, { + Log.d(TAG, "Error: ${it.networkResponse?.data?.decodeToString() ?: it.message}") + continuation.resumeWithException(RuntimeException(it)) + }) { + override fun getHeaders(): Map = this@IdentityToolkitClient.getRequestHeaders() + }) + } } suspend fun createAuthUri(identifier: String? = null, tenantId: String? = null, continueUri: String? = "http://localhost"): JSONObject = @@ -63,8 +82,13 @@ class IdentityToolkitClient(context: Context, private val apiKey: String, privat request("getAccountInfo", JSONObject() .put("idToken", idToken)) - suspend fun getProjectConfig(): JSONObject = suspendCoroutine { continuation -> - queue.add(JsonObjectRequest(GET, buildRelyingPartyUrl("getProjectConfig"), null, { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) })) + suspend fun getProjectConfig(): JSONObject { + refreshAppCheckToken() + return suspendCoroutine { continuation -> + queue.add(JsonObjectRequest(GET, buildRelyingPartyUrl("getProjectConfig"), null, { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }) { + override fun getHeaders(): Map = this@IdentityToolkitClient.getRequestHeaders() + }) + } } suspend fun getOobConfirmationCode(requestType: String, email: String? = null, newEmail: String? = null, continueUrl: String? = null, idToken: String? = null, iOSBundleId: String? = null, iOSAppStoreId: String? = null, androidMinimumVersion: String? = null, androidInstallApp: Boolean? = null, androidPackageName: String? = null, canHandleCodeInApp: Boolean? = null): JSONObject = @@ -132,8 +156,10 @@ class IdentityToolkitClient(context: Context, private val apiKey: String, privat .put("phoneNumber", phoneNumber) .put("sessionInfo", sessionInfo)) - suspend fun getTokenByRefreshToken(refreshToken: String): JSONObject = suspendCoroutine { continuation -> - queue.add(object : JsonRequest(POST, buildStsUrl("token"), "grant_type=refresh_token&refresh_token=$refreshToken", { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }) { + suspend fun getTokenByRefreshToken(refreshToken: String): JSONObject { + refreshAppCheckToken() + return suspendCoroutine { continuation -> + queue.add(object : JsonRequest(POST, buildStsUrl("token"), "grant_type=refresh_token&refresh_token=$refreshToken", { continuation.resume(it) }, { continuation.resumeWithException(RuntimeException(it)) }) { override fun parseNetworkResponse(response: NetworkResponse): Response { return try { val jsonString = String(response.data, Charset.forName(HttpHeaderParser.parseCharset(response.headers, PROTOCOL_CHARSET))) diff --git a/firebase-auth/src/main/java/org/microg/gms/firebase/auth/Constants.java b/firebase-auth/src/main/java/org/microg/gms/firebase/auth/Constants.java index 0f83c9fec4..0b9c379d87 100644 --- a/firebase-auth/src/main/java/org/microg/gms/firebase/auth/Constants.java +++ b/firebase-auth/src/main/java/org/microg/gms/firebase/auth/Constants.java @@ -8,4 +8,6 @@ public class Constants { public static final String EXTRA_API_KEY = "com.google.firebase.auth.API_KEY"; public static final String EXTRA_LIBRARY_VERSION = "com.google.firebase.auth.LIBRARY_VERSION"; + public static final String EXTRA_FIREBASE_APP_ID = "com.google.firebase.auth.FIREBASE_APP_ID"; + public static final String EXTRA_PROJECT_NUMBER = "com.google.firebase.auth.PROJECT_NUMBER"; }