Store authentication tokens safely

Hi% username%. I, regardless of the topic of the report, are constantly asked at conferences the same question - “how to safely store tokens on the user's device?”. I usually try to answer, but time does not allow to fully reveal the topic. With this article I want to completely close this question.

I analyzed a dozen applications to see how they work with tokens. All applications analyzed by me processed critical data and allowed to set a pin-code on an input as additional protection. Let's look at the most common mistakes:

  • Sending a PIN code to the API along with RefreshToken to confirm authentication and receive new tokens. - Badly, RefreshToken is unprotected in the local storage, with physical access to the device or backup, you can remove it, as well, malware can do it.
  • Save the PIN code to the stack along with RefreshToken, then local pin code verification and send the RefreshToken to the API. - Nightmare, RefreshToken is unprotected with a pin, which allows them to be extracted, in addition there is another vector suggesting bypass local authentication.
  • Unsuccessful encryption RefreshToken pin code that allows you to recover from the ciphertext pin code and RefreshToken. - A special case of a previous error, which is operated a little more complicated. But we note that this is the right way.

Looking at the frequent errors, you can proceed to thinking through the logic of safe storage of tokens in your application. It is worth starting with the main assets associated with authentication / authorization during the operation of the application and put forward some requirements to them:

Credentials - (login + password) - are used to authenticate the user to the system.
    + the password is never stored on the device and should be immediately cleared from the RAM after being sent to the API
    + is not sent by the GET method in the query parameters of the HTTP request, instead POST requests are used
    + the keyboard cache is disabled for text fields that process the password
    + the clipboard is deactivated for text fields that contain a password
    + The password is not disclosed through the user interface (those are used asterisks), the password is also not included in the screenshots of

AccessToken - used to confirm user authorization.
    + is never stored in long-term memory and is stored only in RAM
    + is not transferred by the GET method in the query parameters of the HTTP request, instead using POST requests

RefreshToken - used to get the new AccessToken + RefreshToken binding.
    + is not stored in any form in RAM and should be immediately removed from it after it is received from the API and stored in long-term memory or after it is received from long-term memory and used
    + is stored only in encrypted form in long-term memory
    + pin is encrypted using magic and certain rules (the rules will be described below), if the pin has not been set, then we do not save it at all
    + are not transmitted by the GET method in the query parameters of the HTTP request, are used instead POST

PIN requests - (usually 4 or 6 digits) - used to encrypt / decrypt RefreshToken.
    + Never is ever stored on the device and should be immediately cleaned from RAM after use
    + Never leaves the application limits, it is not transmitted anywhere
    + Used only for encryption / decryption RefreshToken

OTP - one-time code for 2FA.
    + OTP is never stored on the device and should be immediately cleared from RAM after being sent to API
    + is not transmitted by the GET method in the query parameters of the HTTP request, instead POST requests are used
    + keyboard cache is disabled for text fields that process OTP
    + clipboard is deactivated for text fields that contain OTP
    + OTP does not fall into the screenshots
    + the application removes the OTP from the screen when it goes into the background

Now we turn to magiccryptography. The main requirement is that under no circumstances should you allow the implementation of such a RefreshToken encryption mechanism, under which you can validate the result of decryption locally. That is, if the attacker took possession of the ciphertext, he should not be able to pick up the key. The only validator should be an API. This is the only way to limit key selection attempts and tokens in the event of a Brute-Force attack.

I will give a clear example, let's say we want to encrypt the UUID
aec27f0f-b8a3-43cb-b076-e075a095abfe
such a set of AES / CBC / PKCS5Padding, using the PIN as the key. It seems the algorithm is good, all according to the guidelines, but there is a key point here - the key contains very little entropy. Let's see what this leads to:

  1. Padding - since our token is 36 bytes, and AES is a block encryption mode with a 128-bit block, the algorithm needs to finish the token up to 48 bytes (which is a multiple of 128 bits). In our version, the tail will be added according to the PKCS5Padding standard, i.e. the value of each byte added equals the number of bytes added
    01
    02 02
    03 03 03
    04 04 04 04
    05 05 05 05 05
    06 06 06 06 06 06
    etc.
    Our last block will look like this:
    ... | | 61 62 66 65 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C 0C |
    And here there is a problem, looking at this padding, we can filter, decrypted with the wrong key, the data (according to the invalid last block) and, thus, determine the valid RefreshToken from the broken heap.
  2. Predicable token format - even if we make our token multiple of 128 bits (for example, removing hyphens) to avoid adding padding, we will come across the following problem. The problem is that we can collect all the strings from the same heap and determine which of them falls under the UUID format. The UUID, in its canonical textual form, is 32 hexadecimal digits divided by a hyphen into 5 groups 8-4-4-4-12
    xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx
    where M is the version and N is an option. All this is enough to eliminate tokens decrypted with the wrong key, leaving the appropriate UUID RefreshToken format.

Taking into account all the above, you can proceed to the implementation, I chose a simple option to generate 64 random bytes and wrap them in base64:

public String createRefreshToken(){
    byte[] refreshToken = newbyte[64];
    final SecureRandom secureRandom = new SecureRandom();
    secureRandom.nextBytes(refreshToken);
    return Base64.getUrlEncoder().withoutPadding()
            .encodeToString(refreshToken);
}
Here is an example of such a token:
YmI8rF9pwB1KjJAZKY9JzqsCu3kFz4xt4GkRCzXS9-FS_kbN3-CF9RGiRuuGqwqMo-VxFDhgQNmgjlQFD2GvbA
Now let's see how it looks algorithmically (on Android and iOS, the algorithm will be the same):

privatestaticfinal String ALGORITHM = "AES";
privatestaticfinal String CIPHER_SUITE = "AES/CBC/NoPadding";
privatestaticfinalint AES_KEY_SIZE = 16;
privatestaticfinalint AES_BLOCK_SIZE = 16;
public String encryptToken(String token, String pin){
    decodedToken = decodeToken(token); // декодируем токен
    rawPin = pin.getBytes();
    byte[] iv = generate(AES_BLOCK_SIZE); // генерируем вектор инициаллизации для режима CBCbyte[] 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);
}
publicbyte[] decodeToken(String token) {
    byte[] rawToken = token.getBytes();
    return Base64.getUrlDecoder().decode(rawToken);
}
publicfinalbyte[] generate(int size) {
    byte[] random = newbyte[size];
    (new SecureRandom()).nextBytes(random);
    return random;
}

Which lines you should pay attention to:

privatestaticfinal String CIPHER_SUITE = "AES/CBC/NoPadding";

No padding, well, you remember.

decodedToken = decodeToken(token); // декодируем токен

You can't just take and encrypt a token in the base64 view, because this view has a certain format (well, you remember).

byte[] key = kdf.deriveKey(rawPin, salt, AES_KEY_SIZE); // удлинняем пин-код до размера ключа

At the output, we obtain a key of AES_KEY_SIZE size suitable for the AES algorithm. As kdf, you can use any key derivation function recommended by Argon2, SHA-3, Scrypt in case of bad life of pbkdf2 (very well paralleled on FPGA).

The final encrypted token can be safely stored on the device and not worry that someone can steal it, whether it is malvar or a subject not burdened by moral principles.

Some more recommendations:

  • Exclude tokens from backups.
  • On iOS, store the token in keychain with the kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly attribute.
  • Do not scatter the assets examined in this article (key, pin, password, etc.) throughout the application.
  • Erase assets immediately as they become unnecessary, do not keep them in memory longer than necessary.
  • Use SecureRandom on Android and SecRandomCopyBytes on iOS to generate random bytes in a cryptographic context.

We considered a number of pitfalls in the storage of tokens, which should, in my opinion, every person who knows applications that work with critical data should know. This topic, in which you can get confused at any step, if you have questions, ask them in the comments. Also welcome comments on the text.

Links:

    the CWE-311: Missing the Encryption of Sensitive the Data
    the CWE-327: the Use of a for Broken or Risky Cryptographic the Algorithm
    the CWE-327: CWE-338: the Use of cryptographically Weak Pseudo-the Random Number The Generator (the PRNG)
    the CWE-598: Information Exposure Through Query Strings in GET Request

Also popular now: