feat: keystore import/export (#30)

This commit is contained in:
Ax333l 2023-06-11 16:38:56 +02:00 committed by GitHub
parent 971277ed39
commit 919b6b7014
11 changed files with 266 additions and 39 deletions

View File

@ -1,11 +1,11 @@
package app.revanced.manager.di 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 app.revanced.manager.util.PM
import org.koin.core.module.dsl.singleOf import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module import org.koin.dsl.module
val managerModule = module { val managerModule = module {
singleOf(::SignerService) singleOf(::KeystoreManager)
singleOf(::PM) singleOf(::PM)
} }

View File

@ -12,4 +12,5 @@ val viewModelModule = module {
viewModelOf(::SourcesViewModel) viewModelOf(::SourcesViewModel)
viewModelOf(::InstallerViewModel) viewModelOf(::InstallerViewModel)
viewModelOf(::UpdateSettingsViewModel) viewModelOf(::UpdateSettingsViewModel)
viewModelOf(::ImportExportViewModel)
} }

View File

@ -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)
}
}

View File

@ -13,4 +13,7 @@ class PreferencesManager(
var dynamicColor by booleanPreference("dynamic_color", true) var dynamicColor by booleanPreference("dynamic_color", true)
var theme by enumPreference("theme", Theme.SYSTEM) var theme by enumPreference("theme", Theme.SYSTEM)
//var sentry by booleanPreference("sentry", true) //var sentry by booleanPreference("sentry", true)
var keystoreCommonName by stringPreference("keystore_cn", KeystoreManager.DEFAULT)
var keystorePass by stringPreference("keystore_pass", KeystoreManager.DEFAULT)
} }

View File

@ -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)
}

View File

@ -1,27 +1,56 @@
package app.revanced.manager.ui.screen.settings 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.clickable
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.*
import androidx.compose.material3.ListItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable 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.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import app.revanced.manager.R 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.AppTopBar
import app.revanced.manager.ui.component.FileSelector
import app.revanced.manager.ui.component.GroupHeader import app.revanced.manager.ui.component.GroupHeader
import org.koin.androidx.compose.getViewModel
import org.koin.compose.rememberKoinInject
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ImportExportSettingsScreen( 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( Scaffold(
topBar = { topBar = {
AppTopBar( AppTopBar(
@ -37,16 +66,123 @@ fun ImportExportSettingsScreen(
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
) { ) {
GroupHeader(stringResource(R.string.signing)) GroupHeader(stringResource(R.string.signing))
ListItem( GroupItem(
modifier = Modifier.clickable { }, onClick = {
headlineContent = { Text(stringResource(R.string.import_keystore)) }, showImportKeystoreDialog = true
supportingContent = { Text(stringResource(R.string.import_keystore_descripion)) } },
headline = R.string.import_keystore,
description = R.string.import_keystore_descripion
) )
ListItem( GroupItem(
modifier = Modifier.clickable { }, onClick = {
headlineContent = { Text(stringResource(R.string.export_keystore)) }, showExportKeystoreDialog = true
supportingContent = { Text(stringResource(R.string.export_keystore_description)) } },
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))
}
}
}
)
} }

View File

@ -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))
}
}

View File

