diff --git a/build.gradle.kts b/build.gradle.kts index cc5c8043..c9b98517 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,6 @@ plugins { } group = "app.revanced" -version = "1.0" repositories { mavenCentral() @@ -16,6 +15,9 @@ repositories { password = project.findProperty("gpr.key") as String? ?: System.getenv("GITHUB_TOKEN") // DO NOT CHANGE! } } + maven { + url = uri("https://jitpack.io") + } } val patchesDependency = "app.revanced:revanced-patches:1.0.0-dev.4" @@ -29,8 +31,12 @@ dependencies { implementation("com.google.code.gson:gson:2.9.0") implementation("me.tongfei:progressbar:0.9.3") + implementation("com.github.li-wjohnson:jadb:master-SNAPSHOT") // using a fork instead. + implementation("org.bouncycastle:bcpkix-jdk15on:1.70") } +val cliMainClass = "app.revanced.cli.Main" + tasks { build { dependsOn(shadowJar) @@ -42,7 +48,7 @@ tasks { exclude(dependency(patchesDependency)) } manifest { - attributes("Main-Class" to "app.revanced.cli.Main") + attributes("Main-Class" to cliMainClass) attributes("Implementation-Title" to project.name) attributes("Implementation-Version" to project.version) } diff --git a/gradle.properties b/gradle.properties index 29e08e8c..e2e428ad 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +version = 1.0.0-dev \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/cli/Main.kt b/src/main/kotlin/app/revanced/cli/Main.kt index 3aff380c..e5cf5d82 100644 --- a/src/main/kotlin/app/revanced/cli/Main.kt +++ b/src/main/kotlin/app/revanced/cli/Main.kt @@ -1,5 +1,6 @@ package app.revanced.cli +import app.revanced.cli.runner.Emulator import app.revanced.cli.utils.PatchLoader import app.revanced.cli.utils.Patches import app.revanced.cli.utils.Preconditions @@ -8,6 +9,7 @@ import app.revanced.patcher.patch.PatchMetadata import app.revanced.patcher.patch.PatchResult import kotlinx.cli.ArgParser import kotlinx.cli.ArgType +import kotlinx.cli.default import kotlinx.cli.required import me.tongfei.progressbar.ProgressBarBuilder import me.tongfei.progressbar.ProgressBarStyle @@ -23,7 +25,9 @@ class Main { inApk: String, inPatches: String, inIntegrations: String?, - inOutput: String, + inOutput: String?, + inEmulate: String?, + hideResults: Boolean, ) { val bar = ProgressBarBuilder() .setTaskName("Working..") @@ -35,12 +39,9 @@ class Main { .setExtraMessage("Initializing") val apk = Preconditions.isFile(inApk) val patchesFile = Preconditions.isFile(inPatches) - val output = Preconditions.isDirectory(inOutput) bar.step() - val patcher = Patcher( - apk, - ) + val patcher = Patcher(apk) inIntegrations?.let { bar.reset().maxHint(1) @@ -53,12 +54,14 @@ class Main { bar.reset().maxHint(1) .extraMessage = "Loading patches" PatchLoader.injectPatches(patchesFile) - bar.step() - val patches = Patches.loadPatches().map { it() } patcher.addPatches(patches) + bar.step() + bar.reset().maxHint(1) + .extraMessage = "Resolving signatures" patcher.resolveSignatures() + bar.step() val amount = patches.size.toLong() bar.reset().maxHint(amount) @@ -70,24 +73,41 @@ class Main { bar.reset().maxHint(-1) .extraMessage = "Generating dex files" val dexFiles = patcher.save() - bar.reset().maxHint(dexFiles.size.toLong()) - .extraMessage = "Saving dex files" - dexFiles.forEach { (dexName, dexData) -> - Files.write(File(output, dexName).toPath(), dexData.data) - bar.step() + + inOutput?.let { + val output = Preconditions.isDirectory(it) + val amount = dexFiles.size.toLong() + bar.reset().maxHint(amount) + .extraMessage = "Saving dex files" + dexFiles.forEach { (dexName, dexData) -> + Files.write(File(output, dexName).toPath(), dexData.data) + bar.step() + } + bar.stepTo(amount) } + bar.close() + inEmulate?.let { device -> + Emulator.emulate( + apk, + dexFiles, + device + ) + } + println("All done!") - printResults(results) + if (!hideResults) { + printResults(results) + } } private fun printResults(results: Map>) { for ((metadata, result) in results) { if (result.isSuccess) { - println("${metadata.name} was applied successfully!") + println("${metadata.shortName} was applied successfully!") } else { - println("${metadata.name} failed to apply! Cause:") + println("${metadata.shortName} failed to apply! Cause:") result.exceptionOrNull()!!.printStackTrace() } } @@ -98,6 +118,9 @@ class Main { println("$CLI_NAME version $CLI_VERSION") val parser = ArgParser(CLI_NAME) + // TODO: add some kind of incremental building, so merging integrations can be skipped. + // this can be achieved manually, but doing it automatically is better. + val apk by parser.option( ArgType.String, fullName = "apk", @@ -121,7 +144,18 @@ class Main { fullName = "output", shortName = "o", description = "Output directory" - ).required() + ) + val emulate by parser.option( + ArgType.String, + fullName = "run-on", + description = "After the CLI is done building, which ADB device should it run on?" + ) + // TODO: package name + val hideResults by parser.option( + ArgType.Boolean, + fullName = "hide-results", + description = "Don't print the patch results." + ).default(false) parser.parse(args) runCLI( @@ -129,6 +163,8 @@ class Main { patches, integrations, output, + emulate, + hideResults, ) } } diff --git a/src/main/kotlin/app/revanced/cli/runner/AdbRunner.kt b/src/main/kotlin/app/revanced/cli/runner/AdbRunner.kt new file mode 100644 index 00000000..1a05d57a --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/runner/AdbRunner.kt @@ -0,0 +1,140 @@ +package app.revanced.cli.runner + +import app.revanced.cli.utils.DexReplacer +import app.revanced.cli.utils.Scripts +import app.revanced.cli.utils.signer.Signer +import me.tongfei.progressbar.ProgressBar +import me.tongfei.progressbar.ProgressBarBuilder +import me.tongfei.progressbar.ProgressBarStyle +import org.jf.dexlib2.writer.io.MemoryDataStore +import se.vidstige.jadb.JadbConnection +import se.vidstige.jadb.JadbDevice +import se.vidstige.jadb.RemoteFile +import se.vidstige.jadb.ShellProcessBuilder +import java.io.File +import java.nio.file.Files +import java.util.concurrent.Executors + +object Emulator { + fun emulate( + apk: File, + dexFiles: Map, + deviceName: String + ) { + lateinit var dvc: JadbDevice + pbar("Initializing").use { bar -> + dvc = JadbConnection().findDevice(deviceName) + ?: throw IllegalArgumentException("No such device with name $deviceName") + if (!dvc.hasSu()) + throw IllegalArgumentException("Device $deviceName is not rooted or does not have su") + bar.step() + } + + lateinit var tmpFile: File // we need this file at the end to clean up. + pbar("Generating APK file", 3).use { bar -> + bar.step().extraMessage = "Creating APK file" + tmpFile = Files.createTempFile("rvc-cli", ".apk").toFile() + apk.copyTo(tmpFile, true) + + bar.step().extraMessage = "Replacing dex files" + DexReplacer.replaceDex(tmpFile, dexFiles) + + bar.step().extraMessage = "Signing APK file" + Signer.signApk(tmpFile) + } + + pbar("Running application", 6, false).use { bar -> + bar.step().extraMessage = "Pushing mount scripts" + dvc.push(Scripts.MOUNT_SCRIPT, RemoteFile(Scripts.SCRIPT_PATH)) + dvc.cmd(Scripts.CREATE_DIR_COMMAND).assertZero() + dvc.cmd(Scripts.MV_MOUNT_COMMAND).assertZero() + dvc.cmd(Scripts.CHMOD_MOUNT_COMMAND).assertZero() + + bar.step().extraMessage = "Pushing APK file" + dvc.push(tmpFile, RemoteFile(Scripts.APK_PATH)) + + bar.step().extraMessage = "Mounting APK file" + dvc.cmd(Scripts.STOP_APP_COMMAND).startAndWait() + dvc.cmd(Scripts.START_MOUNT_COMMAND).assertZero() + + bar.step().extraMessage = "Starting APK file" + dvc.cmd(Scripts.START_APP_COMMAND).assertZero() + + bar.step().setExtraMessage("Debugging APK file").refresh() + println("\nWaiting until app is closed.") + val executor = Executors.newSingleThreadExecutor() + val p = dvc.cmd(Scripts.LOGCAT_COMMAND) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .useExecutor(executor) + .start() + Thread.sleep(250) // give the app some time to start up. + while (dvc.cmd(Scripts.PIDOF_APP_COMMAND).startAndWait() == 0) { + Thread.sleep(250) + } + println("App closed, continuing.") + p.destroy() + executor.shutdown() + + bar.step().extraMessage = "Unmounting APK file" + var exitCode: Int + do { + exitCode = dvc.cmd(Scripts.UNMOUNT_COMMAND).startAndWait() + } while (exitCode != 0) + } + + tmpFile.delete() + } +} + +private fun JadbDevice.push(s: String, remoteFile: RemoteFile) = + this.push(s.byteInputStream(), System.currentTimeMillis(), 644, remoteFile) + +private fun JadbConnection.findDevice(device: String): JadbDevice? { + return devices.find { it.serial == device } +} + +private fun JadbDevice.cmd(s: String): ShellProcessBuilder { + val args = s.split(" ") as ArrayList + val cmd = args.removeFirst() + return shellProcessBuilder(cmd, *args.toTypedArray()) +} + +private fun JadbDevice.hasSu(): Boolean { + return cmd("su -h").startAndWait() == 0 +} + +private fun ShellProcessBuilder.startAndWait(): Int { + return start().waitFor() +} + +private fun ShellProcessBuilder.assertZero() { + if (startAndWait() != 0) { + val cmd = getcmd() + throw IllegalStateException("ADB returned non-zero status code for command: $cmd") + } +} + +private fun pbar(task: String, steps: Long = 1, update: Boolean = true): ProgressBar { + val b = ProgressBarBuilder().setTaskName(task) + if (update) b + .setUpdateIntervalMillis(250) + .continuousUpdate() + return b + .setStyle(ProgressBarStyle.ASCII) + .build() + .maxHint(steps + 1) +} + +private fun ProgressBar.use(block: (ProgressBar) -> Unit) { + block(this) + stepTo(max) // step to 100% + extraMessage = "" // clear extra message + close() +} + +private fun ShellProcessBuilder.getcmd(): String { + val f = this::class.java.getDeclaredField("command") + f.isAccessible = true + return f.get(this) as String +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/cli/utils/DexReplacer.kt b/src/main/kotlin/app/revanced/cli/utils/DexReplacer.kt new file mode 100644 index 00000000..171aa1f6 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/utils/DexReplacer.kt @@ -0,0 +1,31 @@ +package app.revanced.cli.utils + +import lanchon.multidexlib2.BasicDexFileNamer +import org.jf.dexlib2.writer.io.MemoryDataStore +import java.io.File +import java.nio.file.FileSystems +import java.nio.file.Files + +val NAMER = BasicDexFileNamer() + +object DexReplacer { + fun replaceDex(source: File, dexFiles: Map) { + FileSystems.newFileSystem( + source.toPath(), + null + ).use { fs -> + // Delete all classes?.dex files + Files.walk(fs.rootDirectories.first()).forEach { + if ( + it.toString().endsWith(".dex") && + NAMER.isValidName(it.fileName.toString()) + ) Files.delete(it) + } + // Write new dex files + dexFiles + .forEach { (dexName, dexData) -> + Files.write(fs.getPath("/$dexName"), dexData.data) + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/cli/utils/Scripts.kt b/src/main/kotlin/app/revanced/cli/utils/Scripts.kt new file mode 100644 index 00000000..05c4672a --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/utils/Scripts.kt @@ -0,0 +1,34 @@ +package app.revanced.cli.utils + +// TODO: make this a class with PACKAGE_NAME as argument, then use that everywhere. +// make sure +object Scripts { + private const val PACKAGE_NAME = "com.google.android.youtube" + private const val DATA_PATH = "/data/adb/ReVanced" + const val APK_PATH = "/sdcard/base.apk" + const val SCRIPT_PATH = "/sdcard/mount.sh" + + val MOUNT_SCRIPT = + """ + base_path="$DATA_PATH/base.apk" + stock_path=${'$'}{ pm path $PACKAGE_NAME | grep base | sed 's/package://g' } + umount -l ${'$'}stock_path + rm ${'$'}base_path + mv "$APK_PATH" ${'$'}base_path + chmod 644 ${'$'}base_path + chown system:system ${'$'}base_path + chcon u:object_r:apk_data_file:s0 ${'$'}base_path + mount -o bind ${'$'}base_path ${'$'}stock_path + """.trimIndent() + + const val PIDOF_APP_COMMAND = "pidof -s $PACKAGE_NAME" + private const val PIDOF_APP = "\$($PIDOF_APP_COMMAND)" + const val CREATE_DIR_COMMAND = "su -c \"mkdir -p $DATA_PATH/\"" + const val MV_MOUNT_COMMAND = "su -c \"mv /sdcard/mount.sh $DATA_PATH/\"" + const val CHMOD_MOUNT_COMMAND = "su -c \"chmod +x $DATA_PATH/mount.sh\"" + const val START_MOUNT_COMMAND = "su -c $DATA_PATH/mount.sh" + const val UNMOUNT_COMMAND = "su -c \"umount -l $(pm path $PACKAGE_NAME | grep base | sed 's/package://g')\"" + const val LOGCAT_COMMAND = "su -c \"logcat -c && logcat --pid=$PIDOF_APP\"" + const val STOP_APP_COMMAND = "su -c \"kill $PIDOF_APP\"" + const val START_APP_COMMAND = "monkey -p $PACKAGE_NAME 1" +} \ No newline at end of file diff --git a/src/main/kotlin/app/revanced/cli/utils/signer/KeySet.kt b/src/main/kotlin/app/revanced/cli/utils/signer/KeySet.kt new file mode 100644 index 00000000..bbe01348 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/utils/signer/KeySet.kt @@ -0,0 +1,9 @@ +package app.revanced.cli.utils.signer + +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/cli/utils/signer/Signer.kt b/src/main/kotlin/app/revanced/cli/utils/signer/Signer.kt new file mode 100644 index 00000000..936e2796 --- /dev/null +++ b/src/main/kotlin/app/revanced/cli/utils/signer/Signer.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2021 Juby210 & Vendicated + * Licensed under the Open Software License version 3.0 + */ + +package app.revanced.cli.utils.signer + +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 { + private fun newKeystore(out: File) { + val key = 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) + } + + private fun createKey(): KeySet { + 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") + val builder = X509v3CertificateBuilder( + x500Name, + serialNumber, + Date(System.currentTimeMillis() - 1000L * 60L * 60L * 24L * 30L), + Date(System.currentTimeMillis() + 1000L * 60L * 60L * 24L * 366L * 30L), + Locale.ENGLISH, + x500Name, + SubjectPublicKeyInfo.getInstance(pair.public.encoded) + ) + val signer: ContentSigner = JcaContentSignerBuilder("SHA1withRSA").build(pair.private) + return KeySet(JcaX509CertificateConverter().getCertificate(builder.build(signer)), 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) { + Security.addProvider(BouncyCastleProvider()) + + val ks = File(apkFile.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) + + 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 signature = signSigFile(keySet, sigBytes) + zip.writeFile("META-INF/CERT.RSA", signature) + + zip.close() + } + + 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