Chapters

Hide chapters

Real-World Android by Tutorials

Second Edition · Android 12 · Kotlin 1.6+ · Android Studio Chipmunk

Section I: Developing Real World Apps

Section 1: 7 chapters
Show chapters Hide chapters

16. Securing Data at Rest
Written by Antonio Roa-Valverde

This chapter starts with a simple approach to protect your stored data and builds up to more fine-tuned and advanced implementations. You can stop at any time if you have what you need. If you only want to implement a simple login for your app, great, you’ll find that near the beginning. If your project requires customized protocols, carry on to the end of the chapter.

In this chapter, you’ll learn how to:

  • Store a password securely.
  • Protect saved data.
  • Use encryption.

If you missed the previous chapters, the sample app includes a list of pets, their medical data and a section that lets you report safety issues while remaining anonymous.

Launch the starter app for this chapter and you’ll see a simple sign-up screen. Once you enter an email and select Sign Up, the list of pets will populate. Tap the Report tab to report a concern:

Figure 16.1 — Report Section
Figure 16.1 — Report Section

This is quite easy but, is your app also secure? As first step you’ll now implement a login for the user.

Implementing the Login

The app saves data about you, such as your pet’s home address and medical history, your login passwords and the safety reports you’ve submitted. If someone were to take your device, they’d have access to all that personal information.

To ensure only you can access that app data, it’s standard to require a password. Many modern devices have biometric readers like face, retina and fingerprint scanners.

In this first section, you’ll implement a biometric prompt to log in so only you can access the app on your device. You’ll also implement a password fallback, giving the user an alternative log-in option.

First, you need to have the app check that the device is able to use biometrics. In MainActivity.kt, replace the contents of loginPressed() like in the following code:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
  // ...
  fun loginPressed(view: View) {
    val biometricManager = BiometricManager.from(this)
    when (biometricManager.canAuthenticate(BIOMETRIC_STRONG)) {
      BiometricManager.BIOMETRIC_SUCCESS ->
          displayLogin(view, false) // 1
      BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE ->
          displayLogin(view, true) // 2
      BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE ->
          toast("Biometric features are currently unavailable.")
      BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED ->
          toast("Please associate a biometric credential with your account.")
      else ->
          toast("An unknown error occurred. Please check your Biometric settings")
      }
  }
  // ...
}

In this code you see that:

  1. You call displayLogin() if the device can perform biometric authentication with BIOMETRIC_SUCCESS.
  2. Otherwise, the fallback flag is set to true, allowing for password or PIN authentication.

Note: Android 11 divides the biometric features in strong and week. Fingerprint is considered strong, while face recognition is considered weak.

Next, add the following variables to the same MainActivity class:

private lateinit var biometricPrompt: BiometricPrompt
private lateinit var promptInfo: BiometricPrompt.PromptInfo

BiometricPrompt is a class from AndroidX.

Next, replace the contents of displayLogin() with the following:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
  // ...
  private fun displayLogin(view: View, fallback: Boolean) {
    val executor = Executors.newSingleThreadExecutor()
    biometricPrompt = BiometricPrompt(this, executor, // 1
        object : BiometricPrompt.AuthenticationCallback() {
          override fun onAuthenticationError(errorCode: Int,
                                             errString: CharSequence) {
            super.onAuthenticationError(errorCode, errString)
            runOnUiThread {
              toast("Authentication error: $errString")
            }
          }

          override fun onAuthenticationFailed() {
            super.onAuthenticationFailed()
            runOnUiThread {
              toast("Authentication failed")
            }
          }

          override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {// 2
            super.onAuthenticationSucceeded(result)

            runOnUiThread {
              toast("Authentication succeeded!")
              if (!isSignedUp) {
                generateSecretKey() // 3
              }
              performLoginOperation(view)
            }
          }
        })

    if (fallback) {
      promptInfo = BiometricPrompt.PromptInfo.Builder()
          .setTitle("Biometric login for my app")
          .setSubtitle("Log in using your biometric credential")
          // Cannot call setNegativeButtonText() and
          // setDeviceCredentialAllowed() at the same time.
          // .setNegativeButtonText("Use account password")
          .setAllowedAuthenticators(DEVICE_CREDENTIAL) // 4
          .build()
    } else {
      promptInfo = BiometricPrompt.PromptInfo.Builder()
          .setTitle("Biometric login for my app")
          .setSubtitle("Log in using your biometric credential")
          .setNegativeButtonText("Use account password")
          .build()
    }
    biometricPrompt.authenticate(promptInfo)
  }

  // ... 
}

Here’s what’s happening:

  1. You create a BiometricPrompt object for authentication.
  2. You override onAuthenticationSucceeded to determine a successful authentication.
  3. You create a secret key that’s tied to the authentication for first-time users.
  4. You create a fallback to password authentication by calling .setAllowedAuthenticators(DEVICE_CREDENTIAL).

Be sure you have a face, fingerprint or similar biometric scanner on your device to test the biometric part. Build and run. You’ll now be able to log in with your credentials:

Figure 16.2 — Biometric Prompt
Figure 16.2 — Biometric Prompt

Once the authentication is successful, you’ll see the pet list:

Figure 16.3 — Animals Near You
Figure 16.3 — Animals Near You

With that, you’ve secured access to the app with biometric security! That was easy.

Deciding What Security Options To Use

Is biometrics always the safest type of security for your app? To answer that question, it helps to use a threat model, a risk-based approach to making decisions. In other words, you need to consider what the biggest risks your user will face are.

People can use biometrics maliciously. For example, someone could steal your phone and hold it up to your face while you’re unconscious, or law enforcement could hold your device to your finger after they handcuff you.

In cases like these, a password is always better.

On the other hand, biometrics are better if your users are in the spotlight with people streaming to social media. There’s no chance a live streamer will capture their password.

Another thing to consider is: Even though access is limited, your data, such as reports and passwords, are not encrypted. Encryption uses a key to scramble the data. But if it’s all done in the app, you’re still vulnerable.

You’ll address all that next, but first, a little theory.

Exploring Hardware Security Modules

A Trusted Execution Environment (TEE) is software separate from the OS. It safely sandboxes security operations, and though it’s inside the main processor, it’s cordoned off from the main operating system. Security keys that are isolated this way are hardware-backed. You can find out if a key is hardware-backed by using KeyInfo.isInsideSecureHardware().

An example of a TEE is the ARM processor that has the TrustZone secure enclave, available in modern Samsung phones.

A Secure Element (SE) takes this a step further by putting the environment on a segregated chip. It has its own CPU and storage, as well as encryption and random-number generator methods. Security chips that exist outside of the main processor are harder to attack. Google’s devices contain the Titan M security chip, which is an SE.

In both cases, security operations happen at the hardware level in a separate environment that’s less susceptible to software exploits.

Android 9 and above provides the StrongBox Keymaster API for these features: https://developer.android.com/training/articles/keystore#HardwareSecurityModule. To ensure the key exists inside a segregated secure element, you can call KeyGenParameterSpec.Builder.setIsStrongBoxBacked(true).

Now, it’s time to put this information to use!

Hardening Data in the KeyStore

To protect your data, you’ll use MasterKey to generate a key in the KeyStore. This will encrypt your reports that you wish to send.

As you learned above, the benefit of storing a key in the KeyStore is that it allows the OS to operate on it without exposing the secret contents of that key. Key data does not enter the app space.

For devices that don’t have a security chip, permissions for private keys only allow your app to access the keys — and only after user authorization. This means you have to set up a lock screen on the device before you can use the credential storage. This makes it more difficult to extract keys from a device, called extraction prevention.

The security library contains two new classes: EncryptedFile and EncryptedSharedPreferences. In Encryption.kt, there are a few empty boilerplate methods set up for you. Replace encryptFile() with this:

class Encryption {
  companion object {
    // ...
    fun encryptFile(context: Context, file: File): EncryptedFile {
      val masterKey = MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build() // 1
      return EncryptedFile.Builder(
          context,
          file,
          masterKey,
          EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB // 2
      ).build()
    }
  }  
  // ...
}

Here’s what you did:

  1. Created a new master key.
  2. Encrypted the file using the popular secure AES encryption algorithm. (Visit https://en.wikipedia.org/wiki/Advanced_Encryption_Standard if you’re interested in the finer details).

In ReportDetailFragment.kt, find sendReportPressed(). Replace the two lines right after //TODO: Replace below for encrypting the file with the code block below:

val file = File(theContext.filesDir?.absolutePath, "$reportID.txt") // 1
val encryptedFile = encryptFile(theContext, file) // 2
encryptedFile.openFileOutput().bufferedWriter().use {
    it.write(reportString) // 3
}

Here’s what you changed:

  1. You created a file named "$reportID.txt".
  2. You created an EncryptedFile instance using the file object created in the last step.
  3. You used the EncryptedFile instance to write to file all the report data.

You’ve hardened the data stored on the device by using a secure key in the KeyStore. While this is an excellent first step, you can make the data even more secure by tying it to your biometric or password credentials. That way, even if someone accessed that cordoned-off key, it would be useless without your credentials.

Securing Data with Biometrics

For additional security, you can auto-generate a key in KeyStore that’s also protected by your biometric credential. If the device becomes compromised, the key is still encrypted.

This time, you’ll get a bit more advanced. Instead of using a high-level EncryptedFile, you’ll use an encryption class that lets you customize what you want to encrypt later. This is powerful because you can encrypt items in a database or information to send over a network, for example.

In Encryption.kt, add the following to generateSecretKey():

class Encryption {
  companion object {
    // ...
    @TargetApi(Build.VERSION_CODES.R)
    fun generateSecretKey() {
      val keyGenParameterSpec = KeyGenParameterSpec.Builder(
          KEYSTORE_ALIAS,
          KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
          .setBlockModes(KeyProperties.BLOCK_MODE_GCM) // 1
          .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
          .setUserAuthenticationRequired(true) // 2
          .setUserAuthenticationParameters(120, KeyProperties.AUTH_BIOMETRIC_STRONG) // 3
          .build()
      val keyGenerator = KeyGenerator.getInstance(
          KeyProperties.KEY_ALGORITHM_AES, PROVIDER) // 4
      keyGenerator.init(keyGenParameterSpec)
      keyGenerator.generateKey()
    }
    // ...
  }
}

Here are the changes you made:

  1. You chose GCM, a popular and safe block mode that the encryption uses. More on this later.
  2. By passing in .setUserAuthenticationRequired(true), you require a lock screen to be set up and the key to be locked until the user authenticates. Enabling the authentication requirement also revokes the key when the user removes or changes the lock screen.
  3. You made the key available for 120 seconds from authentication. After this time, the user will need to authenticate again using the fingerprint.
  4. You create a KeyGenerator with the above settings and set it to the AndroidKeyStore PROVIDER.

There are a few more options worth mentioning here:

  • setRandomizedEncryptionRequired(true) requires you to have sufficient randomization. Using this ensures that if you encrypt the same data a second time, that encrypted output will be different. This prevents an attacker from getting clues about the ciphertext based on feeding in the same data.
  • Another option is .setUserAuthenticationValidWhileOnBody(boolean remainsValid). It locks the key once the device has detected it’s no longer on the person.

Because you use the same key and cipher in different parts of the app, add the following helper functions to Encryption.kt, inside the companion object code block:

class Encryption {
  companion object {
    // ...
    private fun getSecretKey(): SecretKey {
      val keyStore = KeyStore.getInstance(PROVIDER)

      // Before the keystore can be accessed, it must be loaded.
      keyStore.load(null)
      return keyStore.getKey(KEYSTORE_ALIAS, null) as SecretKey
    }

    private fun getCipher(): Cipher {
      return Cipher.getInstance(KeyProperties.KEY_ALGORITHM_AES + "/"
          + KeyProperties.BLOCK_MODE_GCM + "/"
          + KeyProperties.ENCRYPTION_PADDING_NONE)
    }
  }
}

The first function returns the secret key from the KeyStore. The second returns a pre-configured Cipher.

Next, you’ll use that Cipher to do the actual encryption.

Encrypting Data

At this point, you’ve stored the key in the KeyStore, protected by your credentials. But so far, you’ve stored the user’s generated password in the clear. For your next step, you’ll update the login method to encrypt it using the Cipher object, given the SecretKey.

Start by going to Encryption.kt and replacing the contents of createLoginPassword() with the following:

class Encryption {
  companion object {
    // ...
    fun createLoginPassword(context: Context): ByteArray {
      val cipher = getCipher()
      val secretKey = getSecretKey()
      val random = SecureRandom()
      val passwordBytes = ByteArray(256)
      random.nextBytes(passwordBytes) // 1
      cipher.init(Cipher.ENCRYPT_MODE, secretKey)
      val ivParameters = cipher.parameters.getParameterSpec(GCMParameterSpec::class.java)
      val iv = ivParameters.iv
      PreferencesHelper.saveIV(context, iv) // 2
      return cipher.doFinal(passwordBytes) // 3
    }
    // ...
  }
}

Here’s what’s happening in that code:

  1. You create a random password using SecureRandom.
  2. You gather a randomized initialization vector (IV), which you need to decrypt the data, and save it into the shared preferences. An IV is some initial random data, discussed in more detail during the Customizing encryption section later.
  3. You return a ByteArray containing the encrypted data.

Decrypting Data

You’ve encrypted the password, so now you need to decrypt it when the user authenticates.

Open Encryption.kt and replace the contents of decryptPassword() with the code below:

class Encryption {
  companion object {
    // ...
    fun decryptPassword(context: Context, password: ByteArray): ByteArray {
      val cipher = getCipher()
      val secretKey = getSecretKey()
      val iv = PreferencesHelper.iv(context) // 1
      val ivParameters = GCMParameterSpec(128, iv)
      cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParameters) // 2
      return cipher.doFinal(password) // 3
    }
    // ...
  }
}

Here’s what’s happening:

  1. You retrieve the IV required to decrypt the data.
  2. You initialize Cipher using DECRYPT_MODE.
  3. You return a decrypted ByteArray.

Back in MainActivity.kt, find performLoginOperation(). Replace the line that calls createDataSource, where it says //TODO: Replace with encrypted data source below, with:

val encryptedInfo = createLoginPassword(this)
UserRepository.createDataSource(applicationContext, it, encryptedInfo)

On sign-up, you create a password for the account. Right after the //TODO: Replace below with the implementation that decrypts the password, in performLoginOperation(), replace success = true with the following:

val password = decryptPassword(this,
    Base64.decode(firstUser.password, Base64.NO_WRAP))
if (password.isNotEmpty()) {
  //Send password to authenticate with server etc
  success = true
}

On log-in, you retrieve the password to decrypt the data. The app shouldn’t work without the key.

Build and run, then try to log in. You’ll encounter the following exception:

kotlin.TypeCastException: null cannot be cast to non-null type javax.crypto.SecretKey

That’s because you didn’t create a key during the previous sign-up.

Delete the app to remove the old saved state, then rebuild and run. You’ll be able to log in now. :]

