From d6c1b131369ecbfe2c5048abe044cafd593bbc36 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 11 Jun 2026 10:50:20 +0300 Subject: [PATCH] fix(desktop): stop falling back to user's connected relays for NIP-17 DMs (P0 security) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per NIP-17 §Publishing, gift wraps MUST only be published to the relays advertised in the recipient's kind:10050. Today three send paths in DesktopIAccount fall through to relayManager.connectedRelays.value when the recipient has no kind:10050 cached: sendNip17PrivateMessage (line 200) sendNip17EncryptedFile (line 231) sendGiftWraps (line 253) This is the security-review F-04 metadata leak: at best the wrap never reaches the recipient (their other clients don't read those relays); at worst the recipient pubkey + send timestamp leak to general/feed relays outside their chosen inbox. Same class of bug as the relay- power-tools work explicitly closed for the relay picker on 2026-04-20 ("block DM fallback to all relays — metadata leak"). Replace the fallback with strict resolution: if the recipient has no kind:10050 in the cache, return an empty target set. DmSendTracker already handles total relay count == 0 with a "No relays available" failure state, so the user gets a visible error instead of a silent leak. Indexer fan-out + a UI dialog for the missing-10050 case is the permanent fix, scoped to Phase 4 (DmInboxRelayResolver). This commit is the conservative pre-Phase-4 plug — better to fail visibly than leak silently. NIP-04 send is unchanged: that path is pre-NIP-17, the encrypted content sits next to other public events on the sender's outbox by design. --- .../amethyst/desktop/model/DesktopIAccount.kt | 68 +++++++++---------- 1 file changed, 32 insertions(+), 36 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt index 20fccefa62..672c8bb6b4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt @@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent @@ -197,18 +199,7 @@ class DesktopIAccount( val batch = result.wraps.map { wrap -> val recipientKey = wrap.recipientPubKey() - val targetRelays = - if (recipientKey != null) { - val dmRelays = - localCache - .getOrCreateUser(recipientKey) - .dmInboxRelays() - ?.toSet() - dmRelays?.ifEmpty { null } - ?: relayManager.connectedRelays.value - } else { - relayManager.connectedRelays.value - } + val targetRelays = resolveDmInboxRelaysStrict(recipientKey) wrap to targetRelays } @@ -228,18 +219,7 @@ class DesktopIAccount( val batch = result.wraps.map { wrap -> val recipientKey = wrap.recipientPubKey() - val targetRelays = - if (recipientKey != null) { - val dmRelays = - localCache - .getOrCreateUser(recipientKey) - .dmInboxRelays() - ?.toSet() - dmRelays?.ifEmpty { null } - ?: relayManager.connectedRelays.value - } else { - relayManager.connectedRelays.value - } + val targetRelays = resolveDmInboxRelaysStrict(recipientKey) wrap to targetRelays } @@ -250,24 +230,40 @@ class DesktopIAccount( val batch = wraps.map { wrap -> val recipientKey = wrap.recipientPubKey() - val targetRelays = - if (recipientKey != null) { - val dmRelays = - localCache - .getOrCreateUser(recipientKey) - .dmInboxRelays() - ?.toSet() - dmRelays?.ifEmpty { null } - ?: relayManager.connectedRelays.value - } else { - relayManager.connectedRelays.value - } + val targetRelays = resolveDmInboxRelaysStrict(recipientKey) wrap to targetRelays } scope.launch { dmSendTracker.sendBatch(batch) } } + /** + * NIP-17 inbox-relay resolution, strict variant — no fallback to the + * user's connected relays. + * + * Per NIP-17 §Publishing, a gift wrap MUST only land on relays advertised + * in the recipient's kind:10050. Falling back to the sender's connected + * relays when 10050 is missing publishes the wrap to relays the recipient + * does NOT consult — at best the message never arrives, at worst it leaks + * the conversation metadata (recipient pubkey + send timestamp) to relays + * outside the recipient's chosen inbox. + * + * Empty result means the wrap will not be sent; [DmSendTracker.sendBatch] + * surfaces this as a "No relays available" failure to the user. Indexer + * fan-out + a UI prompt for the missing-10050 case lands with the + * [DmInboxRelayResolver] (Phase 4); until then "no 10050 → cannot send" + * is the conservative position. + */ + private fun resolveDmInboxRelaysStrict(recipientKey: HexKey?): Set { + if (recipientKey == null) return emptySet() + return localCache + .getOrCreateUser(recipientKey) + .dmInboxRelays() + ?.toSet() + ?.ifEmpty { null } + ?: emptySet() + } + private fun addEventToChatroom( event: com.vitorpamplona.quartz.nip01Core.core.Event, roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey,