Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add app integrity api #234

Merged
merged 23 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1bcf84c
feat(AppIntegrity): Create Module and repository
FabianDevel Nov 7, 2024
d405c22
feat(AppIntegrity): POC to call our api
FabianDevel Nov 7, 2024
5fc2c50
feat(AppIntegrity): Add AppIntegrityRoutes and update Manager with cl…
FabianDevel Nov 14, 2024
1e518d8
refactor(AppIntegrity): Rename app integrity package name
FabianDevel Nov 15, 2024
05a0bcd
feat(AppIntegrity): Add app integrity check before starting upload
FabianDevel Nov 15, 2024
43aca97
feat(AppIntegrity): Add indeterminate progress when checking integrity
FabianDevel Dec 4, 2024
c7d8bb2
feat(AppIntegrity): Add sentry to the manager
FabianDevel Dec 4, 2024
2c56347
refactor: Clean code
FabianDevel Dec 4, 2024
f345961
refactor(AppIntegrity): Move AppIntegrityManager in screen instead of…
FabianDevel Dec 5, 2024
1bb1fbe
feat(AppIntegrity): Add mobile_token to the call header
FabianDevel Dec 5, 2024
758f492
feat(AppIntegrity): Put back integrity tests on api
FabianDevel Dec 6, 2024
469dd95
feat(AppIntegrity): Add preprod and prod routes
FabianDevel Dec 12, 2024
567ba44
feat(AppIntegrity): Better state management of the AppIntegrityResult
FabianDevel Dec 12, 2024
0f9f755
feat(AppIntegrity): Send the token as header in initUploadSession
FabianDevel Dec 13, 2024
8ba9f5a
refactor(AppIntegrity): Clean code
FabianDevel Dec 13, 2024
60f0443
feat(AppIntegrity): Force debug build to check app integrity on preprod
FabianDevel Dec 16, 2024
ec85e09
chore: Remove Useless example test and rename the Unit test file
FabianDevel Dec 16, 2024
9441ab6
refactor(AppIntegrity): Inject manager
FabianDevel Dec 17, 2024
3c2957e
feat(AppIntegrity): Add a basic error for user
FabianDevel Dec 17, 2024
5e5e9f4
feat(AppIntegrity): Disable integrity check for debug
FabianDevel Dec 17, 2024
28145a7
chore(AppIntegrity): Rename `checkAppIntegrityBeforeSendingTransfer` …
FabianDevel Dec 17, 2024
6d405c0
docs(AppIntegrity): Add small documentation to the AppIntegrityManager
FabianDevel Dec 17, 2024
fba120f
refactor(AppIntegrity): Use a snackbar instead of a toast to display …
FabianDevel Dec 17, 2024
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
62 changes: 62 additions & 0 deletions Core2/AppIntegrity/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
kotlin("plugin.serialization") version libs.versions.kotlin
}

val sharedCompileSdk: Int by rootProject.extra
val sharedMinSdk: Int by rootProject.extra
val sharedJavaVersion: JavaVersion by rootProject.extra

android {
namespace = "com.infomaniak.core2.appintegrity"
compileSdk = sharedCompileSdk

defaultConfig {
minSdk = sharedMinSdk

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles("consumer-rules.pro")
}

buildTypes {
release {
isMinifyEnabled = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
buildConfigField("String", "APP_INTEGRITY_BASE_URL", "\"https://api.infomaniak.com\"")
}

debug {
buildConfigField("String", "APP_INTEGRITY_BASE_URL", "\"https://api.preprod.dev.infomaniak.ch\"")
}
}

buildFeatures {
buildConfig = true
}

compileOptions {
sourceCompatibility = sharedJavaVersion
targetCompatibility = sharedJavaVersion
}

kotlinOptions {
jvmTarget = sharedJavaVersion.toString()
}
}

dependencies {

implementation(project(":Core2:Sentry"))

implementation(core2.integrity)
implementation(core2.ktor.client.core)
implementation(core2.ktor.client.content.negociation)
implementation(core2.ktor.client.json)
implementation(core2.ktor.client.encoding)
implementation(core2.ktor.client.okhttp)
implementation(core2.kotlinx.serialization.json)
testImplementation(core2.junit)
testImplementation(core2.ktor.client.mock)
androidTestImplementation(core2.androidx.junit)
}
Empty file.
21 changes: 21 additions & 0 deletions Core2/AppIntegrity/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html

# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable

# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Infomaniak SwissTransfer - Multiplatform
* Copyright (C) 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/>.
*/
package com.infomaniak.core2.appintegrity

import com.infomaniak.core2.appintegrity.exceptions.ApiException
import com.infomaniak.core2.appintegrity.exceptions.NetworkException
import com.infomaniak.core2.appintegrity.exceptions.UnexpectedApiErrorFormatException
import com.infomaniak.core2.appintegrity.exceptions.UnknownException
import com.infomaniak.core2.appintegrity.models.ApiError
import io.ktor.client.HttpClient
import io.ktor.client.HttpClientConfig
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpResponseValidator
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.serialization.kotlinx.json.json
import kotlinx.io.IOException
import kotlinx.serialization.json.Json

internal class ApiClientProvider(engine: HttpClientEngine = OkHttp.create()) {

val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
isLenient = true
useAlternativeNames = false
}

val httpClient = createHttpClient(engine)

private fun createHttpClient(engine: HttpClientEngine): HttpClient {
val block: HttpClientConfig<*>.() -> Unit = {
expectSuccess = true
install(ContentNegotiation) {
json([email protected])
}
install(ContentEncoding) {
gzip()
}
install(HttpTimeout) {
requestTimeoutMillis = REQUEST_TIMEOUT
connectTimeoutMillis = REQUEST_TIMEOUT
socketTimeoutMillis = REQUEST_TIMEOUT
}
install(HttpRequestRetry) {
retryOnExceptionIf(maxRetries = MAX_RETRY) { _, cause ->
cause.isNetworkException()
}
delayMillis { retry -> retry * RETRY_DELAY }
}
HttpResponseValidator {
validateResponse { response: HttpResponse ->
val statusCode = response.status.value
if (statusCode >= 300) {
val bodyResponse = response.bodyAsText()
runCatching {
val apiError = json.decodeFromString<ApiError>(bodyResponse)
throw ApiException(apiError.errorCode, apiError.message)
}.onFailure {
throw UnexpectedApiErrorFormatException(statusCode, bodyResponse)
}
}
}
handleResponseExceptionWithRequest { cause, _ ->
when (cause) {
is IOException -> throw NetworkException("Network error: ${cause.message}")
is ApiException, is UnexpectedApiErrorFormatException -> throw cause
else -> throw UnknownException(cause)
}
}
}
}

return HttpClient(engine, block)
}

private fun Throwable.isNetworkException() = this is IOException

