Remove cert model
This commit is contained in:
parent
70c1afb433
commit
e98d3cfb36
7 changed files with 170 additions and 227 deletions
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue