Remove cert model

This commit is contained in:
Philipp Heckel 2026-01-03 15:52:24 -05:00
parent 70c1afb433
commit e98d3cfb36
7 changed files with 170 additions and 227 deletions

View file

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

View file

@ -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/<fingerprint>.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<TrustedCertificate> {
if (!trustedMetadataFile.exists()) return emptyList()
return try {
val json = trustedMetadataFile.readText()
val type = object : TypeToken<List<TrustedCertificate>>() {}.type
gson.fromJson(json, type) ?: emptyList()
} catch (e: Exception) {
Log.w(TAG, "Failed to parse trusted certificates", e)
emptyList()
fun getTrustedCertificates(): List<X509Certificate> {
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<TrustedCertificate>) {
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

View file

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

View file

@ -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<TrustedCertificate>): Array<TrustManager> {
private fun createCombinedTrustManagers(userCerts: List<X509Certificate>): Array<TrustManager> {
// 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

View file

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

View file

@ -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<TrustedCertificate>) {
private fun addTrustedCertPreferences(certs: List<X509Certificate>) {
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<String, List<ClientCertificate>>) {
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()
}
}

View file

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