mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 08:04:45 +00:00
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:
+29
@@ -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))
|
||||
|
||||
+11
-1
@@ -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(
|
||||
|
||||
+16
-14
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-14
@@ -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.
|
||||
|
||||
+32
-2
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user