From 919b6b7014d624ca9c955922901072372eb32422 Mon Sep 17 00:00:00 2001 From: Ax333l Date: Sun, 11 Jun 2023 16:38:56 +0200 Subject: [PATCH] feat: keystore import/export (#30) --- .../app/revanced/manager/di/ManagerModule.kt | 4 +- .../revanced/manager/di/ViewModelModule.kt | 1 + .../manager/domain/manager/KeystoreManager.kt | 59 +++++++ .../domain/manager/PreferencesManager.kt | 3 + .../revanced/manager/patcher/SignerService.kt | 11 -- .../settings/ImportExportSettingsScreen.kt | 162 ++++++++++++++++-- .../ui/viewmodel/ImportExportViewModel.kt | 22 +++ .../ui/viewmodel/InstallerViewModel.kt | 15 +- .../revanced/manager/util/signing/Signer.kt | 17 +- .../manager/util/signing/SigningOptions.kt | 4 +- app/src/main/res/values/strings.xml | 7 + 11 files changed, 266 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt delete mode 100644 app/src/main/java/app/revanced/manager/patcher/SignerService.kt create mode 100644 app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt diff --git a/app/src/main/java/app/revanced/manager/di/ManagerModule.kt b/app/src/main/java/app/revanced/manager/di/ManagerModule.kt index b5dee835..0aae1cd6 100644 --- a/app/src/main/java/app/revanced/manager/di/ManagerModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ManagerModule.kt @@ -1,11 +1,11 @@ package app.revanced.manager.di -import app.revanced.manager.patcher.SignerService +import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.util.PM import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val managerModule = module { - singleOf(::SignerService) + singleOf(::KeystoreManager) singleOf(::PM) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt index c6b1aa09..beb2fed4 100644 --- a/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt +++ b/app/src/main/java/app/revanced/manager/di/ViewModelModule.kt @@ -12,4 +12,5 @@ val viewModelModule = module { viewModelOf(::SourcesViewModel) viewModelOf(::InstallerViewModel) viewModelOf(::UpdateSettingsViewModel) + viewModelOf(::ImportExportViewModel) } diff --git a/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt new file mode 100644 index 00000000..51326dd3 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/domain/manager/KeystoreManager.kt @@ -0,0 +1,59 @@ +package app.revanced.manager.domain.manager + +import android.app.Application +import app.revanced.manager.util.signing.Signer +import app.revanced.manager.util.signing.SigningOptions +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import kotlin.io.path.exists + +class KeystoreManager(app: Application, private val prefs: PreferencesManager) { + companion object { + /** + * Default common name and password for the keystore. + */ + const val DEFAULT = "ReVanced" + + /** + * The default password used by the Flutter version. + */ + const val FLUTTER_MANAGER_PASSWORD = "s3cur3p@ssw0rd" + } + + private val keystorePath = app.dataDir.resolve("manager.keystore").toPath() + private fun options( + cn: String = prefs.keystoreCommonName!!, + pass: String = prefs.keystorePass!! + ) = SigningOptions(cn, pass, keystorePath) + + private fun updatePrefs(cn: String, pass: String) { + prefs.keystoreCommonName = cn + prefs.keystorePass = pass + } + + fun sign(input: File, output: File) = Signer(options()).signApk(input, output) + + init { + if (!keystorePath.exists()) { + regenerate() + } + } + + fun regenerate() = Signer(options(DEFAULT, DEFAULT)).regenerateKeystore().also { + updatePrefs(DEFAULT, DEFAULT) + } + + fun import(cn: String, pass: String, keystore: InputStream) { + // TODO: check if the user actually provided the correct password + Files.copy(keystore, keystorePath, StandardCopyOption.REPLACE_EXISTING) + + updatePrefs(cn, pass) + } + + fun export(target: OutputStream) { + Files.copy(keystorePath, target) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index d2bf7ffc..93eb4d7b 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -13,4 +13,7 @@ class PreferencesManager( var dynamicColor by booleanPreference("dynamic_color", true) var theme by enumPreference("theme", Theme.SYSTEM) //var sentry by booleanPreference("sentry", true) + + var keystoreCommonName by stringPreference("keystore_cn", KeystoreManager.DEFAULT) + var keystorePass by stringPreference("keystore_pass", KeystoreManager.DEFAULT) } diff --git a/app/src/main/java/app/revanced/manager/patcher/SignerService.kt b/app/src/main/java/app/revanced/manager/patcher/SignerService.kt deleted file mode 100644 index a92b077d..00000000 --- a/app/src/main/java/app/revanced/manager/patcher/SignerService.kt +++ /dev/null @@ -1,11 +0,0 @@ -package app.revanced.manager.patcher - -import android.app.Application -import app.revanced.manager.util.signing.Signer -import app.revanced.manager.util.signing.SigningOptions - -class SignerService(app: Application) { - private val options = SigningOptions("ReVanced", "ReVanced", app.dataDir.resolve("manager.keystore").path) - - fun createSigner() = Signer(options) -} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt index d051a7a8..cbdd9757 100644 --- a/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt +++ b/app/src/main/java/app/revanced/manager/ui/screen/settings/ImportExportSettingsScreen.kt @@ -1,27 +1,56 @@ package app.revanced.manager.ui.screen.settings +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.StringRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ListItem -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import app.revanced.manager.R +import app.revanced.manager.ui.viewmodel.ImportExportViewModel +import app.revanced.manager.domain.manager.KeystoreManager.Companion.DEFAULT +import app.revanced.manager.domain.manager.KeystoreManager.Companion.FLUTTER_MANAGER_PASSWORD +import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.ui.component.AppTopBar +import app.revanced.manager.ui.component.FileSelector import app.revanced.manager.ui.component.GroupHeader +import org.koin.androidx.compose.getViewModel +import org.koin.compose.rememberKoinInject @OptIn(ExperimentalMaterial3Api::class) @Composable fun ImportExportSettingsScreen( - onBackClick: () -> Unit + onBackClick: () -> Unit, + vm: ImportExportViewModel = getViewModel() ) { + var showImportKeystoreDialog by rememberSaveable { mutableStateOf(false) } + var showExportKeystoreDialog by rememberSaveable { mutableStateOf(false) } + + if (showImportKeystoreDialog) { + ImportKeystoreDialog( + onDismissRequest = { showImportKeystoreDialog = false }, + onImport = vm::import + ) + } + if (showExportKeystoreDialog) { + ExportKeystoreDialog( + onDismissRequest = { showExportKeystoreDialog = false }, + onExport = vm::export + ) + } + Scaffold( topBar = { AppTopBar( @@ -37,16 +66,123 @@ fun ImportExportSettingsScreen( .verticalScroll(rememberScrollState()) ) { GroupHeader(stringResource(R.string.signing)) - ListItem( - modifier = Modifier.clickable { }, - headlineContent = { Text(stringResource(R.string.import_keystore)) }, - supportingContent = { Text(stringResource(R.string.import_keystore_descripion)) } + GroupItem( + onClick = { + showImportKeystoreDialog = true + }, + headline = R.string.import_keystore, + description = R.string.import_keystore_descripion ) - ListItem( - modifier = Modifier.clickable { }, - headlineContent = { Text(stringResource(R.string.export_keystore)) }, - supportingContent = { Text(stringResource(R.string.export_keystore_description)) } + GroupItem( + onClick = { + showExportKeystoreDialog = true + }, + headline = R.string.export_keystore, + description = R.string.export_keystore_description + ) + GroupItem( + onClick = vm::regenerate, + headline = R.string.regenerate_keystore, + description = R.string.regenerate_keystore_description ) } } +} + +@Composable +private fun GroupItem(onClick: () -> Unit, @StringRes headline: Int, @StringRes description: Int) = + ListItem( + modifier = Modifier.clickable { onClick() }, + headlineContent = { Text(stringResource(headline)) }, + supportingContent = { Text(stringResource(description)) } + ) + +@Composable +fun ExportKeystoreDialog( + onDismissRequest: () -> Unit, + onExport: (Uri) -> Unit +) { + val activityLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("*/*")) { uri -> + uri?.let { + onExport(it) + onDismissRequest() + } + } + val prefs: PreferencesManager = rememberKoinInject() + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + Button( + onClick = { activityLauncher.launch("Manager.keystore") } + ) { + Text(stringResource(R.string.select_file)) + } + }, + title = { Text(stringResource(R.string.export_keystore)) }, + text = { + Column { + Text("Current common name: ${prefs.keystoreCommonName}") + Text("Current password: ${prefs.keystorePass}") + } + } + ) +} + +@Composable +fun ImportKeystoreDialog( + onDismissRequest: () -> Unit, onImport: (Uri, String, String) -> Unit +) { + var cn by rememberSaveable { mutableStateOf(DEFAULT) } + var pass by rememberSaveable { mutableStateOf(DEFAULT) } + + AlertDialog( + onDismissRequest = onDismissRequest, + confirmButton = { + FileSelector( + mime = "*/*", + onSelect = { + onImport(it, cn, pass) + onDismissRequest() + } + ) { + Text(stringResource(R.string.select_file)) + } + }, + title = { Text(stringResource(R.string.import_keystore)) }, + text = { + Column { + TextField( + value = cn, + onValueChange = { cn = it }, + label = { Text("Common Name") } + ) + TextField( + value = pass, + onValueChange = { pass = it }, + label = { Text("Password") } + ) + + Text("Credential presets") + + Button( + onClick = { + cn = DEFAULT + pass = DEFAULT + } + ) { + Text(stringResource(R.string.import_keystore_preset_default)) + } + Button( + onClick = { + cn = DEFAULT + pass = FLUTTER_MANAGER_PASSWORD + } + ) { + Text(stringResource(R.string.import_keystore_preset_flutter)) + } + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt new file mode 100644 index 00000000..c7029d4d --- /dev/null +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/ImportExportViewModel.kt @@ -0,0 +1,22 @@ +package app.revanced.manager.ui.viewmodel + + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.ViewModel +import app.revanced.manager.R +import app.revanced.manager.domain.manager.KeystoreManager +import app.revanced.manager.util.toast + +class ImportExportViewModel(private val app: Application, private val keystoreManager: KeystoreManager) : ViewModel() { + private val contentResolver = app.contentResolver + + fun import(content: Uri, cn: String, pass: String) = + keystoreManager.import(cn, pass, contentResolver.openInputStream(content)!!) + + fun export(target: Uri) = keystoreManager.export(contentResolver.openOutputStream(target)!!) + + fun regenerate() = keystoreManager.regenerate().also { + app.toast(app.getString(R.string.regenerate_keystore_success)) + } +} \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt index 096db32d..029163b8 100644 --- a/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt +++ b/app/src/main/java/app/revanced/manager/ui/viewmodel/InstallerViewModel.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageInstaller import android.net.Uri +import android.util.Log import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -15,8 +16,8 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.map import androidx.work.* +import app.revanced.manager.domain.manager.KeystoreManager import app.revanced.manager.R -import app.revanced.manager.patcher.SignerService import app.revanced.manager.patcher.worker.PatcherProgressManager import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.patcher.worker.StepGroup @@ -25,6 +26,7 @@ import app.revanced.manager.service.UninstallService import app.revanced.manager.util.AppInfo import app.revanced.manager.util.PM import app.revanced.manager.util.PatchesSelection +import app.revanced.manager.util.tag import app.revanced.manager.util.toast import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -38,7 +40,7 @@ class InstallerViewModel( input: AppInfo, selectedPatches: PatchesSelection ) : ViewModel(), KoinComponent { - private val signerService: SignerService by inject() + private val keystoreManager: KeystoreManager by inject() private val app: Application by inject() private val pm: PM by inject() @@ -102,7 +104,8 @@ class InstallerViewModel( if (pmStatus == PackageInstaller.STATUS_SUCCESS) { app.toast(app.getString(R.string.install_app_success)) - installedPackageName = intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) + installedPackageName = + intent.getStringExtra(InstallService.EXTRA_PACKAGE_NAME) } else { app.toast(app.getString(R.string.install_app_fail, extra)) } @@ -134,9 +137,9 @@ class InstallerViewModel( private fun signApk(): Boolean { if (!hasSigned) { try { - signerService.createSigner().signApk(outputFile, signedFile) - } catch (e: Throwable) { - e.printStackTrace() + keystoreManager.sign(outputFile, signedFile) + } catch (e: Exception) { + Log.e(tag, "Got exception while signing", e) app.toast(app.getString(R.string.sign_fail, e::class.simpleName)) return false } diff --git a/app/src/main/java/app/revanced/manager/util/signing/Signer.kt b/app/src/main/java/app/revanced/manager/util/signing/Signer.kt index 8112db73..9c6e0da4 100644 --- a/app/src/main/java/app/revanced/manager/util/signing/Signer.kt +++ b/app/src/main/java/app/revanced/manager/util/signing/Signer.kt @@ -11,25 +11,30 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.operator.ContentSigner import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.math.BigInteger +import java.nio.file.Path import java.security.* import java.security.cert.X509Certificate import java.util.* +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.name +import kotlin.io.path.outputStream class Signer( private val signingOptions: SigningOptions ) { private val passwordCharArray = signingOptions.password.toCharArray() - private fun newKeystore(out: File) { + private fun newKeystore(out: Path) { val (publicKey, privateKey) = createKey() val privateKS = KeyStore.getInstance("BKS", "BC") privateKS.load(null, passwordCharArray) privateKS.setKeyEntry("alias", privateKey, passwordCharArray, arrayOf(publicKey)) - privateKS.store(FileOutputStream(out), passwordCharArray) + out.outputStream().use { stream -> privateKS.store(stream, passwordCharArray) } } + fun regenerateKeystore() = newKeystore(signingOptions.keyStoreFilePath) + private fun createKey(): Pair { val gen = KeyPairGenerator.getInstance("RSA") gen.initialize(4096) @@ -53,13 +58,13 @@ class Signer( fun signApk(input: File, output: File) { Security.addProvider(BouncyCastleProvider()) - val ks = File(signingOptions.keyStoreFilePath) + val ks = signingOptions.keyStoreFilePath if (!ks.exists()) newKeystore(ks) else { Log.i(tag, "Found existing keystore: ${ks.name}") } val keyStore = KeyStore.getInstance("BKS", "BC") - FileInputStream(ks).use { fis -> keyStore.load(fis, null) } + ks.inputStream().use { stream -> keyStore.load(stream, null) } val alias = keyStore.aliases().nextElement() val config = ApkSigner.SignerConfig.Builder( diff --git a/app/src/main/java/app/revanced/manager/util/signing/SigningOptions.kt b/app/src/main/java/app/revanced/manager/util/signing/SigningOptions.kt index 403b01d1..a0ca4d94 100644 --- a/app/src/main/java/app/revanced/manager/util/signing/SigningOptions.kt +++ b/app/src/main/java/app/revanced/manager/util/signing/SigningOptions.kt @@ -1,7 +1,9 @@ package app.revanced.manager.util.signing +import java.nio.file.Path + data class SigningOptions( val cn: String, val password: String, - val keyStoreFilePath: String + val keyStoreFilePath: Path ) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc7300e4..6bbea720 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,8 +30,13 @@ Choose between light or dark theme Import keystore Import a custom keystore + Default + ReVanced Manager (Flutter) Export keystore Export the current keystore + Regenerate keystore + Generate a new keystore + The keystore has been successfully replaced Search apps… Loading… @@ -64,6 +69,8 @@ Unsupported patches Some of the patches do not support this app version (%1$s). The patches only support the following versions: %2$s. + Select file + Installer Install App installed