mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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) }
|
||||
}
|
||||
|
||||
+15
-10
@@ -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()
|
||||
},
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user