Figure 16.4 — Animals Near You
Figure 16.4 — Animals Near You

You’ve now created an encrypted password that will only be available once you’ve authenticated with your credentials. Your data is protected.

Using Cipher opens the door to powerful customization. You can stop here, but if you want to learn about advanced encryption or if your company requires you to use certain protocols, carry on.

Customizing Encryption

In this part, you’ll focus on the recommended standard for encryption, Advanced Encryption Standard (AES). AES uses a substitution–permutation network to encrypt your data with a key. Using this approach, it replaces bytes from one table with the bytes from another, and so creates permutations of data. Just like before, AES requires an encryption key. You’ll customize how that key is created.

Creating a Key

As mentioned above, AES uses a key for encryption. You also use that same key to decrypt the data. This property is called symmetric encryption.

You can use different specific lengths for the key, but 256 bits is standard.

Directly using the user’s password for encryption is dangerous because it likely won’t be random or large enough. A function called Password-Based Key Derivation Function (PBKDF2) comes to the rescue. It takes a password and, by hashing it with random data many times over, creates a key. That random data is called a salt. PBKDF2 creates a strong and unique key, even if someone else uses the same or a very simple password.

Because each key is unique, if an attacker steals and publishes the key online, it doesn’t expose all the users with the same password.

To use PBKDF2, start by generating the salt. Open Encryption.kt and add the following code to encrypt(), where it reads //TODO: Add custom encrypt code here:

