From bd8d0a8354c309a9f5e3de1194c332b5ce7fe1b8 Mon Sep 17 00:00:00 2001 From: Marten Rebane Date: Fri, 15 May 2026 13:25:24 +0300 Subject: [PATCH] Replace deprecated EncryptedPreferences --- app/build.gradle.kts | 2 + .../domain/preferences/DataStoreTest.kt | 8 +- .../kotlin/ee/ria/DigiDoc/RIADigiDocApp.kt | 8 +- .../DigiDoc/domain/preferences/DataStore.kt | 79 +++++----- .../init/EncryptedPreferencesMigration.kt | 75 ++++++++++ commons-lib/build.gradle.kts | 1 - .../preferences/EncryptedPreferencesTest.kt | 84 ++++++++++- .../preferences/EncryptedPreferences.kt | 140 ++++++++++++++++-- .../libdigidoclib/init/Initialization.kt | 5 +- .../ee/ria/DigiDoc/network/utils/ProxyUtil.kt | 4 +- 10 files changed, 333 insertions(+), 73 deletions(-) create mode 100644 app/src/main/kotlin/ee/ria/DigiDoc/init/EncryptedPreferencesMigration.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9038cdea..b53f96138 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -182,6 +182,8 @@ dependencies { kapt(libs.google.dagger.hilt.android.compile) implementation(libs.androidx.hilt) implementation(libs.kotlinx.coroutines.rx3) + // TODO: Remove later. Needed only for one-time migration of old EncryptedSharedPreferences data + implementation(libs.androidx.security.crypto) testImplementation(libs.junit) testImplementation(libs.mockito.kotlin) diff --git a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt index 84f048b2f..2194ec551 100644 --- a/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt +++ b/app/src/androidTest/kotlin/ee/ria/DigiDoc/domain/preferences/DataStoreTest.kt @@ -22,7 +22,6 @@ package ee.ria.DigiDoc.domain.preferences import android.content.Context -import android.content.SharedPreferences import androidx.preference.PreferenceManager import androidx.test.platform.app.InstrumentationRegistry import ee.ria.DigiDoc.common.Constant.Defaults.DEFAULT_UUID_VALUE @@ -83,12 +82,7 @@ class DataStoreTest { preferences.edit().remove(key).apply() } - val encryptedPreferences: SharedPreferences = EncryptedPreferences.getEncryptedPreferences(context) - - encryptedPreferences.all?.clear() - encryptedPreferences.all?.forEach { (key, _) -> - encryptedPreferences.edit().remove(key).apply() - } + EncryptedPreferences.clear(context) } @Test diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocApp.kt b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocApp.kt index 04237b60c..5dbe6d4ee 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocApp.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/RIADigiDocApp.kt @@ -23,6 +23,12 @@ package ee.ria.DigiDoc import android.app.Application import dagger.hilt.android.HiltAndroidApp +import ee.ria.DigiDoc.init.EncryptedPreferencesMigration @HiltAndroidApp -class RIADigiDocApp : Application() +class RIADigiDocApp : Application() { + override fun onCreate() { + super.onCreate() + EncryptedPreferencesMigration.migrate(this) + } +} diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt b/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt index ca29ff3a4..bd98cdc46 100644 --- a/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt +++ b/app/src/main/kotlin/ee/ria/DigiDoc/domain/preferences/DataStore.kt @@ -92,30 +92,15 @@ class DataStore } } - fun getCanNumber(): String { - val encryptedPreferences: SharedPreferences? = getEncryptedPreferences(context) - if (encryptedPreferences != null) { - return encryptedPreferences.getString( - resources.getString(R.string.main_settings_can_key), - "", - ) ?: "" - } - errorLog( - logTag, - "Unable to read CAN", - ) - return "" - } + fun getCanNumber(): String = + runEncrypted(context, "Unable to read CAN") { + EncryptedPreferences.getString(context, resources.getString(R.string.main_settings_can_key)) + } fun setCanNumber(can: String) { - val encryptedPreferences: SharedPreferences? = getEncryptedPreferences(context) - if (encryptedPreferences != null) { - encryptedPreferences.edit { - putString(resources.getString(R.string.main_settings_can_key), can) - } - return + runEncryptedWrite(context, "Unable to save CAN") { + EncryptedPreferences.putString(context, resources.getString(R.string.main_settings_can_key), can) } - errorLog(logTag, "Unable to save CAN") } fun getPhoneNo(): String = @@ -690,26 +675,22 @@ class DataStore ) ?: "" fun setProxyPassword(password: String) { - getEncryptedPreferences(context)?.edit { - putString( + runEncryptedWrite(context, "Unable to set proxy password") { + EncryptedPreferences.putString( + context, resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_password_key), password, ) } - errorLog(logTag, "Unable to set proxy password") } - fun getProxyPassword(): String { - val encryptedPreferences: SharedPreferences? = getEncryptedPreferences(context) - if (encryptedPreferences != null) { - return encryptedPreferences.getString( + fun getProxyPassword(): String = + runEncrypted(context, "Unable to get proxy password") { + EncryptedPreferences.getString( + context, resources.getString(ee.ria.DigiDoc.network.R.string.main_settings_proxy_password_key), - "", - ) ?: "" + ) } - errorLog(logTag, "Unable to get proxy password") - return "" - } fun getLocale(): Locale? { val locale = preferences.getString(KEY_LOCALE, null) @@ -772,16 +753,36 @@ class DataStore preferences.edit { putString(IDENTIFICATION_METHOD_SETTING, myEidIdentificationMethodSetting.methodName) } } - private fun getEncryptedPreferences(context: Context): SharedPreferences? = + private fun runEncrypted( + context: Context, + errorMessage: String, + operation: () -> String, + ): String = try { - EncryptedPreferences.getEncryptedPreferences(context) + operation() } catch (e: GeneralSecurityException) { - errorLog(logTag, "Unable to get encrypted preferences", e) + errorLog(logTag, errorMessage, e) showMessage(context, R.string.error_general_client) - null + "" } catch (e: IOException) { - errorLog(logTag, "Unable to get encrypted preferences", e) + errorLog(logTag, errorMessage, e) showMessage(context, R.string.error_general_client) - null + "" } + + private fun runEncryptedWrite( + context: Context, + errorMessage: String, + operation: () -> Unit, + ) { + try { + operation() + } catch (e: GeneralSecurityException) { + errorLog(logTag, errorMessage, e) + showMessage(context, R.string.error_general_client) + } catch (e: IOException) { + errorLog(logTag, errorMessage, e) + showMessage(context, R.string.error_general_client) + } + } } diff --git a/app/src/main/kotlin/ee/ria/DigiDoc/init/EncryptedPreferencesMigration.kt b/app/src/main/kotlin/ee/ria/DigiDoc/init/EncryptedPreferencesMigration.kt new file mode 100644 index 000000000..aa8b6fa8b --- /dev/null +++ b/app/src/main/kotlin/ee/ria/DigiDoc/init/EncryptedPreferencesMigration.kt @@ -0,0 +1,75 @@ +/* + * Copyright 2017 - 2026 Riigi Infosüsteemi Amet + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + */ + +@file:Suppress("PackageName") + +package ee.ria.DigiDoc.init + +import android.content.Context +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import ee.ria.DigiDoc.common.preferences.EncryptedPreferences +import ee.ria.DigiDoc.utilsLib.logging.LoggingUtil.Companion.errorLog +import java.io.File + +object EncryptedPreferencesMigration { + private const val OLD_PREFS_NAME = "encryptedPreferencesStorage" + + // Keys that were stored in the old EncryptedSharedPreferences + private const val KEY_CAN = "can" + private const val KEY_PROXY_PASSWORD = "main_settings_proxy_password" + + @Suppress("DEPRECATION") + fun migrate(context: Context) { + if (EncryptedPreferences.isMigrated(context)) return + + val oldPrefsFile = File(context.filesDir.parent, "shared_prefs/$OLD_PREFS_NAME.xml") + if (!oldPrefsFile.exists()) { + EncryptedPreferences.setMigrated(context) + return + } + + try { + val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + val oldPrefs = + EncryptedSharedPreferences.create( + OLD_PREFS_NAME, + masterKey, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + + val can = oldPrefs.getString(KEY_CAN, null) + val proxyPassword = oldPrefs.getString(KEY_PROXY_PASSWORD, null) + + if (!can.isNullOrEmpty()) { + EncryptedPreferences.putString(context, KEY_CAN, can) + } + if (!proxyPassword.isNullOrEmpty()) { + EncryptedPreferences.putString(context, KEY_PROXY_PASSWORD, proxyPassword) + } + + context.deleteSharedPreferences(OLD_PREFS_NAME) + EncryptedPreferences.setMigrated(context) + } catch (e: Exception) { + errorLog("EncryptedPrefsMigration", "Failed to migrate encrypted preferences", e) + } + } +} diff --git a/commons-lib/build.gradle.kts b/commons-lib/build.gradle.kts index 1698ed0cc..ad5f8faed 100644 --- a/commons-lib/build.gradle.kts +++ b/commons-lib/build.gradle.kts @@ -53,7 +53,6 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) - implementation(libs.androidx.security.crypto) implementation(libs.guava) implementation(libs.bouncy.castle) implementation(libs.google.dagger.hilt.android) diff --git a/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferencesTest.kt b/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferencesTest.kt index a85b88ceb..b99061bb8 100644 --- a/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferencesTest.kt +++ b/commons-lib/src/androidTest/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferencesTest.kt @@ -22,9 +22,11 @@ package ee.ria.DigiDoc.common.preferences import android.content.Context +import android.util.Base64 import androidx.test.platform.app.InstrumentationRegistry -import ee.ria.DigiDoc.common.preferences.EncryptedPreferences.getEncryptedPreferences -import org.junit.Assert.assertNotNull +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -34,12 +36,84 @@ class EncryptedPreferencesTest { @Before fun setUp() { context = InstrumentationRegistry.getInstrumentation().targetContext + EncryptedPreferences.clear(context) + context + .getSharedPreferences("encryptedPrefsMigration", Context.MODE_PRIVATE) + .edit() + .clear() + .apply() } @Test - fun encryptedPreferences_getEncryptedPreferences_success() { - val result = getEncryptedPreferences(context) + fun encryptedPreferences_putString_success() { + EncryptedPreferences.putString(context, "test_key", "test_value") + assertEquals("test_value", EncryptedPreferences.getString(context, "test_key")) + } + + @Test + fun encryptedPreferences_putString_successOverwritingExistingKey() { + EncryptedPreferences.putString(context, "key", "first") + EncryptedPreferences.putString(context, "key", "second") + assertEquals("second", EncryptedPreferences.getString(context, "key")) + } + + @Test + fun encryptedPreferences_putString_successWithEmptyString() { + EncryptedPreferences.putString(context, "key", "") + assertEquals("", EncryptedPreferences.getString(context, "key", "default")) + } + + @Test + fun encryptedPreferences_putString_successWithMultipleKeys() { + EncryptedPreferences.putString(context, "key_a", "value_a") + EncryptedPreferences.putString(context, "key_b", "value_b") + assertEquals("value_a", EncryptedPreferences.getString(context, "key_a")) + assertEquals("value_b", EncryptedPreferences.getString(context, "key_b")) + } + + @Test + fun encryptedPreferences_getString_missingKeyReturnsCallerDefault() { + assertEquals("default", EncryptedPreferences.getString(context, "missing_key", "default")) + } + + @Test + fun encryptedPreferences_getString_missingKeyReturnsEmptyStringByDefault() { + assertEquals("", EncryptedPreferences.getString(context, "missing_key")) + } + + @Test + fun encryptedPreferences_clear_removesStoredValues() { + EncryptedPreferences.putString(context, "key", "value") + EncryptedPreferences.clear(context) + assertEquals("default", EncryptedPreferences.getString(context, "key", "default")) + } + + @Test + fun encryptedPreferences_getString_returnsDefaultValueOnTamperedCiphertext() { + EncryptedPreferences.putString(context, "tamper_key", "real_value") + val underlying = context.getSharedPreferences("encryptedPreferencesStorageV2", Context.MODE_PRIVATE) + val obfuscatedKey = underlying.all.keys.first() + val fakeData = ByteArray(32) { it.toByte() } + underlying.edit().putString(obfuscatedKey, Base64.encodeToString(fakeData, Base64.NO_WRAP)).apply() + + assertEquals("default", EncryptedPreferences.getString(context, "tamper_key", "default")) + } - assertNotNull(result) + @Test + fun encryptedPreferences_isMigrated_returnsFalseInitially() { + assertFalse(EncryptedPreferences.isMigrated(context)) + } + + @Test + fun encryptedPreferences_setMigrated_success() { + EncryptedPreferences.setMigrated(context) + assertTrue(EncryptedPreferences.isMigrated(context)) + } + + @Test + fun encryptedPreferences_clear_doesNotAffectMigrationFlag() { + EncryptedPreferences.setMigrated(context) + EncryptedPreferences.clear(context) + assertTrue(EncryptedPreferences.isMigrated(context)) } } diff --git a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferences.kt b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferences.kt index 6ec3cc8a4..56212be6a 100644 --- a/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferences.kt +++ b/commons-lib/src/main/kotlin/ee/ria/DigiDoc/common/preferences/EncryptedPreferences.kt @@ -22,24 +22,138 @@ package ee.ria.DigiDoc.common.preferences import android.content.Context -import android.content.SharedPreferences -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log import java.io.IOException import java.security.GeneralSecurityException +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.Mac +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec object EncryptedPreferences { - private const val ENCRYPTED_PREFERENCES_KEY = "encryptedPreferencesStorage" + private const val PREFS_NAME = "encryptedPreferencesStorageV2" + private const val ENC_KEY_ALIAS = "digiDocEncryptedPrefsKey" + private const val HMAC_KEY_ALIAS = "digiDocEncryptedPrefsHmacKey" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 128 + private const val MIGRATION_PREFS_NAME = "encryptedPrefsMigration" + private const val MIGRATION_DONE_KEY = "migrationDone" + + fun isMigrated(context: Context): Boolean = + context + .getSharedPreferences(MIGRATION_PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(MIGRATION_DONE_KEY, false) + + fun setMigrated(context: Context) { + context + .getSharedPreferences(MIGRATION_PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(MIGRATION_DONE_KEY, true) + .apply() + } + + @Throws(IOException::class, GeneralSecurityException::class) + fun getString( + context: Context, + key: String, + default: String = "", + ): String { + ensureKeysExist() + val obfuscatedKey = obfuscateKey(key) + val encoded = prefs(context).getString(obfuscatedKey, null) ?: return default + return runCatching { decrypt(encoded) } + .onFailure { Log.e("EncryptedPreferences", "Decryption failed for key $key", it) } + .getOrDefault(default) + } @Throws(IOException::class, GeneralSecurityException::class) - fun getEncryptedPreferences(context: Context): SharedPreferences { - val masterKey: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - return EncryptedSharedPreferences.create( - ENCRYPTED_PREFERENCES_KEY, - masterKey, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, - ) + fun putString( + context: Context, + key: String, + value: String, + ) { + ensureKeysExist() + val obfuscatedKey = obfuscateKey(key) + prefs(context).edit().putString(obfuscatedKey, encrypt(value)).apply() + } + + @Throws(IOException::class, GeneralSecurityException::class) + fun clear(context: Context) { + prefs(context).edit().clear().apply() + } + + private fun prefs(context: Context) = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private fun ensureKeysExist() { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) } + if (!keyStore.containsAlias(ENC_KEY_ALIAS)) { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE).apply { + init( + KeyGenParameterSpec + .Builder( + ENC_KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + generateKey() + } + } + if (!keyStore.containsAlias(HMAC_KEY_ALIAS)) { + KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA256, ANDROID_KEYSTORE).apply { + init( + KeyGenParameterSpec + .Builder( + HMAC_KEY_ALIAS, + KeyProperties.PURPOSE_SIGN, + ).setDigests(KeyProperties.DIGEST_SHA256) + .build(), + ) + generateKey() + } + } + } + + private fun secretKey(): SecretKey = + KeyStore.getInstance(ANDROID_KEYSTORE).run { + load(null) + getKey(ENC_KEY_ALIAS, null) as SecretKey + } + + private fun hmacKey(): SecretKey = + KeyStore.getInstance(ANDROID_KEYSTORE).run { + load(null) + getKey(HMAC_KEY_ALIAS, null) as SecretKey + } + + private fun obfuscateKey(key: String): String { + val mac = Mac.getInstance("HmacSHA256") + mac.init(hmacKey()) + val hmac = mac.doFinal(key.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(hmac, Base64.NO_WRAP) + } + + private fun encrypt(plaintext: String): String { + val cipher = Cipher.getInstance(TRANSFORMATION).apply { init(Cipher.ENCRYPT_MODE, secretKey()) } + val combined = cipher.iv + cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + return Base64.encodeToString(combined, Base64.NO_WRAP) + } + + private fun decrypt(encoded: String): String { + val data = Base64.decode(encoded, Base64.NO_WRAP) + val cipher = + Cipher.getInstance(TRANSFORMATION).apply { + init(Cipher.DECRYPT_MODE, secretKey(), GCMParameterSpec(GCM_TAG_LENGTH, data, 0, GCM_IV_LENGTH)) + } + return String(cipher.doFinal(data, GCM_IV_LENGTH, data.size - GCM_IV_LENGTH), Charsets.UTF_8) } } diff --git a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt index d1b645168..3247d4fb2 100644 --- a/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt +++ b/libdigidoc-lib/src/main/kotlin/ee/ria/DigiDoc/libdigidoclib/init/Initialization.kt @@ -269,14 +269,11 @@ class Initialization try { val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val encryptedPreferences: SharedPreferences = - EncryptedPreferences.getEncryptedPreferences(context) - overrideProxy( sharedPreferences.getString(hostPreferenceKey, hostDefaultValue), sharedPreferences.getInt(portPreferenceKey, portDefaultValue), sharedPreferences.getString(usernamePreferenceKey, usernameDefaultValue), - encryptedPreferences.getString(passwordPreferenceKey, passwordDefaultValue), + EncryptedPreferences.getString(context, passwordPreferenceKey, passwordDefaultValue), ) } catch (e: IllegalStateException) { errorLog(libdigidocInitLogTag, "Error initializing proxy", e) diff --git a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt index dc2c84769..2f1a21581 100644 --- a/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt +++ b/networking-lib/src/main/kotlin/ee/ria/DigiDoc/network/utils/ProxyUtil.kt @@ -189,9 +189,7 @@ object ProxyUtil { ?: "" val password: String = try { - EncryptedPreferences - .getEncryptedPreferences(context) - .getString(context.getString(R.string.main_settings_proxy_password_key), "") ?: "" + EncryptedPreferences.getString(context, context.getString(R.string.main_settings_proxy_password_key)) } catch (_: IOException) { "" } catch (_: GeneralSecurityException) {