Всем привет, Я, Назаров Александр, техлид в платформенной команде Android SMLab.
Тема безопасности становится актуальнее день ото дня.
Вот и у нас встал вопрос безопасности данных в приложениях.
Хотел поделиться опытом как мы скрыли секреты из исходников мобильного приложения.
Проблема: секреты в коде — это зло
Наверное каждый разработчик знает, что хранить значения паролей, ключей, токенов доступа к системам в исходниках это подарок для злоумышленника.
Подобный код может выглядеть так:
object ApiKeys {
const val MAPS_KEY = "AIzaSyA***************"
const val BACKEND_TOKEN = "secret_token_123"
}
После компиляции даже если включен обфускатор строки останутся как есть в скомпилированном коде
и их просто увидеть глазами, посмотрев содержимое файла.
Например, так
-
через apktool
apktool d app.apk -o app_decodedи искать в smali-коде -
декомпилировать
jadx app.apk -d outи искать в java
"Окей, но можно вынести их из кода на этап сборки и прописать в переменных среды, build.gradle конфигах"
Основной способ сделать так, пробрасывать их через BuildConfig:
app/build.gradle
android {
defaultConfig {
buildConfigField("String", "BACKEND_TOKEN", ""secret_token_123"")
buildConfigField("String", "API_KEY", """+System.getenv("API_KEY")+""")
}
}
Но в таком случае в конечном APK они все равно будут лежать как константы строк.
И все равно их можно достать через декомпиляцию или просто просмотр содержимого файла.
Также хранение секретов в исходниках имеет другой минус - любой пользователь с доступом к репозиторию может получить их.
Задача
Итак, проблема ясна и теперь, чтобы ее решить, стоит определить требования к конечному решению.
Требования такие:
-
секреты не должны храниться ни в исходниках, ни в apk
-
должна быть возможность удобно запускать проект локально
-
CI/CD получает секреты прозрачно
-
Максимально ограничить доступ к PROD секретам(то есть секреты для PROD версии приложения)
Идея решения
Решение можно разбить на 2 части
-
Подтягивание секретов из key-value хранилища
-
Скрытие значений секретов в исходниках
Пункт 1 может быть любое хранилище секретов. На этом пункте не буду заострять внимание.
Будем считать, что мы выбрали самое лучшее хранилище, которое имеет какой-то REST API и scope-доступы к секретам.
Через него можно по апи-ключам получить доступ к связке key-value определенного приложения
Итак, с этими задачами хорошо справиться gradle-плагин.
В исходниках мы подключаем плагин, указываем откуда из хранилища брать секреты.
АПИ-Ключи доступа будут настраиваться либо на CI, либо локально на машине разработчика
Что плагин будет делать:
-
Подтягивать секреты из хранилища при сборке.
-
Генерировать котлин object с key-value связкой секретов приложения, но реальные значения кладутся не как константы строк.
-
Реальные значения видны только в рантайме.
Реализация: Gradle-плагин
Создание плагина
Создать плагин не так сложно
Добавляем в build.gradle
gradlePlugin {
plugins {
create("SecretsPlugin") {
id = "com.android.secrets"
displayName = "Secrets Android Plugin"
implementationClass = "com.android.secrets.SecretsPlugin"
}
}
}
Класс плагина
class SecretsPlugin : Plugin<Project> {
override fun apply(project: Project) {
}
}
Метод apply вызовется когда плагин будет добавлен в блок plugins gradle-модуля.
В этот момент необходимо понять откуда тянуть секреты и получить все необходимые параметры:
-
URL хранилища секретов
-
апи-ключи доступа к хранилищу
-
неймспейс для стенда(dev, test, prod) проекта в хранилище.
URL как глобальное свойство необходимо читать из переменных среды и у него будет дефолтное значение.
Апи-ключи доступа понадобятся как на CI, так и при локальной сборке.
Тогда кроме переменных среды необходимо их задавать где-то, но чтобы это не попало в VCS.
Выход - local.properties. Только дело в том, что в Gradle API нет способа прочитать свойства оттуда. Поэтому мы напишем свой способ читать их.
С kotlin это сделать не так сложно
return File("local.properties").readLines()
.asSequence()
.map { it.trim() }
.filterNot { it.startsWith("#") || !it.contains("=") }
.map { it.split("=") }
.associateBy(
keySelector = { it[0] },
valueTransform = { it[1] }
)
В плагинах конфиг запуска берется из extension на типе Project.
override fun apply(project: Project) {
project.extensions.create("secrets", SecretsProjectExtension::class.java)
}
abstract class SecretsProjectExtension @Inject constructor(project: Project) {
val secretsClassNameProperty: Property<String> =
objects.property(String::class.java)
var secretsClassName: String
get() = secretsClassNameProperty.getOrElse("")
set(value) = secretsClassNameProperty.set(value)
}
В build.gradle(.kts)
secrets {
secretsClassName = "AppSecrets"
}
Но тут возникает еще сложность. Нам необходимо сделать часть параметров уникальных для flavor(namespace для стенда проекта).
При этом необходимо учесть, что плагин может быть использован, как в проекте с GroovyDSL, так и KotlinDSL.
В версии Android Gradle plugin 7.4+ есть апи создания экстеншена для блока flavor
AndroidComponentsExtension.registerExtension(
DslExtension.Builder("secretsFlavor")
.extendBuildTypeWith(SecretsBuildTypeExtension::class.java)
.extendProductFlavorWith(SecretsFlavorExtension::class.java)
.build()
) { config ->
// ...
project.objects.newInstance(SecretsVariantExtension::class.java)
}
В build.gradle
android {
productFlavors {
dev {
secretsFlavor {
namespace = "dev/example-app"
}
}
prod {
secretsFlavor {
namespace = "prod/example-app"
}
}
}
}
То есть в проектах использующих AGP 7.4+ для flavor будет способ задать параметры.
Для версий ниже такого способа нет. В случае KotlinDSL можем написать kotlin-extension функции - ок, а для GroovyDSL придется сделать дублирование структуры flavor в отдельный extension
abstract class Agp74OrLowerSecretsExtension @Inject constructor(project: Project) {
private val objects = project.objects
internal val flavors =
objects.domainObjectContainer(Agp74OrLowerSecretsFlavorExtension::class.java)
fun flavors(action: Action<NamedDomainObjectContainer<Agp74OrLowerSecretsFlavorExtension>>) {
action.execute(flavors)
}
}
abstract class Agp74OrLowerSecretsFlavorExtension(
val flavorName: String,
objects: ObjectFactory
) : Named {
private val pathProperty: Property<String> = objects.property(String::class.java)
private val dimensionProperty: Property<String> = objects.property(String::class.java)
var path: String
get() = pathProperty.getOrElse("")
set(value) = pathProperty.set(value)
var dimension: String?
get() = dimensionProperty.orNull
set(value) = dimensionProperty.set(value)
override fun getName(): String = flavorName
override fun toString(): String {
return "Agp7orLowerPluginFlavorExtension{flavorName=$flavorName, " +
"dimension=$dimension, " +
"path=$path" +
"}"
}
}
В build.gradle
Agp74OrLower {
flavors {
dev {
dimension = "app"
path = "dev/example-app"
}
prod {
dimension = "app"
path = "prod/example-app"
}
}
}
После получения параметров можно создавать gradle-таску, которая будет делать всю работу - читать секреты и генерировать класс с ними.
Когда таска создана хотелось бы ее присоединить к процессу сборки, чтобы каждый раз явно не запускать.
Тут достаточно запустить sync gradle и посмотреть подходящую gradle-таску, за которую можно зацепиться. Например,
fun Project.dependTaskOfAgpBuild(variantName: String, secretsTask: String) {
val targetTask = "generate${variantName}ResValues"
project.tasks.configureEach {
if (name == targetTask) {
dependsOn(secretsTask)
}
}
}
Еще нюанс, о котором хорошо бы подумать, это configuration cache.
Если выделить, то с чем я столкнулся, можно выделить такие правила
-
Все классы в action-фазе gradle task должны быть сериализоваными.
-
Никакого доступа к Project в execution time
-
Нельзя сохранять Project, Logger, Task в свойствах
Итого:
-
URL хранилища задается через переменные среды. Дефолтное значение зашиваем в плагин.
-
АПИ-Ключи читаются либо из переменных среды для запуска на CI, либо из local.properties для запуска локально. Причем для разработки достаточно выдать ключи только к дев, тест стендам. Так уменьшается возможность утечки данных
-
namespace до секретов стенда проекта настраиваются в build.gradle с совместимостью разных версий gradle-plugin и Kotlin/Groovy DSL
Получение секретов
На шаге запроса секретов мы пишем HttpClient, который пойдет в хранилище через REST API и вернет оттуда Map<String, String> с секретами
interface SecretsClient {
fun getSecrets(apiKey: String, namespace: String): Map<String, String>
}
Скрытие секретов
Gradle-таску сконфигурировали, к процессу билда подключили, за секретами сходили.
Теперь пришло время сгенерировать класс с секретами и скрыть их значения.
Генерация класса это по сути работа со списком строк и запись их в файл по определенному пути.
Как записать значения секретов в исходники, но не в виде констант? - сделать функцию, которая из байтов создает строку.
Но чтобы в декомпилированном коде это выглядело не как массив байт делаем побитовое смещение для каждого байта на случайное значение
Например, строка Hello world! по такому алгоритму будет выглядеть так
val helloWorld: String
get() = object : Any() {
var t: Int = 0
override fun toString(): String {
val buf = ByteArray(12)
t = 1212968752
buf[0] = (t ushr 24).toByte()
t = 388461353
buf[1] = (t ushr 3).toByte()
t = 1656095390
buf[2] = (t ushr 15).toByte()
t = -1312401191
buf[3] = (t ushr 1).toByte()
t = 467091852
buf[4] = (t ushr 22).toByte()
t = 1614889546
buf[5] = (t ushr 17).toByte()
t = 1752120241
buf[6] = (t ushr 7).toByte()
t = 702278034
buf[7] = (t ushr 14).toByte()
t = 751424971
buf[8] = (t ushr 2).toByte()
t = 1580048012
buf[9] = (t ushr 13).toByte()
t = -1505226377
buf[10] = (t ushr 20).toByte()
t = -1827629596
buf[11] = (t ushr 15).toByte()
return String(buf, Charset.forName("UTF-8"))
}
}.toString()
Как это будет выглядеть в скомпилированном варианте:
исходники
Log.d(TAG, "password = "+Constants.PASSWORD) // хардкод констант в исходниках
Log.d(TAG, "token = "+AppSecrets.TOKEN) // с замещением констант функции с байтовым смещением
smali-код
.line 2
.line 3
.line 4
const-string v0, "password = 123456"
...
.line 73
const-string v2, "token = "
.line 74
.line 75
invoke-virtual {v2, v0}, Ljava/lang/String;->concat(Ljava/lang/String;)Ljava/lang/String;
декомпилированный
Log.d("App", "password = 123456");
Log.d("App", "token = ".concat(a("token")));
Что получилось: самих констант не видно, только непонятный код.
Конечно это не серебряная пуля и при желании, проанализировав декомпилированный код, можно прочитать скрытые секреты в рантайме, вынеся их в другую среду исполнения, но это куда сложнее, если бы они лежали в открытом виде. Поэтому цели можно считать достигнутыми
Что мы выиграли:
-
секреты не попадают в Git
-
секреты не зашиты в APK
-
даже при реверсе остаются только код
Какие риски остаются:
-
Можно получить значения секретов в рантайме
Заключение
Мы сделали шаг к безопасной работе с секретами:
-
удобная генерация через Gradle-плагин
-
отсутствие секретов в коде и артефактах
-
прозрачная интеграция с CI/CD
Всем хорошего кодинга!
Автор: san4o123
