Adding biometric authentication to a mobile application

Biometric authentication can increase both the security and user experience of mobile applications. However, the convenience it provides comes with the responsibility of ensuring that biometric data is handled securely. This guide details the implementation of biometrics as a supplemental authentication method for both iOS and Android.

The following approach and code samples support the following customer/mobile application workflow:

  1. The customer clicks a login button in an application and is presented with a Strivacity login screen
  2. The customer authenticates
  3. The Strivacity verifies the provided credentials
  4. The Strivacity generates an access token and refresh token for the user session if the credentials are valid
  5. The application prompts customers to set up biometric authentication (e.g., fingerprint or face recognition) for future logins
  6. The application stores the access/refresh token in a secure manner once the user sets up biometric authentication
  7. The customer is successfully logged in and gains access to the application’s features and functionalities
  8. In subsequent app launches, the application checks if the customer has biometric authentication enabled
  9. If biometric authentication is enabled, the application uses the biometric data to get to the access token which is used to authenticate the customer, or uses the refresh token to request a new access token from Strivacity.

iOS implementation guide

In iOS, secure storage of sensitive data using biometrics involves storing the refresh/access token in the Keychain, protected by biometric authentication.

  1. Create a biometry-protected Keychain Item:
    Use SecAccessControlCreateWithFlags to create a SecAccessControl object that ties the keychain entry to the current biometric set (Touch ID or Face ID) and ensures it can only be accessed when the device is unlocked.
  2. Store data in the Keychain:
    Create and store a keychain entry using the SecAccessControl object to enforce biometric protection.
  3. Read a biometry-protected Keychain entry:
    Access the keychain item by passing an LAContext and an optional prompt to handle biometric authentication.
  4. Handle biometric changes:
    Use the .biometryCurrentSet flag in SecAccessControl to invalidate the keychain entry if the biometric data is altered, ensuring the entry remains secure.
  5. Recreate the Keychain entry after biometric change:
    If biometric data changes, prompt the user to reauthenticate and recreate the keychain entry to restore access.

Step-by-Step Implementation

Create a biometry-protected Keychain item

Use SecAccessControlCreateWithFlags to create a SecAccessControl object that ensures the keychain item can only be accessed when the device is unlocked and is protected by the current biometric set.

This example uses the LAContext instance to authenticate the user. You then call the authContext.evaluateAccessControl method which will prompt the user for Touch ID or Face ID authentication. If authentication is successful, the authContext instance is used to read the keychain entry contents.

The authContext instance is put into the query dictionary for the kSecUseAuthenticationContext key. This ensures that any previous authentication is considered by a subsequent SecItemCopyMatching call.

static func getBioSecAccessControl() -> SecAccessControl {
       var access: SecAccessControl?
       var error: Unmanaged<CFError>?
           access = SecAccessControlCreateWithFlags(nil,
               kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
               .biometryCurrentSet,
               &error)
       precondition(access != nil, "SecAccessControlCreateWithFlags failed")
       return access!
   }

static func createBioProtectedEntry(key: String, data: Data) -> OSStatus {
       let query = [
           kSecClass as String: kSecClassGenericPassword as String,
           kSecAttrAccount as String: key,
           kSecAttrAccessControl as String: getBioSecAccessControl(),
           kSecValueData as String: data ] as CFDictionary
       return SecItemAdd(query as CFDictionary, nil)
   }

Read a biometry-protected Entry

To read the data, pass LAContextand an optional prompt to handle biometric authentication:

static func loadBioProtected(key: String, context: LAContext? = nil,
                                prompt: String? = nil) -> Data? {
var query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: key,
            kSecReturnData as String: kCFBooleanTrue,
            kSecAttrAccessControl as String: getBioSecAccessControl(),
            kSecMatchLimit as String: kSecMatchLimitOne ]
    if let context = context {
        query[kSecUseAuthenticationContext as String] = context
        query[kSecUseAuthenticationUI as String] = kSecUseAuthenticationUISkip
    }
    if let prompt = prompt {
        query[kSecUseOperationPrompt as String] = prompt
    }
    var dataTypeRef: AnyObject? = nil
    let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef)
    if status == noErr {
        return (dataTypeRef! as! Data)
    } else {
        return nil
    }
}
static func redBioProtectedEntry(entryName: String) {
    let authContext = LAContext()
    let accessControl = SecAccessControlCreateWithFlags(nil,
                kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
                .biometryCurrentSet,
                &error)
    authContext.evaluateAccessControl(accessControl, operation: .useItem, localizedReason: "Access sample keychain entry") {
        (success, error) in
        var result = ""
        if success, let data = loadBioProtected(key: entryName, context: authContext) {
            let result = String(decoding: data, as: UTF8.self)
        } else {
            result = "Can't read entry, error: \(error?.localizedDescription ?? "-")"
        }
    }
}

Handle biometric changes

If a new fingerprint is added or removed, the SecAccessControl should be set with biometryCurrentSet to automatically invalidate the keychain item, preventing access with the new biometric data.

The flags userPresence, and biometryAny will keep the entry in the keychain, and the new biometric data remains considered valid and can therefore be accessed from the Keychain.

Android implementation guide

