Skip to content

Commit

Permalink
feat: Add app integrity api (#234)
Browse files Browse the repository at this point in the history
  • Loading branch information
tevincent authored Dec 17, 2024
2 parents 7bdaedf + fba120f commit fe03ad9
Show file tree
Hide file tree
Showing 28 changed files with 902 additions and 15 deletions.
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(this@ApiClientProvider.json)
}
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

0 comments on commit fe03ad9

Please sign in to comment.