mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-06 10:51:11 +00:00
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:
1 parent
f52437e021
commit
09ea51e138
4 files changed
+158
-13
No files matched your search
+1
-1
@@ -23,7 +23,7 @@ buildscript {
|
||||
|
||||
coroutinesVersion = '1.8.1'
|
||||
|
||||
biometricVersion = '1.0.1'
|
||||
biometricVersion = '1.1.0'
|
||||
|
||||
progressButtonsVersion = '2.1.0'
|
||||
|
||||
|
||||
+84
@@ -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
|
||||
}
|
||||
}
|
||||
+70
-10
@@ -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"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user