Столкнувшись с рядом проблем, связанных с выполнением шифрования и расшифровки AES в Android, я решил поделиться своим опытом.

TL;DR: здесь можно найти готовое решение. Если вам нужно указать IV и AAD в качестве входных данных, вот обходной путь и настройка, необходимая при создании ключа. Однако будьте предельно осторожны с этим подходом!

Проблема

Я занимался разработкой крипто-API, который позволил бы использовать уже существующую крипто-библиотеку вместе с API Android KeyStore. Идея состояла в том, что в устройствах с аппаратным криптопроцессором наше программное обеспечение должно использовать его для шифрования и расшифровки, а в остальных устройствах обращаться к существующей библиотеке. Для выполнения этой цели требовался общий, абстрактный API, который смог бы скрыть используемую криптосреду от остальной части программного обеспечения. Шифрование и расшифровка выполнялись бы с помощью AES в режиме GCM.

Что я нашел в Интернете

Как и любой другой разработчик, я отправился в Интернет на поиски определения API Android KeyStore, а также примеров его использования. Я нашел примерно следующее:

const val TAG_LENGTH = 16

fun encrypt(key: SecretKey, message: ByteArray): Pair<ByteArray, ByteArray> {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key)
    val iv = cipher.iv.copyOf()
    val ciphertext = cipher.doFinal(message)
    return Pair(iv, ciphertext)
}

fun decrypt(key: SecretKey, iv: ByteArray, message: ByteArray): ByteArray {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val spec = GCMParameterSpec(TAG_LENGTH * 8, iv)
    cipher.init(Cipher.DECRYPT_MODE, key, spec)
    return cipher.doFinal(message)
}

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

  • принимать iv и aad в качестве входных параметров;
  • возвращать tag в качестве вывода;
  • принимать aad и tag в качестве входных параметров.

Разве AES GCM не является стандартом? Как могут быть возможны эти различия?

Немного теории

Пропустите этот раздел, если знакомы с понятиями iv, aad и tag.

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

С другой стороны, aad и tag используются только в аутентифицированных шифрах (иногда также называемых “AEAD”, сокращенно от “Authenticated Encryption with Associated Data”), таких как AES в режиме GCM:

  • tag —  это выходные данные процедуры шифрования, которые затем передаются в процедуру расшифровки для проверки подлинности зашифрованного текста (он должен быть сгенерирован с тем же ключом, который использовался для расшифровки).
  • aad обозначает “Additional Authentication Data” и представляет собой входной сигнал для процедур шифрования и расшифровки, который используется для вычисления tag.

Решение

К сожалению, для всех рассмотренных выше отличий нет единого решения. Для каждого параметра нужен свой подход:

Возврат tag

Начнем с самого простого. Согласно документации Java:

Если используется такой режим AEAD, как GCM/CCM, тег аутентификации добавляется в случае шифрования и проверяется в случае расшифровки.

Проще говоря: метод Cipher.doFinal() (в режиме GCM) добавляет tag в конец зашифрованного текста при шифровании и проверяет его, читая с конца, при расшифровке. Следовательно, можно написать что-то подобное:

const val TAG_LENGTH = 16

class EncryptionOutput(val iv: ByteArray,
                       val tag: ByteArray,
                       val ciphertext: ByteArray)

fun encrypt(key: SecretKey, message: ByteArray): EncryptionOutput {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key)
    val iv = cipher.iv.copyOf()
    val result = cipher.doFinal(message)
    val ciphertext = result.copyOfRange(0, result.size - TAG_LENGTH)
    val tag = result.copyOfRange(result.size - TAG_LENGTH, result.size)
    return EncryptionOutput(iv, tag, ciphertext)
}

fun decrypt(key: SecretKey, iv: ByteArray, tag: ByteArray, ciphertext: ByteArray): ByteArray {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val spec = GCMParameterSpec(TAG_LENGTH * 8, iv)
    cipher.init(Cipher.DECRYPT_MODE, key, spec)
    return cipher.doFinal(ciphertext + tag)
}

А как насчет aad?

Согласно теории, aad также должен предоставляться при шифровании и расшифровки данных, однако он отсутствует в приведенных выше фрагментах кода. Ответ также есть в документации Java, в которой мы находим метод Cipher.updateAAD(byte[] src).

Я не уверен, что разобрался в описании этого метода, однако могу подтвердить на основе собственного опыта, что он действительно влияет на используемый aad: при попытке изменить его в шифровании или расшифровке, расшифровка завершится неудачей с исключением AEADBadTagException, означающим, что аутентификация не удалась.

При использовании метода Cipher.updateAAD(byte[] src) для определения aad, код будет выглядеть следующим образом:

const val AAD_LENGTH = 16
const val TAG_LENGTH = 16

class EncryptionOutput(val iv: ByteArray,
                       val aad: ByteArray,
                       val tag: ByteArray,
                       val ciphertext: ByteArray)

