diff --git a/app/schemas/io.heckel.ntfy.db.Database/16.json b/app/schemas/io.heckel.ntfy.db.Database/16.json index 0577c859..782f5e22 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/16.json +++ b/app/schemas/io.heckel.ntfy.db.Database/16.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 16, - "identityHash": "1e2fa6a6cd2ed5146905f38f71f3c904", + "identityHash": "af6e656e277e4390d3ebbbca1c4bb845", "entities": [ { "tableName": "Subscription", @@ -362,8 +362,14 @@ }, { "tableName": "TrustedCertificate", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fingerprint` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`fingerprint`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `fingerprint` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, { "fieldPath": "fingerprint", "columnName": "fingerprint", @@ -380,7 +386,7 @@ "primaryKey": { "autoGenerate": false, "columnNames": [ - "fingerprint" + "baseUrl" ] } }, @@ -417,7 +423,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1e2fa6a6cd2ed5146905f38f71f3c904')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'af6e656e277e4390d3ebbbca1c4bb845')" ] } } \ No newline at end of file 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 2a6ed49b..c80822c6 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -231,9 +231,9 @@ class Backuper(val context: Context) { try { val cert = CertUtil.parsePemCertificate(c.pem) val fingerprint = CertUtil.calculateFingerprint(cert) - repository.addTrustedCertificate(fingerprint, c.pem) + repository.addTrustedCertificate(c.baseUrl, fingerprint, c.pem) } catch (e: Exception) { - Log.w(TAG, "Unable to restore trusted certificate: ${e.message}. Ignoring.", e) + Log.w(TAG, "Unable to restore trusted certificate for ${c.baseUrl}: ${e.message}. Ignoring.", e) } } } @@ -375,7 +375,10 @@ class Backuper(val context: Context) { private suspend fun createTrustedCertificateList(): List { return repository.getTrustedCertificates().map { trustedCert -> - TrustedCertificateBackup(pem = trustedCert.pem) + TrustedCertificateBackup( + baseUrl = trustedCert.baseUrl, + pem = trustedCert.pem + ) } } @@ -492,6 +495,7 @@ data class User( ) data class TrustedCertificateBackup( + val baseUrl: String, val pem: String ) diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index a3b4ab06..b28e6ac1 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -190,7 +190,8 @@ data class User( @Entity(tableName = "TrustedCertificate") data class TrustedCertificate( - @PrimaryKey @ColumnInfo(name = "fingerprint") val fingerprint: String, + @PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String, + @ColumnInfo(name = "fingerprint") val fingerprint: String, @ColumnInfo(name = "pem") val pem: String ) @@ -386,7 +387,7 @@ abstract class Database : RoomDatabase() { private val MIGRATION_15_16 = object : Migration(15, 16) { override fun migrate(db: SupportSQLiteDatabase) { - db.execSQL("CREATE TABLE TrustedCertificate (fingerprint TEXT NOT NULL, pem TEXT NOT NULL, PRIMARY KEY(fingerprint))") + db.execSQL("CREATE TABLE TrustedCertificate (baseUrl TEXT NOT NULL, fingerprint TEXT NOT NULL, pem TEXT NOT NULL, PRIMARY KEY(baseUrl))") db.execSQL("CREATE TABLE ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))") } } @@ -565,11 +566,14 @@ interface TrustedCertificateDao { @Query("SELECT * FROM TrustedCertificate") suspend fun list(): List + @Query("SELECT * FROM TrustedCertificate WHERE baseUrl = :baseUrl") + suspend fun get(baseUrl: String): TrustedCertificate? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(cert: TrustedCertificate) - @Query("DELETE FROM TrustedCertificate WHERE fingerprint = :fingerprint") - suspend fun delete(fingerprint: String) + @Query("DELETE FROM TrustedCertificate WHERE baseUrl = :baseUrl") + suspend fun delete(baseUrl: String) } @Dao diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 329780d8..ee6f1d6a 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -199,12 +199,16 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) return trustedCertificateDao.list() } - suspend fun addTrustedCertificate(fingerprint: String, pem: String) { - trustedCertificateDao.insert(TrustedCertificate(fingerprint, pem)) + suspend fun getTrustedCertificate(baseUrl: String): TrustedCertificate? { + return trustedCertificateDao.get(baseUrl) } - suspend fun removeTrustedCertificate(fingerprint: String) { - trustedCertificateDao.delete(fingerprint) + suspend fun addTrustedCertificate(baseUrl: String, fingerprint: String, pem: String) { + trustedCertificateDao.insert(TrustedCertificate(baseUrl, fingerprint, pem)) + } + + suspend fun removeTrustedCertificate(baseUrl: String) { + trustedCertificateDao.delete(baseUrl) } // Client certificates diff --git a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt index 9aa15929..9bd02a9c 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/AddFragment.kt @@ -289,7 +289,7 @@ class AddFragment : DialogFragment(), TrustedCertificateFragment.TrustedCertific enableSubscribeView(true) TrustedCertificateFragment - .newInstanceUnknown(certificate) + .newInstanceUnknown(certificate, baseUrl) .show(childFragmentManager, TrustedCertificateFragment.TAG) } 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 55478ede..21f9b45e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt @@ -70,20 +70,19 @@ class CertificateSettingsFragment : BasePreferenceFragment(), try { val cert = CertUtil.parsePemCertificate(trustedCert.pem) val subject = parseCommonName(cert.subjectX500Principal.name) - val issuer = parseCommonName(cert.issuerX500Principal.name) - val isSelfSigned = cert.subjectX500Principal == cert.issuerX500Principal val isExpired = !isValid(cert) val pref = Preference(preferenceScreen.context) - pref.title = subject - pref.summary = when { - isSelfSigned && isExpired -> getString(R.string.settings_advanced_certificates_trusted_item_summary_ca_expired) - isSelfSigned -> getString(R.string.settings_advanced_certificates_trusted_item_summary_ca, dateFormat.format(cert.notAfter)) - isExpired -> getString(R.string.settings_advanced_certificates_trusted_item_summary_leaf_expired, issuer) - else -> getString(R.string.settings_advanced_certificates_trusted_item_summary_leaf, issuer, dateFormat.format(cert.notAfter)) + // Title is the short URL (baseUrl) + pref.title = shortUrl(trustedCert.baseUrl) + // Summary shows certificate subject and expiry + pref.summary = if (isExpired) { + getString(R.string.settings_advanced_certificates_trusted_item_summary_expired, subject) + } else { + getString(R.string.settings_advanced_certificates_trusted_item_summary, subject, dateFormat.format(cert.notAfter)) } pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { - TrustedCertificateFragment.newInstanceView(trustedCert.fingerprint) + TrustedCertificateFragment.newInstanceView(trustedCert.baseUrl) .show(childFragmentManager, TrustedCertificateFragment.TAG) true } diff --git a/app/src/main/java/io/heckel/ntfy/ui/TrustedCertificateFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/TrustedCertificateFragment.kt index 48cb8708..53be0e84 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/TrustedCertificateFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/TrustedCertificateFragment.kt @@ -4,16 +4,22 @@ import android.app.Dialog import android.content.Context import android.os.Bundle import android.view.MenuItem +import android.view.View import android.view.ViewGroup +import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar +import com.google.android.material.textfield.TextInputEditText +import com.google.android.material.textfield.TextInputLayout import io.heckel.ntfy.R import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.util.AfterChangedTextWatcher import io.heckel.ntfy.util.CertUtil +import io.heckel.ntfy.util.validUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -27,30 +33,44 @@ import java.util.Locale * Full-screen dialog fragment for viewing and trusting/deleting server certificates. * * Modes: - * - UNKNOWN: Shows certificate from SSL error with "Trust" action (from AddFragment) - * - ADD: Shows certificate from file picker with "Trust" action (from CertificateSettingsFragment) - * - VIEW: Shows certificate details with "Delete" action (from CertificateSettingsFragment) + * - UNKNOWN: Shows certificate from SSL error with "Trust" action (from AddFragment). + * baseUrl is passed as argument, goes directly to page 2. + * - ADD: Two-page flow - first enter Service URL, then view details and trust. + * Certificate is passed as argument. + * - VIEW: Shows certificate details with "Delete" action (from CertificateSettingsFragment). + * baseUrl is passed as argument. */ class TrustedCertificateFragment : DialogFragment() { private lateinit var repository: Repository private var listener: TrustedCertificateListener? = null private var mode: Mode = Mode.ADD + private var currentPage: Int = 1 private var cert: X509Certificate? = null - private var fingerprint: String? = null + private var baseUrl: String? = null private lateinit var toolbar: MaterialToolbar + private lateinit var nextMenuItem: MenuItem private lateinit var trustMenuItem: MenuItem private lateinit var deleteMenuItem: MenuItem + + // Page 1 views + private lateinit var page1Layout: LinearLayout + private lateinit var baseUrlLayout: TextInputLayout + private lateinit var baseUrlText: TextInputEditText + private lateinit var errorText: TextView + + // Page 2 views + private lateinit var page2Layout: LinearLayout private lateinit var descriptionText: TextView private lateinit var warningText: TextView + private lateinit var baseUrlValueLabel: TextView + private lateinit var baseUrlValueText: TextView private lateinit var subjectText: TextView private lateinit var issuerText: TextView private lateinit var fingerprintText: TextView private lateinit var validFromText: TextView private lateinit var validUntilText: TextView - private lateinit var caText: TextView - private lateinit var caInfoText: TextView interface TrustedCertificateListener { fun onCertificateTrusted(certificate: X509Certificate) @@ -77,18 +97,25 @@ class TrustedCertificateFragment : DialogFragment() { // Determine mode from arguments mode = Mode.valueOf(arguments?.getString(ARG_MODE) ?: Mode.ADD.name) - // Get certificate data based on mode + // Get data based on mode when (mode) { - Mode.UNKNOWN, Mode.ADD -> { + Mode.UNKNOWN -> { val certBytes = arguments?.getByteArray(ARG_CERTIFICATE) - ?: throw IllegalArgumentException("Certificate bytes required for ADD/UNKNOWN mode") + ?: throw IllegalArgumentException("Certificate bytes required for UNKNOWN mode") + baseUrl = arguments?.getString(ARG_BASE_URL) + ?: throw IllegalArgumentException("Base URL required for UNKNOWN mode") + val certFactory = CertificateFactory.getInstance("X.509") + cert = certFactory.generateCertificate(java.io.ByteArrayInputStream(certBytes)) as X509Certificate + } + Mode.ADD -> { + val certBytes = arguments?.getByteArray(ARG_CERTIFICATE) + ?: throw IllegalArgumentException("Certificate bytes required for ADD mode") val certFactory = CertificateFactory.getInstance("X.509") cert = certFactory.generateCertificate(java.io.ByteArrayInputStream(certBytes)) as X509Certificate - fingerprint = CertUtil.calculateFingerprint(cert!!) } Mode.VIEW -> { - fingerprint = arguments?.getString(ARG_FINGERPRINT) - ?: throw IllegalArgumentException("Fingerprint required for VIEW mode") + baseUrl = arguments?.getString(ARG_BASE_URL) + ?: throw IllegalArgumentException("Base URL required for VIEW mode") } } @@ -114,17 +141,16 @@ class TrustedCertificateFragment : DialogFragment() { } } - private fun setupView(view: android.view.View) { + private fun setupView(view: View) { // Setup toolbar toolbar = view.findViewById(R.id.trusted_certificate_toolbar) - toolbar.setNavigationOnClickListener { - if (mode == Mode.ADD || mode == Mode.UNKNOWN) { - listener?.onCertificateRejected() - } - dismiss() - } + toolbar.setNavigationOnClickListener { handleBack() } toolbar.setOnMenuItemClickListener { menuItem -> when (menuItem.itemId) { + R.id.trusted_certificate_action_next -> { + nextClicked() + true + } R.id.trusted_certificate_action_trust -> { trustCertificate() true @@ -136,19 +162,31 @@ class TrustedCertificateFragment : DialogFragment() { else -> false } } + nextMenuItem = toolbar.menu.findItem(R.id.trusted_certificate_action_next) trustMenuItem = toolbar.menu.findItem(R.id.trusted_certificate_action_trust) deleteMenuItem = toolbar.menu.findItem(R.id.trusted_certificate_action_delete) - // Setup views + // Page 1 views + page1Layout = view.findViewById(R.id.trusted_certificate_page1) + baseUrlLayout = view.findViewById(R.id.trusted_certificate_base_url_layout) + baseUrlText = view.findViewById(R.id.trusted_certificate_base_url_text) + errorText = view.findViewById(R.id.trusted_certificate_error_text) + + // Page 2 views + page2Layout = view.findViewById(R.id.trusted_certificate_page2) descriptionText = view.findViewById(R.id.trusted_certificate_description) warningText = view.findViewById(R.id.trusted_certificate_warning) + baseUrlValueLabel = view.findViewById(R.id.trusted_certificate_base_url_value_label) + baseUrlValueText = view.findViewById(R.id.trusted_certificate_base_url_value) subjectText = view.findViewById(R.id.trusted_certificate_subject) issuerText = view.findViewById(R.id.trusted_certificate_issuer) fingerprintText = view.findViewById(R.id.trusted_certificate_fingerprint) validFromText = view.findViewById(R.id.trusted_certificate_valid_from) validUntilText = view.findViewById(R.id.trusted_certificate_valid_until) - caText = view.findViewById(R.id.trusted_certificate_ca) - caInfoText = view.findViewById(R.id.trusted_certificate_ca_info) + + // Validate input when typing + val textWatcher = AfterChangedTextWatcher { validatePage1() } + baseUrlText.addTextChangedListener(textWatcher) when (mode) { Mode.UNKNOWN -> setupUnknownMode() @@ -158,34 +196,47 @@ class TrustedCertificateFragment : DialogFragment() { } private fun setupUnknownMode() { + // Go directly to page 2 with details toolbar.setTitle(R.string.trusted_certificate_dialog_title_unknown) - descriptionText.setText(R.string.trusted_certificate_dialog_description_unknown) - descriptionText.isVisible = true + toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) + page1Layout.isVisible = false + page2Layout.isVisible = true + nextMenuItem.isVisible = false trustMenuItem.isVisible = true deleteMenuItem.isVisible = false + descriptionText.setText(R.string.trusted_certificate_dialog_description_unknown) + descriptionText.isVisible = true + baseUrlValueLabel.isVisible = true + baseUrlValueText.isVisible = true + baseUrlValueText.text = baseUrl + displayCertificateDetails(cert!!) } private fun setupAddMode() { toolbar.setTitle(R.string.trusted_certificate_dialog_title_add) - descriptionText.setText(R.string.trusted_certificate_dialog_description_add) - descriptionText.isVisible = true - trustMenuItem.isVisible = true - deleteMenuItem.isVisible = false - - displayCertificateDetails(cert!!) + toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) + showPage1() } private fun setupViewMode() { toolbar.setTitle(R.string.trusted_certificate_dialog_title) - descriptionText.isVisible = false + toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) + page1Layout.isVisible = false + page2Layout.isVisible = true + nextMenuItem.isVisible = false trustMenuItem.isVisible = false deleteMenuItem.isVisible = true + descriptionText.isVisible = false + baseUrlValueLabel.isVisible = true + baseUrlValueText.isVisible = true + baseUrlValueText.text = baseUrl + // Load certificate from repository lifecycleScope.launch(Dispatchers.IO) { - val trustedCert = repository.getTrustedCertificates().find { it.fingerprint == fingerprint } + val trustedCert = repository.getTrustedCertificate(baseUrl!!) if (trustedCert != null) { try { val x509Cert = CertUtil.parsePemCertificate(trustedCert.pem) @@ -195,7 +246,7 @@ class TrustedCertificateFragment : DialogFragment() { } } catch (e: Exception) { withContext(Dispatchers.Main) { - fingerprintText.text = fingerprint + fingerprintText.text = trustedCert.fingerprint } } } else { @@ -206,6 +257,65 @@ class TrustedCertificateFragment : DialogFragment() { } } + private fun showPage1() { + currentPage = 1 + page1Layout.isVisible = true + page2Layout.isVisible = false + toolbar.setNavigationIcon(R.drawable.ic_close_white_24dp) + nextMenuItem.isVisible = true + trustMenuItem.isVisible = false + deleteMenuItem.isVisible = false + validatePage1() + } + + private fun showPage2() { + currentPage = 2 + page1Layout.isVisible = false + page2Layout.isVisible = true + toolbar.setNavigationIcon(R.drawable.ic_arrow_back_white_24dp) + nextMenuItem.isVisible = false + trustMenuItem.isVisible = true + deleteMenuItem.isVisible = false + + descriptionText.setText(R.string.trusted_certificate_dialog_description_add) + descriptionText.isVisible = true + baseUrlValueLabel.isVisible = true + baseUrlValueText.isVisible = true + baseUrlValueText.text = baseUrl + + displayCertificateDetails(cert!!) + } + + private fun validatePage1() { + val url = baseUrlText.text?.toString()?.trim() ?: "" + nextMenuItem.isEnabled = validUrl(url) + } + + private fun nextClicked() { + val url = baseUrlText.text?.toString()?.trim() ?: "" + + if (!validUrl(url)) { + showError(getString(R.string.trusted_certificate_dialog_error_invalid_url)) + return + } + + baseUrl = url + errorText.isVisible = false + showPage2() + } + + private fun handleBack() { + when { + mode == Mode.VIEW -> dismiss() + mode == Mode.UNKNOWN -> { + listener?.onCertificateRejected() + dismiss() + } + currentPage == 2 -> showPage1() + else -> dismiss() + } + } + private fun displayCertificateDetails(certificate: X509Certificate) { val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) @@ -215,11 +325,6 @@ class TrustedCertificateFragment : DialogFragment() { validFromText.text = dateFormat.format(certificate.notBefore) validUntilText.text = dateFormat.format(certificate.notAfter) - // Determine if this is a CA certificate (self-signed) - val isCa = certificate.subjectX500Principal == certificate.issuerX500Principal - caText.text = if (isCa) getString(R.string.common_yes) else getString(R.string.common_no) - caInfoText.isVisible = isCa - // Show warning if certificate is expired or not yet valid val now = Date() when { @@ -237,12 +342,18 @@ class TrustedCertificateFragment : DialogFragment() { } } + private fun showError(message: String) { + errorText.text = message + errorText.isVisible = true + } + private fun trustCertificate() { val certificate = cert ?: return + val url = baseUrl ?: return lifecycleScope.launch(Dispatchers.IO) { val fingerprint = CertUtil.calculateFingerprint(certificate) val pem = CertUtil.encodeCertificateToPem(certificate) - repository.addTrustedCertificate(fingerprint, pem) + repository.addTrustedCertificate(url, fingerprint, pem) withContext(Dispatchers.Main) { Toast.makeText(context, R.string.trusted_certificate_dialog_added_toast, Toast.LENGTH_SHORT).show() listener?.onCertificateTrusted(certificate) @@ -252,9 +363,9 @@ class TrustedCertificateFragment : DialogFragment() { } private fun deleteCertificate() { - val fp = fingerprint ?: return + val url = baseUrl ?: return lifecycleScope.launch(Dispatchers.IO) { - repository.removeTrustedCertificate(fp) + repository.removeTrustedCertificate(url) withContext(Dispatchers.Main) { Toast.makeText(context, R.string.trusted_certificate_dialog_deleted_toast, Toast.LENGTH_SHORT).show() listener?.onCertificateDeleted() @@ -273,24 +384,28 @@ class TrustedCertificateFragment : DialogFragment() { const val TAG = "NtfyTrustedCertFragment" private const val ARG_MODE = "mode" private const val ARG_CERTIFICATE = "certificate" - private const val ARG_FINGERPRINT = "fingerprint" + private const val ARG_BASE_URL = "base_url" /** - * Create fragment for UNKNOWN mode - showing unknown server certificate with Trust action - * Used when connecting to a server with an untrusted certificate (from AddFragment) + * Create fragment for UNKNOWN mode - showing unknown server certificate with Trust action. + * Used when connecting to a server with an untrusted certificate (from AddFragment). + * The baseUrl is provided so it goes directly to the details page. */ - fun newInstanceUnknown(certificate: X509Certificate): TrustedCertificateFragment { + fun newInstanceUnknown(certificate: X509Certificate, baseUrl: String): TrustedCertificateFragment { return TrustedCertificateFragment().apply { arguments = Bundle().apply { putString(ARG_MODE, Mode.UNKNOWN.name) putByteArray(ARG_CERTIFICATE, certificate.encoded) + putString(ARG_BASE_URL, baseUrl) } } } /** - * Create fragment for ADD mode - showing certificate details with Trust action - * Used when adding a certificate from file picker (from CertificateSettingsFragment) + * Create fragment for ADD mode - two-page flow to add a trusted certificate. + * Page 1: Enter Service URL + * Page 2: View certificate details and trust + * Used when adding a certificate from file picker (from CertificateSettingsFragment). */ fun newInstanceAdd(certificate: X509Certificate): TrustedCertificateFragment { return TrustedCertificateFragment().apply { @@ -302,13 +417,14 @@ class TrustedCertificateFragment : DialogFragment() { } /** - * Create fragment for VIEW mode - showing certificate details with Delete action + * Create fragment for VIEW mode - showing certificate details with Delete action. + * baseUrl is used to look up the certificate from the repository. */ - fun newInstanceView(fingerprint: String): TrustedCertificateFragment { + fun newInstanceView(baseUrl: String): TrustedCertificateFragment { return TrustedCertificateFragment().apply { arguments = Bundle().apply { putString(ARG_MODE, Mode.VIEW.name) - putString(ARG_FINGERPRINT, fingerprint) + putString(ARG_BASE_URL, baseUrl) } } } diff --git a/app/src/main/java/io/heckel/ntfy/util/CertUtil.kt b/app/src/main/java/io/heckel/ntfy/util/CertUtil.kt index a9db4d00..b3697d44 100644 --- a/app/src/main/java/io/heckel/ntfy/util/CertUtil.kt +++ b/app/src/main/java/io/heckel/ntfy/util/CertUtil.kt @@ -6,15 +6,14 @@ import android.util.Base64 import io.heckel.ntfy.db.ClientCertificate import io.heckel.ntfy.db.Repository import okhttp3.OkHttpClient -import okhttp3.internal.tls.OkHostnameVerifier import java.io.ByteArrayInputStream import java.net.URL import java.security.KeyStore import java.security.MessageDigest import java.security.SecureRandom +import java.security.cert.CertificateException import java.security.cert.CertificateFactory import java.security.cert.X509Certificate -import javax.net.ssl.HostnameVerifier import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory import javax.net.ssl.SSLContext @@ -22,13 +21,14 @@ import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.HostnameVerifier import javax.net.ssl.X509TrustManager /** * TLS config: - * - Trust system roots - * - Also trust user-added certs (leaf and/or CA; chains to user-added CAs) - * - Hostname verify ONLY when chain is system-trusted; skip when only user-trusted + * - For each baseUrl, either use the pinned certificate (if one exists) OR system trust + * - Pinned cert = ONLY that exact certificate is trusted (strict pinning) + * - Hostname verification is bypassed for pinned certificates (the fingerprint match is the trust anchor) * - Optional mTLS via per-baseUrl PKCS#12 client cert */ class CertUtil private constructor(context: Context) { @@ -37,32 +37,35 @@ class CertUtil private constructor(context: Context) { suspend fun withTLSConfig(builder: OkHttpClient.Builder, baseUrl: String): OkHttpClient.Builder { try { - val trustedCerts = repository.getTrustedCertificates().mapNotNull { - try { - parsePemCertificate(it.pem) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse trusted certificate: ${it.fingerprint}", e) - null - } - } + val pinnedCert = repository.getTrustedCertificate(baseUrl) val clientCert = repository.getClientCertificate(baseUrl) val clientCertKeyManagers = clientCert?.let { clientCertKeyManagers(it) } - // Always include system trust; add user trust if present. - val systemTm = systemTrustManager() - val userTm = if (trustedCerts.isNotEmpty()) trustManagerForUserCerts(trustedCerts) else null - val compositeTm = if (userTm != null) compositeTrustManager(systemTm, userTm) else systemTm - - // Only override SSL config if we actually have something to add (user trust or mTLS). - if (userTm != null || clientCertKeyManagers != null) { - val sslContext = SSLContext.getInstance("TLS").apply { - init(clientCertKeyManagers, arrayOf(compositeTm), SecureRandom()) + // Determine which trust manager to use + val trustManager: X509TrustManager = if (pinnedCert != null) { + // Strict pinning: only trust the exact pinned certificate + try { + val cert = parsePemCertificate(pinnedCert.pem) + pinnedCertTrustManager(cert) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse pinned certificate for $baseUrl, falling back to system trust", e) + systemTrustManager() } - builder.sslSocketFactory(sslContext.socketFactory, compositeTm) + } else { + systemTrustManager() + } - // Hostname rules only matter if we have custom trust. If not, keep default verifier. - if (userTm != null) { - builder.hostnameVerifier(selectiveHostnameVerifier(systemTm, userTm)) + // Only override SSL config if we have a pinned cert or client cert + if (pinnedCert != null || clientCertKeyManagers != null) { + val sslContext = SSLContext.getInstance("TLS").apply { + init(clientCertKeyManagers, arrayOf(trustManager), SecureRandom()) + } + builder.sslSocketFactory(sslContext.socketFactory, trustManager) + + // Bypass hostname verification for pinned certificates + // The certificate fingerprint match is the trust anchor, not the hostname + if (pinnedCert != null) { + builder.hostnameVerifier(trustAllHostnameVerifier()) } } } catch (e: Exception) { @@ -144,81 +147,48 @@ class CertUtil private constructor(context: Context) { return tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager } - private fun trustManagerForUserCerts(trustedCerts: List): X509TrustManager { - val ks = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null, null) } - trustedCerts.forEachIndexed { idx, cert -> - ks.setCertificateEntry("added-$idx", cert) - } - val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { - init(ks) - } - return tmf.trustManagers.first { it is X509TrustManager } as X509TrustManager + /** + * Hostname verifier that accepts any hostname. + * Used for pinned certificates where the fingerprint match is the trust anchor. + */ + @SuppressLint("BadHostnameVerifier") + private fun trustAllHostnameVerifier(): HostnameVerifier { + return HostnameVerifier { _, _ -> true } } /** - * Trust if either system OR custom accepts the chain. + * Create a trust manager that ONLY trusts the exact pinned certificate. + * This implements strict certificate pinning - system CAs are not trusted. */ - private fun compositeTrustManager(systemTm: X509TrustManager, userTm: X509TrustManager): X509TrustManager = - object : X509TrustManager { - override fun getAcceptedIssuers(): Array = - (systemTm.acceptedIssuers + userTm.acceptedIssuers) - .distinctBy { it.subjectX500Principal.name } - .toTypedArray() + @SuppressLint("CustomX509TrustManager") + private fun pinnedCertTrustManager(pinnedCert: X509Certificate): X509TrustManager { + val pinnedFingerprint = calculateFingerprint(pinnedCert) + return object : X509TrustManager { + override fun getAcceptedIssuers(): Array = arrayOf(pinnedCert) override fun checkClientTrusted(chain: Array?, authType: String?) { - try { - systemTm.checkClientTrusted(chain, authType) - } catch (_: Exception) { - userTm.checkClientTrusted(chain, authType) - } + throw CertificateException("Client authentication not supported with pinned certificate") } override fun checkServerTrusted(chain: Array?, authType: String?) { - try { - systemTm.checkServerTrusted(chain, authType) - } catch (_: Exception) { - userTm.checkServerTrusted(chain, authType) + if (chain.isNullOrEmpty()) { + throw CertificateException("Empty certificate chain") } - } - } - - /** - * Hostname verification: - * - if system-trusted => enforce hostname verification - * - else if custom-trusted => skip hostname verification - * - else => fail - */ - private fun selectiveHostnameVerifier(systemTm: X509TrustManager, userTm: X509TrustManager) = - HostnameVerifier { hostname, session -> - val chain = try { - session.peerCertificates.map { it as X509Certificate }.toTypedArray() - } catch (_: Exception) { - return@HostnameVerifier false - } - if (isTrustedBy(systemTm, chain)) { - OkHostnameVerifier.verify(hostname, session) - } else { - isTrustedBy(userTm, chain) - } - } - - private fun isTrustedBy(tm: X509TrustManager, chain: Array): Boolean { - // authType not reliably available here; try common ones. - return try { - tm.checkServerTrusted(chain, "RSA") - true - } catch (_: Exception) { - try { - tm.checkServerTrusted(chain, "EC") - true - } catch (_: Exception) { - false + val serverCert = chain[0] + val serverFingerprint = calculateFingerprint(serverCert) + if (serverFingerprint != pinnedFingerprint) { + throw CertificateException( + "Certificate fingerprint mismatch. Expected: $pinnedFingerprint, Got: $serverFingerprint" + ) + } + // Optionally verify certificate validity + serverCert.checkValidity() } } } companion object { - private const val TAG = "NtfySSLManager" + private const val TAG = "NtfyCertUtil" @Volatile @SuppressLint("StaticFieldLeak") diff --git a/app/src/main/res/layout/fragment_trusted_certificate_dialog.xml b/app/src/main/res/layout/fragment_trusted_certificate_dialog.xml index 3b226e53..5dc0e3c8 100644 --- a/app/src/main/res/layout/fragment_trusted_certificate_dialog.xml +++ b/app/src/main/res/layout/fragment_trusted_certificate_dialog.xml @@ -36,223 +36,247 @@ android:paddingHorizontal="?dialogPreferredPadding" app:layout_behavior="@string/appbar_scrolling_view_behavior"> - + android:layout_height="wrap_content" + android:orientation="vertical"> - - + + android:orientation="vertical" + android:visibility="gone"> - - + + + + + + + + + + + + + + android:orientation="vertical" + android:visibility="gone"> - - + + - + + - - + + - + - - + + - + - - + + - + - - + + - + - - + + - + - - + - + + + + + + + + + + + + + + + + + + + - diff --git a/app/src/main/res/menu/menu_trusted_certificate_dialog.xml b/app/src/main/res/menu/menu_trusted_certificate_dialog.xml index 0eed4986..2db02bb6 100644 --- a/app/src/main/res/menu/menu_trusted_certificate_dialog.xml +++ b/app/src/main/res/menu/menu_trusted_certificate_dialog.xml @@ -1,6 +1,11 @@ + - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index addac483..ab258fb7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -428,10 +428,8 @@ Manage certificates Add certificates to the trust store and manage client certificates for mTLS Trusted certificates - Leaf certificate, issued by %1$s\nExpires %2$s - Leaf certificate, issued by %1$s\nExpired - CA certificate, self-signed\nExpires %1$s - CA certificate, self-signed\nExpired + %1$s, expires %2$s + %1$s, expired Add a trusted certificate Import a certificate into the trust store (PEM). HTTPS and WebSocket connections will trust this certificate. Client certificates (mTLS) @@ -505,18 +503,22 @@ Certificate details Unknown certificate Add trusted certificate - The server\'s certificate could not be verified. This may happen with self-signed certificates or custom Certificate Authorities. Review the details below before trusting. - The server\'s certificate could not be verified. This may happen with self-signed certificates or custom Certificate Authorities. Review the details below before trusting. + The server\'s certificate could not be verified. Review the details below before trusting. + The server\'s certificate could not be verified. Review the details below before trusting. You\'ve selected a certificate file. Please review the details below before adding it to your trusted certificates. + Enter the service URL that this certificate should be pinned to. The certificate will only be trusted for this URL. + Service URL + https://ntfy.example.com + Service URL Subject Issuer SHA-256 fingerprint Valid from Valid until - Certificate Authority (CA) - All certificates signed by this Certificate Authority will be trusted. Warning: This certificate has expired. Warning: This certificate is not yet valid. + Invalid URL + Next Trust Delete Certificate added