diff --git a/app/schemas/io.heckel.ntfy.db.Database/12.json b/app/schemas/io.heckel.ntfy.db.Database/12.json index 8b1a443..08be4f7 100644 --- a/app/schemas/io.heckel.ntfy.db.Database/12.json +++ b/app/schemas/io.heckel.ntfy.db.Database/12.json @@ -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')" ] } } \ No newline at end of file diff --git a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt index 9ce3069..3b71d9e 100644 --- a/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt +++ b/app/src/main/java/io/heckel/ntfy/backup/Backuper.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/db/Database.kt b/app/src/main/java/io/heckel/ntfy/db/Database.kt index f5ddb44..55446de 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Database.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Database.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/db/Repository.kt b/app/src/main/java/io/heckel/ntfy/db/Repository.kt index 9f13656..75a5373 100644 --- a/app/src/main/java/io/heckel/ntfy/db/Repository.kt +++ b/app/src/main/java/io/heckel/ntfy/db/Repository.kt @@ -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, diff --git a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt index 8bca688..b7215c6 100644 --- a/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/JsonConnection.kt @@ -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) diff --git a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt index 1f168d3..1bf6abb 100644 --- a/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt +++ b/app/src/main/java/io/heckel/ntfy/service/WsConnection.kt @@ -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) } diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt index 4fc9ec7..649f3a1 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailActivity.kt @@ -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) diff --git a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt index 5082708..20f5f71 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/DetailSettingsActivity.kt @@ -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 { 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 diff --git a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt index 2d7865d..3c25728 100644 --- a/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt +++ b/app/src/main/java/io/heckel/ntfy/ui/MainActivity.kt @@ -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++ diff --git a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt index 9119e15..7264326 100644 --- a/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt +++ b/app/src/main/java/io/heckel/ntfy/up/BroadcastReceiver.kt @@ -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 diff --git a/app/src/main/java/io/heckel/ntfy/util/Encryption.kt b/app/src/main/java/io/heckel/ntfy/util/Encryption.kt new file mode 100644 index 0000000..5b54d25 --- /dev/null +++ b/app/src/main/java/io/heckel/ntfy/util/Encryption.kt @@ -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)) + } +} diff --git a/app/src/main/java/io/heckel/ntfy/util/Util.kt b/app/src/main/java/io/heckel/ntfy/util/Util.kt index a6a3197..442bf1c 100644 --- a/app/src/main/java/io/heckel/ntfy/util/Util.kt +++ b/app/src/main/java/io/heckel/ntfy/util/Util.kt @@ -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" diff --git a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt index 582d071..2b562c5 100644 --- a/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt +++ b/app/src/main/java/io/heckel/ntfy/work/PollWorker.kt @@ -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( - subscriptionId = subscription.id, - baseUrl = subscription.baseUrl, - topic = subscription.topic, - user = user, - since = subscription.lastNotificationId - ) + 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()) } diff --git a/app/src/main/res/layout/preference_dialog_editpass_edited.xml b/app/src/main/res/layout/preference_dialog_editpass_edited.xml new file mode 100644 index 0000000..2309b2c --- /dev/null +++ b/app/src/main/res/layout/preference_dialog_editpass_edited.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 466cd40..527b1fd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -347,6 +347,8 @@ Instant delivery Notifications are delivered instantly. Requires a foreground service and consumes more battery. Notifications are delivered using Firebase. Delivery may be delayed, but consumes less battery. + Password + 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. Appearance Subscription icon Set an icon to be displayed in notifications diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml index 93f8c70..76cb385 100644 --- a/app/src/main/res/values/values.xml +++ b/app/src/main/res/values/values.xml @@ -35,6 +35,7 @@ SubscriptionMutedUntil SubscriptionMinPriority SubscriptionAutoDelete + SubscriptionPassword SubscriptionAppearance SubscriptionIconSet SubscriptionIconRemove diff --git a/app/src/main/res/xml/detail_preferences.xml b/app/src/main/res/xml/detail_preferences.xml index e209f16..eb9a1ed 100644 --- a/app/src/main/res/xml/detail_preferences.xml +++ b/app/src/main/res/xml/detail_preferences.xml @@ -28,6 +28,11 @@ app:entryValues="@array/detail_settings_notifications_auto_delete_values" app:defaultValue="-1" app:isPreferenceVisible="false"/> +