From f52437e021ad7cdf8d3e66d32350ce0bb5599c35 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Tue, 3 Nov 2026 18:08:45 +0400 Subject: [PATCH] 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. --- .../encrypt/EncryptedPreferencesImpl.kt | 43 ++- .../data/storage/encrypt/EncryptionUtil.kt | 253 ++---------------- .../storage/encrypt/KeystoreAesGcmCipher.kt | 55 ++++ .../encrypt/LegacyRsaWrappedAesDecrypter.kt | 48 ++++ .../data/storage/encrypt/ValueCipher.kt | 14 + .../nova/common/di/modules/CommonModule.kt | 7 +- .../encrypt/EncryptedPreferencesImplTest.kt | 135 ++++++++++ 7 files changed, 322 insertions(+), 233 deletions(-) create mode 100644 common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/KeystoreAesGcmCipher.kt create mode 100644 common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/LegacyRsaWrappedAesDecrypter.kt create mode 100644 common/src/main/java/io/novafoundation/nova/common/data/storage/encrypt/ValueCipher.kt create mode 100644 common/src/test/java/io/novafoundation/nova/common/data/storage/encrypt/EncryptedPreferencesImplTest.kt 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" + } +}