Привет, Хабр! Меня зовут Артур Илькаев, я работаю в департаменте экосистемных продуктов, мы разрабатываем 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 является довольно прямолинейной.
Рекомендации от 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) для обработки ошибок.
А какие методы шифрования данных вы используете в своём проекте? Как вы справляетесь с ошибками и неожиданными проблемами?
Комментарии (6)
nanashi1003
28.11.2023 09:16так а что по сторонним либам? tink например?
32xlevel Автор
28.11.2023 09:16Tink же и так используется под капотом EncryptedFile & EncryptedSharedPreferences:)
kovserg
Если шифрование проводить в отдельном потоке, приложение тоже зависает?
32xlevel Автор
Не зависает, конечно:) Но стоит учитывать, что если это делать на старте приложения в отдельном потоке и главному нужно дождаться результат, то это может быть долго.
kovserg
Так можно вывести анимированную заставку, с объяснением что "мы ждём у моря погоды" и с чистой совестью ждать ответа от кривых библиотек.
32xlevel Автор
Согласен. Я преследовал цель донести тот факт, что это может быть долго (дольше 5 секунд).