In Android, to securely store a backend token encrypted with biometric data, follow these steps:

  1. Request SecretKey from Android KeyStore: The app requests a SecretKey from the Android KeyStore.
  2. Generate SecretKey: The KeyStore generates the SecretKey in a secure location (TEE).
  3. Retrieve SecretKey Alias: The KeyStore returns an alias for accessing the SecretKey.
  4. Create Cipher for Encryption/Decryption: A Cipher object is created to perform encryption/decryption within the KeyStore system.
  5. Encrypt Data: The KeyStore encrypts the plaintext using the alias and returns the ciphertext.
  6. Decrypt Data: When needed, the KeyStore decrypts the ciphertext using the alias and returns the plaintext.
    Enable Biometric Authentication: Biometric authentication is enabled to secure the SecretKey using authentication binding.
  7. Use CryptoObject: A CryptoObject wraps the Cipher for secure data handling.
    Encrypt Backend Token: The backend token is encrypted using the Cipher wrapped in the CryptoObject, which is passed to onAuthenticationSucceeded.
  8. Decrypt Token After Authentication: The token is decrypted after successful authentication using the Cipher wrapped in the CryptoObject.

With this setup, even if a device is compromised, the data remains encrypted unless the attacker can authenticate with the user’s biometrics.

Implementation

Create/get SecretKey from Android KeyStore

Define a function to create or retrieve a SecretKey from the Android Keystore:

// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256

private fun getOrCreateSecretKey(keyName: String): SecretKey {
        // return Secretkey if it was previously created for that keyName.
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keyStore.load(null) // Keystore must be loaded before it can be accessed
        keyStore.getKey(keyName, null)?.let { return it as SecretKey }
        // Create new SecretKey for the provided keyName
        val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        paramsBuilder.apply {
            setBlockModes(ENCRYPTION_BLOCK_MODE)
            setEncryptionPaddings(ENCRYPTION_PADDING)
            setKeySize(KEY_SIZE)
        }
        val keyGenParams = paramsBuilder.build()
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        return keyGenerator.generateKey()
    }

Create cipher and wrap in CryptoObject for authentication:

Set up the Cipher object for encryption or decryption and wrap it in a CryptoObject when calling the biometric authentication method:

// DECLARE CONSTS
private val ENCRYPTION_ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
private lateinit var promptInfo: BiometricPrompt.PromptInfo

private fun authenticateToEncrypt() {       
    if (BiometricManager.from(applicationContext).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) {
            val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
            val cipher = Cipher.getInstance(transformation)
            val secretKey = getOrCreateSecretKey(KEY_NAME)
            cipher.init(Cipher.ENCRYPT_MODE, secretKey)
            val biometricPrompt = createEncryptBiometricPrompt()
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }

private fun authenticateToDecrypt() {
        if (BiometricManager.from(applicationContext).canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS) {
            val transformation = "$ENCRYPTION_ALGORITHM/$ENCRYPTION_BLOCK_MODE/$ENCRYPTION_PADDING"
            val cipher = Cipher.getInstance(transformation)
            val secretKey = getOrCreateSecretKey(KEY_NAME)
            cipher.init(Cipher.DECRYPT_MODE, secretKey, GCMParameterSpec(128, initializationVector))
            biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
        }
    }

Create biometric prompts for encryption/decryption

Implement separate BiometricPrompt objects for encrypting and decrypting data:

private fun createEncryptBiometricPrompt(): BiometricPrompt {
        val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                // Handle authentication errors
            }

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                encryptData(result.cryptoObject)
            }
            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // Handle authentication failure
            }
        }
        return BiometricPrompt(this, executor, authenticationCallback)
    }
private fun createDecryptBiometricPrompt(): BiometricPrompt {
        val authenticationCallback = object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
                super.onAuthenticationError(errorCode, errString)
                // Handle authentication errors
            }
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                super.onAuthenticationSucceeded(result)
                decryptData(result.cryptoObject)
            }
            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                // Handle authentication failure
            }
        }
        return BiometricPrompt(this, executor, authenticationCallback)

Encrypt and decrypt sensitive data

Define functions to encrypt and decrypt data:

private fun encryptData(cipher: Cipher): EncryptedData {
        val ciphertext = cipher.doFinal(backendToken.toByteArray(Charset.forName("UTF-8")))
        return EncryptedData(ciphertext,cipher.iv)
    }

fun decryptData(ciphertext: ByteArray, cipher: Cipher): String {
        val plaintext = cipher.doFinal(ciphertext)
        return String(plaintext, Charset.forName("UTF-8"))
    }

Handling Biometric Changes

If biometric data is changed (e.g., adding or deleting a fingerprint), invalidate the access to the SecretKey by setting setUserAuthenticationRequired(true) during key generation. This ensures that the key is invalidated if the biometric data changes, preventing unauthorized access.

// DECLARE CONSTS
val ANDROID_KEYSTORE = "AndroidKeyStore"
private val ENCRYPTION_BLOCK_MODE = KeyProperties.BLOCK_MODE_GCM
private val ENCRYPTION_PADDING = KeyProperties.ENCRYPTION_PADDING_NONE
private val KEY_SIZE: Int = 256

private fun getOrCreateSecretKey(keyName: String): SecretKey {
        // return Secretkey if it was previously created for that keyName.
        val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
        keyStore.load(null) // Keystore must be loaded before it can be accessed
        keyStore.getKey(keyName, null)?.let { return it as SecretKey }
        // Create new SecretKey for the provided keyName
        val paramsBuilder = KeyGenParameterSpec.Builder(keyName,
            KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
        paramsBuilder.apply {
            setBlockModes(ENCRYPTION_BLOCK_MODE)
            setEncryptionPaddings(ENCRYPTION_PADDING)
            setKeySize(KEY_SIZE)
            setUserAuthenticationRequired(true) // WE ADD OUR CALL HERE
        }
        val keyGenParams = paramsBuilder.build()
        val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES,
            ANDROID_KEYSTORE)
        keyGenerator.init(keyGenParams)
        return keyGenerator.generateKey()
    }
}

This implementation ensures secure handling of sensitive data through biometric authentication, while also addressing the invalidation of keys if biometric data is altered.