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 package eu.kanade.presentation.more.settings.screen
import android.widget.Toast
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -25,7 +24,6 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.storage.CbzCrypto
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.authenticate
import eu.kanade.tachiyomi.util.system.AuthenticatorUtil.isAuthenticationSupported 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.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.io.File
object SettingsSecurityScreen : SearchableSettings { object SettingsSecurityScreen : SearchableSettings {
@ -168,45 +161,6 @@ object SettingsSecurityScreen : SearchableSettings {
}, },
enabled = isCbzPasswordSet, 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 { kotlin.run {
val navigator = LocalNavigator.currentOrThrow val navigator = LocalNavigator.currentOrThrow
val count by securityPreferences.authenticatorTimeRanges().collectAsState() val count by securityPreferences.authenticatorTimeRanges().collectAsState()

View File

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

View File

@ -9,6 +9,9 @@ import coil.decode.ImageDecoderDecoder
import coil.decode.ImageSource import coil.decode.ImageSource
import coil.fetch.SourceResult import coil.fetch.SourceResult
import coil.request.Options 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 okio.BufferedSource
import tachiyomi.core.util.system.ImageUtil import tachiyomi.core.util.system.ImageUtil
import tachiyomi.decoder.ImageDecoder import tachiyomi.decoder.ImageDecoder
@ -19,9 +22,24 @@ import tachiyomi.decoder.ImageDecoder
class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder { class TachiyomiImageDecoder(private val resources: ImageSource, private val options: Options) : Decoder {
override suspend fun decode(): DecodeResult { override suspend fun decode(): DecodeResult {
val decoder = resources.sourceOrNull()?.use { // SY -->
ImageDecoder.newInstance(it.inputStream()) 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" } 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 { private fun isApplicable(source: BufferedSource): Boolean {
val type = source.peek().inputStream().use { val type = source.peek().inputStream().use {
// SY -->
if (CbzCrypto.detectCoverImageArchive(it)) return true
// SY <--
ImageUtil.findImageType(it) ImageUtil.findImageType(it)
} }
return when (type) { return when (type) {

View File

@ -1,13 +1,11 @@
package eu.kanade.tachiyomi.core.security package eu.kanade.tachiyomi.core.security
import android.content.Context
import eu.kanade.tachiyomi.core.R import eu.kanade.tachiyomi.core.R
import tachiyomi.core.preference.PreferenceStore import tachiyomi.core.preference.PreferenceStore
import tachiyomi.core.preference.getEnum import tachiyomi.core.preference.getEnum
class SecurityPreferences( class SecurityPreferences(
private val preferenceStore: PreferenceStore, private val preferenceStore: PreferenceStore,
private val context: Context,
) { ) {
fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false) fun useAuthenticator() = preferenceStore.getBoolean("use_biometric_lock", false)
@ -23,7 +21,7 @@ class SecurityPreferences(
fun authenticatorDays() = this.preferenceStore.getInt("biometric_days", 0x7F) 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", "") fun sqlPassword() = this.preferenceStore.getString("sql_password", "")
@ -32,9 +30,6 @@ class SecurityPreferences(
fun encryptionType() = this.preferenceStore.getEnum("encryption_type", EncryptionType.AES_256) fun encryptionType() = this.preferenceStore.getEnum("encryption_type", EncryptionType.AES_256)
fun cbzPassword() = this.preferenceStore.getString("cbz_password", "") fun cbzPassword() = this.preferenceStore.getString("cbz_password", "")
fun localCoverLocation() = this.preferenceStore.getEnum("local_cover_location", CoverCacheLocation.IN_MANGA_DIRECTORY)
// SY <-- // SY <--
/** /**
@ -56,11 +51,5 @@ class SecurityPreferences(
AES_128(R.string.aes_128), AES_128(R.string.aes_128),
ZIP_STANDARD(R.string.standard_zip_encryption), 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 <-- // SY <--
} }

View File

@ -1,10 +1,8 @@
package eu.kanade.tachiyomi.util.storage package eu.kanade.tachiyomi.util.storage
import android.content.Context
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties import android.security.keystore.KeyProperties
import android.util.Base64 import android.util.Base64
import eu.kanade.tachiyomi.core.R
import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -20,7 +18,7 @@ import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.InputStream
import java.security.KeyStore import java.security.KeyStore
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Cipher import javax.crypto.Cipher
@ -36,6 +34,7 @@ import javax.crypto.spec.IvParameterSpec
*/ */
object CbzCrypto { object CbzCrypto {
const val DATABASE_NAME = "tachiyomiEncrypted.db" const val DATABASE_NAME = "tachiyomiEncrypted.db"
const val DEFAULT_COVER_NAME = "cover.jpg"
private val securityPreferences: SecurityPreferences by injectLazy() private val securityPreferences: SecurityPreferences by injectLazy()
private val keyStore = KeyStore.getInstance(KEYSTORE).apply { private val keyStore = KeyStore.getInstance(KEYSTORE).apply {
load(null) load(null)
@ -210,23 +209,15 @@ object CbzCrypto {
} }
} }
fun deleteLocalCoverCache(context: Context) { fun detectCoverImageArchive(stream: InputStream): Boolean {
if (context.getExternalFilesDir(LOCAL_CACHE_DIR)?.exists() == true) { val bytes = ByteArray(128)
context.getExternalFilesDir(LOCAL_CACHE_DIR)?.deleteRecursively() if (stream.markSupported()) {
stream.mark(bytes.size)
stream.read(bytes, 0, bytes.size).also { stream.reset() }
} else {
stream.read(bytes, 0, bytes.size)
} }
} return String(bytes).contains(DEFAULT_COVER_NAME, ignoreCase = true)
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() }
} }
} }
@ -242,7 +233,4 @@ private const val CRYPTO_SETTINGS = "$ALGORITHM/$BLOCK_MODE/$PADDING"
private const val KEYSTORE = "AndroidKeyStore" private const val KEYSTORE = "AndroidKeyStore"
private const val ALIAS_CBZ = "cbzPw" private const val ALIAS_CBZ = "cbzPw"
private const val ALIAS_SQL = "sqlPw" private const val ALIAS_SQL = "sqlPw"
private const val LOCAL_CACHE_DIR = "covers/local"
// SY <-- // SY <--

View File

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

View File

@ -230,14 +230,6 @@
<string name="password_protect_downloads">Password protect downloads</string> <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="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="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="cbz_archive_password">CBZ archive password</string>
<string name="wrong_cbz_archive_password">Wrong CBZ archive password</string> <string name="wrong_cbz_archive_password">Wrong CBZ archive password</string>
<string name="encryption_type">Encryption type</string> <string name="encryption_type">Encryption type</string>

View File

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

View File

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