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

View file

@ -12,4 +12,5 @@ val viewModelModule = module {
viewModelOf(::SourcesViewModel)
viewModelOf(::InstallerViewModel)
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 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)
}

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

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.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
}

View file

@ -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(

View file

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

View file

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