Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AppIntegrity/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions AppIntegrity/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Infomaniak Mail - Android
~ Copyright (C) 2022-2024 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 <http://www.gnu.org/licenses/>.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<queries>
<package android:name="app.grapheneos.info"/>
</queries>

</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,11 +15,12 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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,
;
}
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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)
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/
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")
}
Loading
Loading