@ -7,6 +7,7 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.util.Log
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -15,8 +16,8 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.map import androidx.lifecycle.map
import androidx.work.* import androidx.work.*
import app.revanced.manager.domain.manager.KeystoreManager
import app.revanced.manager.R import app.revanced.manager.R
import app.revanced.manager.patcher.SignerService
import app.revanced.manager.patcher.worker.PatcherProgressManager import app.revanced.manager.patcher.worker.PatcherProgressManager
import app.revanced.manager.patcher.worker.PatcherWorker import app.revanced.manager.patcher.worker.PatcherWorker
import app.revanced.manager.patcher.worker.StepGroup 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.AppInfo
import app.revanced.manager.util.PM import app.revanced.manager.util.PM
import app.revanced.manager.util.PatchesSelection import app.revanced.manager.util.PatchesSelection
import app.revanced.manager.util.tag
import app.revanced.manager.util.toast import app.revanced.manager.util.toast
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
@ -38,7 +40,7 @@ class InstallerViewModel(
input: AppInfo, input: AppInfo,
selectedPatches: PatchesSelection selectedPatches: PatchesSelection
) : ViewModel(), KoinComponent { ) : ViewModel(), KoinComponent {
private val signerService: SignerService by inject() private val keystoreManager: KeystoreManager by inject()
private val app: Application by inject() private val app: Application by inject()
private val pm: PM by inject() private val pm: PM by inject()
@ -102,7 +104,8 @@ class InstallerViewModel(
if (pmStatus == PackageInstaller.STATUS_SUCCESS) { if (pmStatus == PackageInstaller.STATUS_SUCCESS) {
app.toast(app.getString(R.string.install_app_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 { } else {
app.toast(app.getString(R.string.install_app_fail, extra)) app.toast(app.getString(R.string.install_app_fail, extra))
} }
@ -134,9 +137,9 @@ class InstallerViewModel(
private fun signApk(): Boolean { private fun signApk(): Boolean {
if (!hasSigned) { if (!hasSigned) {
try { try {
signerService.createSigner().signApk(outputFile, signedFile) keystoreManager.sign(outputFile, signedFile)
} catch (e: Throwable) { } catch (e: Exception) {
e.printStackTrace() Log.e(tag, "Got exception while signing", e)
app.toast(app.getString(R.string.sign_fail, e::class.simpleName)) app.toast(app.getString(R.string.sign_fail, e::class.simpleName))
return false return false
} }

View File

@ -11,25 +11,30 @@ import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.ContentSigner import org.bouncycastle.operator.ContentSigner
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.math.BigInteger import java.math.BigInteger
import java.nio.file.Path
import java.security.* import java.security.*
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.* 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( class Signer(
private val signingOptions: SigningOptions private val signingOptions: SigningOptions
) { ) {
private val passwordCharArray = signingOptions.password.toCharArray() private val passwordCharArray = signingOptions.password.toCharArray()
private fun newKeystore(out: File) { private fun newKeystore(out: Path) {
val (publicKey, privateKey) = createKey() val (publicKey, privateKey) = createKey()
val privateKS = KeyStore.getInstance("BKS", "BC") val privateKS = KeyStore.getInstance("BKS", "BC")
privateKS.load(null, passwordCharArray) privateKS.load(null, passwordCharArray)
privateKS.setKeyEntry("alias", privateKey, passwordCharArray, arrayOf(publicKey)) 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<X509Certificate, PrivateKey> { private fun createKey(): Pair<X509Certificate, PrivateKey> {
val gen = KeyPairGenerator.getInstance("RSA") val gen = KeyPairGenerator.getInstance("RSA")
gen.initialize(4096) gen.initialize(4096)
@ -53,13 +58,13 @@ class Signer(
fun signApk(input: File, output: File) { fun signApk(input: File, output: File) {
Security.addProvider(BouncyCastleProvider()) Security.addProvider(BouncyCastleProvider())
val ks = File(signingOptions.keyStoreFilePath) val ks = signingOptions.keyStoreFilePath
if (!ks.exists()) newKeystore(ks) else { if (!ks.exists()) newKeystore(ks) else {
Log.i(tag, "Found existing keystore: ${ks.name}") Log.i(tag, "Found existing keystore: ${ks.name}")
} }
val keyStore = KeyStore.getInstance("BKS", "BC") 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 alias = keyStore.aliases().nextElement()
val config = ApkSigner.SignerConfig.Builder( val config = ApkSigner.SignerConfig.Builder(

View File

@ -1,7 +1,9 @@
package app.revanced.manager.util.signing package app.revanced.manager.util.signing
import java.nio.file.Path
data class SigningOptions( data class SigningOptions(
val cn: String, val cn: String,
val password: String, val password: String,
val keyStoreFilePath: String val keyStoreFilePath: Path
) )

View File

@ -30,8 +30,13 @@
<string name="theme_description">Choose between light or dark theme</string> <string name="theme_description">Choose between light or dark theme</string>
<string name="import_keystore">Import keystore</string> <string name="import_keystore">Import keystore</string>
<string name="import_keystore_descripion">Import a custom keystore</string> <string name="import_keystore_descripion">Import a custom keystore</string>
<string name="import_keystore_preset_default">Default</string>
<string name="import_keystore_preset_flutter">ReVanced Manager (Flutter)</string>
<string name="export_keystore">Export keystore</string> <string name="export_keystore">Export keystore</string>
<string name="export_keystore_description">Export the current keystore</string> <string name="export_keystore_description">Export the current keystore</string>
<string name="regenerate_keystore">Regenerate keystore</string>
<string name="regenerate_keystore_description">Generate a new keystore</string>
<string name="regenerate_keystore_success">The keystore has been successfully replaced</string>
<string name="search_apps">Search apps…</string> <string name="search_apps">Search apps…</string>
<string name="loading_body">Loading…</string> <string name="loading_body">Loading…</string>
@ -64,6 +69,8 @@
<string name="unsupported_patches">Unsupported patches</string> <string name="unsupported_patches">Unsupported patches</string>
<string name="app_not_supported">Some of the patches do not support this app version (%1$s). The patches only support the following versions: %2$s.</string> <string name="app_not_supported">Some of the patches do not support this app version (%1$s). The patches only support the following versions: %2$s.</string>
<string name="select_file">Select file</string>
<string name="installer">Installer</string> <string name="installer">Installer</string>
<string name="install_app">Install</string> <string name="install_app">Install</string>
<string name="install_app_success">App installed</string> <string name="install_app_success">App installed</string>