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
@@ -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(),