Encrypted CBZ archives (#846)
* Initial Implementation of encrypted CBZ archives * changed a preference key to correct Syntax, changed a function name and changed ComicInfo padding length * Update app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com> * Update app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com> * add necessary imports * fix indentation after merge conflict * Update app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsSecurityScreen.kt Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com> * fix indentation and add imports * collect preferences as states * test if password is correct in ZipPageLoader * added withIOContext to function call * added encryption type preference * implemented database encryption * added proguard rules for sqlcipher and generate padding length with SecureRandom --------- Co-authored-by: jobobby04 <jobobby04@users.noreply.github.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
package eu.kanade.tachiyomi.core.security
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.core.R
|
||||
import tachiyomi.core.preference.PreferenceStore
|
||||
import tachiyomi.core.preference.getEnum
|
||||
|
||||
class SecurityPreferences(
|
||||
private val preferenceStore: PreferenceStore,
|
||||
private val context: Context,
|
||||
) {
|
||||
|
||||
fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false)
|
||||
@@ -20,6 +22,19 @@ class SecurityPreferences(
|
||||
fun authenticatorTimeRanges() = this.preferenceStore.getStringSet("biometric_time_ranges", mutableSetOf())
|
||||
|
||||
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F)
|
||||
|
||||
fun encryptDatabase() = this.preferenceStore.getBoolean("encrypt_database", !context.getDatabasePath("tachiyomi.db").exists())
|
||||
|
||||
fun sqlPassword() = this.preferenceStore.getString("sql_password", "")
|
||||
|
||||
fun passwordProtectDownloads() = preferenceStore.getBoolean("password_protect_downloads", false)
|
||||
|
||||
fun encryptionType() = this.preferenceStore.getEnum("encryption_type", EncryptionType.AES_256)
|
||||
|
||||
fun cbzPassword() = this.preferenceStore.getString("cbz_password", "")
|
||||
|
||||
fun localCoverLocation() = this.preferenceStore.getEnum("local_cover_location", CoverCacheLocation.IN_MANGA_DIRECTORY)
|
||||
|
||||
// SY <--
|
||||
|
||||
/**
|
||||
@@ -33,4 +48,19 @@ class SecurityPreferences(
|
||||
INCOGNITO(R.string.pref_incognito_mode),
|
||||
NEVER(R.string.lock_never),
|
||||
}
|
||||
|
||||
// SY -->
|
||||
enum class EncryptionType(val titleResId: Int) {
|
||||
AES_256(R.string.aes_256),
|
||||
AES_192(R.string.aes_192),
|
||||
AES_128(R.string.aes_128),
|
||||
ZIP_STANDARD(R.string.standard_zip_encryption),
|
||||
}
|
||||
enum class CoverCacheLocation(val titleResId: Int) {
|
||||
IN_MANGA_DIRECTORY(R.string.save_in_manga_directory),
|
||||
INTERNAL(R.string.save_internally),
|
||||
NEVER(R.string.save_never),
|
||||
}
|
||||
|
||||
// SY <--
|
||||
}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
package eu.kanade.tachiyomi.util.storage
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import eu.kanade.tachiyomi.core.R
|
||||
import eu.kanade.tachiyomi.core.security.SecurityPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import logcat.LogPriority
|
||||
import net.lingala.zip4j.ZipFile
|
||||
import net.lingala.zip4j.model.ZipParameters
|
||||
import net.lingala.zip4j.model.enums.AesKeyStrength
|
||||
import net.lingala.zip4j.model.enums.EncryptionMethod
|
||||
import tachiyomi.core.util.system.logcat
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.IvParameterSpec
|
||||
|
||||
// SY -->
|
||||
/**
|
||||
* object used to En/Decrypt and Base64 en/decode
|
||||
* passwords before storing
|
||||
* them in Shared Preferences
|
||||
*/
|
||||
object CbzCrypto {
|
||||
const val DATABASE_NAME = "tachiyomiEncrypted.db"
|
||||
private val securityPreferences: SecurityPreferences by injectLazy()
|
||||
private val keyStore = KeyStore.getInstance(KEYSTORE).apply {
|
||||
load(null)
|
||||
}
|
||||
|
||||
private val encryptionCipherCbz
|
||||
get() = Cipher.getInstance(CRYPTO_SETTINGS).apply {
|
||||
init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
getKey(ALIAS_CBZ),
|
||||
)
|
||||
}
|
||||
|
||||
private val encryptionCipherSql
|
||||
get() = Cipher.getInstance(CRYPTO_SETTINGS).apply {
|
||||
init(
|
||||
Cipher.ENCRYPT_MODE,
|
||||
getKey(ALIAS_SQL),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getDecryptCipher(iv: ByteArray, alias: String): Cipher {
|
||||
return Cipher.getInstance(CRYPTO_SETTINGS).apply {
|
||||
init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
getKey(alias),
|
||||
IvParameterSpec(iv),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getKey(alias: String): SecretKey {
|
||||
val loadedKey = keyStore.getEntry(alias, null) as? KeyStore.SecretKeyEntry
|
||||
return loadedKey?.secretKey ?: generateKey(alias)
|
||||
}
|
||||
|
||||
private fun generateKey(alias: String): SecretKey {
|
||||
return KeyGenerator.getInstance(ALGORITHM).apply {
|
||||
init(
|
||||
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setKeySize(KEY_SIZE)
|
||||
.setBlockModes(BLOCK_MODE)
|
||||
.setEncryptionPaddings(PADDING)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.setUserAuthenticationRequired(false)
|
||||
.build(),
|
||||
)
|
||||
}.generateKey()
|
||||
}
|
||||
|
||||
private fun encrypt(password: String, cipher: Cipher): String {
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
outputStream.use { output ->
|
||||
output.write(cipher.iv)
|
||||
ByteArrayInputStream(password.toByteArray()).use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
while (input.available() > BUFFER_SIZE) {
|
||||
input.read(buffer)
|
||||
output.write(cipher.update(buffer))
|
||||
}
|
||||
output.write(cipher.doFinal(input.readBytes()))
|
||||
}
|
||||
}
|
||||
return Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT)
|
||||
}
|
||||
|
||||
private fun decrypt(encryptedPassword: String, alias: String): String {
|
||||
val inputStream = Base64.decode(encryptedPassword, Base64.DEFAULT).inputStream()
|
||||
return inputStream.use { input ->
|
||||
val iv = ByteArray(IV_SIZE)
|
||||
input.read(iv)
|
||||
val cipher = getDecryptCipher(iv, alias)
|
||||
ByteArrayOutputStream().use { output ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
while (inputStream.available() > BUFFER_SIZE) {
|
||||
inputStream.read(buffer)
|
||||
output.write(cipher.update(buffer))
|
||||
}
|
||||
output.write(cipher.doFinal(inputStream.readBytes()))
|
||||
output.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteKeyCbz() {
|
||||
keyStore.deleteEntry(ALIAS_CBZ)
|
||||
generateKey(ALIAS_CBZ)
|
||||
}
|
||||
|
||||
fun encryptCbz(password: String): String {
|
||||
return encrypt(password, encryptionCipherCbz)
|
||||
}
|
||||
|
||||
fun getDecryptedPasswordCbz(): CharArray {
|
||||
return decrypt(securityPreferences.cbzPassword().get(), ALIAS_CBZ).toCharArray()
|
||||
}
|
||||
|
||||
private fun generateAndEncryptSqlPw() {
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
val password = (1..32).map {
|
||||
charPool[SecureRandom().nextInt(charPool.size)]
|
||||
}.joinToString("", transform = { it.toString() })
|
||||
securityPreferences.sqlPassword().set(encrypt(password, encryptionCipherSql))
|
||||
}
|
||||
|
||||
fun getDecryptedPasswordSql(): ByteArray {
|
||||
if (securityPreferences.sqlPassword().get().isBlank()) generateAndEncryptSqlPw()
|
||||
return decrypt(securityPreferences.sqlPassword().get(), ALIAS_SQL).toByteArray()
|
||||
}
|
||||
|
||||
/** Function that returns true when the supplied password
|
||||
* can Successfully decrypt the supplied zip archive */
|
||||
// not very elegant but this is the solution recommended by the maintainer for checking passwords
|
||||
// a real password check will likely be implemented in the future though
|
||||
fun checkCbzPassword(zip4j: ZipFile, password: CharArray): Boolean {
|
||||
try {
|
||||
zip4j.setPassword(password)
|
||||
zip4j.use { zip ->
|
||||
zip.getInputStream(zip.fileHeaders.firstOrNull())
|
||||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.WARN) { "Wrong CBZ archive password for: ${zip4j.file.name} in: ${zip4j.file.parentFile?.name}" }
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isPasswordSet(): Boolean {
|
||||
return securityPreferences.cbzPassword().get().isNotEmpty()
|
||||
}
|
||||
|
||||
fun isPasswordSetState(scope: CoroutineScope): StateFlow<Boolean> {
|
||||
return securityPreferences.cbzPassword().changes()
|
||||
.map { it.isNotEmpty() }
|
||||
.stateIn(scope, SharingStarted.Eagerly, false)
|
||||
}
|
||||
|
||||
fun getPasswordProtectDlPref(): Boolean {
|
||||
return securityPreferences.passwordProtectDownloads().get()
|
||||
}
|
||||
|
||||
fun createComicInfoPadding(): String? {
|
||||
return if (getPasswordProtectDlPref()) {
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
List(SecureRandom().nextInt(100) + 42) { charPool.random() }.joinToString("")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun setZipParametersEncrypted(zipParameters: ZipParameters) {
|
||||
zipParameters.isEncryptFiles = true
|
||||
|
||||
when (securityPreferences.encryptionType().get()) {
|
||||
SecurityPreferences.EncryptionType.AES_256 -> {
|
||||
zipParameters.encryptionMethod = EncryptionMethod.AES
|
||||
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_256
|
||||
}
|
||||
SecurityPreferences.EncryptionType.AES_192 -> {
|
||||
zipParameters.encryptionMethod = EncryptionMethod.AES
|
||||
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_192
|
||||
}
|
||||
SecurityPreferences.EncryptionType.AES_128 -> {
|
||||
zipParameters.encryptionMethod = EncryptionMethod.AES
|
||||
zipParameters.aesKeyStrength = AesKeyStrength.KEY_STRENGTH_128
|
||||
}
|
||||
SecurityPreferences.EncryptionType.ZIP_STANDARD -> {
|
||||
zipParameters.encryptionMethod = EncryptionMethod.ZIP_STANDARD
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocalCoverCache(context: Context) {
|
||||
if (context.getExternalFilesDir(LOCAL_CACHE_DIR)?.exists() == true) {
|
||||
context.getExternalFilesDir(LOCAL_CACHE_DIR)?.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteLocalCoverSystemFiles(context: Context) {
|
||||
val baseFolderLocation = "${context.getString(R.string.app_name)}${File.separator}local"
|
||||
|
||||
DiskUtil.getExternalStorages(context)
|
||||
.map { File(it.absolutePath, baseFolderLocation) }
|
||||
.asSequence()
|
||||
.flatMap { it.listFiles().orEmpty().toList() }
|
||||
.filter { it.isDirectory }
|
||||
.flatMap { it.listFiles().orEmpty().toList() }
|
||||
.filter { it.name == ".cacheCoverInternal" || it.name == ".nocover" }
|
||||
.forEach { it.delete() }
|
||||
}
|
||||
}
|
||||
|
||||
private const val BUFFER_SIZE = 2048
|
||||
private const val KEY_SIZE = 256
|
||||
private const val IV_SIZE = 16
|
||||
|
||||
private const val ALGORITHM = KeyProperties.KEY_ALGORITHM_AES
|
||||
private const val BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC
|
||||
private const val PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7
|
||||
private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING"
|
||||
|
||||
private const val KEYSTORE = "AndroidKeyStore"
|
||||
private const val ALIAS_CBZ = "cbzPw"
|
||||
private const val ALIAS_SQL = "sqlPw"
|
||||
|
||||
private const val LOCAL_CACHE_DIR = "covers/local"
|
||||
|
||||
// SY <--
|
||||
@@ -23,15 +23,20 @@ import androidx.core.graphics.createBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.green
|
||||
import androidx.core.graphics.red
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import com.hippo.unifile.UniFile
|
||||
import logcat.LogPriority
|
||||
import net.lingala.zip4j.ZipFile
|
||||
import net.lingala.zip4j.model.FileHeader
|
||||
import tachiyomi.decoder.Format
|
||||
import tachiyomi.decoder.ImageDecoder
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.InputStream
|
||||
import java.net.URLConnection
|
||||
import java.security.SecureRandom
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -122,8 +127,18 @@ object ImageUtil {
|
||||
*
|
||||
* @return true if the width is greater than the height
|
||||
*/
|
||||
fun isWideImage(imageStream: BufferedInputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream)
|
||||
fun isWideImage(
|
||||
imageStream: BufferedInputStream,
|
||||
zip4jFile: ZipFile? = null,
|
||||
zip4jEntry: FileHeader? = null,
|
||||
): Boolean {
|
||||
val options = extractImageOptions(
|
||||
imageStream,
|
||||
// SY -->
|
||||
zip4jFile,
|
||||
zip4jEntry,
|
||||
// SY <--
|
||||
)
|
||||
return options.outWidth > options.outHeight
|
||||
}
|
||||
|
||||
@@ -248,16 +263,45 @@ object ImageUtil {
|
||||
*
|
||||
* @return true if the height:width ratio is greater than 3.
|
||||
*/
|
||||
private fun isTallImage(imageStream: InputStream): Boolean {
|
||||
val options = extractImageOptions(imageStream, resetAfterExtraction = false)
|
||||
private fun isTallImage(
|
||||
imageStream: InputStream,
|
||||
// SY -->
|
||||
zip4jFile: ZipFile? = null,
|
||||
zip4jEntry: FileHeader? = null,
|
||||
// SY <--
|
||||
): Boolean {
|
||||
val options = extractImageOptions(
|
||||
imageStream,
|
||||
// SY -->
|
||||
zip4jFile,
|
||||
zip4jEntry,
|
||||
// SY <--
|
||||
resetAfterExtraction = false,
|
||||
)
|
||||
|
||||
return (options.outHeight / options.outWidth) > 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits tall images to improve performance of reader
|
||||
*/
|
||||
fun splitTallImage(tmpDir: UniFile, imageFile: UniFile, filenamePrefix: String): Boolean {
|
||||
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(imageFile.openInputStream())) {
|
||||
fun splitTallImage(
|
||||
tmpDir: UniFile,
|
||||
imageFile: UniFile,
|
||||
filenamePrefix: String,
|
||||
// SY -->
|
||||
zip4jFile: ZipFile? = null,
|
||||
zip4jEntry: FileHeader? = null,
|
||||
// SY <--
|
||||
): Boolean {
|
||||
if (isAnimatedAndSupported(imageFile.openInputStream()) || !isTallImage(
|
||||
imageFile.openInputStream(),
|
||||
// SY -->
|
||||
zip4jFile,
|
||||
zip4jEntry,
|
||||
// SY <--
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -267,7 +311,14 @@ object ImageUtil {
|
||||
return false
|
||||
}
|
||||
|
||||
val options = extractImageOptions(imageFile.openInputStream(), resetAfterExtraction = false).apply {
|
||||
val options = extractImageOptions(
|
||||
imageFile.openInputStream(),
|
||||
// SY -->
|
||||
zip4jFile,
|
||||
zip4jEntry,
|
||||
// SY <--
|
||||
resetAfterExtraction = false,
|
||||
).apply {
|
||||
inJustDecodeBounds = false
|
||||
}
|
||||
|
||||
@@ -313,10 +364,22 @@ object ImageUtil {
|
||||
* Check whether the image is a long Strip that needs splitting
|
||||
* @return true if the image is not animated and it's height is greater than image width and screen height
|
||||
*/
|
||||
fun isStripSplitNeeded(imageStream: BufferedInputStream): Boolean {
|
||||
fun isStripSplitNeeded(
|
||||
imageStream: BufferedInputStream,
|
||||
// SY -->
|
||||
zip4jFile: ZipFile? = null,
|
||||
zip4jEntry: FileHeader? = null,
|
||||
// SY <--
|
||||
): Boolean {
|
||||
if (isAnimatedAndSupported(imageStream)) return false
|
||||
|
||||
val options = extractImageOptions(imageStream)
|
||||
val options = extractImageOptions(
|
||||
imageStream,
|
||||
// SY -->
|
||||
zip4jFile,
|
||||
zip4jEntry,
|
||||
// SY <--
|
||||
)
|
||||
val imageHeightIsBiggerThanWidth = options.outHeight > options.outWidth
|
||||
val imageHeightBiggerThanScreenHeight = options.outHeight > optimalImageHeight
|
||||
return imageHeightIsBiggerThanWidth && imageHeightBiggerThanScreenHeight
|
||||
@@ -348,8 +411,21 @@ object ImageUtil {
|
||||
}
|
||||
}
|
||||
|
||||
fun getSplitDataForStream(imageStream: InputStream): List<SplitData> {
|
||||
return extractImageOptions(imageStream).splitData
|
||||
fun getSplitDataForStream(
|
||||
imageStream: InputStream,
|
||||
// SY -->
|
||||
zip4jFile: ZipFile? = null,
|
||||
zip4jEntry: FileHeader? = null,
|
||||
// SY <--
|
||||
|
||||
): List<SplitData> {
|
||||
// SY -->
|
||||
return extractImageOptions(
|
||||
imageStream,
|
||||
zip4jFile,
|
||||
zip4jEntry,
|
||||
).splitData
|
||||
// <--
|
||||
}
|
||||
|
||||
private val BitmapFactory.Options.splitData
|
||||
@@ -614,8 +690,17 @@ object ImageUtil {
|
||||
*/
|
||||
private fun extractImageOptions(
|
||||
imageStream: InputStream,
|
||||
// SY -->
|
||||
zip4jFile: ZipFile? = null,
|
||||
zip4jEntry: FileHeader? = null,
|
||||
// SY <--
|
||||
resetAfterExtraction: Boolean = true,
|
||||
|
||||
): BitmapFactory.Options {
|
||||
// SY -->
|
||||
// zip4j does currently not support mark() and reset()
|
||||
if (zip4jFile != null && zip4jEntry != null) return extractImageOptionsZip4j(zip4jFile, zip4jEntry)
|
||||
// SY <--
|
||||
imageStream.mark(imageStream.available() + 1)
|
||||
|
||||
val imageBytes = imageStream.readBytes()
|
||||
@@ -625,6 +710,33 @@ object ImageUtil {
|
||||
return options
|
||||
}
|
||||
|
||||
// SY -->
|
||||
private fun extractImageOptionsZip4j(zip4jFile: ZipFile?, zip4jEntry: FileHeader?): BitmapFactory.Options {
|
||||
zip4jFile?.getInputStream(zip4jEntry).use { imageStream ->
|
||||
val imageBytes = imageStream?.readBytes()
|
||||
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
imageBytes?.size?.let { BitmapFactory.decodeByteArray(imageBytes, 0, it, options) }
|
||||
return options
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates random exif metadata used as padding to make
|
||||
* the size of files inside CBZ archives unique
|
||||
*/
|
||||
fun addPaddingToImageExif(imageFile: File) {
|
||||
try {
|
||||
val charPool: List<Char> = ('a'..'z') + ('A'..'Z') + ('0'..'9')
|
||||
val padding = List(SecureRandom().nextInt(16384) + 16384) { charPool.random() }.joinToString("")
|
||||
val exif = ExifInterface(imageFile.absolutePath)
|
||||
exif.setAttribute("UserComment", padding)
|
||||
exif.saveAttributes()
|
||||
} catch (e: Exception) {
|
||||
logcat(LogPriority.ERROR, e)
|
||||
}
|
||||
}
|
||||
// SY <--
|
||||
|
||||
private fun getBitmapRegionDecoder(imageStream: InputStream): BitmapRegionDecoder? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
BitmapRegionDecoder.newInstance(imageStream)
|
||||
|
||||
Reference in New Issue
Block a user