Статьи

Безопасное хранение данных на Android

Сегодня доверие к приложению сильно зависит от того, как обрабатываются личные данные пользователя. Стек Android имеет множество мощных API-интерфейсов, связанных с хранилищем учетных данных и ключей, причем определенные функции доступны только в определенных версиях.

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

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

Для данных, которые вы должны хранить, архитектура Android готова помочь. Начиная с версии 6.0 Marshmallow, полное шифрование диска включено по умолчанию для устройств с такими возможностями. Файлы и SharedPreferences , которые сохраняются приложением, автоматически устанавливаются с константой MODE_PRIVATE . Это означает, что к данным может получить доступ только ваше собственное приложение.

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

1
2
3
SharedPreferences.Editor editor = getSharedPreferences(«preferenceName», MODE_PRIVATE).edit();
editor.putString(«key», «value»);
editor.commit();

Или при сохранении файла.

1
2
3
FileOutputStream fos = openFileOutput(filenameString, Context.MODE_PRIVATE);
fos.write(data);
fos.close();

Избегайте хранения данных во внешнем хранилище, так как эти данные затем будут видны другим приложениям и пользователям. Фактически, чтобы людям было сложнее копировать двоичные файлы и данные вашего приложения, вы можете запретить пользователям устанавливать приложение на внешнее хранилище. Добавление android:installLocation со значением internalOnly к файлу манифеста выполнит это.

Вы также можете предотвратить резервное копирование приложения и его данных. Это также предотвращает загрузку содержимого каталога личных данных приложения с помощью adb backup . Для этого установите для атрибута android:allowBackup значение false в файле манифеста. По умолчанию этот атрибут имеет значение true .

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

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

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

Мы будем использовать рекомендованный стандарт AES , который шифрует данные с помощью ключа. Тот же ключ, который используется для шифрования данных, используется для дешифрования данных, что называется симметричным шифрованием. Имеются ключи разных размеров, и AES256 (256 бит) является предпочтительной длиной для использования с конфиденциальными данными.

Хотя пользовательское взаимодействие с вашим приложением должно вынудить пользователя использовать надежный пароль, есть вероятность, что этот же пароль будет выбран другим пользователем. Передача безопасности наших зашифрованных данных в руки пользователя небезопасна. Вместо этого наши данные должны быть защищены ключом, который является случайным и достаточно большим (то есть имеет достаточную энтропию), чтобы считаться надежным. Вот почему никогда не рекомендуется использовать пароль напрямую для шифрования данных — именно здесь вступает в действие функция, называемая функцией определения ключа на основе пароля (PBKDF2).

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

Давайте начнем с создания этой соли.

1
2
3
SecureRandom random = new SecureRandom();
byte salt[] = new byte[256];
random.nextBytes(salt);

Класс SecureRandom гарантирует, что сгенерированный вывод будет трудно предсказать — это «криптографически сильный генератор случайных чисел». Теперь мы можем поместить соль и пароль в объект шифрования на основе пароля: PBEKeySpec . Конструктор объекта также принимает форму счетчика итераций, что делает ключ сильнее. Это связано с тем, что увеличение количества итераций увеличивает время, необходимое для работы с набором ключей во время атаки методом перебора. Затем PBEKeySpec передается в SecretKeyFactory , которая в итоге генерирует ключ в виде массива byte[] . Мы обернем этот необработанный массив byte[] в объект SecretKeySpec .

1
2
3
4
5
char[] passwordChar = passwordString.toCharArray();
PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256);
SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(«PBKDF2WithHmacSHA1»);
byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded();
SecretKeySpec keySpec = new SecretKeySpec(keyBytes, «AES»);

Обратите внимание, что пароль передается как массив char[] , а класс PBEKeySpec сохраняет его как массив char[] . Массивы char[] обычно используются для функций шифрования, поскольку, хотя класс String является неизменяемым, массив char[] содержащий конфиденциальную информацию, может быть перезаписан, что полностью удаляет конфиденциальные данные из памяти устройства.

Теперь мы готовы зашифровать данные, но нам нужно сделать еще одну вещь. Существуют разные режимы шифрования с AES, но мы будем использовать рекомендуемый: цепочка блоков шифрования (CBC). Это работает с нашими данными по одному блоку за раз. Отличительной особенностью этого режима является то, что каждый следующий незашифрованный блок данных XOR ‘d с предыдущим зашифрованным блоком, чтобы сделать шифрование сильнее. Однако это означает, что первый блок никогда не бывает таким уникальным, как все остальные!

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

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

Давайте создадим IV сейчас.

1
2
3
4
SecureRandom ivRandom = new SecureRandom();
byte[] iv = new byte[16];
ivRandom.nextBytes(iv);
IvParameterSpec ivSpec = new IvParameterSpec(iv);

Примечание о SecureRandom . В версиях 4.3 и ниже архитектура криптографии Java имела уязвимость из-за неправильной инициализации базового генератора псевдослучайных чисел ( PRNG ) . Если вы ориентируетесь на версии 4.3 и ниже, исправление доступно .

Вооружившись IvParameterSpec , теперь мы можем выполнять фактическое шифрование.

1
2
3
Cipher cipher = Cipher.getInstance(«AES/CBC/PKCS7Padding»);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
byte[] encrypted = cipher.doFinal(plainTextBytes);

Здесь мы передаем строку "AES/CBC/PKCS7Padding" . Это определяет шифрование AES с цепочкой блоков шифра. Последняя часть этой строки относится к PKCS7, который является установленным стандартом для заполнения данных, которые не вписываются в размер блока. (Блоки 128 бит, и заполнение выполняется перед шифрованием.)

Чтобы завершить наш пример, мы поместим этот код в метод шифрования, который упакует результат в HashMap содержащий зашифрованные данные, а также соль и вектор инициализации, необходимые для расшифровки.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
private HashMap<String, byte[]> encryptBytes(byte[] plainTextBytes, String passwordString)
{
    HashMap<String, byte[]> map = new HashMap<String, byte[]>();
     
    try
    {
        //Random salt for next step
        SecureRandom random = new SecureRandom();
        byte salt[] = new byte[256];
        random.nextBytes(salt);
 
        //PBKDF2 — derive the key from the password, don’t use passwords directly
        char[] passwordChar = passwordString.toCharArray();
        PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256);
        SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(«PBKDF2WithHmacSHA1»);
        byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded();
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, «AES»);
 
        //Create initialization vector for AES
        SecureRandom ivRandom = new SecureRandom();
        byte[] iv = new byte[16];
        ivRandom.nextBytes(iv);
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
 
        //Encrypt
        Cipher cipher = Cipher.getInstance(«AES/CBC/PKCS7Padding»);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec);
        byte[] encrypted = cipher.doFinal(plainTextBytes);
 
        map.put(«salt», salt);
        map.put(«iv», iv);
        map.put(«encrypted», encrypted);
    }
    catch(Exception e)
    {
        Log.e(«MYAPP», «encryption exception», e);
    }
 
    return map;
}

Вам нужно только сохранить IV и соль с вашими данными. Хотя соли и капельницы считаются общедоступными, убедитесь, что они не увеличиваются или не используются повторно. Чтобы расшифровать данные, все, что нам нужно сделать, это изменить режим в конструкторе Cipher с ENCRYPT_MODE на DECRYPT_MODE .

Метод расшифровки возьмет HashMap который содержит ту же самую необходимую информацию (зашифрованные данные, соль и IV), и вернет расшифрованный массив byte[] условии правильного пароля. Метод расшифровки восстановит ключ шифрования из пароля. Ключ никогда не должен храниться!

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private byte[] decryptData(HashMap<String, byte[]> map, String passwordString)
{
    byte[] decrypted = null;
    try
    {
        byte salt[] = map.get(«salt»);
        byte iv[] = map.get(«iv»);
        byte encrypted[] = map.get(«encrypted»);
 
        //regenerate key from password
        char[] passwordChar = passwordString.toCharArray();
        PBEKeySpec pbKeySpec = new PBEKeySpec(passwordChar, salt, 1324, 256);
        SecretKeyFactory secretKeyFactory = SecretKeyFactory.getInstance(«PBKDF2WithHmacSHA1»);
        byte[] keyBytes = secretKeyFactory.generateSecret(pbKeySpec).getEncoded();
        SecretKeySpec keySpec = new SecretKeySpec(keyBytes, «AES»);
 
        //Decrypt
        Cipher cipher = Cipher.getInstance(«AES/CBC/PKCS7Padding»);
        IvParameterSpec ivSpec = new IvParameterSpec(iv);
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
        decrypted = cipher.doFinal(encrypted);
    }
    catch(Exception e)
    {
        Log.e(«MYAPP», «decryption exception», e);
    }
 
    return decrypted;
}

