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"; }