fix(desktop): stop falling back to user's connected relays for NIP-17 DMs (P0 security)

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.
This commit is contained in:
nrobi144
2026-07-09 07:03:55 +03:00
parent ac26a3624f
commit d6c1b13136
@@ -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<NormalizedRelayUrl> {
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,