diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt index a3e5020d41..779efc2079 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/RelaySupportsNip.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation /** * Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g. @@ -40,3 +42,30 @@ fun relayAdvertisesNip( /** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */ fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29") + +/** + * Whether [channel]'s relay-signed metadata is genuinely from its host relay, per NIP-29: + * "these are addressable events signed by the relay keypair directly … as stated by the NIP-11 + * `self` pubkey", and "relays shouldn't accept these events if they're signed by anyone else". + * + * So the authoritative check is `39000.author == relay.self`. When the relay publishes a `self` + * key we enforce that strictly — this rejects a stray user-published 39000 even on a real NIP-29 + * relay. When the relay does NOT advertise `self` at all (we can't verify cryptographically), we + * fall back to the weaker "advertises NIP-29" signal so a compliant relay that merely omits `self` + * still works. A relay with neither fails. Reads only the cached NIP-11 doc ([relayInfo]); callers + * driving a live surface should warm it first and re-evaluate as it resolves. + */ +fun isRelaySignedRelayGroup( + channel: RelayGroupChannel, + relayInfo: Nip11RelayInformation, +): Boolean { + val self = relayInfo.self + return if (self != null) { + channel.event?.pubKey == self + } else { + relayInfo.supported_nips?.any { it == "29" } == true + } +} + +/** [isRelaySignedRelayGroup] reading the host relay's cached NIP-11 doc (for non-Compose callers). */ +fun isRelaySignedRelayGroup(channel: RelayGroupChannel): Boolean = isRelaySignedRelayGroup(channel, Amethyst.instance.nip11Cache.getFromCache(channel.groupId.relayUrl)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index b1c16cf924..6821e1ba79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -54,6 +54,8 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup +import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route @@ -88,11 +90,15 @@ fun RelayGroupChannelListScreen( RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) + // Warm the relay's NIP-11 so we can tell its genuine (relay-signed) groups from stray + // user-published 39000s that a non-NIP-29 relay may also be storing. + val relayInfo by loadRelayInfo(relay) + // Re-read the relay's channels whenever a group-metadata (kind 39000) event lands in // the cache — driven by LocalCache.observeEvents rather than a timer, so the list // updates as directory events arrive with no polling. The initial value is sorted too // so the first frame doesn't reshuffle when the first emission arrives. - val channels by produceState( + val allChannels by produceState( initialValue = accountViewModel.getRelayGroupChannelsOnRelay(relay).sortedBy { it.toBestDisplayName().lowercase() }, relay, ) { @@ -103,6 +109,10 @@ fun RelayGroupChannelListScreen( } } + // Only the relay's own genuine, relay-signed groups (39000 author == the relay's NIP-11 `self`). + // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. + val channels = remember(allChannels, relayInfo) { allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } } + Scaffold( topBar = { TopBarExtensibleWithBackButton( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt index 405a65d4db..730239c0f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupDiscoveryScreen.kt @@ -219,31 +219,33 @@ private fun WatchAccountForRelayGroupDiscovery( val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList .collectAsStateWithLifecycle() - // Discovery only shows groups hosted on relays that actually run NIP-29 (a relay that does - // rejects user-authored 39xxx, so its 39000s are all genuine; general relays instead carry stray - // fake ones). The feed decides this from each relay's cached NIP-11 `supported_nips`, so warm - // the candidate relays here and re-invalidate as support is confirmed — otherwise a relay whose - // NIP-11 lands after its 39000s would stay hidden until a manual refresh. + // Discovery only shows groups whose 39000 is signed by the host relay's own key (NIP-29's + // authority — the NIP-11 `self` pubkey; general relays instead carry stray user-published 39000s + // that can't be joined). The feed reads that from each relay's cached NIP-11, so warm the + // candidate relays here and re-invalidate as each doc resolves — otherwise a relay whose NIP-11 + // lands after its 39000s would stay hidden until a manual refresh. val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys } - var nip29Relays by remember { mutableStateOf(emptySet()) } + var nip11Loaded by remember { mutableStateOf(emptySet()) } LaunchedEffect(candidateRelays) { - val supported = nip29Relays.toMutableSet() + val loaded = nip11Loaded.toMutableSet() candidateRelays.forEach { relay -> Amethyst.instance.nip11Cache.loadRelayInfo( relay = relay, - onInfo = { info -> - if (info.supported_nips?.any { it == "29" } == true && supported.add(relay)) { - nip29Relays = supported.toSet() - } - }, - onError = { _, _, _ -> }, + onInfo = { if (loaded.add(relay)) nip11Loaded = loaded.toSet() }, + onError = { _, _, _ -> if (loaded.add(relay)) nip11Loaded = loaded.toSet() }, ) } } - LaunchedEffect(listName, perRelay, joinedGroups, nip29Relays) { + LaunchedEffect(listName, perRelay, joinedGroups) { feedContentState.checkKeysInvalidateDataAndSendToTop() } + + // A NIP-11 doc resolving doesn't change the feed key (the self-key test lives in the filter's + // match, not the key), so force a re-filter directly when one lands. + LaunchedEffect(nip11Loaded) { + feedContentState.invalidateData() + } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt index 8d60d372a9..6a060f9806 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/dal/RelayGroupDiscoveryFeedFilter.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet -import com.vitorpamplona.amethyst.model.nip11RelayInfo.relayAdvertisesNip29 +import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -105,19 +105,7 @@ class RelayGroupDiscoveryFeedFilter( joined: Set, ): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember() - /** - * The per-relay constraints, restricted to relays that ADVERTISE NIP-29 in their NIP-11 - * `supported_nips`. A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a - * relay every 39000 is relay-signed and genuine; general relays (nostr.wine, etc.) instead store - * stray user-published 39000s that have no roster and can't be joined. Dropping non-advertising - * relays here filters out that noise for both the match test and the REQ-driven feed at one - * point. Relays whose NIP-11 hasn't loaded yet resolve to false and are warmed by the screen - * ([WatchAccountForRelayGroupDiscovery]), which re-invalidates the feed once support is known. - */ - private fun constraints(): Map = - account.liveRelayGroupsDiscoveryFollowListsPerRelay.value - .toGroupConstraints() - .filterKeys { relayAdvertisesNip29(it) } + private fun constraints(): Map = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints() override fun feed(): List { if (isMine()) return sort(myGroupNotes()) @@ -170,6 +158,10 @@ class RelayGroupDiscoveryFeedFilter( val channel = relayGroupDiscoveryChannelFor(note) ?: return false if (isMine()) return isMyGroup(channel, joinedGroupIds()) if (byRelay.isEmpty()) return false + // Only surface groups whose 39000 is actually signed by the host relay's key (NIP-29's + // authority). This drops stray user-published 39000s stored on general relays — the ones + // with no roster that can't be joined — even when the relay itself is otherwise queried. + if (!isRelaySignedRelayGroup(channel)) return false return byRelay[channel.groupId.relayUrl]?.matches(channel) == true } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index e74998b91d..22c849df1e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -52,6 +52,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDe import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -59,6 +60,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip01Core.store.IEventStore import com.vitorpamplona.quartz.nip01Core.store.verifyAndInsert import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip46RemoteSigner.signer.NostrSignerRemote import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -75,14 +77,17 @@ import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayReachability import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent import com.vitorpamplona.quartz.utils.SeenIds import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import okhttp3.Dispatcher import okhttp3.OkHttpClient +import okhttp3.Request import java.util.concurrent.TimeUnit /** @@ -242,6 +247,26 @@ class Context( .OkHttpNip05Fetcher { _ -> okhttp }, ) + /** + * Fetches [relay]'s NIP-11 relay-information document (over the same OkHttp instance), or null if + * it serves none / the request fails. Used to read the relay's `self` pubkey — NIP-29's authority + * for group metadata — so `relaygroup` reads can verify a 39000 was actually signed by the relay. + */ + suspend fun relayInfo(relay: NormalizedRelayUrl): Nip11RelayInformation? = + withContext(Dispatchers.IO) { + runCatching { + val request = + Request + .Builder() + .url(relay.toHttp()) + .header("Accept", "application/nostr+json") + .build() + okhttp.newCall(request).execute().use { resp -> + resp.body?.string()?.let { Nip11RelayInformation.fromJson(it) } + } + }.getOrNull() + } + // Lazy so an anonymous read (no account dir) never materialises the // per-account marmot stores — constructing them would `mkdir` group dirs // under the shared root. Real accounts build them on first marmot use. diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt index e03a885e37..af7441e3d4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayGroupReadCommands.kt @@ -24,7 +24,9 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent @@ -88,19 +90,26 @@ object RelayGroupReadCommands { Context.open(dataDir).use { ctx -> ctx.prepare() + // NIP-29 requires the 39000 to be signed by the relay's own key; verify against the + // relay's NIP-11 `self` so stray user-published 39000s on a general relay are dropped. + val relaySigned = relaySignedFilter(ctx.relayInfo(relay)) val filter = Filter(kinds = listOf(GroupMetadataEvent.KIND), limit = 500) - val metas = + val allMetas = ctx .drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000) .map { it.second } .filterIsInstance() .distinctBy { it.groupId() } + val metas = + allMetas + .filter { relaySigned(it.pubKey) } .sortedBy { it.name()?.lowercase() ?: it.groupId() } Output.emit( mapOf( "relay" to relay.url, "count" to metas.size, + "unverified_dropped" to (allMetas.size - metas.size), "groups" to metas.map(::metaSummary), ), ) @@ -127,7 +136,13 @@ object RelayGroupReadCommands { tags = mapOf("d" to listOf(groupId)), limit = 10, ) - val events = ctx.drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000).map { it.second } + val relaySigned = relaySignedFilter(ctx.relayInfo(relay)) + val events = + ctx + .drain(mapOf(relay to listOf(filter)), timeoutSecs * 1000) + .map { it.second } + // Only trust relay-signed metadata/roster (39xxx author == the relay's `self`). + .filter { relaySigned(it.pubKey) } val meta = events.filterIsInstance().maxByOrNull { it.createdAt } val admins = events.filterIsInstance().maxByOrNull { it.createdAt }?.admins() ?: emptyList() @@ -156,6 +171,21 @@ object RelayGroupReadCommands { } } + /** + * A predicate for whether a 39xxx author is the host relay's approved key. NIP-29: metadata is + * "signed by the relay keypair directly … as stated by the NIP-11 `self` pubkey". When the relay + * publishes `self` we enforce it strictly; when it omits `self` we fall back to the weaker + * "advertises NIP-29" signal; a relay with neither yields no genuine groups. + */ + private fun relaySignedFilter(info: Nip11RelayInformation?): (HexKey) -> Boolean { + val self = info?.self + return when { + self != null -> { author -> author == self } + info?.supported_nips?.any { it == "29" } == true -> { _ -> true } + else -> { _ -> false } + } + } + private fun metaSummary(meta: GroupMetadataEvent) = mapOf( "group_id" to meta.groupId(),