fix(nip29): reject non-relay-signed group state; share one-tap invite links

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=<code>`
  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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Qst2JsmNYMvXitv2vxo4S
This commit is contained in:
Claude
2026-07-16 13:18:51 +00:00
parent 0e8239b22f
commit 37220306e9
2 changed files with 41 additions and 15 deletions
@@ -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) }
}
@@ -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=<code>` 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()
},
) {