From de826acc704467416d3fb5382aa9468705ecb7da Mon Sep 17 00:00:00 2001 From: SatoshiQaziMuhammed Date: Wed, 4 Nov 2026 03:57:40 +0400 Subject: [PATCH] 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 --- build.gradle | 2 +- .../encrypt/EncryptedPreferencesImpl.kt | 43 ++- .../data/storage/encrypt/EncryptionUtil.kt | 253 ++---------------- .../storage/encrypt/KeystoreAesGcmCipher.kt | 55 ++++ .../encrypt/LegacyRsaWrappedAesDecrypter.kt | 48 ++++ .../data/storage/encrypt/ValueCipher.kt | 14 + .../nova/common/di/modules/CommonModule.kt | 7 +- .../encrypt/EncryptedPreferencesImplTest.kt | 135 ++++++++++ .../BiometricUnlockKey.kt | 84 ++++++ .../RealBiometricService.kt | 80 +++++- .../di/AccountFeatureModule.kt | 5 +- .../connection/light/LightClients.kt | 11 +- 12 files changed, 486 insertions(+), 251 deletions(-) create mode 100644 common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/KeystoreAesGcmCipher.kt create mode 100644 common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/LegacyRsaWrappedAesDecrypter.kt create mode 100644 common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/ValueCipher.kt create mode 100644 common/src/test/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImplTest.kt create mode 100644 feature-account-impl/src/main/java/io/novafoundation/nova/feature_account_impl/BiometricUnlockKey.kt 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/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt index e6c70749..f68390e7 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImpl.kt @@ -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" + } } diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt index c17889fd..eb321dfa 100644 --- a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptionUtil.kt @@ -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:" } } diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/KeystoreAesGcmCipher.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/KeystoreAesGcmCipher.kt new file mode 100644 index 00000000..6f52639b --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/KeystoreAesGcmCipher.kt @@ -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 + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/LegacyRsaWrappedAesDecrypter.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/LegacyRsaWrappedAesDecrypter.kt new file mode 100644 index 00000000..d2b820fa --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/LegacyRsaWrappedAesDecrypter.kt @@ -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 + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/ValueCipher.kt b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/ValueCipher.kt new file mode 100644 index 00000000..5bc7681f --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/ValueCipher.kt @@ -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) diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt index 077aed93..bff5a9bc 100644 --- a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt @@ -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 diff --git a/common/src/test/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImplTest.kt b/common/src/test/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImplTest.kt new file mode 100644 index 00000000..eb372b52 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImplTest.kt @@ -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() + + 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() + + 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" + } +} 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 diff --git a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt index b2e45a47..4dcb35eb 100644 --- a/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt +++ b/runtime/src/main/java/io/novafoundation/nova/runtime/multiNetwork/connection/light/LightClients.kt @@ -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/"