Многие Android-приложения требуют для работы секреты, например ключи API. В большинстве случаев эти ключи предназначены для сервисов Google и могут быть защищены с помощью технологии ключа подписи App Signing Key (настраивается в Google Cloud Console), так что утечка этих ключей не представляет проблемы. Однако иногда приходится добавлять незащищенные ключи или другие секреты, которые следует скрыть. В этой статье я покажу, как это сделать.
Существует 2 подхода к секретам клиента:
- Пакетное размещение секретов в бинарном файле приложения.
- Получение их из удаленного хранилища (сервера).
В этой статье будет подробно описан первый подход.
Использование статического поля
Большинство разработчиков чаще всего прибегают к статическому полю в коде, в котором хранится ключ.
Ключ также может быть добавлен во время компиляции в BuildConfig или ресурсы (как это делает плагин Google Services) с помощью следующей конфигурации build.gradle:
android {
defaultConfig {
buildConfigField("String", "API_KEY", "\"SECRET_API_KEY\"")
resValue("string", "api_key", "\"SECRET_API_KEY\"")
}
buildFeatures {
buildConfig = true
}
}
Тогда доступ к таким ключам можно получить следующим образом:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
Log.e("TEST", "Static field: $API_KEY")
Log.e("TEST", "BuildConfig field: ${BuildConfig.API_KEY}")
Log.e("TEST", "Res field: ${getString(R.string.api_key)}")
}
companion object {
const val API_KEY = "SECRET_API_KEY"
}
}
Основная проблема указанного подхода заключается в том, что эти значения можно легко обнаружить в результирующем приложении.
Во время компиляции R8 оптимизирует использование конкатенации статических строк, поэтому получаем одну строку. Вот почему в скомпилированном коде значение было вынесено в сообщения журнала.
Значение ресурса также можно легко обнаружить в результирующем приложении.
Этот подход можно несколько усовершенствовать, разделив ключ на несколько частей и поместив их в разные части приложения. Затем эти части объединяются во время выполнения. Их также можно подвергнуть определенной обработке (например, использовать оператор XOR с некоторой константой), и это сделает процесс обнаружения реального ключа более сложным для злоумышленника, но все же возможным.
Подход с использованием NDK
Первый подход скрывает ключи в исходном коде Kotlin/Java. Мы можем перейти на более низкий уровень и скрыть ключи в нативном коде (C/C++), который затем компилируется в библиотеку .so.
Начнем со сборки библиотеки .so. В модуль build.gradle необходимо добавить следующую конфигурацию:
android {
externalNativeBuild {
cmake {
path = file("CMakeLists.txt")
}
}
}
Конфигурация CMake ({module}/CMakeLists.txt) выглядит следующим образом:
project(secrets)
cmake_minimum_required(VERSION 3.4.1)
add_library( # Указывается имя библиотеки.
secrets
# Установка библиотеки в качестве разделяемой библиотеки
SHARED
# Предоставление относительного пути к исходному файлу (или файлам)
src/main/cpp/secrets.h
src/main/cpp/secrets.cpp
)
Исходный код библиотеки представлен в виде файлов .cpp и .h:
#include "jni.h"
#include <string>
#define API_KEY "SECRET_API_KEY"
#define API_KEY_LENGTH strlen(API_KEY)
void getApiKey(char* buffer);
extern "C" JNIEXPORT jstring JNICALL
Java_com_kurantsov_NativeSecrets_getApiKeyFromNative(
JNIEnv *env,
jobject thiz
) {
char key_buffer[API_KEY_LENGTH + 1] ;
key_buffer[API_KEY_LENGTH] = '#include "jni.h"';
#include <string>
#define API_KEY "SECRET_API_KEY"
#define API_KEY_LENGTH strlen(API_KEY)
void getApiKey(char* buffer);
extern "C" JNIEXPORT jstring JNICALL
Java_com_kurantsov_NativeSecrets_getApiKeyFromNative(
JNIEnv *env,
jobject thiz
) {
char key_buffer[API_KEY_LENGTH + 1] ;
key_buffer[API_KEY_LENGTH] = '\0';
getApiKey(key_buffer);
return env->NewStringUTF(key_buffer);
}
getApiKey(key_buffer);
return env->NewStringUTF(key_buffer);
}
#include "secrets.h"
void getApiKey(char* buffer) {
for (int i = 0; i < API_KEY_LENGTH; i++) {
buffer[i] = API_KEY[i];
}
}
Чтобы получить данные из нативной библиотеки, можем использовать JNI (Java native interface).
Определим класс Kotlin, который вызовет нативную библиотеку с помощью JNI:
package com.kurantsov
object NativeSecrets {
init {
System.loadLibrary("secrets")
}
external fun getApiKeyFromNative(): String
}
В приведенном выше фрагменте мы определяем объект, который загружает нативную библиотеку в блоке инициализации и обладает методом getApiKeyFromNative, помеченным как external. Это означает, что реализация данного метода выполняется в нативном коде.
Связь между методом Kotlin и нативной функцией осуществляется с помощью специального соглашения об именовании. Нативная функция должна иметь название в следующем формате: Java_{полное имя метода, где ‘.’ заменяется на ‘_’}.
При таком подходе злоумышленнику чуть сложнее узнать фактическое значение секрета, но все же риск несанкционированного доступа сохраняется. Строку можно легко обнаружить, если открыть файл .so в шестнадцатеричном редакторе.
Бонус
Можно сделать нативную библиотеку чуть более “безопасной” и динамичной, выполнив некоторые модификации исходных секретов и реализовав логику для отмены модификаций в нативном коде. В следующем примере я модифицировал исходную строку с помощью операции XOR по каждому байту, а затем выполнил ту же операцию в нативном коде.
Для этого в build.gradle необходимо добавить следующие модификации:
android {
defaultConfig {
val xorValue = 0xAA
val API_KEY = "SECRET_API_KEY"
val apiKeyBytes = API_KEY.toByteArray().map {
it.toInt() xor xorValue
}
val apiKeyDefinitionString =
apiKeyBytes.joinToString(prefix = "[${apiKeyBytes.size}]{", postfix = "}")
externalNativeBuild {
cmake {
cppFlags(
"-DAPI_KEY_LENGTH=${apiKeyBytes.size}",
"-DAPI_KEY_BYTES_DEFINITION=\"$apiKeyDefinitionString\"",
"-DXOR_VALUE=$xorValue",
)
}
}
}
}
Заголовок остается практически тем же, за исключением того, что удалены исходные определения:
#include "jni.h"
void getApiKey(char* buffer);
extern "C" JNIEXPORT jstring JNICALL
Java_com_kurantsov_NativeSecrets_getApiKeyFromNative(
JNIEnv *env,
jobject thiz
) {
char key_buffer[API_KEY_LENGTH + 1] ;
key_buffer[API_KEY_LENGTH] = '#include "jni.h"';
void getApiKey(char* buffer);
extern "C" JNIEXPORT jstring JNICALL
Java_com_kurantsov_NativeSecrets_getApiKeyFromNative(
JNIEnv *env,
jobject thiz
) {
char key_buffer[API_KEY_LENGTH + 1] ;
key_buffer[API_KEY_LENGTH] = '\0';
getApiKey(key_buffer);
return env->NewStringUTF(key_buffer);
}
getApiKey(key_buffer);
return env->NewStringUTF(key_buffer);
}
Файл cpp имеет следующий вид:
#include "secrets.h"
void getApiKey(char* buffer) {
int api_key_bytes API_KEY_BYTES_DEFINITION;
for (int i = 0; i < API_KEY_LENGTH; i++) {
buffer[i] = api_key_bytes[i] ^ XOR_VALUE;
}
}
Полный пример можно найти на GitHub.
Читайте также:
- Android/Kotlin/Jetpack Compose: обработка push-уведомлений
- Изучаем AndroidManifest.xml: <service> как подэлемент <application>
- Модульное тестирование с помощью JUnit в Android
Читайте нас в Telegram, VK и Дзен
Перевод статьи Artsem Kurantsou: Secrets in Android Part 1