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..458d7c7b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -147,6 +147,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 @@ -424,11 +425,47 @@ 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(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 *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. + * + * 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. + */ + fun venueHostRelays(): Set = + RelayAuthVenues.hostRelays( + joinedGroups = relayGroupList.liveRelayGroupIds.value, + joinedCommunities = concordChannelList.liveCommunities.value, + ) + + /** + * 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( + relayUrl: String, + venueId: String, + ): Boolean = + RelayAuthVenues.isJoinedRoom( + venueId = venueId, + relayUrl = relayUrl.normalizeRelayUrlOrNull(), + 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/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 999860ab29..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 @@ -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,48 @@ 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. + * + * 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( venueId: String, kind: AuthPurposeKind, + 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) { - 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) { @@ -518,6 +535,30 @@ private fun rememberVenueLabel( 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/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..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 @@ -37,6 +37,14 @@ 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. [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, @@ -45,7 +53,8 @@ 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 }, ) { /** * The authorization verdict for [ctx], taking the challenge's purpose into account. @@ -59,6 +68,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 +85,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(ctx.relayUrl, it) } + }, // Reading a followed author's outbox. servesFollowedReadCounterparty = ctx.purposes.any { p -> @@ -82,18 +101,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..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 @@ -33,6 +36,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 @@ -41,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]). @@ -48,10 +64,19 @@ 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 (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]). * + * 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 +91,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 +108,12 @@ 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 } + // 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) 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..f6605d7184 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenues.kt @@ -0,0 +1,81 @@ +/* + * 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) and Concord communities (the kind-13302 list). + * + * 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 { + /** + * 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. + */ + fun hostRelays( + joinedGroups: Set, + joinedCommunities: List, + ): Set = + buildSet { + joinedGroups.mapTo(this) { it.relayUrl } + joinedCommunities.forEach { entry -> + entry.relays.forEach { url -> RelayUrlNormalizer.normalizeOrNull(url)?.let(::add) } + } + } + + /** + * 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 = + (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/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..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 @@ -229,6 +229,114 @@ 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 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 + // 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..6b423aa049 --- /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 = { _, venueId -> venueId == joinedGroupId || venueId == 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..a20a331b94 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/RelayAuthVenuesTest.kt @@ -0,0 +1,111 @@ +/* + * 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 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", 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", 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/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..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 @@ -108,6 +108,19 @@ 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) -> session.ownsPlane(planeAddress) }?.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).