Compare commits
1 commit
main
...
e2e-dabble
Author | SHA1 | Date | |
---|---|---|---|
|
a4461bf47f |
18 changed files with 239 additions and 32 deletions
|
@ -2,11 +2,11 @@
|
|||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 12,
|
||||
"identityHash": "9363ad5196e88862acceb1bb9ee91124",
|
||||
"identityHash": "250db1985385d64d880124071eab96fc",
|
||||
"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, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, PRIMARY KEY(`id`))",
|
||||
"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, `lastNotificationId` TEXT, `icon` TEXT, `upAppId` TEXT, `upConnectorToken` TEXT, `displayName` TEXT, `encryptionKey` BLOB, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
|
@ -79,6 +79,12 @@
|
|||
"columnName": "displayName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "encryptionKey",
|
||||
"columnName": "encryptionKey",
|
||||
"affinity": "BLOB",
|
||||
"notNull": false
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
|
@ -326,7 +332,7 @@
|
|||
"views": [],
|
||||
"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, '9363ad5196e88862acceb1bb9ee91124')"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '250db1985385d64d880124071eab96fc')"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@ package io.heckel.ntfy.backup
|
|||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.stream.JsonReader
|
||||
|
@ -102,6 +103,7 @@ class Backuper(val context: Context) {
|
|||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName,
|
||||
encryptionKey = Base64.decode(s.encryptionKey, Base64.DEFAULT)
|
||||
))
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to restore subscription ${s.id} (${topicUrl(s.baseUrl, s.topic)}): ${e.message}. Ignoring.", e)
|
||||
|
@ -226,7 +228,8 @@ class Backuper(val context: Context) {
|
|||
icon = s.icon,
|
||||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName
|
||||
displayName = s.displayName,
|
||||
encryptionKey = if (s.encryptionKey != null) Base64.encodeToString(s.encryptionKey, Base64.DEFAULT) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -334,7 +337,8 @@ data class Subscription(
|
|||
val icon: String?,
|
||||
val upAppId: String?,
|
||||
val upConnectorToken: String?,
|
||||
val displayName: String?
|
||||
val displayName: String?,
|
||||
val encryptionKey: String? // as base64
|
||||
)
|
||||
|
||||
data class Notification(
|
||||
|
@ -343,7 +347,7 @@ data class Notification(
|
|||
val timestamp: Long,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val encoding: String, // "base64" or ""
|
||||
val encoding: String, // "base64", "jwe", or ""
|
||||
val priority: Int, // 1=min, 3=default, 5=max
|
||||
val tags: String,
|
||||
val click: String, // URL/intent to open on notification click
|
||||
|
|
|
@ -23,13 +23,14 @@ data class Subscription(
|
|||
@ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
|
||||
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
|
||||
@ColumnInfo(name = "displayName") val displayName: String?,
|
||||
@ColumnInfo(name = "encryptionKey") val encryptionKey: ByteArray?,
|
||||
@Ignore val totalCount: Int = 0, // Total notifications
|
||||
@Ignore val newCount: Int = 0, // New notifications
|
||||
@Ignore val lastActive: Long = 0, // Unix timestamp
|
||||
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE
|
||||
) {
|
||||
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, lastNotificationId: String, icon: String, upAppId: String, upConnectorToken: String, displayName: String?) :
|
||||
this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, displayName, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
|
||||
constructor(id: Long, baseUrl: String, topic: String, instant: Boolean, mutedUntil: Long, minPriority: Int, autoDelete: Long, lastNotificationId: String, icon: String, upAppId: String, upConnectorToken: String, displayName: String?, encryptionKey: ByteArray?) :
|
||||
this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, displayName, encryptionKey, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
|
||||
}
|
||||
|
||||
enum class ConnectionState {
|
||||
|
@ -49,6 +50,7 @@ data class SubscriptionWithMetadata(
|
|||
val upAppId: String?,
|
||||
val upConnectorToken: String?,
|
||||
val displayName: String?,
|
||||
val encryptionKey: ByteArray?,
|
||||
val totalCount: Int,
|
||||
val newCount: Int,
|
||||
val lastActive: Long
|
||||
|
@ -61,7 +63,7 @@ data class Notification(
|
|||
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
|
||||
@ColumnInfo(name = "title") val title: String,
|
||||
@ColumnInfo(name = "message") val message: String,
|
||||
@ColumnInfo(name = "encoding") val encoding: String, // "base64" or ""
|
||||
@ColumnInfo(name = "encoding") val encoding: String, // "" (raw UTF-8), "base64", or "jwe" (encryption)
|
||||
@ColumnInfo(name = "notificationId") val notificationId: Int, // Android notification popup ID
|
||||
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
|
||||
@ColumnInfo(name = "tags") val tags: String,
|
||||
|
@ -269,6 +271,8 @@ abstract class Database : RoomDatabase() {
|
|||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT")
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN displayName TEXT")
|
||||
db.execSQL("ALTER TABLE Subscription ADD COLUMN encryptionKey BLOB")
|
||||
db.execSQL("ALTER TABLE Notification ADD COLUMN encryption TEXT NOT NULL DEFAULT('')")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -278,7 +282,7 @@ abstract class Database : RoomDatabase() {
|
|||
interface SubscriptionDao {
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
|
@ -291,7 +295,7 @@ interface SubscriptionDao {
|
|||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
|
@ -304,7 +308,7 @@ interface SubscriptionDao {
|
|||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
|
@ -317,7 +321,7 @@ interface SubscriptionDao {
|
|||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
|
@ -330,7 +334,7 @@ interface SubscriptionDao {
|
|||
|
||||
@Query("""
|
||||
SELECT
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName,
|
||||
s.id, s.baseUrl, s.topic, s.instant, s.mutedUntil, s.minPriority, s.autoDelete, s.lastNotificationId, s.icon, s.upAppId, s.upConnectorToken, s.displayName, s.encryptionKey,
|
||||
COUNT(n.id) totalCount,
|
||||
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
|
||||
IFNULL(MAX(n.timestamp),0) AS lastActive
|
||||
|
|
|
@ -385,6 +385,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName,
|
||||
encryptionKey = s.encryptionKey,
|
||||
totalCount = s.totalCount,
|
||||
newCount = s.newCount,
|
||||
lastActive = s.lastActive,
|
||||
|
@ -410,6 +411,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
|
|||
upAppId = s.upAppId,
|
||||
upConnectorToken = s.upConnectorToken,
|
||||
displayName = s.displayName,
|
||||
encryptionKey = s.encryptionKey,
|
||||
totalCount = s.totalCount,
|
||||
newCount = s.newCount,
|
||||
lastActive = s.lastActive,
|
||||
|
|
|
@ -3,6 +3,7 @@ package io.heckel.ntfy.service
|
|||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.util.Encryption
|
||||
import io.heckel.ntfy.util.topicUrl
|
||||
import kotlinx.coroutines.*
|
||||
import okhttp3.Call
|
||||
|
@ -42,7 +43,8 @@ class JsonConnection(
|
|||
since = notification.id
|
||||
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@notify
|
||||
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
|
||||
val notificationDecrypted = Encryption.maybeDecrypt(subscription, notification)
|
||||
val notificationWithSubscriptionId = notificationDecrypted.copy(subscriptionId = subscription.id)
|
||||
notificationListener(subscription, notificationWithSubscriptionId)
|
||||
}
|
||||
val failed = AtomicBoolean(false)
|
||||
|
|
|
@ -7,6 +7,7 @@ import android.os.Looper
|
|||
import io.heckel.ntfy.db.*
|
||||
import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder
|
||||
import io.heckel.ntfy.msg.NotificationParser
|
||||
import io.heckel.ntfy.util.Encryption
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import io.heckel.ntfy.util.topicUrlWs
|
||||
|
@ -144,7 +145,8 @@ class WsConnection(
|
|||
val notification = notificationWithTopic.notification
|
||||
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@synchronize
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@synchronize
|
||||
val notificationWithSubscriptionId = notification.copy(subscriptionId = subscription.id)
|
||||
val notificationDecrypted = Encryption.maybeDecrypt(subscription, notification)
|
||||
val notificationWithSubscriptionId = notificationDecrypted.copy(subscriptionId = subscription.id)
|
||||
notificationListener(subscription, notificationWithSubscriptionId)
|
||||
since.set(notification.id)
|
||||
}
|
||||
|
|
|
@ -118,6 +118,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
upAppId = null,
|
||||
upConnectorToken = null,
|
||||
displayName = null,
|
||||
encryptionKey = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
|
@ -133,7 +134,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
// Fetch cached messages
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
|
||||
val notifications = api
|
||||
.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
|
||||
.map { n -> Encryption.maybeDecrypt(subscription, n) }
|
||||
notifications.forEach { notification -> repository.addNotification(notification) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
|
||||
|
@ -466,7 +469,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
|
|||
try {
|
||||
val subscription = repository.getSubscription(subscriptionId) ?: return@launch
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
|
||||
val notifications = api
|
||||
.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
|
||||
.map { n -> Encryption.maybeDecrypt(subscription, n) }
|
||||
val newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
|
||||
val toastMessage = if (newNotifications.isEmpty()) {
|
||||
getString(R.string.refresh_message_no_results)
|
||||
|
|
|
@ -4,7 +4,6 @@ import android.content.ContentResolver
|
|||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
|
@ -111,6 +110,7 @@ class DetailSettingsActivity : AppCompatActivity() {
|
|||
loadMutedUntilPref()
|
||||
loadMinPriorityPref()
|
||||
loadAutoDeletePref()
|
||||
loadPasswordPref()
|
||||
loadIconSetPref()
|
||||
loadIconRemovePref()
|
||||
} else {
|
||||
|
@ -254,6 +254,30 @@ class DetailSettingsActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun loadPasswordPref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_notifications_password_key) ?: return
|
||||
val pref: EditTextPreference? = findPreference(prefId)
|
||||
pref?.isVisible = true // Hack: Show all settings at once, because subscription is loaded asynchronously
|
||||
pref?.text = ""
|
||||
pref?.preferenceDataStore = object : PreferenceDataStore() {
|
||||
override fun putString(key: String?, value: String?) {
|
||||
val newPassword = value ?: return
|
||||
val encryptionKey = if (newPassword.trim().isEmpty()) null else Encryption.deriveKey(newPassword, topicUrl(subscription))
|
||||
save(subscription.copy(encryptionKey = encryptionKey))
|
||||
}
|
||||
override fun getString(key: String?, defValue: String?): String {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
pref?.summaryProvider = Preference.SummaryProvider<EditTextPreference> { pref ->
|
||||
if (TextUtils.isEmpty(pref.text)) {
|
||||
"No password set"
|
||||
} else {
|
||||
"Password saved"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadIconSetPref() {
|
||||
val prefId = context?.getString(R.string.detail_settings_appearance_icon_set_key) ?: return
|
||||
iconSetPref = findPreference(prefId) ?: return
|
||||
|
|
|
@ -43,7 +43,6 @@ import io.heckel.ntfy.work.PollWorker
|
|||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.security.SecureRandom
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.random.Random
|
||||
|
@ -206,6 +205,20 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
schedulePeriodicPollWorker()
|
||||
schedulePeriodicServiceRestartWorker()
|
||||
schedulePeriodicDeleteWorker()
|
||||
|
||||
testenc()
|
||||
}
|
||||
|
||||
fun testenc() {
|
||||
try {
|
||||
val key = Encryption.deriveKey("secr3t password", "https://ntfy.sh/mysecret")
|
||||
Log.d("encryption", "key ${key.toHex()}")
|
||||
val ciphertext = "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..vbe1Qv_-mKYbUgce.EfmOUIUi7lxXZG_o4bqXZ9pmpr1Rzs4Y5QLE2XD2_aw_SQ.y2hadrN5b2LEw7_PJHhbcA"
|
||||
val plaintext = Encryption.decrypt(ciphertext, key)
|
||||
Log.d("encryption", "decryptString: $plaintext")
|
||||
} catch (e: Exception) {
|
||||
Log.e("encryption", "failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
@ -439,6 +452,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
upAppId = null,
|
||||
upConnectorToken = null,
|
||||
displayName = null,
|
||||
encryptionKey = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
|
@ -455,7 +469,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
|
||||
val notifications = api
|
||||
.poll(subscription.id, subscription.baseUrl, subscription.topic, user)
|
||||
.map { n -> Encryption.maybeDecrypt(subscription, n) }
|
||||
notifications.forEach { notification -> repository.addNotification(notification) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
|
||||
|
@ -492,7 +508,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
|
|||
Log.d(TAG, "subscription: ${subscription}")
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl) // May be null
|
||||
val notifications = api.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
|
||||
val notifications = api
|
||||
.poll(subscription.id, subscription.baseUrl, subscription.topic, user, subscription.lastNotificationId)
|
||||
.map { n -> Encryption.maybeDecrypt(subscription, n) }
|
||||
val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
|
||||
newNotifications.forEach { notification ->
|
||||
newNotificationsCount++
|
||||
|
|
|
@ -80,6 +80,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
|
|||
upAppId = appId,
|
||||
upConnectorToken = connectorToken,
|
||||
displayName = null,
|
||||
encryptionKey = null,
|
||||
totalCount = 0,
|
||||
newCount = 0,
|
||||
lastActive = Date().time/1000
|
||||
|
|
68
app/src/main/java/io/heckel/ntfy/util/Encryption.kt
Normal file
68
app/src/main/java/io/heckel/ntfy/util/Encryption.kt
Normal file
|
@ -0,0 +1,68 @@
|
|||
package io.heckel.ntfy.util
|
||||
|
||||
import android.util.Base64
|
||||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.db.Subscription
|
||||
import io.heckel.ntfy.msg.NotificationParser
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) }
|
||||
|
||||
object Encryption {
|
||||
private const val TAG = "NtfyEncryption"
|
||||
private const val keyDerivIter = 50000
|
||||
private const val keyLenBits = 256
|
||||
private const val gcmTagLenBits = 128
|
||||
private const val encodingJwe = "jwe"
|
||||
private val parser = NotificationParser()
|
||||
|
||||
fun maybeDecrypt(subscription: Subscription, notification: Notification): Notification {
|
||||
if (notification.encoding != encodingJwe) {
|
||||
return notification
|
||||
} else if (subscription.encryptionKey == null) {
|
||||
Log.w(TAG, "Notification is encrypted, but key is missing: $notification; leaving encrypted message intact")
|
||||
return notification
|
||||
}
|
||||
return try {
|
||||
val plaintext = decrypt(notification.message, subscription.encryptionKey)
|
||||
val decryptedNotification = parser.parse(plaintext) ?: throw Exception("Cannot parse decrypted message: $plaintext")
|
||||
if (decryptedNotification.id != notification.id || decryptedNotification.timestamp != notification.timestamp) {
|
||||
throw Exception("Message ID or timestamp mismatch in decrypted message: $plaintext")
|
||||
}
|
||||
decryptedNotification
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Unable to decrypt message, falling back to original", e)
|
||||
notification
|
||||
}
|
||||
}
|
||||
|
||||
fun deriveKey(password: String, topicUrl: String): ByteArray {
|
||||
val salt = MessageDigest.getInstance("SHA-256").digest(topicUrl.toByteArray())
|
||||
val factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256")
|
||||
val spec = PBEKeySpec(password.toCharArray(), salt, keyDerivIter, keyLenBits)
|
||||
return factory.generateSecret(spec).encoded
|
||||
}
|
||||
|
||||
fun decrypt(input: String, key: ByteArray): String {
|
||||
val parts = input.split(".")
|
||||
if (parts.size != 5 || parts[1] != "") {
|
||||
throw Exception("Unexpected format")
|
||||
}
|
||||
val encodedHeader = parts[0]
|
||||
val iv = Base64.decode(parts[2], Base64.URL_SAFE)
|
||||
val ciphertext = Base64.decode(parts[3], Base64.URL_SAFE)
|
||||
val tag = Base64.decode(parts[4], Base64.URL_SAFE)
|
||||
val ciphertextWithTag = ciphertext + tag
|
||||
val secretKeySpec = SecretKeySpec(key, "AES")
|
||||
val gcmParameterSpec = GCMParameterSpec(gcmTagLenBits, iv)
|
||||
val cipher: Cipher = Cipher.getInstance("AES/GCM/NoPadding")
|
||||
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, gcmParameterSpec)
|
||||
cipher.updateAAD(encodedHeader.toByteArray())
|
||||
return String(cipher.doFinal(ciphertextWithTag))
|
||||
}
|
||||
}
|
|
@ -46,6 +46,7 @@ import kotlin.math.abs
|
|||
import kotlin.math.absoluteValue
|
||||
|
||||
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}"
|
||||
fun topicUrl(subscription: Subscription) = topicUrl(subscription.baseUrl, subscription.topic)
|
||||
fun topicUrlUp(baseUrl: String, topic: String) = "${baseUrl}/${topic}?up=1" // UnifiedPush
|
||||
fun topicUrlJson(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/json?since=$since"
|
||||
fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"
|
||||
|
|
|
@ -7,6 +7,7 @@ import io.heckel.ntfy.BuildConfig
|
|||
import io.heckel.ntfy.db.Repository
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.util.Encryption
|
||||
import io.heckel.ntfy.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
@ -40,13 +41,15 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
|
|||
subscriptions.forEach{ subscription ->
|
||||
try {
|
||||
val user = repository.getUser(subscription.baseUrl)
|
||||
val notifications = api.poll(
|
||||
val notifications = api
|
||||
.poll(
|
||||
subscriptionId = subscription.id,
|
||||
baseUrl = subscription.baseUrl,
|
||||
topic = subscription.topic,
|
||||
user = user,
|
||||
since = subscription.lastNotificationId
|
||||
)
|
||||
.map { n -> Encryption.maybeDecrypt(subscription, n) }
|
||||
val newNotifications = repository
|
||||
.onlyNewNotifications(subscription.id, notifications)
|
||||
.map { it.copy(notificationId = Random.nextInt()) }
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
ntfy EDIT:
|
||||
This is a slightly edited copy of the original Android project layout
|
||||
to reduce the marginBottom of the message to something reasonable (was: 48dp).
|
||||
|
||||
~ Copyright (C) 2015 The Android Open Source Project
|
||||
~
|
||||
~ Licensed under the Apache License, Version 2.0 (the "License");
|
||||
~ you may not use this file except in compliance with the License.
|
||||
~ You may obtain a copy of the License at
|
||||
~
|
||||
~ http://www.apache.org/licenses/LICENSE-2.0
|
||||
~
|
||||
~ Unless required by applicable law or agreed to in writing, software
|
||||
~ distributed under the License is distributed on an "AS IS" BASIS,
|
||||
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
~ See the License for the specific language governing permissions and
|
||||
~ limitations under the License
|
||||
-->
|
||||
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="48dp"
|
||||
android:layout_marginBottom="48dp"
|
||||
android:overScrollMode="ifContentScrolls">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@android:id/message"
|
||||
style="?android:attr/textAppearanceSmall"
|
||||
android:layout_marginLeft="24dp"
|
||||
android:layout_marginRight="24dp"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:layout_marginBottom="2dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?android:attr/textColorSecondary"/>
|
||||
|
||||
<EditText
|
||||
android:id="@android:id/edit"
|
||||
android:layout_marginLeft="20dp"
|
||||
android:layout_marginRight="20dp"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight = "48dp"
|
||||
android:inputType="textPassword"/>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
|
@ -347,6 +347,8 @@
|
|||
<string name="detail_settings_notifications_instant_title">Instant delivery</string>
|
||||
<string name="detail_settings_notifications_instant_summary_on">Notifications are delivered instantly. Requires a foreground service and consumes more battery.</string>
|
||||
<string name="detail_settings_notifications_instant_summary_off">Notifications are delivered using Firebase. Delivery may be delayed, but consumes less battery.</string>
|
||||
<string name="detail_settings_notifications_password_title">Password</string>
|
||||
<string name="detail_settings_notifications_password_dialog_summary">Choose a password to use to decrypt incoming end-to-end encrypted notifications. The derived key will be stored locally. If the password is incorrect, messages will not decrypt correctly.</string>
|
||||
<string name="detail_settings_appearance_header">Appearance</string>
|
||||
<string name="detail_settings_appearance_icon_set_title">Subscription icon</string>
|
||||
<string name="detail_settings_appearance_icon_set_summary">Set an icon to be displayed in notifications</string>
|
||||
|
|
|
@ -35,6 +35,7 @@
|
|||
<string name="detail_settings_notifications_muted_until_key" translatable="false">SubscriptionMutedUntil</string>
|
||||
<string name="detail_settings_notifications_min_priority_key" translatable="false">SubscriptionMinPriority</string>
|
||||
<string name="detail_settings_notifications_auto_delete_key" translatable="false">SubscriptionAutoDelete</string>
|
||||
<string name="detail_settings_notifications_password_key" translatable="false">SubscriptionPassword</string>
|
||||
<string name="detail_settings_appearance_header_key" translatable="false">SubscriptionAppearance</string>
|
||||
<string name="detail_settings_appearance_icon_set_key" translatable="false">SubscriptionIconSet</string>
|
||||
<string name="detail_settings_appearance_icon_remove_key" translatable="false">SubscriptionIconRemove</string>
|
||||
|
|
|
@ -28,6 +28,11 @@
|
|||
app:entryValues="@array/detail_settings_notifications_auto_delete_values"
|
||||
app:defaultValue="-1"
|
||||
app:isPreferenceVisible="false"/> <!-- Same as Repository.AUTO_DELETE_USE_GLOBAL -->
|
||||
<EditTextPreference
|
||||
app:key="@string/detail_settings_notifications_password_key"
|
||||
app:title="@string/detail_settings_notifications_password_title"
|
||||
app:dialogLayout="@layout/preference_dialog_editpass_edited"
|
||||
app:dialogMessage="@string/detail_settings_notifications_password_dialog_summary"/>
|
||||
</PreferenceCategory>
|
||||
<PreferenceCategory
|
||||
app:key="@string/detail_settings_appearance_header_key"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package io.heckel.ntfy.firebase
|
||||
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import androidx.work.*
|
||||
import com.google.firebase.messaging.FirebaseMessagingService
|
||||
import com.google.firebase.messaging.RemoteMessage
|
||||
|
@ -11,10 +10,10 @@ import io.heckel.ntfy.db.Attachment
|
|||
import io.heckel.ntfy.db.Notification
|
||||
import io.heckel.ntfy.util.Log
|
||||
import io.heckel.ntfy.msg.ApiService
|
||||
import io.heckel.ntfy.msg.MESSAGE_ENCODING_BASE64
|
||||
import io.heckel.ntfy.msg.NotificationDispatcher
|
||||
import io.heckel.ntfy.msg.NotificationParser
|
||||
import io.heckel.ntfy.service.SubscriberService
|
||||
import io.heckel.ntfy.util.Encryption
|
||||
import io.heckel.ntfy.util.toPriority
|
||||
import io.heckel.ntfy.util.topicShortUrl
|
||||
import io.heckel.ntfy.work.PollWorker
|
||||
|
@ -124,7 +123,7 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
url = attachmentUrl,
|
||||
)
|
||||
} else null
|
||||
val notification = Notification(
|
||||
val notificationOriginal = Notification(
|
||||
id = id,
|
||||
subscriptionId = subscription.id,
|
||||
timestamp = timestamp,
|
||||
|
@ -139,6 +138,7 @@ class FirebaseService : FirebaseMessagingService() {
|
|||
notificationId = Random.nextInt(),
|
||||
deleted = false
|
||||
)
|
||||
val notification = Encryption.maybeDecrypt(subscription, notificationOriginal)
|
||||
if (repository.addNotification(notification)) {
|
||||
Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
|
||||
dispatcher.dispatch(subscription, notification)
|
||||
|
|
Loading…
Reference in a new issue