Обзор актуальных инструментов шифрования в Android

от автора

Привет, Хабр! Меня зовут Артур Илькаев, я работаю в департаменте экосистемных продуктов, мы разрабатываем VK ID SDK и все что связано с авторизацией и сессиями, в частности — мультиаккаунт.

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

Первый способ: самостоятельное шифрование примитивами из Android SDK

Если вы будете писать сами, скорее всего столкнётесь с массой проблем. Наиболее безопасная схема: хранить мастер-ключ в Keystore, а slave-ключи для шифрования данных — в любом другом Persistent Storage, например в SharedPreferences. Используемые примитивы: AES, RSA, Keystore. Мы хранили slave-ключи в SharedPreferences. Надеюсь, вам не придётся этим заниматься, потому что так вы сэкономите много нервов и времени.

Пример кода:

AesEncryption.kt
// Любая реализация хранения slave-ключей, например в SharedPreferences interface KeyStorage {     operator fun get(name: String): ByteArray?     operator fun set(name: String, key: ByteArray?) }   class AesEncryptionManager(     context: Context,     initExecutor: Executor,     exceptionHandler: (Exception) -> Unit,     private val keyStorage: KeyStorage, ) : EncryptionManager {       private val initLock = ReentrantReadWriteLock()       private val masterKeyValidityStartDate: Date     private val masterKeyValidityEndDate: Date       private var initLatch = CountDownLatch(1)       private lateinit var keyStore: KeyStore     private lateinit var aesCipher: Cipher     private val cipherLock: ReentrantLock = ReentrantLock()       init {         val calendar = Calendar.getInstance()         masterKeyValidityStartDate = calendar.time         calendar.add(Calendar.YEAR, 30)         masterKeyValidityEndDate = calendar.time         initExecutor.execute { init(exceptionHandler, masterKeyCreationCallback) }     }       @WorkerThread     @Throws(EncryptionException::class)     fun init(exceptionHandler: (Exception) -> Unit) = initLock.write {         if (initLatch.count == 0L) {             return         }           try {             keyStore = KeyStore.getInstance("AndroidKeyStore")             keyStore.load(null)             aesCipher = Cipher.getInstance(AES_CIPHER_SUIT)               if (!hasMasterKey()) {                 createMasterKey()                 masterKeyCreationCallback()             }         } catch (e: Exception) {             exceptionHandler.invoke(EncryptionException("Failed to run init", e))         } finally {             initLatch.countDown()         }     }       override fun encrypt(keyAlias: String, data: ByteArray): EncryptionManager.EncryptedData? {         initLock.read {             checkStateOrThrow()         }           val key = getKey(keyAlias) ?: createKey(keyAlias)         return encryptWithKey(key, data)     }       override fun decrypt(keyAlias: String, data: EncryptionManager.EncryptedData): ByteArray? {         initLock.read {             checkStateOrThrow()         }           val key = getKey(keyAlias) ?: throw EncryptionException("No key with alias $keyAlias")         return decryptWithKey(key, data)     }       override fun removeKey(keyAlias: String) {         keyStorage[keyAlias] = null     }       override fun waitForInitialize(maxTimeMs: Long): Boolean {         return initLatch.await(maxTimeMs, TimeUnit.MILLISECONDS)     }       // -- Master key operations       private fun checkStateOrThrow() {         if (initLatch.count > 0L) {             throw EncryptionException("Manager is not initialized")         }           if (!hasMasterKey()) {             throw EncryptionException("Cannot perform operations without master key")         }     }       private fun createMasterKey() {         try {             KeyPairGenerator                 .getInstance("RSA", "AndroidKeyStore")                 .apply {                     initialize(createSpec())                     generateKeyPair()                 }         } catch (e: Exception) {             throw EncryptionException("Failed to generate master key", e)         }     }       private fun hasMasterKey(): Boolean {         return try {             keyStore.getKey(MASTER_KEY_ALIAS, null) != null         } catch (e: Exception) {             L.w(e, "Failed to retrieve master key")             false         }     }       private fun encryptWithMasterKey(data: ByteArray): ByteArray {         return try {             val cipher = Cipher.getInstance(RSA_KEY_SUIT)             val key = keyStore.getCertificate(MASTER_KEY_ALIAS).publicKey             cipher.init(Cipher.ENCRYPT_MODE, key)             cipher.doFinal(data)         } catch (e: Exception) {             throw EncryptionException("Failed to encrypt with master key", e)         }     }       private fun decryptWithMasterKey(data: ByteArray): ByteArray {         return try {             val cipher = Cipher.getInstance(RSA_KEY_SUIT)             val key = keyStore.getKey(MASTER_KEY_ALIAS, null)             cipher.init(Cipher.DECRYPT_MODE, key)             cipher.doFinal(data)         } catch (e: Exception) {             throw EncryptionException("Failed to decrypt with master key", e)         }     }   // -- AES key operations       private fun getKey(name: String): Key? {         val encryptedKey = keyStorage[name]         if (encryptedKey == null) {             L.i("No key with alias $name")             return null         }           return Key(decryptWithMasterKey(encryptedKey))     }       private fun createKey(name: String): Key {         val key = UUID.randomUUID().toString()             .lowercase()             .replace("-", "")             .toCharArray()           val salt = UUID.randomUUID().toByteArray()           val spec = PBEKeySpec(key, salt, PBKDF2_ITER_COUNT, AES_KEY_SIZE)           val skf = SecretKeyFactory.getInstance(PBKDF2_ALGORITHM)         val generatedKey = try {             skf.generateSecret(spec).encoded         } catch (e: Exception) {             throw EncryptionException("Failed to generate key", e)         }           val encryptedKey = encryptWithMasterKey(generatedKey)         keyStorage[name] = encryptedKey           return Key(generatedKey)     }       private fun encryptWithKey(key: Key, data: ByteArray): EncryptionManager.EncryptedData {         return try {             val keySpec = SecretKeySpec(key.encodedKey, AES_KEY_SPEC)             cipherLock.withLock {                 aesCipher.init(Cipher.ENCRYPT_MODE, keySpec)                 val encrypted = aesCipher.doFinal(data)                 EncryptionManager.EncryptedData(encrypted, aesCipher.iv)             }         } catch (e: Exception) {             throw EncryptionException("Failed to encrypt with raw aes key", e)         }     }       private fun decryptWithKey(key: Key, data: EncryptionManager.EncryptedData): ByteArray {         return try {             cipherLock.withLock {                 val keySpec = SecretKeySpec(key.encodedKey, AES_KEY_SPEC)                 aesCipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(data.initVector))                 aesCipher.doFinal(data.data)             }         } catch (e: Exception) {             throw EncryptionException("Failed to decrypt with aes key", e)         }     }       private fun createSpec(): AlgorithmParameterSpec {         return KeyGenParameterSpec.Builder(MASTER_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)             .setKeySize(KEY_SIZE)             .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)             .setAlgorithmParameterSpec(RSAKeyGenParameterSpec(KEY_SIZE, RSAKeyGenParameterSpec.F4))             // key validity start & end explicitly not set as they caused KeyNotYetValidException @ api 27             .setCertificateSubject(X500Principal("CN=$MASTER_KEY_ALIAS"))             .setCertificateSerialNumber(BigInteger.valueOf(abs(MASTER_KEY_ALIAS.hashCode()).toLong()))             .build()     }       companion object {         private const val MASTER_KEY_ALIAS = "ALIAS_MASTER_KEY"           private const val KEY_SIZE = 2048 // bits         private const val AES_KEY_SIZE = 256         private const val PBKDF2_ITER_COUNT = 10000           private const val AES_CIPHER_SUIT = "AES/CBC/PKCS7Padding"         private const val AES_KEY_SPEC = "AES"         private const val RSA_KEY_SUIT = "RSA/NONE/PKCS1Padding"         private const val PBKDF2_ALGORITHM = "PBKDF2WithHmacSHA1"     } }