class Encryption {
  companion object {
    // ...
    fun encrypt(dataToEncrypt: ByteArray,
                password: CharArray): HashMap<String, ByteArray> {
      val map = HashMap<String, ByteArray>()
      val random = SecureRandom() // HERE
      val salt = ByteArray(256)
      random.nextBytes(salt) 

      return map
    }
    // ...
  }
}

Here, you use SecureRandom, a cryptographically strong random number generator, which makes sure the output is difficult to predict. You should always use a secure class like this, instead of using java.util.Random, for example.

Next, you’ll generate a key with the user’s password and the salt. Add the following right under the code you just added in encrypt() in Encryption.kt:

class Encryption {
  companion object {
    // ...
    fun encrypt(dataToEncrypt: ByteArray,
                password: CharArray): HashMap<String, ByteArray> {
      val map = HashMap<String, ByteArray>()
      val random = SecureRandom()
      val salt = ByteArray(256) 
      random.nextBytes(salt) 

      val pbKeySpec = PBEKeySpec(password, salt, 1324, 256) // 1
      val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1") // 2
      val keyBytes = secretKeyFactory.generateSecret(pbKeySpec).encoded // 3
      val keySpec = SecretKeySpec(keyBytes, "AES") // 4
      return map
    }
    // ...
  }
}

Here’s what is going on inside that code. You:

  1. Put the salt and password into PBEKeySpec, a password-based encryption object. The constructor takes an iteration count (1324). The higher the number, the longer it would take to operate on a set of keys during a brute force attack.
  2. Passed PBEKeySpec into the SecretKeyFactory.
  3. Generated the key as a ByteArray.
  4. Wrapped the raw ByteArray into a SecretKeySpec object.

Now you have a secure key. The next part of customization involves the mode of operation.

Choosing an Encryption Mode

The mode defines how the data is processed. One example is Electronic Code Book (ECB). It’s simplistic in that it splits up the data and repeats the encryption process for every chunk with the same key. Because each block uses the same key, this mode is highly insecure. Don’t use this mode.

On the other hand, Counter Mode (CTR) uses a counter so each block encrypts differently. CTR is efficient and safe to use.

There are a few other modes that are useful: GCM offers authentication in addition to encryption, whereas XTS is optimized for full disk encryption. You’ll use Cipher Block Chaining (CBC) when you XOR each block of plaintext with the previous block.

Note: To learn more about the various modes of operation, go here: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation. To understand more about XOR, check this out: https://whatis.techtarget.com/definition/logic-gate-AND-OR-XOR-NOT-NAND-NOR-and-XNOR.

You’re almost ready to encrypt, but there’s one more thing you need to consider when it comes to modes.

Adding an Initialization Vector

As mentioned above, you’re going to use the standard mode, cipher block chaining (CBC), to encrypt your data one chunk at a time. You’ll XOR each block of data in the pipeline with the previous block that it encrypted. That dependency on previous blocks makes the encryption strong.

But can you see a problem? What about the first block? It has no previous block to help with its encryption.

If you encrypt a message that starts off the same as another message, the first encrypted block would be the same! That provides a clue for an attacker, and you don’t want that. In fact, you’re striving for a concept known as Perfect Secrecy, where the ciphertext conveys zero information about the plaintext.

To remedy the first block problem, you’ll use an initialization vector (IV).

An IV is a fancy term for a block of random data that you XOR with the first block. Remember that each block relies on all blocks processed up until that point. This means that identical sets of data encrypted with the same key will not produce identical outputs.

Create an IV now by adding the following code to the Encryption.kt file in encrypt() like this

