Compare commits

...

1 commit

Author SHA1 Message Date
Philipp Heckel
a4461bf47f End to end, dabbling 2022-08-18 21:11:29 -04:00
18 changed files with 239 additions and 32 deletions

View file

@ -2,11 +2,11 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 12, "version": 12,
"identityHash": "9363ad5196e88862acceb1bb9ee91124", "identityHash": "250db1985385d64d880124071eab96fc",
"entities": [ "entities": [
{ {
"tableName": "Subscription", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -79,6 +79,12 @@
"columnName": "displayName", "columnName": "displayName",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": false "notNull": false
},
{
"fieldPath": "encryptionKey",
"columnName": "encryptionKey",
"affinity": "BLOB",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -326,7 +332,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View file

@ -2,6 +2,7 @@ package io.heckel.ntfy.backup
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Base64
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.stream.JsonReader import com.google.gson.stream.JsonReader
@ -102,6 +103,7 @@ class Backuper(val context: Context) {
upAppId = s.upAppId, upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken, upConnectorToken = s.upConnectorToken,
displayName = s.displayName, displayName = s.displayName,
encryptionKey = Base64.decode(s.encryptionKey, Base64.DEFAULT)
)) ))
} catch (e: Exception) { } catch (e: Exception) {
Log.w(TAG, "Unable to restore subscription ${s.id} (${topicUrl(s.baseUrl, s.topic)}): ${e.message}. Ignoring.", e) 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, icon = s.icon,
upAppId = s.upAppId, upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken, 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 icon: String?,
val upAppId: String?, val upAppId: String?,
val upConnectorToken: String?, val upConnectorToken: String?,
val displayName: String? val displayName: String?,
val encryptionKey: String? // as base64
) )
data class Notification( data class Notification(
@ -343,7 +347,7 @@ data class Notification(
val timestamp: Long, val timestamp: Long,
val title: String, val title: String,
val message: 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 priority: Int, // 1=min, 3=default, 5=max
val tags: String, val tags: String,
val click: String, // URL/intent to open on notification click val click: String, // URL/intent to open on notification click

View file

@ -23,13 +23,14 @@ data class Subscription(
@ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name @ColumnInfo(name = "upAppId") val upAppId: String?, // UnifiedPush application package name
@ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token @ColumnInfo(name = "upConnectorToken") val upConnectorToken: String?, // UnifiedPush connector token
@ColumnInfo(name = "displayName") val displayName: String?, @ColumnInfo(name = "displayName") val displayName: String?,
@ColumnInfo(name = "encryptionKey") val encryptionKey: ByteArray?,
@Ignore val totalCount: Int = 0, // Total notifications @Ignore val totalCount: Int = 0, // Total notifications
@Ignore val newCount: Int = 0, // New notifications @Ignore val newCount: Int = 0, // New notifications
@Ignore val lastActive: Long = 0, // Unix timestamp @Ignore val lastActive: Long = 0, // Unix timestamp
@Ignore val state: ConnectionState = ConnectionState.NOT_APPLICABLE @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?) : 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, 0, 0, 0, ConnectionState.NOT_APPLICABLE) this(id, baseUrl, topic, instant, mutedUntil, minPriority, autoDelete, lastNotificationId, icon, upAppId, upConnectorToken, displayName, encryptionKey, 0, 0, 0, ConnectionState.NOT_APPLICABLE)
} }
enum class ConnectionState { enum class ConnectionState {
@ -49,6 +50,7 @@ data class SubscriptionWithMetadata(
val upAppId: String?, val upAppId: String?,
val upConnectorToken: String?, val upConnectorToken: String?,
val displayName: String?, val displayName: String?,
val encryptionKey: ByteArray?,
val totalCount: Int, val totalCount: Int,
val newCount: Int, val newCount: Int,
val lastActive: Long val lastActive: Long
@ -61,7 +63,7 @@ data class Notification(
@ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp @ColumnInfo(name = "timestamp") val timestamp: Long, // Unix timestamp
@ColumnInfo(name = "title") val title: String, @ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "message") val message: 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 = "notificationId") val notificationId: Int, // Android notification popup ID
@ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max @ColumnInfo(name = "priority", defaultValue = "3") val priority: Int, // 1=min, 3=default, 5=max
@ColumnInfo(name = "tags") val tags: String, @ColumnInfo(name = "tags") val tags: String,
@ -269,6 +271,8 @@ abstract class Database : RoomDatabase() {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE Subscription ADD COLUMN lastNotificationId TEXT") 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 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 { interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -291,7 +295,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -304,7 +308,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -317,7 +321,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive
@ -330,7 +334,7 @@ interface SubscriptionDao {
@Query(""" @Query("""
SELECT 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(n.id) totalCount,
COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount, COUNT(CASE n.notificationId WHEN 0 THEN NULL ELSE n.id END) newCount,
IFNULL(MAX(n.timestamp),0) AS lastActive IFNULL(MAX(n.timestamp),0) AS lastActive

View file

@ -385,6 +385,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
upAppId = s.upAppId, upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken, upConnectorToken = s.upConnectorToken,
displayName = s.displayName, displayName = s.displayName,
encryptionKey = s.encryptionKey,
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,
@ -410,6 +411,7 @@ class Repository(private val sharedPrefs: SharedPreferences, private val databas
upAppId = s.upAppId, upAppId = s.upAppId,
upConnectorToken = s.upConnectorToken, upConnectorToken = s.upConnectorToken,
displayName = s.displayName, displayName = s.displayName,
encryptionKey = s.encryptionKey,
totalCount = s.totalCount, totalCount = s.totalCount,
newCount = s.newCount, newCount = s.newCount,
lastActive = s.lastActive, lastActive = s.lastActive,

View file

@ -3,6 +3,7 @@ package io.heckel.ntfy.service
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.topicUrl import io.heckel.ntfy.util.topicUrl
import kotlinx.coroutines.* import kotlinx.coroutines.*
import okhttp3.Call import okhttp3.Call
@ -42,7 +43,8 @@ class JsonConnection(
since = notification.id since = notification.id
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify val subscriptionId = topicsToSubscriptionIds[topic] ?: return@notify
val subscription = repository.getSubscription(subscriptionId) ?: 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) notificationListener(subscription, notificationWithSubscriptionId)
} }
val failed = AtomicBoolean(false) val failed = AtomicBoolean(false)

View file

@ -7,6 +7,7 @@ import android.os.Looper
import io.heckel.ntfy.db.* import io.heckel.ntfy.db.*
import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder import io.heckel.ntfy.msg.ApiService.Companion.requestBuilder
import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.util.topicUrlWs import io.heckel.ntfy.util.topicUrlWs
@ -144,7 +145,8 @@ class WsConnection(
val notification = notificationWithTopic.notification val notification = notificationWithTopic.notification
val subscriptionId = topicsToSubscriptionIds[topic] ?: return@synchronize val subscriptionId = topicsToSubscriptionIds[topic] ?: return@synchronize
val subscription = repository.getSubscription(subscriptionId) ?: 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) notificationListener(subscription, notificationWithSubscriptionId)
since.set(notification.id) since.set(notification.id)
} }

View file

@ -118,6 +118,7 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
upAppId = null, upAppId = null,
upConnectorToken = null, upConnectorToken = null,
displayName = null, displayName = null,
encryptionKey = null,
totalCount = 0, totalCount = 0,
newCount = 0, newCount = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000
@ -133,7 +134,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
// Fetch cached messages // Fetch cached messages
try { try {
val user = repository.getUser(subscription.baseUrl) // May be null 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) } notifications.forEach { notification -> repository.addNotification(notification) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) Log.e(TAG, "Unable to fetch notifications: ${e.message}", e)
@ -466,7 +469,9 @@ class DetailActivity : AppCompatActivity(), ActionMode.Callback, NotificationFra
try { try {
val subscription = repository.getSubscription(subscriptionId) ?: return@launch val subscription = repository.getSubscription(subscriptionId) ?: return@launch
val user = repository.getUser(subscription.baseUrl) // May be null 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 newNotifications = repository.onlyNewNotifications(subscriptionId, notifications)
val toastMessage = if (newNotifications.isEmpty()) { val toastMessage = if (newNotifications.isEmpty()) {
getString(R.string.refresh_message_no_results) getString(R.string.refresh_message_no_results)

View file

@ -4,7 +4,6 @@ import android.content.ContentResolver
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.text.TextUtils import android.text.TextUtils
@ -111,6 +110,7 @@ class DetailSettingsActivity : AppCompatActivity() {
loadMutedUntilPref() loadMutedUntilPref()
loadMinPriorityPref() loadMinPriorityPref()
loadAutoDeletePref() loadAutoDeletePref()
loadPasswordPref()
loadIconSetPref() loadIconSetPref()
loadIconRemovePref() loadIconRemovePref()
} else { } 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() { private fun loadIconSetPref() {
val prefId = context?.getString(R.string.detail_settings_appearance_icon_set_key) ?: return val prefId = context?.getString(R.string.detail_settings_appearance_icon_set_key) ?: return
iconSetPref = findPreference(prefId) ?: return iconSetPref = findPreference(prefId) ?: return

View file

@ -43,7 +43,6 @@ import io.heckel.ntfy.work.PollWorker
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.security.SecureRandom
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.random.Random import kotlin.random.Random
@ -206,6 +205,20 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
schedulePeriodicPollWorker() schedulePeriodicPollWorker()
schedulePeriodicServiceRestartWorker() schedulePeriodicServiceRestartWorker()
schedulePeriodicDeleteWorker() 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() { override fun onResume() {
@ -439,6 +452,7 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
upAppId = null, upAppId = null,
upConnectorToken = null, upConnectorToken = null,
displayName = null, displayName = null,
encryptionKey = null,
totalCount = 0, totalCount = 0,
newCount = 0, newCount = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000
@ -455,7 +469,9 @@ class MainActivity : AppCompatActivity(), ActionMode.Callback, AddFragment.Subsc
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val user = repository.getUser(subscription.baseUrl) // May be null 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) } notifications.forEach { notification -> repository.addNotification(notification) }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Unable to fetch notifications: ${e.message}", e) 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}") Log.d(TAG, "subscription: ${subscription}")
try { try {
val user = repository.getUser(subscription.baseUrl) // May be null 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) val newNotifications = repository.onlyNewNotifications(subscription.id, notifications)
newNotifications.forEach { notification -> newNotifications.forEach { notification ->
newNotificationsCount++ newNotificationsCount++

View file

@ -80,6 +80,7 @@ class BroadcastReceiver : android.content.BroadcastReceiver() {
upAppId = appId, upAppId = appId,
upConnectorToken = connectorToken, upConnectorToken = connectorToken,
displayName = null, displayName = null,
encryptionKey = null,
totalCount = 0, totalCount = 0,
newCount = 0, newCount = 0,
lastActive = Date().time/1000 lastActive = Date().time/1000

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

View file

@ -46,6 +46,7 @@ import kotlin.math.abs
import kotlin.math.absoluteValue import kotlin.math.absoluteValue
fun topicUrl(baseUrl: String, topic: String) = "${baseUrl}/${topic}" 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 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 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" fun topicUrlWs(baseUrl: String, topic: String, since: String) = "${topicUrl(baseUrl, topic)}/ws?since=$since"

View file

@ -7,6 +7,7 @@ import io.heckel.ntfy.BuildConfig
import io.heckel.ntfy.db.Repository import io.heckel.ntfy.db.Repository
import io.heckel.ntfy.msg.ApiService import io.heckel.ntfy.msg.ApiService
import io.heckel.ntfy.msg.NotificationDispatcher import io.heckel.ntfy.msg.NotificationDispatcher
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@ -40,13 +41,15 @@ class PollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx,
subscriptions.forEach{ subscription -> subscriptions.forEach{ subscription ->
try { try {
val user = repository.getUser(subscription.baseUrl) val user = repository.getUser(subscription.baseUrl)
val notifications = api.poll( val notifications = api
subscriptionId = subscription.id, .poll(
baseUrl = subscription.baseUrl, subscriptionId = subscription.id,
topic = subscription.topic, baseUrl = subscription.baseUrl,
user = user, topic = subscription.topic,
since = subscription.lastNotificationId user = user,
) since = subscription.lastNotificationId
)
.map { n -> Encryption.maybeDecrypt(subscription, n) }
val newNotifications = repository val newNotifications = repository
.onlyNewNotifications(subscription.id, notifications) .onlyNewNotifications(subscription.id, notifications)
.map { it.copy(notificationId = Random.nextInt()) } .map { it.copy(notificationId = Random.nextInt()) }

View file

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

View file

@ -347,6 +347,8 @@
<string name="detail_settings_notifications_instant_title">Instant delivery</string> <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_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_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_header">Appearance</string>
<string name="detail_settings_appearance_icon_set_title">Subscription icon</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> <string name="detail_settings_appearance_icon_set_summary">Set an icon to be displayed in notifications</string>

View file

@ -35,6 +35,7 @@
<string name="detail_settings_notifications_muted_until_key" translatable="false">SubscriptionMutedUntil</string> <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_min_priority_key" translatable="false">SubscriptionMinPriority</string>
<string name="detail_settings_notifications_auto_delete_key" translatable="false">SubscriptionAutoDelete</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_header_key" translatable="false">SubscriptionAppearance</string>
<string name="detail_settings_appearance_icon_set_key" translatable="false">SubscriptionIconSet</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> <string name="detail_settings_appearance_icon_remove_key" translatable="false">SubscriptionIconRemove</string>

View file

@ -28,6 +28,11 @@
app:entryValues="@array/detail_settings_notifications_auto_delete_values" app:entryValues="@array/detail_settings_notifications_auto_delete_values"
app:defaultValue="-1" app:defaultValue="-1"
app:isPreferenceVisible="false"/> <!-- Same as Repository.AUTO_DELETE_USE_GLOBAL --> 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>
<PreferenceCategory <PreferenceCategory
app:key="@string/detail_settings_appearance_header_key" app:key="@string/detail_settings_appearance_header_key"

View file

@ -1,7 +1,6 @@
package io.heckel.ntfy.firebase package io.heckel.ntfy.firebase
import android.content.Intent import android.content.Intent
import android.util.Base64
import androidx.work.* import androidx.work.*
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage 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.db.Notification
import io.heckel.ntfy.util.Log import io.heckel.ntfy.util.Log
import io.heckel.ntfy.msg.ApiService 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.NotificationDispatcher
import io.heckel.ntfy.msg.NotificationParser import io.heckel.ntfy.msg.NotificationParser
import io.heckel.ntfy.service.SubscriberService import io.heckel.ntfy.service.SubscriberService
import io.heckel.ntfy.util.Encryption
import io.heckel.ntfy.util.toPriority import io.heckel.ntfy.util.toPriority
import io.heckel.ntfy.util.topicShortUrl import io.heckel.ntfy.util.topicShortUrl
import io.heckel.ntfy.work.PollWorker import io.heckel.ntfy.work.PollWorker
@ -124,7 +123,7 @@ class FirebaseService : FirebaseMessagingService() {
url = attachmentUrl, url = attachmentUrl,
) )
} else null } else null
val notification = Notification( val notificationOriginal = Notification(
id = id, id = id,
subscriptionId = subscription.id, subscriptionId = subscription.id,
timestamp = timestamp, timestamp = timestamp,
@ -139,6 +138,7 @@ class FirebaseService : FirebaseMessagingService() {
notificationId = Random.nextInt(), notificationId = Random.nextInt(),
deleted = false deleted = false
) )
val notification = Encryption.maybeDecrypt(subscription, notificationOriginal)
if (repository.addNotification(notification)) { if (repository.addNotification(notification)) {
Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}") Log.d(TAG, "Dispatching notification: from=${remoteMessage.from}, fcmprio=${remoteMessage.priority}, fcmprio_orig=${remoteMessage.originalPriority}, data=${data}")
dispatcher.dispatch(subscription, notification) dispatcher.dispatch(subscription, notification)