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:
@@ -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