From e98d3cfb36d47f3d26f274ced42742d3b0d66822 Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 3 Jan 2026 15:52:24 -0500 Subject: [PATCH] Remove cert model --- .../java/io/heckel/ntfy/backup/Backuper.kt | 25 ++-- .../io/heckel/ntfy/tls/CertificateManager.kt | 80 +++++------ .../io/heckel/ntfy/tls/CertificateModels.kt | 109 +++++---------- .../java/io/heckel/ntfy/tls/SSLManager.kt | 15 +-- .../io/heckel/ntfy/ui/CertificateFragment.kt | 127 +++++++++--------- .../ntfy/ui/CertificateSettingsFragment.kt | 37 ++--- .../ntfy/ui/CertificateTrustFragment.kt | 4 +- 7 files changed, 170 insertions(+), 227 deletions(-) diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 00d7daf5..3f81a8ca 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -229,15 +229,8 @@ class Backuper(val context: Context) { } certificates.forEach { c -> try { - val trustedCert = io.heckel.ntfy.tls.TrustedCertificate( - fingerprint = c.fingerprint, - subject = c.subject, - issuer = c.issuer, - notBefore = c.notBefore, - notAfter = c.notAfter, - pemEncoded = c.pemEncoded - ) - certManager.addTrustedCertificate(trustedCert) + val cert = certManager.parsePemCertificate(c.pemEncoded) + certManager.addTrustedCertificate(cert) } catch (e: Exception) { Log.w(TAG, "Unable to restore trusted certificate ${c.fingerprint}: ${e.message}. Ignoring.", e) } @@ -366,14 +359,14 @@ class Backuper(val context: Context) { } private fun createTrustedCertificateList(): List { - return certManager.getTrustedCertificates().map { c -> + return certManager.getTrustedCertificates().map { cert -> TrustedCertificateBackup( - fingerprint = c.fingerprint, - subject = c.subject, - issuer = c.issuer, - notBefore = c.notBefore, - notAfter = c.notAfter, - pemEncoded = c.pemEncoded + fingerprint = io.heckel.ntfy.tls.calculateFingerprint(cert), + subject = cert.subjectX500Principal.name, + issuer = cert.issuerX500Principal.name, + notBefore = cert.notBefore.time, + notAfter = cert.notAfter.time, + pemEncoded = io.heckel.ntfy.tls.encodeToPem(cert) ) } } diff --git a/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt b/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt index ee1294e7..3d0c7c21 100644 --- a/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt +++ b/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt @@ -13,9 +13,9 @@ import java.security.cert.X509Certificate /** * Manages trusted server certificates and client certificates for mTLS. - * + * * All certificates are stored in app's private files directory: - * - Trusted certificates: certs/trusted/{fingerprint}.pem + metadata in certs/trusted.json + * - Trusted certificates: certs/trusted/.pem (loaded directly from files) * - Client certificates: certs/client/{alias}.p12 + metadata in certs/client.json */ class CertificateManager private constructor(private val context: Context) { @@ -23,62 +23,51 @@ class CertificateManager private constructor(private val context: Context) { private val certsDir = File(context.filesDir, CERTS_DIR).apply { mkdirs() } private val trustedDir = File(certsDir, TRUSTED_DIR).apply { mkdirs() } private val clientDir = File(certsDir, CLIENT_DIR).apply { mkdirs() } - private val trustedMetadataFile = File(certsDir, TRUSTED_METADATA_FILE) private val clientMetadataFile = File(certsDir, CLIENT_METADATA_FILE) // ==================== Trusted Server Certificates ==================== /** - * Get all trusted server certificates + * Get all trusted server certificates by loading PEM files from disk */ - fun getTrustedCertificates(): List { - if (!trustedMetadataFile.exists()) return emptyList() - return try { - val json = trustedMetadataFile.readText() - val type = object : TypeToken>() {}.type - gson.fromJson(json, type) ?: emptyList() - } catch (e: Exception) { - Log.w(TAG, "Failed to parse trusted certificates", e) - emptyList() + fun getTrustedCertificates(): List { + val pemFiles = trustedDir.listFiles { file -> file.extension == "pem" } ?: return emptyList() + return pemFiles.mapNotNull { file -> + try { + parsePemCertificate(file.readText()) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse certificate file: ${file.name}", e) + null + } } } /** - * Add a trusted certificate + * Add a trusted certificate (saves as PEM file) */ - fun addTrustedCertificate(cert: TrustedCertificate) { - // Save PEM file - val pemFile = File(trustedDir, "${cert.fingerprint.replace(":", "")}.pem") - pemFile.writeText(cert.pemEncoded) - - // Update metadata - val certs = getTrustedCertificates().toMutableList() - certs.removeAll { it.fingerprint == cert.fingerprint } - certs.add(cert) - saveTrustedMetadata(certs) + fun addTrustedCertificate(cert: X509Certificate) { + val fingerprint = calculateFingerprint(cert) + val filename = fingerprint.replace(":", "") + ".pem" + val pemFile = File(trustedDir, filename) + pemFile.writeText(encodeToPem(cert)) } /** - * Add a trusted certificate from X509Certificate + * Remove a trusted certificate by fingerprint */ - fun addTrustedCertificate(cert: X509Certificate) { - addTrustedCertificate(TrustedCertificate.fromX509Certificate(cert)) + fun removeTrustedCertificate(fingerprint: String) { + val filename = fingerprint.replace(":", "") + ".pem" + val pemFile = File(trustedDir, filename) + if (pemFile.exists()) { + pemFile.delete() + } } /** * Remove a trusted certificate */ - fun removeTrustedCertificate(cert: TrustedCertificate) { - // Delete PEM file - val pemFile = File(trustedDir, "${cert.fingerprint.replace(":", "")}.pem") - if (pemFile.exists()) { - pemFile.delete() - } - - // Update metadata - val certs = getTrustedCertificates().toMutableList() - certs.removeAll { it.fingerprint == cert.fingerprint } - saveTrustedMetadata(certs) + fun removeTrustedCertificate(cert: X509Certificate) { + removeTrustedCertificate(calculateFingerprint(cert)) } /** @@ -94,14 +83,6 @@ class CertificateManager private constructor(private val context: Context) { return factory.generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate } - private fun saveTrustedMetadata(certs: List) { - if (certs.isEmpty()) { - trustedMetadataFile.delete() - } else { - trustedMetadataFile.writeText(gson.toJson(certs)) - } - } - // ==================== Client Certificates (mTLS) ==================== /** @@ -144,7 +125,7 @@ class CertificateManager private constructor(private val context: Context) { /** * Add a client certificate from a PKCS#12 file - * + * * @param baseUrl Server URL this certificate is for * @param pkcs12Data PKCS#12 file contents * @param password Password for the PKCS#12 file @@ -168,14 +149,14 @@ class CertificateManager private constructor(private val context: Context) { // Update metadata val clientCert = ClientCertificate.fromX509Certificate(baseUrl, storageAlias, cert, password) val certs = getClientCertificates().toMutableList() - + // Remove existing cert for same baseUrl val oldCert = certs.find { it.baseUrl == baseUrl } if (oldCert != null) { removeClientCertificate(oldCert) certs.removeAll { it.baseUrl == baseUrl } } - + certs.add(clientCert) saveClientMetadata(certs) } @@ -200,7 +181,6 @@ class CertificateManager private constructor(private val context: Context) { private const val CERTS_DIR = "certs" private const val TRUSTED_DIR = "trusted" private const val CLIENT_DIR = "client" - private const val TRUSTED_METADATA_FILE = "trusted.json" private const val CLIENT_METADATA_FILE = "client.json" @SuppressLint("StaticFieldLeak") // Using applicationContext, so no leak diff --git a/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt b/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt index 8d792324..77067107 100644 --- a/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt +++ b/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt @@ -1,98 +1,63 @@ package io.heckel.ntfy.tls -import java.security.cert.X509Certificate +import android.util.Base64 import java.security.MessageDigest +import java.security.cert.X509Certificate /** - * Represents a trusted server certificate (self-signed or custom CA) - * stored in the app's certificate trust store. + * Calculate SHA-256 fingerprint of a certificate */ -data class TrustedCertificate( - val fingerprint: String, // SHA-256 fingerprint of the certificate - val subject: String, // Subject DN (e.g., "CN=example.com") - val issuer: String, // Issuer DN - val notBefore: Long, // Validity start (Unix timestamp in millis) - val notAfter: Long, // Validity end (Unix timestamp in millis) - val pemEncoded: String // PEM-encoded certificate -) { - companion object { - /** - * Create a TrustedCertificate from an X509Certificate - */ - fun fromX509Certificate(cert: X509Certificate): TrustedCertificate { - return TrustedCertificate( - fingerprint = calculateFingerprint(cert), - subject = cert.subjectX500Principal.name, - issuer = cert.issuerX500Principal.name, - notBefore = cert.notBefore.time, - notAfter = cert.notAfter.time, - pemEncoded = encodeToPem(cert) - ) - } +fun calculateFingerprint(cert: X509Certificate): String { + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(cert.encoded) + return digest.joinToString(":") { "%02X".format(it) } +} - /** - * Calculate SHA-256 fingerprint of a certificate - */ - fun calculateFingerprint(cert: X509Certificate): String { - val md = MessageDigest.getInstance("SHA-256") - val digest = md.digest(cert.encoded) - return digest.joinToString(":") { "%02X".format(it) } - } - - /** - * Encode certificate to PEM format - */ - private fun encodeToPem(cert: X509Certificate): String { - val base64 = android.util.Base64.encodeToString(cert.encoded, android.util.Base64.NO_WRAP) - val sb = StringBuilder() - sb.append("-----BEGIN CERTIFICATE-----\n") - // Split into 64-character lines - var i = 0 - while (i < base64.length) { - val end = minOf(i + 64, base64.length) - sb.append(base64.substring(i, end)) - sb.append("\n") - i += 64 - } - sb.append("-----END CERTIFICATE-----") - return sb.toString() - } +/** + * Encode certificate to PEM format + */ +fun encodeToPem(cert: X509Certificate): String { + val base64 = Base64.encodeToString(cert.encoded, Base64.NO_WRAP) + val sb = StringBuilder() + sb.append("-----BEGIN CERTIFICATE-----\n") + // Split into 64-character lines + var i = 0 + while (i < base64.length) { + val end = minOf(i + 64, base64.length) + sb.append(base64.substring(i, end)) + sb.append("\n") + i += 64 } + sb.append("-----END CERTIFICATE-----") + return sb.toString() +} - /** - * Check if the certificate is currently valid (not expired) - */ - fun isValid(): Boolean { - val now = System.currentTimeMillis() - return now in notBefore..notAfter - } - - /** - * Get a human-readable subject (extract CN if available) - */ - fun displaySubject(): String { - val cnMatch = Regex("CN=([^,]+)").find(subject) - return cnMatch?.groupValues?.get(1) ?: subject - } +/** + * Get a human-readable subject from a certificate (extract CN if available) + */ +fun displaySubject(cert: X509Certificate): String { + val subject = cert.subjectX500Principal.name + val cnMatch = Regex("CN=([^,]+)").find(subject) + return cnMatch?.groupValues?.get(1) ?: subject } /** * Represents metadata for a client certificate used for mTLS. - * The actual private key is stored in Android KeyStore or a PKCS#12 file. + * The actual certificate and private key are stored in a PKCS#12 file. */ data class ClientCertificate( val baseUrl: String, // Server URL this client cert is used for - val alias: String, // KeyStore alias or filename prefix for PKCS#12 + val alias: String, // Filename prefix for PKCS#12 val fingerprint: String, // SHA-256 fingerprint of the certificate val subject: String, // Subject DN val issuer: String, // Issuer DN val notBefore: Long, // Validity start (Unix timestamp in millis) val notAfter: Long, // Validity end (Unix timestamp in millis) - val password: String? = null // Password for PKCS#12 files (null for Android KeyStore) + val password: String? = null // Password for PKCS#12 files ) { companion object { /** - * Generate a unique alias for storing in KeyStore + * Generate a unique alias for storing */ fun generateAlias(baseUrl: String): String { val timestamp = System.currentTimeMillis() @@ -112,7 +77,7 @@ data class ClientCertificate( return ClientCertificate( baseUrl = baseUrl, alias = alias, - fingerprint = TrustedCertificate.calculateFingerprint(cert), + fingerprint = calculateFingerprint(cert), subject = cert.subjectX500Principal.name, issuer = cert.issuerX500Principal.name, notBefore = cert.notBefore.time, diff --git a/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt b/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt index 43f97328..663a1350 100644 --- a/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt +++ b/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt @@ -48,7 +48,7 @@ class SSLManager private constructor(context: Context) { // Get all user-trusted certificates val trustedCerts = certManager.getTrustedCertificates() - val trustedFingerprints = trustedCerts.map { it.fingerprint }.toSet() + val trustedFingerprints = trustedCerts.map { calculateFingerprint(it) }.toSet() if (trustedCerts.isNotEmpty()) { trustManagers.addAll(createCombinedTrustManagers(trustedCerts)) } @@ -91,7 +91,7 @@ class SSLManager private constructor(context: Context) { if (serverCerts.isNotEmpty()) { val serverCert = serverCerts[0] as? X509Certificate if (serverCert != null) { - val serverFingerprint = TrustedCertificate.calculateFingerprint(serverCert) + val serverFingerprint = calculateFingerprint(serverCert) if (trustedFingerprints.contains(serverFingerprint)) { Log.d(TAG, "Hostname verification bypassed for $hostname - certificate is user-trusted") return@hostnameVerifier true @@ -165,18 +165,13 @@ class SSLManager private constructor(context: Context) { * Create TrustManagers that trust both user-added certs and system CAs. * Uses TrustManagerFactory (standard approach). */ - private fun createCombinedTrustManagers(userCerts: List): Array { + private fun createCombinedTrustManagers(userCerts: List): Array { // Create a KeyStore with all certificates val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) } // Add user-trusted certificates - userCerts.forEachIndexed { index, trustedCert -> - try { - val cert = certManager.parsePemCertificate(trustedCert.pemEncoded) - keyStore.setCertificateEntry("user$index", cert) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse trusted certificate: ${trustedCert.fingerprint}", e) - } + userCerts.forEachIndexed { index, cert -> + keyStore.setCertificateEntry("user$index", cert) } // Add system CA certificates for combined trust diff --git a/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt index d1481d6e..6afdc71c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt @@ -20,10 +20,11 @@ import com.google.android.material.textfield.TextInputLayout import io.heckel.ntfy.R import io.heckel.ntfy.tls.CertificateManager import io.heckel.ntfy.tls.ClientCertificate -import io.heckel.ntfy.tls.TrustedCertificate +import io.heckel.ntfy.tls.calculateFingerprint import io.heckel.ntfy.util.AfterChangedTextWatcher import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.validUrl +import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.* @@ -33,15 +34,16 @@ import java.util.* class CertificateFragment : DialogFragment() { private lateinit var listener: CertificateDialogListener private lateinit var certManager: CertificateManager - + private var mode: Mode = Mode.ADD_TRUSTED - private var trustedCertificate: TrustedCertificate? = null + private var trustedCertificate: X509Certificate? = null + private var trustedCertFingerprint: String? = null private var clientCertificate: ClientCertificate? = null - + // File contents private var certPem: String? = null private var pkcs12Data: ByteArray? = null - + // Views private lateinit var toolbar: MaterialToolbar private lateinit var saveMenuItem: MenuItem @@ -61,12 +63,12 @@ class CertificateFragment : DialogFragment() { private lateinit var validFromText: TextView private lateinit var validUntilText: TextView private lateinit var errorText: TextView - + // File pickers private val certFilePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { handleCertFileSelected(it) } } - + private val pkcs12FilePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri -> uri?.let { handlePkcs12FileSelected(it) } } @@ -89,29 +91,30 @@ class CertificateFragment : DialogFragment() { if (activity == null) { throw IllegalStateException("Activity cannot be null") } - + certManager = CertificateManager.getInstance(requireContext()) - + // Determine mode from arguments mode = Mode.valueOf(arguments?.getString(ARG_MODE) ?: Mode.ADD_TRUSTED.name) - + // Get existing certificate data if viewing arguments?.getString(ARG_TRUSTED_CERT_FINGERPRINT)?.let { fingerprint -> + trustedCertFingerprint = fingerprint trustedCertificate = certManager.getTrustedCertificates() - .find { it.fingerprint == fingerprint } + .find { calculateFingerprint(it) == fingerprint } } arguments?.getString(ARG_CLIENT_CERT_ALIAS)?.let { alias -> clientCertificate = certManager.getClientCertificates().find { it.alias == alias } } - + // Build the view val view = requireActivity().layoutInflater.inflate(R.layout.fragment_certificate_dialog, null) setupView(view) - + // Build dialog val dialog = Dialog(requireContext(), R.style.Theme_App_FullScreenDialog) dialog.setContentView(view) - + return dialog } @@ -144,7 +147,7 @@ class CertificateFragment : DialogFragment() { } saveMenuItem = toolbar.menu.findItem(R.id.certificate_dialog_action_save) deleteMenuItem = toolbar.menu.findItem(R.id.certificate_dialog_action_delete) - + // Setup views descriptionText = view.findViewById(R.id.certificate_dialog_description) baseUrlLayout = view.findViewById(R.id.certificate_dialog_base_url_layout) @@ -161,12 +164,12 @@ class CertificateFragment : DialogFragment() { validFromText = view.findViewById(R.id.certificate_dialog_valid_from) validUntilText = view.findViewById(R.id.certificate_dialog_valid_until) errorText = view.findViewById(R.id.certificate_dialog_error_text) - + // Validate input when typing val textWatcher = AfterChangedTextWatcher { validateInput() } baseUrlText.addTextChangedListener(textWatcher) passwordText.addTextChangedListener(textWatcher) - + // Configure based on mode when (mode) { Mode.ADD_TRUSTED -> setupAddTrustedMode() @@ -174,11 +177,11 @@ class CertificateFragment : DialogFragment() { Mode.VIEW_TRUSTED -> setupViewTrustedMode() Mode.VIEW_CLIENT -> setupViewClientMode() } - + // Initial validation validateInput() } - + private fun setupAddTrustedMode() { toolbar.setTitle(R.string.certificate_dialog_title_add_trusted) descriptionText.text = getString(R.string.certificate_dialog_description_add_trusted) @@ -188,11 +191,11 @@ class CertificateFragment : DialogFragment() { detailsLayout.isVisible = false saveMenuItem.setTitle(R.string.certificate_dialog_button_add) deleteMenuItem.isVisible = false - + selectCertButton.text = getString(R.string.certificate_dialog_select_cert_file) selectCertButton.setOnClickListener { certFilePicker.launch("*/*") } } - + private fun setupAddClientMode() { toolbar.setTitle(R.string.certificate_dialog_title_add_client) descriptionText.text = getString(R.string.certificate_dialog_description_add_client) @@ -202,17 +205,17 @@ class CertificateFragment : DialogFragment() { detailsLayout.isVisible = false saveMenuItem.setTitle(R.string.certificate_dialog_button_add) deleteMenuItem.isVisible = false - + selectCertButton.text = getString(R.string.certificate_dialog_select_p12_file) selectCertButton.setOnClickListener { pkcs12FilePicker.launch("*/*") } } - + private fun setupViewTrustedMode() { val cert = trustedCertificate ?: run { dismiss() return } - + toolbar.setTitle(R.string.certificate_dialog_title_view) descriptionText.isVisible = false baseUrlLayout.isVisible = false @@ -221,21 +224,21 @@ class CertificateFragment : DialogFragment() { detailsLayout.isVisible = true saveMenuItem.isVisible = false deleteMenuItem.isVisible = true - + val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - subjectText.text = cert.subject - issuerText.text = cert.issuer - fingerprintText.text = cert.fingerprint - validFromText.text = dateFormat.format(Date(cert.notBefore)) - validUntilText.text = dateFormat.format(Date(cert.notAfter)) + subjectText.text = cert.subjectX500Principal.name + issuerText.text = cert.issuerX500Principal.name + fingerprintText.text = calculateFingerprint(cert) + validFromText.text = dateFormat.format(cert.notBefore) + validUntilText.text = dateFormat.format(cert.notAfter) } - + private fun setupViewClientMode() { val cert = clientCertificate ?: run { dismiss() return } - + toolbar.setTitle(R.string.certificate_dialog_title_view) descriptionText.isVisible = false baseUrlLayout.isVisible = false @@ -244,7 +247,7 @@ class CertificateFragment : DialogFragment() { detailsLayout.isVisible = true saveMenuItem.isVisible = false deleteMenuItem.isVisible = true - + val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) subjectText.text = cert.subject issuerText.text = cert.issuer @@ -252,13 +255,13 @@ class CertificateFragment : DialogFragment() { validFromText.text = dateFormat.format(Date(cert.notBefore)) validUntilText.text = dateFormat.format(Date(cert.notAfter)) } - + private fun validateInput() { if (!this::saveMenuItem.isInitialized) return - + val baseUrl = baseUrlText.text?.toString()?.trim() ?: "" val password = passwordText.text?.toString() ?: "" - + when (mode) { Mode.ADD_TRUSTED -> { saveMenuItem.isEnabled = certPem != null @@ -271,11 +274,11 @@ class CertificateFragment : DialogFragment() { } } } - + private fun handleCertFileSelected(uri: Uri) { try { - val content = requireContext().contentResolver.openInputStream(uri)?.use { - it.bufferedReader().readText() + val content = requireContext().contentResolver.openInputStream(uri)?.use { + it.bufferedReader().readText() } if (content != null && content.contains("-----BEGIN CERTIFICATE-----")) { certPem = content @@ -290,11 +293,11 @@ class CertificateFragment : DialogFragment() { showError(getString(R.string.certificate_dialog_error_invalid_cert)) } } - + private fun handlePkcs12FileSelected(uri: Uri) { try { - val data = requireContext().contentResolver.openInputStream(uri)?.use { - it.readBytes() + val data = requireContext().contentResolver.openInputStream(uri)?.use { + it.readBytes() } if (data != null && data.isNotEmpty()) { pkcs12Data = data @@ -309,7 +312,7 @@ class CertificateFragment : DialogFragment() { showError(getString(R.string.certificate_dialog_error_invalid_p12)) } } - + private fun saveClicked() { when (mode) { Mode.ADD_TRUSTED -> addTrustedCertificate() @@ -317,24 +320,24 @@ class CertificateFragment : DialogFragment() { else -> { /* View modes don't have save */ } } } - + private fun deleteClicked() { when (mode) { - Mode.VIEW_TRUSTED -> trustedCertificate?.let { confirmDeleteTrustedCertificate(it) } + Mode.VIEW_TRUSTED -> trustedCertFingerprint?.let { confirmDeleteTrustedCertificate(it) } Mode.VIEW_CLIENT -> clientCertificate?.let { confirmDeleteClientCertificate(it) } else -> { /* Add modes don't have delete */ } } } - + private fun addTrustedCertificate() { val certContent = certPem - + // Validate if (certContent == null) { showError(getString(R.string.certificate_dialog_error_missing_cert)) return } - + try { val cert = certManager.parsePemCertificate(certContent) certManager.addTrustedCertificate(cert) @@ -346,12 +349,12 @@ class CertificateFragment : DialogFragment() { showError(getString(R.string.certificate_dialog_error_invalid_cert)) } } - + private fun addClientCertificate() { val baseUrl = baseUrlText.text.toString().trim() val data = pkcs12Data val password = passwordText.text.toString() - + // Validate if (baseUrl.isEmpty()) { showError(getString(R.string.certificate_dialog_error_missing_url)) @@ -369,7 +372,7 @@ class CertificateFragment : DialogFragment() { showError(getString(R.string.certificate_dialog_error_missing_password)) return } - + try { certManager.addClientCertificatePkcs12(baseUrl, data, password) Toast.makeText(context, R.string.certificate_dialog_added_toast, Toast.LENGTH_SHORT).show() @@ -380,12 +383,12 @@ class CertificateFragment : DialogFragment() { showError(getString(R.string.certificate_dialog_error_invalid_p12_password)) } } - - private fun confirmDeleteTrustedCertificate(cert: TrustedCertificate) { + + private fun confirmDeleteTrustedCertificate(fingerprint: String) { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.certificate_dialog_delete_confirm) .setPositiveButton(R.string.certificate_dialog_button_delete) { _, _ -> - certManager.removeTrustedCertificate(cert) + certManager.removeTrustedCertificate(fingerprint) Toast.makeText(context, R.string.certificate_dialog_deleted_toast, Toast.LENGTH_SHORT).show() listener.onCertificateDeleted() dismiss() @@ -393,7 +396,7 @@ class CertificateFragment : DialogFragment() { .setNegativeButton(R.string.certificate_dialog_button_cancel, null) .show() } - + private fun confirmDeleteClientCertificate(cert: ClientCertificate) { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.certificate_dialog_delete_confirm) @@ -406,7 +409,7 @@ class CertificateFragment : DialogFragment() { .setNegativeButton(R.string.certificate_dialog_button_cancel, null) .show() } - + private fun showError(message: String) { errorText.text = message errorText.isVisible = true @@ -424,7 +427,7 @@ class CertificateFragment : DialogFragment() { private const val ARG_MODE = "mode" private const val ARG_TRUSTED_CERT_FINGERPRINT = "trusted_cert_fingerprint" private const val ARG_CLIENT_CERT_ALIAS = "client_cert_alias" - + fun newInstanceAddTrusted(): CertificateFragment { return CertificateFragment().apply { arguments = Bundle().apply { @@ -432,7 +435,7 @@ class CertificateFragment : DialogFragment() { } } } - + fun newInstanceAddClient(): CertificateFragment { return CertificateFragment().apply { arguments = Bundle().apply { @@ -440,16 +443,16 @@ class CertificateFragment : DialogFragment() { } } } - - fun newInstanceViewTrusted(cert: TrustedCertificate): CertificateFragment { + + fun newInstanceViewTrusted(fingerprint: String): CertificateFragment { return CertificateFragment().apply { arguments = Bundle().apply { putString(ARG_MODE, Mode.VIEW_TRUSTED.name) - putString(ARG_TRUSTED_CERT_FINGERPRINT, cert.fingerprint) + putString(ARG_TRUSTED_CERT_FINGERPRINT, fingerprint) } } } - + fun newInstanceViewClient(cert: ClientCertificate): CertificateFragment { return CertificateFragment().apply { arguments = Bundle().apply { diff --git a/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt index 8ce8bd34..a2332cd4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt @@ -7,10 +7,12 @@ import androidx.preference.PreferenceCategory import io.heckel.ntfy.R import io.heckel.ntfy.tls.CertificateManager import io.heckel.ntfy.tls.ClientCertificate -import io.heckel.ntfy.tls.TrustedCertificate +import io.heckel.ntfy.tls.calculateFingerprint +import io.heckel.ntfy.tls.displaySubject import io.heckel.ntfy.util.shortUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.* @@ -30,11 +32,11 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen preferenceScreen.removeAll() lifecycleScope.launch(Dispatchers.IO) { val trustedCerts = certManager.getTrustedCertificates() - + val clientCerts = certManager.getClientCertificates() .groupBy { it.baseUrl } .toSortedMap() - + activity?.runOnUiThread { addTrustedCertPreferences(trustedCerts) addClientCertPreferences(clientCerts) @@ -42,14 +44,14 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen } } - private fun addTrustedCertPreferences(certs: List) { + private fun addTrustedCertPreferences(certs: List) { val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - + // Trusted certificates header val trustedCategory = PreferenceCategory(preferenceScreen.context) trustedCategory.title = getString(R.string.settings_certificates_prefs_trusted_header) preferenceScreen.addPreference(trustedCategory) - + if (certs.isEmpty()) { val emptyPref = Preference(preferenceScreen.context) emptyPref.title = getString(R.string.settings_certificates_prefs_trusted_empty) @@ -57,22 +59,23 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen trustedCategory.addPreference(emptyPref) } else { certs.forEach { cert -> + val fingerprint = calculateFingerprint(cert) val pref = Preference(preferenceScreen.context) - pref.title = cert.displaySubject() - pref.summary = if (cert.isValid()) { - getString(R.string.settings_certificates_prefs_expires, dateFormat.format(Date(cert.notAfter))) + pref.title = displaySubject(cert) + pref.summary = if (isValid(cert)) { + getString(R.string.settings_certificates_prefs_expires, dateFormat.format(cert.notAfter)) } else { getString(R.string.settings_certificates_prefs_expired) } pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { - CertificateFragment.newInstanceViewTrusted(cert) + CertificateFragment.newInstanceViewTrusted(fingerprint) .show(childFragmentManager, CertificateFragment.TAG) true } trustedCategory.addPreference(pref) } } - + // Add trusted certificate val addTrustedPref = Preference(preferenceScreen.context) addTrustedPref.title = getString(R.string.settings_certificates_prefs_trusted_add_title) @@ -87,12 +90,12 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen private fun addClientCertPreferences(certsByBaseUrl: Map>) { val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - + // Client certificates header val clientCategory = PreferenceCategory(preferenceScreen.context) clientCategory.title = getString(R.string.settings_certificates_prefs_client_header) preferenceScreen.addPreference(clientCategory) - + if (certsByBaseUrl.isEmpty()) { val emptyPref = Preference(preferenceScreen.context) emptyPref.title = getString(R.string.settings_certificates_prefs_client_empty) @@ -117,7 +120,7 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen } } } - + // Add client certificate val addClientPref = Preference(preferenceScreen.context) addClientPref.title = getString(R.string.settings_certificates_prefs_client_add_title) @@ -130,6 +133,11 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen clientCategory.addPreference(addClientPref) } + private fun isValid(cert: X509Certificate): Boolean { + val now = Date() + return now.after(cert.notBefore) && now.before(cert.notAfter) + } + // CertificateFragment.CertificateDialogListener implementation override fun onCertificateAdded() { reload() @@ -139,4 +147,3 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen reload() } } - diff --git a/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt index 274c5ccb..833cb2e4 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt @@ -11,7 +11,7 @@ import androidx.fragment.app.DialogFragment import com.google.android.material.appbar.MaterialToolbar import io.heckel.ntfy.R import io.heckel.ntfy.tls.CertificateManager -import io.heckel.ntfy.tls.TrustedCertificate +import io.heckel.ntfy.tls.calculateFingerprint import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.* @@ -112,7 +112,7 @@ class CertificateTrustFragment : DialogFragment() { // Populate certificate details subjectText.text = certificate.subjectX500Principal.name issuerText.text = certificate.issuerX500Principal.name - fingerprintText.text = TrustedCertificate.calculateFingerprint(certificate) + fingerprintText.text = calculateFingerprint(certificate) validFromText.text = dateFormat.format(certificate.notBefore) validUntilText.text = dateFormat.format(certificate.notAfter)