fun encrypt(key: SecretKey, message: ByteArray): EncryptionOutput {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key)
    val iv = cipher.iv.copyOf()
    val aad = SecureRandom().generateSeed(AAD_LENGTH)
    cipher.updateAAD(aad)
    val result = cipher.doFinal(message)
    val ciphertext = result.copyOfRange(0, result.size - TAG_LENGTH)
    val tag = result.copyOfRange(result.size - TAG_LENGTH, result.size)
    return EncryptionOutput(iv, aad, tag, ciphertext)
}

fun decrypt(key: SecretKey, iv: ByteArray, aad: ByteArray, tag: ByteArray, ciphertext: ByteArray): ByteArray {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val spec = GCMParameterSpec(TAG_LENGTH * 8, iv)
    cipher.init(Cipher.DECRYPT_MODE, key, spec)
    cipher.updateAAD(aad)
    return cipher.doFinal(ciphertext + tag)
}

Обратите внимание, что я установил aad в качестве вывода процедуры шифрования. Причину рассмотрим чуть позже. Приведенный выше код показывает подход, который я бы рекомендовал всем, кто ищет безопасный API для реализации шифрования и расшифровки AES GCM в Android.

IV

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

Похоже, с помощью класса GCMParameterSpec можно выполнить следующее:

const val TAG_LENGTH = 16

class EncryptionOutput(val tag: ByteArray,
                       val ciphertext: ByteArray)

fun encrypt(key: SecretKey, iv: ByteArray, aad: ByteArray, message: ByteArray): EncryptionOutput {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val spec = GCMParameterSpec(TAG_LENGTH * 8, iv)
    cipher.init(Cipher.ENCRYPT_MODE, key, spec)
    cipher.updateAAD(aad)
    val result = cipher.doFinal(message)
    val ciphertext = result.copyOfRange(0, result.size - TAG_LENGTH)
    val tag = result.copyOfRange(result.size - TAG_LENGTH, result.size)
    return EncryptionOutput(tag, ciphertext)
}

fun decrypt(key: SecretKey, iv: ByteArray, aad: ByteArray, tag: ByteArray, ciphertext: ByteArray): ByteArray {
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    val spec = GCMParameterSpec(TAG_LENGTH * 8, iv)
    cipher.init(Cipher.DECRYPT_MODE, key, spec)
    cipher.updateAAD(aad)
    return cipher.doFinal(ciphertext + tag)
}

При попытке выполнить его мы получим исключение: java.security.InvalidAlgorithmParameterException: Caller-provided IV not permitted, что вполне объяснимо: Android не позволяет указывать и использовать IV.

Почему? Это поведение введено в API 23 и предназначено для гарантии того, что вызывающий метод не использует IV повторно, так как это может нарушить безопасность блочного шифра. Суть заключается в следующем: IV не следует использовать повторно с одним и тем же ключом, и для того, чтобы убедиться в этом, Android генерирует новый случайный ключ.

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

Но если вам, как и мне, действительно нужно предоставить ivaad) в качестве входных параметров, то есть один способ. При создании ключа AES воспользуйтесь методом setRandomizedEncryptionRequired(), чтобы явно попросить Android разрешения предоставить iv в качестве входных данных. В таком случае метод генерации ключей будет следующим:

fun generateAesKey(): SecretKey {
    val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    val kgps = KeyGenParameterSpec.Builder("my_aes_key", KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
        .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
        // Так мы получим разрешение 
        .setRandomizedEncryptionRequired(false)
        .build()
    keyGenerator.init(kgps)
    return keyGenerator.generateKey()
}

Используйте этот метод только в том случае, если вы действительно знаете, что делаете! Убедитесь, что ivaad) сгенерированы правильно и достаточно случайны для конкретного варианта использования.

Заключительные мысли

API Java и Android усложняют реализацию AES GCM, совместимых с другими криптосистемами. В чем причина?

Мои догадки относительно того, почему разработчики этих API решили реализовать их таким образом:

  • Вполне очевидно, что целью включения tag в результат Cipher.doFinal(), было сохранение целостности API JCE. Cipher.doFinal() возвращал byte[] в течение многих лет, независимо от примитива шифрования, поэтому они решили оставить все на своих местах.
  • На мой взгляд, было бы лучше, если бы aad был частью конструктора GCMParameterSpec, но я предполагаю, что есть неизвестная мне причина, по которой он используется в качестве метода в классе Cipher. Однако я могу утверждать, что в документации следовало бы указать, какое значение aad используется, когда вызывающий метод не указывает его.
  • И, наконец, IV: мне нравится подход Android (и iOS), обеспечивающий безопасные значения по умолчанию для крипто-API. Тех, кто не разбирается в шифровании, количество вариантов, предоставляемых другими API (такими как JCE), может напугать, поэтому хорошая практика — предоставлять по умолчанию наиболее безопасные варианты.

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

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Marc Obrador Sureda: Doing AES/GCM in Android: adventures in the field

Предыдущая статьяRelay для Angular
Следующая статьяЭффективное или частное хранение данных с помощью JavaScript WeakMaps