Android data encryption and decryption

Shedding light on Android Encryption. Android Crypto API Part 2 — Cipher

Hey folks. If you are reading this article, you may be having trouble figuring out how to encrypt data in your Android application to store it securely or send it to a server. Or maybe you just want to learn something new, and I am delighted to help you with that. Welcome to the second part of the Android Crypto API :)

Hayk Mkrtchyan
ProAndroidDev
Published in
9 min readFeb 28, 2024

--

Android Crypto API article poster which includes all the terminology that confuses developers, like Cipher, MessageDigest, TEE, Algorithms, Block Mode and Paddings, Android KeyStore, KeyGenerator, etc.
Android Cryptography

This article consists of 3 parts:

Welcome to the second part of our Android Crypto API. Please make sure to take a look at part 1 for a better understanding. The ones who did, great job! Now join me to explore today’s topic where we will puzzle out the following:

- Revealing Cipher
-
Transformation (Algorithm/Block mode/Padding)
-
Encrypt and decrypt data using Cipher
-
Key material and SecretKeySpec
-
Additional Info
-
Further resources and gists
-
Conclusion

Revealing Cipher

Let’s begin by adding some constants.

private const val AES_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
private const val TRANSFORMATION = "$AES_ALGORITHM/$BLOCK_MODE/$PADDING"

We will break down every line of this a little later. For now, let’s just get our Cipher instance:

// If we have a look at the docs it says: 
// Returns a Cipher object that implements the specified transformation.
val cipher = Cipher.getInstance(TRANSFORMATION)

Transformation (Algorithm/Block mode/Padding)

Now what is that TRANSFORMATION? Let’s find out. As you can see, the transformation is a combination of AES_ALGORITHM, BLOCK_MODE, and PADDING.

AES_ALGORITHM: We’ll use AES for our encryption. It is a symmetric key encryption algorithm, meaning the same key is used for both encryption and decryption. Let’s have a closer look at how AES performs encryption. Suppose we have our initial data (plain text). AES breaks it into smaller parts that we call blocks. Each block has a fixed size and for AES it’s 128 bits (16 bytes). It encrypts each block separately and then goes through multiple rounds of encryption. The number of rounds depends on the key length. It can be 128/192/256 bits. That’s all we need to know, but you can always delve deeper.

We do not have symmetric keys until API level 23 (Android 6). They are available from API 23 onwards.

BLOCK_MODE: Block modes define how a block cipher, like AES, processes and encrypts data in blocks. They determine the interactions between these blocks during encryption. There are different block modes like ECB, CBC, CFB, etc. For our tutorial, we will go with CBC (Cipher block chaining). In CBC mode, each block is mixed with the previous ciphertext block, creating a chain of interdependent encrypted blocks. This prevents an identical plaintext from producing the same ciphertext and adds an additional layer of security.

Hmm, no, it won’t actually:)) Still, we would get the same ciphertext for an identical plaintext.

IV: So why do we still get the same ciphertext for the same plaintext? The chaining effect in CBC is based on XORing each plaintext block with the ciphertext of the previous block. This creates dependencies between the blocks. But what about the very first block? There is no previous block. And here comes the Initialization Vector (IV) — a random generated value that is combined with the first block of plaintext before encryption. It has the same size as the block, i.e. 16 bytes. As it is random, each time a different mix is generated for each block, and thus we always get a different ciphertext for the same plaintext.

PADDING: As previously mentioned, CBC processes data in a fixed-size blocks. However, not all messages perfectly fit into these blocks. That’s where padding comes in. It adds extra bytes to ensure it aligns with the block size. There are paddings like PKCS5, Zero Padding, ISO Padding, etc. We will use PKCS7.

So overall here are all the steps involved: First, we break our data into blocks (AES). If the data doesn’t completely fit inside the block, we add extra bytes to it (Padding). Then we generate a random IV and mix when encrypting our first block (CBC). In the end, we get our ciphertext which is always different even for the same plaintext. And that makes our TRANSFORMATION that we pass to Cipher.

