Многие 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.

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

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


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

Предыдущая статьяЛучшая IDE для Python-разработки в 2024 году