Implemented local cover encryption (#881)

* Implemented local cover encryption and made coil capable of reading encrypted cover archives

* add check that the file is not an image before determining that it is a zip file
This commit is contained in:
Shamicen 2023-05-13 04:51:37 +02:00 committed by GitHub
parent 282a0c4e16
commit 291734a406
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 81 additions and 154 deletions

View File

@ -1,6 +1,5 @@
package eu.kanade.presentation.more.settings.screen
import android.widget.Toast
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
@ -25,7 +24,6 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@ -57,13 +55,8 @@ import eu.kanade.tachiyomi.ui.category.biometric.BiometricTimesScreen
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported
import eu.kanade.tachiyomi.util.system.toast
import logcat.LogPriority
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
object SettingsSecurityScreen : SearchableSettings {
@ -168,45 +161,6 @@ object SettingsSecurityScreen : SearchableSettings {
},
enabled = isCbzPasswordSet,
),
Preference.PreferenceItem.ListPreference(
pref = securityPreferences.localCoverLocation(),
title = stringResource(R.string.save_local_manga_covers),
entries = SecurityPreferences.CoverCacheLocation.values()
.associateWith { stringResource(it.titleResId) },
enabled = passwordProtectDownloads,
onValueChanged = {
try {
withIOContext {
CbzCrypto.deleteLocalCoverCache(context)
CbzCrypto.deleteLocalCoverSystemFiles(context)
}
true
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
context.toast(e.toString(), Toast.LENGTH_SHORT).show()
false
}
},
),
Preference.PreferenceItem.TextPreference(
title = stringResource(R.string.delete_cached_local_source_covers),
subtitle = stringResource(R.string.delete_cached_local_source_covers_subtitle),
onClick = {
try {
CbzCrypto.deleteLocalCoverCache(context)
CbzCrypto.deleteLocalCoverSystemFiles(context)
context.toast(R.string.successfully_deleted_all_locally_cached_covers, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
logcat(LogPriority.ERROR, e)
context.toast(R.string.something_went_wrong_deleting_your_cover_images, Toast.LENGTH_LONG).show()
}
},
enabled = produceState(false) {
withIOContext {
value = context.getExternalFilesDir("covers/local")?.absolutePath?.let { File(it).listFiles()?.isNotEmpty() } == true
}
}.value,
),
kotlin.run {
val navigator = LocalNavigator.currentOrThrow
val count by securityPreferences.authenticatorTimeRanges().collectAsState()

View File

@ -214,7 +214,7 @@ class PreferenceModule(val application: Application) : InjektModule {
SourcePreferences(get())
}
addSingletonFactory {
SecurityPreferences(get(), application.applicationContext)
SecurityPreferences(get())
}
addSingletonFactory {
LibraryPreferences(get())

View File

@ -9,6 +9,9 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.ImageSource
import coil.fetch.SourceResult
import coil.request.Options
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.FileHeader
import okio.BufferedSource
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.decoder.ImageDecoder
@ -19,9 +22,24 @@ import tachiyomi.decoder.ImageDecoder
class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
override suspend fun decode(): DecodeResult {
val decoder = resources.sourceOrNull()?.use {
ImageDecoder.newInstance(it.inputStream())
// SY -->
var zip4j: ZipFile? = null
var entry: FileHeader? = null
if (resources.sourceOrNull()?.peek()?.use { CbzCrypto.detectCoverImageArchive(it.inputStream()) } == true) {
if (resources.source().peek().use { ImageUtil.findImageType(it.inputStream()) == null }) {
zip4j = ZipFile(resources.file().toFile().absolutePath)
entry = zip4j.fileHeaders.firstOrNull { it.fileName.equals(CbzCrypto.DEFAULT_COVER_NAME, ignoreCase = true) }
if (zip4j.isEncrypted) zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
}
}
val decoder = resources.sourceOrNull()?.use {
zip4j.use { zipFile ->
ImageDecoder.newInstance(zipFile?.getInputStream(entry) ?: it.inputStream())
}
}
// SY <--
check(decoder != null && decoder.width > 0 && decoder.height > 0) { "Failed to initialize decoder" }
@ -45,6 +63,9 @@ class TachiyomiImageDecoder(private val resources: ImageSource, private val opti
private fun isApplicable(source: BufferedSource): Boolean {
val type = source.peek().inputStream().use {
// SY -->
if (CbzCrypto.detectCoverImageArchive(it)) return true
// SY <--
ImageUtil.findImageType(it)
}
return when (type) {

View File

@ -1,13 +1,11 @@
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)
@ -23,7 +21,7 @@ class SecurityPreferences(
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F)
fun encryptDatabase() = this.preferenceStore.getBoolean("encrypt_database", !context.getDatabasePath("tachiyomi.db").exists())
fun encryptDatabase() = this.preferenceStore.getBoolean("encrypt_database", false)
fun sqlPassword() = this.preferenceStore.getString("sql_password", "")
@ -32,9 +30,6 @@ class SecurityPreferences(
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 <--
/**
@ -56,11 +51,5 @@ class SecurityPreferences(
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 <--
}

View File

@ -1,10 +1,8 @@
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
@ -20,7 +18,7 @@ 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.io.InputStream
import java.security.KeyStore
import java.security.SecureRandom
import javax.crypto.Cipher
@ -36,6 +34,7 @@ import javax.crypto.spec.IvParameterSpec
*/
object CbzCrypto {
const val DATABASE_NAME = "tachiyomiEncrypted.db"
const val DEFAULT_COVER_NAME = "cover.jpg"
private val securityPreferences: SecurityPreferences by injectLazy()
private val keyStore = KeyStore.getInstance(KEYSTORE).apply {
load(null)
@ -210,23 +209,15 @@ object CbzCrypto {
}
}
fun deleteLocalCoverCache(context: Context) {
if (context.getExternalFilesDir(LOCAL_CACHE_DIR)?.exists() == true) {
context.getExternalFilesDir(LOCAL_CACHE_DIR)?.deleteRecursively()
fun detectCoverImageArchive(stream: InputStream): Boolean {
val bytes = ByteArray(128)
if (stream.markSupported()) {
stream.mark(bytes.size)
stream.read(bytes, 0, bytes.size).also { stream.reset() }
} else {
stream.read(bytes, 0, bytes.size)
}
}
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() }
return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
}
}
@ -242,7 +233,4 @@ 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 <--

View File

@ -42,6 +42,10 @@ import kotlin.math.min
object ImageUtil {
fun isImage(name: String, openStream: (() -> InputStream)? = null): Boolean {
// SY -->
if (File(name).extension.equals("cbi", ignoreCase = true)) return true
// SY <--
val contentType = try {
URLConnection.guessContentTypeFromName(name)
} catch (e: Exception) {

View File

@ -230,14 +230,6 @@
<string name="password_protect_downloads">Password protect downloads</string>
<string name="password_protect_downloads_summary">Encrypts CBZ archive downloads with the given password.\nWARNING: DATA INSIDE THE ARCHIVES WILL BE LOST FOREVER IF YOU FORGET THE PASSWORD</string>
<string name="delete_cbz_archive_password">Delete CBZ archive password</string>
<string name="save_in_manga_directory">In manga directory</string>
<string name="save_internally">Internally</string>
<string name="save_never">Never (local source manga won\'t have covers)</string>
<string name="save_local_manga_covers">Save local manga covers</string>
<string name="delete_cached_local_source_covers">Delete cached local source covers</string>
<string name="delete_cached_local_source_covers_subtitle">Internally cached local source manga covers are NOT deleted automatically Please delete them here then open the local source extension to generate them again</string>
<string name="successfully_deleted_all_locally_cached_covers">Successfully deleted all locally cached covers</string>
<string name="something_went_wrong_deleting_your_cover_images">Something went wrong deleting your cover images: </string>
<string name="cbz_archive_password">CBZ archive password</string>
<string name="wrong_cbz_archive_password">Wrong CBZ archive password</string>
<string name="encryption_type">Encryption type</string>

View File

@ -390,13 +390,16 @@ actual class LocalSource(
is Format.Zip -> {
ZipFile(format.file).use { zip ->
// SY -->
if (zip.isEncrypted) zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
var encrypted = false
if (zip.isEncrypted) {
zip.setPassword(CbzCrypto.getDecryptedPasswordCbz())
encrypted = true
}
val entry = zip.fileHeaders.toList()
.sortedWith { f1, f2 -> f1.fileName.compareToCaseInsensitiveNaturalOrder(f2.fileName) }
.find { !it.isDirectory && ImageUtil.isImage(it.fileName) { zip.getInputStream(it) } }
entry?.let { coverManager.update(manga, zip.getInputStream(it), encrypted) }
// SY <--
entry?.let { coverManager.update(manga, zip.getInputStream(it)) }
}
}
is Format.Rar -> {

View File

@ -2,58 +2,40 @@ package tachiyomi.source.local.image
import android.content.Context
import com.hippo.unifile.UniFile
import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.storage.CbzCrypto
import eu.kanade.tachiyomi.util.storage.DiskUtil
import net.lingala.zip4j.ZipFile
import net.lingala.zip4j.model.ZipParameters
import tachiyomi.core.util.system.ImageUtil
import tachiyomi.source.local.io.LocalSourceFileSystem
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.File
import java.io.InputStream
private const val DEFAULT_COVER_NAME = "cover.jpg"
// SY -->
private const val NO_COVER_FILE = ".nocover"
private const val CACHE_COVER_INTERNAL = ".cacheCoverInternal"
private const val LOCAL_CACHE_DIR = "covers/local"
// SY <--
private const val COVER_ARCHIVE_NAME = "cover.cbi"
actual class LocalCoverManager(
private val context: Context,
private val fileSystem: LocalSourceFileSystem,
// SY -->
private val coverCacheDir: File? = context.getExternalFilesDir(LOCAL_CACHE_DIR),
private val securityPreferences: SecurityPreferences = Injekt.get(),
// SY <--
) {
actual fun find(mangaUrl: String): File? {
return fileSystem.getFilesInMangaDirectory(mangaUrl)
// Get all file whose names start with 'cover'
// --> SY
.filter { (it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true)) || it.name == NO_COVER_FILE || it.name == CACHE_COVER_INTERNAL }
.filter { it.isFile && it.nameWithoutExtension.equals("cover", ignoreCase = true) }
// Get the first actual image
.firstOrNull {
if (it.name != NO_COVER_FILE && it.name != CACHE_COVER_INTERNAL) {
ImageUtil.isImage(it.name) { it.inputStream() }
} else if (it.name == NO_COVER_FILE) {
true
} else if (it.name == CACHE_COVER_INTERNAL) {
return File("$coverCacheDir/${it.parentFile?.name}/$DEFAULT_COVER_NAME")
} else {
false
}
// SY <--
ImageUtil.isImage(it.name) { it.inputStream() } || it.name == COVER_ARCHIVE_NAME
}
}
actual fun update(
manga: SManga,
inputStream: InputStream,
// SY -->
encrypted: Boolean,
// SY <--
): File? {
val directory = fileSystem.getMangaDirectory(manga.url)
if (directory == null) {
@ -64,48 +46,40 @@ actual class LocalCoverManager(
var targetFile = find(manga.url)
if (targetFile == null) {
// SY -->
targetFile = when (securityPreferences.localCoverLocation().get()) {
SecurityPreferences.CoverCacheLocation.INTERNAL -> File(directory.absolutePath, CACHE_COVER_INTERNAL)
SecurityPreferences.CoverCacheLocation.NEVER -> File(directory.absolutePath, NO_COVER_FILE)
SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY -> File(directory.absolutePath, DEFAULT_COVER_NAME)
if (encrypted) {
targetFile = File(directory.absolutePath, COVER_ARCHIVE_NAME)
} else {
targetFile = File(directory.absolutePath, DEFAULT_COVER_NAME)
targetFile.createNewFile()
}
if (targetFile.parentFile?.parentFile?.name != "local") targetFile.parentFile?.mkdirs()
targetFile.createNewFile()
// SY <--
}
if (targetFile.name == NO_COVER_FILE) return null
if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.IN_MANGA_DIRECTORY) {
// SY <--
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input ->
// It might not exist at this point
targetFile.parentFile?.mkdirs()
inputStream.use { input ->
// SY -->
if (encrypted) {
val zip4j = ZipFile(targetFile)
val zipParameters = ZipParameters()
zip4j.setPassword(CbzCrypto.getDecryptedPasswordCbz())
CbzCrypto.setZipParametersEncrypted(zipParameters)
zipParameters.fileNameInZip = DEFAULT_COVER_NAME
zip4j.addStream(input, zipParameters)
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
manga.thumbnail_url = zip4j.file.absolutePath
return zip4j.file
} else {
// SY <--
targetFile.outputStream().use { output ->
input.copyTo(output)
}
DiskUtil.createNoMediaFile(UniFile.fromFile(directory), context)
manga.thumbnail_url = targetFile.absolutePath
return targetFile
// SY -->
}
} else if (securityPreferences.localCoverLocation().get() == SecurityPreferences.CoverCacheLocation.INTERNAL) {
// It might not exist at this point
targetFile.parentFile?.mkdirs()
val path = "$coverCacheDir/${targetFile.parentFile?.name}/$DEFAULT_COVER_NAME"
val outputFile = File(path)
outputFile.parentFile?.mkdirs()
outputFile.createNewFile()
inputStream.use { input ->
outputFile.outputStream().use { output ->
input.copyTo(output)
}
}
manga.thumbnail_url = outputFile.absolutePath
return outputFile
} else {
return null
}
// SY <--
}
}

View File

@ -8,5 +8,7 @@ expect class LocalCoverManager {
fun find(mangaUrl: String): File?
fun update(manga: SManga, inputStream: InputStream): File?
// SY -->
fun update(manga: SManga, inputStream: InputStream, encrypted: Boolean = false): File?
// SY <--
}