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

View File

@@ -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) {
consumeIfMatchesAccount(event, acc)
}
}
}
val manager = notificationManager() private suspend fun consumeIfMatchesAccount(pushWrappedEvent: GiftWrapEvent, account: Account) {
if (manager.areNotificationsEnabled()) { val key = account.keyPair.privKey ?: return
when (event) { pushWrappedEvent.unwrap(key)?.let { notificationEvent ->
is PrivateDmEvent -> notify(event) if (!LocalCache.justVerify(notificationEvent)) return // invalid event
is LnZapEvent -> notify(event) if (LocalCache.notes[notificationEvent.id] != null) return // already processed
is GiftWrapEvent -> unwrapAndNotify(event)
} 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,75 +83,67 @@ 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 followingKeySet = acc.followingKeySet()
val chatEvent = unwrapAndConsume(giftWrap, account = acc)
if (chatEvent is ChatMessageEvent && // must be messages, not any other event val isKnownRoom = (
acc.keyPair.privKey != null && // can't decrypt acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true ||
chatEvent.createdAt > TimeUtils.fiveMinutesAgo() && // old event being re-broadcasted acc.userProfile().hasSentMessagesTo(chatRoom)
chatEvent.pubKey != acc.userProfile().pubkeyHex // from the user ) && !acc.isAllHidden(chatRoom.users)
) {
val chatNote = LocalCache.notes[chatEvent.id] ?: return
val chatRoom = chatEvent.chatroomKey(acc.keyPair.pubKey.toHexKey())
val followingKeySet = acc.followingKeySet() if (isKnownRoom) {
val content = chatNote.event?.content() ?: ""
val isKnownRoom = ( val user = chatNote.author?.toBestDisplayName() ?: ""
acc.userProfile().privateChatrooms[chatRoom]?.senderIntersects(followingKeySet) == true || val userPicture = chatNote.author?.profilePicture()
acc.userProfile().hasSentMessagesTo(chatRoom) val noteUri = chatNote.toNEvent()
) && !acc.isAllHidden(chatRoom.users) notificationManager().sendDMNotification(
event.id,
if (isKnownRoom) { content,
val content = chatNote.event?.content() ?: "" user,
val user = chatNote.author?.toBestDisplayName() ?: "" userPicture,
val userPicture = chatNote.author?.profilePicture() noteUri,
val noteUri = chatNote.toNEvent() applicationContext
notificationManager().sendDMNotification(chatEvent.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 { if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) val followingKeySet = acc.followingKeySet()
if (acc != null && acc.userProfile().pubkeyHex == event.verifiedRecipientPubKey()) { val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter {
val followingKeySet = acc.followingKeySet() (
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true ||
acc.userProfile().hasSentMessagesTo(it)
) && !acc.isAllHidden(it.users)
}.toSet()
val knownChatrooms = acc.userProfile().privateChatrooms.keys.filter { note.author?.let {
( if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
acc.userProfile().privateChatrooms[it]?.senderIntersects(followingKeySet) == true || val content = acc.decryptContent(note) ?: ""
acc.userProfile().hasSentMessagesTo(it) val user = note.author?.toBestDisplayName() ?: ""
) && !acc.isAllHidden(it.users) val userPicture = note.author?.profilePicture()
}.toSet() val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
note.author?.let {
if (ChatroomKey(persistentSetOf(it.pubkeyHex)) in knownChatrooms) {
val content = acc.decryptContent(note) ?: ""
val user = note.author?.toBestDisplayName() ?: ""
val userPicture = note.author?.profilePicture()
val noteUri = note.toNEvent()
notificationManager().sendDMNotification(event.id, content, user, userPicture, noteUri, applicationContext)
}
} }
} }
} }
} }
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,39 +154,35 @@ 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 { if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) {
val acc = LocalPreferences.loadFromEncryptedStorage(it.npub) val amount = showAmount(event.amount)
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let {
if (acc != null && acc.userProfile().pubkeyHex == event.zappedAuthor().firstOrNull()) { val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest)
val amount = showAmount(event.amount) if (decryptedContent != null) {
val senderInfo = (noteZapRequest.event as? LnZapRequestEvent)?.let { val author = LocalCache.getOrCreateUser(decryptedContent.pubKey)
val decryptedContent = acc.decryptZapContentAuthor(noteZapRequest) Pair(author, decryptedContent.content)
if (decryptedContent != null) { } else if (!noteZapRequest.event?.content().isNullOrBlank()) {
val author = LocalCache.getOrCreateUser(decryptedContent.pubKey) Pair(noteZapRequest.author, noteZapRequest.event?.content())
Pair(author, decryptedContent.content) } else {
} else if (!noteZapRequest.event?.content().isNullOrBlank()) { Pair(noteZapRequest.author, null)
Pair(noteZapRequest.author, noteZapRequest.event?.content())
} else {
Pair(noteZapRequest.author, null)
}
} }
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
} }
val zappedContent =
noteZapped?.let { it1 -> acc.decryptContent(it1)?.split("\n")?.get(0) }
val user = senderInfo?.first?.toBestDisplayName() ?: ""
var title = applicationContext.getString(R.string.app_notification_zaps_channel_message, amount)
senderInfo?.second?.ifBlank { null }?.let {
title += " ($it)"
}
var content = applicationContext.getString(R.string.app_notification_zaps_channel_message_from, user)
zappedContent?.let {
content += " " + applicationContext.getString(R.string.app_notification_zaps_channel_message_for, zappedContent)
}
val userPicture = senderInfo?.first?.profilePicture()
val noteUri = "nostr:Notifications"
notificationManager().sendZapNotification(event.id, content, title, userPicture, noteUri, applicationContext)
} }
} }

View File

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

View File

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

View File

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

View File

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