Android encrypt and decrypt a file with Jetpack Security

Recently, Google released its security-crypto library as part of jetpack components to ease the process of making apps more secure.

The Jetpack Security (JetSec) crypto library provides abstractions for encrypting Files and SharedPreferences objects. The library promotes the use of the AndroidKeyStore while using safe and well-known cryptographic primitives. Using EncryptedFile and EncryptedSharedPreferences allows you to locally protect files that may contain sensitive data, API keys, OAuth tokens, and other types of secrets.

Jetpack Security is based on Tink, an open-source, cross-platform security project from Google. Tink might be appropriate if you need general encryption, hybrid encryption, or something similar. Jetpack Security data structures are fully compatible with Tink.

At the moment, the library includes three main features:

  • MasterKeys
  • EncryptedSharedPreferences
  • EncryptedFile

Master Key

Master Key and its management is very interesting and evidently the most important part of this library. It is to be provided while creating encrypted SharedPreferences object. It can be generated in following ways :

  • Hardcoded strings : This is not advised as it reduces security.
  • With JetPack’s AES256_GCM_SPEC specification : This is recommended way of generating a Master Key. JetSec library can help us generate a key using this specification as follows.
val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)

Custom key : This can be used for apps that require higher security and user-authentication to access local data. We need to mention keystoreAlias and key purposes along with other encryption configurations as shown below.

val keySpecifications = KeyGenParameterSpec.Builder(
    "key_alias",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).apply {
    setKeySize(256)
    setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
}.build()

val masterKey = MasterKeys.getOrCreate(keySpecifications)

Important options:

  • userAuthenticationRequired() and userAuthenticationValiditySeconds() can be used to create a time-bound key. Time-bound keys require authorization using BiometricPrompt for both encryption and decryption of symmetric keys.
  • unlockedDeviceRequired() sets a flag that helps ensure key access cannot happen if the device is not unlocked. This flag is available on Android Pie and higher.
  • Use setIsStrongBoxBacked(), to run crypto operations on a stronger separate chip. This has a slight performance impact, but is more secure. It’s available on some devices that run Android Pie or higher.

Note: If your app needs to encrypt data in the background, you should not use time-bound keys or require that the device is unlocked, as you will not be able to accomplish this without a user present.

// Custom Advanced Master Key
val advancedSpec = KeyGenParameterSpec.Builder(
    "master_key",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
).apply {
    setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    setKeySize(256)
    setUserAuthenticationRequired(true)
    setUserAuthenticationValidityDurationSeconds(15) // must be larger than 0
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        setUnlockedDeviceRequired(true)
        setIsStrongBoxBacked(true)
    }
}.build()

val advancedKeyAlias = MasterKeys.getOrCreate(advancedSpec)

Logic to authenticate user is to be handled by the app using BiometricPrompt. We can use the key for specified time once we get success in BiometricPrompt’s onAuthenticationSucceeded method.

// Create BiometricPrompt instance in onCreate

val biometricPrompt = BiometricPrompt(
    this, // Activity
    ContextCompat.getMainExecutor(this),
    authenticationCallback
)

private val authenticationCallback = object : AuthenticationCallback() {
        override fun onAuthenticationSucceeded(
            result: AuthenticationResult
        ) {
            super.onAuthenticationSucceeded(result)
            // Unlocked -- do work here.
        }
        override fun onAuthenticationError(
            errorCode: Int, errString: CharSequence
        ) {
            super.onAuthenticationError(errorCode, errString)
            // Handle error.
        }
    }


// To use
val promptInfo = PromptInfo.Builder()
    .setTitle("Unlock?")
    .setDescription("Would you like to unlock this key?")
    .setDeviceCredentialAllowed(true)
    .build()
biometricPrompt.authenticate(promptInfo)

Encrypt Files

EncryptedFile allows you to easily encrypt data using FileInputStream and decrypt using FileOutputStream. To create an instance of it, we need several things:

