Merge pull request #2705 from davotoula/feat/desktop-quartz-enhancements

desktop: verify event signatures on receive
This commit is contained in:
Vitor Pamplona
2026-05-03 12:19:07 -04:00
committed by GitHub
6 changed files with 266 additions and 77 deletions
@@ -29,8 +29,11 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.cache.LargeSoftCache
import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.checkSignature
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.aTag.taggedAddresses
@@ -49,6 +52,8 @@ import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.channels.BufferOverflow
@@ -162,15 +167,35 @@ class DesktopLocalCache : ICacheProvider {
}
}
fun justVerify(event: Event): Boolean =
if (!event.verify()) {
try {
event.checkSignature()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("Event Verification Failed") {
"Kind: ${event.kind} createdAt=${event.createdAt} id=${event.id} pubkey=${event.pubKey} reason=${e.message}"
}
}
false
} else {
true
}
// ----- Event consumption -----
/**
* Routes an event to the appropriate consume method.
* Returns true if the event was consumed (new), false if already seen.
*/
fun consume(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean = false,
): Boolean {
if (!wasVerified && !justVerify(event)) return false
return route(event, relay)
}
private fun route(
event: Event,
relay: NormalizedRelayUrl?,
): Boolean =
when (event) {
is MetadataEvent -> {
@@ -454,7 +479,9 @@ class DesktopLocalCache : ICacheProvider {
zappedNote: Note?,
relay: NormalizedRelayUrl?,
onResponse: suspend (LnZapPaymentResponseEvent) -> Unit,
wasVerified: Boolean = false,
): Boolean {
if (!wasVerified && !justVerify(event)) return false
val note = getOrCreateNote(event.id)
val author = getOrCreateUser(event.pubKey)
@@ -481,7 +508,9 @@ class DesktopLocalCache : ICacheProvider {
fun consume(
event: LnZapPaymentResponseEvent,
relay: NormalizedRelayUrl?,
wasVerified: Boolean = false,
): Boolean {
if (!wasVerified && !justVerify(event)) return false
val requestId = event.requestId()
val pending = paymentTracker.onResponseReceived(requestId) ?: return false
@@ -545,9 +574,10 @@ class DesktopLocalCache : ICacheProvider {
// ----- Own event consumption -----
override fun justConsumeMyOwnEvent(event: Event): Boolean {
if (!justVerify(event)) return false
// For addressable/replaceable events, store in the addressable note cache
// so state holders (Nip65RelayListState, etc.) pick it up via their flows
if (event is com.vitorpamplona.quartz.nip01Core.core.AddressableEvent) {
if (event is AddressableEvent) {
val address = event.address()
val note = getOrCreateAddressableNote(address)
val author = getOrCreateUser(event.pubKey) ?: return false
@@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEv
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
@@ -134,27 +135,31 @@ class NwcPaymentHandler(
filters = listOf(filter),
onEvent = { event, relay ->
if (event is LnZapPaymentResponseEvent && event.requestId() == requestId) {
// Unsubscribe
relayManager.closeSubscription(nwcConnection.relayUri, subId)
// Move verify + cache mutation + decrypt off the relay's
// WebSocket reader thread; Schnorr verify is non-trivial
// CPU work and shouldn't block frame parsing.
@OptIn(kotlinx.coroutines.DelicateCoroutinesApi::class)
kotlinx.coroutines.GlobalScope.launch(kotlinx.coroutines.Dispatchers.IO) {
if (!localCache.justVerify(event)) return@launch
// Store response note and link to zapped note
val responseNote = localCache.getOrCreateNote(event.id)
responseNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList())
responseNote.addRelay(relay)
zappedNote?.addZapPayment(requestNote, responseNote)
relayManager.closeSubscription(nwcConnection.relayUri, subId)
// Decrypt and process response
try {
kotlinx.coroutines.runBlocking {
val responseNote = localCache.getOrCreateNote(event.id)
responseNote.loadEvent(event, localCache.getOrCreateUser(event.pubKey), emptyList())
responseNote.addRelay(relay)
zappedNote?.addZapPayment(requestNote, responseNote)
try {
val response = event.decrypt(nwcSigner)
val result = processResponse(response)
if (continuation.isActive) {
continuation.resume(result)
}
}
} catch (e: Exception) {
if (continuation.isActive) {
continuation.resume(PaymentResult.Error("Failed to decrypt response: ${e.message}"))
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
if (continuation.isActive) {
continuation.resume(PaymentResult.Error("Failed to decrypt response: ${e.message}"))
}
}
}
}
@@ -33,6 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -120,19 +122,21 @@ class DesktopRelaySubscriptionsCoordinator(
fun consumeEvent(
event: Event,
relay: NormalizedRelayUrl?,
wasVerified: Boolean = false,
) {
scope.launch(Dispatchers.IO) {
try {
val consumed = localCache.consume(event, relay)
if (consumed) {
_lastEventAt.value = System.currentTimeMillis()
val note = localCache.getNoteIfExists(event.id) ?: return@launch
eventBundler.invalidateList(note) { batch ->
localCache.eventStream.emitNewNotes(batch)
}
if (!localCache.consume(event, relay, wasVerified)) return@launch
_lastEventAt.value = System.currentTimeMillis()
val note = localCache.getNoteIfExists(event.id) ?: return@launch
eventBundler.invalidateList(note) { batch ->
localCache.eventStream.emitNewNotes(batch)
}
} catch (e: Exception) {
println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}")
if (e is CancellationException) throw e
Log.w("DesktopRelaySubscriptionsCoordinator") {
"Failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}"
}
}
}
}
@@ -167,7 +167,7 @@ class CoordinatorPipelineTest {
content = "Hello from relay",
sig = dummySig,
)
coordinator.consumeEvent(event, relayUrl)
coordinator.consumeEvent(event, relayUrl, wasVerified = true)
waitForBundler()
@@ -203,7 +203,7 @@ class CoordinatorPipelineTest {
content = "test",
sig = dummySig,
)
coordinator.consumeEvent(event, relayUrl)
coordinator.consumeEvent(event, relayUrl, wasVerified = true)
waitForBundler()
assertTrue(coordinator.lastEventAt.value != null, "lastEventAt should be set after consumeEvent")
@@ -227,7 +227,7 @@ class CoordinatorPipelineTest {
content = "",
sig = dummySig,
)
coordinator.consumeEvent(contactEvent, relayUrl)
coordinator.consumeEvent(contactEvent, relayUrl, wasVerified = true)
waitForBundler()
assertTrue(
@@ -255,7 +255,7 @@ class CoordinatorPipelineTest {
content = "",
sig = dummySig,
)
coordinator.consumeEvent(contactEvent, relayUrl)
coordinator.consumeEvent(contactEvent, relayUrl, wasVerified = true)
waitForBundler()
// Step 2: Create following feed ViewModel
@@ -274,7 +274,7 @@ class CoordinatorPipelineTest {
content = "Note from followed user",
sig = dummySig,
)
coordinator.consumeEvent(textEvent, relayUrl)
coordinator.consumeEvent(textEvent, relayUrl, wasVerified = true)
waitForBundler()
val state = vm.feedState.feedContent.value
@@ -311,7 +311,7 @@ class CoordinatorPipelineTest {
content = "Note that won't show",
sig = dummySig,
)
coordinator.consumeEvent(textEvent, relayUrl)
coordinator.consumeEvent(textEvent, relayUrl, wasVerified = true)
waitForBundler()
assertIs<FeedState.Empty>(
@@ -349,8 +349,8 @@ class CoordinatorPipelineTest {
)
// Consume same event twice (can happen with multiple relays)
coordinator.consumeEvent(event, relayUrl)
coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/"))
coordinator.consumeEvent(event, relayUrl, wasVerified = true)
coordinator.consumeEvent(event, NormalizedRelayUrl("wss://relay2.test/"), wasVerified = true)
waitForBundler()
assertTrue(
@@ -127,7 +127,7 @@ class DesktopCachePipelineTest {
val cache = DesktopLocalCache()
val event = textNote("note1".padEnd(64, '0'), userPubKey)
val consumed = cache.consume(event, relayUrl)
val consumed = cache.consume(event, relayUrl, wasVerified = true)
assertTrue(consumed, "First consume should return true")
val note = cache.getNoteIfExists("note1".padEnd(64, '0'))
@@ -140,8 +140,8 @@ class DesktopCachePipelineTest {
val cache = DesktopLocalCache()
val event = textNote("note1".padEnd(64, '0'), userPubKey)
cache.consume(event, relayUrl)
val secondConsume = cache.consume(event, relayUrl)
cache.consume(event, relayUrl, wasVerified = true)
val secondConsume = cache.consume(event, relayUrl, wasVerified = true)
assertTrue(!secondConsume, "Second consume of same event should return false")
}
@@ -151,7 +151,7 @@ class DesktopCachePipelineTest {
val cache = DesktopLocalCache()
val event = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey))
cache.consume(event, relayUrl)
cache.consume(event, relayUrl, wasVerified = true)
assertEquals(setOf(followedPubKey), cache.followedUsers.value)
}
@@ -168,8 +168,8 @@ class DesktopCachePipelineTest {
createdAt = 200,
)
cache.consume(old, relayUrl)
cache.consume(newer, relayUrl)
cache.consume(old, relayUrl, wasVerified = true)
cache.consume(newer, relayUrl, wasVerified = true)
assertEquals(setOf(followedPubKey, unfollowedPubKey), cache.followedUsers.value)
}
@@ -180,8 +180,8 @@ class DesktopCachePipelineTest {
val newer = contactList("cl2".padEnd(64, '0'), userPubKey, listOf(followedPubKey, unfollowedPubKey), createdAt = 200)
val old = contactList("cl1".padEnd(64, '0'), userPubKey, listOf(followedPubKey), createdAt = 100)
cache.consume(newer, relayUrl)
cache.consume(old, relayUrl)
cache.consume(newer, relayUrl, wasVerified = true)
cache.consume(old, relayUrl, wasVerified = true)
assertEquals(
setOf(followedPubKey, unfollowedPubKey),
@@ -197,8 +197,8 @@ class DesktopCachePipelineTest {
val note = textNote(noteId, userPubKey)
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId)
cache.consume(note, relayUrl)
cache.consume(react, relayUrl)
cache.consume(note, relayUrl, wasVerified = true)
cache.consume(react, relayUrl, wasVerified = true)
val cachedNote = cache.getNoteIfExists(noteId)!!
assertTrue(cachedNote.countReactions() > 0, "Note should have reactions after consuming reaction event")
@@ -223,7 +223,7 @@ class DesktopCachePipelineTest {
delay(50)
val event = textNote("note1".padEnd(64, '0'), userPubKey)
cache.consume(event, relayUrl)
cache.consume(event, relayUrl, wasVerified = true)
val note = cache.getNoteIfExists(event.id)!!
cache.emitNewNotes(setOf(note))
@@ -244,9 +244,9 @@ class DesktopCachePipelineTest {
val filter = DesktopGlobalFeedFilter(cache)
// Add notes from different authors
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl)
cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl)
cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl)
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl, wasVerified = true)
cache.consume(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl, wasVerified = true)
val feed = filter.feed()
assertEquals(3, feed.size, "Global feed should contain all text notes")
@@ -255,10 +255,10 @@ class DesktopCachePipelineTest {
@Test
fun `FollowingFeedFilter only includes notes from followed users`() {
val cache = DesktopLocalCache()
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl)
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl)
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
val feed = filter.feed()
@@ -270,7 +270,7 @@ class DesktopCachePipelineTest {
@Test
fun `FollowingFeedFilter returns empty when no follows`() {
val cache = DesktopLocalCache()
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl)
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { emptySet() }
val feed = filter.feed()
@@ -281,8 +281,8 @@ class DesktopCachePipelineTest {
@Test
fun `ProfileFeedFilter only shows notes from target pubkey`() {
val cache = DesktopLocalCache()
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl)
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl)
cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl, wasVerified = true)
val filter = DesktopProfileFeedFilter(followedPubKey, cache)
val feed = filter.feed()
@@ -297,8 +297,8 @@ class DesktopCachePipelineTest {
val rootId = "root".padEnd(64, '0')
val replyId = "reply".padEnd(64, '0')
cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl)
cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl)
cache.consume(textNote(rootId, userPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl, wasVerified = true)
val filter = DesktopThreadFilter(rootId, cache)
val feed = filter.feed()
@@ -310,11 +310,11 @@ class DesktopCachePipelineTest {
fun `NotificationFeedFilter shows events tagging user`() {
val cache = DesktopLocalCache()
val noteId = "note1".padEnd(64, '0')
cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl)
cache.consume(textNote(noteId, userPubKey, createdAt = 100), relayUrl, wasVerified = true)
// Reaction from someone else targeting user's note
val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200)
cache.consume(react, relayUrl)
cache.consume(react, relayUrl, wasVerified = true)
val filter = DesktopNotificationFeedFilter(userPubKey, cache)
val feed = filter.feed()
@@ -334,7 +334,7 @@ class DesktopCachePipelineTest {
fun `ViewModel starts in Loading then transitions to Loaded after refresh`() =
runBlocking {
val cache = DesktopLocalCache()
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl)
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
val vm = DesktopFeedViewModel(DesktopGlobalFeedFilter(cache), cache)
@@ -373,7 +373,7 @@ class DesktopCachePipelineTest {
// Simulate relay event arriving
val event = textNote("n1".padEnd(64, '0'), userPubKey)
cache.consume(event, relayUrl)
cache.consume(event, relayUrl, wasVerified = true)
val note = cache.getNoteIfExists(event.id)!!
cache.emitNewNotes(setOf(note))
@@ -389,7 +389,7 @@ class DesktopCachePipelineTest {
fun `Following ViewModel only shows followed users notes via eventStream`() =
runBlocking {
val cache = DesktopLocalCache()
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
val vm = DesktopFeedViewModel(filter, cache)
@@ -397,7 +397,7 @@ class DesktopCachePipelineTest {
// Add followed user's note
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100)
cache.consume(e1, relayUrl)
cache.consume(e1, relayUrl, wasVerified = true)
val note1 = cache.getNoteIfExists(e1.id)!!
cache.emitNewNotes(setOf(note1))
waitForBundler()
@@ -406,7 +406,7 @@ class DesktopCachePipelineTest {
// Add unfollowed user's note
val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200)
cache.consume(e2, relayUrl)
cache.consume(e2, relayUrl, wasVerified = true)
val note2 = cache.getNoteIfExists(e2.id)!!
cache.emitNewNotes(setOf(note2))
waitForBundler()
@@ -422,7 +422,7 @@ class DesktopCachePipelineTest {
// No contact list consumed — followedUsers remains empty
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
cache.consume(e1, relayUrl)
cache.consume(e1, relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
val vm = DesktopFeedViewModel(filter, cache)
@@ -442,8 +442,8 @@ class DesktopCachePipelineTest {
@Test
fun `clear resets all cache state`() {
val cache = DesktopLocalCache()
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl)
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
cache.consume(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl, wasVerified = true)
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
cache.clear()
@@ -459,9 +459,9 @@ class DesktopCachePipelineTest {
@Test
fun `global feed is sorted newest first`() {
val cache = DesktopLocalCache()
cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl)
cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl)
cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl)
cache.consume(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl, wasVerified = true)
cache.consume(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl, wasVerified = true)
cache.consume(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl, wasVerified = true)
val filter = DesktopGlobalFeedFilter(cache)
val feed = filter.feed()
@@ -487,7 +487,7 @@ class DesktopCachePipelineTest {
sig = dummySig,
)
cache.consume(metadata, relayUrl)
cache.consume(metadata, relayUrl, wasVerified = true)
val user = cache.getUserIfExists(userPubKey)
assertTrue(user != null, "User should exist after metadata consumption")
@@ -506,12 +506,12 @@ class DesktopCachePipelineTest {
// Create a text note
val textEvent = textNote("t1".padEnd(64, '0'), userPubKey)
cache.consume(textEvent, relayUrl)
cache.consume(textEvent, relayUrl, wasVerified = true)
val textNote = cache.getNoteIfExists(textEvent.id)!!
// Create a reaction (not a text note)
val reactEvent = reaction("r1".padEnd(64, '0'), userPubKey, "t1".padEnd(64, '0'))
cache.consume(reactEvent, relayUrl)
cache.consume(reactEvent, relayUrl, wasVerified = true)
val reactNote = cache.getNoteIfExists(reactEvent.id)!!
val filtered = filter.applyFilter(setOf(textNote, reactNote))
@@ -523,16 +523,16 @@ class DesktopCachePipelineTest {
@Test
fun `FollowingFeedFilter applyFilter respects follow set`() {
val cache = DesktopLocalCache()
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl)
cache.consume(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl, wasVerified = true)
val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value }
val e1 = textNote("n1".padEnd(64, '0'), followedPubKey)
cache.consume(e1, relayUrl)
cache.consume(e1, relayUrl, wasVerified = true)
val note1 = cache.getNoteIfExists(e1.id)!!
val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey)
cache.consume(e2, relayUrl)
cache.consume(e2, relayUrl, wasVerified = true)
val note2 = cache.getNoteIfExists(e2.id)!!
val filtered = filter.applyFilter(setOf(note1, note2))
@@ -592,7 +592,7 @@ class DesktopCachePipelineTest {
sig = dummySig,
)
cache.consume(metadata, relayUrl)
cache.consume(metadata, relayUrl, wasVerified = true)
val user = cache.getUserIfExists(userPubKey)!!
val cached = user.metadataOrNull()
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.cache
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Verifies that [DesktopLocalCache] rejects unverified events at its public
* ingress points (`consume` and `justConsumeMyOwnEvent`). This is the
* desktop equivalent of Amethyst Android's `LocalCache.justVerify` guard
* and closes the receive-time verification gap flagged in the Quartz
* security review (item 2.1, finding #1).
*/
class DesktopLocalCacheVerifyTest {
private val relayUrl = NormalizedRelayUrl("wss://relay.test/")
private fun signedTextNote(
content: String = "hello",
createdAt: Long = 1_700_000_000,
): TextNoteEvent {
val signer = NostrSignerSync(KeyPair())
return signer.sign<TextNoteEvent>(
createdAt = createdAt,
kind = TextNoteEvent.KIND,
tags = emptyArray(),
content = content,
)
}
@Test
fun `consume rejects an event with a forged signature`() {
val cache = DesktopLocalCache()
val authentic = signedTextNote()
val tampered =
TextNoteEvent(
id = authentic.id,
pubKey = authentic.pubKey,
createdAt = authentic.createdAt,
tags = authentic.tags,
content = "tampered content",
sig = authentic.sig,
)
val consumed = cache.consume(tampered, relayUrl)
assertFalse(consumed, "Tampered event should be rejected")
assertNull(
cache.getNoteIfExists(tampered.id),
"Tampered event must not enter the cache",
)
}
@Test
fun `consume rejects an event whose id-hash does not match its content`() {
// Synthetic event with arbitrary id and signature — fails the id check
// before the Schnorr verify is even attempted.
val cache = DesktopLocalCache()
val event =
TextNoteEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1_700_000_000,
tags = emptyArray(),
content = "synthetic",
sig = "0".repeat(128),
)
val consumed = cache.consume(event, relayUrl)
assertFalse(consumed)
assertNull(cache.getNoteIfExists(event.id))
}
@Test
fun `consume accepts a properly signed event`() {
val cache = DesktopLocalCache()
val signed = signedTextNote(content = "authentic")
val consumed = cache.consume(signed, relayUrl)
assertTrue(consumed, "Signed event should be accepted")
val note = cache.getNoteIfExists(signed.id)
assertNotNull(note, "Signed event must reach the cache")
assertEquals(signed.id, note.event?.id)
}
@Test
fun `justConsumeMyOwnEvent rejects unverified events`() {
val cache = DesktopLocalCache()
val unsigned =
TextNoteEvent(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1_700_000_000,
tags = emptyArray(),
content = "not signed",
sig = "0".repeat(128),
)
val accepted = cache.justConsumeMyOwnEvent(unsigned)
assertFalse(accepted)
}
@Test
fun `wasVerified bypass routes synthetic test events`() {
val cache = DesktopLocalCache()
val event =
TextNoteEvent(
id = "n1".padEnd(64, '0'),
pubKey = "a".repeat(64),
createdAt = 1_700_000_000,
tags = emptyArray(),
content = "synthetic",
sig = "0".repeat(128),
)
val consumed = cache.consume(event, relayUrl, wasVerified = true)
assertTrue(consumed, "wasVerified=true must skip the signature check")
assertNotNull(cache.getNoteIfExists(event.id))
}
}