Для работы многих Android-приложений требуются секретные данные, например ключи API. В большинстве случаев эти ключи предназначены для сервисов Google и защищаются с помощью ключа подписи приложения (его можно настроить в Google Cloud Console), так что утечка таких ключей не является проблемой. Однако иногда приходится добавлять ключи или другие секреты, которые не являются защищенными и должны быть скрыты. В этой статье я покажу, как это сделать.

Существует 2 подхода к обработке секретов клиента.

  • Пакетное размещение секретов в бинарном файле приложения.
  • Получение секретов из удаленного хранилища (сервера).

Первый подход мы рассмотрели в 1-й части статьи. Здесь будет продемонстрирован второй подход.

Основное преимущество этого подхода заключается в более надежной защищенности секретов. Поскольку они не включены в пакет, поверхность атаки переносится на удаленный сервис, что усложняет задачу злоумышленнику.

Еще один плюс этого подхода заключается в том, что секреты могут быть заменены (в случае незаконного доступа или истечения срока действия) без необходимости обновлять клиентское приложение (выпускать новую версию).

Remote Configuration

Большинство Android-приложений используют такие сервисы Firebase, как Cloud Messaging, Real-Time Database, Crashlytics и др.

Одним из сервисов, предоставляемых Firebase, является Remote Configuration (удаленная конфигурация). Он обладает продвинутым функционалом для A/B-тестирования, предлагая широкий спектр значений для тонкой настройки в зависимости от страны, версии приложения и многого другого.

Чтобы использовать этот сервис для получения секретов, выполните следующие действия.

  1. Создайте проект Firebase и Remote Configuration в консоли.

В моем случае конфигурация выглядит следующим образом:

Remote Configuration
  1. Интегрируйте сервисы Firebase в приложение.

В качестве первого шага нужно будет загрузить файл google-services.json, в котором указана вся информация о проекте Firebase.

Затем необходимо применить Gradle-плагин play-services. Плагин считывает содержимое файла google-services.json и генерирует строковые ресурсы для приложения. Это выглядит следующим образом:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="default_web_client_id" translatable="false">442808137136-fuaph2labpcoo6iosp9uak8f5mqjk2bi.apps.googleusercontent.com</string>
<string name="gcm_defaultSenderId" translatable="false">442808137136</string>
<string name="google_api_key" translatable="false">AIzaSyA67njI-zrCiwrFBsOM5uFBfAvpdp_bq0c</string>
<string name="google_app_id" translatable="false">1:442808137136:android:a95fdc8e6c74f4e58c1275</string>
<string name="google_crash_reporting_api_key" translatable="false">AIzaSyA67njI-zrCiwrFBsOM5uFBfAvpdp_bq0c</string>
<string name="google_storage_bucket" translatable="false">integrity-api-poc-390116.appspot.com</string>
<string name="project_id" translatable="false">integrity-api-poc-390116</string>
</resources>
  1. Используйте FirebaseRemoteConfig для получения секретов.

Вот пример реализации хранилища данных:

internal class RemoteConfigSecretsDataSource @Inject constructor(
private val remoteConfig: FirebaseRemoteConfig,
private val remoteConfigDtoMapper: RemoteConfigDtoMapper,
) : RemoteSecretsDataSource {
private val configurationTask: Task<Void>

init {
val configuration = FirebaseRemoteConfigSettings.Builder()
.setFetchTimeoutInSeconds(FETCH_INTERVAL.inWholeSeconds)
.build()
configurationTask = remoteConfig.setConfigSettingsAsync(configuration)
}

override suspend fun getSecrets(): Secrets = withContext(Dispatchers.IO) {
ensureConfigFetched()
val dtoString = remoteConfig.getString(SECRETS_KEY)
return@withContext remoteConfigDtoMapper(dtoString)
}

private suspend fun ensureConfigFetched() {
configurationTask.await()
// Проверка актуальности Remote Configuration
if (remoteConfig.info.lastFetchStatus == FirebaseRemoteConfig.LAST_FETCH_STATUS_NO_FETCH_YET
|| (System.currentTimeMillis() - remoteConfig.info.fetchTimeMillis).milliseconds >= FETCH_INTERVAL
) {
// Получение и активация последних значений
remoteConfig.fetchAndActivate().await()
} else {
// Активация текущих кэшированных значений
remoteConfig.activate()
}
}

private companion object {
const val SECRETS_KEY = "SECRETS"
val FETCH_INTERVAL: Duration = 1.days
}
}
internal class RemoteConfigDtoMapper(private val json: Json) {
@Inject
constructor() : this(Json { ignoreUnknownKeys = true })

operator fun invoke(dtoString: String): Secrets {
val dto = json.decodeFromString<RemoteConfigDTO>(dtoString)
return Secrets(
serverApiKey = dto.apiKey,
serverApiPassword = dto.apiPassword,
)
}

@Serializable
private data class RemoteConfigDTO(
@SerialName("API_KEY")
val apiKey: String,
@SerialName("API_PASSWORD")
val apiPassword: String,
)
}

Демонстрация Firebase Remote Config

Вердикт

Этот подход очень прост в интеграции и использовании, но у него есть огромный недостаток: у него нет никакой защиты. Таким образом, любое приложение, которому известны идентификаторы проектов Firebase (которые легко извлекаются из пакетов приложений), может получить значения из Remote Configuration.

API Play Integrity/Firebase App Check

Основная проблема подходов на основе бэкенда заключается в необходимость определять, какие вызовы совершаются от легитимных клиентов, а какие — нет. Если просто добавить определенный вызов API на бэкенд из приложения для получения секретов, его все равно можно будет легко перепрограммировать.