class Encryption {
  companion object {
    // ...
    fun encrypt(dataToEncrypt: ByteArray,
                password: CharArray): HashMap<String, ByteArray> {
      val map = HashMap<String, ByteArray>()
      val random = SecureRandom()
      val salt = ByteArray(256) 
      random.nextBytes(salt) 
      val pbKeySpec = PBEKeySpec(password, salt, 1324, 256)
      val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
      val keyBytes = secretKeyFactory.generateSecret(pbKeySpec).encoded
      val keySpec = SecretKeySpec(keyBytes, "AES")

      val ivRandom = SecureRandom() //not caching previous seeded instance of SecureRandom
      val iv = ByteArray(16)
      ivRandom.nextBytes(iv) // 1
      val ivSpec = IvParameterSpec(iv) // 2      
      return map
    }
    // ...
  }
}

Here, you:

  1. Create 16 bytes of random data.
  2. Package it into IvParameterSpec.

This ensures the first block of data is random, strengthening your security.

Finalizing the Encryption

Now that you have all the necessary pieces, you can finally get to the encryption! Add the following code to encrypt() in the Encryption.kt file to perform the customized encryption:

class Encryption {
  companion object {
    // ...
    fun encrypt(dataToEncrypt: ByteArray,
                password: CharArray): HashMap<String, ByteArray> {
      val map = HashMap<String, ByteArray>()
      val random = SecureRandom()
      val salt = ByteArray(256) 
      random.nextBytes(salt) 
      val pbKeySpec = PBEKeySpec(password, salt, 1324, 256)
      val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
      val keyBytes = secretKeyFactory.generateSecret(pbKeySpec).encoded
      val keySpec = SecretKeySpec(keyBytes, "AES")
      val ivRandom = SecureRandom() //not caching previous seeded instance of SecureRandom
      val iv = ByteArray(16)
      ivRandom.nextBytes(iv)
      val ivSpec = IvParameterSpec(iv)

      val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding") // 1
      cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
      val encrypted = cipher.doFinal(dataToEncrypt) // 2      
      return map
    }
    // ...
  }
}

Here:

  1. You passed in the specification string, “AES/CBC/PKCS7Padding”. It chooses AES with cipher block chaining mode. PKCS7Padding is a well-known standard for padding. Since you’re working with blocks, not all data will fit perfectly into the block size, so you need to pad the remaining space. By the way, blocks are 128 bits long and AES adds padding before encryption.
  2. doFinal does the actual encryption.

Next, complete encrypt() in Encryption.kt adding the following code:

class Encryption {
  companion object {
    // ...
    fun encrypt(dataToEncrypt: ByteArray,
                password: CharArray): HashMap<String, ByteArray> {
      val map = HashMap<String, ByteArray>()
      val random = SecureRandom()
      val salt = ByteArray(256) 
      random.nextBytes(salt) 
      val pbKeySpec = PBEKeySpec(password, salt, 1324, 256)
      val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
      val keyBytes = secretKeyFactory.generateSecret(pbKeySpec).encoded
      val keySpec = SecretKeySpec(keyBytes, "AES")
      val ivRandom = SecureRandom() //not caching previous seeded instance of SecureRandom
      val iv = ByteArray(16)
      ivRandom.nextBytes(iv)
      val ivSpec = IvParameterSpec(iv)
      val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
      cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
      val encrypted = cipher.doFinal(dataToEncrypt)


      map["salt"] = salt // HERE
      map["iv"] = iv // HERE
      map["encrypted"] = encrypted // HERE
      return map
    }
    // ...
  }
}

Here, you packaged the encrypted data into a HashMap. You also added the salt and the IV to the map because you need all those pieces to decrypt the data.

This isn’t the only way to go about this. It’s common to prefix the ciphertext with the IV and then strip it off and use it for the decryption. For the purposes of learning, you use a map here so you’ won’t be distracted with sub-arrays and off-by-one counts. :]

If you followed the steps correctly, you shouldn’t have any errors and encrypt is ready to secure some data!

It’s okay to store salts and IVs, but reusing or sequentially incrementing them weakens the security.

You should never store the key!

Now, you’ve built the means of encrypting this data, but you still need to decrypt it. You’ll see how to do that next.

