React Native: Implementing an AES encryption module for Android

 • 

For an app I am currently working on I had to find a way to encrypt sensitive user data due to data security laws that the app has to abide by.
For iOS devices you don't have to worry about doing much yourself as the developer since they come with hardware level encryption. However, it's a different story for Android devices. While some devices do offer hardware encryption facilities, not all do. And when they do, it is not a guaranteed that the feature is enabled, since it's the users and not the developers who control that setting.

Since we use Realm for persistent storage in the app, which offers AES encryption with a 32 byte key, I chose to also use the same for encrypting non-database data such as user images and attachment files.

We're using React Native to create the app, which is why I hoped to find a preexisting React Native library that I could use. Unfortunately I couldn't find anything that actually worked the way I needed it to work, so I decided to write my own native module. Using a plain JavaScript crypto implementation was also not an option, as those are typically very slow, which would be problematic as there is a fair amount of encrypting and decrypting of data in the app.

Native Java code

These are the two methods for encrypting and decrypting a Base64 String input using the spongycastle crypto library.


@ReactMethod
    public void getRandomIv(Promise promise){
        SecureRandom rng = new SecureRandom();
        byte[] ivBytes = new byte[16];
        rng.nextBytes(ivBytes);
            
        String ivBase64 = Base64.encodeToString(ivBytes, Base64.NO_WRAP);
        promise.resolve(ivBase64);
    }

@ReactMethod
    public void encryptSpongy(byte[] key, String clearText, String ivBytesBase64, Promise promise)
    {
        try
        {
            byte[] clear = clearText.getBytes("UTF-8");
            byte[] ivBytes= Base64.decode(ivBytesBase64, Base64.NO_WRAP);


            PaddedBufferedBlockCipher cipher = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()));

            cipher.init(true, new ParametersWithIV(new KeyParameter(key), ivBytes));
            byte[] outBuf   = new byte[cipher.getOutputSize(clear.length)];

            int processed = cipher.processBytes(clear, 0, clear.length, outBuf, 0);
            processed += cipher.doFinal(outBuf, processed);

            byte[] outBuf2 = new byte[processed + 16];        // Make room for iv
            System.arraycopy(ivBytes, 0, outBuf2, 0, 16);    // Add iv
            System.arraycopy(outBuf, 0, outBuf2, 16, processed);    // Then the encrypted data

            final String encryptedB64 = Base64.encodeToString(outBuf2, Base64.NO_WRAP);

            //System.out.println(encryptedB64);

            promise.resolve(encryptedB64);

            //return encryptedB64;
        }
        catch(Exception e)
        {
            e.printStackTrace();
        }
    }
    
@ReactMethod
    public void decryptSpongy(byte[] key, String encryptedBase64, Promise promise)
    {
        try
        {
            final byte[] encrypted = Base64.decode(encryptedBase64, Base64.NO_WRAP);

            PaddedBufferedBlockCipher cipher = new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine()));
            byte[] ivBytes = new byte[16];
            System.arraycopy(encrypted, 0, ivBytes, 0, ivBytes.length); // Get iv from data
            byte[] dataonly = new byte[encrypted.length - ivBytes.length];
            System.arraycopy(encrypted, ivBytes.length, dataonly, 0, encrypted.length   - ivBytes.length);

            cipher.init(false, new ParametersWithIV(new KeyParameter(key), ivBytes));
            byte[] clear = new byte[cipher.getOutputSize(dataonly.length)];
            System.out.println(cipher.getOutputSize(dataonly.length));
            int len = cipher.processBytes(dataonly, 0, dataonly.length, clear,0);
            len += cipher.doFinal(clear, len);


            final String decryptedString = new String(clear).substring(0, len);
            System.out.println(decryptedString);

            promise.resolve(decryptedString);


            //return decryptedString;
        }
        catch(Exception e)
        {
            e.printStackTrace();
        }
    }    
  

The getRandomIV() method simply generates a random 16 byte initialization vector (IV) for the AES cipher and returns it as a Base64 encoded string.

In the encrypt() method
new PaddedBufferedBlockCipher(new CBCBlockCipher(new AESEngine())) means that we want to use the CBC mode of the AES cipher with padding. The padding mode used by default is PKCS7.
In the following lines the cipher is applied to the byte array of the input string and written into outBuf, after which the initiazation vector and the cipher are both copied into outBuf2. Finally, the cipher is encoded into a Base64 string and resolved as the result of a promise.

The decrypt() method simply reverses what happend in the encrypt() method, as AES is a symmetric cipher. Since the method expects the Base64 representation of the cipher instead of the actual cipher, we first have to decode it to retrieve the byte array on which the decryption algorithm will operate. Next we extract the initialzation vector and the encrypted data into separate byte arrays. After that, the same approach is used for decrypting the data that was used for encrypting it. The decrypted cleartext is stored in the clear variable.

Since we're using padding and AES is a block cipher which works on blocks of 16 bytes, the resulting cipher and also the cleartext obtained from decrypting will always be a multiple of 16 bytes.
This is why we have to extract the original cleartext in the decrypt() method from the result of applying the algorithm to the cipher. This is what happens here:
new String(clear).substring(0, len);. The len variable is the amount of bytes in the resulting cleartext which are not padding.

Takeaways and things I (re)learned in the process of implementing this module

  • AES is a block cipher. This means it takes a plaintext of x bytes and encrypts it in blocks of y bytes. The resulting cipher will have a size that is a multiple of the blocksize y.
  • AES requires a random initialization vector with the same size as the blocksize.
  • In AES the block size is 16 bytes.
  • If the last block of data is less than 16 bytes long it has to be padded, so that the algorithm can work with 16 bytes.
  • The size of a cipher from a plaintext with length x is calculated as (x/16 +1)*16 (the division is an integer division).
    • i.e. for x = 15 the cipher size is 16
  • This results in cleartext lengths smaller than a multiple of 16 being rounded up to the next higher multiple and lengths which are an exact multiple of 16 also being rounded up, which results in getting a complete 16 bytes of padding.
  • It is common to store the IV together with the ciphertext which results in an additional 16 bytes.
  • AES can use 3 different key sizes: 128, 192 and 256 bits. The algorithm automatically changes the number of iterations based on the key size used. You don't have to specify the key size, you simply enter the key of one of those three sizes and the algorithm will automatically use the correct number of iterations.