diff --git a/app/src/main/java/com/chiller3/custota/Preferences.kt b/app/src/main/java/com/chiller3/custota/Preferences.kt index e1ae486..74d64aa 100644 --- a/app/src/main/java/com/chiller3/custota/Preferences.kt +++ b/app/src/main/java/com/chiller3/custota/Preferences.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -9,9 +9,13 @@ import android.content.ContentResolver import android.content.Context import android.content.Intent import android.net.Uri +import android.util.Base64 import android.util.Log import androidx.core.content.edit import androidx.preference.PreferenceManager +import java.io.ByteArrayInputStream +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate class Preferences(private val context: Context) { companion object { @@ -36,9 +40,11 @@ class Preferences(private val context: Context) { const val PREF_OPEN_LOG_DIR = "open_log_dir" const val PREF_ALLOW_REINSTALL = "allow_reinstall" const val PREF_REVERT_COMPLETED = "revert_completed" + const val PREF_INSTALL_CSIG_CERT = "install_csig_cert" // Not associated with a UI preference private const val PREF_DEBUG_MODE = "debug_mode" + private const val PREF_CSIG_CERTS = "csig_certs" // Legacy preferences private const val PREF_OTA_SERVER_URL = "ota_server_url" @@ -118,6 +124,33 @@ class Preferences(private val context: Context) { get() = prefs.getBoolean(PREF_ALLOW_REINSTALL, false) set(enabled) = prefs.edit { putBoolean(PREF_ALLOW_REINSTALL, enabled) } + var csigCerts: Set + get() { + val encoded = prefs.getStringSet(PREF_CSIG_CERTS, emptySet())!! + val factory = CertificateFactory.getInstance("X.509") + + return encoded + .asSequence() + .map { base64 -> + val der = Base64.decode(base64, Base64.DEFAULT) + + ByteArrayInputStream(der).use { + factory.generateCertificate(it) as X509Certificate + } + } + .toSet() + } + set(certs) { + val encoded = certs + .asSequence() + .map { + Base64.encodeToString(it.encoded, Base64.NO_WRAP) + } + .toSet() + + prefs.edit { putStringSet(PREF_CSIG_CERTS, encoded) } + } + /** Migrate legacy preferences to current preferences. */ fun migrate() { if (prefs.contains(PREF_OTA_SERVER_URL)) { diff --git a/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt b/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt index 7f59d05..3c317de 100644 --- a/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt +++ b/app/src/main/java/com/chiller3/custota/settings/SettingsFragment.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2022-2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2022-2024 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only * Based on BCR code. */ @@ -61,6 +61,7 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic private lateinit var prefVersion: LongClickablePreference private lateinit var prefOpenLogDir: Preference private lateinit var prefRevertCompleted: Preference + private lateinit var prefInstallCsigCert: Preference private lateinit var scheduledAction: UpdaterThread.Action @@ -72,6 +73,12 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic startActivity(Permissions.getAppInfoIntent(requireContext())) } } + private val requestSafInstallCsigCert = + registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + uri?.let { + viewModel.installCsigCert(it) + } + } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.preferences_root, rootKey) @@ -116,6 +123,9 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic prefRevertCompleted = findPreference(Preferences.PREF_REVERT_COMPLETED)!! prefRevertCompleted.onPreferenceClickListener = this + prefInstallCsigCert = findPreference(Preferences.PREF_INSTALL_CSIG_CERT)!! + prefInstallCsigCert.onPreferenceClickListener = this + refreshCheckForUpdates() refreshOtaSource() refreshVersion() @@ -252,23 +262,38 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic performAction() return true } + prefInstallCsigCert -> { + // See AOSP's frameworks/base/mime/java-res/android.mime.types + requestSafInstallCsigCert.launch(arrayOf( + "application/x-x509-ca-cert", + "application/x-x509-user-cert", + "application/x-x509-server-cert", + "application/x-pem-file", + )) + return true + } } return false } override fun onPreferenceLongClick(preference: Preference): Boolean { - when (preference) { - prefOtaSource -> { + when { + preference === prefOtaSource -> { prefs.otaSource = null return true } - prefVersion -> { + preference === prefVersion -> { prefs.isDebugMode = !prefs.isDebugMode refreshVersion() refreshDebugPrefs() return true } + preference.key.startsWith(PREF_CERT_PREFIX) -> { + val index = preference.key.removePrefix(PREF_CERT_PREFIX).toInt() + viewModel.removeCsigCert(index) + return true + } } return false @@ -286,7 +311,7 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic } } - private fun addCertPreferences(certs: List) { + private fun addCertPreferences(certs: List>) { val context = requireContext() prefNoCertificates.isVisible = certs.isEmpty() @@ -299,11 +324,14 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic } } - for ((i, cert) in certs.withIndex()) { - val p = Preference(context).apply { + for ((i, item) in certs.withIndex()) { + val (cert, isSystem) = item + val validates = if (isSystem) { "OTA + csig" } else { "csig" } + + val p = LongClickablePreference(context).apply { key = PREF_CERT_PREFIX + i isPersistent = false - title = getString(R.string.pref_certificate_name, (i + 1).toString()) + title = getString(R.string.pref_certificate_name, (i + 1).toString(), validates) summary = buildString { append(getString(R.string.pref_certificate_desc_subject, cert.subjectDN.toString())) @@ -316,6 +344,10 @@ class SettingsFragment : PreferenceFragmentCompat(), Preference.OnPreferenceClic append(getString(R.string.pref_certificate_desc_type, cert.typeName)) } isIconSpaceReserved = false + + if (!isSystem) { + onPreferenceLongClickListener = this@SettingsFragment + } } categoryCertificates.addPreference(p) diff --git a/app/src/main/java/com/chiller3/custota/settings/SettingsViewModel.kt b/app/src/main/java/com/chiller3/custota/settings/SettingsViewModel.kt index 53fc3fd..7b816bb 100644 --- a/app/src/main/java/com/chiller3/custota/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/chiller3/custota/settings/SettingsViewModel.kt @@ -1,14 +1,17 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ package com.chiller3.custota.settings +import android.app.Application +import android.net.Uri import android.service.oemlock.IOemLockService import android.util.Log -import androidx.lifecycle.ViewModel +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.chiller3.custota.Preferences import com.chiller3.custota.extension.toSingleLineString import com.chiller3.custota.updater.OtaPaths import com.chiller3.custota.wrapper.ServiceManagerProxy @@ -18,34 +21,96 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.IOException +import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -class SettingsViewModel : ViewModel() { - private val _certs = MutableStateFlow>(emptyList()) - val certs: StateFlow> = _certs +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + private val prefs = Preferences(getApplication()) + + private val _certs = MutableStateFlow>>(emptyList()) + val certs: StateFlow>> = _certs private val _bootloaderStatus = MutableStateFlow(null) val bootloaderStatus: StateFlow = _bootloaderStatus init { - loadCertificates() + loadCerts() } - private fun loadCertificates() { + private fun loadCerts() { viewModelScope.launch { - withContext(Dispatchers.IO) { - val certs = try { + val systemCerts = try { + withContext(Dispatchers.IO) { OtaPaths.otaCerts - } catch (e: Exception) { - Log.w(TAG, "Failed to load certificates") - emptyList() } + } catch (e: Exception) { + Log.w(TAG, "Failed to load system certificates", e) + emptySet() + } + + val csigCerts = try { + // Avoid duplicates. + prefs.csigCerts.subtract(systemCerts) + } catch (e: Exception) { + Log.w(TAG, "Failed to load user csig certificates", e) + emptySet() + } + + _certs.update { systemCerts.sortedWith(certCompare).map { it to true } + + csigCerts.sortedWith(certCompare).map { it to false } } + } + } + + fun installCsigCert(uri: Uri) { + viewModelScope.launch { + val cert = try { + withContext(Dispatchers.IO) { + val factory = CertificateFactory.getInstance("X.509") + + getApplication().contentResolver.openInputStream(uri)?.use { + factory.generateCertificate(it) as X509Certificate + } ?: throw IOException("Null input stream: $uri") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to load certificate: $uri") + return@launch + } + + val allCerts = _certs.value - _certs.update { certs } + if (allCerts.any { it.first == cert }) { + Log.w(TAG, "Certificate already exists: $cert") + return@launch } + + Log.d(TAG, "Installing user csig certificate: $cert") + + prefs.csigCerts = sequence { + yieldAll(allCerts.asSequence().filter { !it.second }.map { it.first }) + yield(cert) + }.toSet() + + loadCerts() } } + fun removeCsigCert(index: Int) { + val allCerts = _certs.value + val cert = allCerts[index].first + require(!allCerts[index].second) { "Tried to delete system certificate at $index: $cert" } + + Log.d(TAG, "Removing user csig certificate: $cert") + + prefs.csigCerts = allCerts + .asSequence() + .filterIndexed { i, _ -> i != index } + .map { it.first } + .toSet() + + loadCerts() + } + fun refreshBootloaderStatus() { val status = try { val service = IOemLockService.Stub.asInterface( @@ -76,5 +141,10 @@ class SettingsViewModel : ViewModel() { companion object { private val TAG = SettingsViewModel::class.java.simpleName + + private val certCompare = compareBy( + { it.subjectDN.name }, + { it.serialNumber }, + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/chiller3/custota/updater/OtaPaths.kt b/app/src/main/java/com/chiller3/custota/updater/OtaPaths.kt index 60f0895..c1a438a 100644 --- a/app/src/main/java/com/chiller3/custota/updater/OtaPaths.kt +++ b/app/src/main/java/com/chiller3/custota/updater/OtaPaths.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -35,9 +35,9 @@ object OtaPaths { const val METADATA_NAME = "metadata.pb" /** Parse X509 certificates from [OTACERTS_ZIP]. */ - val otaCerts: List + val otaCerts: Set get() { - val result = mutableListOf() + val result = mutableSetOf() val factory = CertificateFactory.getInstance("X.509") ZipFile(OTACERTS_ZIP).use { zip -> diff --git a/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt b/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt index edf3964..b4de340 100644 --- a/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt +++ b/app/src/main/java/com/chiller3/custota/updater/UpdaterThread.kt @@ -1,5 +1,5 @@ /* - * SPDX-FileCopyrightText: 2023 Andrew Gunnerson + * SPDX-FileCopyrightText: 2023-2024 Andrew Gunnerson * SPDX-License-Identifier: GPL-3.0-only */ @@ -375,16 +375,14 @@ class UpdaterThread( val csigRaw = stream.use { it.readBytes() } val csigCms = CMSSignedData(csigRaw) - // Verify the signature against the same OTA certs as what's used for the payload. - val csigValid = OtaPaths.otaCerts.any { cert -> + // Verify the signature against both the system OTA certificates and the custom certificates + // installed by the user. The custom certificates cannot be used for verifying the payload. + val csigCert = (OtaPaths.otaCerts + prefs.csigCerts).find { cert -> csigCms.signerInfos.any { signerInfo -> signerInfo.verify(JcaSimpleSignerInfoVerifierBuilder().build(cert)) } - } - if (!csigValid) { - throw ValidationException("csig is not signed by a trusted key") - } - Log.d(TAG, "csig signature is valid") + } ?: throw ValidationException("csig is not signed by a trusted key") + Log.d(TAG, "csig is signed by: $csigCert") val csigInfoRaw = String(csigCms.signedContent.content as ByteArray) val csigInfo: CsigInfo = Json.decodeFromString(csigInfoRaw) diff --git a/app/src/main/res/values-vi/values.xml b/app/src/main/res/values-vi/values.xml index 4603b56..064bbf6 100644 --- a/app/src/main/res/values-vi/values.xml +++ b/app/src/main/res/values-vi/values.xml @@ -15,7 +15,7 @@ Kiểm tra cập nhật OTA Đặt lịch kiểm tra cập nhật OTA. - Chứng chỉ %1$s + Chứng chỉ %1$s (%2$s) Chủ đề: %1$s Số sê-ri: %1$s Loại: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6287f1..d9e0e57 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,6 @@ @@ -17,7 +17,7 @@ Schedule a check for new OTA updates. OTA installation source No installation source set. - Certificate %1$s + Certificate %1$s (%2$s) Subject: %1$s Serial: %1$s Type: %1$s @@ -61,6 +61,8 @@ If the latest OTA matches the current OS fingerprint, treat it as an update. This may cause continuous reinstalls if automatic updates are enabled. Revert completed update This is only possible after an update completes, but before rebooting. This is meant for debugging purposes only. + Install csig certificate + The certificate is only used for verifying .csig files. This is not necessary when the certificate exists in the system\'s otacerts.zip. Long press the certificate to remove it. @string/pref_ota_source_name diff --git a/app/src/main/res/xml/preferences_root.xml b/app/src/main/res/xml/preferences_root.xml index 89b2bc3..79f01f5 100644 --- a/app/src/main/res/xml/preferences_root.xml +++ b/app/src/main/res/xml/preferences_root.xml @@ -139,5 +139,12 @@ app:title="@string/pref_revert_completed_name" app:summary="@string/pref_revert_completed_desc" app:iconSpaceReserved="false" /> + +