Decrypting with Salts and IVs

You have some encrypted data. To decrypt it, you’ll have to change the mode of Cipher in the init method from ENCRYPT_MODE to DECRYPT_MODE.

Start by adding the following to decrypt in Encryption.kt, where the line reads //TODO: Add custom decrypt code here:

class Encryption {
  companion object {
    // ...
    fun decrypt(map: HashMap<String, ByteArray>, password: CharArray): ByteArray? {
      var decrypted: ByteArray? = null
      try {
        // 1
        val salt = map["salt"]
        val iv = map["iv"]
        val encrypted = map["encrypted"]

        // 2
        //regenerate key from password
        val pbKeySpec = PBEKeySpec(password, salt, 1324, 256)
        val secretKeyFactory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1")
        val keyBytes = secretKeyFactory.generateSecret(pbKeySpec).encoded
        val keySpec = SecretKeySpec(keyBytes, "AES")

        // 3
        //Decrypt
        val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding")
        val ivSpec = IvParameterSpec(iv)
        cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
        decrypted = cipher.doFinal(encrypted)
      } catch (e: Exception) {
        Log.e("MYAPP", "decryption exception", e)
      }
      return decrypted
    }
    // ...
  }
}

In this code, you did the following:

  1. Used the HashMap that contains the encrypted data, salt and IV necessary for decryption.
  2. Regenerated the key given that information plus the user’s password.
  3. Decrypted the data and returned it as a ByteArray.

Notice how you used the same configuration for the decryption, but you’ve traced your steps back. That’s because you’re using a symmetric encryption algorithm. You can now encrypt data as well as decrypt it!

Oh, and did I mention? Never store the key! :]

Updating the Saving Method

Now that the encryption process is complete, you need to test it. The app is already writing data to storage.

In ReportDetailFragment.kt, uncomment the line below //TODO: Test your custom encryption here. Then add the following to testCustomEncryption():

@AndroidEntryPoint
class ReportDetailFragment : Fragment() {
  // ...
  private fun testCustomEncryption(reportString: String) {
    val password = REPORT_SESSION_KEY.toCharArray()
    val bytes = reportString.toByteArray(Charsets.UTF_8)
    val map = Encryption.encrypt(bytes, password) // 1
    val reportID = UUID.randomUUID().toString()
    val outFile = File(activity?.filesDir?.absolutePath, "$reportID.txt")
    ObjectOutputStream(FileOutputStream(outFile)).use { // 2
      it.writeObject(map)
    }

    //TEST decrypt
    val decryptedBytes = Encryption.decrypt(map, password) // 3
    decryptedBytes?.let {
      val decryptedString = String(it, Charsets.UTF_8)
      Log.e("Encryption Test", "The decrypted string is: $decryptedString") // 4
    }
  }
  // ...
}

In the updated code, you:

  1. Fed the data into the encryption method.
  2. Saved the encrypted data.
  3. Called decrypt using the encrypted data, IV and salt.
  4. Tested that it worked.

Build and run now. Then go to the report section and enter a message you want to test, for example: “Very lovely cat is looking for help!” After you send the report, you’ll see the decrypted string in the logs:

Figure 16.5 — Encryption Test
Figure 16.5 — Encryption Test

Congratulations!

Key Points

In this chapter, you learned the following:

  • How to add a simple login with a password or biometrics.
  • How to tie that to protect your data and keys in the KeyStore.
  • That EncryptedFile is a high-level encryption helper that you can use with those keys.
  • You can customize the encryption using Cipher.

It’s great to know how to properly implement security. Armed with this knowledge, you’ll be able to confirm if third-party security libraries are up to the best practices.

On the other hand, implementing it all yourself, especially if you’re in a rush, can lead to mistakes. If you’re in that situation, consider using an industry-approved or time-tested third party.

One drawback to using a third-party solution comes when hackers expose a vulnerability in a popular library. This affects all the apps that rely on that library at the same time. Apps with custom implementations are immune to wide-spread, scripted attacks.

You’ve secured your data at rest. With that knowledge, you’ll secure data in transit in the next chapter.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.