Move to database

This commit is contained in:
Philipp Heckel 2026-01-03 16:30:38 -05:00
parent 01f86269b0
commit 998aa1bb33
11 changed files with 760 additions and 455 deletions

View file

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

View file

@ -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<TrustedCertificateBackup>?) {
private suspend fun applyTrustedCertificates(certificates: List<TrustedCertificateBackup>?) {
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<ClientCertificateBackup>?) {
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<TrustedCertificateBackup> {
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<TrustedCertificateBackup> {
return repository.getTrustedCertificates().map { trustedCert ->
TrustedCertificateBackup(pem = trustedCert.pem)
}
}
private suspend fun createClientCertificateList(): List<ClientCertificateBackup> {
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<Subscription>?,
val notifications: List<Notification>?,
val users: List<User>?,
val trustedCertificates: List<TrustedCertificateBackup>? = null
val trustedCertificates: List<TrustedCertificateBackup>? = null,
val clientCertificates: List<ClientCertificateBackup>? = 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")

View file

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

View file

@ -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<Long, ConnectionState>()
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<TrustedCertificate> {
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<ClientCertificate> {
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)
}

View file

@ -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/<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) {
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<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 (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<ClientCertificate> {
if (!clientMetadataFile.exists()) return emptyList()
return try {
val json = clientMetadataFile.readText()
val type = object : TypeToken<List<ClientCertificate>>() {}.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<ClientCertificate>) {
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 }
}
}
}
}

View file

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

View file

@ -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<TrustManager>()
val keyManagers = mutableListOf<KeyManager>()
// 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<X509Certificate>): Array<TrustManager> {
private fun createCombinedTrustManagers(trustedCerts: List<TrustedCertificate>): Array<TrustManager> {
// 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<KeyManager>? {
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
}
}
}

View file

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

View file

@ -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<X509Certificate>) {
private fun addTrustedCertPreferences(certs: List<TrustedCertificate>) {
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<String, List<ClientCertificate>>) {
val dateFormat = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
private fun addClientCertPreferences(certs: List<ClientCertificate>) {
// 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)
}

View file

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

View file

@ -508,7 +508,8 @@
<string name="settings_certificates_prefs_client_empty">No client certificates</string>
<string name="settings_certificates_prefs_client_add">Add client certificate</string>
<string name="settings_certificates_prefs_client_add_title">Add a client certificate</string>
<string name="settings_certificates_prefs_client_add_summary">Import PEM certificate and key files for mutual TLS authentication</string>
<string name="settings_certificates_prefs_client_add_summary">Import PKCS#12 certificate for mutual TLS authentication</string>
<string name="settings_certificates_prefs_client_configured">Client certificate configured</string>
<string name="settings_certificates_prefs_expires">Expires: %1$s</string>
<string name="settings_certificates_prefs_expired">Expired</string>
<!-- Certificate dialog -->