From c5f80bc7b050e937e5925708779e0bdad6705b6c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 21:49:00 +0000 Subject: [PATCH 1/2] fix(relay-auth): count relay groups and Concord rooms as "a room I joined" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "…it's my relay, or a room I joined" — the default auto-login exemption — only ever knew two kinds of room: NIP-28 public chats and NIP-72 communities. A NIP-29 relay group's id and a Concord community's id are on neither list, so a relay whose entire job is hosting a group the user joined fell through to a prompt on every connection, or, before its subscription was assembled and there was anything attributable to say, to a silent denial that left the room empty. The venue check now has two halves: a venue *id* named by a purpose (adding joined NIP-29 group ids and Concord community ids to the chats/communities it already knew), and the relay that *hosts* one. The second half is what covers the challenge that arrives before any of the room's filters do — and a group or community host relay serves nothing else, so it is a safe signal. Three supporting corrections, all of the same "the room doesn't look like a room" shape: - The first-party gate took a NIP-29 group-relay set; it now takes every joined room's host, so Concord relays and Buzz workspaces qualify too. Concord is the harder case of that rule: a plane wrap the account publishes is signed by the plane's stream key, so even its own outbound traffic carries someone else's pubkey and can never qualify by the publishing rule. - A pending `h`-tagged event is a post into that group, not a notification to whoever it mentions. - A pending Concord plane wrap is a post into that community. It is a kind-1059 wrap `p`-tagged to a throwaway pubkey, so on tag shape alone it read as a gift wrap: the prompt offered to "send a message" to a key belonging to nobody. ConcordSessionRegistry gained the plane -> community lookup that makes it recognizable. The prompt can now name both kinds of room instead of showing an id prefix: a NIP-29 group resolves against the relay doing the asking (the id alone is ambiguous without its host), and a Concord community's name comes off the account's own joined list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PHCHwnWjVt83Ne3qGBeGq6 --- .../vitorpamplona/amethyst/model/Account.kt | 34 +++++ .../compose/RelayAuthPromptHost.kt | 46 +++++-- .../authCommand/model/AuthCoordinator.kt | 24 +++- .../authCommand/model/RelayAuthFirstParty.kt | 20 +-- .../model/RelayAuthPermissionLedger.kt | 52 +++++--- .../model/RelayAuthPurposeDeriver.kt | 18 +++ .../authCommand/model/RelayAuthVenues.kt | 73 ++++++++++ .../model/RelayAuthFirstPartyTest.kt | 10 ++ .../model/RelayAuthPurposeDeriverTest.kt | 70 ++++++++++ .../model/RelayAuthVenueCoverageTest.kt | 125 ++++++++++++++++++ .../authCommand/model/RelayAuthVenuesTest.kt | 100 ++++++++++++++ .../model/concord/ConcordSessionManager.kt | 7 + .../model/concord/ConcordSessionRegistry.kt | 20 +++ .../commons/relayauth/RelayAuthResolver.kt | 10 +- 14 files changed, 560 insertions(+), 49 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4cfab50126..4725093239 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect +import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -147,6 +148,7 @@ import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.InMemoryRelayAuthPermissionStore import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionCache import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthPermissionLedger +import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.RelayAuthVenues import com.vitorpamplona.amethyst.service.relayClient.chatDelivery.ChatDeliveryTracker import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyRequestsCache import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentFilterAssembler @@ -427,8 +429,40 @@ class Account( isTrustedVenue = { venueId -> venueId in publicChatList.flowSet.value || venueId in communityList.flowSet.value || + isJoinedRoomId(venueId) || Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true }, + isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false }, + ) + + /** + * Relays that exist here because a room was joined on them: the host of every NIP-29 relay group + * on the kind-10009 list, the relays of every joined Concord community, and every joined Buzz + * workspace. + * + * All are venues in the [RelayAuthCustomToggles.myRelaysAndVenues] sense but none shows up in a + * NIP-65/DM/search list, so nothing else in the auth path can see them: a NIP-29 group's content + * is `#h`-scoped and never names the user, and a Concord plane is addressed to a derived stream + * key rather than to anyone's pubkey. + */ + fun venueHostRelays(): Set = + RelayAuthVenues.hostRelays( + joinedGroups = relayGroupList.liveRelayGroupIds.value, + joinedCommunities = concordChannelList.liveCommunities.value, + joinedWorkspaces = BuzzWorkspaces.flow.value, + ) + + /** + * True when [venueId] is a room this account joined that the venue *lists* above don't cover: a + * NIP-29 group id (from the kind-10009 list) or a Concord community id (from the kind-13302 list). + * Those are the ids the subscription assemblers declare on their filters, so this is what turns a + * `READ_VENUE`/`POST_VENUE` on a joined group or community into a trusted venue. + */ + private fun isJoinedRoomId(venueId: String): Boolean = + RelayAuthVenues.isJoinedRoom( + venueId = venueId, + joinedGroups = relayGroupList.liveRelayGroupIds.value, + joinedCommunities = concordChannelList.liveCommunities.value, ) // Per-account relay NOTIFY (payment-prompt) cache. NotifyCoordinator attributes each incoming diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt index 999860ab29..49099715b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt @@ -82,7 +82,9 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId /** Avatars shown in the facepile before the "+N" overflow badge. */ private const val FACEPILE_MAX = 5 @@ -177,7 +179,7 @@ private fun RelayAuthPromptDialog( val who = when (primary?.kind) { AuthPurposeKind.POST_VENUE, AuthPurposeKind.READ_VENUE -> - primary.venues.firstOrNull()?.let { rememberVenueLabel(it, primary.kind, accountViewModel) } + primary.venues.firstOrNull()?.let { rememberVenueLabel(it, primary.kind, prompt.relayUrl, accountViewModel) } else -> counterpartyLabel(faces, accountViewModel) } @@ -478,33 +480,40 @@ private fun rememberCounterpartyName( } /** - * A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity, - * or a NIP-72 community. + * A display name for a venue id — a public chat channel (64-hex event id), a NIP-53 live activity, a + * NIP-72 community, a NIP-29 relay group, or a Concord community. * * Only a [AuthPurposeKind.POST_VENUE] id is *known* to be a channel (it is the root of a channel * message we are sending). A READ id may have come from the tag-shape fallback, where a bare `#e` * list is as likely to be note ids on a thread as channel roots — so we only ever *look up* an * existing channel there. Get-or-creating on read is what used to mint phantom public chats in * [LocalCache] for ordinary notes, complete with a metadata subscription for a room that never was. + * + * The two joined-room shapes are checked first, because both are id shapes the rules above would + * otherwise mislabel: a NIP-29 group id is only meaningful together with its host relay ([relayUrl], + * which is the relay doing the asking), and a Concord community id is a bare 64-hex string that + * resolves to no [Channel] at all — its name lives on the account's own joined-communities list. */ @Composable private fun rememberVenueLabel( venueId: String, kind: AuthPurposeKind, + relayUrl: NormalizedRelayUrl, accountViewModel: AccountViewModel, ): String { val channel: Channel? = - remember(venueId, kind) { - when { - venueId.length == 64 -> - if (kind == AuthPurposeKind.POST_VENUE) { - accountViewModel.checkGetOrCreatePublicChatChannel(venueId) - } else { - LocalCache.getPublicChatChannelIfExists(venueId) - } - venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) } - else -> null - } + remember(venueId, kind, relayUrl) { + LocalCache.getRelayGroupChannelIfExists(GroupId(venueId, relayUrl)) + ?: when { + venueId.length == 64 -> + if (kind == AuthPurposeKind.POST_VENUE) { + accountViewModel.checkGetOrCreatePublicChatChannel(venueId) + } else { + LocalCache.getPublicChatChannelIfExists(venueId) + } + venueId.startsWith("30311:") -> Address.parse(venueId)?.let { accountViewModel.checkGetOrCreateLiveActivityChannel(it) } + else -> null + } } if (channel != null) { @@ -514,6 +523,15 @@ private fun rememberVenueLabel( if (name.isNotBlank()) return name } + val concordName = + remember(venueId) { + accountViewModel.account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == venueId } + ?.name + ?.takeIf { it.isNotBlank() } + } + if (concordName != null) return concordName + // Community: the d-identifier is the name in NIP-72. Also the fallback for an unresolved channel. return venueId.substringAfterLast(':').ifEmpty { venueId.take(8) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 4865be0594..7ff6533dc3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -76,6 +76,10 @@ class AuthCoordinator( RelayAuthPurposeDeriver.derive( pendingEvents = client.activeOutboxEvents(relayUrl), activeFilters = client.activeRequests(relayUrl), + // The context describes the shared socket, not one account, so a + // plane is looked up across every watched account — same as the + // stream-key AUTHs above. + venueForPlaneAuthor = ::concordCommunityForPlane, ), ) } @@ -165,6 +169,13 @@ class AuthCoordinator( }, ) + /** + * The joined Concord community whose plane [planeAddress] is, across every watched account, or + * null when the pubkey isn't a plane of ours. Feeds [RelayAuthPurposeDeriver] so a pending plane + * wrap reads as a post into that community rather than as a DM to the throwaway key it `p`-tags. + */ + private fun concordCommunityForPlane(planeAddress: HexKey): HexKey? = authWithAccounts.distinct().firstNotNullOfOrNull { it.concordSessions.communityIdForPlane(planeAddress) } + /** * Signs one kind-22242 AUTH per Concord plane stream key hosted on [relayUrl], across every * watched account. Signed locally from the derived stream secret (a raw [KeyPair] via @@ -237,13 +248,12 @@ class AuthCoordinator( relayUrl = relayUrl, pendingEvents = client.activeOutboxEvents(relayUrl), myRelays = account.trustedRelays.flow.value, - // A NIP-29 relay group the user explicitly joined (kind-10009) is a first-party reason - // to authenticate with its host relay: private/closed group content is `#h`-scoped and - // never names the user, so it fails the pubkey checks above — without this, a joined - // private group's messages are refused with `auth-required` and the group stays empty. - myGroupRelays = - account.relayGroupList.liveRelayGroupIds.value - .mapTo(mutableSetOf()) { it.relayUrl }, + // A room the user explicitly joined — a NIP-29 relay group (kind-10009) or a Concord + // community (kind-13302) — makes its host relay first-party. Neither room's traffic + // names the user: NIP-29 content is `#h`-scoped and Concord planes ride derived stream + // keys, so both fail the pubkey checks above. Without this, a joined private group's + // messages are refused with `auth-required` and the room stays empty. + myVenueRelays = account.venueHostRelays(), ) fun destroy() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt index c44c506364..68009ebc7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstParty.kt @@ -35,13 +35,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl * the recipient's inbox relay), or * - the relay is one it configured itself ([myRelays] — its NIP-65 / DM / search / … lists, which * is where its own inbox/outbox reads are routed anyway), or - * - the relay hosts a NIP-29 relay group the account explicitly joined ([myGroupRelays], from its - * kind-10009 list). A private/closed group's content (kind-9 chat, kind-11 threads, …) is - * `#h`-scoped and served only to authenticated members; that read never names the user, so it - * can't qualify via [pendingEvents], and a group host relay is not one of the account's own - * NIP-65/DM/… lists, so it can't qualify via [myRelays] either. Without this, a joined private - * group is refused with `auth-required` and renders empty — the group's own metadata (39000) is - * public and still loads, so the group appears but shows no messages. + * - the relay hosts a room the account explicitly joined ([myVenueRelays]): a NIP-29 relay group + * from its kind-10009 list, or a Concord community from its kind-13302 list. Neither kind of room + * can qualify by the two rules above. A private/closed NIP-29 group's content (kind-9 chat, + * kind-11 threads, …) is `#h`-scoped and served only to authenticated members, so the read never + * names the user; a Concord plane is authored by — and addressed to — a derived stream key, so + * even the wraps the account *publishes* there carry someone else's pubkey. And neither host relay + * is in the account's own NIP-65/DM/… lists, so [myRelays] doesn't see them either. Without this, + * a joined private group is refused with `auth-required` and renders empty — the group's own + * metadata (39000) is public and still loads, so the group appears but shows no messages. * * Crucially, an active subscription merely *naming* the account (a `#p` tag or `authors` entry) is * NOT a first-party reason: the app packs several accounts' pubkeys into one merged filter and fans @@ -56,10 +58,10 @@ object RelayAuthFirstParty { relayUrl: NormalizedRelayUrl, pendingEvents: List, myRelays: Set, - myGroupRelays: Set = emptySet(), + myVenueRelays: Set = emptySet(), ): Boolean { if (pendingEvents.any { it.pubKey == me }) return true if (relayUrl in myRelays) return true - return relayUrl in myGroupRelays + return relayUrl in myVenueRelays } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt index 6181fa1807..8ab1bdec84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -37,6 +37,13 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict * [globalPolicy] → prompt-if-attributable-else-deny. Under [RelayAuthPolicy.CUSTOM] the * [customToggles] gate each category, using [isFollowed] to split the counterparties carried in the * [RelayAuthContext] into followed vs. stranger. + * + * Venues come in two shapes and both count as "a room I joined" for the + * [RelayAuthCustomToggles.myRelaysAndVenues] toggle: [isTrustedVenue] recognizes a venue *id* named + * by a purpose (a NIP-28 chat, a NIP-72 community, a NIP-53 stream, a NIP-29 group id, a Concord + * community id), while [isVenueHostRelay] recognizes the relay that *hosts* one — the only signal + * available for a room whose traffic never names it in a way the deriver can see, or whose challenge + * arrives before its subscription does. */ class RelayAuthPermissionLedger( val store: RelayAuthPermissionStore, @@ -46,6 +53,7 @@ class RelayAuthPermissionLedger( val isBlocked: (String) -> Boolean = { false }, val isFollowed: (String) -> Boolean = { false }, val isTrustedVenue: (String) -> Boolean = { false }, + val isVenueHostRelay: (String) -> Boolean = { false }, ) { /** * The authorization verdict for [ctx], taking the challenge's purpose into account. @@ -59,6 +67,15 @@ class RelayAuthPermissionLedger( isFirstParty: Boolean = true, ): RelayAuthVerdict { fun isWrite(kind: AuthPurposeKind) = kind == AuthPurposeKind.SEND_DM || kind == AuthPurposeKind.NOTIFY_INBOX + + // The relay itself hosts a room this account joined — a NIP-29 relay group or a Concord + // community. Computed apart from the purposes because those rooms are the relay's whole + // reason to be here: their traffic is `#h`-scoped (NIP-29) or addressed to derived stream + // keys (Concord), so an AUTH challenge that lands before the room's subscription is + // assembled carries no venue to match — and the joined room would sit empty behind a + // question the user already answered by joining it. + val hostsMyVenue = isVenueHostRelay(ctx.relayUrl) + val inputs = RelayAuthInputs( storedOverride = store.loadDecision(ctx.relayUrl), @@ -67,10 +84,11 @@ class RelayAuthPermissionLedger( toggles = customToggles(), isInMyRelayList = isInMyRelayList(ctx.relayUrl), servesTrustedVenue = - ctx.purposes.any { p -> - (p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) && - p.venues.any(isTrustedVenue) - }, + hostsMyVenue || + ctx.purposes.any { p -> + (p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) && + p.venues.any(isTrustedVenue) + }, // Reading a followed author's outbox. servesFollowedReadCounterparty = ctx.purposes.any { p -> @@ -82,18 +100,22 @@ class RelayAuthPermissionLedger( // Messaging a non-followed user's inbox. servesStrangerWriteCounterparty = ctx.purposes.any { p -> isWrite(p.kind) && p.counterparties.any { !isFollowed(it) } }, + // Hosting a joined room is itself an explanation, and the only one available when the + // challenge arrives before any of that room's filters do. Without it, a user on + // "decide per relay" (or with the venue toggle off) would get a silent denial for a + // room they joined instead of the question. MY_INBOX and THREAD likewise name no + // counterparty by design — the relay is holding back the user's *own* inbox or the + // conversation on screen — and are fully explainable, so they too must reach ASK. hasAttributablePurpose = - ctx.purposes.any { - // MY_INBOX and THREAD name no counterparty by design — the relay is holding - // back the user's *own* inbox or the conversation on screen. They are still - // fully explainable, so they must reach ASK rather than a silent DENY. - it.kind == AuthPurposeKind.MY_OWN_RELAY || - it.kind == AuthPurposeKind.OTHER || - it.kind == AuthPurposeKind.MY_INBOX || - it.kind == AuthPurposeKind.THREAD || - it.counterparties.isNotEmpty() || - it.venues.isNotEmpty() - }, + hostsMyVenue || + ctx.purposes.any { + it.kind == AuthPurposeKind.MY_OWN_RELAY || + it.kind == AuthPurposeKind.OTHER || + it.kind == AuthPurposeKind.MY_INBOX || + it.kind == AuthPurposeKind.THREAD || + it.counterparties.isNotEmpty() || + it.venues.isNotEmpty() + }, isFirstParty = isFirstParty, ) return RelayAuthResolver.resolve(inputs) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt index d6bad70abc..3e2878a6db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip10Notes.tags.MarkedETag import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent @@ -48,10 +49,18 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve * * Sends (from the outbox) are read from the events themselves: * + * - a pending Concord plane wrap (recognized by [venueForPlaneAuthor], since the wrap is *signed* by + * the plane's stream key) => [AuthPurposeKind.POST_VENUE] for that community; + * - a pending `h`-tagged event => [AuthPurposeKind.POST_VENUE] for that NIP-29 relay group; * - a pending gift wrap (kind 1059) => sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]); * - a pending channel/community/live post => [AuthPurposeKind.POST_VENUE] for that venue; * - any other pending event with `p` tags => delivering it to those users' inboxes ([AuthPurposeKind.NOTIFY_INBOX]). * + * The two room rules come first because both would otherwise be read as something else entirely: a + * Concord wrap is kind 1059 `p`-tagged to a *throwaway* pubkey, so the gift-wrap rule would explain a + * community post as a DM to a stranger nobody can name, and a NIP-29 chat message mentioning someone + * would be explained as a notification rather than as a message into the group. + * * Reads prefer the purpose the subscription **declared**. Assemblers build every filter as an * [ExplainedFilter] carrying a [SubPurpose][com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose] * and the entity ids it serves, so we simply read it (see `toAuthPurposeKind`). That is the only way @@ -66,9 +75,14 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve * is prompted about instead of silently failing. */ object RelayAuthPurposeDeriver { + /** + * @param venueForPlaneAuthor maps the pubkey that *signed* a pending event to the room it streams + * for — today, a Concord plane address to its community id. Returns null for anything else. + */ fun derive( pendingEvents: List, activeFilters: Map>, + venueForPlaneAuthor: (HexKey) -> String? = { null }, ): List { val dmRecipients = mutableSetOf() val notifyRecipients = mutableSetOf() @@ -78,7 +92,11 @@ object RelayAuthPurposeDeriver { pendingEvents.forEach { event -> val pubkeys = event.tags.mapNotNull(PTag::parseKey) val venues = event.tags.mapNotNull(ATag::parseAddress).filter { it.kind in VENUE_KINDS } + val planeVenue = venueForPlaneAuthor(event.pubKey) + val groupId = event.tags.firstNotNullOfOrNull(GroupIdTag::parse) when { + planeVenue != null -> postVenues.add(planeVenue) + groupId != null -> postVenues.add(groupId) event.kind == GiftWrapEvent.KIND -> dmRecipients.addAll(pubkeys) event.kind == ChannelMessageEvent.KIND -> event.tags.channelRootId()?.let(postVenues::add) venues.isNotEmpty() -> venues.forEach { postVenues.add(it.toValue()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt new file mode 100644 index 0000000000..65150f5f48 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId + +/** + * The rooms an account joined that the NIP-51 venue lists don't describe: NIP-29 relay groups (the + * kind-10009 list), Concord communities (the kind-13302 list) and Buzz workspaces. + * + * All three are venues in the "…it's my relay, or a room I joined" sense, and all three are invisible + * to every other signal in the auth path — a NIP-29 group's content is `#h`-scoped and never names the + * user, and a Concord plane is authored by and addressed to derived stream keys — so this is what + * feeds them to [RelayAuthPermissionLedger] and [RelayAuthFirstParty]. Pure, so the id/url matching is + * testable without an [com.vitorpamplona.amethyst.model.Account]. + */ +object RelayAuthVenues { + /** + * The relays these rooms live on. A NIP-29 group id is only meaningful together with its host, so + * the [GroupId] carries an already-normalized url; a Concord entry stores raw url strings written + * by whoever created the community, so those are normalized here — comparing them verbatim + * against a challenge's relay url is what makes a trailing slash or a `wss://` case difference + * quietly drop the whole community. + * + * [joinedWorkspaces] are Buzz workspaces, the third joined-room shape: one relay each, joined by + * redeeming an HTTP invite rather than by publishing a list event, so the relay url *is* the + * membership record. + */ + fun hostRelays( + joinedGroups: Set, + joinedCommunities: List, + joinedWorkspaces: Set = emptySet(), + ): Set = + buildSet { + joinedGroups.mapTo(this) { it.relayUrl } + joinedCommunities.forEach { entry -> + entry.relays.forEach { url -> RelayUrlNormalizer.normalizeOrNull(url)?.let(::add) } + } + addAll(joinedWorkspaces) + } + + /** + * True when [venueId] names one of these rooms — a NIP-29 group id or a Concord community id. + * These are the ids the subscription assemblers declare as their filters' entity ids, so this is + * what turns a `READ_VENUE`/`POST_VENUE` on a joined room into a trusted venue. + */ + fun isJoinedRoom( + venueId: String, + joinedGroups: Set, + joinedCommunities: List, + ): Boolean = joinedGroups.any { it.id == venueId } || joinedCommunities.any { it.id == venueId } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt index 0f248049ee..354750736b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthFirstPartyTest.kt @@ -88,4 +88,14 @@ class RelayAuthFirstPartyTest { val groupRelay = NormalizedRelayUrl("wss://chat.wisp.talk/") assertFalse(RelayAuthFirstParty.hasReason(me, groupRelay, emptyList(), emptySet(), emptySet())) } + + @Test + fun aRelayHostingAJoinedConcordCommunityIsFirstParty() { + // Concord is the harder case of the same rule: a plane wrap this account publishes is signed + // by the plane's *stream key*, so even its own outbound traffic carries someone else's pubkey + // and the pendingEvents rule can never fire. The joined-communities list is the only signal. + val concordRelay = NormalizedRelayUrl("wss://relay.dreamith.to/") + val planeWrap = event(other) + assertTrue(RelayAuthFirstParty.hasReason(me, concordRelay, listOf(planeWrap), emptySet(), setOf(concordRelay))) + } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt index 904a5dad57..0c461f763d 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt @@ -229,6 +229,76 @@ class RelayAuthPurposeDeriverTest { assertEquals(setOf(alice), purposes[0].counterparties) } + // ---- rooms whose traffic doesn't look like a room ----------------------------------------- + + @Test + fun postingIntoARelayGroupIsAVenuePostAndNotANotification() { + // A NIP-29 chat message is `#h`-scoped; the mention it carries would otherwise make this read + // as "delivering a notification to alice" instead of "posting into the group". + val groupId = "abcd1234" + val ev = + Event( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + kind = 9, + tags = arrayOf(arrayOf("h", groupId), arrayOf("p", alice)), + content = "hi", + sig = "22".repeat(64), + ) + + val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap()) + + assertEquals(listOf(AuthPurposeKind.POST_VENUE), purposes.map { it.kind }) + assertEquals(setOf(groupId), purposes[0].venues) + } + + @Test + fun postingIntoAConcordChannelIsAVenuePostAndNotADmToItsThrowawayPTag() { + // A Concord plane wrap is kind 1059 signed by the plane's stream key and `p`-tagged to a fresh + // random pubkey. On tag shape alone it is a gift wrap, so the prompt used to offer to "send a + // message" to a key that belongs to nobody and will never be seen again. + val planeAddress = "9".repeat(64) + val communityId = "c".repeat(64) + val throwaway = "e".repeat(64) + val wrap = + Event( + id = "00".repeat(32), + pubKey = planeAddress, + createdAt = 1_700_000_000L, + kind = GiftWrapEvent.KIND, + tags = arrayOf(arrayOf("p", throwaway)), + content = "", + sig = "22".repeat(64), + ) + + val purposes = + RelayAuthPurposeDeriver.derive( + pendingEvents = listOf(wrap), + activeFilters = emptyMap(), + venueForPlaneAuthor = { if (it == planeAddress) communityId else null }, + ) + + assertEquals(listOf(AuthPurposeKind.POST_VENUE), purposes.map { it.kind }) + assertEquals(setOf(communityId), purposes[0].venues) + assertEquals(emptySet(), purposes[0].counterparties) + } + + @Test + fun aGiftWrapFromAnUnknownAuthorIsStillADm() { + // The plane lookup must not swallow real NIP-17 traffic: an author we don't recognize as a + // plane keeps the gift-wrap reading. + val purposes = + RelayAuthPurposeDeriver.derive( + pendingEvents = listOf(event(GiftWrapEvent.KIND, listOf(alice))), + activeFilters = emptyMap(), + venueForPlaneAuthor = { null }, + ) + + assertEquals(listOf(AuthPurposeKind.SEND_DM), purposes.map { it.kind }) + assertEquals(setOf(alice), purposes[0].counterparties) + } + @Test fun readingMyInboxAndSendingADmAreBothReported() { val purposes = diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt new file mode 100644 index 0000000000..2836f837fb --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose +import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthContext +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthDecision +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy +import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * "…it's my relay, or a room I joined" must actually cover every room the app lets you join. NIP-29 + * relay groups and Concord communities were invisible to it: neither id is on the public-chat or + * NIP-72 community lists the venue check consulted, so a relay whose only job is hosting a group the + * user joined fell through to a prompt on every connection (or, with nothing attributable yet, to a + * silent denial that left the room empty). + */ +class RelayAuthVenueCoverageTest { + private val groupRelay = "wss://groups.example.com/" + private val concordRelay = "wss://relay.dreamith.to/" + private val joinedGroupId = "abcd1234" + private val joinedCommunityId = "c".repeat(64) + + private class NoStore : RelayAuthPermissionStore { + override suspend fun loadDecision(relayUrl: String): RelayAuthDecision? = null + + override suspend fun storeDecision( + relayUrl: String, + decision: RelayAuthDecision, + ) = Unit + + override suspend fun clearDecision(relayUrl: String) = Unit + + override suspend fun allDecisions(): Map = emptyMap() + } + + private fun ledger(toggles: RelayAuthCustomToggles = RelayAuthCustomToggles()) = + RelayAuthPermissionLedger( + store = NoStore(), + globalPolicy = { RelayAuthPolicy.CUSTOM }, + customToggles = { toggles }, + isTrustedVenue = { it == joinedGroupId || it == joinedCommunityId }, + isVenueHostRelay = { it == groupRelay || it == concordRelay }, + ) + + private fun readVenue( + relayUrl: String, + venueId: String, + ) = RelayAuthContext(relayUrl, listOf(AuthPurpose(AuthPurposeKind.READ_VENUE, venues = setOf(venueId)))) + + @Test + fun readingAJoinedRelayGroupAutoAuthenticates() = + runTest { + // The group's chat is `#h`-scoped: the subscription declares RELAY_GROUPS, which carries + // the group id as its venue. That id is on the kind-10009 list, so the venue toggle covers + // it exactly like a public chat the user joined. + assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(readVenue(groupRelay, joinedGroupId))) + } + + @Test + fun readingAJoinedConcordCommunityAutoAuthenticates() = + runTest { + assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(readVenue(concordRelay, joinedCommunityId))) + } + + @Test + fun aJoinedRoomsHostAutoAuthenticatesBeforeAnyOfItsFiltersExist() = + runTest { + // The challenge that matters arrives on connect, before the room's subscription is + // assembled — so there is no venue in the context to match. The host relay itself is the + // signal: nothing else is on it. + assertEquals(RelayAuthVerdict.ALLOW, ledger().decide(RelayAuthContext(groupRelay))) + } + + @Test + fun aJoinedRoomsHostStillAsksWhenTheVenueToggleIsOff() = + runTest { + // Turning the toggle off must mean "ask me", not "deny in silence": hosting a room the user + // joined is an explanation, so the challenge is attributable even with no purposes. + val verdict = ledger(RelayAuthCustomToggles(myRelaysAndVenues = false)).decide(RelayAuthContext(groupRelay)) + assertEquals(RelayAuthVerdict.ASK, verdict) + } + + @Test + fun aGroupOnARelayIHaveNotJoinedIsNotAutoAuthenticated() = + runTest { + // Browsing a group directory names a venue we never joined on a relay that hosts nothing of + // ours — explainable, so it asks, but it is never silently granted. + val verdict = ledger().decide(readVenue("wss://stranger.example.com/", "somebodyElsesGroup")) + assertEquals(RelayAuthVerdict.ASK, verdict) + } + + @Test + fun aJoinedRoomsHostIsNotAutoAuthenticatedForABystanderAccount() = + runTest { + // The room belongs to another logged-in account; this one has no first-party reason to be + // on the relay, so it asks instead of revealing its npub on its own. + val verdict = ledger().decide(readVenue(groupRelay, joinedGroupId), isFirstParty = false) + assertEquals(RelayAuthVerdict.ASK, verdict) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt new file mode 100644 index 0000000000..c455132cfe --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.relayClient.authCommand.model + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip29RelayGroups.GroupId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The joined-room sources behind the "…it's my relay, or a room I joined" auto-login toggle. */ +class RelayAuthVenuesTest { + private val groupHost = RelayUrlNormalizer.normalize("wss://groups.example.com") + private val communityId = "c".repeat(64) + + private val joinedGroups = setOf(GroupId("abcd1234", groupHost)) + + private fun community(relays: List) = + ConcordCommunityListEntry( + id = communityId, + owner = "1".repeat(64), + ownerSalt = "2".repeat(64), + root = "3".repeat(64), + relays = relays, + name = "Dreamith", + ) + + @Test + fun bothJoinedRoomKindsContributeTheirHostRelays() { + val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("wss://relay.dreamith.to")))) + + assertEquals( + setOf(groupHost, RelayUrlNormalizer.normalize("wss://relay.dreamith.to")), + relays, + ) + } + + @Test + fun aConcordEntrysRawRelayStringIsNormalizedBeforeItIsCompared() { + // The community list stores whatever url the community's creator wrote. Comparing that + // verbatim against a normalized challenge url is how a whole community silently stops + // matching over a trailing slash or an upper-case host. + val relays = RelayAuthVenues.hostRelays(emptySet(), listOf(community(listOf("wss://Relay.Dreamith.to/")))) + + assertEquals(setOf(RelayUrlNormalizer.normalize("wss://relay.dreamith.to")), relays) + } + + @Test + fun aJoinedBuzzWorkspaceIsItsOwnHostRelay() { + // Buzz membership is granted server-side by an HTTP invite claim — there is no list event to + // read it back from, so the joined relay url is the whole record of the room. + val workspace = RelayUrlNormalizer.normalize("wss://block.buzz") + val relays = RelayAuthVenues.hostRelays(emptySet(), emptyList(), setOf(workspace)) + + assertEquals(setOf(workspace), relays) + } + + @Test + fun anUnparseableRelayIsDroppedRatherThanFailingTheWholeList() { + val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("not a url at all")))) + + assertEquals(setOf(groupHost), relays) + } + + @Test + fun aJoinedGroupIdAndCommunityIdAreBothJoinedRooms() { + val communities = listOf(community(listOf("wss://relay.dreamith.to"))) + + assertTrue(RelayAuthVenues.isJoinedRoom("abcd1234", joinedGroups, communities)) + assertTrue(RelayAuthVenues.isJoinedRoom(communityId, joinedGroups, communities)) + } + + @Test + fun aRoomIHaveNotJoinedIsNotAJoinedRoom() { + val communities = listOf(community(listOf("wss://relay.dreamith.to"))) + + assertFalse(RelayAuthVenues.isJoinedRoom("someoneElsesGroup", joinedGroups, communities)) + assertFalse(RelayAuthVenues.isJoinedRoom("d".repeat(64), joinedGroups, communities)) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt index 0bb8faba0f..44da2aa96a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionManager.kt @@ -157,6 +157,13 @@ class ConcordSessionManager( return outcome.claimed } + /** + * The joined community [planeAddress] streams for, or null when it isn't one of ours. Lets the + * NIP-42 auth path name a pending plane wrap as a post into that community instead of reading its + * throwaway `p` tag as a DM recipient (see [ConcordSessionRegistry.communityIdForPlane]). + */ + fun communityIdForPlane(planeAddress: HexKey): HexKey? = registry.communityIdForPlane(planeAddress) + fun sessions() = registry.sessions() fun sessionFor(communityId: HexKey) = registry.sessionFor(communityId) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 09be414805..0c022651eb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -108,6 +108,26 @@ class ConcordSessionRegistry( out } + /** + * The joined community whose planes [planeAddress] belongs to, or null when no session claims it. + * + * The NIP-42 auth path reads this to recognize an outbound plane wrap for what it is. A wrap is + * signed by the plane's stream key and `p`-tagged to a throwaway pubkey, so on tag shape alone it + * is indistinguishable from a NIP-17 gift wrap — a post into a community would otherwise be + * explained to the user as a direct message to a stranger nobody can name. + */ + fun communityIdForPlane(planeAddress: HexKey): HexKey? = + lock.withLock { + sessions.entries + .firstOrNull { (_, session) -> + planeAddress == session.controlPlaneAddress || + planeAddress == session.guestbookAddress || + planeAddress == session.nextBaseRekeyAddress || + planeAddress in session.historicalControlPlaneAddresses() || + planeAddress in session.channelAddresses() + }?.key + } + /** * Routes an inbound stream [wrap] to whichever session recognizes it, returning that session's * [ConcordIngestOutcome] (or [ConcordIngestOutcome.NOT_MINE] if none claim it). A wrap belongs to diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt index 4445f01f4f..d9030c0889 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayauth/RelayAuthResolver.kt @@ -24,8 +24,8 @@ package com.vitorpamplona.amethyst.commons.relayauth * The per-situation switches applied under [RelayAuthPolicy.CUSTOM]. Each independently authorizes * one category of relay; a situation with no matching toggle falls through to a prompt. * - * @param myRelaysAndVenues your own relays, plus venues (public chats, communities, live streams) - * you've joined, subscribed to, or favorited. + * @param myRelaysAndVenues your own relays, plus venues (public chats, NIP-72 communities, live + * streams, NIP-29 relay groups, Concord communities) you've joined, subscribed to, or favorited. * @param readFollows a relay serving the outbox of someone you follow (to download their posts). * @param messageFollows a relay serving the inbox of someone you follow (to send DMs, replies, * notifications). @@ -49,8 +49,10 @@ data class RelayAuthCustomToggles( * @param policy the top-level [RelayAuthPolicy]. * @param toggles the [RelayAuthCustomToggles] applied when [policy] is [RelayAuthPolicy.CUSTOM]. * @param isInMyRelayList the relay is in the user's own relay list. - * @param servesTrustedVenue this relay hosts a venue (public chat, community, or live stream) the - * user has joined, subscribed to, or favorited. + * @param servesTrustedVenue this relay hosts a venue the user has joined, subscribed to, or + * favorited — a public chat, a NIP-72 community, a live stream, a NIP-29 relay group, or a Concord + * community. True either because a purpose names one of those venues or because the relay itself is + * a joined room's host (a group/community relay serves nothing else). * @param servesFollowedReadCounterparty a followed user's outbox is served here (reading them). * @param servesFollowedWriteCounterparty a followed user's inbox is served here (messaging them). * @param servesStrangerWriteCounterparty a non-followed user's inbox is served here (messaging them). From 90043b1e87c64948d24059b13af6cf793a003a21 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 12 Aug 2026 22:28:47 +0000 Subject: [PATCH 2/2] fix(relay-auth): four defects the venue-coverage change introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the previous commit. Each of these is the same shape: an id that looks like something it isn't. - A Concord community id is a bare 64-hex string, so `rememberVenueLabel`'s public-chat branch took it — and on a POST_VENUE, which is exactly what a pending plane wrap now derives, that branch *get-or-creates*. So naming a Concord room minted the phantom public chat (plus its metadata subscription) this function exists to avoid, then named the room after the phantom's own nevent, making the Concord lookup below it unreachable. Both joined-room shapes now resolve first, and the community name is read off the folded ConcordChannels in LocalCache before the active account's joined list — LocalCache is shared by every logged-in account, so a prompt raised for one account no longer degrades to a hex prefix while another is on screen. - `venueHostRelays()` folded the process-wide BuzzWorkspaces singleton into a per-account venue set, so every logged-in account auto-authenticated on a workspace only one of them joined — silently revealing a bystander account's npub where the user used to be asked. `isFirstParty` cannot catch that: the Buzz set carries no account. Only per-account list events belong here; the workspace's own first-party reason in AuthCoordinator is unchanged. - A NIP-29 group id is scoped to its host relay and is routinely generic (`_` is the spec's relay-wide group), so matching on the id alone let a group we merely browsed elsewhere pass for one we joined. The trusted-venue check now takes the (relay, venue) pair. A Concord community id is a 64-hex derived value that names one community wherever it is served, so it still matches on its own. - Marmot (MLS) carries its group id in an `h` tag exactly like NIP-29, so the new rule read a kind-445 send as a post into a room — an opaque MLS id with no metadata behind it, 64-hex, and therefore another phantom-channel mint at label time. MLS kinds keep their prior reading. Two allocations out of the auth path while here: only a stream-wrap kind pays for the plane lookup (it ran per pending event), and the lookup itself is a membership test on the session rather than a union of its channel and prior-epoch address sets. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PHCHwnWjVt83Ne3qGBeGq6 --- .../vitorpamplona/amethyst/model/Account.kt | 31 ++++++------ .../amethyst/model/LocalCache.kt | 9 ++++ .../compose/RelayAuthPromptHost.kt | 49 ++++++++++++++----- .../model/RelayAuthPermissionLedger.kt | 7 +-- .../model/RelayAuthPurposeDeriver.kt | 23 +++++++-- .../authCommand/model/RelayAuthVenues.kt | 38 ++++++++------ .../model/RelayAuthPurposeDeriverTest.kt | 38 ++++++++++++++ .../model/RelayAuthVenueCoverageTest.kt | 2 +- .../authCommand/model/RelayAuthVenuesTest.kt | 39 +++++++++------ .../model/concord/ConcordCommunitySession.kt | 16 ++++++ .../model/concord/ConcordSessionRegistry.kt | 9 +--- 11 files changed, 190 insertions(+), 71 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 4725093239..458d7c7b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect -import com.vitorpamplona.amethyst.commons.model.buzz.BuzzWorkspaces import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordSessionManager @@ -426,22 +425,22 @@ class Account( isInMyRelayList = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in trustedRelays.flow.value } ?: false }, isBlocked = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in blockedRelayList.flow.value } ?: false }, isFollowed = { pubkey -> pubkey in allFollows.flow.value.authors }, - isTrustedVenue = { venueId -> + isTrustedVenue = { relayUrl, venueId -> venueId in publicChatList.flowSet.value || venueId in communityList.flowSet.value || - isJoinedRoomId(venueId) || + isJoinedRoomId(relayUrl, venueId) || Address.parse(venueId)?.pubKeyHex?.let { it in allFollows.flow.value.authors } == true }, isVenueHostRelay = { relayUrl -> relayUrl.normalizeRelayUrlOrNull()?.let { it in venueHostRelays() } ?: false }, ) /** - * Relays that exist here because a room was joined on them: the host of every NIP-29 relay group - * on the kind-10009 list, the relays of every joined Concord community, and every joined Buzz - * workspace. + * Relays that exist here because *this account* joined a room on them: the host of every NIP-29 + * relay group on its kind-10009 list, plus the relays of every Concord community on its + * kind-13302 list. * - * All are venues in the [RelayAuthCustomToggles.myRelaysAndVenues] sense but none shows up in a - * NIP-65/DM/search list, so nothing else in the auth path can see them: a NIP-29 group's content + * Both are venues in the [RelayAuthCustomToggles.myRelaysAndVenues] sense but neither shows up in + * a NIP-65/DM/search list, so nothing else in the auth path can see them: a NIP-29 group's content * is `#h`-scoped and never names the user, and a Concord plane is addressed to a derived stream * key rather than to anyone's pubkey. */ @@ -449,18 +448,22 @@ class Account( RelayAuthVenues.hostRelays( joinedGroups = relayGroupList.liveRelayGroupIds.value, joinedCommunities = concordChannelList.liveCommunities.value, - joinedWorkspaces = BuzzWorkspaces.flow.value, ) /** - * True when [venueId] is a room this account joined that the venue *lists* above don't cover: a - * NIP-29 group id (from the kind-10009 list) or a Concord community id (from the kind-13302 list). - * Those are the ids the subscription assemblers declare on their filters, so this is what turns a - * `READ_VENUE`/`POST_VENUE` on a joined group or community into a trusted venue. + * True when [venueId], served by [relayUrl], is a room this account joined that the venue *lists* + * above don't cover: a NIP-29 group id (from the kind-10009 list) or a Concord community id (from + * the kind-13302 list). Those are the ids the subscription assemblers declare on their filters, so + * this is what turns a `READ_VENUE`/`POST_VENUE` on a joined group or community into a trusted + * venue. */ - private fun isJoinedRoomId(venueId: String): Boolean = + private fun isJoinedRoomId( + relayUrl: String, + venueId: String, + ): Boolean = RelayAuthVenues.isJoinedRoom( venueId = venueId, + relayUrl = relayUrl.normalizeRelayUrlOrNull(), joinedGroups = relayGroupList.liveRelayGroupIds.value, joinedCommunities = concordChannelList.liveCommunities.value, ) 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 14bb1531d4..7254304b16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -846,6 +846,15 @@ object LocalCache : ILocalCache, ICacheProvider, Dao { fun getOrCreateConcordChannel(key: ConcordChannelId): ConcordChannel = concordChannels.getOrCreate(key) { ConcordChannel(key) } + /** + * Any known channel of Concord community [communityId], or null when we hold none. + * + * A community is not itself a cached object — it has no metadata event, only a folded Control + * Plane — so its display fields (`communityName`, icon, relays) are carried on every one of its + * channels. Callers that need to *name* a community therefore ask for whichever channel we have. + */ + fun getAnyConcordChannelOfCommunity(communityId: HexKey): ConcordChannel? = concordChannels.filter { key, _ -> key.communityId == communityId }.firstOrNull() + /** * Lands a decrypted Concord chat rumor in the cache as a real Note and, for * message-like kinds, attaches it to its channel so the shared chat feed and diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt index 49099715b9..34e23d91da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/compose/RelayAuthPromptHost.kt @@ -489,10 +489,12 @@ private fun rememberCounterpartyName( * existing channel there. Get-or-creating on read is what used to mint phantom public chats in * [LocalCache] for ordinary notes, complete with a metadata subscription for a room that never was. * - * The two joined-room shapes are checked first, because both are id shapes the rules above would - * otherwise mislabel: a NIP-29 group id is only meaningful together with its host relay ([relayUrl], - * which is the relay doing the asking), and a Concord community id is a bare 64-hex string that - * resolves to no [Channel] at all — its name lives on the account's own joined-communities list. + * Both joined-room shapes are resolved **before** any of that, because the rules above would not just + * mislabel them, they would act on them. A NIP-29 group id is only meaningful together with its host + * relay ([relayUrl], the relay doing the asking). A Concord community id is a bare 64-hex string, so + * the public-chat branch would take it: on a `POST_VENUE` — which is exactly what a pending plane wrap + * now derives — that get-or-create mints the phantom channel this function was rewritten to stop + * minting, and then names the room after its own nevent. */ @Composable private fun rememberVenueLabel( @@ -501,6 +503,12 @@ private fun rememberVenueLabel( relayUrl: NormalizedRelayUrl, accountViewModel: AccountViewModel, ): String { + // Resolved off LocalCache first and the active account's list second: a prompt is raised per + // account, so the account on screen is not necessarily the one being asked about — the cache is + // shared by all of them, the list is not. + val concordName = remember(venueId) { concordCommunityLabel(venueId, accountViewModel) } + if (concordName != null) return concordName + val channel: Channel? = remember(venueId, kind, relayUrl) { LocalCache.getRelayGroupChannelIfExists(GroupId(venueId, relayUrl)) @@ -523,19 +531,34 @@ private fun rememberVenueLabel( if (name.isNotBlank()) return name } - val concordName = - remember(venueId) { - accountViewModel.account.concordChannelList.liveCommunities.value - .firstOrNull { it.id == venueId } - ?.name - ?.takeIf { it.isNotBlank() } - } - if (concordName != null) return concordName - // Community: the d-identifier is the name in NIP-72. Also the fallback for an unresolved channel. return venueId.substringAfterLast(':').ifEmpty { venueId.take(8) } } +/** + * The label for a Concord community id, or null when [venueId] is not a community we know of — which + * is what tells the caller to go on treating the id as a channel root. + * + * A community has no metadata event to look up: its name comes from the folded Control Plane, held on + * the `ConcordChannel`s in [LocalCache] (shared by every logged-in account, so this still resolves a + * prompt raised for a different one), and failing that from the active account's joined list. A + * community we can place but cannot name still returns a label, so the caller never falls through to + * the channel branches with an id it would get-or-create. + */ +private fun concordCommunityLabel( + venueId: String, + accountViewModel: AccountViewModel, +): String? { + val folded = LocalCache.getAnyConcordChannelOfCommunity(venueId) + val joined = + accountViewModel.account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == venueId } + if (folded == null && joined == null) return null + return folded?.communityName?.takeIf { it.isNotBlank() } + ?: joined?.name?.takeIf { it.isNotBlank() } + ?: venueId.take(8) +} + /** * The best display name for [pubkey], reactive to metadata arriving from relays. Falls back to a * generic "someone you haven't loaded yet" rather than a hex prefix dressed up as a person's name. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt index 8ab1bdec84..a4170280f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPermissionLedger.kt @@ -43,7 +43,8 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthVerdict * by a purpose (a NIP-28 chat, a NIP-72 community, a NIP-53 stream, a NIP-29 group id, a Concord * community id), while [isVenueHostRelay] recognizes the relay that *hosts* one — the only signal * available for a room whose traffic never names it in a way the deriver can see, or whose challenge - * arrives before its subscription does. + * arrives before its subscription does. [isTrustedVenue] is asked about the (relay, venue) pair + * rather than the id alone because a NIP-29 group id means nothing without its host relay. */ class RelayAuthPermissionLedger( val store: RelayAuthPermissionStore, @@ -52,7 +53,7 @@ class RelayAuthPermissionLedger( val isInMyRelayList: (String) -> Boolean = { false }, val isBlocked: (String) -> Boolean = { false }, val isFollowed: (String) -> Boolean = { false }, - val isTrustedVenue: (String) -> Boolean = { false }, + val isTrustedVenue: (relayUrl: String, venueId: String) -> Boolean = { _, _ -> false }, val isVenueHostRelay: (String) -> Boolean = { false }, ) { /** @@ -87,7 +88,7 @@ class RelayAuthPermissionLedger( hostsMyVenue || ctx.purposes.any { p -> (p.kind == AuthPurposeKind.POST_VENUE || p.kind == AuthPurposeKind.READ_VENUE) && - p.venues.any(isTrustedVenue) + p.venues.any { isTrustedVenue(ctx.relayUrl, it) } }, // Reading a followed author's outbox. servesFollowedReadCounterparty = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt index 3e2878a6db..cec6d600c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriver.kt @@ -24,6 +24,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFil import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind import com.vitorpamplona.amethyst.commons.relayauth.toAuthPurposeKind +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -42,6 +45,18 @@ import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefiniti * activities. Their `a` addresses are `kind:ownerPubkey:dTag`. */ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEvent.KIND) +/** The kinds a Concord plane wrap can be — the only ones worth asking `venueForPlaneAuthor` about. */ +private val STREAM_WRAP_KINDS = setOf(ConcordStreamEnvelope.KIND_WRAP, ConcordStreamEnvelope.KIND_WRAP_EPHEMERAL) + +/** + * Marmot (MLS) carries the group id in an `h` tag exactly like NIP-29 does, but an MLS group is not a + * room we can name: the id is an opaque MLS value with no metadata event and no channel object behind + * it. Reading it as a venue would put that id in front of the user as a room name — and, on a + * `POST_VENUE`, get-or-create a phantom public chat for it (the id is 64-hex). These keep their prior + * reading: no `p` tags, so they land on the unattributed safety net. + */ +private val MLS_GROUP_KINDS = setOf(GroupEvent.KIND, WelcomeEvent.KIND) + /** * Infers *why* a relay wants NIP-42 auth from what Amethyst is currently doing with it — the * events pending delivery and the active subscription filters (both from the [INostrClient]). @@ -51,7 +66,8 @@ private val VENUE_KINDS = setOf(CommunityDefinitionEvent.KIND, LiveActivitiesEve * * - a pending Concord plane wrap (recognized by [venueForPlaneAuthor], since the wrap is *signed* by * the plane's stream key) => [AuthPurposeKind.POST_VENUE] for that community; - * - a pending `h`-tagged event => [AuthPurposeKind.POST_VENUE] for that NIP-29 relay group; + * - a pending `h`-tagged event => [AuthPurposeKind.POST_VENUE] for that NIP-29 relay group (except + * the [MLS_GROUP_KINDS], whose `h` names something unnameable); * - a pending gift wrap (kind 1059) => sending a DM to its `p` recipient ([AuthPurposeKind.SEND_DM]); * - a pending channel/community/live post => [AuthPurposeKind.POST_VENUE] for that venue; * - any other pending event with `p` tags => delivering it to those users' inboxes ([AuthPurposeKind.NOTIFY_INBOX]). @@ -92,8 +108,9 @@ object RelayAuthPurposeDeriver { pendingEvents.forEach { event -> val pubkeys = event.tags.mapNotNull(PTag::parseKey) val venues = event.tags.mapNotNull(ATag::parseAddress).filter { it.kind in VENUE_KINDS } - val planeVenue = venueForPlaneAuthor(event.pubKey) - val groupId = event.tags.firstNotNullOfOrNull(GroupIdTag::parse) + // Only a stream wrap can belong to a plane, so only a wrap pays for the lookup. + val planeVenue = if (event.kind in STREAM_WRAP_KINDS) venueForPlaneAuthor(event.pubKey) else null + val groupId = if (event.kind in MLS_GROUP_KINDS) null else event.tags.firstNotNullOfOrNull(GroupIdTag::parse) when { planeVenue != null -> postVenues.add(planeVenue) groupId != null -> postVenues.add(groupId) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt index 65150f5f48..f6605d7184 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt @@ -27,13 +27,18 @@ import com.vitorpamplona.quartz.nip29RelayGroups.GroupId /** * The rooms an account joined that the NIP-51 venue lists don't describe: NIP-29 relay groups (the - * kind-10009 list), Concord communities (the kind-13302 list) and Buzz workspaces. + * kind-10009 list) and Concord communities (the kind-13302 list). * - * All three are venues in the "…it's my relay, or a room I joined" sense, and all three are invisible - * to every other signal in the auth path — a NIP-29 group's content is `#h`-scoped and never names the - * user, and a Concord plane is authored by and addressed to derived stream keys — so this is what - * feeds them to [RelayAuthPermissionLedger] and [RelayAuthFirstParty]. Pure, so the id/url matching is + * Both are venues in the "…it's my relay, or a room I joined" sense, and both are invisible to every + * other signal in the auth path — a NIP-29 group's content is `#h`-scoped and never names the user, + * and a Concord plane is authored by and addressed to derived stream keys — so this is what feeds + * them to [RelayAuthPermissionLedger] and [RelayAuthFirstParty]. Pure, so the id/url matching is * testable without an [com.vitorpamplona.amethyst.model.Account]. + * + * Everything here is per account, deliberately: these lists come off *this* account's own list events, + * so one account's rooms can never grant another account's identity away. That rules out the + * process-wide joined sets (Buzz workspaces) — they carry no account, so folding them in here would + * auto-authenticate every logged-in account on a workspace only one of them joined. */ object RelayAuthVenues { /** @@ -42,32 +47,35 @@ object RelayAuthVenues { * by whoever created the community, so those are normalized here — comparing them verbatim * against a challenge's relay url is what makes a trailing slash or a `wss://` case difference * quietly drop the whole community. - * - * [joinedWorkspaces] are Buzz workspaces, the third joined-room shape: one relay each, joined by - * redeeming an HTTP invite rather than by publishing a list event, so the relay url *is* the - * membership record. */ fun hostRelays( joinedGroups: Set, joinedCommunities: List, - joinedWorkspaces: Set = emptySet(), ): Set = buildSet { joinedGroups.mapTo(this) { it.relayUrl } joinedCommunities.forEach { entry -> entry.relays.forEach { url -> RelayUrlNormalizer.normalizeOrNull(url)?.let(::add) } } - addAll(joinedWorkspaces) } /** - * True when [venueId] names one of these rooms — a NIP-29 group id or a Concord community id. - * These are the ids the subscription assemblers declare as their filters' entity ids, so this is - * what turns a `READ_VENUE`/`POST_VENUE` on a joined room into a trusted venue. + * True when [venueId], as served by [relayUrl], names one of these rooms. These are the ids the + * subscription assemblers declare as their filters' entity ids, so this is what turns a + * `READ_VENUE`/`POST_VENUE` on a joined room into a trusted venue. + * + * A NIP-29 group id is matched **together with its host**: ids are scoped to their relay and are + * routinely generic (`_` is the spec's relay-wide group), so an id alone would let a group we + * merely browsed on some other relay pass for one we joined. A Concord community id is a 64-hex + * derived value that names exactly one community wherever it is served, so it matches on its own + * — and its relays are covered by [hostRelays] anyway. */ fun isJoinedRoom( venueId: String, + relayUrl: NormalizedRelayUrl?, joinedGroups: Set, joinedCommunities: List, - ): Boolean = joinedGroups.any { it.id == venueId } || joinedCommunities.any { it.id == venueId } + ): Boolean = + (relayUrl != null && GroupId(venueId, relayUrl) in joinedGroups) || + joinedCommunities.any { it.id == venueId } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt index 0c461f763d..aa5fab5c58 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthPurposeDeriverTest.kt @@ -253,6 +253,44 @@ class RelayAuthPurposeDeriverTest { assertEquals(setOf(groupId), purposes[0].venues) } + @Test + fun aMarmotGroupMessageIsNotTreatedAsARoomWeCanName() { + // MLS carries its group id in an `h` tag exactly like NIP-29, but the id is an opaque MLS + // value with no metadata event behind it — and it is 64-hex, so a POST_VENUE on it would have + // the label get-or-create a phantom public chat. Stays on the unattributed safety net. + val ev = + Event( + id = "00".repeat(32), + pubKey = "11".repeat(32), + createdAt = 1_700_000_000L, + kind = 445, + tags = arrayOf(arrayOf("h", "d".repeat(64))), + content = "", + sig = "22".repeat(64), + ) + + val purposes = RelayAuthPurposeDeriver.derive(listOf(ev), emptyMap()) + + assertEquals(listOf(AuthPurposeKind.OTHER), purposes.map { it.kind }) + } + + @Test + fun aPendingEventThatIsNotAWrapIsNeverAskedAboutPlanes() { + // The plane lookup walks every joined community on every pending event; only a stream wrap + // can belong to a plane, so only a wrap may pay for it. + var asked = 0 + RelayAuthPurposeDeriver.derive( + pendingEvents = listOf(event(1, listOf(alice)), event(GiftWrapEvent.KIND, listOf(bob))), + activeFilters = emptyMap(), + venueForPlaneAuthor = { + asked++ + null + }, + ) + + assertEquals(1, asked) + } + @Test fun postingIntoAConcordChannelIsAVenuePostAndNotADmToItsThrowawayPTag() { // A Concord plane wrap is kind 1059 signed by the plane's stream key and `p`-tagged to a fresh diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt index 2836f837fb..6b423aa049 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenueCoverageTest.kt @@ -63,7 +63,7 @@ class RelayAuthVenueCoverageTest { store = NoStore(), globalPolicy = { RelayAuthPolicy.CUSTOM }, customToggles = { toggles }, - isTrustedVenue = { it == joinedGroupId || it == joinedCommunityId }, + isTrustedVenue = { _, venueId -> venueId == joinedGroupId || venueId == joinedCommunityId }, isVenueHostRelay = { it == groupRelay || it == concordRelay }, ) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt index c455132cfe..a20a331b94 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt @@ -65,16 +65,6 @@ class RelayAuthVenuesTest { assertEquals(setOf(RelayUrlNormalizer.normalize("wss://relay.dreamith.to")), relays) } - @Test - fun aJoinedBuzzWorkspaceIsItsOwnHostRelay() { - // Buzz membership is granted server-side by an HTTP invite claim — there is no list event to - // read it back from, so the joined relay url is the whole record of the room. - val workspace = RelayUrlNormalizer.normalize("wss://block.buzz") - val relays = RelayAuthVenues.hostRelays(emptySet(), emptyList(), setOf(workspace)) - - assertEquals(setOf(workspace), relays) - } - @Test fun anUnparseableRelayIsDroppedRatherThanFailingTheWholeList() { val relays = RelayAuthVenues.hostRelays(joinedGroups, listOf(community(listOf("not a url at all")))) @@ -86,15 +76,36 @@ class RelayAuthVenuesTest { fun aJoinedGroupIdAndCommunityIdAreBothJoinedRooms() { val communities = listOf(community(listOf("wss://relay.dreamith.to"))) - assertTrue(RelayAuthVenues.isJoinedRoom("abcd1234", joinedGroups, communities)) - assertTrue(RelayAuthVenues.isJoinedRoom(communityId, joinedGroups, communities)) + assertTrue(RelayAuthVenues.isJoinedRoom("abcd1234", groupHost, joinedGroups, communities)) + assertTrue(RelayAuthVenues.isJoinedRoom(communityId, groupHost, joinedGroups, communities)) } @Test fun aRoomIHaveNotJoinedIsNotAJoinedRoom() { val communities = listOf(community(listOf("wss://relay.dreamith.to"))) - assertFalse(RelayAuthVenues.isJoinedRoom("someoneElsesGroup", joinedGroups, communities)) - assertFalse(RelayAuthVenues.isJoinedRoom("d".repeat(64), joinedGroups, communities)) + assertFalse(RelayAuthVenues.isJoinedRoom("someoneElsesGroup", groupHost, joinedGroups, communities)) + assertFalse(RelayAuthVenues.isJoinedRoom("d".repeat(64), groupHost, joinedGroups, communities)) + } + + @Test + fun aGroupIdOnlyCountsOnTheRelayIJoinedItOn() { + // NIP-29 ids are relay-scoped and often generic — `_` is the spec's relay-wide group — so an + // id-only match would let a group merely browsed on some other relay pass for one I joined, + // and hand that relay an automatic login. + val elsewhere = RelayUrlNormalizer.normalize("wss://other.example.com") + + assertFalse(RelayAuthVenues.isJoinedRoom("abcd1234", elsewhere, joinedGroups, emptyList())) + assertFalse(RelayAuthVenues.isJoinedRoom("abcd1234", null, joinedGroups, emptyList())) + } + + @Test + fun aCommunityIdCountsWhereverItIsServed() { + // Unlike a group id, a Concord community id is a 64-hex derived value that names exactly one + // community anywhere — and a community's own relays are covered by hostRelays regardless. + val elsewhere = RelayUrlNormalizer.normalize("wss://other.example.com") + val communities = listOf(community(listOf("wss://relay.dreamith.to"))) + + assertTrue(RelayAuthVenues.isJoinedRoom(communityId, elsewhere, emptySet(), communities)) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 58f6156b89..ba91cfb8e4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -277,6 +277,22 @@ class ConcordCommunitySession( */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys + historicalChannelKeysByAddress.keys } + /** + * True when [address] is one of this community's plane addresses — Control (current or a prior + * epoch we still hold), Guestbook, next-epoch rekey, or any folded Chat Plane. + * + * A membership test rather than a set to iterate, because the caller is the NIP-42 auth path + * asking "whose room is this wrap for?" on every challenge: answering that from + * [channelAddresses] + [historicalControlPlaneAddresses] allocates a fresh union per community + * per question, where four map lookups do. + */ + fun ownsPlane(address: HexKey): Boolean = + address == controlPlaneAddress || + address == guestbookAddress || + address == nextBaseRekeyAddress || + address in historicalControlKeys || + lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } + /** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */ fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 0c022651eb..22c7dd47de 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -118,14 +118,7 @@ class ConcordSessionRegistry( */ fun communityIdForPlane(planeAddress: HexKey): HexKey? = lock.withLock { - sessions.entries - .firstOrNull { (_, session) -> - planeAddress == session.controlPlaneAddress || - planeAddress == session.guestbookAddress || - planeAddress == session.nextBaseRekeyAddress || - planeAddress in session.historicalControlPlaneAddresses() || - planeAddress in session.channelAddresses() - }?.key + sessions.entries.firstOrNull { (_, session) -> session.ownsPlane(planeAddress) }?.key } /**