exporting credentials now requires a password (encrypted)

This commit is contained in:
rebelonion 2024-02-02 16:45:07 -06:00
parent ed6275b0e8
commit 2214c47c0c
6 changed files with 228 additions and 26 deletions

View file

@ -49,6 +49,8 @@ import ani.dantotsu.media.Media
import ani.dantotsu.parsers.ShowResponse import ani.dantotsu.parsers.ShowResponse
import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.settings.saving.PrefManager 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 ani.dantotsu.subcriptions.NotificationClickReceiver
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.model.GlideUrl 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<String, *>, context: Context) { fun savePrefsToDownloads(title: String, map: Map<String, *>, context: Context, password: CharArray? = null) {
FileProvider.getUriForFile( FileProvider.getUriForFile(
context, context,
"$APPLICATION_ID.provider", "$APPLICATION_ID.provider",
savePrefs( if (password != null) {
map, savePrefs(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath, map,
title, Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath,
context title,
) ?: return context,
password
) ?: return
} else {
savePrefs(
map,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath,
title,
context
) ?: return
}
) )
} }
@ -622,6 +634,34 @@ fun savePrefs(map: Map<String, *>, path: String, title: String, context: Context
} }
} }
fun savePrefs(map: Map<String, *>, 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) { fun shareImage(title: String, bitmap: Bitmap, context: Context) {
val contentUri = FileProvider.getUriForFile( val contentUri = FileProvider.getUriForFile(

View file

@ -7,6 +7,7 @@ import android.graphics.drawable.Animatable
import android.os.Build.* import android.os.Build.*
import android.os.Build.VERSION.* import android.os.Build.VERSION.*
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
@ -39,6 +40,7 @@ import ani.dantotsu.parsers.MangaSources
import ani.dantotsu.settings.saving.PrefName import ani.dantotsu.settings.saving.PrefName
import ani.dantotsu.settings.saving.PrefManager import ani.dantotsu.settings.saving.PrefManager
import ani.dantotsu.settings.saving.internal.Location import ani.dantotsu.settings.saving.internal.Location
import ani.dantotsu.settings.saving.internal.PreferenceKeystore
import ani.dantotsu.subcriptions.Notifications import ani.dantotsu.subcriptions.Notifications
import ani.dantotsu.subcriptions.Notifications.Companion.openSettings import ani.dantotsu.subcriptions.Notifications.Companion.openSettings
import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime import ani.dantotsu.subcriptions.Subscription.Companion.defaultTime
@ -70,6 +72,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
lateinit var binding: ActivitySettingsBinding lateinit var binding: ActivitySettingsBinding
private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller() private val extensionInstaller = Injekt.get<BasePreferences>().extensionInstaller()
private var cursedCounter = 0 private var cursedCounter = 0
private var tempPassword: CharArray? = null
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
@SuppressLint("SetTextI18n", "Recycle") @SuppressLint("SetTextI18n", "Recycle")
@ -85,15 +88,28 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
val openDocumentLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val openDocumentLauncher = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
if (uri != null) { if (uri != null) {
try { try {
val jsonString = contentResolver.openInputStream(uri)?.bufferedReader() val jsonString = contentResolver.openInputStream(uri)?.readBytes()
.use { it?.readText()} ?: throw Exception("Error reading file")
val location: Location = val location: Location =
Location.entries.find { it.name.lowercase() == selectedImpExp.lowercase() } Location.entries.find { it.name.lowercase() == selectedImpExp.lowercase() }
?: return@registerForActivityResult ?: 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 gson = Gson()
val type = object : TypeToken<Map<String, Map<String, Any>>>() {}.type val type = object : TypeToken<Map<String, Map<String, Any>>>() {}.type
val rawMap: Map<String, Map<String, Any>> = gson.fromJson(jsonString, type) val rawMap: Map<String, Map<String, Any>> = gson.fromJson(decryptedJson, type)
val deserializedMap = mutableMapOf<String, Any?>() val deserializedMap = mutableMapOf<String, Any?>()
@ -112,7 +128,8 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
} }
} }
PrefManager.importAllPrefs(deserializedMap, location) if(PrefManager.importAllPrefs(deserializedMap, location))
restartApp()
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
toast("Error importing settings") toast("Error importing settings")
@ -218,7 +235,7 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
val pinnedSourcesBoolean = val pinnedSourcesBoolean =
animeSourcesWithoutDownloadsSource.map { it.name in AnimeSources.pinnedAnimeSources } animeSourcesWithoutDownloadsSource.map { it.name in AnimeSources.pinnedAnimeSources }
val pinnedSourcesOriginal: Set<String> = PrefManager.getVal(PrefName.PinnedAnimeSources) val pinnedSourcesOriginal: Set<String> = PrefManager.getVal(PrefName.PinnedAnimeSources)
val pinnedSources = pinnedSourcesOriginal.toMutableSet() ?: mutableSetOf() val pinnedSources = pinnedSourcesOriginal.toMutableSet()
val alertDialog = AlertDialog.Builder(this, R.style.MyPopup) val alertDialog = AlertDialog.Builder(this, R.style.MyPopup)
.setTitle("Pinned Anime Sources") .setTitle("Pinned Anime Sources")
.setMultiChoiceItems( .setMultiChoiceItems(
@ -265,24 +282,52 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
binding.importExportSettings.setOnClickListener { binding.importExportSettings.setOnClickListener {
var i = 0 var i = 0
selectedImpExp = Location.entries[i].name selectedImpExp = Location.entries[i].name
val filteredLocations = Location.entries.filter { it.exportable }
val dialog = AlertDialog.Builder(this, R.style.MyPopup) val dialog = AlertDialog.Builder(this, R.style.MyPopup)
.setTitle("Import/Export Settings") .setTitle("Import/Export Settings")
.setSingleChoiceItems(Location.entries .setSingleChoiceItems( filteredLocations.map { it.name }.toTypedArray(), i) { dialog, which ->
.filter { it.exportable } selectedImpExp = filteredLocations[which].name
.map { it.name }.toTypedArray(), 0) { dialog, which ->
selectedImpExp = Location.entries[which].name
i = which i = which
} }
.setPositiveButton("Import...") { dialog, _ -> .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() dialog.dismiss()
} }
.setNegativeButton("Export...") { dialog, _ -> .setNegativeButton("Export...") { dialog, _ ->
if (i < 0) return@setNegativeButton if (i < 0) return@setNegativeButton
savePrefsToDownloads(Location.entries[i].name,
PrefManager.exportAllPrefs(Location.entries[i]),
this@SettingsActivity)
dialog.dismiss() 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, _ -> .setNeutralButton("Cancel") { dialog, _ ->
dialog.dismiss() dialog.dismiss()
@ -877,6 +922,44 @@ class SettingsActivity : AppCompatActivity(), SimpleDialog.OnDialogResultListene
show() 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<TextInputEditText>(R.id.userAgentTextBox)?.hint = "Password"
val subtitleTextView = dialogView.findViewById<TextView>(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<TextInputEditText>(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 { companion object {
fun getDeviceInfo(): String { fun getDeviceInfo(): String {

View file

@ -90,7 +90,7 @@ object PrefManager {
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T> getNullableVal(prefName: PrefName, default: T?) : T? { fun <T> getNullableVal(prefName: PrefName, default: T?) : T? { //Strings don't necessarily need to use this one
return try { return try {
val pref = getPrefLocation(prefName.data.prefLocation) val pref = getPrefLocation(prefName.data.prefLocation)
when (prefName.data.type) { when (prefName.data.type) {
@ -254,7 +254,7 @@ object PrefManager {
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun importAllPrefs(prefs: Map<String, *>, prefLocation: Location) { fun importAllPrefs(prefs: Map<String, *>, prefLocation: Location): Boolean {
val pref = getPrefLocation(prefLocation) val pref = getPrefLocation(prefLocation)
var hadError = false var hadError = false
pref.edit().clear().apply() pref.edit().clear().apply()
@ -273,8 +273,13 @@ object PrefManager {
} }
} }
apply() apply()
if (hadError) snackString("Error importing preferences") return if (hadError) {
else snackString("Preferences imported") snackString("Error importing preferences")
false
} else {
snackString("Preferences imported")
true
}
} }
} }

View file

@ -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)
}
}
}

View file

@ -123,12 +123,16 @@ class SubscriptionHelper {
) : java.io.Serializable ) : java.io.Serializable
private const val subscriptions = "subscriptions" private const val subscriptions = "subscriptions"
@Suppress("UNCHECKED_CAST")
fun getSubscriptions(): Map<Int, SubscribeMedia> = fun getSubscriptions(): Map<Int, SubscribeMedia> =
PrefManager.getNullableCustomVal<Map<Int, SubscribeMedia>>(subscriptions, null, Map::class.java)!! (PrefManager.getNullableCustomVal(subscriptions, null, Map::class.java) as? Map<Int, SubscribeMedia>)
?: mapOf<Int, SubscribeMedia>().also { PrefManager.setCustomVal(subscriptions, it) } ?: mapOf<Int, SubscribeMedia>().also { PrefManager.setCustomVal(subscriptions, it) }
@Suppress("UNCHECKED_CAST")
fun saveSubscription(context: Context, media: Media, subscribed: Boolean) { fun saveSubscription(context: Context, media: Media, subscribed: Boolean) {
val data = PrefManager.getNullableCustomVal<Map<Int, SubscribeMedia>?>(subscriptions, null)!!.toMutableMap() val data = PrefManager.getNullableCustomVal(subscriptions, null, Map::class.java) as? MutableMap<Int, SubscribeMedia>
?: mutableMapOf()
if (subscribed) { if (subscribed) {
if (!data.containsKey(media.id)) { if (!data.containsKey(media.id)) {
val new = SubscribeMedia( val new = SubscribeMedia(

View file

@ -6,6 +6,15 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="@font/poppins"
android:visibility="gone"
android:text="Exporting credentials requires a password for encryption."
android:textSize="18sp" />
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/userAgentTextBox" android:id="@+id/userAgentTextBox"
android:layout_width="match_parent" android:layout_width="match_parent"