diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt index 28b711231..59d00a98e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCache.kt @@ -31,6 +31,8 @@ import com.vitorpamplona.amethyst.commons.services.nwc.NwcPaymentTracker import com.vitorpamplona.quartz.nip01Core.core.Address 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 +51,7 @@ 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.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.channels.BufferOverflow @@ -162,15 +165,51 @@ class DesktopLocalCache : ICacheProvider { } } + // ----- Event verification ----- + + /** + * Verifies an event's id-hash and Schnorr signature. On failure, logs + * the offending kind/createdAt and returns false so callers can drop it. + * Mirrors Amethyst Android's [com.vitorpamplona.amethyst.model.LocalCache.justVerify] + * so unverified relay traffic never reaches the desktop cache. + */ + fun justVerify(event: Event): Boolean = + if (!event.verify()) { + try { + event.checkSignature() + } catch (e: Exception) { + 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. + * Returns true if the event was consumed (new), false if already seen + * or if signature verification failed. */ fun consume( event: Event, relay: NormalizedRelayUrl?, + ): Boolean { + if (!justVerify(event)) return false + return consumeAssumingVerified(event, relay) + } + + /** + * Routes an already-verified event to its consume method. Internal seam + * exposed for tests that exercise routing/cache logic with synthetic + * events whose signatures are not real. + */ + internal fun consumeAssumingVerified( + event: Event, + relay: NormalizedRelayUrl?, ): Boolean = when (event) { is MetadataEvent -> { @@ -455,6 +494,7 @@ class DesktopLocalCache : ICacheProvider { relay: NormalizedRelayUrl?, onResponse: suspend (LnZapPaymentResponseEvent) -> Unit, ): Boolean { + if (!justVerify(event)) return false val note = getOrCreateNote(event.id) val author = getOrCreateUser(event.pubKey) @@ -482,6 +522,7 @@ class DesktopLocalCache : ICacheProvider { event: LnZapPaymentResponseEvent, relay: NormalizedRelayUrl?, ): Boolean { + if (!justVerify(event)) return false val requestId = event.requestId() val pending = paymentTracker.onResponseReceived(requestId) ?: return false @@ -545,6 +586,7 @@ 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) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index b8fe4a15f..254acb19a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -123,20 +123,43 @@ class DesktopRelaySubscriptionsCoordinator( ) { 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) - } - } + runConsume(localCache.consume(event, relay), event) } catch (e: Exception) { println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}") } } } + /** + * Test-only seam — bypasses signature verification in the cache so unit + * tests can pump synthetic events through the full coordinator pipeline. + * Production code MUST use [consumeEvent]. + */ + internal fun consumeEventAssumingVerified( + event: Event, + relay: NormalizedRelayUrl?, + ) { + scope.launch(Dispatchers.IO) { + try { + runConsume(localCache.consumeAssumingVerified(event, relay), event) + } catch (e: Exception) { + println("Coordinator: failed to consume kind=${event.kind} id=${event.id} relay=$relay: ${e.message}") + } + } + } + + private suspend fun runConsume( + consumed: Boolean, + event: Event, + ) { + if (!consumed) return + _lastEventAt.value = System.currentTimeMillis() + val note = localCache.getNoteIfExists(event.id) ?: return + eventBundler.invalidateList(note) { batch -> + localCache.eventStream.emitNewNotes(batch) + } + } + /** * Request a consolidated interaction subscription for the given note IDs. * Subscribes to kinds 7 (reactions), 9735 (zaps), 6 (reposts), and 1 (replies) diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt index 36dbc87f8..5f4efd162 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/CoordinatorPipelineTest.kt @@ -53,7 +53,7 @@ import kotlin.test.assertTrue * These tests use a stub INostrClient (no real relay connections) and exercise * the full event consumption path through DesktopRelaySubscriptionsCoordinator. * - * Key invariant being tested: when coordinator.consumeEvent() is called, + * Key invariant being tested: when coordinator.consumeEventAssumingVerified() is called, * the event should flow through cache → eventStream → ViewModel.feedState. */ class CoordinatorPipelineTest { @@ -167,14 +167,14 @@ class CoordinatorPipelineTest { content = "Hello from relay", sig = dummySig, ) - coordinator.consumeEvent(event, relayUrl) + coordinator.consumeEventAssumingVerified(event, relayUrl) waitForBundler() val state = vm.feedState.feedContent.value assertIs( state, - "ViewModel should be Loaded after coordinator.consumeEvent()", + "ViewModel should be Loaded after coordinator.consumeEventAssumingVerified()", ) assertTrue( vm.feedState.visibleNotes().any { it.idHex == event.id }, @@ -203,7 +203,7 @@ class CoordinatorPipelineTest { content = "test", sig = dummySig, ) - coordinator.consumeEvent(event, relayUrl) + coordinator.consumeEventAssumingVerified(event, relayUrl) 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.consumeEventAssumingVerified(contactEvent, relayUrl) waitForBundler() assertTrue( @@ -255,7 +255,7 @@ class CoordinatorPipelineTest { content = "", sig = dummySig, ) - coordinator.consumeEvent(contactEvent, relayUrl) + coordinator.consumeEventAssumingVerified(contactEvent, relayUrl) 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.consumeEventAssumingVerified(textEvent, relayUrl) 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.consumeEventAssumingVerified(textEvent, relayUrl) waitForBundler() assertIs( @@ -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.consumeEventAssumingVerified(event, relayUrl) + coordinator.consumeEventAssumingVerified(event, NormalizedRelayUrl("wss://relay2.test/")) waitForBundler() assertTrue( diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt index 20c1b2bcd..1d7bc219f 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopCachePipelineTest.kt @@ -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.consumeAssumingVerified(event, relayUrl) 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.consumeAssumingVerified(event, relayUrl) + val secondConsume = cache.consumeAssumingVerified(event, relayUrl) 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.consumeAssumingVerified(event, relayUrl) assertEquals(setOf(followedPubKey), cache.followedUsers.value) } @@ -168,8 +168,8 @@ class DesktopCachePipelineTest { createdAt = 200, ) - cache.consume(old, relayUrl) - cache.consume(newer, relayUrl) + cache.consumeAssumingVerified(old, relayUrl) + cache.consumeAssumingVerified(newer, relayUrl) 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.consumeAssumingVerified(newer, relayUrl) + cache.consumeAssumingVerified(old, relayUrl) 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.consumeAssumingVerified(note, relayUrl) + cache.consumeAssumingVerified(react, relayUrl) 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.consumeAssumingVerified(event, relayUrl) 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.consumeAssumingVerified(textNote("n1".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consumeAssumingVerified(textNote("n2".padEnd(64, '0'), followedPubKey, createdAt = 200), relayUrl) + cache.consumeAssumingVerified(textNote("n3".padEnd(64, '0'), unfollowedPubKey, createdAt = 300), relayUrl) 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.consumeAssumingVerified(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) - cache.consume(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) - cache.consume(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) + cache.consumeAssumingVerified(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consumeAssumingVerified(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) 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.consumeAssumingVerified(textNote("n1".padEnd(64, '0'), followedPubKey), relayUrl) 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.consumeAssumingVerified(textNote("n1".padEnd(64, '0'), followedPubKey, createdAt = 100), relayUrl) + cache.consumeAssumingVerified(textNote("n2".padEnd(64, '0'), unfollowedPubKey, createdAt = 200), relayUrl) 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.consumeAssumingVerified(textNote(rootId, userPubKey, createdAt = 100), relayUrl) + cache.consumeAssumingVerified(textNote(replyId, followedPubKey, createdAt = 200, replyToId = rootId), relayUrl) 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.consumeAssumingVerified(textNote(noteId, userPubKey, createdAt = 100), relayUrl) // Reaction from someone else targeting user's note val react = reaction("react1".padEnd(64, '0'), followedPubKey, noteId, createdAt = 200) - cache.consume(react, relayUrl) + cache.consumeAssumingVerified(react, relayUrl) 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.consumeAssumingVerified(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) 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.consumeAssumingVerified(event, relayUrl) 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.consumeAssumingVerified(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) 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.consumeAssumingVerified(e1, relayUrl) 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.consumeAssumingVerified(e2, relayUrl) 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.consumeAssumingVerified(e1, relayUrl) 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.consumeAssumingVerified(textNote("n1".padEnd(64, '0'), userPubKey), relayUrl) + cache.consumeAssumingVerified(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) 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.consumeAssumingVerified(textNote("old".padEnd(64, '0'), userPubKey, createdAt = 100), relayUrl) + cache.consumeAssumingVerified(textNote("mid".padEnd(64, '0'), userPubKey, createdAt = 200), relayUrl) + cache.consumeAssumingVerified(textNote("new".padEnd(64, '0'), userPubKey, createdAt = 300), relayUrl) val filter = DesktopGlobalFeedFilter(cache) val feed = filter.feed() @@ -487,7 +487,7 @@ class DesktopCachePipelineTest { sig = dummySig, ) - cache.consume(metadata, relayUrl) + cache.consumeAssumingVerified(metadata, relayUrl) 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.consumeAssumingVerified(textEvent, relayUrl) 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.consumeAssumingVerified(reactEvent, relayUrl) 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.consumeAssumingVerified(contactList("cl".padEnd(64, '0'), userPubKey, listOf(followedPubKey)), relayUrl) val filter = DesktopFollowingFeedFilter(cache) { cache.followedUsers.value } val e1 = textNote("n1".padEnd(64, '0'), followedPubKey) - cache.consume(e1, relayUrl) + cache.consumeAssumingVerified(e1, relayUrl) val note1 = cache.getNoteIfExists(e1.id)!! val e2 = textNote("n2".padEnd(64, '0'), unfollowedPubKey) - cache.consume(e2, relayUrl) + cache.consumeAssumingVerified(e2, relayUrl) 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.consumeAssumingVerified(metadata, relayUrl) val user = cache.getUserIfExists(userPubKey)!! val cached = user.metadataOrNull() diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt new file mode 100644 index 000000000..dc590a897 --- /dev/null +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/cache/DesktopLocalCacheVerifyTest.kt @@ -0,0 +1,148 @@ +/* + * 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( + 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 with an all-zero signature`() { + 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 `consumeAssumingVerified bypass still routes 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.consumeAssumingVerified(event, relayUrl) + + assertTrue(consumed, "consumeAssumingVerified must skip the signature check") + assertNotNull(cache.getNoteIfExists(event.id)) + } +}