Move to database
This commit is contained in:
parent
01f86269b0
commit
998aa1bb33
11 changed files with 760 additions and 455 deletions
392
app/schemas/io.heckel.ntfy.db.Database/15.json
Normal file
392
app/schemas/io.heckel.ntfy.db.Database/15.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue