Секреты под защитой: как мы спрятали ключи приложения с помощью Gradle-плагина

в 10:16, , рубрики: android gradle plugin, android security

Всем привет, Я, Назаров Александр, техлид в платформенной команде 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 части

  1. Подтягивание секретов из key-value хранилища

  2. Скрытие значений секретов в исходниках

Пункт 1 может быть любое хранилище секретов. На этом пункте не буду заострять внимание.
Будем считать, что мы выбрали самое лучшее хранилище, которое имеет какой-то REST API и scope-доступы к секретам.
Через него можно по апи-ключам получить доступ к связке key-value определенного приложения

Итак, с этими задачами хорошо справиться gradle-плагин.
В исходниках мы подключаем плагин, указываем откуда из хранилища брать секреты.
АПИ-Ключи доступа будут настраиваться либо на CI, либо локально на машине разработчика
Что плагин будет делать:

  1. Подтягивать секреты из хранилища при сборке.

  2. Генерировать котлин object с key-value связкой секретов приложения, но реальные значения кладутся не как константы строк.

  3. Реальные значения видны только в рантайме.

Реализация: 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js