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