A diagram that illustrates the process of encryption with AES algorithm and CBC mode. We see the whole process how a plain text is converted to a ciphertext. The initialization vector is there too and you can see how it is combined with the very first block.
Encrypting data using the AES CBC mode.
Same diagram as the previous one, except padding is applied.
Encrypting data using the AES CBC mode with padding.
A diagram that illustrates the process of decryption with AES algorithm and CBC mode. We see the whole process how a ciphertext is decrypted back to a plain text.
Decrypting data using the AES CBC mode.

Encrypt and decrypt data using Cipher

Back to our code. To start working with the Cipher instance, we need to initialize it for a specific operation with a key.

// Operation Mode - Cipher.ENCRYPT_MODE
// Key - SecretKeySpec(keyValue, AES_ALGORITHM)
// Algorithm params - IvParameterSpec(iv)

val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyValue, AES_ALGORITHM), IvParameterSpec(iv))

For the operation mode, we pass ENCRYPT_MODE as we want to do encryption. Similarly, for decryption, we would pass DECRYPT_MODE. The second parameter is our key. We’ll talk about it later. With the third parameter, we specify our Initialization Vector (IV). Let’s have a look at how to generate it.

private fun generateRandomIV(size: Int): ByteArray {
val random = SecureRandom()
val iv = ByteArray(size)
random.nextBytes(iv)
return iv
}

Here we generate a random IV with a given size. As we have initialized our Cipher and generated IV, we can proceed to encryption. First, I’ll show the full code then we will go line by line:

@Throws(Exception::class)
fun encrypt(inputText: String): String {
val cipher = Cipher.getInstance(TRANSFORMATION)
val iv = generateRandomIV(cipher.blockSize)
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(keyValue, AES_ALGORITHM), IvParameterSpec(iv))

val encryptedBytes = cipher.doFinal(inputText.toByteArray())
val encryptedDataWithIV = ByteArray(iv.size + encryptedBytes.size)
System.arraycopy(iv, 0, encryptedDataWithIV, 0, iv.size)
System.arraycopy(encryptedBytes, 0, encryptedDataWithIV, iv.size, encryptedBytes.size)
return Base64.encodeToString(encryptedDataWithIV, Base64.DEFAULT)
}

You are already familiar with the first 3 lines))) Worth mentioning that we have the cipher’s block size. Also, this function can throw several exceptions, that’s why we simply throw a general Exception.

Error handling is essential when dealing with encryption/decryption.

val encryptedBytes = cipher.doFinal(inputText.toByteArray())

The doFinal() function takes a ByteArray as an input and does the encryption. So here we have our encrypted bytes in the output. But how to connect it with our IV? Later, we should use the same generated IV in our decryption, otherwise, we can’t decrypt our message back :(

val encryptedDataWithIV = ByteArray(iv.size + encryptedBytes.size)
System.arraycopy(iv, 0, encryptedDataWithIV, 0, iv.size)
System.arraycopy(encryptedBytes, 0, encryptedDataWithIV, iv.size, encryptedBytes.size)

We create a new ByteArray that has the summary length of our generated IV and encrypted bytes. First, we copy our IV into that ByteArray using System.arrayCopy(). The initial 16 bytes are occupied by our IV. We then append our encrypted bytes to our IV. In the end, we use Base64 to encode it to String.

Consider working directly with ByteArray and encoding it to String or another type outside this function. I did this in the same function for simplicity.

Let’s take a look at our decrypt function, and then we will test our code.

@Throws(Exception::class)
fun decrypt(data: String): String {
val encryptedDataWithIV = Base64.decode(data, Base64.DEFAULT)
val cipher = Cipher.getInstance(TRANSFORMATION)
val iv = encryptedDataWithIV.copyOfRange(0, cipher.blockSize)
val encryptedData = encryptedDataWithIV.copyOfRange(cipher.blockSize, encryptedDataWithIV.size)
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(keyValue, AES_ALGORITHM), IvParameterSpec(iv))

val decryptedBytes = cipher.doFinal(encryptedData)
return String(decryptedBytes, Charsets.UTF_8)
}

First, we decode our String to ByteArray. Then we split our ByteArray to extract the IV from it using the copyOfRange() function. When we have separated our IV and encrypted bytes, we can initialize our Cipher and pass that IV as an argument. As you can see this time we pass the operation mode DECRYPT_MODE. Then with the doFinal() function, we decrypt our data. In the end, we just convert our decrypted ByteArray to String.

If you look closely, you’ll notice that we use the same doFinal() in both our encryption and decryption. Then how is that function aware should it encrypt or decrypt our message? Well, it does base on which operation mode our Cipher was initialized.

On a button click you call either encrypt() by passing the input text or decrypt() by passing the encrypted data.

Button(onClick = { AESUtils.encrypt(input) }) {
Text(text = "Encrypt")
}

Button(onClick = { AESUtils.decrypt(encryptedText) }) {
Text(text = "Decrypt")
}

Now, let’s test our code.

The application demo where we illustrate the process of encryption and decryption
Application Demo

Works like a charm. You can find the complete code at the end of this article.

Key material and SecretKeySpec

To encrypt and decrypt our data we need a key. In the above examples we used SecretKeySpec class and passed it as an argument for the key. This class is used to construct a key from a given byte array.

But what is this byte array we are talking about? To gain a better understanding, let's take a closer look at its constructor.

/**
@param key - the key material of the secret key.
@param algorithm - the name of the secret-key algorithm to be associate with the given key material.
*/
public SecretKeySpec(byte[] key, String algorithm) { ... }

So to construct a key it needs a key material. In this context, it is a byte[].

To understand better, treat it as some kind of signature for that key. If someone has that key material (signature), they can construct the same key. So it is vital to keep it as safe as possible.

Let’s see how we can create our byte array (key material).

private val keyValue = "keep_this_key_in_secret".toByteArray(Charsets.UTF_8)

If we run our code and click on encrypt, our app will crash with the following message: java.security.InvalidKeyException: Unsupported key size: 23 bytes.

So why is that? Somewhere above we mentioned the significance of the key length. For AES it can be 128, 192, or 256 bits. You can see in some places something like AES-192 or AES-256. That value specifies the key length. More key length means more security. You are free to delve deep if you want to. AES-128 is sufficient to ensure the security we need. So for our input we pass a ByteArray, which is 23 bytes or 184 bits. SecretKeySpec can’t find an AES algorithm that can have a key with that length, that’s why it crashes. For AES-128 our key size should be 128 bits or 16 bytes. Let’s fix it.

private val keyValue = "keep_this_key_sc".toByteArray(Charsets.UTF_8)

As we’ve said, it is crucial to keep it as safe as possible. In real applications, you should at least consider keeping it in local properties and reading from there instead of hardcoding. For demonstration purposes, I keep it hardcoded.

Additional Info

Sometimes you may need to persist the key in some storage. To make this operation secure you should perform key wrapping. In simpler words, you create a new key and encrypt the key you want to save. Afterward, you should store that Base64 String in a database or another place. This will also give you an additional layer of security. Then you can unwrap it with the key you wrapped with and perform cryptographic operations.

A diagram that illustrates how key wrapping is performed. It demonstrates 2 keys, one is the key generated via some key material or based on user input, and the other one is the actual key that we use for encryption/decryption purposes. We use the first key to wrap our second key, which also gives an additional security.
Key Wrapping

Important: For key wrapping you should use Cipher.WRAP_MODE. Similarly, for unwrapping use Cipher.UNWRAP_MODE. I’ll provide an example gist below. Make sure to check.

What if you want to generate a key based on some user input? For example, a password-protected application. For that you should take a look at PBEKeySpec.

Conclusion

In this second part of our article, we introduced Cipher, explored cipher block chaining, initialization vector, and padding. Next, we have illustrated an example of how to encrypt and decrypt data with Cipher. We broke down the code and explained it in detail, line by line. Also, we discussed about key material and how to construct a key using SecretKeySpec. In conclusion, we have included some additional information and added the code in the resources section, allowing you to explore further and enhance your knowledge on your own. If there are some points you couldn’t understand, feel free to ask in the comments section. Hope you enjoyed reading. Thank you.

--

--