From 37220306e9858bbed0d30d7fd9d53a66d146eb59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:18:51 +0000 Subject: [PATCH] fix(nip29): reject non-relay-signed group state; share one-tap invite links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authorization hardening: - LocalCache only applies a kind 39000-39005 addressable (group metadata, admins, members, roles, pins) to a group's state when the event is signed by the relay's own NIP-11 `self` key. Previously any author's 39001 served for a group id was applied (newest wins), so a stray/malicious user-published admin list on a lax relay could inject itself as admin in the client's view and unlock moderation UI. The guard only blocks when `self` is known and differs, so legitimate groups (and relays whose NIP-11 hasn't loaded) are unaffected. Moderation events (9000-9010) were already stored-but-not-applied — the client relies on the relay-republished 39001/39002 — so they need no change. Invite links: - The invite dialog now produces the spec's single `naddr1…?invite=` link for closed groups and copies just that, so one tap joins. It previously copied the naddr and code as two separate lines, which the new parser (that reads `?invite=`) could never round-trip. spotless clean; amethyst compiles. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S --- .../amethyst/model/LocalCache.kt | 31 ++++++++++++++++--- .../relayGroup/InviteRelayGroupDialog.kt | 25 +++++++++------ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index bbbbc2d2d4..1a5c583ad6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -1853,6 +1853,27 @@ object LocalCache : ILocalCache, ICacheProvider { return new } + /** + * NIP-29 addressables (kinds 39000-39005) are authoritative for a group's metadata, + * roster, roles and pins ONLY when signed by the relay's own key — the NIP-11 `self` + * pubkey. This returns false only when we can positively tell an event is NOT relay-signed + * (the relay advertises a `self` and the event's author differs), so a stray or malicious + * user-published 39000/39001/… served by a lax relay can't overwrite a group's state (e.g. + * inject itself into the admin list). When `self` isn't known yet — the NIP-11 doc hasn't + * loaded, or the relay doesn't advertise one — we don't block, so legitimate groups still + * populate and this never regresses a relay whose key we simply haven't fetched. + */ + private fun isRelaySignedGroupEvent( + event: Event, + relay: NormalizedRelayUrl, + ): Boolean { + val self = + Amethyst.instance.nip11Cache + .getFromCache(relay) + .self ?: return true + return event.pubKey == self + } + /** * NIP-29 relay-signed group metadata (kind 39000). Stored as an addressable * note and used to populate the [RelayGroupChannel]'s name/picture/about/ @@ -1868,7 +1889,7 @@ object LocalCache : ILocalCache, ICacheProvider { ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val note = getOrCreateAddressableNote(event.address()) val channel = getOrCreateRelayGroupChannel(GroupId(event.groupId(), relay)) (note.event as? GroupMetadataEvent)?.let { channel.updateGroupInfo(it, note) } @@ -1884,7 +1905,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupMembersEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateMembers(it) } } @@ -1898,7 +1919,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupAdminsEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateAdmins(it) } } @@ -1912,7 +1933,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? GroupPinnedEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updatePinned(it) } } @@ -1926,7 +1947,7 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val new = consumeBaseReplaceable(event, relay, wasVerified) - if (relay != null) { + if (relay != null && isRelaySignedGroupEvent(event, relay)) { val latest = getOrCreateAddressableNote(event.address()).event as? SupportedRolesEvent latest?.let { getOrCreateRelayGroupChannel(GroupId(it.groupId(), relay)).updateSupportedRoles(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt index e89cd689e1..16c210e033 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/InviteRelayGroupDialog.kt @@ -67,11 +67,18 @@ fun InviteRelayGroupDialog( .collectAsStateWithLifecycle() val liveChannel = channelState.channel as? RelayGroupChannel ?: channel - // A shareable, cross-client coordinate for the group (opens the chat in any - // NIP-29 client). Null until the relay-signed metadata has loaded. - val nAddr = liveChannel.toNAddr()?.let { "nostr:$it" } val isClosed = liveChannel.isClosed() + // A shareable, cross-client coordinate for the group (opens the chat in any NIP-29 + // client). Null until the relay-signed metadata has loaded. For a closed (invite-only) + // group we append the one-time code as the spec's `?invite=` suffix so the whole + // link is a single tap that auto-joins — no separate code to paste. + val nAddr = + liveChannel.toNAddr()?.let { base -> + val uri = "nostr:$base" + if (isClosed) "$uri?invite=$code" else uri + } + // A join code is only meaningful for closed (invite-only) groups; open groups // join directly from the shared naddr. So mint the kind-9009 invite only when // the group is actually closed, rather than on every dialog open. @@ -116,15 +123,13 @@ fun InviteRelayGroupDialog( } }, confirmButton = { - // Copy the group link, plus the code when the group is closed (so a - // recipient has both to join). Never fall back to copying a code the - // dialog didn't show — for an open group with metadata not yet loaded - // there is simply nothing to copy, so disable the button. - val toCopy = listOfNotNull(nAddr, if (isClosed) code else null).joinToString("\n") + // Copy the single shareable link. For a closed group the code is already + // embedded as `?invite=…`, so one tap joins. Nothing to copy until the + // group's metadata (and thus the naddr) has loaded, so disable until then. TextButton( - enabled = toCopy.isNotBlank(), + enabled = nAddr != null, onClick = { - scope.launch { clipboard.setText(toCopy) } + nAddr?.let { link -> scope.launch { clipboard.setText(link) } } onDismiss() }, ) {