diff --git a/build.gradle.kts b/build.gradle.kts index b3157c9e..8724ce7f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,6 +9,7 @@ group = "app.revanced" repositories { mavenCentral() + google() mavenLocal() maven { url = uri("https://maven.pkg.github.com/revanced/multidexlib2") @@ -27,7 +28,7 @@ dependencies { implementation("app.revanced:revanced-patcher:1.0.0") implementation("info.picocli:picocli:4.6.3") - + implementation("com.android.tools.build:apksig:7.2.1") implementation("com.github.li-wjohnson:jadb:master-SNAPSHOT") // using a fork instead. implementation("org.bouncycastle:bcpkix-jdk15on:1.70") implementation("org.jetbrains.kotlin:kotlin-reflect:1.6.21") diff --git a/src/main/kotlin/app/revanced/cli/MainCommand.kt b/src/main/kotlin/app/revanced/cli/MainCommand.kt deleted file mode 100644 index b8ab9c42..00000000 --- a/src/main/kotlin/app/revanced/cli/MainCommand.kt +++ /dev/null @@ -1,96 +0,0 @@ -package app.revanced.cli - -import app.revanced.patcher.PatcherOptions -import app.revanced.patcher.annotation.Name -import app.revanced.patcher.util.patch.implementation.JarPatchBundle -import app.revanced.utils.adb.Adb -import app.revanced.utils.patcher.addPatchesFiltered -import app.revanced.utils.signature.Signature -import picocli.CommandLine.* -import java.io.File - -@Command( - name = "ReVanced-CLI", version = ["1.0.0"], mixinStandardHelpOptions = true -) -internal object MainCommand : Runnable { - @Parameters( - paramLabel = "INCLUDE", - description = ["Which patches to include. If none is specified, all compatible patches will be included"] - ) - internal var includedPatches = arrayOf() - - @Option(names = ["-p", "--patches"], description = ["One or more bundles of patches"]) - internal var patchBundles = arrayOf() - - @Option(names = ["-t", "--temp-dir"], description = ["Temporal resource cache directory"]) - internal var cacheDirectory = "revanced-cache" - - @Option(names = ["-r", "--resource-patcher"], description = ["Disable patching resources"]) - internal var disableResourcePatching: Boolean = false - - @Option( - names = ["-c", "--clean"], - description = ["Clean the temporal resource cache directory. This will be done anyways when running the patcher"] - ) - internal var clean: Boolean = false - - @Option(names = ["-l", "--list"], description = ["List patches only"]) - internal var listOnly: Boolean = false - - @Option(names = ["-s", "--signature-checker"], description = ["Check signatures of all patches"]) - internal var signatureCheck: Boolean = false - - @Option(names = ["-m", "--merge"], description = ["One or more dex file containers to merge"]) - internal var mergeFiles = listOf() - - @Option(names = ["-a", "--apk"], description = ["Input file to be patched"], required = true) - internal lateinit var inputFile: File - - @Option(names = ["-o", "--out"], description = ["Output file path"], required = true) - internal lateinit var outputPath: String - - @Option(names = ["-d", "--deploy-on"], description = ["If specified, deploy to adb device with given name"]) - internal var deploy: String? = null - - @Option(names = ["-b", "--debugging"], description = ["Disable patch version compatibility"]) - internal var debugging: Boolean = false - - override fun run() { - if (listOnly) { - for (patchBundlePath in patchBundles) for (it in JarPatchBundle(patchBundlePath).loadPatches()) { - - // TODO: adjust extension methods to be able to do this - val name = (it.annotations.find { it is Name } as? Name)?.name ?: it.simpleName - println( - "[available] $name" - ) - } - return - } - - val patcher = app.revanced.patcher.Patcher(PatcherOptions(inputFile, cacheDirectory, !disableResourcePatching)) - - if (signatureCheck) { - patcher.addPatchesFiltered() - Signature.checkSignatures(patcher) - return - } - - val outputFile = File(outputPath) - - var adb: Adb? = null - deploy?.let { - adb = Adb( - outputFile, patcher.packageName, deploy!! - ) - } - - Patcher.start(patcher) - - if (clean) File(cacheDirectory).deleteRecursively() - - adb?.deploy() - - if (clean) outputFile.delete() - } -} diff --git a/src/main/kotlin/app/revanced/cli/Patcher.kt b/src/main/kotlin/app/revanced/cli/Patcher.kt deleted file mode 100644 index f6d7589c..00000000 --- a/src/main/kotlin/app/revanced/cli/Patcher.kt +++ /dev/null @@ -1,55 +0,0 @@ -package app.revanced.cli - -import app.revanced.utils.filesystem.FileSystemUtils -import app.revanced.utils.patcher.addPatchesFiltered -import app.revanced.utils.patcher.applyPatchesPrint -import app.revanced.utils.patcher.mergeFiles -import app.revanced.utils.signing.Signer -import java.io.File -import java.io.FileFilter - -internal class Patcher { - internal companion object { - internal fun start(patcher: app.revanced.patcher.Patcher) { - // merge files like necessary integrations - patcher.mergeFiles() - // add patches, but filter incompatible or excluded patches - patcher.addPatchesFiltered(includeFilter = MainCommand.includedPatches.isNotEmpty()) - // apply patches - patcher.applyPatchesPrint() - - // write output file - val outFile = File(MainCommand.outputPath) - if (outFile.exists()) outFile.delete() - MainCommand.inputFile.copyTo(outFile) - - val zipFileSystem = FileSystemUtils(outFile) - - // replace all dex files - for ((name, data) in patcher.save()) { - zipFileSystem.replaceFile(name, data.data) - } - - if (!MainCommand.disableResourcePatching) { - for (file in File(MainCommand.cacheDirectory).resolve("build/").listFiles(FileFilter { it.isDirectory }) - ?.first()?.listFiles()!!) { - if (!file.isDirectory) { - zipFileSystem.replaceFile(file.name, file.readBytes()) - continue - } - zipFileSystem.replaceDirectory(file) - } - } - - // finally close the stream - zipFileSystem.close() - - // and sign the apk file - Signer.signApk(outFile) - - println("[done]") - } - - - } -} diff --git a/src/main/kotlin/app/revanced/cli/command/MainCommand.kt b/src/main/kotlin/app/revanced/cli/command/MainCommand.kt new file mode 100644 index 00000000..8ae72222 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/command/MainCommand.kt @@ -0,0 +1,109 @@ +package app.revanced.cli.command + +import app.revanced.cli.patcher.Patcher +import app.revanced.cli.signing.Signing +import app.revanced.patcher.PatcherOptions +import app.revanced.patcher.extensions.PatchExtensions.patchName +import app.revanced.patcher.util.patch.implementation.JarPatchBundle +import app.revanced.utils.adb.Adb +import picocli.CommandLine.Command +import picocli.CommandLine.Option +import java.io.File +import java.nio.file.Files + +@Command( + name = "ReVanced-CLI", version = ["1.0.0"], mixinStandardHelpOptions = true, +) +internal object MainCommand : Runnable { + @Option(names = ["-a", "--apk"], description = ["Input file to be patched"], required = true) + lateinit var inputFile: File + + @Option(names = ["-o", "--out"], description = ["Output file path"], required = true) + lateinit var outputPath: String + + @Option( + names = ["-i", "--include"], + description = ["Which patches to include. If none is specified, all compatible default patches will be included"] + ) + var includedPatches = arrayOf() + + @Option(names = ["-r", "--resource-patcher"], description = ["Disable patching resources"]) + var disableResourcePatching: Boolean = false + + @Option(names = ["--debugging"], description = ["Disable patch version compatibility"]) + var debugging: Boolean = false + + @Option(names = ["-m", "--merge"], description = ["One or more dex file containers to merge"]) + var mergeFiles = listOf() + + @Option(names = ["-b", "--bundles"], description = ["One or more bundles of patches"]) + var patchBundles = arrayOf() + + @Option(names = ["-l", "--list"], description = ["List patches only"]) + var listOnly: Boolean = false + + @Option(names = ["--install"], description = ["If specified, instead of mounting, install"]) + var install: Boolean = false + + @Option(names = ["--cn"], description = ["Overwrite the default CN for the signed file"]) + var cn = "ReVanced" + + @Option(names = ["-p", "--password"], description = ["Overwrite the default password for the signed file"]) + var password = "ReVanced" + + @Option(names = ["-d", "--deploy-on"], description = ["If specified, deploy to adb device with given name"]) + var deploy: String? = null + + @Option(names = ["-t", "--temp-dir"], description = ["Temporal resource cache directory"]) + var cacheDirectory = "revanced-cache" + + @Option( + names = ["-c", "--clean"], + description = ["Clean the temporal resource cache directory. This will be done anyways when running the patcher"] + ) + var clean: Boolean = false + + @Option(names = ["--sign"], description = ["Sign the apk file"]) + var signApk: Boolean = false + + override fun run() { + if (listOnly) { + for (patchBundlePath in patchBundles) for (patch in JarPatchBundle(patchBundlePath).loadPatches()) { + println("[available] ${patch.patchName}") + } + return + } + + val patcher = app.revanced.patcher.Patcher(PatcherOptions(inputFile, cacheDirectory, !disableResourcePatching)) + + val outputFile = File(outputPath) + + val adb: Adb? = deploy?.let { + Adb(outputFile, patcher.data.packageMetadata.packageName, deploy!!, install) + } + + val patchedFile = if (signApk) File(cacheDirectory).resolve("raw.apk") else outputFile + + Patcher.start(patcher, patchedFile) + + if (signApk) { + Signing.start( + patchedFile, + outputFile, + cn, + password, + ) + } + + if (clean) File(cacheDirectory).deleteRecursively() + + adb?.let { + println("[deploying]") + it.deploy() + } + + if (clean && deploy != null) Files.delete(outputFile.toPath()) + + println("[done]") + } +} diff --git a/src/main/kotlin/app/revanced/cli/Main.kt b/src/main/kotlin/app/revanced/cli/main/Main.kt similarity index 60% rename from src/main/kotlin/app/revanced/cli/Main.kt rename to src/main/kotlin/app/revanced/cli/main/Main.kt index f62f798d..3fc7b3d0 100644 --- a/src/main/kotlin/app/revanced/cli/Main.kt +++ b/src/main/kotlin/app/revanced/cli/main/Main.kt @@ -1,5 +1,6 @@ -package app.revanced.cli +package app.revanced.cli.main +import app.revanced.cli.command.MainCommand import picocli.CommandLine internal fun main(args: Array) { diff --git a/src/main/kotlin/app/revanced/cli/patcher/Patcher.kt b/src/main/kotlin/app/revanced/cli/patcher/Patcher.kt new file mode 100644 index 00000000..38956763 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/patcher/Patcher.kt @@ -0,0 +1,41 @@ +package app.revanced.cli.patcher + +import app.revanced.cli.command.MainCommand.cacheDirectory +import app.revanced.cli.command.MainCommand.disableResourcePatching +import app.revanced.cli.command.MainCommand +import app.revanced.cli.command.MainCommand.includedPatches +import app.revanced.utils.filesystem.ZipFileSystemUtils +import app.revanced.utils.patcher.addPatchesFiltered +import app.revanced.utils.patcher.applyPatchesVerbose +import app.revanced.utils.patcher.mergeFiles +import java.io.File +import java.nio.file.Files + +internal object Patcher { + internal fun start(patcher: app.revanced.patcher.Patcher, output: File) { + // merge files like necessary integrations + patcher.mergeFiles() + // add patches, but filter incompatible or excluded patches + patcher.addPatchesFiltered(includeFilter = includedPatches.isNotEmpty()) + // apply patches + patcher.applyPatchesVerbose() + + // write output file + if (output.exists()) Files.delete(output.toPath()) + MainCommand.inputFile.copyTo(output) + + ZipFileSystemUtils(output).use { fileSystem -> + // replace all dex files + val result = patcher.save() + result.dexFiles.forEach { + fileSystem.write(it.name, it.memoryDataStore.data) + } + + // write resources + if (!disableResourcePatching) { + fileSystem.writePathRecursively(File(cacheDirectory).resolve("build").toPath()) + fileSystem.uncompress(*result.doNotCompress!!.toTypedArray()) + } + } + } +} diff --git a/src/main/kotlin/app/revanced/cli/signing/Signing.kt b/src/main/kotlin/app/revanced/cli/signing/Signing.kt new file mode 100644 index 00000000..f314c082 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/signing/Signing.kt @@ -0,0 +1,23 @@ +package app.revanced.cli.signing + +import app.revanced.cli.command.MainCommand.cacheDirectory +import app.revanced.utils.signing.Signer +import app.revanced.utils.signing.align.ZipAligner +import java.io.File + +object Signing { + fun start(inputFile: File, outputFile: File, cn: String, password: String) { + // align & sign + val cacheDirectory = File(cacheDirectory) + val alignedOutput = cacheDirectory.resolve("aligned.apk") + val signedOutput = cacheDirectory.resolve("signed.apk") + ZipAligner.align(inputFile, alignedOutput) + Signer( + cn, + password + ).signApk(inputFile, signedOutput) + + // write to output + signedOutput.copyTo(outputFile) + } +} diff --git a/src/main/kotlin/app/revanced/utils/adb/Adb.kt b/src/main/kotlin/app/revanced/utils/adb/Adb.kt index c03db285..a59d699c 100644 --- a/src/main/kotlin/app/revanced/utils/adb/Adb.kt +++ b/src/main/kotlin/app/revanced/utils/adb/Adb.kt @@ -9,6 +9,7 @@ internal class Adb( private val apk: File, private val packageName: String, deviceName: String, + private val install: Boolean = false, private val logging: Boolean = true ) { private val device: JadbDevice @@ -21,39 +22,46 @@ internal class Adb( throw IllegalArgumentException("Root required on $deviceName. Deploying failed.") } - private fun String.replacePlaceholder(): String { - return this.replace(Constants.PLACEHOLDER, packageName) + private fun String.replacePlaceholder(with: String? = null): String { + return this.replace(Constants.PLACEHOLDER, with ?: packageName) } internal fun deploy() { - // create revanced path - device.run("${Constants.COMMAND_CREATE_DIR} ${Constants.PATH_REVANCED}") - - // push patched file - device.copy(Constants.PATH_INIT_PUSH, apk) - // install apk - device.run(Constants.COMMAND_INSTALL_APK.replacePlaceholder()) - - // push mount script - device.createFile( - Constants.PATH_INIT_PUSH, - Constants.CONTENT_MOUNT_SCRIPT.replacePlaceholder() - ) - // install mount script - device.run(Constants.COMMAND_INSTALL_MOUNT.replacePlaceholder()) - - // push umount script - device.createFile( - Constants.PATH_INIT_PUSH, - Constants.CONTENT_UMOUNT_SCRIPT.replacePlaceholder() - ) - // install mount script - device.run(Constants.COMMAND_INSTALL_UMOUNT.replacePlaceholder()) - - // unmount the apk for sanity - device.run(Constants.PATH_UMOUNT.replacePlaceholder()) - // mount the apk - device.run(Constants.PATH_MOUNT.replacePlaceholder()) + + if (install) { + TODO("support installing the apk") + device.run(Constants.COMMAND_INSTALL_APK.replacePlaceholder("\"$apk\"")) + } else { + // push patched file + device.copy(Constants.PATH_INIT_PUSH, apk) + + // create revanced path + device.run("${Constants.COMMAND_CREATE_DIR} ${Constants.PATH_REVANCED}") + + // prepare mounting the apk + device.run(Constants.COMMAND_PREPARE_MOUNT_APK.replacePlaceholder()) + + // push mount script + device.createFile( + Constants.PATH_INIT_PUSH, + Constants.CONTENT_MOUNT_SCRIPT.replacePlaceholder() + ) + // install mount script + device.run(Constants.COMMAND_INSTALL_MOUNT.replacePlaceholder()) + + // push umount script + device.createFile( + Constants.PATH_INIT_PUSH, + Constants.CONTENT_UMOUNT_SCRIPT.replacePlaceholder() + ) + // install mount script + device.run(Constants.COMMAND_INSTALL_UMOUNT.replacePlaceholder()) + + // unmount the apk for sanity + device.run(Constants.PATH_UMOUNT.replacePlaceholder()) + // mount the apk + device.run(Constants.PATH_MOUNT.replacePlaceholder()) + } // relaunch app device.run(Constants.COMMAND_RESTART.replacePlaceholder()) diff --git a/src/main/kotlin/app/revanced/utils/adb/Constants.kt b/src/main/kotlin/app/revanced/utils/adb/Constants.kt index 1b27e3d7..0fc437bb 100644 --- a/src/main/kotlin/app/revanced/utils/adb/Constants.kt +++ b/src/main/kotlin/app/revanced/utils/adb/Constants.kt @@ -28,7 +28,7 @@ internal object Constants { internal const val PATH_UMOUNT = "/data/adb/post-fs-data.d/un$NAME_MOUNT_SCRIPT" // move to revanced apk path & set permissions - internal const val COMMAND_INSTALL_APK = + internal const val COMMAND_PREPARE_MOUNT_APK = "base_path=\"$PATH_REVANCED_APP\" && mv $PATH_INIT_PUSH ${'$'}base_path && chmod 644 ${'$'}base_path && chown system:system ${'$'}base_path && chcon u:object_r:apk_data_file:s0 ${'$'}base_path" // install mount script & set permissions @@ -37,6 +37,9 @@ internal object Constants { // install umount script & set permissions internal const val COMMAND_INSTALL_UMOUNT = "mv $PATH_INIT_PUSH $PATH_UMOUNT && $COMMAND_CHMOD_MOUNT $PATH_UMOUNT" + // install apk & cleanup + internal const val COMMAND_INSTALL_APK = "install $PLACEHOLDER" + // unmount script internal val CONTENT_UMOUNT_SCRIPT = """ diff --git a/src/main/kotlin/app/revanced/utils/filesystem/FileSystemUtils.kt b/src/main/kotlin/app/revanced/utils/filesystem/FileSystemUtils.kt deleted file mode 100644 index 7a1d32e6..00000000 --- a/src/main/kotlin/app/revanced/utils/filesystem/FileSystemUtils.kt +++ /dev/null @@ -1,63 +0,0 @@ -package app.revanced.utils.filesystem - -import java.io.Closeable -import java.io.File -import java.nio.file.FileSystem -import java.nio.file.FileSystems -import java.nio.file.Files - -internal class FileSystemUtils( - file: File -) : Closeable { - private var fileSystem: FileSystem - - init { - fileSystem = FileSystems.newFileSystem(file.toPath(), null as ClassLoader?) - } - - private fun deleteDirectory(dirPath: String) { - val files = Files.walk(fileSystem.getPath("$dirPath/")) - - files - .sorted(Comparator.reverseOrder()) - .forEach { - - Files.delete(it) - } - - files.close() - } - - - internal fun replaceDirectory(replacement: File) { - if (!replacement.isDirectory) throw Exception("${replacement.name} is not a directory.") - - // FIXME: make this delete the directory recursively - //deleteDirectory(replacement.name) - //val path = Files.createDirectory(fileSystem.getPath(replacement.name)) - - val excludeFromPath = replacement.path.removeSuffix(replacement.name) - for (path in Files.walk(replacement.toPath())) { - val file = path.toFile() - if (file.isDirectory) { - val relativePath = path.toString().removePrefix(excludeFromPath) - val fileSystemPath = fileSystem.getPath(relativePath) - if (!Files.exists(fileSystemPath)) Files.createDirectory(fileSystemPath) - - continue - } - - replaceFile(path.toString().removePrefix(excludeFromPath), file.readBytes()) - } - } - - internal fun replaceFile(sourceFile: String, content: ByteArray) { - val path = fileSystem.getPath(sourceFile) - Files.deleteIfExists(path) - Files.write(path, content) - } - - override fun close() { - fileSystem.close() - } -} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/utils/filesystem/ZipFileSystemUtils.kt b/src/main/kotlin/app/revanced/utils/filesystem/ZipFileSystemUtils.kt new file mode 100644 index 00000000..d8333cf4 --- /dev/null +++ b/src/main/kotlin/app/revanced/utils/filesystem/ZipFileSystemUtils.kt @@ -0,0 +1,59 @@ +package app.revanced.utils.filesystem + +import java.io.Closeable +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.util.zip.ZipEntry + +internal class ZipFileSystemUtils( + file: File +) : Closeable { + private var zipFileSystem = FileSystems.newFileSystem(file.toPath(), mapOf("noCompression" to true)) + + private fun Path.deleteRecursively() { + if (Files.isDirectory(this)) { + Files.list(this).forEach { path -> + path.deleteRecursively() + } + } + + Files.delete(this) + } + + internal fun writePathRecursively(path: Path) { + Files.list(path).let { fileStream -> + fileStream.forEach { filePath -> + val fileSystemPath = filePath.getRelativePath(path) + fileSystemPath.deleteRecursively() + } + + fileStream + }.close() + + Files.walk(path).let { fileStream -> + fileStream.skip(1).forEach { filePath -> + val relativePath = filePath.getRelativePath(path) + + if (Files.isDirectory(filePath)) { + Files.createDirectory(relativePath) + return@forEach + } + + Files.copy(filePath, relativePath) + } + + fileStream + }.close() + } + + internal fun write(path: String, content: ByteArray) = Files.write(zipFileSystem.getPath(path), content) + + private fun Path.getRelativePath(path: Path): Path = zipFileSystem.getPath(path.relativize(this).toString()) + + internal fun uncompress(vararg paths: String) = + paths.forEach { Files.setAttribute(zipFileSystem.getPath(it), "zip:method", ZipEntry.STORED) } + + override fun close() = zipFileSystem.close() +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/utils/patcher/Patcher.kt b/src/main/kotlin/app/revanced/utils/patcher/Patcher.kt index fee8d8f1..2dc2db16 100644 --- a/src/main/kotlin/app/revanced/utils/patcher/Patcher.kt +++ b/src/main/kotlin/app/revanced/utils/patcher/Patcher.kt @@ -1,9 +1,12 @@ package app.revanced.utils.patcher -import app.revanced.cli.MainCommand +import app.revanced.cli.command.MainCommand +import app.revanced.cli.command.MainCommand.debugging +import app.revanced.cli.command.MainCommand.patchBundles import app.revanced.patcher.Patcher import app.revanced.patcher.data.base.Data import app.revanced.patcher.extensions.PatchExtensions.compatiblePackages +import app.revanced.patcher.extensions.PatchExtensions.excludeByDefault import app.revanced.patcher.extensions.PatchExtensions.patchName import app.revanced.patcher.patch.base.Patch import app.revanced.patcher.util.patch.implementation.JarPatchBundle @@ -11,10 +14,10 @@ import app.revanced.patcher.util.patch.implementation.JarPatchBundle fun Patcher.addPatchesFiltered( includeFilter: Boolean = false ) { - val packageName = this.packageName - val packageVersion = this.packageVersion + val packageName = this.data.packageMetadata.packageName + val packageVersion = this.data.packageMetadata.packageVersion - MainCommand.patchBundles.forEach { bundle -> + patchBundles.forEach { bundle -> val includedPatches = mutableListOf>>() JarPatchBundle(bundle).loadPatches().forEach patch@{ patch -> val compatiblePackages = patch.compatiblePackages @@ -22,7 +25,7 @@ fun Patcher.addPatchesFiltered( val prefix = "[skipped] $patchName" - if (includeFilter && !MainCommand.includedPatches.contains(patchName)) { + if ((includeFilter && !MainCommand.includedPatches.contains(patchName)) || patch.excludeByDefault) { println(prefix) return@patch } @@ -34,7 +37,7 @@ fun Patcher.addPatchesFiltered( return@patch } - if (!(MainCommand.debugging || compatiblePackage.versions.any { it == packageVersion })) { + if (!(debugging || compatiblePackage.versions.any { it == packageVersion })) { println("$prefix: Unsupported version.") return@patch } @@ -47,7 +50,7 @@ fun Patcher.addPatchesFiltered( } } -fun Patcher.applyPatchesPrint() { +fun Patcher.applyPatchesVerbose() { this.applyPatches().forEach { (patch, result) -> if (result.isSuccess) { println("[success] $patch") diff --git a/src/main/kotlin/app/revanced/utils/signing/KeySet.kt b/src/main/kotlin/app/revanced/utils/signing/KeySet.kt deleted file mode 100644 index 1eb9da86..00000000 --- a/src/main/kotlin/app/revanced/utils/signing/KeySet.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.revanced.utils.signing - -import java.security.PrivateKey -import java.security.cert.X509Certificate - -data class KeySet( - val publicKey: X509Certificate, - val privateKey: PrivateKey -) \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/utils/signing/Signer.kt b/src/main/kotlin/app/revanced/utils/signing/Signer.kt index 16adfe48..28ef43c1 100644 --- a/src/main/kotlin/app/revanced/utils/signing/Signer.kt +++ b/src/main/kotlin/app/revanced/utils/signing/Signer.kt @@ -1,65 +1,40 @@ -/* - * Copyright (c) 2021 Juby210 & Vendicated - * Licensed under the Open Software License version 3.0 - */ - package app.revanced.utils.signing +import com.android.apksig.ApkSigner import org.bouncycastle.asn1.x500.X500Name import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo import org.bouncycastle.cert.X509v3CertificateBuilder -import org.bouncycastle.cert.jcajce.JcaCertStore import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter -import org.bouncycastle.cms.* -import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.ContentSigner -import org.bouncycastle.operator.DigestCalculatorProvider import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder -import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder -import org.bouncycastle.util.encoders.Base64 -import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.math.BigInteger -import java.nio.file.FileSystems -import java.nio.file.Files -import java.nio.file.Path import java.security.* import java.security.cert.X509Certificate import java.util.* -import java.util.jar.Attributes -import java.util.jar.JarFile -import java.util.jar.Manifest -import java.util.regex.Pattern - - -const val CN = "ReVanced" -val PASSWORD = "revanced".toCharArray() // TODO: make it secure; random password should be enough -/** - * APK Signer. - * @author Aliucord authors - * @author ReVanced team - */ -object Signer { +internal class Signer( + private val cn: String, password: String +) { + private val passwordCharArray = password.toCharArray() private fun newKeystore(out: File) { - val key = createKey() + val (publicKey, privateKey) = createKey() val privateKS = KeyStore.getInstance("BKS", "BC") - privateKS.load(null, PASSWORD) - privateKS.setKeyEntry("alias", key.privateKey, PASSWORD, arrayOf(key.publicKey)) - privateKS.store(FileOutputStream(out), PASSWORD) + privateKS.load(null, passwordCharArray) + privateKS.setKeyEntry("alias", privateKey, passwordCharArray, arrayOf(publicKey)) + privateKS.store(FileOutputStream(out), passwordCharArray) } - private fun createKey(): KeySet { + private fun createKey(): Pair { val gen = KeyPairGenerator.getInstance("RSA") gen.initialize(2048) val pair = gen.generateKeyPair() var serialNumber: BigInteger - do serialNumber = - BigInteger.valueOf(SecureRandom().nextLong()) while (serialNumber < BigInteger.ZERO) - val x500Name = X500Name("CN=$CN") + do serialNumber = BigInteger.valueOf(SecureRandom().nextLong()) while (serialNumber < BigInteger.ZERO) + val x500Name = X500Name("CN=$cn") val builder = X509v3CertificateBuilder( x500Name, serialNumber, @@ -69,143 +44,31 @@ object Signer { x500Name, SubjectPublicKeyInfo.getInstance(pair.public.encoded) ) - val signer: ContentSigner = JcaContentSignerBuilder("SHA1withRSA").build(pair.private) - return KeySet(JcaX509CertificateConverter().getCertificate(builder.build(signer)), pair.private) + val signer: ContentSigner = JcaContentSignerBuilder("SHA256withRSA").build(pair.private) + return JcaX509CertificateConverter().getCertificate(builder.build(signer)) to pair.private } - private val stripPattern: Pattern = Pattern.compile("^META-INF/(.*)[.](MF|SF|RSA|DSA)$") - - // based on https://gist.github.com/mmuszkow/10288441 - // and https://github.com/fornwall/apksigner/blob/master/src/main/java/net/fornwall/apksigner/ZipSigner.java - fun signApk(apkFile: File) { + fun signApk(input: File, output: File) { Security.addProvider(BouncyCastleProvider()) - val ks = File(apkFile.parent, "revanced-cli.keystore") + val ks = File(input.parent, "revanced-cli.keystore") if (!ks.exists()) newKeystore(ks) val keyStore = KeyStore.getInstance("BKS", "BC") FileInputStream(ks).use { fis -> keyStore.load(fis, null) } val alias = keyStore.aliases().nextElement() - val keySet = KeySet( - (keyStore.getCertificate(alias) as X509Certificate), - (keyStore.getKey(alias, PASSWORD) as PrivateKey) - ) - - val zip = FileSystems.newFileSystem(apkFile.toPath(), null as ClassLoader?) - - val dig = MessageDigest.getInstance("SHA1") - val digests: MutableMap = LinkedHashMap() - - for (entry in zip.allEntries) { - val name = entry.toString() - if (stripPattern.matcher(name).matches()) { - Files.delete(entry) - } else { - digests[name] = toBase64(dig.digest(Files.readAllBytes(entry))) - } - } - - val sectionDigests: MutableMap = LinkedHashMap() - var manifest = Manifest() - var attrs = manifest.mainAttributes - attrs[Attributes.Name.MANIFEST_VERSION] = "1.0" - attrs[Attributes.Name("Created-By")] = CN - - val digestAttr = Attributes.Name("SHA1-Digest") - for ((name, value) in digests) { - val attributes = Attributes() - attributes[digestAttr] = value - manifest.entries[name] = attributes - sectionDigests[name] = hashEntrySection(name, attributes, dig) - } - ByteArrayOutputStream().use { baos -> - manifest.write(baos) - zip.writeFile(JarFile.MANIFEST_NAME, baos.toByteArray()) - } - - val manifestHash = getManifestHash(manifest, dig) - val tmpManifest = Manifest() - tmpManifest.mainAttributes.putAll(attrs) - val manifestMainHash = getManifestHash(tmpManifest, dig) - - manifest = Manifest() - attrs = manifest.mainAttributes - attrs[Attributes.Name.SIGNATURE_VERSION] = "1.0" - attrs[Attributes.Name("Created-By")] = CN - attrs[Attributes.Name("SHA1-Digest-Manifest")] = manifestHash - attrs[Attributes.Name("SHA1-Digest-Manifest-Main-Attributes")] = manifestMainHash - for ((key, value) in sectionDigests) { - val attributes = Attributes() - attributes[digestAttr] = value - manifest.entries[key] = attributes - } - var sigBytes: ByteArray - ByteArrayOutputStream().use { sigStream -> - manifest.write(sigStream) - sigBytes = sigStream.toByteArray() - zip.writeFile("META-INF/CERT.SF", sigBytes) - } + val config = ApkSigner.SignerConfig.Builder( + cn, + keyStore.getKey(alias, passwordCharArray) as PrivateKey, + listOf(keyStore.getCertificate(alias) as X509Certificate) + ).build() - val signature = signSigFile(keySet, sigBytes) - zip.writeFile("META-INF/CERT.RSA", signature) + val signer = ApkSigner.Builder(listOf(config)) + signer.setCreatedBy(cn) + signer.setInputApk(input) + signer.setOutputApk(output) - zip.close() + signer.build().sign() } - - private fun hashEntrySection(name: String, attrs: Attributes, dig: MessageDigest): String { - val manifest = Manifest() - manifest.mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0" - ByteArrayOutputStream().use { baos -> - manifest.write(baos) - val emptyLen = baos.toByteArray().size - manifest.entries[name] = attrs - baos.reset() - manifest.write(baos) - var ob = baos.toByteArray() - ob = Arrays.copyOfRange(ob, emptyLen, ob.size) - return toBase64(dig.digest(ob)) - } - } - - private fun getManifestHash(manifest: Manifest, dig: MessageDigest): String { - ByteArrayOutputStream().use { baos -> - manifest.write(baos) - return toBase64(dig.digest(baos.toByteArray())) - } - } - - private fun signSigFile(keySet: KeySet, content: ByteArray): ByteArray { - val msg: CMSTypedData = CMSProcessableByteArray(content) - val certs = JcaCertStore(Collections.singletonList(keySet.publicKey)) - val gen = CMSSignedDataGenerator() - val jcaContentSignerBuilder = JcaContentSignerBuilder("SHA1withRSA") - val sha1Signer: ContentSigner = jcaContentSignerBuilder.build(keySet.privateKey) - val jcaDigestCalculatorProviderBuilder = JcaDigestCalculatorProviderBuilder() - val digestCalculatorProvider: DigestCalculatorProvider = jcaDigestCalculatorProviderBuilder.build() - val jcaSignerInfoGeneratorBuilder = JcaSignerInfoGeneratorBuilder(digestCalculatorProvider) - jcaSignerInfoGeneratorBuilder.setDirectSignature(true) - val signerInfoGenerator: SignerInfoGenerator = jcaSignerInfoGeneratorBuilder.build(sha1Signer, keySet.publicKey) - gen.addSignerInfoGenerator(signerInfoGenerator) - gen.addCertificates(certs) - val sigData: CMSSignedData = gen.generate(msg, false) - return sigData.toASN1Structure().getEncoded("DER") - } - - private fun toBase64(data: ByteArray): String { - return String(Base64.encode(data)) - } -} - -private val java.nio.file.FileSystem.allEntries: List - get() = buildList { - this@allEntries.rootDirectories.forEach { dir -> - Files.walk(dir).filter(Files::isRegularFile).forEach { file -> - this@buildList.add(file) - } - } - } - -private fun java.nio.file.FileSystem.writeFile(path: String, bytes: ByteArray) { - Files.write(this.getPath("/$path"), bytes) } \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/utils/signing/align/Aligner.kt b/src/main/kotlin/app/revanced/utils/signing/align/Aligner.kt new file mode 100644 index 00000000..d7ab747f --- /dev/null +++ b/src/main/kotlin/app/revanced/utils/signing/align/Aligner.kt @@ -0,0 +1,64 @@ +package app.revanced.utils.signing.align + +import app.revanced.utils.signing.align.stream.MultiOutputStream +import app.revanced.utils.signing.align.stream.PeekingFakeStream +import java.io.File +import java.util.* +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +internal object ZipAligner { + fun align(input: File, output: File, alignment: Int = 4) { + val zipFile = ZipFile(input) + + val entries: Enumeration = zipFile.entries() + + // fake + val peekingFakeStream = PeekingFakeStream() + val fakeOutputStream = ZipOutputStream(peekingFakeStream) + // real + val zipOutputStream = ZipOutputStream(output.outputStream()) + + val multiOutputStream = MultiOutputStream( + listOf( + fakeOutputStream, // fake, used to add the data to the fake stream + zipOutputStream // real + ) + ) + + var bias = 0 + while (entries.hasMoreElements()) { + var padding = 0 + + val entry: ZipEntry = entries.nextElement()!! + // fake, used to calculate the file offset of the entry + fakeOutputStream.putNextEntry(entry) + + if (entry.size == entry.compressedSize) { + val fileOffset = peekingFakeStream.peek() + val newOffset = fileOffset + bias + padding = ((alignment - (newOffset % alignment)) % alignment).toInt() + + // fake, used to add the padding, because we add it to real as well in the extra field + peekingFakeStream.seek(padding.toLong()) + // real + entry.extra = if (entry.extra == null) ByteArray(padding) + else Arrays.copyOf(entry.extra, entry.extra.size + padding) + } + + zipOutputStream.putNextEntry(entry) + zipFile.getInputStream(entry).copyTo(multiOutputStream) + + // fake, used to add remaining bytes + fakeOutputStream.closeEntry() + // real + zipOutputStream.closeEntry() + + bias += padding + } + + zipFile.close() + zipOutputStream.close() + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/utils/signing/align/stream/MultiOutputStream.kt b/src/main/kotlin/app/revanced/utils/signing/align/stream/MultiOutputStream.kt new file mode 100644 index 00000000..02f8307c --- /dev/null +++ b/src/main/kotlin/app/revanced/utils/signing/align/stream/MultiOutputStream.kt @@ -0,0 +1,25 @@ +package app.revanced.utils.signing.align.stream + +import java.io.OutputStream + +internal class MultiOutputStream( + private val streams: Iterable, +) : OutputStream() { + override fun write(b: ByteArray, off: Int, len: Int) { + streams.forEach { + it.write(b, off, len) + } + } + + override fun write(b: ByteArray) { + streams.forEach { + it.write(b) + } + } + + override fun write(b: Int) { + streams.forEach { + it.write(b) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/utils/signing/align/stream/PeekingFakeStream.kt b/src/main/kotlin/app/revanced/utils/signing/align/stream/PeekingFakeStream.kt new file mode 100644 index 00000000..9f4893d4 --- /dev/null +++ b/src/main/kotlin/app/revanced/utils/signing/align/stream/PeekingFakeStream.kt @@ -0,0 +1,27 @@ +package app.revanced.utils.signing.align.stream + +import java.io.OutputStream + +internal class PeekingFakeStream : OutputStream() { + private var numberOfBytes: Long = 0 + + fun seek(n: Long) { + numberOfBytes += n + } + + fun peek(): Long { + return numberOfBytes + } + + override fun write(b: Int) { + numberOfBytes++ + } + + override fun write(b: ByteArray) { + numberOfBytes += b.size + } + + override fun write(b: ByteArray, offset: Int, len: Int) { + numberOfBytes += len - offset + } +} \ No newline at end of file