ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Biometric Authorization in Compose Multiplatform App

--

When securing your app, traditional methods like passwords, social logins, and OTPs are common choices. However, these can become inconvenient, especially if users need to log in daily. In such cases, biometric login provides a more seamless and secure solution.

In this article, I will guide you through implementing two-factor authentication using a public-private key pair generated via biometric login on both Android and iOS, using Compose Multiplatform.

Prerequisites

  1. A basic understanding of Kotlin Multiplatform (KMP) and Compose Multiplatform (CMP).
  2. Initial setup of a CMP project, or you can clone the following Git repository and skip the setup part: https://github.com/mohitsoni48/kmmbiometric.git

Setup

For Android, we will use the biometric library from AndroidX. Add the following dependency to your androidMain:

androidMain.dependencies {
//...other dependencies
implementation("androidx.biometric:biometric:1.1.0")
}

For iOS, no external dependencies are required. Just add “Privacy — Face ID Usage Description” in your info.plist

Defining Common Classes and Interfaces

Although biometric authentication will be implemented within the shared module of our project, the platform-specific nature of biometric operations requires us to define expect classes for various cryptographic objects. These classes and interfaces will be utilized by our ViewModel and other common classes, ensuring a seamless integration across platforms.

We will define two key interfaces:

  1. BioMetricUtil: This interface will encompass the methods necessary for setting up and managing biometric authentication.
  2. ICipherUtil: This interface will handle the generation, storage, and retrieval of public-private key pairs used in the authentication process.

The ICipherUtil interface will leverage expect classes defined in the commonMain module, with their corresponding actual implementations provided in the platform-specific modules.

interface BioMetricUtil {

suspend fun setAndReturnPublicKey(): String?
suspend fun authenticate(): AuthenticationResult
fun canAuthenticate(): Boolean
suspend fun generatePublicKey(): String?
fun signUserId(ucc: String): String
fun isBiometricSet(): Boolean
fun getPublicKey(): String?
fun isValidCrypto(): Boolean
}

sealed class AuthenticationResult {
data object Success: AuthenticationResult()
data object Failed: AuthenticationResult()
data object AttemptExhausted: AuthenticationResult()
data object NegativeButtonClick: AuthenticationResult()
data class Error(val error: String): AuthenticationResult()
}
interface ICipherUtil {
@Throws(Exception::class)
fun generateKeyPair(): CommonKeyPair

fun getPublicKey(): CommonPublicKey

@Throws(Exception::class)
fun getCrypto(): Crypto

@Throws(Exception::class)
suspend fun removePublicKey()
}

expect class CommonKeyPair
expect interface CommonPublicKey
expect class Crypto

Implementing the App with Biometric Authentication

We’ll defer the implementation of the two interfaces (BioMetricUtil and ICipherUtil) until later. For now, let's focus on creating an app that utilizes biometric authentication. While the primary focus of this article is not the UI or Repository layers, you can refer to the provided Git repositories if you need a starting point.

It’s important to note that the Git repositories contain some architectural decisions that I wouldn’t normally recommend; they’re simplified for the purpose of demonstrating how to use biometric authentication.

For ease of implementation, I’ve integrated the BioMetricUtil directly within the ViewModel. This ViewModel will manage:

  1. Checking if biometric authentication is supported by the device.
  2. Verifying if biometric authentication has already been set up by the user.
  3. Setting up biometric authentication and sending the public key to the backend.
  4. Authenticating the user each time and sending a signed user ID to the backend for verification.
