From 2214c47c0c19435f31f9c637894691c112250cdb Mon Sep 17 00:00:00 2001 From: rebelonion <87634197+rebelonion@users.noreply.github.com> Date: Fri, 2 Feb 2024 16:45:07 -0600 Subject: [PATCH] exporting credentials now requires a password (encrypted) --- app/src/main/java/ani/dantotsu/Functions.kt | 54 +++++++-- .../ani/dantotsu/settings/SettingsActivity.kt | 109 +++++++++++++++--- .../dantotsu/settings/saving/PrefManager.kt | 13 ++- .../saving/internal/PreferenceKeystore.kt | 61 ++++++++++ .../subcriptions/SubscriptionHelper.kt | 8 +- app/src/main/res/layout/dialog_user_agent.xml | 9 ++ 6 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/ani/dantotsu/settings/saving/internal/PreferenceKeystore.kt diff --git a/app/src/main/java/ani/dantotsu/Functions.kt b/app/src/main/java/ani/dantotsu/Functions.kt index 1e5d3ffc..6c0805f4 100644 --- a/app/src/main/java/ani/dantotsu/Functions.kt +++ b/app/src/main/java/ani/dantotsu/Functions.kt @@ -49,6 +49,8 @@ import ani.dantotsu.media.Media import ani.dantotsu.parsers.ShowResponse import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefManager +import ani.dantotsu.settings.saving.internal.PreferenceKeystore +import ani.dantotsu.settings.saving.internal.PreferenceKeystore.Companion.generateSalt import ani.dantotsu.subcriptions.NotificationClickReceiver import com.bumptech.glide.Glide import com.bumptech.glide.load.model.GlideUrl @@ -588,16 +590,26 @@ fun saveImageToDownloads(title: String, bitmap: Bitmap, context: Context) { ) } -fun savePrefsToDownloads(title: String, map: Map, context: Context) { +fun savePrefsToDownloads(title: String, map: Map, context: Context, password: CharArray? = null) { FileProvider.getUriForFile( context, "$APPLICATION_ID.provider", - savePrefs( - map, - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath, - title, - context - ) ?: return + if (password != null) { + savePrefs( + map, + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath, + title, + context, + password + ) ?: return + } else { + savePrefs( + map, + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath, + title, + context + ) ?: return + } ) } @@ -622,6 +634,34 @@ fun savePrefs(map: Map, path: String, title: String, context: Context } } +fun savePrefs(map: Map, path: String, title: String, context: Context, password: CharArray): File? { + var file = File(path, "$title.ani") + var counter = 1 + while (file.exists()) { + file = File(path, "${title}_${counter}.ani") + counter++ + } + + val salt = generateSalt() + + return try { + val gson = Gson() + val json = gson.toJson(map) + val encryptedData = PreferenceKeystore.encryptWithPassword(password, json, salt) + + // Combine salt and encrypted data + val dataToSave = salt + encryptedData + + file.writeBytes(dataToSave) + scanFile(file.absolutePath, context) + toast(String.format(context.getString(R.string.saved_to_path, file.absolutePath))) + file + } catch (e: Exception) { + snackString("Failed to save settings: ${e.localizedMessage}") + null + } +} + fun shareImage(title: String, bitmap: Bitmap, context: Context) { val contentUri = FileProvider.getUriForFile( diff --git a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt index 03d40308..df64eb2f 100644 --- a/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt +++ b/app/src/main/java/ani/dantotsu/settings/SettingsActivity.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.Animatable import android.os.Build.* import android.os.Build.VERSION.* import android.os.Bundle +import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter @@ -39,6 +40,7 @@ import ani.dantotsu.parsers.MangaSources import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.internal.Location +import ani.dantotsu.settings.saving.internal.PreferenceKeystore import ani.dantotsu.subcriptions.Notifications import ani.dantotsu.subcriptions.Notifications.Companion.openSettings import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime @@ -70,6 +72,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene lateinit var binding: ActivitySettingsBinding private val extensionInstaller = Injekt.get().extensionInstaller() private var cursedCounter = 0 + private var tempPassword: CharArray? = null @OptIn(UnstableApi::class) @SuppressLint("SetTextI18n", "Recycle") @@ -85,15 +88,28 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene val openDocumentLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { try { - val jsonString = contentResolver.openInputStream(uri)?.bufferedReader() - .use { it?.readText()} + val jsonString = contentResolver.openInputStream(uri)?.readBytes() + ?: throw Exception("Error reading file") val location: Location = Location.entries.find { it.name.lowercase() == selectedImpExp.lowercase() } ?: return@registerForActivityResult + val decryptedJson = if (location == Location.Protected) { + val password = tempPassword ?: return@registerForActivityResult + tempPassword = null + val salt = jsonString.copyOfRange(0, 16) + val encrypted = jsonString.copyOfRange(16, jsonString.size) + PreferenceKeystore.decryptWithPassword( + password, + encrypted, + salt + ) + } else { + jsonString.toString(Charsets.UTF_8) + } val gson = Gson() val type = object : TypeToken>>() {}.type - val rawMap: Map> = gson.fromJson(jsonString, type) + val rawMap: Map> = gson.fromJson(decryptedJson, type) val deserializedMap = mutableMapOf() @@ -112,7 +128,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene } } - PrefManager.importAllPrefs(deserializedMap, location) + if(PrefManager.importAllPrefs(deserializedMap, location)) + restartApp() } catch (e: Exception) { e.printStackTrace() toast("Error importing settings") @@ -218,7 +235,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene val pinnedSourcesBoolean = animeSourcesWithoutDownloadsSource.map { it.name in AnimeSources.pinnedAnimeSources } val pinnedSourcesOriginal: Set = PrefManager.getVal(PrefName.PinnedAnimeSources) - val pinnedSources = pinnedSourcesOriginal.toMutableSet() ?: mutableSetOf() + val pinnedSources = pinnedSourcesOriginal.toMutableSet() val alertDialog = AlertDialog.Builder(this, R.style.MyPopup) .setTitle("Pinned Anime Sources") .setMultiChoiceItems( @@ -265,24 +282,52 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene binding.importExportSettings.setOnClickListener { var i = 0 selectedImpExp = Location.entries[i].name + val filteredLocations = Location.entries.filter { it.exportable } val dialog = AlertDialog.Builder(this, R.style.MyPopup) .setTitle("Import/Export Settings") - .setSingleChoiceItems(Location.entries - .filter { it.exportable } - .map { it.name }.toTypedArray(), 0) { dialog, which -> - selectedImpExp = Location.entries[which].name + .setSingleChoiceItems( filteredLocations.map { it.name }.toTypedArray(), i) { dialog, which -> + selectedImpExp = filteredLocations[which].name i = which } .setPositiveButton("Import...") { dialog, _ -> - openDocumentLauncher.launch(arrayOf("*/*")) + if (filteredLocations[i] == Location.Protected) { + passwordAlertDialog(false) { password -> + if (password != null) { + tempPassword = password + openDocumentLauncher.launch(arrayOf("*/*")) + } else { + toast("Password cannot be empty") + } + } + } else { + openDocumentLauncher.launch(arrayOf("*/*")) + } dialog.dismiss() } .setNegativeButton("Export...") { dialog, _ -> if (i < 0) return@setNegativeButton - savePrefsToDownloads(Location.entries[i].name, - PrefManager.exportAllPrefs(Location.entries[i]), - this@SettingsActivity) dialog.dismiss() + if (filteredLocations[i] == Location.Protected) { + passwordAlertDialog(true) { password -> + if (password != null) { + savePrefsToDownloads( + filteredLocations[i].name, + PrefManager.exportAllPrefs(filteredLocations[i]), + this@SettingsActivity, + password + ) + } else { + toast("Password cannot be empty") + } + } + } else { + savePrefsToDownloads( + filteredLocations[i].name, + PrefManager.exportAllPrefs(filteredLocations[i]), + this@SettingsActivity, + null + ) + } } .setNeutralButton("Cancel") { dialog, _ -> dialog.dismiss() @@ -877,6 +922,44 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene show() } } + private fun passwordAlertDialog(isExporting:Boolean, callback: (CharArray?) -> Unit) { + val password = CharArray(16).apply { fill('0') } + + // Inflate the dialog layout + val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_user_agent, null) + dialogView.findViewById(R.id.userAgentTextBox)?.hint = "Password" + val subtitleTextView = dialogView.findViewById(R.id.subtitle) + subtitleTextView?.visibility = View.VISIBLE + if (!isExporting) + subtitleTextView?.text = "Enter your password to decrypt the file" + + val dialog = AlertDialog.Builder(this, R.style.MyPopup) + .setTitle("Enter Password") + .setView(dialogView) + .setPositiveButton("OK", null) + .setNegativeButton("Cancel") { dialog, _ -> + password.fill('0') + dialog.dismiss() + callback(null) + } + .create() + + dialog.window?.setDimAmount(0.8f) + dialog.show() + + // Override the positive button here + dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { + val editText = dialog.findViewById(R.id.userAgentTextBox) + if (editText?.text?.isNotBlank() == true) { + editText.text?.toString()?.trim()?.toCharArray(password) + dialog.dismiss() + callback(password) + } else { + toast("Password cannot be empty") + } + } + } + companion object { fun getDeviceInfo(): String { diff --git a/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt b/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt index 46f53740..31c9917f 100644 --- a/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt +++ b/app/src/main/java/ani/dantotsu/settings/saving/PrefManager.kt @@ -90,7 +90,7 @@ object PrefManager { } @Suppress("UNCHECKED_CAST") - fun getNullableVal(prefName: PrefName, default: T?) : T? { + fun getNullableVal(prefName: PrefName, default: T?) : T? { //Strings don't necessarily need to use this one return try { val pref = getPrefLocation(prefName.data.prefLocation) when (prefName.data.type) { @@ -254,7 +254,7 @@ object PrefManager { } @Suppress("UNCHECKED_CAST") - fun importAllPrefs(prefs: Map, prefLocation: Location) { + fun importAllPrefs(prefs: Map, prefLocation: Location): Boolean { val pref = getPrefLocation(prefLocation) var hadError = false pref.edit().clear().apply() @@ -273,8 +273,13 @@ object PrefManager { } } apply() - if (hadError) snackString("Error importing preferences") - else snackString("Preferences imported") + return if (hadError) { + snackString("Error importing preferences") + false + } else { + snackString("Preferences imported") + true + } } } diff --git a/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferenceKeystore.kt b/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferenceKeystore.kt new file mode 100644 index 00000000..ad7b2810 --- /dev/null +++ b/app/src/main/java/ani/dantotsu/settings/saving/internal/PreferenceKeystore.kt @@ -0,0 +1,61 @@ +package ani.dantotsu.settings.saving.internal + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.PBEKeySpec + +//used to encrypt and decrypt json strings on import and export +class PreferenceKeystore { + companion object { + fun generateKey(alias: String) { + val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + + keyGenerator.init( + KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .setRandomizedEncryptionRequired(false) + .build() + ) + keyGenerator.generateKey() + } + + fun encryptWithPassword(password: CharArray, plaintext: String, salt: ByteArray): ByteArray { + val secretKey = deriveKeyFromPassword(password, salt) + val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}") + cipher.init(Cipher.ENCRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) + return cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) + } + + fun decryptWithPassword(password: CharArray, ciphertext: ByteArray, salt: ByteArray): String { + val secretKey = deriveKeyFromPassword(password, salt) + val cipher = Cipher.getInstance("${KeyProperties.KEY_ALGORITHM_AES}/${KeyProperties.BLOCK_MODE_CBC}/${KeyProperties.ENCRYPTION_PADDING_PKCS7}") + cipher.init(Cipher.DECRYPT_MODE, secretKey, IvParameterSpec(ByteArray(16))) // Use the correct IV + return cipher.doFinal(ciphertext).toString(Charsets.UTF_8) + } + + fun generateSalt(): ByteArray { + val random = SecureRandom() + val salt = ByteArray(16) + random.nextBytes(salt) + return salt + } + + private fun deriveKeyFromPassword(password: CharArray, salt: ByteArray): SecretKey { + val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256") + val spec = PBEKeySpec(password, salt, 10000, 256) + return factory.generateSecret(spec) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt b/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt index da4468c2..260aba33 100644 --- a/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt +++ b/app/src/main/java/ani/dantotsu/subcriptions/SubscriptionHelper.kt @@ -123,12 +123,16 @@ class SubscriptionHelper { ) : java.io.Serializable private const val subscriptions = "subscriptions" + + @Suppress("UNCHECKED_CAST") fun getSubscriptions(): Map = - PrefManager.getNullableCustomVal>(subscriptions, null, Map::class.java)!! + (PrefManager.getNullableCustomVal(subscriptions, null, Map::class.java) as? Map) ?: mapOf().also { PrefManager.setCustomVal(subscriptions, it) } + @Suppress("UNCHECKED_CAST") fun saveSubscription(context: Context, media: Media, subscribed: Boolean) { - val data = PrefManager.getNullableCustomVal?>(subscriptions, null)!!.toMutableMap() + val data = PrefManager.getNullableCustomVal(subscriptions, null, Map::class.java) as? MutableMap + ?: mutableMapOf() if (subscribed) { if (!data.containsKey(media.id)) { val new = SubscribeMedia( diff --git a/app/src/main/res/layout/dialog_user_agent.xml b/app/src/main/res/layout/dialog_user_agent.xml index 45ca04f9..56f8f77d 100644 --- a/app/src/main/res/layout/dialog_user_agent.xml +++ b/app/src/main/res/layout/dialog_user_agent.xml @@ -6,6 +6,15 @@ android:orientation="vertical" android:padding="16dp"> + +