mirror of
https://github.com/ReVanced/revanced-manager.git
synced 2024-11-10 01:01:56 +01:00
feat: keystore import/export (#30)
This commit is contained in:
parent
971277ed39
commit
919b6b7014
11 changed files with 266 additions and 39 deletions
|
@ -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)
|
||||
}
|
|
@ -12,4 +12,5 @@ val viewModelModule = module {
|
|||
viewModelOf(::SourcesViewModel)
|
||||
viewModelOf(::InstallerViewModel)
|
||||
viewModelOf(::UpdateSettingsViewModel)
|
||||
viewModelOf(::ImportExportViewModel)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<X509Certificate, PrivateKey> {
|
||||
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(
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -30,8 +30,13 @@
|
|||
<string name="theme_description">Choose between light or dark theme</string>
|
||||
<string name="import_keystore">Import 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_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="loading_body">Loading…</string>
|
||||
|
@ -64,6 +69,8 @@
|
|||
<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="select_file">Select file</string>
|
||||
|
||||
<string name="installer">Installer</string>
|
||||
<string name="install_app">Install</string>
|
||||
<string name="install_app_success">App installed</string>
|
||||
|
|
Loading…
Reference in a new issue