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/"