Механизм, с помощью которого клиент может удостовериться в том, что он «общается» с легитимным бэкендом, уже существует и называется «пиннинг сертификатов» («certificate pinning»). Этот механизм требует, чтобы SSL-сертификат бэкенда приложения имел набор сертификационных пинов. Пины проходят процесс валидации во время SSL-квитирования при установлении контакта. Если в цепочке сертификатов не находятся знакомые пины, квитирование не удается и вызов API не выполняется. Этот механизм используется для защиты клиента от атак с применением технологии «незаконный посредник» («man-in-the-middle»).

Тем не менее пиннинг сертификатов нельзя использовать со стороны бэкенда для проверки «источника» вызова. Если вы включите что-то в сам вызов (какой-то идентификатор, API KEY или что-нибудь еще), то получите ту же самую проблему, которую пытаетесь решить. Поэтому лучшим подходом будет подтверждение того, что вызов сделан из приложения, которое подписано вами (с помощью известного ключа). Такую функциональность предоставляет API Play Integrity.

Этот API использует сервисы Google Play для проверки того, что приложение не подвергалось изменениям и подписано правильным ключом (сравнивая с приложением от Google Play).

Рабочий поток:

Рабочий поток API Integrity (из официальной документации)
  1. Приложение запрашивает токен целостности у API Integrity от Google Play.
  1. Приложение отправляет запрос к API бэкенда с токеном целостности.
  1. Бэкенд проверяет токен целостности и, если он валиден, отвечает секретами.     

Сам поток выглядит довольно просто, однако требует корректной обработки результатов проверки, что может усложнить процесс. Firebase помогает еще более упростить поток с помощью функции безопасности Firebase App Check, которая использует API Integrity от Google Play «под капотом».

Чтобы использовать функцию Firebase App Check, ее нужно подключить в консоли Firebase.

Используйте Firebase App Check для получения секретов следующим образом.

  1. Инициализируйте Firebase App Check:
val firebaseAppCheck = FirebaseAppCheck.getInstance()
firebaseAppCheck.installAppCheckProviderFactory(
    PlayIntegrityAppCheckProviderFactory.getInstance()
)
  1. Имплементируйте источник данных:
internal class BackendSecretsDataSource @Inject constructor(
private val client: HttpClient,
private val appCheck: FirebaseAppCheck,
private val mapper: SecretsDtoMapper,
) : RemoteSecretsDataSource {
override suspend fun getSecrets(): Secrets = withContext(Dispatchers.IO) {
// Получение токена AppCheck
val token = appCheck.limitedUseAppCheckToken
.await()
.token

// Отправка запроса к бэкенду с токеном для получения секретов
val dto = client.get("/secrets") {
headers {
append("X-Firebase-AppCheck", token)
}
}.body<SecretsDto>()

return@withContext mapper.map(dto)
}
}
  1. Валидация на стороне сервера может выглядеть следующим образом:
@Serializable
data class SecretsResponse(
val apiKey: String,
val apiPassword: String,
)


private val SERVER_SECRETS = SecretsResponse(
apiKey = "VERY_SECRET_API_KEY",
apiPassword = "VERY_SECRET_API_PASSWORD",
)

private const val FIREBASE_PROJECT_NUMBER = "442808137136"

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

fun Application.module() {
configureRouting()
configureSerialization()
}

fun Application.configureRouting() {
routing {
secretsRouting()
}
}

fun Application.configureSerialization() {
install(ContentNegotiation) {
json()
}
}

fun Route.secretsRouting() {
route("/secrets") {
get {
val token = call.request.headers["X-Firebase-AppCheck"] ?: run {
call.respond(HttpStatusCode.Unauthorized, "No required header")
return@get
}

val jwt = try {
val keys =
JSONWebKeySetHelper.retrieveKeysFromJWKS("https://firebaseappcheck.googleapis.com/v1/jwks")

val verifiers = keys.map {
RSAVerifier.newVerifier(JSONWebKey.parse(it))
}

verifiers.firstNotNullOf { verifier ->
runCatching {
JWT.getDecoder().decode(token, verifier)
}.getOrNull()
}
} catch (e: Exception) {
println("Error decoding token: ${e.message}")
call.respond(HttpStatusCode.Unauthorized, "Error during token decoding")
return@get
}
if (
jwt.header.algorithm != Algorithm.RS256 ||
jwt.header.type != "JWT" ||
jwt.issuer != "https://firebaseappcheck.googleapis.com/$FIREBASE_PROJECT_NUMBER" ||
jwt.isExpired ||
(jwt.audience as List<*>).none { it != "projects/$FIREBASE_PROJECT_NUMBER" }
) {
call.respond(HttpStatusCode.Unauthorized, "Error during token validation")
return@get
}

call.respond(HttpStatusCode.OK, SERVER_SECRETS)
}
}
}

Демонстрация

Случай успеха (когда приложение подписано ожидаемым ключом):

Случай ошибки (когда приложение подписано неизвестным ключом):

Вердикт

Такой подход значительно повышает безопасность приложений и мешает злоумышленникам получить реальные секреты. Однако он не является абсолютной защитой и имеет ряд недостатков.

  • Для получения токена целостности необходимо использовать сервисы Google Play.
  • API Play Integrity имеет ограничение в 10 тыс. бесплатных аттестаций в месяц.

Полный пример можно найти на GitHub.

Читайте также:

Читайте нас в Telegram, VK и Дзен


Перевод статьи Artsem Kurantsou: Secrets in Android Part 2.

Предыдущая статьяNetlas — полноценный инструмент интернет-разведки