diff --git a/app/src/main/java/org/tasks/injection/ApplicationModule.kt b/app/src/main/java/org/tasks/injection/ApplicationModule.kt index 56fa47c80..b7d231111 100644 --- a/app/src/main/java/org/tasks/injection/ApplicationModule.kt +++ b/app/src/main/java/org/tasks/injection/ApplicationModule.kt @@ -39,6 +39,8 @@ import org.tasks.jobs.WorkManager import org.tasks.kmp.createDataStore import org.tasks.preferences.Preferences import org.tasks.preferences.TasksPreferences +import org.tasks.security.AndroidKeyStoreEncryption +import org.tasks.security.KeyStoreEncryption import java.util.Locale import javax.inject.Singleton @@ -179,4 +181,8 @@ class ApplicationModule { @Singleton fun providesVtodoCache(caldavDao: CaldavDao, fileStorage: FileStorage) = VtodoCache(caldavDao, fileStorage) + + @Provides + @Singleton + fun providesKeyStoreEncryption(): KeyStoreEncryption = AndroidKeyStoreEncryption() } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/security/KeyStoreEncryption.kt b/app/src/main/java/org/tasks/security/KeyStoreEncryption.kt deleted file mode 100644 index 5073b6ef2..000000000 --- a/app/src/main/java/org/tasks/security/KeyStoreEncryption.kt +++ /dev/null @@ -1,97 +0,0 @@ -package org.tasks.security - -import android.annotation.SuppressLint -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import android.util.Base64 -import org.tasks.Strings.isNullOrEmpty -import timber.log.Timber -import java.nio.charset.StandardCharsets -import java.security.KeyStore -import java.security.SecureRandom -import java.util.* -import javax.crypto.* -import javax.crypto.spec.GCMParameterSpec -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class KeyStoreEncryption @Inject constructor() { - - private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE) - - fun encrypt(text: String): String? { - val iv = ByteArray(GCM_IV_LENGTH) - SecureRandom().nextBytes(iv) - val cipher = getCipher(Cipher.ENCRYPT_MODE, iv) - return try { - val output = cipher.doFinal(text.toByteArray(ENCODING)) - val result = ByteArray(iv.size + output.size) - System.arraycopy(iv, 0, result, 0, iv.size) - System.arraycopy(output, 0, result, iv.size, output.size) - Base64.encodeToString(result, Base64.DEFAULT) - } catch (e: IllegalBlockSizeException) { - Timber.e(e) - null - } catch (e: BadPaddingException) { - Timber.e(e) - null - } - } - - fun decrypt(text: String?): String? { - if (isNullOrEmpty(text)) { - return null - } - val decoded = Base64.decode(text, Base64.DEFAULT) - val iv = Arrays.copyOfRange(decoded, 0, GCM_IV_LENGTH) - val cipher = getCipher(Cipher.DECRYPT_MODE, iv) - return try { - val decrypted = cipher.doFinal(decoded, GCM_IV_LENGTH, decoded.size - GCM_IV_LENGTH) - String(decrypted, ENCODING) - } catch (e: IllegalBlockSizeException) { - Timber.e(e) - "" - } catch (e: BadPaddingException) { - Timber.e(e) - "" - } - } - - private fun getCipher(cipherMode: Int, iv: ByteArray): Cipher { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(cipherMode, secretKey, GCMParameterSpec(GCM_TAG_LENGTH * java.lang.Byte.SIZE, iv)) - return cipher - } - - private val secretKey: SecretKey - get() { - val entry: KeyStore.Entry? = keyStore.getEntry(ALIAS, null) - return (entry as KeyStore.SecretKeyEntry?)?.secretKey ?: generateNewKey() - } - - @SuppressLint("TrulyRandom") - private fun generateNewKey(): SecretKey { - val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) - keyGenerator.init( - KeyGenParameterSpec.Builder( - ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setRandomizedEncryptionRequired(false) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .build()) - return keyGenerator.generateKey() - } - - init { - keyStore.load(null) - } - - companion object { - private const val ANDROID_KEYSTORE = "AndroidKeyStore" - private const val ALIAS = "passwords" - private val ENCODING = StandardCharsets.UTF_8 - private const val GCM_IV_LENGTH = 12 - private const val GCM_TAG_LENGTH = 16 - } -} \ No newline at end of file diff --git a/kmp/src/androidMain/kotlin/org/tasks/security/AndroidKeyProvider.kt b/kmp/src/androidMain/kotlin/org/tasks/security/AndroidKeyProvider.kt new file mode 100644 index 000000000..7c44512df --- /dev/null +++ b/kmp/src/androidMain/kotlin/org/tasks/security/AndroidKeyProvider.kt @@ -0,0 +1,42 @@ +package org.tasks.security + +import android.annotation.SuppressLint +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +class AndroidKeyProvider : KeyProvider { + + private val keyStore: KeyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + + init { + keyStore.load(null) + } + + override fun getKey(): SecretKey { + val entry: KeyStore.Entry? = keyStore.getEntry(ALIAS, null) + return (entry as KeyStore.SecretKeyEntry?)?.secretKey ?: generateNewKey() + } + + @SuppressLint("TrulyRandom") + private fun generateNewKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE) + keyGenerator.init( + KeyGenParameterSpec.Builder( + ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setRandomizedEncryptionRequired(false) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build() + ) + return keyGenerator.generateKey() + } + + companion object { + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val ALIAS = "passwords" + } +} \ No newline at end of file diff --git a/kmp/src/androidMain/kotlin/org/tasks/security/AndroidKeyStoreEncryption.kt b/kmp/src/androidMain/kotlin/org/tasks/security/AndroidKeyStoreEncryption.kt new file mode 100644 index 000000000..917b5e0c9 --- /dev/null +++ b/kmp/src/androidMain/kotlin/org/tasks/security/AndroidKeyStoreEncryption.kt @@ -0,0 +1,18 @@ +package org.tasks.security + +import java.util.Base64 + +class AndroidKeyStoreEncryption() : KeyStoreEncryption(AndroidKeyProvider()) { + override fun decodeBase64(text: String): ByteArray { + return try { + Base64.getDecoder().decode(text) + } catch (_: IllegalArgumentException) { + try { + android.util.Base64.decode(text, android.util.Base64.DEFAULT) + } catch (e: Exception) { + logger.e(e) { "Failed to decode Base64 data with both decoders" } + ByteArray(0) + } + } + } +} \ No newline at end of file diff --git a/kmp/src/commonMain/kotlin/org/tasks/security/KeyProvider.kt b/kmp/src/commonMain/kotlin/org/tasks/security/KeyProvider.kt new file mode 100644 index 000000000..ebd226afc --- /dev/null +++ b/kmp/src/commonMain/kotlin/org/tasks/security/KeyProvider.kt @@ -0,0 +1,7 @@ +package org.tasks.security + +import javax.crypto.SecretKey + +interface KeyProvider { + fun getKey(): SecretKey +} \ No newline at end of file diff --git a/kmp/src/commonMain/kotlin/org/tasks/security/KeyStoreEncryption.kt b/kmp/src/commonMain/kotlin/org/tasks/security/KeyStoreEncryption.kt new file mode 100644 index 000000000..39d39c97c --- /dev/null +++ b/kmp/src/commonMain/kotlin/org/tasks/security/KeyStoreEncryption.kt @@ -0,0 +1,78 @@ +package org.tasks.security + +import co.touchlab.kermit.Logger +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import java.util.Base64 +import javax.crypto.BadPaddingException +import javax.crypto.Cipher +import javax.crypto.IllegalBlockSizeException +import javax.crypto.spec.GCMParameterSpec + +open class KeyStoreEncryption( + private val keyProvider: KeyProvider +) { + protected val logger = Logger.withTag("KeyStoreEncryption") + + fun encrypt(text: String): String? { + val iv = ByteArray(GCM_IV_LENGTH) + SecureRandom().nextBytes(iv) + val cipher = getCipher(Cipher.ENCRYPT_MODE, iv) + return try { + val output = cipher.doFinal(text.toByteArray(ENCODING)) + val result = ByteArray(iv.size + output.size) + System.arraycopy(iv, 0, result, 0, iv.size) + System.arraycopy(output, 0, result, iv.size, output.size) + Base64.getEncoder().encodeToString(result) + } catch (e: IllegalBlockSizeException) { + logger.e(e) { "Failed to encrypt data" } + null + } catch (e: BadPaddingException) { + logger.e(e) { "Failed to encrypt data" } + null + } + } + + fun decrypt(text: String?): String? { + if (text.isNullOrBlank()) { + return null + } + val decoded = decodeBase64(text) + if (decoded.isEmpty()) { + return "" + } + val iv = decoded.copyOfRange(0, GCM_IV_LENGTH) + val cipher = getCipher(Cipher.DECRYPT_MODE, iv) + return try { + val decrypted = cipher.doFinal(decoded, GCM_IV_LENGTH, decoded.size - GCM_IV_LENGTH) + String(decrypted, ENCODING) + } catch (e: IllegalBlockSizeException) { + logger.e(e) { "Failed to decrypt data" } + "" + } catch (e: BadPaddingException) { + logger.e(e) { "Failed to decrypt data" } + "" + } + } + + protected open fun decodeBase64(text: String): ByteArray { + return try { + Base64.getDecoder().decode(text) + } catch (e: IllegalArgumentException) { + logger.e(e) { "Failed to decode Base64 data" } + ByteArray(0) + } + } + + private fun getCipher(cipherMode: Int, iv: ByteArray): Cipher { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(cipherMode, keyProvider.getKey(), GCMParameterSpec(GCM_TAG_LENGTH * java.lang.Byte.SIZE, iv)) + return cipher + } + + companion object { + private val ENCODING = StandardCharsets.UTF_8 + private const val GCM_IV_LENGTH = 12 + private const val GCM_TAG_LENGTH = 16 + } +}