From 998aa1bb333b0e7643304556d3bf28ae43e9e20e Mon Sep 17 00:00:00 2001 From: Philipp Heckel Date: Sat, 3 Jan 2026 16:30:38 -0500 Subject: [PATCH] Move to database --- .../io.heckel.ntfy.db.Database/15.json | 392 ++++++++++++++++++ .../java/io/heckel/ntfy/backup/Backuper.kt | 67 ++- .../main/java/io/heckel/ntfy/db/Database.kt | 52 ++- .../main/java/io/heckel/ntfy/db/Repository.kt | 34 ++ .../io/heckel/ntfy/tls/CertificateManager.kt | 189 --------- .../io/heckel/ntfy/tls/CertificateModels.kt | 105 ----- .../java/io/heckel/ntfy/tls/SSLManager.kt | 87 +++- .../io/heckel/ntfy/ui/CertificateFragment.kt | 168 +++++--- .../ntfy/ui/CertificateSettingsFragment.kt | 92 ++-- .../ntfy/ui/CertificateTrustFragment.kt | 26 +- app/src/main/res/values/strings.xml | 3 +- 11 files changed, 760 insertions(+), 455 deletions(-) create mode 100644 app/schemas/io.heckel.ntfy.db.Database/15.json delete mode 100644 app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt delete mode 100644 app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt diff --git a/app/schemas/io.heckel.ntfy.db.Database/15.json b/app/schemas/io.heckel.ntfy.db.Database/15.json new file mode 100644 index 00000000..5d6d5fb3 --- /dev/null +++ b/app/schemas/io.heckel.ntfy.db.Database/15.json @@ -0,0 +1,392 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "405eb445e4bc622f6624984e6769b047", + "entities": [ + { + "tableName": "Subscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `baseUrl` TEXT NOT NULL, `topic` TEXT NOT NULL, `instant` INTEGER NOT NULL, `mutedUntil` INTEGER NOT NULL, `minPriority` INTEGER NOT NULL, `autoDelete` INTEGER NOT NULL, `insistent` INTEGER NOT NULL, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `dedicatedChannels` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topic", + "columnName": "topic", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instant", + "columnName": "instant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mutedUntil", + "columnName": "mutedUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minPriority", + "columnName": "minPriority", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoDelete", + "columnName": "autoDelete", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "insistent", + "columnName": "insistent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastNotificationId", + "columnName": "lastNotificationId", + "affinity": "TEXT" + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT" + }, + { + "fieldPath": "upAppId", + "columnName": "upAppId", + "affinity": "TEXT" + }, + { + "fieldPath": "upConnectorToken", + "columnName": "upConnectorToken", + "affinity": "TEXT" + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "dedicatedChannels", + "columnName": "dedicatedChannels", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Subscription_baseUrl_topic", + "unique": true, + "columnNames": [ + "baseUrl", + "topic" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_baseUrl_topic` ON `${TABLE_NAME}` (`baseUrl`, `topic`)" + }, + { + "name": "index_Subscription_upConnectorToken", + "unique": true, + "columnNames": [ + "upConnectorToken" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_Subscription_upConnectorToken` ON `${TABLE_NAME}` (`upConnectorToken`)" + } + ] + }, + { + "tableName": "Notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `title` TEXT NOT NULL, `message` TEXT NOT NULL, `contentType` TEXT NOT NULL, `encoding` TEXT NOT NULL, `notificationId` INTEGER NOT NULL, `priority` INTEGER NOT NULL DEFAULT 3, `tags` TEXT NOT NULL, `click` TEXT NOT NULL, `actions` TEXT, `deleted` INTEGER NOT NULL, `icon_url` TEXT, `icon_contentUri` TEXT, `attachment_name` TEXT, `attachment_type` TEXT, `attachment_size` INTEGER, `attachment_expires` INTEGER, `attachment_url` TEXT, `attachment_contentUri` TEXT, `attachment_progress` INTEGER, PRIMARY KEY(`id`, `subscriptionId`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscriptionId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentType", + "columnName": "contentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encoding", + "columnName": "encoding", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notificationId", + "columnName": "notificationId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priority", + "columnName": "priority", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "3" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "click", + "columnName": "click", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "TEXT" + }, + { + "fieldPath": "deleted", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon.url", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "icon.contentUri", + "columnName": "icon_contentUri", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.name", + "columnName": "attachment_name", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.type", + "columnName": "attachment_type", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.size", + "columnName": "attachment_size", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachment.expires", + "columnName": "attachment_expires", + "affinity": "INTEGER" + }, + { + "fieldPath": "attachment.url", + "columnName": "attachment_url", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.contentUri", + "columnName": "attachment_contentUri", + "affinity": "TEXT" + }, + { + "fieldPath": "attachment.progress", + "columnName": "attachment_progress", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "subscriptionId" + ] + } + }, + { + "tableName": "User", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseUrl" + ] + } + }, + { + "tableName": "Log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `tag` TEXT NOT NULL, `level` INTEGER NOT NULL, `message` TEXT NOT NULL, `exception` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "exception", + "columnName": "exception", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "TrustedCertificate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`fingerprint` TEXT NOT NULL, `pem` TEXT NOT NULL, PRIMARY KEY(`fingerprint`))", + "fields": [ + { + "fieldPath": "fingerprint", + "columnName": "fingerprint", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pem", + "columnName": "pem", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "fingerprint" + ] + } + }, + { + "tableName": "ClientCertificate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`baseUrl` TEXT NOT NULL, `p12Base64` TEXT NOT NULL, `password` TEXT NOT NULL, PRIMARY KEY(`baseUrl`))", + "fields": [ + { + "fieldPath": "baseUrl", + "columnName": "baseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "p12Base64", + "columnName": "p12Base64", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "baseUrl" + ] + } + } + ], + "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, '405eb445e4bc622f6624984e6769b047')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 3f81a8ca..b715a9c4 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -10,7 +10,7 @@ import io.heckel.ntfy.app.Application import io.heckel.ntfy.db.Repository import io.heckel.ntfy.firebase.FirebaseMessenger import io.heckel.ntfy.msg.NotificationService -import io.heckel.ntfy.tls.CertificateManager +import io.heckel.ntfy.tls.SSLManager import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.topicUrl import java.io.InputStreamReader @@ -21,7 +21,6 @@ class Backuper(val context: Context) { private val repository = (context.applicationContext as Application).repository private val messenger = FirebaseMessenger() private val notifier = NotificationService(context) - private val certManager = CertificateManager.getInstance(context) suspend fun backup(uri: Uri, withSettings: Boolean = true, withSubscriptions: Boolean = true, withUsers: Boolean = true) { Log.d(TAG, "Backing up settings to file $uri") @@ -53,6 +52,7 @@ class Backuper(val context: Context) { applyNotifications(backupFile.notifications) applyUsers(backupFile.users) applyTrustedCertificates(backupFile.trustedCertificates) + applyClientCertificates(backupFile.clientCertificates) } private fun applySettings(settings: Settings?) { @@ -223,16 +223,31 @@ class Backuper(val context: Context) { } } - private fun applyTrustedCertificates(certificates: List?) { + private suspend fun applyTrustedCertificates(certificates: List?) { if (certificates == null) { return } certificates.forEach { c -> try { - val cert = certManager.parsePemCertificate(c.pemEncoded) - certManager.addTrustedCertificate(cert) + val pem = c.pem + val x509Cert = SSLManager.parsePemCertificate(pem) + val fingerprint = SSLManager.calculateFingerprint(x509Cert) + repository.addTrustedCertificate(fingerprint, pem) } catch (e: Exception) { - Log.w(TAG, "Unable to restore trusted certificate ${c.fingerprint}: ${e.message}. Ignoring.", e) + Log.w(TAG, "Unable to restore trusted certificate: ${e.message}. Ignoring.", e) + } + } + } + + private suspend fun applyClientCertificates(certificates: List?) { + if (certificates == null) { + return + } + certificates.forEach { c -> + try { + repository.addClientCertificate(c.baseUrl, c.p12Base64, c.password) + } catch (e: Exception) { + Log.w(TAG, "Unable to restore client certificate for ${c.baseUrl}: ${e.message}. Ignoring.", e) } } } @@ -245,7 +260,8 @@ class Backuper(val context: Context) { subscriptions = if (withSubscriptions) createSubscriptionList() else null, notifications = if (withSubscriptions) createNotificationList() else null, users = if (withUsers) createUserList() else null, - trustedCertificates = if (withSettings) createTrustedCertificateList() else null + trustedCertificates = if (withSettings) createTrustedCertificateList() else null, + clientCertificates = if (withSettings) createClientCertificateList() else null ) } @@ -358,15 +374,18 @@ class Backuper(val context: Context) { } } - private fun createTrustedCertificateList(): List { - return certManager.getTrustedCertificates().map { cert -> - TrustedCertificateBackup( - 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) + private suspend fun createTrustedCertificateList(): List { + return repository.getTrustedCertificates().map { trustedCert -> + TrustedCertificateBackup(pem = trustedCert.pem) + } + } + + private suspend fun createClientCertificateList(): List { + return repository.getClientCertificates().map { clientCert -> + ClientCertificateBackup( + baseUrl = clientCert.baseUrl, + p12Base64 = clientCert.p12Base64, + password = clientCert.password ) } } @@ -385,7 +404,8 @@ data class BackupFile( val subscriptions: List?, val notifications: List?, val users: List?, - val trustedCertificates: List? = null + val trustedCertificates: List? = null, + val clientCertificates: List? = null ) data class Settings( @@ -473,12 +493,13 @@ data class User( ) data class TrustedCertificateBackup( - val fingerprint: String, - val subject: String, - val issuer: String, - val notBefore: Long, - val notAfter: Long, - val pemEncoded: String + val pem: String +) + +data class ClientCertificateBackup( + val baseUrl: String, + val p12Base64: String, + val password: String ) class InvalidBackupFileException : Exception("Invalid backup file format") diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index de989f17..f6f029ef 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -188,6 +188,19 @@ data class User( override fun toString(): String = username } +@Entity(tableName = "TrustedCertificate") +data class TrustedCertificate( + @PrimaryKey @ColumnInfo(name = "fingerprint") val fingerprint: String, + @ColumnInfo(name = "pem") val pem: String +) + +@Entity(tableName = "ClientCertificate") +data class ClientCertificate( + @PrimaryKey @ColumnInfo(name = "baseUrl") val baseUrl: String, + @ColumnInfo(name = "p12Base64") val p12Base64: String, + @ColumnInfo(name = "password") val password: String +) + @Entity(tableName = "Log") data class LogEntry( @PrimaryKey(autoGenerate = true) val id: Long, // Internal ID, only used in Repository and activities @@ -201,13 +214,15 @@ data class LogEntry( this(0, timestamp, tag, level, message, exception) } -@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class], version = 14) +@androidx.room.Database(entities = [Subscription::class, Notification::class, User::class, LogEntry::class, TrustedCertificate::class, ClientCertificate::class], version = 15) @TypeConverters(Converters::class) abstract class Database : RoomDatabase() { abstract fun subscriptionDao(): SubscriptionDao abstract fun notificationDao(): NotificationDao abstract fun userDao(): UserDao abstract fun logDao(): LogDao + abstract fun trustedCertificateDao(): TrustedCertificateDao + abstract fun clientCertificateDao(): ClientCertificateDao companion object { @Volatile @@ -230,6 +245,7 @@ abstract class Database : RoomDatabase() { .addMigrations(MIGRATION_11_12) .addMigrations(MIGRATION_12_13) .addMigrations(MIGRATION_13_14) + .addMigrations(MIGRATION_14_15) .fallbackToDestructiveMigration(true) .build() this.instance = instance @@ -341,6 +357,13 @@ abstract class Database : RoomDatabase() { db.execSQL("ALTER TABLE Notification ADD COLUMN contentType TEXT NOT NULL DEFAULT ('')") } } + + private val MIGRATION_14_15 = object : Migration(14, 15) { + 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 ClientCertificate (baseUrl TEXT NOT NULL, p12Base64 TEXT NOT NULL, password TEXT NOT NULL, PRIMARY KEY(baseUrl))") + } + } } } @@ -510,3 +533,30 @@ interface LogDao { @Query("DELETE FROM log") fun deleteAll() } + +@Dao +interface TrustedCertificateDao { + @Query("SELECT * FROM TrustedCertificate") + suspend fun list(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(cert: TrustedCertificate) + + @Query("DELETE FROM TrustedCertificate WHERE fingerprint = :fingerprint") + suspend fun delete(fingerprint: String) +} + +@Dao +interface ClientCertificateDao { + @Query("SELECT * FROM ClientCertificate") + suspend fun list(): List + + @Query("SELECT * FROM ClientCertificate WHERE baseUrl = :baseUrl") + suspend fun get(baseUrl: String): ClientCertificate? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(cert: ClientCertificate) + + @Query("DELETE FROM ClientCertificate WHERE baseUrl = :baseUrl") + suspend fun delete(baseUrl: String) +} diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 6e346efd..5e7678f3 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -23,6 +23,8 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) private val subscriptionDao = database.subscriptionDao() private val notificationDao = database.notificationDao() private val userDao = database.userDao() + private val trustedCertificateDao = database.trustedCertificateDao() + private val clientCertificateDao = database.clientCertificateDao() private val connectionStates = ConcurrentHashMap() private val connectionStatesLiveData = MutableLiveData(connectionStates) @@ -192,6 +194,38 @@ class Repository(private val sharedPrefs: SharedPreferences, database: Database) userDao.delete(baseUrl) } + // Trusted certificates + + suspend fun getTrustedCertificates(): List { + return trustedCertificateDao.list() + } + + suspend fun addTrustedCertificate(fingerprint: String, pem: String) { + trustedCertificateDao.insert(TrustedCertificate(fingerprint, pem)) + } + + suspend fun removeTrustedCertificate(fingerprint: String) { + trustedCertificateDao.delete(fingerprint) + } + + // Client certificates + + suspend fun getClientCertificates(): List { + return clientCertificateDao.list() + } + + suspend fun getClientCertificate(baseUrl: String): ClientCertificate? { + return clientCertificateDao.get(baseUrl) + } + + suspend fun addClientCertificate(baseUrl: String, p12Base64: String, password: String) { + clientCertificateDao.insert(ClientCertificate(baseUrl, p12Base64, password)) + } + + suspend fun removeClientCertificate(baseUrl: String) { + clientCertificateDao.delete(baseUrl) + } + fun getPollWorkerVersion(): Int { return sharedPrefs.getInt(SHARED_PREFS_POLL_WORKER_VERSION, 0) } diff --git a/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt b/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt deleted file mode 100644 index f7ef0e3b..00000000 --- a/app/src/main/java/io/heckel/ntfy/tls/CertificateManager.kt +++ /dev/null @@ -1,189 +0,0 @@ -package io.heckel.ntfy.tls - -import android.annotation.SuppressLint -import android.content.Context -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import io.heckel.ntfy.util.Log -import java.io.ByteArrayInputStream -import java.io.File -import java.security.KeyStore -import java.security.cert.CertificateFactory -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/.pem (loaded directly from files) - * - Client certificates: certs/client/{alias}.p12 + metadata in certs/client.json - */ -class CertificateManager private constructor(private val context: Context) { - private val gson = Gson() - 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 clientMetadataFile = File(certsDir, CLIENT_METADATA_FILE) - - // ==================== Trusted Server Certificates ==================== - - /** - * Get all trusted server certificates by loading PEM files from disk - */ - fun getTrustedCertificates(): List { - val pemFiles = trustedDir.listFiles { file -> file.extension == "pem" } ?: return emptyList() - return pemFiles.mapNotNull { file -> - try { - parsePemCertificate(file.readText()) - } catch (e: Exception) { - Log.w(TAG, "Failed to parse certificate file: ${file.name}", e) - null - } - } - } - - /** - * Add a trusted certificate (saves as PEM file) - */ - fun addTrustedCertificate(cert: X509Certificate) { - val fingerprint = calculateFingerprint(cert) - val filename = fingerprint.replace(":", "") + ".pem" - val pemFile = File(trustedDir, filename) - pemFile.writeText(encodeToPem(cert)) - } - - /** - * Remove a trusted certificate by fingerprint - */ - fun removeTrustedCertificate(fingerprint: String) { - val filename = fingerprint.replace(":", "") + ".pem" - val pemFile = File(trustedDir, filename) - if (pemFile.exists()) { - pemFile.delete() - } - } - - /** - * Parse a PEM-encoded certificate string to X509Certificate - */ - fun parsePemCertificate(pem: String): X509Certificate { - val cleanPem = pem - .replace("-----BEGIN CERTIFICATE-----", "") - .replace("-----END CERTIFICATE-----", "") - .replace("\\s".toRegex(), "") - val decoded = android.util.Base64.decode(cleanPem, android.util.Base64.DEFAULT) - val factory = CertificateFactory.getInstance("X.509") - return factory.generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate - } - - // ==================== Client Certificates (mTLS) ==================== - - /** - * Get all client certificate metadata - */ - fun getClientCertificates(): List { - if (!clientMetadataFile.exists()) return emptyList() - return try { - val json = clientMetadataFile.readText() - val type = object : TypeToken>() {}.type - gson.fromJson(json, type) ?: emptyList() - } catch (e: Exception) { - Log.w(TAG, "Failed to parse client certificates", e) - emptyList() - } - } - - /** - * Get client certificate for a specific server (only one per server) - */ - fun getClientCertificateForServer(baseUrl: String): ClientCertificate? { - return getClientCertificates().find { it.baseUrl == baseUrl } - } - - /** - * Remove a client certificate - */ - fun removeClientCertificate(cert: ClientCertificate) { - // Remove PKCS#12 file - val p12File = File(clientDir, "${cert.alias}.p12") - if (p12File.exists()) { - p12File.delete() - } - - // Update metadata - val certs = getClientCertificates().toMutableList() - certs.removeAll { it.alias == cert.alias } - saveClientMetadata(certs) - } - - /** - * 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 - */ - fun addClientCertificatePkcs12(baseUrl: String, pkcs12Data: ByteArray, password: String) { - // Load the PKCS#12 to verify and extract certificate info - val pkcs12KeyStore = KeyStore.getInstance("PKCS12") - pkcs12KeyStore.load(ByteArrayInputStream(pkcs12Data), password.toCharArray()) - - // Get the first certificate from the PKCS#12 - val alias = pkcs12KeyStore.aliases().nextElement() - val cert = pkcs12KeyStore.getCertificate(alias) as X509Certificate - - // Generate a unique alias for storage - val storageAlias = ClientCertificate.generateAlias(baseUrl) - - // Save the PKCS#12 file - val p12File = File(clientDir, "$storageAlias.p12") - p12File.writeBytes(pkcs12Data) - - // 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) - } - - /** - * Get the path to a client certificate's PKCS#12 file - */ - fun getClientCertificatePath(alias: String): File { - return File(clientDir, "$alias.p12") - } - - private fun saveClientMetadata(certs: List) { - if (certs.isEmpty()) { - clientMetadataFile.delete() - } else { - clientMetadataFile.writeText(gson.toJson(certs)) - } - } - - companion object { - private const val TAG = "NtfyCertManager" - private const val CERTS_DIR = "certs" - private const val TRUSTED_DIR = "trusted" - private const val CLIENT_DIR = "client" - private const val CLIENT_METADATA_FILE = "client.json" - - @SuppressLint("StaticFieldLeak") // Using applicationContext, so no leak - @Volatile - private var instance: CertificateManager? = null - - fun getInstance(context: Context): CertificateManager { - return instance ?: synchronized(this) { - instance ?: CertificateManager(context.applicationContext).also { instance = it } - } - } - } -} diff --git a/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt b/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt deleted file mode 100644 index 77067107..00000000 --- a/app/src/main/java/io/heckel/ntfy/tls/CertificateModels.kt +++ /dev/null @@ -1,105 +0,0 @@ -package io.heckel.ntfy.tls - -import android.util.Base64 -import java.security.MessageDigest -import java.security.cert.X509Certificate - -/** - * 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 - */ -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() -} - -/** - * 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 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, // 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 -) { - companion object { - /** - * Generate a unique alias for storing - */ - fun generateAlias(baseUrl: String): String { - val timestamp = System.currentTimeMillis() - val sanitizedUrl = baseUrl.replace(Regex("[^a-zA-Z0-9]"), "_") - return "ntfy_client_${sanitizedUrl}_$timestamp" - } - - /** - * Create ClientCertificate metadata from an X509Certificate - */ - fun fromX509Certificate( - baseUrl: String, - alias: String, - cert: X509Certificate, - password: String? = null - ): ClientCertificate { - return ClientCertificate( - baseUrl = baseUrl, - alias = alias, - fingerprint = calculateFingerprint(cert), - subject = cert.subjectX500Principal.name, - issuer = cert.issuerX500Principal.name, - notBefore = cert.notBefore.time, - notAfter = cert.notAfter.time, - password = password - ) - } - } - - /** - * 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 - } -} diff --git a/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt b/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt index 663a1350..a8026d76 100644 --- a/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt +++ b/app/src/main/java/io/heckel/ntfy/tls/SSLManager.kt @@ -2,11 +2,18 @@ package io.heckel.ntfy.tls import android.annotation.SuppressLint import android.content.Context +import android.util.Base64 +import io.heckel.ntfy.db.ClientCertificate +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.db.TrustedCertificate import io.heckel.ntfy.util.Log +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient -import java.io.FileInputStream +import java.io.ByteArrayInputStream import java.security.KeyStore +import java.security.MessageDigest import java.security.SecureRandom +import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import javax.net.ssl.KeyManager import javax.net.ssl.KeyManagerFactory @@ -27,7 +34,7 @@ import javax.net.ssl.X509TrustManager */ class SSLManager private constructor(context: Context) { private val appContext: Context = context.applicationContext - private val certManager: CertificateManager by lazy { CertificateManager.getInstance(appContext) } + private val repository: Repository by lazy { Repository.getInstance(appContext) } /** * Get an OkHttpClient.Builder configured with custom SSL for a specific server @@ -46,15 +53,15 @@ class SSLManager private constructor(context: Context) { val trustManagers = mutableListOf() val keyManagers = mutableListOf() - // Get all user-trusted certificates - val trustedCerts = certManager.getTrustedCertificates() - val trustedFingerprints = trustedCerts.map { calculateFingerprint(it) }.toSet() + // Get all user-trusted certificates from database + val trustedCerts = runBlocking { repository.getTrustedCertificates() } + val trustedFingerprints = trustedCerts.map { it.fingerprint }.toSet() if (trustedCerts.isNotEmpty()) { trustManagers.addAll(createCombinedTrustManagers(trustedCerts)) } // Get client certificate for mTLS - val clientCert = certManager.getClientCertificateForServer(baseUrl) + val clientCert = runBlocking { repository.getClientCertificate(baseUrl) } if (clientCert != null) { createKeyManagers(clientCert)?.let { keyManagers.addAll(it.toList()) } } @@ -165,13 +172,18 @@ class SSLManager private constructor(context: Context) { * Create TrustManagers that trust both user-added certs and system CAs. * Uses TrustManagerFactory (standard approach). */ - private fun createCombinedTrustManagers(userCerts: List): Array { + private fun createCombinedTrustManagers(trustedCerts: List): Array { // Create a KeyStore with all certificates val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) } // Add user-trusted certificates - userCerts.forEachIndexed { index, cert -> - keyStore.setCertificateEntry("user$index", cert) + trustedCerts.forEachIndexed { index, trustedCert -> + try { + val cert = parsePemCertificate(trustedCert.pem) + keyStore.setCertificateEntry("user$index", cert) + } catch (e: Exception) { + Log.w(TAG, "Failed to parse trusted certificate: ${trustedCert.fingerprint}", e) + } } // Add system CA certificates for combined trust @@ -187,29 +199,20 @@ class SSLManager private constructor(context: Context) { } /** - * Create KeyManagers for mTLS client authentication using PKCS#12 file. + * Create KeyManagers for mTLS client authentication using PKCS#12 data from database. * Uses KeyManagerFactory (standard approach). */ private fun createKeyManagers(clientCert: ClientCertificate): Array? { - val p12File = certManager.getClientCertificatePath(clientCert.alias) - if (!p12File.exists()) { - Log.w(TAG, "PKCS#12 file not found: ${p12File.absolutePath}") - return null - } - if (clientCert.password == null) { - Log.w(TAG, "No password for PKCS#12 client certificate: ${clientCert.alias}") - return null - } - return try { + val p12Data = Base64.decode(clientCert.p12Base64, Base64.DEFAULT) val keyStore = KeyStore.getInstance("PKCS12") - FileInputStream(p12File).use { keyStore.load(it, clientCert.password.toCharArray()) } + ByteArrayInputStream(p12Data).use { keyStore.load(it, clientCert.password.toCharArray()) } val keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()) keyManagerFactory.init(keyStore, clientCert.password.toCharArray()) keyManagerFactory.keyManagers } catch (e: Exception) { - Log.e(TAG, "Failed to load PKCS#12 client certificate", e) + Log.e(TAG, "Failed to load PKCS#12 client certificate for ${clientCert.baseUrl}", e) null } } @@ -244,5 +247,45 @@ class SSLManager private constructor(context: Context) { instance ?: SSLManager(context).also { instance = 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 + */ + fun encodeToPem(cert: X509Certificate): String { + val base64 = Base64.encodeToString(cert.encoded, Base64.NO_WRAP) + val sb = StringBuilder() + sb.append("-----BEGIN CERTIFICATE-----\n") + 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() + } + + /** + * Parse a PEM-encoded certificate string to X509Certificate + */ + fun parsePemCertificate(pem: String): X509Certificate { + val cleanPem = pem + .replace("-----BEGIN CERTIFICATE-----", "") + .replace("-----END CERTIFICATE-----", "") + .replace("\\s".toRegex(), "") + val decoded = Base64.decode(cleanPem, Base64.DEFAULT) + val factory = CertificateFactory.getInstance("X.509") + return factory.generateCertificate(ByteArrayInputStream(decoded)) as X509Certificate + } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt index 6afdc71c..ba20606e 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateFragment.kt @@ -4,6 +4,7 @@ import android.app.Dialog import android.content.Context import android.net.Uri import android.os.Bundle +import android.util.Base64 import android.view.MenuItem import android.view.View import android.view.ViewGroup @@ -12,19 +13,25 @@ import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts 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.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText 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.calculateFingerprint +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.db.TrustedCertificate +import io.heckel.ntfy.tls.SSLManager import io.heckel.ntfy.util.AfterChangedTextWatcher import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.validUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.security.KeyStore import java.security.cert.X509Certificate +import java.io.ByteArrayInputStream import java.text.SimpleDateFormat import java.util.* @@ -33,12 +40,11 @@ import java.util.* */ class CertificateFragment : DialogFragment() { private lateinit var listener: CertificateDialogListener - private lateinit var certManager: CertificateManager + private lateinit var repository: Repository private var mode: Mode = Mode.ADD_TRUSTED - private var trustedCertificate: X509Certificate? = null - private var trustedCertFingerprint: String? = null - private var clientCertificate: ClientCertificate? = null + private var trustedCertificate: TrustedCertificate? = null + private var clientCertBaseUrl: String? = null // File contents private var certPem: String? = null @@ -92,19 +98,19 @@ class CertificateFragment : DialogFragment() { throw IllegalStateException("Activity cannot be null") } - certManager = CertificateManager.getInstance(requireContext()) + repository = Repository.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 { calculateFingerprint(it) == fingerprint } + lifecycleScope.launch(Dispatchers.IO) { + trustedCertificate = repository.getTrustedCertificates().find { it.fingerprint == fingerprint } + } } - arguments?.getString(ARG_CLIENT_CERT_ALIAS)?.let { alias -> - clientCertificate = certManager.getClientCertificates().find { it.alias == alias } + arguments?.getString(ARG_CLIENT_CERT_BASE_URL)?.let { baseUrl -> + clientCertBaseUrl = baseUrl } // Build the view @@ -212,10 +218,22 @@ class CertificateFragment : DialogFragment() { private fun setupViewTrustedMode() { val cert = trustedCertificate ?: run { - dismiss() + // Certificate will be loaded asynchronously, set up UI after it loads + lifecycleScope.launch(Dispatchers.IO) { + val fingerprint = arguments?.getString(ARG_TRUSTED_CERT_FINGERPRINT) + trustedCertificate = fingerprint?.let { + repository.getTrustedCertificates().find { c -> c.fingerprint == it } + } + withContext(Dispatchers.Main) { + trustedCertificate?.let { displayTrustedCertDetails(it) } ?: dismiss() + } + } return } + displayTrustedCertDetails(cert) + } + private fun displayTrustedCertDetails(trustedCert: TrustedCertificate) { toolbar.setTitle(R.string.certificate_dialog_title_view) descriptionText.isVisible = false baseUrlLayout.isVisible = false @@ -225,16 +243,21 @@ class CertificateFragment : DialogFragment() { saveMenuItem.isVisible = false deleteMenuItem.isVisible = true - val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - 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) + try { + val x509Cert = SSLManager.parsePemCertificate(trustedCert.pem) + val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) + subjectText.text = x509Cert.subjectX500Principal.name + issuerText.text = x509Cert.issuerX500Principal.name + fingerprintText.text = trustedCert.fingerprint + validFromText.text = dateFormat.format(x509Cert.notBefore) + validUntilText.text = dateFormat.format(x509Cert.notAfter) + } catch (e: Exception) { + fingerprintText.text = trustedCert.fingerprint + } } private fun setupViewClientMode() { - val cert = clientCertificate ?: run { + val baseUrl = clientCertBaseUrl ?: run { dismiss() return } @@ -248,12 +271,12 @@ class CertificateFragment : DialogFragment() { 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)) + // Display baseUrl as the identifier + subjectText.text = baseUrl + issuerText.isVisible = false + fingerprintText.isVisible = false + validFromText.isVisible = false + validUntilText.isVisible = false } private fun validateInput() { @@ -323,8 +346,8 @@ class CertificateFragment : DialogFragment() { private fun deleteClicked() { when (mode) { - Mode.VIEW_TRUSTED -> trustedCertFingerprint?.let { confirmDeleteTrustedCertificate(it) } - Mode.VIEW_CLIENT -> clientCertificate?.let { confirmDeleteClientCertificate(it) } + Mode.VIEW_TRUSTED -> trustedCertificate?.let { confirmDeleteTrustedCertificate(it.fingerprint) } + Mode.VIEW_CLIENT -> clientCertBaseUrl?.let { confirmDeleteClientCertificate(it) } else -> { /* Add modes don't have delete */ } } } @@ -338,15 +361,23 @@ class CertificateFragment : DialogFragment() { return } - try { - val cert = certManager.parsePemCertificate(certContent) - certManager.addTrustedCertificate(cert) - Toast.makeText(context, R.string.certificate_dialog_added_toast, Toast.LENGTH_SHORT).show() - listener.onCertificateAdded() - dismiss() - } catch (e: Exception) { - Log.w(TAG, "Failed to add trusted certificate", e) - showError(getString(R.string.certificate_dialog_error_invalid_cert)) + lifecycleScope.launch(Dispatchers.IO) { + try { + val x509Cert = SSLManager.parsePemCertificate(certContent) + val fingerprint = SSLManager.calculateFingerprint(x509Cert) + val pem = SSLManager.encodeToPem(x509Cert) + repository.addTrustedCertificate(fingerprint, pem) + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.certificate_dialog_added_toast, Toast.LENGTH_SHORT).show() + listener.onCertificateAdded() + dismiss() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to add trusted certificate", e) + withContext(Dispatchers.Main) { + showError(getString(R.string.certificate_dialog_error_invalid_cert)) + } + } } } @@ -373,14 +404,27 @@ class CertificateFragment : DialogFragment() { return } - try { - certManager.addClientCertificatePkcs12(baseUrl, data, password) - Toast.makeText(context, R.string.certificate_dialog_added_toast, Toast.LENGTH_SHORT).show() - listener.onCertificateAdded() - dismiss() - } catch (e: Exception) { - Log.w(TAG, "Failed to add client certificate", e) - showError(getString(R.string.certificate_dialog_error_invalid_p12_password)) + lifecycleScope.launch(Dispatchers.IO) { + try { + // Verify the PKCS#12 can be loaded with the password + val keyStore = KeyStore.getInstance("PKCS12") + ByteArrayInputStream(data).use { keyStore.load(it, password.toCharArray()) } + + // Store as base64 + val p12Base64 = Base64.encodeToString(data, Base64.NO_WRAP) + repository.addClientCertificate(baseUrl, p12Base64, password) + + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.certificate_dialog_added_toast, Toast.LENGTH_SHORT).show() + listener.onCertificateAdded() + dismiss() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to add client certificate", e) + withContext(Dispatchers.Main) { + showError(getString(R.string.certificate_dialog_error_invalid_p12_password)) + } + } } } @@ -388,23 +432,31 @@ class CertificateFragment : DialogFragment() { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.certificate_dialog_delete_confirm) .setPositiveButton(R.string.certificate_dialog_button_delete) { _, _ -> - certManager.removeTrustedCertificate(fingerprint) - Toast.makeText(context, R.string.certificate_dialog_deleted_toast, Toast.LENGTH_SHORT).show() - listener.onCertificateDeleted() - dismiss() + lifecycleScope.launch(Dispatchers.IO) { + repository.removeTrustedCertificate(fingerprint) + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.certificate_dialog_deleted_toast, Toast.LENGTH_SHORT).show() + listener.onCertificateDeleted() + dismiss() + } + } } .setNegativeButton(R.string.certificate_dialog_button_cancel, null) .show() } - private fun confirmDeleteClientCertificate(cert: ClientCertificate) { + private fun confirmDeleteClientCertificate(baseUrl: String) { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.certificate_dialog_delete_confirm) .setPositiveButton(R.string.certificate_dialog_button_delete) { _, _ -> - certManager.removeClientCertificate(cert) - Toast.makeText(context, R.string.certificate_dialog_deleted_toast, Toast.LENGTH_SHORT).show() - listener.onCertificateDeleted() - dismiss() + lifecycleScope.launch(Dispatchers.IO) { + repository.removeClientCertificate(baseUrl) + withContext(Dispatchers.Main) { + Toast.makeText(context, R.string.certificate_dialog_deleted_toast, Toast.LENGTH_SHORT).show() + listener.onCertificateDeleted() + dismiss() + } + } } .setNegativeButton(R.string.certificate_dialog_button_cancel, null) .show() @@ -426,7 +478,7 @@ class CertificateFragment : DialogFragment() { const val TAG = "NtfyCertFragment" 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" + private const val ARG_CLIENT_CERT_BASE_URL = "client_cert_base_url" fun newInstanceAddTrusted(): CertificateFragment { return CertificateFragment().apply { @@ -453,11 +505,11 @@ class CertificateFragment : DialogFragment() { } } - fun newInstanceViewClient(cert: ClientCertificate): CertificateFragment { + fun newInstanceViewClient(baseUrl: String): CertificateFragment { return CertificateFragment().apply { arguments = Bundle().apply { putString(ARG_MODE, Mode.VIEW_CLIENT.name) - putString(ARG_CLIENT_CERT_ALIAS, cert.alias) + putString(ARG_CLIENT_CERT_BASE_URL, baseUrl) } } } diff --git a/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt index a2332cd4..2e72e837 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateSettingsFragment.kt @@ -5,14 +5,13 @@ import androidx.lifecycle.lifecycleScope import androidx.preference.Preference 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.calculateFingerprint -import io.heckel.ntfy.tls.displaySubject +import io.heckel.ntfy.db.ClientCertificate +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.db.TrustedCertificate +import io.heckel.ntfy.tls.SSLManager 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.* @@ -20,22 +19,19 @@ import java.util.* * Fragment for managing trusted certificates and client certificates. */ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragment.CertificateDialogListener { - private lateinit var certManager: CertificateManager + private lateinit var repository: Repository override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { setPreferencesFromResource(R.xml.certificate_preferences, rootKey) - certManager = CertificateManager.getInstance(requireActivity()) + repository = Repository.getInstance(requireActivity()) reload() } fun reload() { preferenceScreen.removeAll() lifecycleScope.launch(Dispatchers.IO) { - val trustedCerts = certManager.getTrustedCertificates() - - val clientCerts = certManager.getClientCertificates() - .groupBy { it.baseUrl } - .toSortedMap() + val trustedCerts = repository.getTrustedCertificates() + val clientCerts = repository.getClientCertificates() activity?.runOnUiThread { addTrustedCertPreferences(trustedCerts) @@ -44,7 +40,7 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen } } - private fun addTrustedCertPreferences(certs: List) { + private fun addTrustedCertPreferences(certs: List) { val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) // Trusted certificates header @@ -58,21 +54,25 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen emptyPref.isEnabled = false trustedCategory.addPreference(emptyPref) } else { - certs.forEach { cert -> - val fingerprint = calculateFingerprint(cert) - val pref = Preference(preferenceScreen.context) - 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) + certs.forEach { trustedCert -> + try { + val x509Cert = SSLManager.parsePemCertificate(trustedCert.pem) + val pref = Preference(preferenceScreen.context) + pref.title = getDisplaySubject(x509Cert) + pref.summary = if (isValid(x509Cert)) { + getString(R.string.settings_certificates_prefs_expires, dateFormat.format(x509Cert.notAfter)) + } else { + getString(R.string.settings_certificates_prefs_expired) + } + pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + CertificateFragment.newInstanceViewTrusted(trustedCert.fingerprint) + .show(childFragmentManager, CertificateFragment.TAG) + true + } + trustedCategory.addPreference(pref) + } catch (e: Exception) { + // Skip invalid certificates } - pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { - CertificateFragment.newInstanceViewTrusted(fingerprint) - .show(childFragmentManager, CertificateFragment.TAG) - true - } - trustedCategory.addPreference(pref) } } @@ -88,36 +88,28 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen trustedCategory.addPreference(addTrustedPref) } - private fun addClientCertPreferences(certsByBaseUrl: Map>) { - val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()) - + private fun addClientCertPreferences(certs: List) { // Client certificates header val clientCategory = PreferenceCategory(preferenceScreen.context) clientCategory.title = getString(R.string.settings_certificates_prefs_client_header) preferenceScreen.addPreference(clientCategory) - if (certsByBaseUrl.isEmpty()) { + if (certs.isEmpty()) { val emptyPref = Preference(preferenceScreen.context) emptyPref.title = getString(R.string.settings_certificates_prefs_client_empty) emptyPref.isEnabled = false clientCategory.addPreference(emptyPref) } else { - certsByBaseUrl.forEach { (baseUrl, certs) -> - certs.forEach { cert -> - val pref = Preference(preferenceScreen.context) - pref.title = "${cert.displaySubject()} (${shortUrl(baseUrl)})" - pref.summary = if (cert.isValid()) { - getString(R.string.settings_certificates_prefs_expires, dateFormat.format(Date(cert.notAfter))) - } else { - getString(R.string.settings_certificates_prefs_expired) - } - pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { - CertificateFragment.newInstanceViewClient(cert) - .show(childFragmentManager, CertificateFragment.TAG) - true - } - clientCategory.addPreference(pref) + certs.forEach { cert -> + val pref = Preference(preferenceScreen.context) + pref.title = shortUrl(cert.baseUrl) + pref.summary = getString(R.string.settings_certificates_prefs_client_configured) + pref.onPreferenceClickListener = Preference.OnPreferenceClickListener { + CertificateFragment.newInstanceViewClient(cert.baseUrl) + .show(childFragmentManager, CertificateFragment.TAG) + true } + clientCategory.addPreference(pref) } } @@ -133,7 +125,13 @@ class CertificateSettingsFragment : BasePreferenceFragment(), CertificateFragmen clientCategory.addPreference(addClientPref) } - private fun isValid(cert: X509Certificate): Boolean { + private fun getDisplaySubject(cert: java.security.cert.X509Certificate): String { + val subject = cert.subjectX500Principal.name + val cnMatch = Regex("CN=([^,]+)").find(subject) + return cnMatch?.groupValues?.get(1) ?: subject + } + + private fun isValid(cert: java.security.cert.X509Certificate): Boolean { val now = Date() return now.after(cert.notBefore) && now.before(cert.notAfter) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt b/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt index 833cb2e4..aab28ce5 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/CertificateTrustFragment.kt @@ -8,10 +8,13 @@ import android.view.ViewGroup import android.widget.TextView import androidx.core.view.isVisible import androidx.fragment.app.DialogFragment +import androidx.lifecycle.lifecycleScope import com.google.android.material.appbar.MaterialToolbar import io.heckel.ntfy.R -import io.heckel.ntfy.tls.CertificateManager -import io.heckel.ntfy.tls.calculateFingerprint +import io.heckel.ntfy.db.Repository +import io.heckel.ntfy.tls.SSLManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import java.security.cert.X509Certificate import java.text.SimpleDateFormat import java.util.* @@ -24,6 +27,7 @@ class CertificateTrustFragment : DialogFragment() { private lateinit var listener: CertificateTrustListener private lateinit var certificate: X509Certificate private lateinit var baseUrl: String + private lateinit var repository: Repository private lateinit var toolbar: MaterialToolbar private lateinit var trustMenuItem: MenuItem @@ -48,6 +52,8 @@ class CertificateTrustFragment : DialogFragment() { throw IllegalStateException("Activity cannot be null") } + repository = Repository.getInstance(requireContext()) + // Get certificate data from arguments val certBytes = arguments?.getByteArray(ARG_CERTIFICATE) ?: throw IllegalArgumentException("Certificate bytes required") @@ -60,7 +66,7 @@ class CertificateTrustFragment : DialogFragment() { // Build the view val view = requireActivity().layoutInflater.inflate(R.layout.fragment_certificate_trust_dialog, null) - + // Setup toolbar toolbar = view.findViewById(R.id.certificate_trust_toolbar) toolbar.setNavigationOnClickListener { @@ -77,7 +83,7 @@ class CertificateTrustFragment : DialogFragment() { } } trustMenuItem = toolbar.menu.findItem(R.id.certificate_trust_action_trust) - + setupCertificateDetails(view) // Build dialog @@ -112,7 +118,7 @@ class CertificateTrustFragment : DialogFragment() { // Populate certificate details subjectText.text = certificate.subjectX500Principal.name issuerText.text = certificate.issuerX500Principal.name - fingerprintText.text = calculateFingerprint(certificate) + fingerprintText.text = SSLManager.calculateFingerprint(certificate) validFromText.text = dateFormat.format(certificate.notBefore) validUntilText.text = dateFormat.format(certificate.notAfter) @@ -132,11 +138,13 @@ class CertificateTrustFragment : DialogFragment() { } } } - + private fun trustCertificate() { - // Save the certificate to global trust store - val certManager = CertificateManager.getInstance(requireContext()) - certManager.addTrustedCertificate(certificate) + lifecycleScope.launch(Dispatchers.IO) { + val fingerprint = SSLManager.calculateFingerprint(certificate) + val pem = SSLManager.encodeToPem(certificate) + repository.addTrustedCertificate(fingerprint, pem) + } listener.onCertificateTrusted(baseUrl, certificate) dismiss() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dcf90102..a6477b17 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -508,7 +508,8 @@ No client certificates Add client certificate Add a client certificate - Import PEM certificate and key files for mutual TLS authentication + Import PKCS#12 certificate for mutual TLS authentication + Client certificate configured Expires: %1$s Expired