javaandroidkotlinencryptionaes

javax.crypto.BadPaddingException even though block is correct size (multiple of 16)


I've encountered this problem whilst trying to create encryption for Preference DataStore. When trying to decrypt message I get this javax.crypto.BadPaddingException

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import android.util.Log
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec

class CryptoManager {
    private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply {
        load(null)
    }

    companion object {
        private const val 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 = "$ALGORITHM/$BLOCK_MODE/$PADDING"
    }

    private val encryptCipher get() = Cipher.getInstance(TRANSFORMATION).apply {
        init(Cipher.ENCRYPT_MODE, getKey())
    }

    private fun getDecryptCipherForIv(iv: ByteArray): Cipher {
        return Cipher.getInstance(TRANSFORMATION).apply {
            init(Cipher.DECRYPT_MODE, getKey(), IvParameterSpec(iv))
        }
    }

    private fun getKey(): SecretKey {
        val existingKey = keyStore.getEntry("myAppSecret", null) as? KeyStore.SecretKeyEntry
        return existingKey?.secretKey ?: createKey()
    }

    private fun createKey(): SecretKey {
        return KeyGenerator.getInstance(ALGORITHM).apply {
            init(
                KeyGenParameterSpec.Builder(
                    "myAppSecret",
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
                )
                    .setBlockModes(BLOCK_MODE)
                    .setEncryptionPaddings(PADDING)
                    .setUserAuthenticationRequired(false)
                    .setRandomizedEncryptionRequired(true)
                    .build()
            )
        }.generateKey()
    }

    private fun getSizeBytes(size: Int): ByteArray {
        return byteArrayOf(
                (size shr 24).toByte(),
                (size shr 16).toByte(),
                (size shr 8).toByte(),
                size.toByte()
        )
    }

    private fun getSizeFromBytes(bytes: ByteArray): Int {
        return (bytes[0].toInt() shl 24) or
                ((bytes[1].toInt() and 0xFF) shl 16) or
                ((bytes[2].toInt() and 0xFF) shl 8) or
                (bytes[3].toInt() and 0xFF)
    }

    fun encrypt(bytes: ByteArray): ByteArray {
        val encrypted = encryptCipher.doFinal(bytes)
        val sizeBytes = getSizeBytes(encrypted.size)
        val iv = encryptCipher.iv

        Log.d("myDebug", "encrypt iv: ${iv.map { it.toInt().toByte() }.joinToString(" ")}")

        return Base64.encode(byteArrayOf(encryptCipher.iv.size.toByte()) +
                iv +
                sizeBytes +
                encrypted,
            Base64.DEFAULT)

    }

    fun decrypt(bytes64: ByteArray): ByteArray {

        // ByteArray should look like IV_SIZE | IV | MESSAGE_SIZE_BYTES | MESSAGE
        val bytes = Base64.decode(bytes64, Base64.DEFAULT)

        Log.d("myDebug", "decrypt bytes: ${bytes.map { it.toInt().toByte() }.joinToString(" ")}")

        // Pointer for moving, and extracting data from ByteArray
        var pointer = 0
        
        val ivSize = bytes[pointer++].toInt()

        val iv = bytes.sliceArray(pointer until ivSize + pointer)

        Log.d("myDebug", "iv: ${iv.map { it.toInt().toByte() }.joinToString(" ")}")

        pointer += ivSize

        // size of the message is coded over 4 bytes (int)
        val messageSize = getSizeFromBytes(bytes.sliceArray(pointer until pointer + 4))

        Log.d("myDebug", "messageSize: $messageSize")

        pointer += 4 


        val encryptedMessage = bytes.sliceArray(pointer until pointer + messageSize)
        Log.d("myDebug", "message: ${encryptedMessage.map { it.toInt().toByte() }.joinToString(" ")}")
        Log.d("myDebug", "extracted message size: ${encryptedMessage.size}")

        return getDecryptCipherForIv(iv).doFinal(encryptedMessage)
    }
}

After extracting the message I can see it gets padded and extracted correctly - in multiples of 16. Am I missing something? What's the issue with this code? I left multiple Log.d's in case anyone wants to run it.

javax.crypto.BadPaddingException at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineDoFinal(AndroidKeyStoreCipherSpiBase.java:624) at javax.crypto.Cipher.doFinal(Cipher.java:2056)

Thank you for your time


Solution

  • A BadPaddingException is thrown if, after decryption, the padding does not match the specified padding, in this case PKCS#7 padding.

    A typical cause is a failed decryption, e.g. if the wrong key is used during decryption or the ciphertext is corrupted. This results in a byte sequence that differs from the actual plaintext, including a mostly invalid padding (mostly, because an incorrect decryption can also result in a valid padding with a low probability).

    In addition, a wrong IV can trigger this exception. For the CBC mode, an incorrect IV leads to a corruption of the first block. For plaintexts that are smaller than one block (16 bytes for AES), the padding is automatically affected by the corruption, which thus usually results in a BadPaddingException. For plaintexts that are greater than/equal to one block, the padding bytes are not located in the first block, so that the padding is not affected by the corruption and no BadPaddingException is thrown, but the first block of the decrypted data is corrupted.

    In the posted code, a wrong IV is indeed the reason for the issue. This can be easily verified as described above: If the code is used to encrypt a plaintext that is smaller than one block, a BadPaddingException is generally (but not necessarily) thrown; for longer plaintexts, decryption is successful, apart from the first block, which is corrupted.
    The reason for the incorrect IV is that the getter encryptCipher creates (and initializes) a new Cipher instance. As a result, the IV used during encryption in encryptCipher.doFinal(bytes) is different from the IV determined with encryptCipher.iv, which is later concatenated with the ciphertext, so that an incorrect IV is eventually used during decryption.

    A possible fix is:

    ...
    val cipher = encryptCipher
    val encrypted = cipher.doFinal(bytes)
    val sizeBytes = getSizeBytes(encrypted.size)
    val iv = cipher.iv
    return Base64.encode(byteArrayOf(iv.size.toByte()) + iv + sizeBytes + encrypted, Base64.DEFAULT)
    ...