Я проанализировал с десяток приложений, чтобы посмотреть как они работают с токенами. Все проанализированные мной приложения обрабатывали критичные данные и позволяли установить пин-код на вход в качестве дополнительной защиты. Давайте посмотрим на самые частые ошибки:
- Отправка в API пин-кода вместе с RefreshToken, для подтверждения аутентификации и получения новых токенов. — Плохо, RefreshToken лежит незащищенный в локальном хранилище, при физическом доступе к устройству или бэкапу можно его извлечь, так же это может сделать малварь.
- Сохранение пин-кода в сторэдж вместе с RefreshToken, далее локальная проверка пин-кода и отправка RefreshToken в API. — Кошмар, RefreshToken лежит незащищенный вместе с пином, что позволяет их извлечь, кроме того появляется еще один вектор предполагающий bypass локальной аутентификации.
- Неудачное шифрование RefreshToken пин-кодом, которое позволяет восстановить из шифротекста пин-код и RefreshToken. — Частный случай предыдущей ошибки, эксплуатирующийся немного сложней. Но отметим, что это правильный путь.
Посмотрев на частые ошибки можно переходить к продумыванию логики безопасного хранения токенов в вашем приложении. Начать стоит с основных ассетов, связанных с аутентификацией/авторизацией в процессе работы приложения и выдвинуть к ним некоторые требования:
Credentials — (логин + пароль) — используются для аутентификации пользователя в системе.
+ пароль никогда не хранится на устройстве и должен быть немедленно очищен из оперативной памяти после отправки в API
+ не передаются методом GET в query параметрах HTTP запроса, вместо этого используются POST запросы
+ кэш клавиатуры отключен для текстовых полей обрабатывающих пароль
+ буфер обмена деактивирован у текстовых полей, которые содержат пароль
+ пароль не разглашаются через пользовательский интерфейс (те используются звездочки), так же пароль не попадает в скриншоты
AccessToken — используется для подтверждения авторизации пользователя.
+ никогда не сохраняется в долговременную память и хранится только в оперативной памяти
+ не передаются методом GET в query параметрах HTTP запроса, вместо этого используются POST запросы
RefreshToken — используется для получения новой связки AccessToken+RefreshToken.
+ ни в каком виде не хранится в оперативной памяти и должен быть немедленно удален из нее после получения от API и сохранения в долговременную память или после получения из долговременной памяти и использования
+ хранится только в зашифрованном виде в долговременной памяти
+ шифруется пином с помощью магии и определенных правил (правила будут описаны ниже), те если пин не был установлен, то не сохраняем вообще
+ не передаются методом GET в query параметрах HTTP запроса, вместо этого используются POST запросы
PIN — (обычно 4 или 6 значное число) — используется для шифрования/дешифрования RefreshToken.
+ никогда и нигде не хранится на устройстве и должен быть немедленно очищен из оперативной памяти после использования
+ никогда не покидает пределы приложения, те никуда не передается
+ используется только для шифрования/дешифрования RefreshToken
OTP — одноразовый код для 2FA.
+ OTP никогда не хранится на устройстве и должен быть немедленно очищен из оперативной памяти после отправки в API
+ не передаются методом GET в query параметрах HTTP запроса, вместо этого используются POST запросы
+ кэш клавиатуры отключен для текстовых полей обрабатывающих OTP
+ буфер обмена деактивирован у текстовых полей, которые содержат OTP
+ OTP не попадает в скриншоты
+ приложение удаляет OTP с экрана, когда уходит в бэкграунд
Теперь перейдем к
Я приведу наглядный пример, допустим мы хотим зашифровать UUID
aec27f0f-b8a3-43cb-b076-e075a095abfeтаким набором AES/CBC/PKCS5Padding, в качестве ключа используя PIN. Вроде алгоритм хороший, все по гайдлайнам, но тут есть ключевой момент — ключ содержит крайне мало энтропии. Давайте посмотрим к чему это ведет:
- Padding — поскольку наш токен занимает 36 байт, а AES блочный режим шифрования с блоком 128 бит, то алгориму нужно добить токен до 48 байт (что кратно 128 битам). В нашем варианте хвост будет добавлен по стандарту PKCS5Padding, т.е. значение каждого добавляемого байта равняется количеству добавляемых байт
01
У нас последний блок будет выглядеть примерно так:
02 02
03 03 03
04 04 04 04
05 05 05 05 05
06 06 06 06 06 06
etc.
… | 61 62 66 65 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C |
И тут есть проблема, глядя на этот padding мы можем отсеить, расшифрованные неправильным ключом, данные (по невалидному последнему блоку) и, тем самым, определить из сбрученной кучи валидный RefreshToken. - Предикабельный формат токена — даже если сделаем наш токен кратным 128 битам (например убрав дефисы), чтобы избежать добавления паддинга, то мы наткнемся на следующую проблему. Проблема заключается в том, что все из той же сбрученой кучи мы можем собрать строки и определить какая из них попадает под формат UUID. UUID в своем каноническом текстовом виде представляет собой 32 цифры в шестнадцатеричном формате разделенных дефисом на 5 групп 8-4-4-4-12
xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
где M — это версия, а N — вариант. Всего этого вполне достаточно, чтобы отсеить расшифрованные неправильным ключом токены, оставив подходящий под формат UUID RefreshToken.
С учетом всего перечисленного выше можно переходить к реализации, я выбрал простой вариант сгенерировать 64 рандомных байта и завернуть их в base64:
Вот пример такого токена:public String createRefreshToken() { byte[] refreshToken = new byte[64]; final SecureRandom secureRandom = new SecureRandom(); secureRandom.nextBytes(refreshToken); return Base64.getUrlEncoder().withoutPadding() .encodeToString(refreshToken); }
YmI8rF9pwB1KjJAZKY9JzqsCu3kFz4xt4GkRCzXS9-FS_kbN3-CF9RGiRuuGqwqMo-VxFDhgQNmgjlQFD2GvbAТеперь посмотрим как это выглядит алгоритмически (на Android и iOS алгоритм будет одинаковый):
private static final String ALGORITHM = "AES"; private static final String CIPHER_SUITE = "AES/CBC/NoPadding"; private static final int AES_KEY_SIZE = 16; private static final int AES_BLOCK_SIZE = 16; public String encryptToken(String token, String pin) { decodedToken = decodeToken(token); // декодируем токен rawPin = pin.getBytes(); byte[] iv = generate(AES_BLOCK_SIZE); // генерируем вектор инициаллизации для режима CBC byte[] salt = generate(AES_KEY_SIZE); // генерируем соль для функции удлиннения ключа byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE); // удлинняем пин-код до размера ключа Cipher cipher = Cipher.getInstance(CIPHER_SUITE); // инициаллизируем нашим шифронабором cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, ALGORITHM), new IvParameterSpec(iv)); return cipher.doFinal(token); } public byte[] decodeToken(String token) { byte[] rawToken = token.getBytes(); return Base64.getUrlDecoder().decode(rawToken); } public final byte[] generate(int size) { byte[] random = new byte[size]; (new SecureRandom()).nextBytes(random); return random; }
На какие строки стоит обратить внимание:
private static final String CIPHER_SUITE = "AES/CBC/NoPadding";
Никакого padding, ну вы помните.
decodedToken = decodeToken(token); // декодируем токен
Нельзя просто так взять и зашифровать токен в base64 представлении, тк это представление имеет определенный формат (ну вы помните).
byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE); // удлинняем пин-код до размера ключа
На выходе получим ключ размером AES_KEY_SIZE, пригодный для алгоритма AES. В качестве kdf можно использовать любую key derivation function, рекомендуемые Argon2, SHA-3, Scrypt в случае плохой жизни pbkdf2(очень хорошо параллелится на FPGA).
Итоговый зашифрованный токен можно спокойно сохранять на устройстве и не беспокоиться о том, что его может кто-то украсть, будь-то малварь или не отягощенный моральными принципами субъект.
Еще немного рекомендаций:
- Исключите токены из бэкапов.
- На iOS храните токен в keychain с атрибутом kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly.
- Не раскидывайте ассеты, расмотренные в этой статье (key, pin, password и тд.) по всему приложению.
- Затирайте ассеты сразу как они становятся не нужны, не держите их в памяти дольше чем необходимо.
- Используйте SecureRandom в Android и SecRandomCopyBytes в iOS для генерации рандомных байт в криптографическом контексте.
Мы рассмотрели некоторое количество подводных камней при хранении токенов, которые должен, по-моему мнению, знать каждый человек разрабатывающий приложения, работающие с критичными данными. Эта тема, в которой можно запутаться на любом шаге, если есть вопросы, задавайте их в комментариях. Так же приветствуются замечания по тексту.
Ссылки:
CWE-311: Missing Encryption of Sensitive Data
CWE-327: Use of a Broken or Risky Cryptographic Algorithm
CWE-327: CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator (PRNG)
CWE-598: Information Exposure Through Query Strings in GET Request

