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.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-11-03 18:08:45 +04:00
1 parent 35c71062f0
commit f52437e021
7 files changed
+322 -233

No files matched your search

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