From 6e8ca6939ac9a54fdd4ef88cae69961cd4c6a2e8 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 6 May 2026 17:37:26 +0200 Subject: [PATCH 1/4] refactor: Add details and remediation to AppIntegrityException --- AppIntegrity/build.gradle.kts | 1 + .../appintegrity/AppIntegrityManagerFdroid.kt | 21 ++- .../AbstractAppIntegrityManager.kt | 4 +- .../core/appintegrity/AppIntegrityIssue.kt | 42 ++++++ .../AppIntegrityIssueExtensions.kt | 35 +++++ .../appintegrity/IntegrityDialogResponse.kt | 26 ++++ .../exceptions/IntegrityException.kt | 22 ++- .../AppIntegrityExceptionsConversion.kt | 128 ++++++++++++++++++ .../core/appintegrity/AppIntegrityManager.kt | 53 +++++--- .../back/DerivedTokenGenerator.kt | 4 +- .../back/DerivedTokenGeneratorImpl.kt | 28 ++-- gradle/core.versions.toml | 1 + 12 files changed, 311 insertions(+), 54 deletions(-) create mode 100644 AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssue.kt create mode 100644 AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AppIntegrityIssueExtensions.kt create mode 100644 AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/IntegrityDialogResponse.kt create mode 100644 AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityExceptionsConversion.kt 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..edc3b1592 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, @@ -39,16 +39,23 @@ class AppIntegrityManager( } override suspend fun requestClassicIntegrityVerdictToken(challenge: String): String { - throw FDroidUnsupportedIntegrityException() + throw unsupportedException() } - 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/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt index ed3b07cc1..e0034fd7e 100644 --- a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/AbstractAppIntegrityManager.kt @@ -48,8 +48,8 @@ abstract class AbstractAppIntegrityManager { abstract suspend fun getChallenge(): String - abstract suspend fun getApiIntegrityVerdict( - integrityToken: String, + 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/IntegrityDialogResponse.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/IntegrityDialogResponse.kt new file mode 100644 index 000000000..ef1570a4a --- /dev/null +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/IntegrityDialogResponse.kt @@ -0,0 +1,26 @@ +/* + * 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 + +enum class IntegrityDialogResponse { + Failed, + Unavailable, + Cancelled, + Successful, + ; +} diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt index aea3b122d..784966c69 100644 --- a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt @@ -17,9 +17,21 @@ */ package com.infomaniak.core.appintegrity.exceptions -/** An error has occurred when verified by api play integrity or our remote api */ -sealed class IntegrityException(override val cause: Throwable? = null) : Exception() +import androidx.activity.ComponentActivity +import com.infomaniak.core.appintegrity.AppIntegrityIssue +import com.infomaniak.core.appintegrity.IntegrityDialogResponse +import com.infomaniak.core.appintegrity.isRecoverable -class AppIntegrityException(cause: Throwable?) : IntegrityException(cause) - -class FDroidUnsupportedIntegrityException() : IntegrityException() +/** + * 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 (ComponentActivity) -> 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..ac6212dd6 --- /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 androidx.activity.ComponentActivity +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 (ComponentActivity) -> 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 (ComponentActivity) -> 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..829d06da1 100644 --- a/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManager.kt +++ b/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManager.kt @@ -21,7 +21,9 @@ import android.content.Context 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 +34,7 @@ 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 java.util.UUID /** @@ -92,26 +94,24 @@ class AppIntegrityManager(private val appContext: Context, userAgent: String) : * ###### Can throw Integrity exceptions. */ override suspend fun requestClassicIntegrityVerdictToken(challenge: String): String { + return requestClassicIntegrityVerdictTokenResponse(challenge)?.token() ?: "fake integrity token" + } + + private suspend fun requestClassicIntegrityVerdictTokenResponse(challenge: String): IntegrityTokenResponse? { 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")) - } - } + if (BuildConfig.DEBUG) return null 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 +125,39 @@ 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 = requestClassicIntegrityVerdictTokenResponse(challenge) + ?: error("Can't get attestation token in debug") + 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(), 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/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt b/CrossAppLogin/Back/src/main/kotlin/com/infomaniak/core/crossapplogin/back/DerivedTokenGenerator.kt index 5b69ca1d3..f1497b573 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 @@ -31,6 +31,6 @@ internal sealed interface DerivedTokenGenerator { 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..e99cade98 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 @@ -18,12 +18,12 @@ 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.appintegrity.isRecoverable import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.dynamicLazyMap @@ -44,9 +44,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, @@ -94,11 +91,7 @@ internal class DerivedTokenGeneratorImpl( 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 - } + is AppIntegrityException -> exception.issue.isRecoverable() else -> true } } @@ -162,25 +155,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" } From 2935b669e80f9ed7e8110bdae02b96465d112a33 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 6 May 2026 17:45:52 +0200 Subject: [PATCH 2/4] chore: Replace ComponentActivity with Activity since it's enough --- .../core/appintegrity/exceptions/IntegrityException.kt | 4 ++-- .../core/appintegrity/AppIntegrityExceptionsConversion.kt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt index 784966c69..5d149445c 100644 --- a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt +++ b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt @@ -17,7 +17,7 @@ */ package com.infomaniak.core.appintegrity.exceptions -import androidx.activity.ComponentActivity +import android.app.Activity import com.infomaniak.core.appintegrity.AppIntegrityIssue import com.infomaniak.core.appintegrity.IntegrityDialogResponse import com.infomaniak.core.appintegrity.isRecoverable @@ -32,6 +32,6 @@ class AppIntegrityException( errorCode: Int, val issue: AppIntegrityIssue, message: String = "AppIntegrity issue ($errorCode): $issue", - val showRemediationDialog: (suspend (ComponentActivity) -> IntegrityDialogResponse)? = null, + 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 index ac6212dd6..6594df1fb 100644 --- a/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityExceptionsConversion.kt +++ b/AppIntegrity/src/standard/kotlin/com/infomaniak/core/appintegrity/AppIntegrityExceptionsConversion.kt @@ -17,7 +17,7 @@ */ package com.infomaniak.core.appintegrity -import androidx.activity.ComponentActivity +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 @@ -56,7 +56,7 @@ internal fun IntegrityServiceException.toAppIntegrityException( private fun showStandardRemediationDialog( exception: StandardIntegrityException, standardIntegrityManager: StandardIntegrityManager -): suspend (ComponentActivity) -> IntegrityDialogResponse = { activity -> +): suspend (Activity) -> IntegrityDialogResponse = { activity -> val dialogRequest = StandardIntegrityManager.StandardIntegrityDialogRequest.builder() .setActivity(activity) .setTypeCode(IntegrityDialogTypeCode.GET_INTEGRITY) @@ -70,7 +70,7 @@ private fun showStandardRemediationDialog( private fun showRemediationDialog( exception: IntegrityServiceException, integrityManager: IntegrityManager -): suspend (ComponentActivity) -> IntegrityDialogResponse = { activity -> +): suspend (Activity) -> IntegrityDialogResponse = { activity -> val dialogRequest = IntegrityDialogRequest.builder() .setActivity(activity) .setTypeCode(IntegrityDialogTypeCode.GET_INTEGRITY) From c37dc8be7f01a050c9736fe67d18a4cb491b8a6b Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Wed, 6 May 2026 17:46:26 +0200 Subject: [PATCH 3/4] chore: Rename file to match hosted class --- .../{IntegrityException.kt => AppIntegrityException.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/{IntegrityException.kt => AppIntegrityException.kt} (100%) diff --git a/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt b/AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/AppIntegrityException.kt similarity index 100% rename from AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/IntegrityException.kt rename to AppIntegrity/src/main/kotlin/com/infomaniak/core/appintegrity/exceptions/AppIntegrityException.kt From 26b567f76cee961ca3b922818ffc63f193f018a8 Mon Sep 17 00:00:00 2001 From: Louis CAD Date: Thu, 7 May 2026 10:46:08 +0200 Subject: [PATCH 4/4] refactor: Introduce isGuaranteedToFail function for AppIntegrityManager --- .../appintegrity/AppIntegrityManagerFdroid.kt | 4 +- AppIntegrity/src/main/AndroidManifest.xml | 24 ++++++++++ .../AbstractAppIntegrityManager.kt | 15 +++---- .../core/appintegrity/AppIntegrityManager.kt | 45 ++++++++++++++----- .../Back/src/main/AndroidManifest.xml | 1 - .../back/BaseCrossAppLoginViewModel.kt | 2 +- .../back/DerivedTokenGenerator.kt | 2 +- .../back/DerivedTokenGeneratorImpl.kt | 23 +--------- 8 files changed, 67 insertions(+), 49 deletions(-) create mode 100644 AppIntegrity/src/main/AndroidManifest.xml 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 edc3b1592..9abb685e5 100644 --- a/AppIntegrity/src/fdroid/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManagerFdroid.kt +++ b/AppIntegrity/src/fdroid/kotlin/com/infomaniak/core/appintegrity/AppIntegrityManagerFdroid.kt @@ -38,9 +38,7 @@ class AppIntegrityManager( onFailure() } - override suspend fun requestClassicIntegrityVerdictToken(challenge: String): String { - throw unsupportedException() - } + override suspend fun isGuaranteedToFail(): Boolean = true override suspend fun getChallenge() = throw unsupportedException() 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 e0034fd7e..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,18 +36,15 @@ 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 + /** + * Currently uses classic Play Integrity API. + * + * @throws com.infomaniak.core.appintegrity.exceptions.AppIntegrityException + */ abstract suspend fun requestAttestationToken( challenge: String, packageName: String, 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 829d06da1..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,6 +18,7 @@ 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 @@ -35,6 +36,7 @@ import com.infomaniak.core.sentry.SentryLog import io.sentry.Sentry import io.sentry.SentryLevel import kotlinx.coroutines.tasks.await +import splitties.init.appCtx import java.util.UUID /** @@ -85,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 * @@ -93,17 +117,15 @@ class AppIntegrityManager(private val appContext: Context, userAgent: String) : * * ###### Can throw Integrity exceptions. */ - override suspend fun requestClassicIntegrityVerdictToken(challenge: String): String { - return requestClassicIntegrityVerdictTokenResponse(challenge)?.token() ?: "fake integrity token" - } + 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 - private suspend fun requestClassicIntegrityVerdictTokenResponse(challenge: String): IntegrityTokenResponse? { val nonce = Base64.encodeToString(challenge.toByteArray(), Base64.DEFAULT) - // 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 - return runCatching { val tokenRequest = IntegrityTokenRequest.builder().setNonce(nonce).build() classicIntegrityTokenProvider.requestIntegrityToken(tokenRequest).await() @@ -130,19 +152,18 @@ class AppIntegrityManager(private val appContext: Context, userAgent: String) : packageName: String, targetUrl: String ): String { - val tokenResponse = requestClassicIntegrityVerdictTokenResponse(challenge) - ?: error("Can't get attestation token in debug") + val tokenResponse = requestClassicIntegrityVerdictToken(challenge) return getApiIntegrityVerdict(tokenResponse, packageName, targetUrl) } private suspend fun getApiIntegrityVerdict( - integrityToken: IntegrityTokenResponse, + integrityToken: IntegrityTokenResponse?, packageName: String, targetUrl: String, ): String { runCatching { val apiResponse = appIntegrityRepository.getJwtToken( - integrityToken = integrityToken.token(), + integrityToken = integrityToken?.token() ?: "fake integrity token", packageName = packageName, targetUrl = targetUrl, challengeId = challengeId, 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 f1497b573..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 @@ -25,7 +25,7 @@ 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 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 e99cade98..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,13 +17,11 @@ */ 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.NetworkException -import com.infomaniak.core.appintegrity.isRecoverable import com.infomaniak.core.common.Xor import com.infomaniak.core.common.cancellable import com.infomaniak.core.common.dynamicLazyMap @@ -75,26 +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 AppIntegrityException -> exception.issue.isRecoverable() - else -> true - } - } + override suspend fun isAppIntegrityGuaranteedToFail(): Boolean = appIntegrityManager.isGuaranteedToFail() private suspend fun attemptDerivingToken(token: String): Xor { val targetUrl = tokenRetrievalUrl