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:
Shamicen
2023-05-06 17:06:54 +02:00
committed by GitHub
parent 514e061dd9
commit 88f076afd4
22 changed files with 970 additions and 86 deletions
@@ -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)