Чтобы упростить пример, мы пропускаем проверку ошибок, которая позволяет убедиться, что HashMap содержит необходимые пары ключ-значение. Теперь мы можем проверить наши методы, чтобы убедиться, что после шифрования данные расшифрованы правильно.

01
02
03
04
05
06
07
08
09
10
11
12
//Encryption test
String string = «My sensitive string that I want to encrypt»;
byte[] bytes = string.getBytes();
HashMap<String, byte[]> map = encryptBytes(bytes, «UserSuppliedPassword»);
 
//Decryption test
byte[] decrypted = decryptData(map, «UserSuppliedPassword»);
if (decrypted != null)
{
    String decryptedString = new String(decrypted);
    Log.e(«MYAPP», «Decrypted String is : » + decryptedString);
}

Методы используют массив byte[] так что вы можете шифровать произвольные данные, а не только объекты String .

Теперь, когда у нас есть зашифрованный массив byte[] , мы можем сохранить его в хранилище.

1
2
3
FileOutputStream fos = openFileOutput(«test.dat», Context.MODE_PRIVATE);
fos.write(encrypted);
fos.close();

Если вы не хотите сохранять IV и соль отдельно, HashMap можно сериализовать с классами ObjectInputStream и ObjectOutputStream .

1
2
3
4
FileOutputStream fos = openFileOutput(«map.dat», Context.MODE_PRIVATE);
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(map);
oos.close();

Вы также можете сохранить защищенные данные в SharedPreferences вашего приложения.

1
2
3
4
5
SharedPreferences.Editor editor = getSharedPreferences(«prefs», Context.MODE_PRIVATE).edit();
String keyBase64String = Base64.encodeToString(encryptedKey, Base64.NO_WRAP);
String valueBase64String = Base64.encodeToString(encryptedValue, Base64.NO_WRAP);
editor.putString(keyBase64String, valueBase64String);
editor.commit();

Поскольку SharedPreferences является системой XML, которая принимает в качестве значений только определенные примитивы и объекты, нам необходимо преобразовать наши данные в совместимый формат, такой как объект String . Base64 позволяет нам преобразовывать необработанные данные в String представление, которое содержит только символы, разрешенные в формате XML . Зашифруйте и ключ, и значение, чтобы злоумышленник не мог понять, для чего может быть значение.

В приведенном выше примере как encryptedKey и encryptedValue — это зашифрованные массивы byte[] возвращаемые нашим encryptBytes() . IV и соль могут быть сохранены в файле настроек или в виде отдельного файла. Чтобы вернуть зашифрованные байты из SharedPreferences , мы можем применить декодирование Base64 к сохраненной String .

1
2
3
SharedPreferences preferences = getSharedPreferences(«prefs», Context.MODE_PRIVATE);
String base64EncryptedString = preferences.getString(keyBase64String, «default»);
byte[] encryptedBytes = Base64.decode(base64EncryptedString, Base64.NO_WRAP);

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

Теоретически вы можете просто удалить свои общие настройки, удалив файлы /data/data/com.your.package.name/shared_prefs/your_prefs_name.xml и your_prefs_name.bak и очистив настройки в памяти с помощью следующего кода:

1
getSharedPreferences(«prefs», Context.MODE_PRIVATE).edit().clear().commit();

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
public static void secureWipeFile(File file) throws IOException
{
    if (file != null && file.exists())
    {
        final long length = file.length();
        final SecureRandom random = new SecureRandom();
        final RandomAccessFile randomAccessFile = new RandomAccessFile(file, «rws»);
        randomAccessFile.seek(0);
        randomAccessFile.getFilePointer();
        byte[] data = new byte[64];
        int position = 0;
        while (position < length)
        {
            random.nextBytes(data);
            randomAccessFile.write(data);
            position += data.length;
        }
        randomAccessFile.close();
        file.delete();
    }
}

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

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

  • Android SDK
    Отображение диалогов дизайна материала в приложении для Android
    Чике Мгбемена
  • Android SDK
    Отправка данных с помощью HTTP-клиента Retrofit 2 для Android
    Чике Мгбемена
  • Android SDK
    Как создать приложение для Android-чата с помощью Firebase
    Ашраф Хатхибелагал