Bind biometric unlock to a keystore key

A biometric success was taken from the prompt callback alone. With strong biometrics the prompt now
carries a cipher of a keystore key that requires user authentication and is invalidated when
biometrics are enrolled: the first prompt seals a random token, later ones must open it, and only an
opened token unlocks. If biometrics changed, biometry is turned off and the PIN is asked for. Weak
biometrics (most face unlock) cannot use a keystore key and keep the plain prompt. androidx.biometric
goes to 1.1.0 for the authenticator classes.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-11-03 18:08:45 +04:00
1 parent f52437e021
commit 09ea51e138
4 files changed
+158 -13

No files matched your search

+1 -1
View File
@@ -23,7 +23,7 @@ buildscript {
coroutinesVersion = '1.8.1'
biometricVersion = '1.0.1'
biometricVersion = '1.1.0'
progressButtonsVersion = '2.1.0'
@@ -0,0 +1,84 @@
package io.novafoundation.nova.feature_account_impl
import android.security.keystore.KeyGenParameterSpec
import android.util.Base64
import android.security.keystore.KeyProperties
import io.novafoundation.nova.common.data.storage.Preferences
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* A keystore key usable only after a strong biometric check. Turning biometry on seals a random token with
* it; each unlock must open that token, so a reported success counts only when the key itself was usable.
*/
class BiometricUnlockKey(private val preferences: Preferences) {
/**
* The cipher for the next prompt: it opens the stored token, or seals a new one when there is none.
*
* @throws android.security.keystore.KeyPermanentlyInvalidatedException after biometrics were enrolled or removed
*/
fun cipher(): Cipher {
val cipher = Cipher.getInstance(TRANSFORMATION)
val sealed = storedToken()
if (sealed == null) {
cipher.init(Cipher.ENCRYPT_MODE, key())
} else {
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed, 0, IV_SIZE))
}
return cipher
}
/** Runs [cipher] after the prompt succeeded; false when the key refused it. */
fun complete(cipher: Cipher): Boolean = runCatching {
val sealed = storedToken()
if (sealed == null) {
val token = ByteArray(TOKEN_SIZE).also(SecureRandom()::nextBytes)
preferences.putString(TOKEN_FIELD, Base64.encodeToString(cipher.iv + cipher.doFinal(token), Base64.NO_WRAP))
} else {
cipher.doFinal(sealed, IV_SIZE, sealed.size - IV_SIZE)
}
}.isSuccess
fun clear() {
preferences.removeField(TOKEN_FIELD)
keyStore().deleteEntry(ALIAS)
}
private fun storedToken(): ByteArray? = preferences.getString(TOKEN_FIELD)?.let { Base64.decode(it, Base64.NO_WRAP) }
private fun key(): SecretKey {
(keyStore().getKey(ALIAS, null) as SecretKey?)?.let { return it }
val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE)
.apply { init(spec) }
.generateKey()
}
private fun keyStore() = KeyStore.getInstance(KEY_STORE).apply { load(null) }
private companion object {
const val KEY_STORE = "AndroidKeyStore"
const val ALIAS = "biometric_unlock_v1"
const val TOKEN_FIELD = "biometric_unlock_token"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_SIZE = 12
const val TAG_BITS = 128
const val TOKEN_SIZE = 32
}
}
@@ -1,7 +1,10 @@
package io.novafoundation.nova.feature_account_impl
import androidx.biometric.BiometricConstants
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.util.Log
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricPrompt
import io.novafoundation.nova.common.sequrity.biometry.BiometricResponse
import io.novafoundation.nova.common.sequrity.biometry.BiometricService
@@ -10,9 +13,11 @@ import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepos
import io.novafoundation.nova.common.sequrity.biometry.BiometricPromptFactory
import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory
import kotlinx.coroutines.flow.Flow
import javax.crypto.Cipher
class RealBiometricServiceFactory(
private val accountRepository: AccountRepository
private val accountRepository: AccountRepository,
private val unlockKey: BiometricUnlockKey,
) : BiometricServiceFactory {
override fun create(
biometricManager: BiometricManager,
@@ -23,7 +28,8 @@ class RealBiometricServiceFactory(
accountRepository,
biometricManager,
biometricPromptFactory,
promptInfo
promptInfo,
unlockKey
)
}
}
@@ -32,13 +38,17 @@ class RealBiometricService(
private val accountRepository: AccountRepository,
private val biometricManager: BiometricManager,
private val biometricPromptFactory: BiometricPromptFactory,
private val promptInfo: BiometricPrompt.PromptInfo
private val promptInfo: BiometricPrompt.PromptInfo,
private val unlockKey: BiometricUnlockKey,
) : BiometricPrompt.AuthenticationCallback(), BiometricService {
override val biometryServiceResponseFlow = singleReplaySharedFlow<BiometricResponse>()
private val biometricPrompt = biometricPromptFactory.create(this)
// Whether the running prompt was bound to [unlockKey], so its success must come with a usable cipher
private var promptUsesKey = false
override fun isEnabled(): Boolean {
return accountRepository.isBiometricEnabled()
}
@@ -63,12 +73,14 @@ class RealBiometricService(
accountRepository.setBiometricOn()
} else {
accountRepository.setBiometricOff()
clearUnlockKey()
}
}
override fun refreshBiometryState() {
if (!isBiometricReady() && isEnabled()) {
accountRepository.setBiometricOff()
clearUnlockKey()
}
}
@@ -78,17 +90,37 @@ class RealBiometricService(
return
}
biometricPrompt.authenticate(promptInfo)
// Weak biometrics (most face unlock) cannot use a keystore key; they keep the plain prompt
if (biometricManager.canAuthenticate(BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
promptUsesKey = false
biometricPrompt.authenticate(promptInfo)
return
}
val cipher = try {
unlockCipher()
} catch (e: Exception) {
Log.w(LOG_TAG, "Biometric unlock key unusable", e)
null
}
if (cipher == null) {
biometryServiceResponseFlow.tryEmit(BiometricResponse.NotReady)
return
}
promptUsesKey = true
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
}
override fun isBiometricReady(): Boolean {
return biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
return biometricManager.canAuthenticate(BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
val cancelledByUser = errorCode == BiometricConstants.ERROR_CANCELED ||
errorCode == BiometricConstants.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricConstants.ERROR_USER_CANCELED
val cancelledByUser = errorCode == BiometricPrompt.ERROR_CANCELED ||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
errorCode == BiometricPrompt.ERROR_USER_CANCELED
biometryServiceResponseFlow.tryEmit(BiometricResponse.Error(cancelledByUser, errString.toString()))
}
@@ -98,6 +130,34 @@ class RealBiometricService(
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
biometryServiceResponseFlow.tryEmit(BiometricResponse.Success)
val cipher = result.cryptoObject?.cipher
val unlocked = when {
cipher != null -> unlockKey.complete(cipher)
else -> !promptUsesKey
}
biometryServiceResponseFlow.tryEmit(if (unlocked) BiometricResponse.Success else BiometricResponse.Fail)
}
/** Null when biometry was on but biometrics changed since: it has to be turned on again behind the PIN. */
private fun unlockCipher(): Cipher? = try {
unlockKey.cipher()
} catch (e: KeyPermanentlyInvalidatedException) {
clearUnlockKey()
if (isEnabled()) {
accountRepository.setBiometricOff()
null
} else {
unlockKey.cipher()
}
}
private fun clearUnlockKey() {
runCatching { unlockKey.clear() }.onFailure { Log.w(LOG_TAG, "Cannot clear the biometric unlock key", it) }
}
private companion object {
const val LOG_TAG = "BiometricService"
}
}
@@ -78,6 +78,7 @@ import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSing
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
import io.novafoundation.nova.feature_account_impl.BiometricUnlockKey
import io.novafoundation.nova.feature_account_impl.RealBiometricServiceFactory
import io.novafoundation.nova.feature_account_impl.data.cloudBackup.CloudBackupAccountsModificationsTracker
import io.novafoundation.nova.feature_account_impl.data.ethereum.transaction.RealEvmTransactionService
@@ -734,8 +735,8 @@ class AccountFeatureModule {
@Provides
@FeatureScope
fun provideBiometricServiceFactory(accountRepository: AccountRepository): BiometricServiceFactory {
return RealBiometricServiceFactory(accountRepository)
fun provideBiometricServiceFactory(accountRepository: AccountRepository, preferences: Preferences): BiometricServiceFactory {
return RealBiometricServiceFactory(accountRepository, BiometricUnlockKey(preferences))
}
@Provides