class BiometricAuthorizationViewModel: ViewModel() {
private val setBiometricPublicKeyRepository = SetBiometricPublicKeyRepository()
private val verifyBiometric = VerifyBiometric()

private val _state: MutableStateFlow<BiometricState> =
MutableStateFlow(BiometricState(false, null))
private val _effect: MutableSharedFlow<BiometricEffect> = MutableSharedFlow(replay = 0)

val state: StateFlow<BiometricState>
get() = _state

val effect: SharedFlow<BiometricEffect>
get() = _effect



fun setBiometricAuthorization(bioMetricUtil: BioMetricUtil) {
viewModelScope.launch {
_state.value = BiometricState(isLoading = true, error = null)
if (!bioMetricUtil.canAuthenticate()) {
_state.value = BiometricState(isLoading = true, error = "Biometric not available")
return@launch
}
val publicKey = bioMetricUtil.setAndReturnPublicKey() ?: ""
setBiometricPublicKeyRepository.set(publicKey)
_state.value = BiometricState(isLoading = false, error = null)
_effect.emit(BiometricEffect.BiometricSetSuccess)

}
}

fun authorizeBiometric(bioMetricUtil: BioMetricUtil) {
viewModelScope.launch {
when(val biometricResult = bioMetricUtil.authenticate()) {
AuthenticationResult.AttemptExhausted -> {
_state.value = BiometricState(isLoading = false, error = "Attempt Exhausted")
}
is AuthenticationResult.Error -> {
_state.value = BiometricState(isLoading = false, error = biometricResult.error)
}
AuthenticationResult.Failed -> {
_state.value = BiometricState(isLoading = false, error = "Biometric Failed")
}
AuthenticationResult.NegativeButtonClick -> {
_state.value = BiometricState(isLoading = false, error = "Biometric Canceled")
}
AuthenticationResult.Success -> {
_state.value = BiometricState(isLoading = true, error = null)
val signedUserId = bioMetricUtil.signUserId("userId")
val result = verifyBiometric.verify(signedUserId)
if (result.isSuccess) {
_state.value = BiometricState(isLoading = false, error = null)
_effect.emit(BiometricEffect.BiometricAuthSuccess)
} else {
_state.value = BiometricState(isLoading = false, error = result.exceptionOrNull()!!.message)
}
}
}

}
}
}

data class BiometricState(
val isLoading: Boolean,
val error: String?
)

sealed class BiometricEffect {
data object BiometricSetSuccess: BiometricEffect()
data object BiometricAuthSuccess: BiometricEffect()
}

Android Implementation

Code speaks louder than words. Here is the implementation of BiometricUtil and CipherUtil in Android. You need to put this code in the androidMain(shared) module

class BiometricUtilAndroidImpl(
private val activity: FragmentActivity,
private val cipherUtil: ICipherUtil
) : BioMetricUtil {

private val executor = ContextCompat.getMainExecutor(activity)
private var promptInfo: PromptInfo? = null
private var biometricPrompt: BiometricPrompt? = null

override suspend fun setAndReturnPublicKey(): String? {
val authenticateResult = authenticate()
return when (authenticateResult) {
is AuthenticationResult.Success -> generatePublicKey()
else -> null
}
}

override fun canAuthenticate(): Boolean {
return BiometricManager.from(activity).canAuthenticate(BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
}

override suspend fun generatePublicKey(): String? {
return cipherUtil.generateKeyPair().public?.encoded?.toBase64Encoded()?.toPemFormat()?.toBase64Encoded()
}

override fun getPublicKey(): String? {
return cipherUtil.getPublicKey().encoded?.toBase64Encoded()?.toPemFormat()?.toBase64Encoded()
}

override fun isValidCrypto(): Boolean {
return try {
cipherUtil.getCrypto()
true
} catch (e: Exception){
false
}
}

override suspend fun authenticate(): AuthenticationResult = suspendCoroutine { continuation ->

biometricPrompt = BiometricPrompt(activity, executor, object :
AuthenticationCallback() {
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
}

override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
when (errorCode) {
BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT -> continuation.resume(AuthenticationResult.AttemptExhausted)
BiometricPrompt.ERROR_NEGATIVE_BUTTON -> continuation.resume(AuthenticationResult.NegativeButtonClick)
else -> continuation.resume(AuthenticationResult.Error(errString.toString()))
}
}

override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
continuation.resume(AuthenticationResult.Success)
}
})

promptInfo?.let {
biometricPrompt?.authenticate(it, cipherUtil.getCrypto())
}
}

override fun signUserId(ucc: String): String {
cipherUtil.getCrypto().signature?.update(ucc.toByteArray())
return cipherUtil.getCrypto().signature?.sign()?.toBase64Encoded() ?: ""
}

override fun isBiometricSet(): Boolean {
return !getPublicKey().isNullOrEmpty() && isValidCrypto()
}

fun preparePrompt(
title: String,
subtitle: String,
description: String,
): BioMetricUtil {
promptInfo = PromptInfo.Builder()
.setTitle(title)
.setSubtitle(subtitle)
.setDescription(description)
.setNegativeButtonText("Cancel")
.setAllowedAuthenticators(BIOMETRIC_STRONG)
.build()
return this
}
}

fun ByteArray.toBase64Encoded(): String? {
return Base64.getEncoder().encodeToString(this)
}

fun String.toBase64Encoded(): String? {
return Base64.getEncoder().encodeToString(this.toByteArray())
}

private fun String.toPemFormat(): String {
val stringBuilder = StringBuilder()
stringBuilder.append("-----BEGIN RSA PUBLIC KEY-----").append("\n")
chunked(64).forEach {
stringBuilder.append(it).append("\n")
}
stringBuilder.append("-----END RSA PUBLIC KEY-----")
return stringBuilder.toString()
}
class CipherUtilAndroidImpl: ICipherUtil {
private val KEY_NAME = "biometric_key"

override fun generateKeyPair(): KeyPair {
val keyPairGenerator: KeyPairGenerator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")
val parameterSpec: KeyGenParameterSpec = KeyGenParameterSpec.Builder(KEY_NAME,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY).run {
setDigests(KeyProperties.DIGEST_SHA256)
setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
build()
}
keyPairGenerator.initialize(parameterSpec)
return keyPairGenerator.genKeyPair()
}

override fun getPublicKey(): PublicKey = getKeyPair().public

private fun getKeyPair(): KeyPair {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore?.getCertificate(KEY_NAME).let { return KeyPair(it?.publicKey, null) }
}

override fun getCrypto(): Crypto {
val signature = Signature.getInstance("SHA256withRSA")
val keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
val key: PrivateKey = if(keyStore.containsAlias(KEY_NAME))
keyStore.getKey(KEY_NAME, null) as PrivateKey
else
generateKeyPair().private
signature.initSign(key)
return BiometricPrompt.CryptoObject(signature)
}

override suspend fun removePublicKey() {
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)
keyStore?.deleteEntry(KEY_NAME)
}

}

actual typealias CommonKeyPair = KeyPair

actual typealias CommonPublicKey = PublicKey

actual typealias Crypto = BiometricPrompt.CryptoObject

iOS Implementation

The iOS implementation will be carried out in Swift. To proceed, you’ll need to open Xcode and implement the BioMetricUtil and ICipherUtil interfaces there. This will involve creating the platform-specific code necessary to handle biometric authentication and cryptographic operations on iOS.

import Foundation
import shared
import LocalAuthentication


class BiometricUtilIosImpl: BioMetricUtil {
private let cipherUtil = CipherUtilIosImpl()

private var promptDescription: String = "Authenticate"

func setAndReturnPublicKey(completionHandler: @escaping (String?, (any Error)?) -> Void) {

let laContext = LAContext()
laContext.localizedReason = promptDescription
laContext.localizedFallbackTitle = "Cancel"
laContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: promptDescription) {
[weak self] success, authenticationError in

DispatchQueue.main.async {
if success {
completionHandler(self?.getPublicKey(), nil)
} else {
completionHandler(nil, authenticationError)
}
}
}
}

func authenticate() async throws -> AuthenticationResult {
do {
_ = try self.cipherUtil.getCrypto()
return AuthenticationResult.Success()
} catch {
print("AuthenticateError: \(error.localizedDescription)")
return AuthenticationResult.Error(error: error.localizedDescription)
}
}

func canAuthenticate() -> Bool {
var error: NSError?
let laContext = LAContext()
return laContext.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
}