companion object {
private const val REQUEST_TIMEOUT = 10_000L
private const val MAX_RETRY = 3
private const val RETRY_DELAY = 500L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
* Infomaniak Core2 - Android
* Copyright (C) 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/>.
*/
package com.infomaniak.core2.appintegrity

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.IntegrityTokenRequest
import com.google.android.play.core.integrity.StandardIntegrityManager.*
import com.infomaniak.core2.appintegrity.exceptions.NetworkException
import com.infomaniak.sentry.SentryLog
import io.sentry.Sentry
import io.sentry.SentryLevel
import java.util.UUID

/**
* Manager used to verify that the device used is real and doesn't have integrity problems
*
* There is 2 types of Request:
* - the standard request ([requestIntegrityVerdictToken]) that need a warm-up first ([warmUpTokenProvider])
* - the classic request ([requestClassicIntegrityVerdictToken]) that need additional Api checks
*/
class AppIntegrityManager(private val appContext: Context) {

private var appIntegrityTokenProvider: StandardIntegrityTokenProvider? = null
private val classicIntegrityTokenProvider by lazy { IntegrityManagerFactory.create(appContext) }
private val appIntegrityRepository by lazy { AppIntegrityRepository() }

private var challenge = ""
private var challengeId = ""

/**
* This function is needed in case of standard verdict request by [requestIntegrityVerdictToken].
* It must be called once at the initialisation because it can take a long time (up to several minutes)
*/
fun warmUpTokenProvider(appCloudNumber: Long, onFailure: () -> Unit) {
val integrityManager = IntegrityManagerFactory.createStandard(appContext)
integrityManager.prepareIntegrityToken(
PrepareIntegrityTokenRequest.builder().setCloudProjectNumber(appCloudNumber).build()
).addOnSuccessListener { tokenProvider ->
appIntegrityTokenProvider = tokenProvider
SentryLog.i(APP_INTEGRITY_MANAGER_TAG, "warmUpTokenProvider: Success")
}.addOnFailureListener { manageException(it, "Error during warmup", onFailure) }
}

/**
* Standard verdict request for Integrity token
* It should protect automatically from replay attack, but for now this protection seemed to not be working
*/
fun requestIntegrityVerdictToken(
requestHash: String,
onSuccess: (String) -> Unit,
onFailure: () -> Unit,
onNullTokenProvider: (String) -> Unit,
) {
if (appIntegrityTokenProvider == null) {
onNullTokenProvider("Integrity token provider is null during a verdict request. This should not be possible")
} else {
appIntegrityTokenProvider?.request(StandardIntegrityTokenRequest.builder().setRequestHash(requestHash).build())
?.addOnSuccessListener { response -> onSuccess(response.token()) }
?.addOnFailureListener { manageException(it, "Error when requiring a standard integrity token", onFailure) }
}
}

/**
* 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.
*/
fun requestClassicIntegrityVerdictToken(onSuccess: (String) -> Unit, onFailure: () -> Unit) {

// You can comment this if you want to test the App Integrity (also see getJwtToken in AppIntegrityRepository)
if (BuildConfig.DEBUG) {
onSuccess("Basic app integrity token")
return
}

val nonce = Base64.encodeToString(challenge.toByteArray(), Base64.DEFAULT)

classicIntegrityTokenProvider.requestIntegrityToken(IntegrityTokenRequest.builder().setNonce(nonce).build())
?.addOnSuccessListener { response -> onSuccess(response.token()) }
?.addOnFailureListener { manageException(it, "Error when requiring a classic integrity token", onFailure) }
}

suspend fun getChallenge(onSuccess: () -> Unit, onFailure: () -> Unit) = runCatching {
generateChallengeId()
val apiResponse = appIntegrityRepository.getChallenge(challengeId)
SentryLog.d(
tag = APP_INTEGRITY_MANAGER_TAG,
msg = "challengeId hash : ${challengeId.hashCode()} / challenge hash: ${apiResponse.data.hashCode()}",
)
apiResponse.data?.let { challenge = it }
onSuccess()
}.getOrElse {
manageException(it, "Error fetching challenge", onFailure)
}

suspend fun getApiIntegrityVerdict(
integrityToken: String,
packageName: String,
targetUrl: String,
onSuccess: (String) -> Unit,
onFailure: () -> Unit,
) {
runCatching {
val apiResponse = appIntegrityRepository.getJwtToken(
integrityToken = integrityToken,
packageName = packageName,
targetUrl = targetUrl,
challengeId = challengeId,
)
apiResponse.data?.let(onSuccess)
}.getOrElse {
manageException(it, "Error during Integrity check by API", onFailure)
}
}

/**
* Only used to test App Integrity in Apps before their real backend implementation
*/
suspend fun callDemoRoute(mobileToken: String) {
runCatching {
val apiResponse = appIntegrityRepository.demo(mobileToken)
val logMessage = if (apiResponse.isSuccess()) {
"Success demo route response: ${apiResponse.data}"
} else {
"Error demo route : ${apiResponse.error?.errorCode}"
}
Log.d(APP_INTEGRITY_MANAGER_TAG, logMessage)
}.getOrElse {
it.printStackTrace()
}
}

private fun generateChallengeId() {
challengeId = UUID.randomUUID().toString()
}

private fun manageException(exception: Throwable, errorMessage: String, onFailure: () -> Unit) {
if (exception !is NetworkException) {
Sentry.captureMessage(errorMessage, SentryLevel.ERROR) { scope ->
scope.setTag("exception", exception.message.toString())
scope.setExtra("stacktrace", exception.printStackTrace().toString())
}
}
exception.printStackTrace()
onFailure()
}

companion object {
const val APP_INTEGRITY_MANAGER_TAG = "App integrity manager"
const val ATTESTATION_TOKEN_HEADER = "Ik-mobile-token"
}
}
Loading
Loading