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)
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()
anduserAuthenticationValiditySeconds()
can be used to create a time-bound key. Time-bound keys require authorization usingBiometricPrompt
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(
this@MainActivity,
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"
}
}
Download example in github.