Remove CA support, make trusted certs pinned to hostname

This commit is contained in:
Philipp Heckel 2026-01-08 22:33:35 -05:00
parent eb11eec984
commit 4304004a17
11 changed files with 498 additions and 365 deletions

View file

@ -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')"
]
}
}

View file

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

View file

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

View file

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

View file

@ -289,7 +289,7 @@ class AddFragment : DialogFragment(), TrustedCertificateFragment.TrustedCertific
enableSubscribeView(true)
TrustedCertificateFragment
.newInstanceUnknown(certificate)
.newInstanceUnknown(certificate, baseUrl)
.show(childFragmentManager, TrustedCertificateFragment.TAG)
}

View file

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

View file

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

View file

@ -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<TrustManager>(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>(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<X509Certificate>): 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<X509Certificate> =
(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<X509Certificate> = arrayOf(pinnedCert)
override fun checkClientTrusted(chain: Array<out X509Certificate>?, 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<out X509Certificate>?, 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<X509Certificate>): 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")

View file

@ -36,223 +36,247 @@
android:paddingHorizontal="?dialogPreferredPadding"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.constraintlayout.widget.ConstraintLayout
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- Description (only shown in ADD mode) -->
<TextView
android:id="@+id/trusted_certificate_description"
<!-- Page 1: Service URL input -->
<LinearLayout
android:id="@+id/trusted_certificate_page1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_description"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="16dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
android:orientation="vertical"
android:visibility="gone">
<!-- Warning for expired/not-yet-valid certs -->
<TextView
android:id="@+id/trusted_certificate_warning"
<TextView
android:id="@+id/trusted_certificate_page1_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_description_page1"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="16dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/trusted_certificate_base_url_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="@string/trusted_certificate_dialog_base_url_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/trusted_certificate_base_url_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textUri"
android:text="@string/trusted_certificate_dialog_base_url_placeholder" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/trusted_certificate_error_text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/holo_red_dark"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="8dp"
android:visibility="gone" />
</LinearLayout>
<!-- Page 2: Certificate details -->
<LinearLayout
android:id="@+id/trusted_certificate_page2"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/holo_red_dark"
android:textStyle="bold"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_description"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
android:orientation="vertical"
android:visibility="gone">
<!-- Subject -->
<TextView
android:id="@+id/trusted_certificate_subject_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_subject"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_warning"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Description (shown in ADD and UNKNOWN modes) -->
<TextView
android:id="@+id/trusted_certificate_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_description"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="16dp"
android:visibility="gone" />
<TextView
android:id="@+id/trusted_certificate_subject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_subject_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Warning for expired/not-yet-valid certs -->
<TextView
android:id="@+id/trusted_certificate_warning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="@android:color/holo_red_dark"
android:textStyle="bold"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
android:visibility="gone" />
<!-- Issuer -->
<TextView
android:id="@+id/trusted_certificate_issuer_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_issuer"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_subject"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Service URL (shown in VIEW and UNKNOWN modes) -->
<TextView
android:id="@+id/trusted_certificate_base_url_value_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_service_url"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
android:visibility="gone" />
<TextView
android:id="@+id/trusted_certificate_issuer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_issuer_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/trusted_certificate_base_url_value"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true"
android:visibility="gone" />
<!-- Fingerprint -->
<TextView
android:id="@+id/trusted_certificate_fingerprint_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_fingerprint"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_issuer"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Subject -->
<TextView
android:id="@+id/trusted_certificate_subject_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_subject"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp" />
<TextView
android:id="@+id/trusted_certificate_fingerprint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:fontFamily="monospace"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_fingerprint_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/trusted_certificate_subject"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true" />
<!-- Valid From -->
<TextView
android:id="@+id/trusted_certificate_valid_from_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_valid_from"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_fingerprint"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/trusted_certificate_valid_until_label"
app:layout_constraintWidth_percent="0.5" />
<!-- Issuer -->
<TextView
android:id="@+id/trusted_certificate_issuer_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_issuer"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp" />
<TextView
android:id="@+id/trusted_certificate_valid_from"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_valid_from_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@id/trusted_certificate_valid_until"
app:layout_constraintWidth_percent="0.5" />
<TextView
android:id="@+id/trusted_certificate_issuer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true" />
<!-- Valid Until -->
<TextView
android:id="@+id/trusted_certificate_valid_until_label"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_valid_until"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_fingerprint"
app:layout_constraintStart_toEndOf="@id/trusted_certificate_valid_from_label"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.5" />
<!-- Fingerprint -->
<TextView
android:id="@+id/trusted_certificate_fingerprint_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_fingerprint"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp" />
<TextView
android:id="@+id/trusted_certificate_valid_until"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_valid_until_label"
app:layout_constraintStart_toEndOf="@id/trusted_certificate_valid_from"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintWidth_percent="0.5" />
<TextView
android:id="@+id/trusted_certificate_fingerprint"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="12sp"
android:fontFamily="monospace"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:textIsSelectable="true" />
<!-- Certificate Authority -->
<TextView
android:id="@+id/trusted_certificate_ca_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_ca"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_valid_from"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- Valid From / Valid Until -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp">
<TextView
android:id="@+id/trusted_certificate_ca"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_ca_label"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<!-- CA Info (only shown for CA certificates) -->
<TextView
android:id="@+id/trusted_certificate_ca_info"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_ca_info"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:layout_constraintTop_toBottomOf="@id/trusted_certificate_ca"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<TextView
android:id="@+id/trusted_certificate_valid_from_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_valid_from"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/trusted_certificate_valid_from"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/trusted_certificate_valid_until_label"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/trusted_certificate_dialog_valid_until"
android:textSize="12sp"
android:textColor="?android:textColorSecondary"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
<TextView
android:id="@+id/trusted_certificate_valid_until"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="14sp"
android:paddingStart="4dp"
android:paddingEnd="4dp" />
</LinearLayout>
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="16dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -1,6 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/trusted_certificate_action_next"
android:title="@string/trusted_certificate_dialog_button_next"
android:visible="false"
app:showAsAction="always" />
<item
android:id="@+id/trusted_certificate_action_trust"
android:title="@string/trusted_certificate_dialog_button_trust"
@ -12,4 +17,3 @@
android:visible="false"
app:showAsAction="always" />
</menu>

View file

@ -428,10 +428,8 @@
<string name="settings_advanced_certificates_title">Manage certificates</string>
<string name="settings_advanced_certificates_summary">Add certificates to the trust store and manage client certificates for mTLS</string>
<string name="settings_advanced_certificates_trusted_header">Trusted certificates</string>
<string name="settings_advanced_certificates_trusted_item_summary_leaf">Leaf certificate, issued by %1$s\nExpires %2$s</string>
<string name="settings_advanced_certificates_trusted_item_summary_leaf_expired">Leaf certificate, issued by %1$s\nExpired</string>
<string name="settings_advanced_certificates_trusted_item_summary_ca">CA certificate, self-signed\nExpires %1$s</string>
<string name="settings_advanced_certificates_trusted_item_summary_ca_expired">CA certificate, self-signed\nExpired</string>
<string name="settings_advanced_certificates_trusted_item_summary">%1$s, expires %2$s</string>
<string name="settings_advanced_certificates_trusted_item_summary_expired">%1$s, expired</string>
<string name="settings_advanced_certificates_trusted_add_title">Add a trusted certificate</string>
<string name="settings_advanced_certificates_trusted_add_summary">Import a certificate into the trust store (PEM). HTTPS and WebSocket connections will trust this certificate.</string>
<string name="settings_advanced_certificates_client_header">Client certificates (mTLS)</string>
@ -505,18 +503,22 @@
<string name="trusted_certificate_dialog_title">Certificate details</string>
<string name="trusted_certificate_dialog_title_unknown">Unknown certificate</string>
<string name="trusted_certificate_dialog_title_add">Add trusted certificate</string>
<string name="trusted_certificate_dialog_description">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.</string>
<string name="trusted_certificate_dialog_description_unknown">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.</string>
<string name="trusted_certificate_dialog_description">The server\'s certificate could not be verified. Review the details below before trusting.</string>
<string name="trusted_certificate_dialog_description_unknown">The server\'s certificate could not be verified. Review the details below before trusting.</string>
<string name="trusted_certificate_dialog_description_add">You\'ve selected a certificate file. Please review the details below before adding it to your trusted certificates.</string>
<string name="trusted_certificate_dialog_description_page1">Enter the service URL that this certificate should be pinned to. The certificate will only be trusted for this URL.</string>
<string name="trusted_certificate_dialog_base_url_hint">Service URL</string>
<string name="trusted_certificate_dialog_base_url_placeholder">https://ntfy.example.com</string>
<string name="trusted_certificate_dialog_service_url">Service URL</string>
<string name="trusted_certificate_dialog_subject">Subject</string>
<string name="trusted_certificate_dialog_issuer">Issuer</string>
<string name="trusted_certificate_dialog_fingerprint">SHA-256 fingerprint</string>
<string name="trusted_certificate_dialog_valid_from">Valid from</string>
<string name="trusted_certificate_dialog_valid_until">Valid until</string>
<string name="trusted_certificate_dialog_ca">Certificate Authority (CA)</string>
<string name="trusted_certificate_dialog_ca_info">All certificates signed by this Certificate Authority will be trusted.</string>
<string name="trusted_certificate_dialog_expired_warning">Warning: This certificate has expired.</string>
<string name="trusted_certificate_dialog_not_yet_valid_warning">Warning: This certificate is not yet valid.</string>
<string name="trusted_certificate_dialog_error_invalid_url">Invalid URL</string>
<string name="trusted_certificate_dialog_button_next">Next</string>
<string name="trusted_certificate_dialog_button_trust">Trust</string>
<string name="trusted_certificate_dialog_button_delete">Delete</string>
<string name="trusted_certificate_dialog_added_toast">Certificate added</string>