diff --git a/AppIntegrity/build.gradle.kts b/AppIntegrity/build.gradle.kts index 62e1e5e3c..d4ce1e129 100644 --- a/AppIntegrity/build.gradle.kts +++ b/AppIntegrity/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(project(":Sentry")) "standardImplementation"(core.integrity) + "standardImplementation"(core.kotlinx.coroutines.play.services) implementation(core.ktor.client.core) implementation(core.ktor.client.content.negociation) implementation(core.ktor.client.json) diff --git a/AppIntegrity/src/fdroid/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManagerFdroid.kt b/AppIntegrity/src/fdroid/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManagerFdroid.kt index fc5717514..9abb685e5 100644 --- a/AppIntegrity/src/fdroid/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManagerFdroid.kt +++ b/AppIntegrity/src/fdroid/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManagerFdroid.kt @@ -18,7 +18,7 @@ package com.infomaniak.core.appintegrity import android.content.Context -import com.infomaniak.core.appintegrity.exceptions.FDroidUnsupportedIntegrityException +import com.infomaniak.core.appintegrity.exceptions.AppIntegrityException class AppIntegrityManager( @Suppress("unused") private val appContext: Context, @@ -38,17 +38,22 @@ class AppIntegrityManager( onFailure() } - override suspend fun requestClassicIntegrityVerdictToken(challenge: String): String { - throw FDroidUnsupportedIntegrityException() - } + override suspend fun isGuaranteedToFail(): Boolean = true - override suspend fun getChallenge() = throw FDroidUnsupportedIntegrityException() + override suspend fun getChallenge() = throw unsupportedException() - override suspend fun getApiIntegrityVerdict( - integrityToken: String, + override suspend fun requestAttestationToken( + challenge: String, packageName: String, - targetUrl: String, - ) = throw FDroidUnsupportedIntegrityException() + targetUrl: String + ) = throw unsupportedException() + + private fun unsupportedException() = AppIntegrityException( + errorCode = 0, + issue = AppIntegrityIssue.DeviceIssue.ApiNotAvailable, + message = "App Integrity not supported on FDroid variant", + cause = null + ) companion object { const val APP_INTEGRITY_MANAGER_TAG = "App integrity manager" diff --git a/AppIntegrity/src/main/AndroidManifest.xml b/AppIntegrity/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5334d5dab --- /dev/null +++ b/AppIntegrity/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt index ed3b07cc1..b5c794a36 100644 --- a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt @@ -36,20 +36,17 @@ abstract class AbstractAppIntegrityManager { onNullTokenProvider: (String) -> Unit, ) - /** - * Classic verdict request for Integrity token - * - * This doesn't automatically protect from replay attack, thus the use of challenge/challengeId pair with our API to add this - * layer of protection. - * - * ###### Can throw Integrity exceptions. - */ - abstract suspend fun requestClassicIntegrityVerdictToken(challenge: String): String + abstract suspend fun isGuaranteedToFail(): Boolean abstract suspend fun getChallenge(): String - abstract suspend fun getApiIntegrityVerdict( - integrityToken: String, + /** + * Currently uses classic Play Integrity API. + * + * @throws com.infomaniak.core.appintegrity.exceptions.AppIntegrityException + */ + abstract suspend fun requestAttestationToken( + challenge: String, packageName: String, targetUrl: String, ): String diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssue.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssue.kt new file mode 100644 index 000000000..4a194e54a --- /dev/null +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssue.kt @@ -0,0 +1,42 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.appintegrity + +sealed interface AppIntegrityIssue { + enum class RetryLater : AppIntegrityIssue { + NetworkError, GoogleServerUnavailable, TooManyRequests; + } + + enum class Internal : AppIntegrityIssue { + ClientTransientError, InternalError, IntegrityTokenProviderInvalid, CantBindToService; + } + + enum class DeviceIssue : AppIntegrityIssue { + PlayStoreVersionOutdated, + PlayServicesVersionOutdated, + PlayStoreAccountNotFound, + PlayStoreNotFound, + PlayServicesNotFound, + ApiNotAvailable, + ; + } + + data class DevError(val message: String) : AppIntegrityIssue + + data class SuspiciousError(val message: String) : AppIntegrityIssue +} diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssueExtensions.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssueExtensions.kt new file mode 100644 index 000000000..563864e7b --- /dev/null +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssueExtensions.kt @@ -0,0 +1,35 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.appintegrity + +import com.infomaniak.core.appintegrity.AppIntegrityIssue.DevError +import com.infomaniak.core.appintegrity.AppIntegrityIssue.DeviceIssue +import com.infomaniak.core.appintegrity.AppIntegrityIssue.Internal +import com.infomaniak.core.appintegrity.AppIntegrityIssue.RetryLater +import com.infomaniak.core.appintegrity.AppIntegrityIssue.SuspiciousError + +fun AppIntegrityIssue.isRecoverable(): Boolean = when (this) { + is Internal -> true + is RetryLater -> true + DeviceIssue.PlayStoreAccountNotFound -> true + DeviceIssue.PlayStoreVersionOutdated, DeviceIssue.PlayServicesVersionOutdated -> true + DeviceIssue.PlayStoreNotFound, DeviceIssue.PlayServicesNotFound -> false + DeviceIssue.ApiNotAvailable -> false + is DevError -> false + is SuspiciousError -> false +} diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/IntegrityDialogResponse.kt similarity index 60% rename from AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt rename to AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/IntegrityDialogResponse.kt index aea3b122d..ef1570a4a 100644 --- a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/IntegrityDialogResponse.kt @@ -1,6 +1,6 @@ /* - * Infomaniak Core - Android - * Copyright (C) 2025 Infomaniak Network SA + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -15,11 +15,12 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package com.infomaniak.core.appintegrity.exceptions +package com.infomaniak.core.appintegrity -/** An error has occurred when verified by api play integrity or our remote api */ -sealed class IntegrityException(override val cause: Throwable? = null) : Exception() - -class AppIntegrityException(cause: Throwable?) : IntegrityException(cause) - -class FDroidUnsupportedIntegrityException() : IntegrityException() +enum class IntegrityDialogResponse { + Failed, + Unavailable, + Cancelled, + Successful, + ; +} diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/AppIntegrityException.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/AppIntegrityException.kt new file mode 100644 index 000000000..5d149445c --- /dev/null +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/AppIntegrityException.kt @@ -0,0 +1,37 @@ +/* + * Infomaniak Core - Android + * Copyright (C) 2025 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.appintegrity.exceptions + +import android.app.Activity +import com.infomaniak.core.appintegrity.AppIntegrityIssue +import com.infomaniak.core.appintegrity.IntegrityDialogResponse +import com.infomaniak.core.appintegrity.isRecoverable + +/** + * Thrown when either the Play App Integrity API or our remote API fails at verifying the device or the app integrity. + * + * - Some issues are recoverable, use the [isRecoverable] extension on [issue] to know. + * - Some issues are remediable through a dialog. Use [showRemediationDialog] to do so. + */ +class AppIntegrityException( + errorCode: Int, + val issue: AppIntegrityIssue, + message: String = "AppIntegrity issue ($errorCode): $issue", + val showRemediationDialog: (suspend (Activity) -> IntegrityDialogResponse)? = null, + cause: Throwable?, +) : Exception(message, cause) diff --git a/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityExceptionsConversion.kt b/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityExceptionsConversion.kt new file mode 100644 index 000000000..6594df1fb --- /dev/null +++ b/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityExceptionsConversion.kt @@ -0,0 +1,128 @@ +/* + * Infomaniak Authenticator - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.core.appintegrity + +import android.app.Activity +import com.google.android.play.core.integrity.IntegrityDialogRequest +import com.google.android.play.core.integrity.IntegrityManager +import com.google.android.play.core.integrity.IntegrityServiceException +import com.google.android.play.core.integrity.StandardIntegrityException +import com.google.android.play.core.integrity.StandardIntegrityManager +import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityDialogRequest.StandardIntegrityResponse +import com.google.android.play.core.integrity.model.IntegrityDialogResponseCode +import com.google.android.play.core.integrity.model.IntegrityDialogTypeCode +import com.google.android.play.core.integrity.model.IntegrityErrorCode +import com.google.android.play.core.integrity.model.StandardIntegrityErrorCode +import com.infomaniak.core.appintegrity.exceptions.AppIntegrityException +import kotlinx.coroutines.tasks.await + +internal fun StandardIntegrityException.toAppIntegrityException( + standardIntegrityManager: StandardIntegrityManager +): AppIntegrityException { + return AppIntegrityException( + errorCode = errorCode, + issue = appIntegrityIssueFor(integrityErrorCode = errorCode), + showRemediationDialog = if (isRemediable) showStandardRemediationDialog(this, standardIntegrityManager) else null, + cause = this, + ) +} + +internal fun IntegrityServiceException.toAppIntegrityException( + standardIntegrityManager: IntegrityManager +): AppIntegrityException { + return AppIntegrityException( + errorCode = errorCode, + issue = appIntegrityIssueFor(integrityErrorCode = errorCode), + showRemediationDialog = if (isRemediable) showRemediationDialog(this, standardIntegrityManager) else null, + cause = this, + ) +} + +private fun showStandardRemediationDialog( + exception: StandardIntegrityException, + standardIntegrityManager: StandardIntegrityManager +): suspend (Activity) -> IntegrityDialogResponse = { activity -> + val dialogRequest = StandardIntegrityManager.StandardIntegrityDialogRequest.builder() + .setActivity(activity) + .setTypeCode(IntegrityDialogTypeCode.GET_INTEGRITY) + .setStandardIntegrityResponse(StandardIntegrityResponse.ExceptionDetails(exception)) + .build() + + val dialogResponseCode = standardIntegrityManager.showDialog(dialogRequest).await() + remediationDialogResponseCodeToIntegrityDialogResponse(dialogResponseCode) +} + +private fun showRemediationDialog( + exception: IntegrityServiceException, + integrityManager: IntegrityManager +): suspend (Activity) -> IntegrityDialogResponse = { activity -> + val dialogRequest = IntegrityDialogRequest.builder() + .setActivity(activity) + .setTypeCode(IntegrityDialogTypeCode.GET_INTEGRITY) + .setIntegrityResponse(IntegrityDialogRequest.IntegrityResponse.ExceptionDetails(exception)) + .build() + + val dialogResponseCode = integrityManager.showDialog(dialogRequest).await() + remediationDialogResponseCodeToIntegrityDialogResponse(dialogResponseCode) +} + +/** + * See [the online doc](https://developer.android.com/google/play/integrity/reference/com/google/android/play/core/integrity/model/IntegrityDialogResponseCode) + * + * @see IntegrityDialogResponse + */ +private fun remediationDialogResponseCodeToIntegrityDialogResponse(code: Int): IntegrityDialogResponse = when (code) { + IntegrityDialogResponseCode.DIALOG_CANCELLED -> IntegrityDialogResponse.Cancelled + IntegrityDialogResponseCode.DIALOG_FAILED -> IntegrityDialogResponse.Failed + IntegrityDialogResponseCode.DIALOG_UNAVAILABLE -> IntegrityDialogResponse.Unavailable + IntegrityDialogResponseCode.DIALOG_SUCCESSFUL -> IntegrityDialogResponse.Successful + else -> IntegrityDialogResponse.Failed +} + +/** + * See the [Handle Play Integrity API error codes](https://developer.android.com/google/play/integrity/error-codes) page. + * + * @see IntegrityErrorCode + * @see StandardIntegrityErrorCode + */ +private fun appIntegrityIssueFor( + @StandardIntegrityErrorCode @IntegrityErrorCode integrityErrorCode: Int +): AppIntegrityIssue = when (integrityErrorCode) { + IntegrityErrorCode.NETWORK_ERROR -> AppIntegrityIssue.RetryLater.NetworkError + IntegrityErrorCode.GOOGLE_SERVER_UNAVAILABLE -> AppIntegrityIssue.RetryLater.GoogleServerUnavailable + IntegrityErrorCode.CLIENT_TRANSIENT_ERROR -> AppIntegrityIssue.Internal.ClientTransientError + StandardIntegrityErrorCode.CLIENT_TRANSIENT_ERROR -> AppIntegrityIssue.Internal.ClientTransientError + IntegrityErrorCode.TOO_MANY_REQUESTS -> AppIntegrityIssue.RetryLater.TooManyRequests + IntegrityErrorCode.INTERNAL_ERROR -> AppIntegrityIssue.Internal.InternalError + IntegrityErrorCode.CANNOT_BIND_TO_SERVICE -> AppIntegrityIssue.Internal.CantBindToService + IntegrityErrorCode.PLAY_STORE_VERSION_OUTDATED -> AppIntegrityIssue.DeviceIssue.PlayStoreVersionOutdated + IntegrityErrorCode.PLAY_SERVICES_VERSION_OUTDATED -> AppIntegrityIssue.DeviceIssue.PlayServicesVersionOutdated + IntegrityErrorCode.PLAY_STORE_ACCOUNT_NOT_FOUND -> AppIntegrityIssue.DeviceIssue.PlayStoreAccountNotFound + IntegrityErrorCode.PLAY_STORE_NOT_FOUND -> AppIntegrityIssue.DeviceIssue.PlayStoreNotFound + IntegrityErrorCode.PLAY_SERVICES_NOT_FOUND -> AppIntegrityIssue.DeviceIssue.PlayServicesNotFound + IntegrityErrorCode.API_NOT_AVAILABLE -> AppIntegrityIssue.DeviceIssue.ApiNotAvailable + IntegrityErrorCode.CLOUD_PROJECT_NUMBER_IS_INVALID -> AppIntegrityIssue.DevError("CLOUD_PROJECT_NUMBER_IS_INVALID") + IntegrityErrorCode.APP_NOT_INSTALLED -> AppIntegrityIssue.SuspiciousError("APP_NOT_INSTALLED") + IntegrityErrorCode.APP_UID_MISMATCH -> AppIntegrityIssue.SuspiciousError("APP_UID_MISMATCH") + IntegrityErrorCode.NONCE_TOO_SHORT -> AppIntegrityIssue.DevError("NONCE_TOO_SHORT") + IntegrityErrorCode.NONCE_TOO_LONG -> AppIntegrityIssue.DevError("NONCE_TOO_LONG") + IntegrityErrorCode.NONCE_IS_NOT_BASE64 -> AppIntegrityIssue.DevError("NONCE_IS_NOT_BASE64") + IntegrityErrorCode.NO_ERROR -> AppIntegrityIssue.SuspiciousError("NO_ERROR (What a Terrible Failure)") + StandardIntegrityErrorCode.INTEGRITY_TOKEN_PROVIDER_INVALID -> AppIntegrityIssue.Internal.IntegrityTokenProviderInvalid + else -> AppIntegrityIssue.SuspiciousError("Unexpected error code: $integrityErrorCode") +} diff --git a/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManager.kt b/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManager.kt index e24337679..081b4d06d 100644 --- a/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManager.kt +++ b/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManager.kt @@ -18,10 +18,13 @@ package com.infomaniak.core.appintegrity import android.content.Context +import android.content.pm.PackageManager import android.util.Base64 import android.util.Log import com.google.android.play.core.integrity.IntegrityManagerFactory +import com.google.android.play.core.integrity.IntegrityServiceException import com.google.android.play.core.integrity.IntegrityTokenRequest +import com.google.android.play.core.integrity.IntegrityTokenResponse import com.google.android.play.core.integrity.StandardIntegrityManager.PrepareIntegrityTokenRequest import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenProvider import com.google.android.play.core.integrity.StandardIntegrityManager.StandardIntegrityTokenRequest @@ -32,7 +35,8 @@ import com.infomaniak.core.common.cancellable import com.infomaniak.core.sentry.SentryLog import io.sentry.Sentry import io.sentry.SentryLevel -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.tasks.await +import splitties.init.appCtx import java.util.UUID /** @@ -83,6 +87,28 @@ class AppIntegrityManager(private val appContext: Context, userAgent: String) : } } + override suspend fun isGuaranteedToFail(): Boolean { + val isRunningGrapheneOS = try { + appCtx.packageManager.getPackageInfo("app.grapheneos.info", PackageManager.MATCH_DISABLED_COMPONENTS) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + if (isRunningGrapheneOS) return true + + // Be sure the length is between 16 and 500 char so the computed nonce sent to AppIntegrity will have a correct size + val dummyChallenge = "Dummy challenge to know if AppIntegrity can be reached" + return try { + requestClassicIntegrityVerdictToken(dummyChallenge) + false + } catch (e: AppIntegrityException) { + !e.issue.isRecoverable() + } catch (t: Throwable) { + SentryLog.e(APP_INTEGRITY_MANAGER_TAG, "Unexpected exception in isGuaranteedToFail", t) + true + } + } + /** * Classic verdict request for Integrity token * @@ -91,27 +117,23 @@ class AppIntegrityManager(private val appContext: Context, userAgent: String) : * * ###### Can throw Integrity exceptions. */ - override suspend fun requestClassicIntegrityVerdictToken(challenge: String): String { + private suspend fun requestClassicIntegrityVerdictToken(challenge: String): IntegrityTokenResponse? { + /** + * The backend token check is disabled by default in preprod. + * See [AppIntegrityRepository.getJwtToken]'s `force_integrity_test` if you want to enable it. + */ + if (BuildConfig.DEBUG) return null + val nonce = Base64.encodeToString(challenge.toByteArray(), Base64.DEFAULT) - val token: CompletableDeferred = CompletableDeferred() - - // The backend token check is disabled by default in preprod. - // See [AppIntegrityRepository.getJwtToken]'s `force_integrity_test` if you want to enable it. - if (BuildConfig.DEBUG) return "fake integrity token" - - classicIntegrityTokenProvider.requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build()) - .addOnCompleteListener { task -> - if (task.isSuccessful) { - token.complete(task.result.token()) - } else { - token.completeExceptionally(task.exception ?: error("Failure when requestIntegrityToken")) - } - } return runCatching { - token.await() + val tokenRequest = IntegrityTokenRequest.builder().setNonce(nonce).build() + classicIntegrityTokenProvider.requestIntegrityToken(tokenRequest).await() }.cancellable().getOrElse { exception -> - throw AppIntegrityException(exception) + throw when (exception) { + is IntegrityServiceException -> exception.toAppIntegrityException(classicIntegrityTokenProvider) + else -> exception + } } } @@ -125,22 +147,38 @@ class AppIntegrityManager(private val appContext: Context, userAgent: String) : return apiResponse.data ?: error("Get challenge cannot contain null data") } - override suspend fun getApiIntegrityVerdict( - integrityToken: String, + override suspend fun requestAttestationToken( + challenge: String, + packageName: String, + targetUrl: String + ): String { + val tokenResponse = requestClassicIntegrityVerdictToken(challenge) + return getApiIntegrityVerdict(tokenResponse, packageName, targetUrl) + } + + private suspend fun getApiIntegrityVerdict( + integrityToken: IntegrityTokenResponse?, packageName: String, targetUrl: String, ): String { runCatching { val apiResponse = appIntegrityRepository.getJwtToken( - integrityToken = integrityToken, + integrityToken = integrityToken?.token() ?: "fake integrity token", packageName = packageName, targetUrl = targetUrl, challengeId = challengeId, ) return apiResponse.data ?: error("Integrity ApiResponse cannot contain null data") }.cancellable().getOrElse { exception -> + //TODO[AppIntegrity]: Handle verdict issues fully by looking up backend response. + // See this doc: https://developer.android.com/google/play/integrity/remediation#request-integrity-dialog if (exception is UnexpectedApiErrorFormatException && exception.bodyResponse.contains("invalid_attestation")) { - throw AppIntegrityException(exception) + throw AppIntegrityException( + errorCode = exception.statusCode, + issue = AppIntegrityIssue.SuspiciousError("invalid_attestation"), + message = "Invalid attestation", + cause = exception + ) } else { throw exception } diff --git a/CrossAppLogin/Back/src/main/AndroidManifest.xml b/CrossAppLogin/Back/src/main/AndroidManifest.xml index cabd930a9..0cc495e62 100644 --- a/CrossAppLogin/Back/src/main/AndroidManifest.xml +++ b/CrossAppLogin/Back/src/main/AndroidManifest.xml @@ -21,7 +21,6 @@ - diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt index 668580804..c4c8d6c42 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/BaseCrossAppLoginViewModel.kt @@ -153,7 +153,7 @@ internal class CrossAppLoginFacadeImpl( @OptIn(ExperimentalSerializationApi::class) override suspend fun activateUpdates(hostActivity: ComponentActivity, singleSelection: Boolean): Nothing = coroutineScope { // Do nothing if the user's device cannot be verified via Play's AppIntegrity, to avoid displaying the CrossAppLogin - if (!derivedTokenGenerator.checkIfAppIntegrityCouldSucceed()) { + if (derivedTokenGenerator.isAppIntegrityGuaranteedToFail()) { _availableAccounts.emit(CrossAppLogin.AccountsFromOtherApps.none()) awaitCancellation() } diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt index 5b69ca1d3..7ac3d3101 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt @@ -17,7 +17,7 @@ */ package com.infomaniak.core.crossapplogin.back -import com.infomaniak.core.appintegrity.exceptions.IntegrityException +import com.infomaniak.core.appintegrity.exceptions.AppIntegrityException import com.infomaniak.core.common.Xor import com.infomaniak.lib.login.ApiToken import okhttp3.Response @@ -25,12 +25,12 @@ import okhttp3.Response internal sealed interface DerivedTokenGenerator { suspend fun attemptDerivingOneOfTheseTokens(tokensToTry: Set): Xor - suspend fun checkIfAppIntegrityCouldSucceed(): Boolean + suspend fun isAppIntegrityGuaranteedToFail(): Boolean sealed interface Issue { data class ErrorResponse(val response: Response) : Issue data class NetworkIssue(val e: Exception) : Issue data class OtherIssue(val e: Throwable) : Issue - data class AppIntegrityCheckFailed(val details: IntegrityException) : Issue + data class AppIntegrityCheckFailed(val details: AppIntegrityException) : Issue } } diff --git a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt index 116cc255b..55f9ce5e6 100644 --- a/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt +++ b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGeneratorImpl.kt @@ -17,12 +17,10 @@ */ package com.infomaniak.core.crossapplogin.back -import android.content.pm.PackageManager +import com.infomaniak.core.appintegrity.AppIntegrityIssue import com.infomaniak.core.appintegrity.AppIntegrityManager import com.infomaniak.core.appintegrity.AppIntegrityManager.Companion.APP_INTEGRITY_MANAGER_TAG import com.infomaniak.core.appintegrity.exceptions.AppIntegrityException -import com.infomaniak.core.appintegrity.exceptions.FDroidUnsupportedIntegrityException -import com.infomaniak.core.appintegrity.exceptions.IntegrityException import com.infomaniak.core.appintegrity.exceptions.NetworkException import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable @@ -44,9 +42,6 @@ import okhttp3.RequestBody import splitties.init.appCtx import java.io.IOException -private const val INTEGRITY_UNAVAILABLE_ERROR_REGEX = - "(CANNOT_BIND_TO_SERVICE|PLAY_STORE_NOT_FOUND|PLAY_SERVICES_NOT_FOUND|PLAY_SERVICES_VERSION_OUTDATED|PLAY_STORE_VERSION_OUTDATED|TOO_MANY_REQUESTS)" - internal class DerivedTokenGeneratorImpl( coroutineScope: CoroutineScope, private val tokenRetrievalUrl: String, @@ -78,30 +73,7 @@ internal class DerivedTokenGeneratorImpl( }.last() } - override suspend fun checkIfAppIntegrityCouldSucceed(): Boolean = runCatching { - - val isRunningGrapheneOS = try { - appCtx.packageManager.getPackageInfo("app.grapheneos.info", PackageManager.MATCH_DISABLED_COMPONENTS) - true - } catch (_: PackageManager.NameNotFoundException) { - false - } - if (isRunningGrapheneOS) return false - - // Be sure the length is between 16 and 500 char so the computed nonce sent to AppIntegrity will have a correct size - val dummyChallenge = "Dummy challenge to know if AppIntegrity can be reached" - appIntegrityManager.requestClassicIntegrityVerdictToken(dummyChallenge) - true - }.cancellable().getOrElse { exception -> - when (exception) { - is FDroidUnsupportedIntegrityException -> false - is AppIntegrityException -> { - // If we get one of these Integrity exceptions, it means we won't be able to connect to the Service when tying later - exception.cause?.message?.contains(Regex(INTEGRITY_UNAVAILABLE_ERROR_REGEX)) == false - } - else -> true - } - } + override suspend fun isAppIntegrityGuaranteedToFail(): Boolean = appIntegrityManager.isGuaranteedToFail() private suspend fun attemptDerivingToken(token: String): Xor { val targetUrl = tokenRetrievalUrl @@ -162,25 +134,20 @@ internal class DerivedTokenGeneratorImpl( Xor.First(fetchNewAttestationToken(targetUrl)) }.cancellable().getOrElse { val issue: Issue = when (it) { - is IntegrityException -> Issue.AppIntegrityCheckFailed(it) is IOException, is NetworkException -> Issue.NetworkIssue(it) + is AppIntegrityException if (it.issue == AppIntegrityIssue.RetryLater.NetworkError) -> Issue.NetworkIssue(it) + is AppIntegrityException -> Issue.AppIntegrityCheckFailed(it) else -> Issue.OtherIssue(it) } Xor.Second(issue) } - // TODO: Improve error handling as some are recoverable (network or backends availability related), while some are not. - // See Play Integrity error codes: https://developer.android.com/google/play/integrity/error-codes, - // and remediation: https://developer.android.com/google/play/integrity/remediation - @Throws(AppIntegrityException::class, FDroidUnsupportedIntegrityException::class) + @Throws(AppIntegrityException::class) private suspend inline fun fetchNewAttestationToken(targetUrl: String): String { val challenge = appIntegrityManager.getChallenge() - val appIntegrityToken = appIntegrityManager.requestClassicIntegrityVerdictToken(challenge) - SentryLog.i(APP_INTEGRITY_MANAGER_TAG, "request for app integrity token successful") - - val attestationToken = appIntegrityManager.getApiIntegrityVerdict( - integrityToken = appIntegrityToken, + val attestationToken = appIntegrityManager.requestAttestationToken( + challenge = challenge, packageName = hostAppPackageName, targetUrl = targetUrl, ) diff --git a/gradle/core.versions.toml b/gradle/core.versions.toml index 0d456ef89..294081cf9 100644 --- a/gradle/core.versions.toml +++ b/gradle/core.versions.toml @@ -169,6 +169,7 @@ integrity = { module = "com.google.android.play:integrity", version.ref = "integ kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" } kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-play-services = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" }