func generatePublicKey() async throws -> String? {
let keyPair = try cipherUtil.generateKeyPair()
return keyPair.publicKey?.toPemFormat().toBase64()
}

func getPublicKey() -> String? {
return cipherUtil.getPublicKey().encoded.toPemFormat().toBase64()
}

func isBiometricSet() -> Bool {
return UserDefaults.standard.string(forKey: "PublicKey") == nil
}

func isValidCrypto() -> Bool {
do {
_ = try cipherUtil.getCrypto()
return true
} catch {
return false
}
}

func signUserId(ucc: String) -> String {
guard let data = ucc.data(using: .utf8) else {
print("Failed to convert UCC to data")
fatalError()
}

var error: Unmanaged<CFError>?
guard let signature = SecKeyCreateSignature(cipherUtil.getKey()!, .rsaSignatureMessagePKCS1v15SHA256, data as CFData, &error) else {
if let error = error {
print("Error creating signature: \(error.takeRetainedValue())")
}
fatalError()
}

return (signature as Data).base64EncodedString()
}


}

extension String {
func toPemFormat() -> String {
let chunkSize = 64
var pemString = "-----BEGIN RSA PUBLIC KEY-----\n"
var base64String = self
while base64String.count > 0 {
let chunkIndex = base64String.index(base64String.startIndex, offsetBy: min(chunkSize, base64String.count))
let chunk = base64String[..<chunkIndex]
pemString.append(contentsOf: chunk)
pemString.append("\n")
base64String = String(base64String[chunkIndex...])
}
pemString.append("-----END RSA PUBLIC KEY-----")
return pemString
}
}

extension String {
func toBase64() -> String? {
guard let data = self.data(using: .utf8) else {
return nil
}
return data.base64EncodedString()
}
}
import Foundation
import shared

class CipherUtilIosImpl: ICipherUtil {
private let KEY_NAME = "biometric_key"
private let tag: String

private lazy var key: SecKey? = {
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag,
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecReturnRef as String: true
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess else { return nil }
return (item as! SecKey)
}()

init() {
self.tag = KEY_NAME
}

func generateKeyPair() throws -> CommonKeyPair {
let access = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.userPresence,
nil
)!
let attributes: [String: Any] = [
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
kSecAttrKeySizeInBits as String: 2048,
kSecPrivateKeyAttrs as String: [
kSecAttrIsPermanent as String: true,
kSecAttrApplicationTag as String: tag,
kSecAttrAccessControl as String: access
]
]

var error: Unmanaged<CFError>?
guard let privateKey = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
throw error!.takeRetainedValue() as Error
}
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecInternalError), userInfo: nil)
}

let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil)! as Data
let privateKeyData = SecKeyCopyExternalRepresentation(privateKey, nil)! as Data

return CommonKeyPair(publicKey: publicKeyData.base64EncodedString(), privateKey: privateKeyData.base64EncodedString())
}

func getCrypto() throws -> Crypto {
guard let privateKey = getKey() else {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(errSecItemNotFound), userInfo: nil)
}

let publicKey = SecKeyCopyPublicKey(privateKey)!
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil)! as Data
let privateKeyData = SecKeyCopyExternalRepresentation(privateKey, nil)! as Data

UserDefaults.standard.setValue(publicKeyData.base64EncodedString(), forKey: "PublicKey")

return Crypto()
}

func getPublicKey() -> any CommonPublicKey {
let savedPublicKey = UserDefaults.standard.string(forKey: "PublicKey")
if (savedPublicKey != nil) {
return CommonPublicKeyImpl(encoded: savedPublicKey!)
}

guard let privateKey = getKey() else { return CommonPublicKeyImpl(encoded: "") }
guard let publicKey = SecKeyCopyPublicKey(privateKey) else { return CommonPublicKeyImpl(encoded: "") }
let publicKeyData = SecKeyCopyExternalRepresentation(publicKey, nil)! as Data
let publicKeyString = publicKeyData.base64EncodedString()
UserDefaults.standard.setValue(publicKeyString, forKey: "PublicKey")
return CommonPublicKeyImpl(encoded: publicKeyString)
}


func removePublicKey() async throws {
UserDefaults.standard.removeObject(forKey: "PublicKey")
let query: [String: Any] = [
kSecClass as String: kSecClassKey,
kSecAttrApplicationTag as String: tag,
kSecAttrKeyType as String: kSecAttrKeyTypeRSA
]
let status = SecItemDelete(query as CFDictionary)
if status != errSecSuccess && status != errSecItemNotFound {
throw NSError(domain: NSOSStatusErrorDomain, code: Int(status), userInfo: nil)
}
}

func getKey() -> SecKey? {
return key
}

}

Explaination

Android

The CipherUtilAndroidImpl class is responsible for managing cryptographic operations in an Android environment:

  • Generate Key Pair: This method creates and stores an RSA key pair in the Android KeyStore. It initializes a KeyPairGenerator with specifications for signing and verifying, using the AndroidKeyStore to securely store the generated keys.
  • Get Public Key: This method retrieves the public key from the KeyStore. It accesses the stored key pair and returns the public key for use in encryption or verification processes.
  • Get Crypto: This method provides a CryptoObject used by BiometricPrompt for cryptographic operations. It initializes a Signature instance with the private key from the KeyStore for signing purposes. If the key does not exist, it generates a new key pair and initializes the signature with it.
  • Remove Public Key: This method deletes the key pair from the KeyStore, ensuring that sensitive cryptographic material is securely removed when no longer needed.

The class leverages the Android KeyStore system to handle cryptographic operations securely, ensuring that keys are managed and protected according to best practices.

iOS

The CipherUtilIosImpl class performs cryptographic operations on iOS using the Keychain and the Security framework. Here’s a detailed breakdown of its implementation:

Key Pair Generation

  • Attributes Configuration: The class sets up access control for the RSA key pair, ensuring that the key is stored securely in the Keychain. The access control specifies that the key can only be accessed when the device has a passcode set and user presence (biometric authentication) is required.
  • Key Generation: The class generates an RSA key pair with a key size of 2048 bits. The private key is stored permanently in the Keychain with the specified access control attributes. The public key is derived from the private key and is also stored for later use.

Retrieving and Using Keys

  • Get Key: The class configures a query to retrieve the RSA key pair from the Keychain. If the key exists, it returns the SecKey reference.
  • Get Crypto: It initializes cryptographic operations using the private key retrieved from the Keychain. The class also saves the public key to UserDefaults to ensure it is available for future operations.

Key Management

  • Remove Public Key: The class removes the RSA key pair from the Keychain and deletes the associated public key from UserDefaults when it is no longer needed.

The class uses iOS’s Keychain for secure key storage and retrieval, and integrates with the Security framework to handle cryptographic operations effectively.

Conclusion

In this article, we’ve explored the integration of biometric authentication within a Compose Multiplatform app. By leveraging platform-specific implementations for Android and iOS, we’ve demonstrated how to securely manage biometric authentication and cryptographic key operations.

On Android, we used the `BiometricPrompt` API to handle authentication, while the `CipherUtilAndroidImpl` class managed cryptographic key generation and storage through the Android Keystore. On iOS, we utilized the `LocalAuthentication` framework and Keychain services via the `CipherUtilIosImpl` class to achieve similar functionality.

This approach ensures that your application not only benefits from the security of biometric authentication but also maintains a consistent interface across both platforms. Although the article skipped some details like UI and repository setup, the core implementation provides a solid foundation for integrating biometric authentication into your Compose Multiplatform projects.

By following this guide, you can enhance your app’s security while providing users with a seamless and convenient authentication experience. If you have any questions or need further clarification, feel free to reach out or explore the provided code repositories for additional insights.

--

--

Published in ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Written by Mohitsoni

Senior Mobile Developer at Punch. Android | iOS | KMM.

Responses (2)