Ошибки:

  • KeyStoreException: Key not found

  • KeyStoreException: Unknown error at android.security.KeyStore.getKeyStoreException при получении данных

  • KeyStoreException: Memory allocation failed at android.security.KeyStore инициализация

  • KeyStoreException: Unsupported digest

Ежедневно мы фиксировали больше 150 тысяч (!) ошибок при работе с шифрованием. При этом сами отловить их не могли.

Среднее время инициализации по нашим данным: 100 мс, с учётом хранения сессий в БД. Больше всего времени занимают операции KeyStore.load() и cipher.doFinal(). Они могут приводить к ANR на некоторых устройствах.

Второй способ: EncryptedFile

Очень простой в реализации способ, рекомендуемый Google:

EncryptedFileWrapper.kt
class EncryptedFileWrapper(     context: Context,     fileName: String,     private val sessionStatHelper: SessionStatHelper ) {     private val lock = Any()          private val encryptedFileLink by lazy(mode = LazyThreadSafetyMode.NONE) {         File(context.filesDir, "encrypted_$fileName")     }     private val encryptedFile: EncryptedFile by lazy(mode = LazyThreadSafetyMode.NONE) {         try {             EncryptedFile.Builder(                 encryptedFileLink,                 context,                 MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),                 EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB             ).build()         } catch (th: Throwable) {             L.e(th, "create_encrypted_file")             sessionStatHelper.sendError(mapOf("action" to "create_encrypted_file", "stacktrace" to th.stackTraceToString()))             throw th         }     }      @Throws(GeneralSecurityException::class, IOException::class, KeyStoreException::class)     fun writeEncryptedUnsafe(value: String) = synchronized(lock) {         if (encryptedFileLink.exists()) {             encryptedFileLink.delete()         }          val bytes = value.toByteArray(StandardCharsets.UTF_8)         encryptedFile.openFileOutput().use {             it.write(bytes)             it.flush()         }     }      @Throws(GeneralSecurityException::class, IOException::class, KeyStoreException::class)     fun readEncryptedUnsafe(): String? = synchronized(lock) { // blocking reading         if (!encryptedFileLink.exists()) {             return null         }          if (encryptedFileLink.length() == 0L) {             encryptedFileLink.delete()             return null         }          val byteArrayOutputStream = ByteArrayOutputStream()         var nextByte: Int         encryptedFile.openFileInput().use { encryptedStream ->             nextByte = encryptedStream.read()             while (nextByte != -1) {                 byteArrayOutputStream.write(nextByte)                 nextByte = encryptedStream.read()             }         }          return byteArrayOutputStream.use { String(it.toByteArray(), StandardCharsets.UTF_8) }     }      @Throws(SecurityException::class)     fun clear(): Boolean = synchronized(lock) {         encryptedFileLink.delete()     } }

Но вот как на самом деле нужно создавать экземпляр EncryptedFile:

EncryptedFileCreation.kt
private val encryptedFile: EncryptedFile by lazy(mode = LazyThreadSafetyMode.NONE) {     // Single-creation problem     // Inspired by https://github.com/signalapp/Signal-Android/commit/f1f505d41c0d164c10226290d2af6f01b4461ae5     try {         createEncryptedFileForce(context)     } catch (th: Throwable) {         L.e(th, "create_encrypted_file_1")         sessionStatHelper.sendError(mapOf("action" to create_encrypted_file_1", "stacktrace" to th.partStackTrace()))           try {             createEncryptedFileForce(context)         } catch (th: Throwable) {             L.e(th, "create_encrypted_file_1")             sessionStatHelper.sendError(mapOf("action" to "EF_create_encrypted_file_2", "stacktrace" to th.partStackTrace()))             throw th         }     } }

Да-да, если вы не смогли создать в первый раз, попробуйте ещё! Такой способ нашли разработчики защищённого мессенджера Signal.

Данные:

  • Среднее время инициализации: 70-90 мс в зависимости от устройства и версии Android. На некоторых устройствах инициализация, шифрование и дешифрование может приводить к ANR.

  • Среднее количество ошибок в день: 5000.

  • Ошибки:

    • InvalidProtocolBufferException: Protocol message contained an invalid tag (zero)

    • KeyStoreException: the master key android-keystore://androidx_security_master_key exists but is unusable.

    • IOException: write failed: ENOSPC (No space left on device).

    • FileNotFoundException: /data/user/0/com.vkontakte.android/files/encrypted_sessions.json: open failed: ENOSPC (No space left on device).

Ошибки EncryptedFile

Ошибки EncryptedFile
Среднее время инициализации EncryptedFile

Среднее время инициализации EncryptedFile

Достоинства EncryptedFile:

  • Простота. В отличие от самостоятельного шифрования, реализация с использованием EncryptedFile является довольно прямолинейной.

  • Рекомендации от Google. Так как это решение рекомендовано Google, оно обычно хорошо поддерживается и обновляется.

Недостатки EncryptedFile:

  • Ошибки. Как мы выяснили, могут возникнуть различные ошибки, которые не всегда очевидны из официальной документации.

  • Время инициализации. На некоторых устройствах инициализация может занимать довольно много времени.

Последний способ: EncryptedSharedPreferences

Самый приятный способ. Но он подвержен тем же самым проблемам и ошибкам, что есть у EncryptedFile. У него чуть лучше время инициализации и чуть меньше ошибок, но он тоже не гарантирует полной защищённости от ошибок шифрования даже на последних версиях Android. На некоторых устройствах инициализация, шифрование и дешифрование может приводить к ANR.

Есть и другие способы, можно найти много библиотек на GitHub, но, судя по их коду, они всё равно не могут гарантировать защищённости.

Сравнительная таблица всех способов:

Способ

Среднее количество ошибок в день (на аудитории VK)

Средняя длительность инициализации

Гарантия, что данные будут записаны и прочитаны

Примитивы шифрования из Andorid SDK

>100 тыс. в день

100 мс

Отсутствует

EncryptedFile

5000 в день (уникальные)

70-90 мс

Отсутствует

EncryptedSharedPreferences

3000 в день (уникальные)

70 мс

Отсутствует

Где мои гарантии?

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

SafeEncryptedPreferences.kt
class SafeEncryptedPreferences(     context: Context,     fileName: String ) : SharedPreferences {     private val encrypted: SharedPreferences by lazy { EncryptedPreferencesHelper.createEncrypted(context, fileName, plain) }     private val plain: SharedPreferences by lazy {         context.getSharedPreferences("plain_$fileName", MODE_PRIVATE)     }       override fun contains(key: String?): Boolean {         return encrypted.safeContains(key) || plain.contains(key)     }       override fun getBoolean(key: String?, defValue: Boolean): Boolean {         return get(key, defValue, SharedPreferences::getBoolean)     }       override fun getInt(key: String?, defValue: Int): Int {         return get(key, defValue, SharedPreferences::getInt)     }       override fun getLong(key: String?, defValue: Long): Long {         return get(key, defValue, SharedPreferences::getLong)     }       override fun getFloat(key: String?, defValue: Float): Float {         return get(key, defValue, SharedPreferences::getFloat)     }       override fun getString(key: String?, defValue: String?): String? {         return get(key, defValue, SharedPreferences::getString)     }       override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {         return get(key, defValues, SharedPreferences::getStringSet)     }       override fun getAll(): MutableMap<String, *> {         val allEncrypted = encrypted.safeAll()         val allPlain = plain.all         val all = HashMap<String, Any?>(allEncrypted.size + allEncrypted.size)         all.putAll(allPlain)         all.putAll(allEncrypted)         return all     }       override fun edit(): SharedPreferences.Editor {         return Editor(encrypted.edit(), plain.edit())     }       override fun registerOnSharedPreferenceChangeListener(         listener: SharedPreferences.OnSharedPreferenceChangeListener?     ) {         encrypted.registerOnSharedPreferenceChangeListener(listener)         plain.registerOnSharedPreferenceChangeListener(listener)     }       override fun unregisterOnSharedPreferenceChangeListener(         listener: SharedPreferences.OnSharedPreferenceChangeListener?     ) {         encrypted.unregisterOnSharedPreferenceChangeListener(listener)         plain.unregisterOnSharedPreferenceChangeListener(listener)     }       private inline fun <T> get(         key: String?,         defValue: T,         getter: SharedPreferences.(key: String?, defValue: T) -> T     ): T {         return if (encrypted.safeContains(key)) {             try {                 encrypted.getter(key, defValue)             } catch (e: Exception) {                 plain.getter(key, defValue)             }         } else {             plain.getter(key, defValue)         }     }       private class Editor(         private val encryptedEditor: SharedPreferences.Editor,         private val plainEditor: SharedPreferences.Editor     ) : SharedPreferences.Editor {           private val clearRequested = AtomicBoolean(false)           override fun remove(key: String?) = apply {             encryptedEditor.safeRemove(key)             plainEditor.remove(key)         }           override fun clear() = apply {             clearRequested.set(true)             encryptedEditor.safeClear()             plainEditor.clear()         }           override fun putLong(key: String?, value: Long) = apply {             put(key, value, SharedPreferences.Editor::putLong)         }           override fun putInt(key: String?, value: Int) = apply {             put(key, value, SharedPreferences.Editor::putInt)         }           override fun putBoolean(key: String?, value: Boolean) = apply {             put(key, value, SharedPreferences.Editor::putBoolean)         }           override fun putStringSet(key: String?, values: MutableSet<String>?) = apply {             put(key, values, SharedPreferences.Editor::putStringSet)         }           override fun putFloat(key: String?, value: Float) = apply {             put(key, value, SharedPreferences.Editor::putFloat)         }           override fun putString(key: String?, value: String?) = apply {             put(key, value, SharedPreferences.Editor::putString)         }           override fun commit(): Boolean {             return encryptedEditor.safeCommit() && plainEditor.commit()         }           override fun apply() {             if (clearRequested.getAndSet(false)) {                 encryptedEditor.safeCommit()             } else {                 encryptedEditor.safeApply()             }             plainEditor.apply()         }           private inline fun <T> put(             key: String?,             value: T,             putter: SharedPreferences.Editor.(key: String?, value: T) -> Any         ) = apply {             try {                 encryptedEditor.putter(key, value)             } catch (e: Exception) {                 plainEditor.putter(key, value)             }         }     }       companion object {         fun SharedPreferences.safeContains(key: String?): Boolean {             return try {                 contains(key)             } catch (e: Exception) {                 false             }         }           fun SharedPreferences.safeAll(): Map<String, *> {             return try {                 all             } catch (e: Exception) {                 emptyMap<String, Any?>()             }         }           fun SharedPreferences.Editor.safeRemove(key: String?): SharedPreferences.Editor {             return try {                 remove(key)             } catch (e: Exception) {                 this             }         }           fun SharedPreferences.Editor.safeClear(): SharedPreferences.Editor {             return try {                 clear()             } catch (e: Exception) {                 this             }         }           fun SharedPreferences.Editor.safeCommit(): Boolean {             return try {                 commit()             } catch (e: Exception) {                 return false             }         }           fun SharedPreferences.Editor.safeApply() {             return try {                 apply()             } catch (ignored: Exception) {             }         }     } }

Вот в такой мы плачевной ситуации. При этом мы используем самую стабильную версию security-библиотеки: 1.0.0-stable.

Бонус: как там дела у iOS?

В iOS для шифрования секретов используют Keychain, он имеет гарантии и может вызываться в главном потоке.

Резюме

Выводы по рассмотренным способам шифрования данных:

Метод

Преимущества

Недостатки

Средняя длительность инициализации

Среднее количество ошибок в день

Самостоятельное шифрование с Keystore

Полный контроль, гибкость

Сложность, высокий риск ошибок

100 мс

150 000

EncryptedFile

Простота реализации, рекомендации Google

Возможные ошибки и их отсутствие в документации, отсутствие гарантий

70-90 мс

5000

EncryptedSharedPreferences

Простота реализации, рекомендации Google

Возможные ошибки и их отсутствие в документации, отсутствие гарантий

70 мс

Сторонние библиотеки

Простота использования

Зависимость от сторонних разработчиков

Важно помнить: ни один из методов не предоставляет абсолютных гарантий сохранности данных. Для максимальной надёжности рекомендуется использовать двойную инициализацию и запасное хранилище (fallback store) для обработки ошибок.

А какие методы шифрования данных вы используете в своём проекте? Как вы справляетесь с ошибками и неожиданными проблемами?


ссылка на оригинал статьи https://habr.com/ru/articles/776728/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *