mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-06 02:41:12 +00:00
Merge b69f865b05 into 35c71062f0
This commit is contained in:
12 files changed
+486
-251
No files matched your search
+1
-1
@@ -23,7 +23,7 @@ buildscript {
|
||||
|
||||
coroutinesVersion = '1.8.1'
|
||||
|
||||
biometricVersion = '1.0.1'
|
||||
biometricVersion = '1.1.0'
|
||||
|
||||
progressButtonsVersion = '2.1.0'
|
||||
|
||||
|
||||
+40
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
+24
-229
@@ -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:"
|
||||
}
|
||||
}
|
||||
+55
@@ -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
|
||||
}
|
||||
}
|
||||
+48
@@ -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
|
||||
}
|
||||
}
|
||||
+14
@@ -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
|
||||
|
||||
+135
@@ -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"
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
package io.novafoundation.nova.feature_account_impl
|
||||
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.util.Base64
|
||||
import android.security.keystore.KeyProperties
|
||||
import io.novafoundation.nova.common.data.storage.Preferences
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
/**
|
||||
* A keystore key usable only after a strong biometric check. Turning biometry on seals a random token with
|
||||
* it; each unlock must open that token, so a reported success counts only when the key itself was usable.
|
||||
*/
|
||||
class BiometricUnlockKey(private val preferences: Preferences) {
|
||||
|
||||
/**
|
||||
* The cipher for the next prompt: it opens the stored token, or seals a new one when there is none.
|
||||
*
|
||||
* @throws android.security.keystore.KeyPermanentlyInvalidatedException after biometrics were enrolled or removed
|
||||
*/
|
||||
fun cipher(): Cipher {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
val sealed = storedToken()
|
||||
|
||||
if (sealed == null) {
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key())
|
||||
} else {
|
||||
cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(TAG_BITS, sealed, 0, IV_SIZE))
|
||||
}
|
||||
|
||||
return cipher
|
||||
}
|
||||
|
||||
/** Runs [cipher] after the prompt succeeded; false when the key refused it. */
|
||||
fun complete(cipher: Cipher): Boolean = runCatching {
|
||||
val sealed = storedToken()
|
||||
|
||||
if (sealed == null) {
|
||||
val token = ByteArray(TOKEN_SIZE).also(SecureRandom()::nextBytes)
|
||||
preferences.putString(TOKEN_FIELD, Base64.encodeToString(cipher.iv + cipher.doFinal(token), Base64.NO_WRAP))
|
||||
} else {
|
||||
cipher.doFinal(sealed, IV_SIZE, sealed.size - IV_SIZE)
|
||||
}
|
||||
}.isSuccess
|
||||
|
||||
fun clear() {
|
||||
preferences.removeField(TOKEN_FIELD)
|
||||
keyStore().deleteEntry(ALIAS)
|
||||
}
|
||||
|
||||
private fun storedToken(): ByteArray? = preferences.getString(TOKEN_FIELD)?.let { Base64.decode(it, Base64.NO_WRAP) }
|
||||
|
||||
private fun key(): SecretKey {
|
||||
(keyStore().getKey(ALIAS, null) as SecretKey?)?.let { return it }
|
||||
|
||||
val spec = KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setKeySize(256)
|
||||
.setUserAuthenticationRequired(true)
|
||||
.setInvalidatedByBiometricEnrollment(true)
|
||||
.build()
|
||||
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE)
|
||||
.apply { init(spec) }
|
||||
.generateKey()
|
||||
}
|
||||
|
||||
private fun keyStore() = KeyStore.getInstance(KEY_STORE).apply { load(null) }
|
||||
|
||||
private companion object {
|
||||
const val KEY_STORE = "AndroidKeyStore"
|
||||
const val ALIAS = "biometric_unlock_v1"
|
||||
const val TOKEN_FIELD = "biometric_unlock_token"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val IV_SIZE = 12
|
||||
const val TAG_BITS = 128
|
||||
const val TOKEN_SIZE = 32
|
||||
}
|
||||
}
|
||||
+70
-10
@@ -1,7 +1,10 @@
|
||||
package io.novafoundation.nova.feature_account_impl
|
||||
|
||||
import androidx.biometric.BiometricConstants
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.util.Log
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import io.novafoundation.nova.common.sequrity.biometry.BiometricResponse
|
||||
import io.novafoundation.nova.common.sequrity.biometry.BiometricService
|
||||
@@ -10,9 +13,11 @@ import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepos
|
||||
import io.novafoundation.nova.common.sequrity.biometry.BiometricPromptFactory
|
||||
import io.novafoundation.nova.common.sequrity.biometry.BiometricServiceFactory
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import javax.crypto.Cipher
|
||||
|
||||
class RealBiometricServiceFactory(
|
||||
private val accountRepository: AccountRepository
|
||||
private val accountRepository: AccountRepository,
|
||||
private val unlockKey: BiometricUnlockKey,
|
||||
) : BiometricServiceFactory {
|
||||
override fun create(
|
||||
biometricManager: BiometricManager,
|
||||
@@ -23,7 +28,8 @@ class RealBiometricServiceFactory(
|
||||
accountRepository,
|
||||
biometricManager,
|
||||
biometricPromptFactory,
|
||||
promptInfo
|
||||
promptInfo,
|
||||
unlockKey
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,13 +38,17 @@ class RealBiometricService(
|
||||
private val accountRepository: AccountRepository,
|
||||
private val biometricManager: BiometricManager,
|
||||
private val biometricPromptFactory: BiometricPromptFactory,
|
||||
private val promptInfo: BiometricPrompt.PromptInfo
|
||||
private val promptInfo: BiometricPrompt.PromptInfo,
|
||||
private val unlockKey: BiometricUnlockKey,
|
||||
) : BiometricPrompt.AuthenticationCallback(), BiometricService {
|
||||
|
||||
override val biometryServiceResponseFlow = singleReplaySharedFlow<BiometricResponse>()
|
||||
|
||||
private val biometricPrompt = biometricPromptFactory.create(this)
|
||||
|
||||
// Whether the running prompt was bound to [unlockKey], so its success must come with a usable cipher
|
||||
private var promptUsesKey = false
|
||||
|
||||
override fun isEnabled(): Boolean {
|
||||
return accountRepository.isBiometricEnabled()
|
||||
}
|
||||
@@ -63,12 +73,14 @@ class RealBiometricService(
|
||||
accountRepository.setBiometricOn()
|
||||
} else {
|
||||
accountRepository.setBiometricOff()
|
||||
clearUnlockKey()
|
||||
}
|
||||
}
|
||||
|
||||
override fun refreshBiometryState() {
|
||||
if (!isBiometricReady() && isEnabled()) {
|
||||
accountRepository.setBiometricOff()
|
||||
clearUnlockKey()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,17 +90,37 @@ class RealBiometricService(
|
||||
return
|
||||
}
|
||||
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
// Weak biometrics (most face unlock) cannot use a keystore key; they keep the plain prompt
|
||||
if (biometricManager.canAuthenticate(BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
promptUsesKey = false
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
return
|
||||
}
|
||||
|
||||
val cipher = try {
|
||||
unlockCipher()
|
||||
} catch (e: Exception) {
|
||||
Log.w(LOG_TAG, "Biometric unlock key unusable", e)
|
||||
null
|
||||
}
|
||||
|
||||
if (cipher == null) {
|
||||
biometryServiceResponseFlow.tryEmit(BiometricResponse.NotReady)
|
||||
return
|
||||
}
|
||||
|
||||
promptUsesKey = true
|
||||
biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher))
|
||||
}
|
||||
|
||||
override fun isBiometricReady(): Boolean {
|
||||
return biometricManager.canAuthenticate() == BiometricManager.BIOMETRIC_SUCCESS
|
||||
return biometricManager.canAuthenticate(BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
}
|
||||
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
val cancelledByUser = errorCode == BiometricConstants.ERROR_CANCELED ||
|
||||
errorCode == BiometricConstants.ERROR_NEGATIVE_BUTTON ||
|
||||
errorCode == BiometricConstants.ERROR_USER_CANCELED
|
||||
val cancelledByUser = errorCode == BiometricPrompt.ERROR_CANCELED ||
|
||||
errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON ||
|
||||
errorCode == BiometricPrompt.ERROR_USER_CANCELED
|
||||
|
||||
biometryServiceResponseFlow.tryEmit(BiometricResponse.Error(cancelledByUser, errString.toString()))
|
||||
}
|
||||
@@ -98,6 +130,34 @@ class RealBiometricService(
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
biometryServiceResponseFlow.tryEmit(BiometricResponse.Success)
|
||||
val cipher = result.cryptoObject?.cipher
|
||||
val unlocked = when {
|
||||
cipher != null -> unlockKey.complete(cipher)
|
||||
else -> !promptUsesKey
|
||||
}
|
||||
|
||||
biometryServiceResponseFlow.tryEmit(if (unlocked) BiometricResponse.Success else BiometricResponse.Fail)
|
||||
}
|
||||
|
||||
/** Null when biometry was on but biometrics changed since: it has to be turned on again behind the PIN. */
|
||||
private fun unlockCipher(): Cipher? = try {
|
||||
unlockKey.cipher()
|
||||
} catch (e: KeyPermanentlyInvalidatedException) {
|
||||
clearUnlockKey()
|
||||
|
||||
if (isEnabled()) {
|
||||
accountRepository.setBiometricOff()
|
||||
null
|
||||
} else {
|
||||
unlockKey.cipher()
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearUnlockKey() {
|
||||
runCatching { unlockKey.clear() }.onFailure { Log.w(LOG_TAG, "Cannot clear the biometric unlock key", it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOG_TAG = "BiometricService"
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -78,6 +78,7 @@ import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectSing
|
||||
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletCommunicator
|
||||
import io.novafoundation.nova.feature_account_api.presenatation.mixin.selectWallet.SelectWalletMixin
|
||||
import io.novafoundation.nova.feature_account_api.presenatation.navigation.ExtrinsicNavigationWrapper
|
||||
import io.novafoundation.nova.feature_account_impl.BiometricUnlockKey
|
||||
import io.novafoundation.nova.feature_account_impl.RealBiometricServiceFactory
|
||||
import io.novafoundation.nova.feature_account_impl.data.cloudBackup.CloudBackupAccountsModificationsTracker
|
||||
import io.novafoundation.nova.feature_account_impl.data.ethereum.transaction.RealEvmTransactionService
|
||||
@@ -734,8 +735,8 @@ class AccountFeatureModule {
|
||||
|
||||
@Provides
|
||||
@FeatureScope
|
||||
fun provideBiometricServiceFactory(accountRepository: AccountRepository): BiometricServiceFactory {
|
||||
return RealBiometricServiceFactory(accountRepository)
|
||||
fun provideBiometricServiceFactory(accountRepository: AccountRepository, preferences: Preferences): BiometricServiceFactory {
|
||||
return RealBiometricServiceFactory(accountRepository, BiometricUnlockKey(preferences))
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
+6
-5
@@ -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/"
|
||||
|
||||
Reference in new issue
Block a user