[Из песочницы] Храним токены авторизации безопасно

habr.png

Привет %username%. Меня, независимо от темы доклада, на конференциях постоянно спрашивают один и тот же вопрос — «как безопасно хранить токены на устройстве пользователя?». Обычно я стараюсь ответить, но время не позволяет полностью раскрыть тему. Этой статьей я хочу полностью закрыть этот вопрос.
Я проанализировал с десяток приложений, чтобы посмотреть как они работают с токенами. Все проанализированные мной приложения обрабатывали критичные данные и позволяли установить пин-код на вход в качестве дополнительной защиты. Давайте посмотрим на самые частые ошибки:

  • Отправка в 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 с экрана, когда уходит в бэкграунд

Теперь перейдем к магии криптографии. Основное требование — ни при каких обстоятельствах нельзя допустить реализацию такого механизма шифрования RefreshToken, при котором можно провалидировать результат расшифровки локально. То есть, если злоумышленник завладел шифротекстом он не должен иметь возможности подобрать ключ. Единственным валидатором должно быть API. Это единственный способ ограничить попытки подбора ключа и заинвалидировать токены в случае Brute-Force атаки.

Я приведу наглядный пример, допустим мы хотим зашифровать UUID

aec27f0f-b8a3–43cb-b076-e075a095abfe

таким набором AES/CBC/PKCS5Padding, в качестве ключа используя PIN. Вроде алгоритм хороший, все по гайдлайнам, но тут есть ключевой момент — ключ содержит крайне мало энтропии. Давайте посмотрим к чему это ведет:

  1. 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.
  2. Предикабельный формат токена — даже если сделаем наш токен кратным 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.

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

Еще немного рекомендаций:

  • Исключите токены из бэкапов.
  • На 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

© Habrahabr.ru