feat: verify NIP-29 group metadata is signed by the relay's own key

NIP-29 metadata/roster events (39000-39003) "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 test for a genuine group is `39000.author == relay.self` — which
also rejects a stray user-published 39000 even on a real NIP-29 relay, something
the earlier supported_nips heuristic could not.

Add `isRelaySignedRelayGroup(channel)`: strict `author == self` when the relay
publishes `self`, falling back to `supported_nips ∋ 29` when it omits `self`, and
false when it has neither. Apply it at the surfaces that show unsolicited groups:

- Discovery feed: replace the relay-level supported_nips filter with the
  per-channel self-key check in matches(); the screen now warms each candidate
  relay's NIP-11 and re-invalidates the feed as each doc resolves.
- On-relay group list: filter to relay-signed groups, warming that relay's
  NIP-11 so genuine groups fill in and fakes stay hidden.
- CLI `relaygroup browse`/`info`: fetch the relay's NIP-11 (new
  Context.relayInfo) and drop 39xxx not signed by `self`; browse reports the
  dropped count.

Explicit user actions (a received invite link, opening an naddr) are left
untouched — hiding those would be user-hostile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-10 15:18:13 +00:00
parent 188ba3c609
commit 0f01dc0c12
6 changed files with 119 additions and 31 deletions
@@ -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))
@@ -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(
@@ -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<NormalizedRelayUrl>()) }
var nip11Loaded by remember { mutableStateOf(emptySet<NormalizedRelayUrl>()) }
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()
}
}
/**
@@ -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<GroupId>,
): 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<NormalizedRelayUrl, GroupDiscoveryConstraint> =
account.liveRelayGroupsDiscoveryFollowListsPerRelay.value
.toGroupConstraints()
.filterKeys { relayAdvertisesNip29(it) }
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
override fun feed(): List<Note> {
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
}
@@ -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.
@@ -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<GroupMetadataEvent>()
.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<GroupMetadataEvent>().maxByOrNull { it.createdAt }
val admins = events.filterIsInstance<GroupAdminsEvent>().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(),