diff --git a/build.gradle b/build.gradle index 777d6295..a79d25d8 100644 --- a/build.gradle +++ b/build.gradle @@ -23,7 +23,7 @@ buildscript { coroutinesVersion = '1.8.1' - biometricVersion = '1.0.1' + biometricVersion = '1.1.0' progressButtonsVersion = '2.1.0' diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/BiometricUnlockKey.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/BiometricUnlockKey.kt new file mode 100644 index 00000000..fd99a487 --- /dev/null +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/BiometricUnlockKey.kt @@ -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 + } +} diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt index 9ccb49d4..3dc600da 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/RealBiometricService.kt @@ -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() 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" } } diff --git a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt index 3650c596..a16c33af 100644 --- a/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt +++ b/feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/di/AccountFeatureModule.kt @@ -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