Migrates to Encrypted Push Notifications

This commit is contained in:
Vitor Pamplona
2023-09-08 16:16:26 -04:00
parent 939eb1bd8d
commit b4f1c4d13e
5 changed files with 147 additions and 98 deletions
@@ -24,25 +24,41 @@ import kotlinx.collections.immutable.persistentSetOf
import java.math.BigDecimal import java.math.BigDecimal
class EventNotificationConsumer(private val applicationContext: Context) { class EventNotificationConsumer(private val applicationContext: Context) {
suspend fun consume(event: GiftWrapEvent) {
if (!LocalCache.justVerify(event)) return
if (!notificationManager().areNotificationsEnabled()) return
suspend fun consume(event: Event) { // PushNotification Wraps don't include a receiver.
if (LocalCache.notes[event.id] == null) { // Test with all logged in accounts
if (LocalCache.justVerify(event)) { LocalPreferences.allSavedAccounts().forEach {
LocalCache.justConsume(event, null) val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.keyPair.privKey != null) {
val manager = notificationManager() consumeIfMatchesAccount(event, acc)
if (manager.areNotificationsEnabled()) {
when (event) {
is PrivateDmEvent -> notify(event)
is LnZapEvent -> notify(event)
is GiftWrapEvent -> unwrapAndNotify(event)
} }
} }
} }
private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
val key = account.keyPair.privKey ?: return
pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
if (!LocalCache.justVerify(notificationEvent)) return // invalid event
if (LocalCache.notes[notificationEvent.id] != null) return // already processed
LocalCache.justConsume(notificationEvent, null)
unwrapAndConsume(notificationEvent, account)?.let { innerEvent ->
if (innerEvent is PrivateDmEvent) {
notify(innerEvent, account)
} else if (innerEvent is LnZapEvent) {
notify(innerEvent, account)
} else if (innerEvent is ChatMessageEvent) {
notify(innerEvent, account)
}
}
} }
} }
suspend fun unwrapAndConsume(event: Event, account: Account): Event? { private fun unwrapAndConsume(event: Event, account: Account): Event? {
if (!LocalCache.justVerify(event)) return null if (!LocalCache.justVerify(event)) return null
return when (event) { return when (event) {
@@ -67,22 +83,13 @@ class EventNotificationConsumer(private val applicationContext: Context) {
} }
} }
private suspend fun unwrapAndNotify(giftWrap: GiftWrapEvent) { private fun notify(event: ChatMessageEvent, acc: Account) {
val giftWrapNote = LocalCache.notes[giftWrap.id] ?: return if (event.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
event.pubKey != acc.userProfile().pubkeyHex
) { // from the user
LocalPreferences.allSavedAccounts().forEach { val chatNote = LocalCache.notes[event.id] ?: return
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) val chatRoom = event.chatroomKey(acc.keyPair.pubKey.toHexKey())
if (acc != null && acc.userProfile().pubkeyHex == giftWrap.recipientPubKey()) {
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
if (chatEvent is ChatMessageEvent && // must be messages, not any other event
acc.keyPair.privKey != null && // can't decrypt
chatEvent.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted
chatEvent.pubKey != acc.userProfile().pubkeyHex // from the user
) {
val chatNote = LocalCache.notes[chatEvent.id] ?: return
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet() val followingKeySet = acc.followingKeySet()
@@ -96,22 +103,24 @@ class EventNotificationConsumer(private val applicationContext: Context) {
val user = chatNote.author?.toBestDisplayName() ?: "" val user = chatNote.author?.toBestDisplayName() ?: ""
val userPicture = chatNote.author?.profilePicture() val userPicture = chatNote.author?.profilePicture()
val noteUri = chatNote.toNEvent() val noteUri = chatNote.toNEvent()
notificationManager().sendDMNotification(chatEvent.id, content, user, userPicture, noteUri, applicationContext) notificationManager().sendDMNotification(
} event.id,
} content,
user,
userPicture,
noteUri,
applicationContext
)
} }
} }
} }
private fun notify(event: PrivateDmEvent) { private fun notify(event: PrivateDmEvent, acc: Account) {
val note = LocalCache.notes[event.id] ?: return val note = LocalCache.notes[event.id] ?: return
// old event being re-broadcast // old event being re-broadcast
if (event.createdAt < TimeUtils.fiveMinutesAgo()) return if (event.createdAt < TimeUtils.fiveMinutesAgo()) return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val followingKeySet = acc.followingKeySet() val followingKeySet = acc.followingKeySet()
@@ -133,9 +142,8 @@ class EventNotificationConsumer(private val applicationContext: Context) {
} }
} }
} }
}
private fun notify(event: LnZapEvent) { private fun notify(event: LnZapEvent, acc: Account) {
val noteZapEvent = LocalCache.notes[event.id] ?: return val noteZapEvent = LocalCache.notes[event.id] ?: return
// old event being re-broadcast // old event being re-broadcast
@@ -146,9 +154,6 @@ class EventNotificationConsumer(private val applicationContext: Context) {
if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return if ((event.amount ?: BigDecimal.ZERO) < BigDecimal.TEN) return
LocalPreferences.allSavedAccounts().forEach {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub)
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val amount = showAmount(event.amount) val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let { val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
@@ -180,7 +185,6 @@ class EventNotificationConsumer(private val applicationContext: Context) {
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext) notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
} }
} }
}
fun notificationManager(): NotificationManager { fun notificationManager(): NotificationManager {
return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager return ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager
@@ -1,6 +1,7 @@
package com.vitorpamplona.amethyst.service.notifications package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager import android.app.NotificationManager
import android.util.LruCache
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage import com.google.firebase.messaging.RemoteMessage
@@ -8,6 +9,7 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateDMChannel
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.getOrCreateZapChannel
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -15,19 +17,34 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class PushNotificationReceiverService : FirebaseMessagingService() { class PushNotificationReceiverService : FirebaseMessagingService() {
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val eventCache = LruCache<String, String>(100)
// this is called when a message is received // this is called when a message is received
override fun onMessageReceived(remoteMessage: RemoteMessage) { override fun onMessageReceived(remoteMessage: RemoteMessage) {
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
remoteMessage.data.let { parseMessage(remoteMessage.data)?.let {
val eventStr = remoteMessage.data["event"] ?: return@let receiveIfNew(it)
val event = Event.fromJson(eventStr)
EventNotificationConsumer(applicationContext).consume(event)
} }
} }
} }
private suspend fun parseMessage(params: Map<String, String>): GiftWrapEvent? {
params["encryptedEvent"]?.let { eventStr ->
(Event.fromJson(eventStr) as? GiftWrapEvent)?.let {
return it
}
}
return null
}
private suspend fun receiveIfNew(event: GiftWrapEvent) {
if (eventCache.get(event.id) == null) {
eventCache.put(event.id, event.id)
EventNotificationConsumer(applicationContext).consume(event)
}
}
override fun onDestroy() { override fun onDestroy() {
scope.cancel() scope.cancel()
super.onDestroy() super.onDestroy()
@@ -5,6 +5,8 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.decodePublicKey
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -5,7 +5,9 @@ import com.vitorpamplona.quartz.encoders.hexToByteArray
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.crypto.KeyPair import com.vitorpamplona.quartz.crypto.KeyPair
import com.vitorpamplona.quartz.encoders.Hex
import com.vitorpamplona.quartz.encoders.HexKey import com.vitorpamplona.quartz.encoders.HexKey
import com.vitorpamplona.quartz.encoders.decodePublicKey
import com.vitorpamplona.quartz.events.ChatMessageEvent import com.vitorpamplona.quartz.events.ChatMessageEvent
import com.vitorpamplona.quartz.events.Event import com.vitorpamplona.quartz.events.Event
import com.vitorpamplona.quartz.events.GiftWrapEvent import com.vitorpamplona.quartz.events.GiftWrapEvent
@@ -557,4 +559,28 @@ class GiftWrapEventTest {
null null
} }
} }
@Test
fun decryptMsgFromNostrTools() {
val receiversPrivateKey = Hex.decode("df51ec558372612918a83446d279d683039bece79b7a721274b1d3cb612dc6af")
val msg = """
{
"tags": [],
"content": "AUC1i3lHsEOYQZaqav8jAw/Dv25r6BpUX4r7ARaj/7JEqvtHkbtaWXEx3LvMlDJstNX1C90RIelgYTzxb4Xnql7zFmXtxGGd/gXOZzW/OCNWECTrhFTruZUcsyn2ssJMgEMBZKY3PgbAKykHlGCuWR3KI9bo+IA5sTqHlrwDGAysxBypRuAxTdtEApw1LSu2A+1UQsdHK/4HcW/fQLPguWGyPv09dftJIJkFWM8VYBQT7b5FeAEMhjlUM+lEmLMnx6qb07Ji/YMESkhzFlgGjHNVl1Q/BT4i6X+Skogl6Si3lWQzlS9oebUim1BQW+RO0IOyQLalZwjzGP+eE7Ry62ukQg7cPiqk62p7NNula17SF2Q8aVFLxr8WjbLXoWhZOWY25uFbTl7OPGGQb5TewRsjHoFeU4h05Ien3Ymf1VPqJVJCMIxU+yFZ1IMZh/vQW4BSx8VotRdNA05fz03ST88GzGxUvqEm4VW/Yp5q4UUkCDQTKmUImaSFmTser39WmvS5+dHY6ne4RwnrZR0ZYrG1bthRHycnPmaJiYsHn9Ox37EzgLR07pmNxr2+86NR3S3TLAVfTDN3XaXRee/7UfW/MXULVyuyweksIHOYBvANC0PxmGSs4UiFoCbwNi45DT2y0SwP6CxzDuM=",
"kind": 1059,
"created_at": 1694192155914,
"pubkey": "8253eb518413b57f0df329d3d4287bdef4031fd71c32ad1952d854e703dae6a7",
"id": "ae625fd43612127d63bfd1967ba32ae915100842a205fc2c3b3fc02ab3827f08",
"sig": "2807a7ab5728984144676fd34686267cbe6fe38bc2f65a3640ba9243c13e8a1ae5a9a051e8852aa0c997a3623d7fa066cf2073a233c6d7db46fb1a0d4c01e5a3"
}
""".trimIndent()
val wrap = Event.fromJson(msg) as GiftWrapEvent
wrap.checkSignature()
val event = wrap.unwrap(receiversPrivateKey)
assertNotNull(event)
println(event)
}
} }
@@ -21,7 +21,7 @@ class LnZapEvent(
fromJson(it) fromJson(it)
} as? LnZapRequestEvent } as? LnZapRequestEvent
} catch (e: Exception) { } catch (e: Exception) {
Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()}", e) Log.w("LnZapEvent", "Failed to Parse Contained Post ${description()} in event ${id}", e)
null null
} }