Keystore AES-GCM for stored secrets; biometric unlock bound to a keystore key (#32)

* Encrypt stored secrets with a keystore AES-GCM key and migrate old values on read

Secrets and the PIN were AES-CBC without authentication, under a random key kept in shared preferences
and wrapped by a keystore RSA key with PKCS#1 v1.5 padding. They are now AES-256-GCM under a key that
never leaves the Android keystore, stored as "v2:" + Base64.

Old values stay readable through a read-only decrypter of the previous scheme; nothing is written in it
any more and no new RSA key is created. A legacy value is re-encrypted when it is read, and replaced
only once the new copy decrypts to the same text. A value that cannot be decrypted now reads as absent
and is left untouched, where decrypt() used to return "" and let a caller treat it as a value.

* 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.

* Log the light client under a fixed tag; LOG_TAG in a coroutine named the coroutine
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-11-04 03:57:40 +04:00
1 parent 35c71062f0
commit de826acc70
12 files changed
+486 -251

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'
@@ -1,19 +1,37 @@
package io.novafoundation.nova.common.data.storage.encrypt
import android.util.Log
import io.novafoundation.nova.common.data.storage.Preferences
class EncryptedPreferencesImpl(
private val preferences: Preferences,
private val encryptionUtil: EncryptionUtil
private val encryptionUtil: EncryptionUtil,
private val logError: (String, Throwable?) -> Unit = { message, error -> Log.e(LOG_TAG, message, error) },
) : EncryptedPreferences {
override fun putEncryptedString(field: String, value: String) {
preferences.putString(field, encryptionUtil.encrypt(value))
}
/**
* A value that cannot be decrypted reads as absent and stays stored as it is. A legacy value is
* re-encrypted in the current scheme, and replaced only once the new copy decrypts to the same text.
*/
override fun getDecryptedString(field: String): String? {
val encryptedString = preferences.getString(field)
return encryptedString?.let { encryptionUtil.decrypt(it) }
val stored = preferences.getString(field) ?: return null
val value = try {
encryptionUtil.decrypt(stored)
} catch (e: UndecryptableValueException) {
logError("Cannot decrypt $field", e)
return null
}
if (!encryptionUtil.isCurrent(stored)) {
reEncrypt(field, value)
}
return value
}
override fun hasKey(field: String): Boolean {
@@ -23,4 +41,23 @@ class EncryptedPreferencesImpl(
override fun removeKey(field: String) {
preferences.removeField(field)
}
private fun reEncrypt(field: String, value: String) {
val reEncrypted = try {
encryptionUtil.encrypt(value).takeIf { encryptionUtil.decrypt(it) == value }
} catch (e: Exception) {
logError("Cannot re-encrypt $field", e)
return
}
if (reEncrypted != null) {
preferences.putString(field, reEncrypted)
} else {
logError("Re-encrypted $field does not decrypt to its value", null)
}
}
private companion object {
const val LOG_TAG = "EncryptedPreferences"
}
}
@@ -1,246 +1,41 @@
package io.novafoundation.nova.common.data.storage.encrypt
import android.content.Context
import android.os.Build
import android.security.KeyPairGeneratorSpec
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import org.bouncycastle.util.Arrays
import org.bouncycastle.util.encoders.Base64
import java.math.BigInteger
import java.security.InvalidAlgorithmParameterException
import java.security.InvalidKeyException
import java.security.Key
import java.security.KeyPairGenerator
import java.security.KeyStore
import java.security.NoSuchAlgorithmException
import java.security.PrivateKey
import java.security.PublicKey
import java.security.SecureRandom
import java.security.spec.AlgorithmParameterSpec
import java.util.Calendar
import javax.crypto.BadPaddingException
import javax.crypto.Cipher
import javax.crypto.IllegalBlockSizeException
import javax.crypto.KeyGenerator
import javax.crypto.NoSuchPaddingException
import javax.crypto.SecretKey
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject
import javax.security.auth.x500.X500Principal
class EncryptionUtil @Inject constructor(
private val context: Context
/**
* Text form of encrypted preference values. Current values are `v2:` and Base64 of [current]'s output;
* anything else is the legacy scheme and is read through [legacy].
*/
class EncryptionUtil(
private val current: ValueCipher,
private val legacy: ValueDecrypter,
) {
companion object {
private const val RSA = "RSA"
private const val AES = "AES"
private const val KEY_STORE_PROVIDER = "AndroidKeyStore"
private const val TRANSFORMATION = "RSA/ECB/PKCS1Padding"
private const val KEY_ALIAS = "key_alias"
private const val BLOCK_SIZE = 16
private const val AES_KEY_LENGTH = 256
private var second = false
private var privateKey: PrivateKey? = null
private var publicKey: PublicKey? = null
private const val SECRET_KEY = "secret_key"
private val secureRandom = SecureRandom()
private var keyStore: KeyStore? = null
fun encrypt(cleartext: String): String {
return CURRENT_PREFIX + Base64.toBase64String(current.encrypt(cleartext.toByteArray()))
}
init {
initKeystore()
}
fun getPrerenceAesKey(): Key {
val secretKey: SecretKey
val encryptedKey = context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).getString(SECRET_KEY, "")
if (encryptedKey!!.isEmpty()) {
val keyGenerator = KeyGenerator.getInstance(AES)
keyGenerator.init(AES_KEY_LENGTH, secureRandom)
secretKey = keyGenerator.generateKey()
context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).edit().putString(SECRET_KEY, encryptRsa(secretKey.encoded)).apply()
} else {
val key = decryptRsa(encryptedKey)
secretKey = SecretKeySpec(key, 0, key!!.size, AES)
}
return secretKey
}
private fun initKeystore() {
try {
keyStore = KeyStore.getInstance(KEY_STORE_PROVIDER)
keyStore!!.load(null)
if (keyStore!!.getKey(KEY_ALIAS, null) == null) {
createKeys()
}
privateKey = keyStore!!.getKey(KEY_ALIAS, null) as PrivateKey
publicKey = keyStore!!.getCertificate(KEY_ALIAS).publicKey
} catch (e: Exception) {
if (!second) {
second = true
initKeystore()
}
e.printStackTrace()
}
}
private fun createKeys() {
val startDate = Calendar.getInstance()
val endDate = Calendar.getInstance()
endDate.add(Calendar.YEAR, 25)
val spec: AlgorithmParameterSpec
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
spec = KeyPairGeneratorSpec.Builder(context)
.setAlias(KEY_ALIAS)
.setSubject(X500Principal("CN=Sora"))
.setSerialNumber(BigInteger.ONE)
.setStartDate(startDate.time)
.setEndDate(endDate.time)
.build()
} else {
spec = KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setCertificateSubject(X500Principal("CN=Sora"))
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1)
.setCertificateSerialNumber(BigInteger.ONE)
.setCertificateNotBefore(startDate.time)
.setCertificateNotAfter(endDate.time)
.build()
}
val keyPairGenerator = KeyPairGenerator.getInstance(RSA, KEY_STORE_PROVIDER)
keyPairGenerator.initialize(spec)
keyPairGenerator.generateKeyPair()
}
fun encrypt(cleartext: String?): String {
if (cleartext != null && cleartext.isNotEmpty()) {
try {
return encrypt(getPrerenceAesKey().encoded, cleartext)
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
}
}
return ""
}
fun encrypt(key: ByteArray, cleartext: String): String {
try {
val result = encrypt(key, cleartext.toByteArray())
return Base64.toBase64String(result)
} catch (e: Exception) {
e.printStackTrace()
}
return ""
}
fun decrypt(encryptedBase64: String): String {
try {
return decrypt(getPrerenceAesKey().encoded, encryptedBase64)
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
/** @throws UndecryptableValueException */
fun decrypt(stored: String): String {
val clear = when {
isCurrent(stored) -> current.decrypt(decodeBase64(stored.removePrefix(CURRENT_PREFIX)))
// The legacy scheme stored an empty string as is
stored.isEmpty() -> ByteArray(0)
else -> legacy.decrypt(decodeBase64(stored))
}
return ""
return String(clear)
}
fun decrypt(key: ByteArray, encryptedBase64: String): String {
try {
val encrypted = Base64.decode(encryptedBase64)
val result = decrypt(key, encrypted)
return String(result)
} catch (e: Exception) {
e.printStackTrace()
}
fun isCurrent(stored: String): Boolean = stored.startsWith(CURRENT_PREFIX)
return ""
private fun decodeBase64(text: String): ByteArray = try {
Base64.decode(text)
} catch (e: Exception) {
throw UndecryptableValueException(e)
}
@Throws(Exception::class)
private fun encrypt(key: ByteArray, clear: ByteArray): ByteArray {
val skeySpec = SecretKeySpec(key, "AES")
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, skeySpec, IvParameterSpec(generateIVBytes()), secureRandom)
return Arrays.concatenate(cipher.iv, cipher.doFinal(clear))
}
@Throws(
NoSuchPaddingException::class,
NoSuchAlgorithmException::class,
InvalidAlgorithmParameterException::class,
InvalidKeyException::class,
BadPaddingException::class,
IllegalBlockSizeException::class
)
private fun decrypt(key: ByteArray, encrypted: ByteArray): ByteArray {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(key, "AES"),
IvParameterSpec(Arrays.copyOfRange(encrypted, 0, BLOCK_SIZE)),
secureRandom
)
return cipher.doFinal(Arrays.copyOfRange(encrypted, BLOCK_SIZE, encrypted.size))
}
private fun encryptRsa(input: ByteArray): String {
val cipher: Cipher
try {
cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
return Base64.toBase64String(cipher.doFinal(input))
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
} catch (e: NoSuchPaddingException) {
e.printStackTrace()
} catch (e: InvalidKeyException) {
e.printStackTrace()
} catch (e: BadPaddingException) {
e.printStackTrace()
} catch (e: IllegalBlockSizeException) {
e.printStackTrace()
}
return ""
}
private fun decryptRsa(encrypted: String): ByteArray? {
val cipher: Cipher
try {
cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, privateKey)
return cipher.doFinal(Base64.decode(encrypted))
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
} catch (e: NoSuchPaddingException) {
e.printStackTrace()
} catch (e: InvalidKeyException) {
e.printStackTrace()
} catch (e: BadPaddingException) {
e.printStackTrace()
} catch (e: IllegalBlockSizeException) {
e.printStackTrace()
}
return null
}
private fun generateIVBytes(): ByteArray {
val ivBytes = ByteArray(BLOCK_SIZE)
secureRandom.nextBytes(ivBytes)
return ivBytes
private companion object {
const val CURRENT_PREFIX = "v2:"
}
}
@@ -0,0 +1,55 @@
package io.novafoundation.nova.common.data.storage.encrypt
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* AES-256-GCM under a key that never leaves the Android keystore. Output is the 12-byte IV the
* keystore chose followed by the ciphertext and its 16-byte tag.
*/
class KeystoreAesGcmCipher(private val alias: String) : ValueCipher {
override fun encrypt(clear: ByteArray): ByteArray {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.ENCRYPT_MODE, key())
return cipher.iv + cipher.doFinal(clear)
}
override fun decrypt(sealed: ByteArray): ByteArray = try {
val cipher = Cipher.getInstance(TRANSFORMATION)
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed, 0, IV_SIZE))
cipher.doFinal(sealed, IV_SIZE, sealed.size - IV_SIZE)
} catch (e: Exception) {
throw UndecryptableValueException(e)
}
@Synchronized
private fun key(): SecretKey {
val keyStore = KeyStore.getInstance(KEY_STORE).apply { load(null) }
(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)
.build()
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE)
.apply { init(spec) }
.generateKey()
}
private companion object {
const val KEY_STORE = "AndroidKeyStore"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val IV_SIZE = 12
const val TAG_BITS = 128
}
}
@@ -0,0 +1,48 @@
package io.novafoundation.nova.common.data.storage.encrypt
import android.content.Context
import java.security.KeyStore
import java.security.PrivateKey
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import org.bouncycastle.util.encoders.Base64
/**
* Reads values written before the keystore AES-GCM key: AES-256-CBC under a random key that is kept in
* shared preferences wrapped by a keystore RSA key (PKCS#1). Read-only, and only so those values can be
* re-encrypted; nothing is written in this scheme anymore and no new RSA key is created.
*/
class LegacyRsaWrappedAesDecrypter(private val context: Context) : ValueDecrypter {
private val aesKey by lazy(::unwrapAesKey)
override fun decrypt(sealed: ByteArray): ByteArray = try {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), IvParameterSpec(sealed, 0, BLOCK_SIZE))
cipher.doFinal(sealed, BLOCK_SIZE, sealed.size - BLOCK_SIZE)
} catch (e: Exception) {
throw UndecryptableValueException(e)
}
private fun unwrapAesKey(): ByteArray {
val wrapped = context.getSharedPreferences(KEY_ALIAS, Context.MODE_PRIVATE).getString(SECRET_KEY, null)
?: error("No wrapped legacy key")
val keyStore = KeyStore.getInstance(KEY_STORE).apply { load(null) }
val privateKey = keyStore.getKey(KEY_ALIAS, null) as PrivateKey? ?: error("No legacy RSA key")
// PKCS#1 v1.5 is what these keys were wrapped with; decrypting is the only use left
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.DECRYPT_MODE, privateKey)
return cipher.doFinal(Base64.decode(wrapped))
}
private companion object {
const val KEY_STORE = "AndroidKeyStore"
const val KEY_ALIAS = "key_alias"
const val SECRET_KEY = "secret_key"
const val BLOCK_SIZE = 16
}
}
@@ -0,0 +1,14 @@
package io.novafoundation.nova.common.data.storage.encrypt
/** Decrypts a stored value, throwing [UndecryptableValueException] when it cannot. */
interface ValueDecrypter {
fun decrypt(sealed: ByteArray): ByteArray
}
interface ValueCipher : ValueDecrypter {
fun encrypt(clear: ByteArray): ByteArray
}
class UndecryptableValueException(cause: Throwable) : Exception("Stored value cannot be decrypted", cause)
@@ -47,6 +47,8 @@ import io.novafoundation.nova.common.data.storage.PreferencesImpl
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferences
import io.novafoundation.nova.common.data.storage.encrypt.EncryptedPreferencesImpl
import io.novafoundation.nova.common.data.storage.encrypt.EncryptionUtil
import io.novafoundation.nova.common.data.storage.encrypt.KeystoreAesGcmCipher
import io.novafoundation.nova.common.data.storage.encrypt.LegacyRsaWrappedAesDecrypter
import io.novafoundation.nova.common.di.scope.ApplicationScope
import io.novafoundation.nova.common.domain.interactor.AssetViewModeInteractor
import io.novafoundation.nova.common.domain.usecase.MaskingModeUseCase
@@ -207,7 +209,10 @@ class CommonModule {
@Provides
@ApplicationScope
fun provideEncryptionUtil(context: Context): EncryptionUtil {
return EncryptionUtil(context)
return EncryptionUtil(
current = KeystoreAesGcmCipher(alias = "data_key_v2"),
legacy = LegacyRsaWrappedAesDecrypter(context),
)
}
@Provides
@@ -0,0 +1,135 @@
package io.novafoundation.nova.common.data.storage.encrypt
import io.novafoundation.nova.common.data.storage.Preferences
import org.bouncycastle.util.encoders.Base64
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
class EncryptedPreferencesImplTest {
private val preferences = MapPreferences()
private val legacy = JvmAesCbc()
private val errors = mutableListOf<String>()
private fun encryptedPreferences(current: ValueCipher = JvmAesGcm()) =
EncryptedPreferencesImpl(preferences, EncryptionUtil(current, legacy), logError = { message, _ -> errors += message })
@Test
fun `a legacy value is read and re-encrypted in the current scheme`() {
preferences.putString(FIELD, legacy.encryptToText("seed words"))
val encryptedPreferences = encryptedPreferences()
assertEquals("seed words", encryptedPreferences.getDecryptedString(FIELD))
assertTrue(preferences.getString(FIELD)!!.startsWith("v2:"))
assertEquals("seed words", encryptedPreferences.getDecryptedString(FIELD))
}
@Test
fun `a legacy empty string stays empty`() {
preferences.putString(FIELD, "")
assertEquals("", encryptedPreferences().getDecryptedString(FIELD))
assertTrue(preferences.getString(FIELD)!!.startsWith("v2:"))
}
@Test
fun `an undecryptable legacy value reads as absent and is left as it is`() {
val corrupt = Base64.toBase64String(ByteArray(48) { 7 })
preferences.putString(FIELD, corrupt)
assertNull(encryptedPreferences().getDecryptedString(FIELD))
assertEquals(corrupt, preferences.getString(FIELD))
assertEquals(1, errors.size)
}
@Test
fun `a tampered current value reads as absent`() {
val encryptedPreferences = encryptedPreferences()
encryptedPreferences.putEncryptedString(FIELD, "1234")
val stored = preferences.getString(FIELD)!!
val bytes = Base64.decode(stored.removePrefix("v2:")).also { it[it.size - 1] = (it[it.size - 1] + 1).toByte() }
preferences.putString(FIELD, "v2:" + Base64.toBase64String(bytes))
assertNull(encryptedPreferences.getDecryptedString(FIELD))
}
@Test
fun `a legacy value is kept when its re-encryption does not decrypt back`() {
val legacyText = legacy.encryptToText("seed words")
preferences.putString(FIELD, legacyText)
assertEquals("seed words", encryptedPreferences(current = GarblingCipher()).getDecryptedString(FIELD))
assertEquals(legacyText, preferences.getString(FIELD))
assertEquals(1, errors.size)
}
@Test
fun `values are written in the current scheme`() {
encryptedPreferences().putEncryptedString(FIELD, "1234")
assertTrue(preferences.getString(FIELD)!!.startsWith("v2:"))
}
private class MapPreferences : Preferences by Mockito.mock(Preferences::class.java) {
private val values = mutableMapOf<String, String?>()
override fun contains(field: String) = field in values
override fun putString(field: String, value: String?) = values.set(field, value)
override fun getString(field: String): String? = values[field]
override fun removeField(field: String) {
values.remove(field)
}
}
private class JvmAesGcm : ValueCipher {
private val key = SecretKeySpec(ByteArray(32) { 1 }, "AES")
override fun encrypt(clear: ByteArray): ByteArray {
val iv = ByteArray(12).also(SecureRandom()::nextBytes)
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) }
return iv + cipher.doFinal(clear)
}
override fun decrypt(sealed: ByteArray): ByteArray = try {
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, sealed, 0, 12)) }
cipher.doFinal(sealed, 12, sealed.size - 12)
} catch (e: Exception) {
throw UndecryptableValueException(e)
}
}
/** The legacy scheme without its RSA wrapping of the key. */
private class JvmAesCbc : ValueDecrypter {
private val key = SecretKeySpec(ByteArray(32) { 2 }, "AES")
fun encryptToText(clear: String): String {
val iv = ByteArray(16).also(SecureRandom()::nextBytes)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { init(Cipher.ENCRYPT_MODE, key, IvParameterSpec(iv)) }
return Base64.toBase64String(iv + cipher.doFinal(clear.toByteArray()))
}
override fun decrypt(sealed: ByteArray): ByteArray = try {
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding").apply { init(Cipher.DECRYPT_MODE, key, IvParameterSpec(sealed, 0, 16)) }
cipher.doFinal(sealed, 16, sealed.size - 16)
} catch (e: Exception) {
throw UndecryptableValueException(e)
}
}
private class GarblingCipher : ValueCipher {
override fun encrypt(clear: ByteArray) = clear
override fun decrypt(sealed: ByteArray) = sealed + 0
}
private companion object {
const val FIELD = "pin_code"
}
}
@@ -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
@@ -2,7 +2,6 @@ package io.novafoundation.nova.runtime.multiNetwork.connection.light
import android.content.Context
import android.util.Log
import io.novafoundation.nova.common.utils.LOG_TAG
import io.novafoundation.nova.lightclient.HostResolver
import io.novafoundation.nova.lightclient.PezkuwiLight
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
@@ -58,7 +57,7 @@ class LightClients(
inUse += chainId
PezkuwiLight.chainUrl(base, chainId)
}.onFailure {
Log.w(LOG_TAG, "Light client unavailable for $chainId", it)
Log.w(TAG, "Light client unavailable for $chainId", it)
}.getOrNull()
}
}
@@ -75,7 +74,7 @@ class LightClients(
// Still registered on failure, so a later url() does not register it twice
runCatching { PezkuwiLight.removeChain(unused) }
.onSuccess { registered -= unused }
.onFailure { Log.w(LOG_TAG, "Cannot stop the light client for $unused", it) }
.onFailure { Log.w(TAG, "Cannot stop the light client for $unused", it) }
.getOrNull() ?: break
}
}
@@ -96,9 +95,9 @@ class LightClients(
val candidate = specApi.spec(SPEC_SOURCE + chain.file)
if (LightSpecs.isUpdate(current = spec(chain), candidate = candidate)) {
writeAtomically(storedSpec(chain), candidate)
Log.d(LOG_TAG, "Light client specification ${chain.file} updated")
Log.d(TAG, "Light client specification ${chain.file} updated")
}
}.onFailure { Log.w(LOG_TAG, "Cannot refresh light client specification ${chain.file}", it) }
}.onFailure { Log.w(TAG, "Cannot refresh light client specification ${chain.file}", it) }
}
}
}
@@ -137,6 +136,8 @@ class LightClients(
const val ZAGROS: ChainId = "1b0b4727bbb5e44ed587d1056b4ad537ffb330034509a58866a088d77e792cd5"
const val ZAGROS_PEOPLE: ChainId = "c72b38f36affaeb2661018b201c2be02e8c09fc699640525d39200a10fd77878"
// Not LOG_TAG: called inside a coroutine, it gives the coroutine class name
const val TAG = "LightClients"
const val DATABASE_DIR = "light-client"
const val SPECS_DIR = "light-client-specs"
const val BUNDLED_DIR = "light-client/"