EncryptedFile.Builder(
    file,
    applicationContext,
    masterKeyAlias,
    EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build()

The first parameter is a File object that defines the path and name of where encrypted data should be stored. In my example, I created an instance of it like this:

val file = File(filesDir, ENCRYPTED_FILE_NAME) 

Using masterKeyAlias is exactly the same as in the EncryptedSharedPreferences example. The last parameter, fileEncryptionScheme defines the scheme of how the input/output stream should be encrypted or decrypted. At the moment, the only available value is AES256_GCM_HKDF_4KB. Below, I have listed some details about it:

  • Size of the main key: 32 bytes
  • HKDF algo: HMAC-SHA256
  • Size of AES-GCM derived keys: 32 bytes
  • Ciphertext segment size: 4096 bytes

For example purposes, I decided to download the Keyboardshortcut.pdf file from the example repository, accessible here using the OkHttp library, getting bytes from the response response.body!!.bytes() and then saving it by passing those bytes to following method:

private fun onFileDownloaded(bytes: ByteArray) {
        var encryptedOutputStream: FileOutputStream? = null
        try {
            encryptedOutputStream = encryptedFile.openFileOutput().apply {
                write(bytes)
            }
            result.text = getString(R.string.file_downloaded)
        } catch (e: Exception) {
            Log.e("TAG", "Could not open encrypted file", e)
            result.text = e.message
        } finally {
            encryptedOutputStream?.close()
        }
    }

And then reading it:

private fun readFile(fileInput: () -> FileInputStream) {
        Log.i("TAG", "Loading file...")

        var fileInputStream: FileInputStream? = null


        try {
            fileInputStream = fileInput()
            val fos = FileOutputStream(file2)
            val buffer = ByteArray(1024)
            var len1 = 0
            while (fileInputStream.read(buffer).also { len1 = it } > 0) {
                fos.write(buffer, 0, len1)
            }

            fileInputStream.close()
            fos.close()
        } catch (e: FileNotFoundException) {
            System.err.println("FileStreamsTest: $e")
        } catch (e: IOException) {
            System.err.println("FileStreamsTest: $e")
        }

Thats it. Here is my full MainActivity.kt

class MainActivity : AppCompatActivity() {

    private val file by lazy { File(filesDir, ENCRYPTED_FILE_NAME) }
    private val file2 by lazy { File(filesDir, "1$ENCRYPTED_FILE_NAME") }

    private val encryptedFile by lazy {
        EncryptedFile.Builder(
            file,
            applicationContext,
            masterKeyAlias,
            EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
        ).build()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        setupListeners()
    }

    private fun setupListeners() {

        fileDownload.setOnClickListener { downloadAndEncryptFile() }
        fileDecrypt.setOnClickListener {
            readFile { encryptedFile.openFileInput() }
        }
    }

    private fun downloadAndEncryptFile() {
        if (file.exists()) {
            Log.i("TAG", "Encrypted file already exists!")
            result.text = getString(R.string.file_exists)
        } else {
            Log.e("TAG", "Encrypted file does not exist exists! Downloading...")
            val request = Request.Builder().url(FILE_URL).build()
            okHttpClient.newCall(request).enqueue(
                object : Callback {
                    override fun onFailure(call: Call, e: IOException) {
                        result.text = e.message
                        Log.e("TAG", "Error occurred!", e)
                    }

                    override fun onResponse(call: Call, response: Response) {
                        onFileDownloaded(response.body!!.bytes())
                    }
                }
            )
        }
    }

    private fun onFileDownloaded(bytes: ByteArray) {
        var encryptedOutputStream: FileOutputStream? = null
        try {
            encryptedOutputStream = encryptedFile.openFileOutput().apply {
                write(bytes)
            }
            result.text = getString(R.string.file_downloaded)
        } catch (e: Exception) {
            Log.e("TAG", "Could not open encrypted file", e)
            result.text = e.message
        } finally {
            encryptedOutputStream?.close()
        }
    }

    private fun readFile(fileInput: () -> FileInputStream) {
        Log.i("TAG", "Loading file...")

        var fileInputStream: FileInputStream? = null


        try {
            fileInputStream = fileInput()
            val fos = FileOutputStream(file2)
            val buffer = ByteArray(1024)
            var len1 = 0
            while (fileInputStream.read(buffer).also { len1 = it } > 0) {
                fos.write(buffer, 0, len1)
            }

            fileInputStream.close()
            fos.close()
        } catch (e: FileNotFoundException) {
            System.err.println("FileStreamsTest: $e")
        } catch (e: IOException) {
            System.err.println("FileStreamsTest: $e")
        } finally {
            val uri = FileProvider.getUriForFile(
                [email protected],
                BuildConfig.APPLICATION_ID + ".provider",
                file2
            )

            val intent = Intent(Intent.ACTION_VIEW)
            intent.setDataAndType(uri, "application/pdf")
            intent.flags = Intent.FLAG_ACTIVITY_NO_HISTORY
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
            startActivity(intent)
        }
    }

    companion object {
        val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
        val okHttpClient: OkHttpClient = OkHttpClient.Builder().build()
        const val ENCRYPTED_FILE_NAME = "SHORTCUT4.pdf"
        const val FILE_URL =
            "https://faculty.kfupm.edu.sa/ee/ibrahimh/misc/COMPUTER%20SHORTCUTS.pdf"
    }
}
github_link

Leave a Reply

Your email address will not be published. Required fields are marked *

Back to Top