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 68df7e7ed0..941368763b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -26,6 +26,8 @@ import com.vitorpamplona.amethyst.BuildConfig import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlane +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanner import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle @@ -654,12 +656,16 @@ class Account( val state = session.state.value ?: continue val communityId = session.entry.id val relays = relaysByCommunity[communityId] ?: emptySet() + // Every folded channel, openable or not: a channel we hold no key for is hidden from the + // display surfaces, but its object still learns the truth so arriving at one anyway (a + // stale route) explains itself instead of offering a composer that can't publish. + val openable = session.openableChannelIds().toSet() for (channelIdHex in state.channels.keys) { val channel = cache.getOrCreateConcordChannel(ConcordChannelId(communityId, channelIdHex)) // Invalidate the channel's metadata flow only on a real change so the Messages-row // name + community chip recompose when the fold first resolves them (they observe // metadata.stateFlow via observeChannel), without churning every row every tick. - if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo() + if (channel.updateFrom(state, relays, myPubKey, channelIdHex in openable)) channel.updateChannelInfo() channel.notes .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } .forEach { channel.removeNote(it) } @@ -2354,6 +2360,26 @@ class Account( return ConcordInviteResult.Joined(bundle.communityId) } + /** + * The community entry plus the plane a message we author on [channelIdHex] must ride, or null when + * we may not write there at all. + * + * Which secret addresses the channel is [ConcordChannelPlanner]'s call: the shared community root + * for a public channel, the per-member key delivered in our own kind-13302 bundle for a private + * one. Null means the community isn't joined/folded, or it is a private channel we were never + * granted — and in that case publishing anyway is not a graceful degradation. Deriving from the + * root would put private traffic on the plane every member of the community can read and write, + * which is precisely the bug this replaced. + */ + private fun concordWritePlane( + communityId: String, + channelIdHex: String, + ): Pair? { + val session = concordSessions.sessionFor(communityId) ?: return null + val plane = ConcordChannelPlanner.writePlane(session.entry, session.state.value, channelIdHex) ?: return null + return session.entry to plane + } + /** * Post [text] to a Concord channel: derive the channel plane key, build an * encrypted-seal kind-1059 wrap authored by that plane key (not our identity), @@ -2371,9 +2397,8 @@ class Account( imetas: List = emptyList(), ): Boolean { if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false + val channelKey = plane.key // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). @@ -2386,13 +2411,13 @@ class Account( // the user attached media); an inline reply is a kind-9 message quoting the parent; a // fresh post is a plain kind-9 message. parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelImageReply(signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags) parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelReply(signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) parent != null -> - ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelInlineReply(signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) else -> - ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelMessage(signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags) } trackConcordDelivery(entry, channelKey, wrap) publishConcordWrap(entry, wrap) @@ -2413,12 +2438,11 @@ class Account( ): Boolean { if (imetas.isEmpty()) return sendConcordChannelMessage(communityId, channelIdHex, text) if (!isWriteable()) return false - val session = concordSessions.sessionFor(communityId) ?: return false - val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false + val channelKey = plane.key // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. val emojiTags = emoji.findEmojiTags(text).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelImageMessage(signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags) trackConcordDelivery(entry, channelKey, wrap) publishConcordWrap(entry, wrap) return true @@ -2555,13 +2579,12 @@ class Account( val target = note.event ?: return false val communityId = channel.channelId.communityId val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false + val channelKey = plane.key // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. val emojiTags = emoji.findEmojiTags(reaction).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelReaction(signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } @@ -2587,12 +2610,11 @@ class Account( val communityId = channel.channelId.communityId val channelIdHex = channel.channelId.channelId - val entry = concordSessions.sessionFor(communityId)?.entry ?: return false - - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return false + val channelKey = plane.key // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. val emojiTags = emoji.findEmojiTags(newText).map { it.toTagArray() }.toTypedArray() - val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelEdit(signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } @@ -2607,9 +2629,8 @@ class Account( channelIdHex: String, ) { if (!isWriteable()) return - val entry = concordSessions.sessionFor(communityId)?.entry ?: return - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelTyping(signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val (entry, plane) = concordWritePlane(communityId, channelIdHex) ?: return + val wrap = ConcordActions.buildChannelTyping(signer, plane.key, channelIdHex, plane.epoch, TimeUtils.now()) val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } if (relays.isNotEmpty()) client.publish(wrap, relays) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 532b172116..1fe3ba646f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -307,12 +307,14 @@ fun ConcordChannelListScreen( } }, ) { padding -> + // Only channels this account can open. A private channel whose per-member key was never + // delivered to us has no derivable plane (CORD-03 §1), so its row would open a room that can + // never load — the reference client omits it for the same reason. val channels = - state - ?.channels - ?.entries - ?.toList() - .orEmpty() + remember(state, session, revision) { + val folded = state?.channels.orEmpty() + session?.openableChannelIds().orEmpty().mapNotNull { id -> folded[id]?.let { id to it } } + } if (channels.isEmpty()) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text( @@ -331,9 +333,9 @@ fun ConcordChannelListScreen( Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(bottom = if (canManageChannels) FAB_CLEARANCE else 0.dp), ) { - items(channels, key = { it.key }) { entry -> - val def = entry.value.definition - val name = def.name.ifBlank { entry.key } + items(channels, key = { it.first }) { entry -> + val def = entry.second.definition + val name = def.name.ifBlank { entry.first } val icon = when { def.voice == true -> MaterialSymbols.Mic @@ -341,24 +343,24 @@ fun ConcordChannelListScreen( else -> MaterialSymbols.Tag } val typingAuthors = - remember(typingMap, typingNow, entry.key) { - (typingMap[entry.key] ?: emptyMap()) + remember(typingMap, typingNow, entry.first) { + (typingMap[entry.first] ?: emptyMap()) .filterValues { typingNow - it <= ConcordCommunitySession.TYPING_STALE_SECS } .keys .sorted() } ConcordChannelListRow( communityId = communityId, - channelKey = entry.key, + channelKey = entry.first, channelName = name, icon = icon, isVoice = def.voice == true, typingAuthors = typingAuthors, canManageChannels = canManageChannels, accountViewModel = accountViewModel, - onClick = { nav.nav(Route.Concord(communityId, entry.key)) }, - onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, - onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.key, initialName = name) }, + onClick = { nav.nav(Route.Concord(communityId, entry.first)) }, + onRename = { channelEditor = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) }, + onDelete = { channelToDelete = ConcordChannelEditor(channelIdHex = entry.first, initialName = name) }, ) HorizontalDivider(thickness = 0.25.dp, color = MaterialTheme.colorScheme.outlineVariant) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt index af44f5e0ca..a4bbec20ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/dal/ChatroomListKnownFeedFilter.kt @@ -191,8 +191,9 @@ class ChatroomListKnownFeedFilter( when (account.settings.concordViewMode.value) { ConcordViewMode.INLINE -> account.concordSessions.sessions().flatMap { session -> - val state = session.state.value ?: return@flatMap emptyList() - state.channels.keys.map { channelIdHex -> + // Openable channels only: a private channel whose key was never delivered to + // us has no derivable plane, so a row for it could never fill or be posted to. + session.openableChannelIds().map { channelIdHex -> val channel = LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, channelIdHex)) channel.newestConcordNote(account) ?: channel.placeholderNote() } @@ -201,9 +202,10 @@ class ChatroomListKnownFeedFilter( ConcordViewMode.GROUPED -> // One row per joined community, carrying the newest message across ALL its channels. account.concordSessions.sessions().mapNotNull { session -> - val state = session.state.value ?: return@mapNotNull null + if (session.state.value == null) return@mapNotNull null val newest = - state.channels.keys + session + .openableChannelIds() .mapNotNull { LocalCache.getOrCreateConcordChannel(ConcordChannelId(session.entry.id, it)).newestConcordNote(account) } .maxByOrNull { it.createdAt() ?: 0L } ConcordServerRoomNote(session.entry.id, newest) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index fa23a7ee33..dcf224e512 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.Guestbook import com.vitorpamplona.quartz.concord.cord02Community.GuestbookAction import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry -import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat @@ -66,16 +65,6 @@ data class ConcordChatMessage( val epoch: Long, ) -/** - * One channel's Chat Plane at a prior epoch: the epoch-invariant [channelIdHex], the [epoch] the - * wraps are bound to (for `isBoundTo` validation), and the derived [key] to decrypt them. - */ -data class HistoricalChannelPlane( - val channelIdHex: HexKey, - val epoch: Long, - val key: GroupKey, -) - /** * Concord community verbs — pure builders, plane-key derivation, relay-filter * assembly, and event folding usable from amy CLI, the Android app, and any other @@ -95,12 +84,29 @@ object ConcordActions { rootEpoch: Long, ): GroupKey = ConcordKeyDerivation.controlPlaneKey(communityRoot, communityId, rootEpoch) + /** + * A **public** channel's Chat Plane: keyed by the shared `community_root`, so every member + * derives it with no key distribution. Never use this for a `private: true` channel — see + * [privateChannel] — and prefer [ConcordChannelPlanner], which picks between the two. + */ fun publicChannel( communityRoot: ByteArray, channelId: ByteArray, rootEpoch: Long, ): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + /** + * A **private** channel's Chat Plane: keyed by the per-member `channel_key` delivered in the + * kind-13302 entry ([ConcordCommunityListEntry.privateChannels]) at that channel's own epoch + * (CORD-03 §1). Only members granted the key can derive the address at all, which is the whole + * of the private-channel ACL — there is no server to enforce one. + */ + fun privateChannel( + channelKey: ByteArray, + channelId: ByteArray, + channelEpoch: Long, + ): GroupKey = ConcordChannelKeys.privateChannel(channelKey, channelId, channelEpoch) + /** * How many prior epochs of channel history to backfill. A CORD-06 Refounding rotates the * `community_root` and bumps the epoch, so pre-refounding messages live under a *different* @@ -112,25 +118,6 @@ object ConcordActions { */ const val MAX_BACKFILL_EPOCHS = 8 - /** - * The historical Chat Plane keys for [channelIdsHex] across the prior epochs in [heldRoots] - * (newest-held first, bounded to [MAX_BACKFILL_EPOCHS]). The channel id is epoch-invariant, so a - * message decrypted under a held root lands in the same channel as the current-epoch ones. - */ - fun historicalChannelPlanes( - heldRoots: List, - channelIdsHex: Collection, - ): List = - heldRoots - .sortedByDescending { it.epoch } - .take(MAX_BACKFILL_EPOCHS) - .flatMap { held -> - val rootBytes = held.key.hexToByteArray() - channelIdsHex.map { channelIdHex -> - HistoricalChannelPlane(channelIdHex, held.epoch, publicChannel(rootBytes, channelIdHex.hexToByteArray(), held.epoch)) - } - } - /** The Guestbook Plane address for a community at [rootEpoch] — where join/leave motions ride. */ fun guestbookPlane( communityRoot: ByteArray, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordChannelPlanes.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordChannelPlanes.kt new file mode 100644 index 0000000000..a59b024f8b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordChannelPlanes.kt @@ -0,0 +1,188 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray + +/** + * One address a channel's traffic rides at, together with the [epoch] its wraps are bound to. + * + * The epoch travels *with* the address rather than being taken from the community: a public + * channel's plane is keyed by the `community_root` at the **root** epoch, while a private + * channel's is keyed by its own delivered key at its **own** channel epoch (CORD-03 §1). Binding + * a private channel's wraps against the root epoch would reject every one of them. + */ +data class ConcordChannelPlane( + val channelIdHex: HexKey, + val epoch: Long, + val key: GroupKey, +) + +/** + * Every plane coordinate of one channel **this account can actually open**. + * + * [reads] is what to subscribe and decrypt, newest epoch first, and always contains [write]. + * A public channel reads across every held root epoch, so history survives a CORD-06 Refounding; + * a private channel has exactly one, because a per-channel rekey replaces its delivered key + * rather than accumulating epochs. + */ +data class ConcordChannelPlanes( + val channelIdHex: HexKey, + val isPrivate: Boolean, + /** Where our own new messages are published, and the epoch to stamp them with. */ + val write: ConcordChannelPlane, + /** Every address to watch, [write] first. */ + val reads: List, +) + +/** + * Resolves a community's channels into the plane coordinates this account can use — the single + * place that decides which secret addresses a channel. + * + * It exists because that decision was previously made independently at six call sites (the plane + * registry, the subscription planner, the session's re-fold, and three send paths), and every one + * of them derived from the `community_root`. For a `private: true` channel that is the **wrong + * secret**: its plane is keyed by a per-member key delivered in the kind-13302 bundle + * ([ConcordCommunityListEntry.privateChannels]), so deriving from the root put private traffic on + * an address every member of the community could read and write — and, in the other direction, + * made a correctly-addressed private channel look permanently empty. + * + * A channel whose key we do not hold is **omitted entirely**, matching the reference client: the + * room cannot be opened, and offering a row that opens nothing is worse than not offering it. + */ +object ConcordChannelPlanner { + /** + * Every channel of [state] this account can open, in fold order, followed by any private + * channel whose key we hold but whose definition has not folded yet (a fresh join sees its + * private rooms before the Control Plane catches up; the fold's name wins once it lands). + * + * A `null` [state] means the Control Plane has not folded, so only bundle-held private + * channels are known. + */ + fun channelPlanes( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState?, + ): List { + val privateKeys = privateKeysOf(entry) + val folded = state?.channels.orEmpty() + val out = ArrayList(folded.size + privateKeys.size) + + for ((channelIdHex, channel) in folded) { + val planes = + if (channel.definition.private) { + privateKeys[channelIdHex]?.let { privatePlanes(channelIdHex, it) } + } else { + publicPlanes(entry, channelIdHex) + } + planes?.let { out.add(it) } + } + for ((channelIdHex, held) in privateKeys) { + if (channelIdHex in folded) continue + out.add(privatePlanes(channelIdHex, held)) + } + return out + } + + /** + * The coordinates of one channel, or null when this account cannot open it — an unheld private + * channel, or a channel id absent from both the fold and the bundle. + * + * Derives only the requested channel's keys, so a send path doesn't pay for the whole community. + */ + fun channelPlanesFor( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState?, + channelIdHex: HexKey, + ): ConcordChannelPlanes? { + val definition = state?.channels?.get(channelIdHex)?.definition + val held = privateKeysOf(entry)[channelIdHex] + return when { + // Known private, either from the fold or from the bundle alone (fold still catching up). + definition?.private == true || (definition == null && held != null) -> + held?.let { privatePlanes(channelIdHex, it) } + definition != null -> publicPlanes(entry, channelIdHex) + else -> null + } + } + + /** Where a new message on [channelIdHex] must be published, or null if we may not write there. */ + fun writePlane( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState?, + channelIdHex: HexKey, + ): ConcordChannelPlane? = channelPlanesFor(entry, state, channelIdHex)?.write + + /** + * The bundle's delivered private-channel keys by channel id. Last wins: the bundle carries one + * key per private channel, since a per-channel rekey replaces the entry instead of appending an + * epoch — which is also why a private channel has a single read plane. + */ + private fun privateKeysOf(entry: ConcordCommunityListEntry): Map = entry.privateChannels.associateBy { it.channelId } + + private fun privatePlanes( + channelIdHex: HexKey, + held: PrivateChannelKey, + ): ConcordChannelPlanes { + val plane = + ConcordChannelPlane( + channelIdHex, + held.epoch, + ConcordActions.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch), + ) + return ConcordChannelPlanes(channelIdHex, isPrivate = true, write = plane, reads = listOf(plane)) + } + + private fun publicPlanes( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): ConcordChannelPlanes { + val idBytes = channelIdHex.hexToByteArray() + val write = + ConcordChannelPlane( + channelIdHex, + entry.rootEpoch, + ConcordActions.publicChannel(entry.root.hexToByteArray(), idBytes, entry.rootEpoch), + ) + // Prior epochs the account still holds a root for: a Refounding rotates the root, so + // pre-refounding history lives on a different address per epoch. Bounded — every covered + // epoch multiplies the subscription + AUTH footprint by the channel count. + val older = + entry.heldRoots + .sortedByDescending { it.epoch } + .take(ConcordActions.MAX_BACKFILL_EPOCHS) + .map { held -> + ConcordChannelPlane( + channelIdHex, + held.epoch, + ConcordActions.publicChannel(held.key.hexToByteArray(), idBytes, held.epoch), + ) + } + // heldRoots may or may not include the current root depending on how the entry was written + // (stranded recovery folds it in), so dedupe by address and keep the write plane first. + val reads = (listOf(write) + older).distinctBy { it.key.publicKeyHex } + return ConcordChannelPlanes(channelIdHex, isPrivate = false, write = write, reads = reads) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 9a210a8c95..b05228e06e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -102,36 +102,30 @@ object ConcordSubscriptionPlanner { } /** - * Chat-plane subscriptions for every live channel in a folded community [state] — at the current - * epoch, plus each channel's plane at every prior epoch the account still holds a root for - * ([ConcordCommunityListEntry.heldRoots]). A CORD-06 Refounding rotates the root per epoch, so the - * pre-refounding history lives under those prior-epoch planes; subscribing to them is what lets the - * client fetch messages older than the last Refounding instead of stopping at "All caught up". + * Chat-plane subscriptions for every channel of a folded community [state] this account can open + * — each channel's current address plus, for a public channel, its plane at every prior epoch the + * account still holds a root for ([ConcordCommunityListEntry.heldRoots]). A CORD-06 Refounding + * rotates the root per epoch, so the pre-refounding history lives under those prior-epoch planes; + * subscribing to them is what lets the client fetch messages older than the last Refounding + * instead of stopping at "All caught up". + * + * Private channels are addressed by their own delivered key, and one whose key we don't hold is + * not subscribed at all — see [ConcordChannelPlanner]. */ fun channelPlaneSubs( entry: ConcordCommunityListEntry, state: ConcordCommunityState, ): List { - val root = entry.root.hexToByteArray() val relays = normalize(entry.relays) - val current = - state.channels.keys.map { channelIdHex -> - val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - ConcordPlaneSub( - channelId = ConcordChannelId(entry.id, channelIdHex), - pubKeyHex = ch.publicKeyHex, - relays = relays, - ) - } - val historical = - ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane -> + return ConcordChannelPlanner.channelPlanes(entry, state).flatMap { channel -> + channel.reads.map { plane -> ConcordPlaneSub( channelId = ConcordChannelId(entry.id, plane.channelIdHex), pubKeyHex = plane.key.publicKeyHex, relays = relays, ) } - return current + historical + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt index 90b4ee9960..80c8cd189d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGate.kt @@ -63,9 +63,12 @@ sealed interface PostingGate { data object Dissolved : Blocked /** - * We cannot place this account in the community at all — no key, no role. Distinct from - * [NotAMember] in that there is no roster to join and no relay to ask: in Concord, holding the - * key *is* membership. + * We hold no key for what we are looking at — either the community itself (no key, no role) or + * this specific private channel, whose per-member key was never delivered to us (CORD-03 §1). + * + * Distinct from [NotAMember] in that there is no roster to join and no relay to ask: in Concord, + * holding the key *is* membership. Normally unreachable, because a channel we cannot derive is + * not displayed at all — this is the backstop for arriving at one anyway. */ data object NoKey : Blocked diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index c1d10b503f..f3cf44783c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -79,6 +79,18 @@ class ConcordChannel( var membership: ConcordMembership = ConcordMembership.MEMBER private set + /** + * Whether this account holds the key that addresses **this channel's** plane. Always true for a + * public channel (the community root derives it); for a private channel it means the per-member + * key was delivered in our kind-13302 bundle (CORD-03 §1). + * + * Optimistic until a fold says otherwise, matching [membership] — a channel we can't open is + * normally never displayed at all, so this is the backstop for arriving at one anyway (a stale + * route, a link) rather than the primary gate. + */ + var holdsKey: Boolean = true + private set + /** * True once the community has been dissolved by an owner-signed tombstone (CORD-02 §9). On sight * the client seals the Community read-only: held keys still open history, but nothing new is @@ -111,6 +123,7 @@ class ConcordChannel( state: ConcordCommunityState, relays: Set, myPubKey: HexKey, + holdsChannelKey: Boolean = true, ): Boolean { val def = state.channels[channelId.channelId]?.definition // Channel fields keep their prior value until the channel edition folds. @@ -131,6 +144,7 @@ class ConcordChannel( communityIcon != newCommunityIcon || communityBanner != newCommunityBanner || membership != newMembership || + holdsKey != holdsChannelKey || dissolved != newDissolved channelName = newChannelName @@ -141,6 +155,7 @@ class ConcordChannel( communityBanner = newCommunityBanner communityRelays = relays membership = newMembership + holdsKey = holdsChannelKey dissolved = newDissolved return changed } @@ -162,8 +177,10 @@ class ConcordChannel( when { dissolved -> PostingGate.Dissolved membership == ConcordMembership.BANNED -> PostingGate.Banned - // NONE — no key and no role, so there is nothing to place this account by. - !membership.isMember() -> PostingGate.NoKey + // Either we can't be placed in the community at all (NONE — no key, no role), or this is + // a private channel whose per-member key we were never granted. Both mean the plane we + // would publish to isn't ours to derive. + !membership.isMember() || !holdsKey -> PostingGate.NoKey else -> PostingGate.Allowed } 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 49acfd9b1e..4ed3352186 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 @@ -21,6 +21,9 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlane +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanes +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanner import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -178,14 +181,22 @@ class ConcordCommunitySession( private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() - // channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold. - private var channelKeysByAddress = HashMap>() + // channel plane pubkey -> the channel's CURRENT write/read plane, refreshed on each control + // re-fold. The epoch rides along rather than being read off the community: a private channel is + // bound to its own channel epoch, not the root epoch (CORD-03 §1), so taking the epoch from the + // entry would reject every private wrap on arrival. + private var channelKeysByAddress = HashMap() // Prior-epoch channel plane pubkey -> (channelIdHex, key, epoch), for pre-Refounding history. // A CORD-06 Refounding rotates the root per epoch, so older messages live under a different // plane per held root; we re-derive those here so historical wraps are subscribed, AUTHed, and // decrypted alongside the current epoch. Empty when the account holds no prior roots. - private var historicalChannelKeysByAddress = HashMap>() + private var historicalChannelKeysByAddress = HashMap() + + // Every channel of the current fold this account can open, in fold order. A private channel whose + // per-member key was never delivered to us is absent: its plane is not derivable, so there is + // nothing to subscribe, decrypt or publish — and nothing to show. + private var openableChannels = listOf() private val _state = MutableStateFlow(null) val state: StateFlow = _state @@ -254,8 +265,17 @@ class ConcordCommunitySession( */ fun channelAddresses(): Set = lock.withLock { channelKeysByAddress.keys + historicalChannelKeysByAddress.keys } + /** + * The channel ids this account can actually open, in fold order — every folded channel except a + * private one whose key we hold no bundle entry for. + * + * Display surfaces iterate this instead of `state.channels`: a channel we cannot derive shows an + * unopenable room and (before the plane resolver existed) invited a post onto the wrong plane. + */ + fun openableChannelIds(): List = lock.withLock { openableChannels.map { it.channelIdHex } } + /** 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 } + fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key } /** * Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each @@ -266,8 +286,8 @@ class ConcordCommunitySession( */ fun channelPlaneAddressesAllEpochs(channelIdHex: HexKey): List = lock.withLock { - val current = channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key - val historical = historicalChannelKeysByAddress.entries.filter { it.value.first == channelIdHex }.map { it.key } + val current = channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key + val historical = historicalChannelKeysByAddress.entries.filter { it.value.channelIdHex == channelIdHex }.map { it.key } (listOfNotNull(current) + historical) } @@ -296,9 +316,9 @@ class ConcordCommunitySession( // Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the // gated relays must serve their wraps too. historicalControlKeys.values.map { it.first } + - channelKeysByAddress.values.map { it.second } + + channelKeysByAddress.values.map { it.key } + // Prior-epoch channel stream keys so the gated relays serve their older wraps too. - historicalChannelKeysByAddress.values.map { it.second } + historicalChannelKeysByAddress.values.map { it.key } } /** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */ @@ -370,15 +390,13 @@ class ConcordCommunitySession( } val current = lock.withLock { channelKeysByAddress[wrap.pubKey] } if (current != null) { - val (channelIdHex, key) = current - return ingestChannelWrap(wrap, channelIdHex, key, entry.rootEpoch, seenOnRelays) + return ingestChannelWrap(wrap, current.channelIdHex, current.key, current.epoch, seenOnRelays) } // A prior-epoch plane (pre-Refounding history). Decrypt with that epoch's key and // bind-check against that epoch. Keyed separately from the current buffer so a re-fold // (which rebuilds only the current-epoch keys) never re-projects the historical ones. val historical = lock.withLock { historicalChannelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE - val (channelIdHex, key, epoch) = historical - return ingestChannelWrap(wrap, channelIdHex, key, epoch, seenOnRelays) + return ingestChannelWrap(wrap, historical.channelIdHex, historical.key, historical.epoch, seenOnRelays) } } } @@ -452,25 +470,31 @@ class ConcordCommunitySession( controlFloorsLocked(), ) - val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first } - val next = HashMap>() - for (channelIdHex in folded.channels.keys) { - val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - next[key.publicKeyHex] = channelIdHex to key + val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.channelIdHex } + + // One resolver decides which secret addresses each channel — the root for a public + // channel, the bundle's delivered key for a private one — and drops any private + // channel we hold no key for. Channels are known only after a fold, hence here. + val planes = ConcordChannelPlanner.channelPlanes(entry, folded) + openableChannels = planes + val next = HashMap() + val historical = HashMap() + for (channel in planes) { + next[channel.write.key.publicKeyHex] = channel.write + // Prior-epoch planes hold pre-Refounding history: subscribed, AUTHed and decrypted + // alongside the current one, but kept apart so a re-fold (which rebuilds only the + // current-epoch keys) never re-projects them. + for (plane in channel.reads) { + if (plane.key.publicKeyHex != channel.write.key.publicKeyHex) { + historical[plane.key.publicKeyHex] = plane + } + } } channelKeysByAddress = next - - // Re-derive the prior-epoch planes for the same (epoch-invariant) channel ids, so older - // pre-Refounding history is subscribed/AUTHed/decrypted. Channels are known only after a - // fold, hence derived here rather than up front. - val historical = HashMap>() - for (plane in ConcordActions.historicalChannelPlanes(entry.heldRoots, folded.channels.keys)) { - historical[plane.key.publicKeyHex] = Triple(plane.channelIdHex, plane.key, plane.epoch) - } historicalChannelKeysByAddress = historical _state.value = folded - folded.channels.keys.filterNot { it in prevChannels } + planes.map { it.channelIdHex }.filterNot { it in prevChannels } } // Project only channels appearing for the first time. Existing channels' wraps were already @@ -533,9 +557,9 @@ class ConcordCommunitySession( * re-fold (keys may change). Prior-epoch wraps in the buffer simply won't open under the current * key and are skipped — they were already emitted when they landed (the sink dedups by id). */ private fun reprojectChannel(channelIdHex: HexKey) { - val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return + val plane = lock.withLock { channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex } } ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return - emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps) + emitChannelRumors(channelIdHex, plane.key, plane.epoch, wraps) } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt index 86699776e7..627acb6780 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.concord +import com.vitorpamplona.amethyst.commons.actions.ConcordChannelPlanner import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -81,17 +82,23 @@ class ConcordPlaneRegistry { } } - /** Registers the Chat Plane address of every channel in a folded community [state]. */ + /** + * Registers every Chat Plane address of every channel in a folded community [state] this account + * can open — across held epochs for a public channel, and at its own epoch for a private one. + * + * A private channel whose key we hold no bundle entry for registers **nothing**: the address is + * not derivable from the community root, and deriving it from the root anyway (as this used to) + * put private traffic on a plane every member could read and write. + */ fun registerChannels( entry: ConcordCommunityListEntry, state: ConcordCommunityState, ) = lock.withLock { - val root = entry.root.hexToByteArray() - for (channelIdHex in state.channels.keys) { - val ch = - com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys - .publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch) + for (channel in ConcordChannelPlanner.channelPlanes(entry, state)) { + for (plane in channel.reads) { + planes[plane.key.publicKeyHex] = + ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channel.channelIdHex), plane.key) + } } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordChannelPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordChannelPlannerTest.kt new file mode 100644 index 0000000000..7fcb2b942d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordChannelPlannerTest.kt @@ -0,0 +1,199 @@ +/* + * 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.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Which secret addresses a Concord channel (CORD-03 §1). + * + * The bug this pins down: every call site used to derive *every* channel's plane from the shared + * `community_root`, including `private: true` ones. That put private traffic on an address any member + * of the community could derive — so a private channel was readable and postable by people who were + * never granted its key — and, symmetrically, made a correctly-addressed private channel from another + * client look permanently empty. + */ +class ConcordChannelPlannerTest { + private val owner = "0f".repeat(32) + private val communityId = "1c".repeat(32) + private val root = "aa".repeat(32) + private val priorRoot = "ab".repeat(32) + private val publicChannelId = "c1".repeat(32) + private val privateChannelId = "c2".repeat(32) + private val privateKey = "77".repeat(32) + private val rootEpoch = 3L + private val privateEpoch = 5L + + private fun ed( + kind: ControlEntityKind, + eid: String, + content: String, + ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, owner, "r-$eid", 0) + + /** A fold with one public channel and one private channel. */ + private fun state(): ConcordCommunityState = + ConcordCommunityState.fold( + listOf( + ed(ControlEntityKind.CHANNEL, publicChannelId, """{"name":"general"}"""), + ed(ControlEntityKind.CHANNEL, privateChannelId, """{"name":"mods","private":true}"""), + ), + owner, + ) + + private fun entry( + privateChannels: List = emptyList(), + heldRoots: List = emptyList(), + ) = ConcordCommunityListEntry( + id = communityId, + owner = owner, + ownerSalt = "01".repeat(32), + root = root, + rootEpoch = rootEpoch, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = listOf("wss://r.example"), + ) + + private val heldPrivate = PrivateChannelKey(privateChannelId, privateKey, privateEpoch, "mods") + + @Test + fun publicChannelIsAddressedByTheCommunityRootAtTheRootEpoch() { + val planes = ConcordChannelPlanner.channelPlanesFor(entry(), state(), publicChannelId)!! + assertEquals(false, planes.isPrivate) + assertEquals(rootEpoch, planes.write.epoch) + assertEquals( + ConcordActions.publicChannel(root.hexToByteArray(), publicChannelId.hexToByteArray(), rootEpoch).publicKeyHex, + planes.write.key.publicKeyHex, + ) + } + + @Test + fun privateChannelIsAddressedByItsOwnDeliveredKeyAtItsOwnEpoch() { + val planes = ConcordChannelPlanner.channelPlanesFor(entry(privateChannels = listOf(heldPrivate)), state(), privateChannelId)!! + + assertEquals(true, planes.isPrivate) + // Its own epoch, not the community's — the wraps are bound to it, so taking the root epoch + // here would make every private message fail its bind check on arrival. + assertEquals(privateEpoch, planes.write.epoch) + assertEquals( + ConcordActions.privateChannel(privateKey.hexToByteArray(), privateChannelId.hexToByteArray(), privateEpoch).publicKeyHex, + planes.write.key.publicKeyHex, + ) + // The regression: the root-derived address is a DIFFERENT plane, and must not be used. + assertNotEquals( + ConcordActions.publicChannel(root.hexToByteArray(), privateChannelId.hexToByteArray(), rootEpoch).publicKeyHex, + planes.write.key.publicKeyHex, + ) + } + + @Test + fun aPrivateChannelWeHoldNoKeyForIsOmittedEntirely() { + val entry = entry() // community member, but never granted the private channel's key + val planes = ConcordChannelPlanner.channelPlanes(entry, state()) + + assertEquals(listOf(publicChannelId), planes.map { it.channelIdHex }) + assertNull(ConcordChannelPlanner.channelPlanesFor(entry, state(), privateChannelId)) + // The important half: no write coordinate at all, so no send path can fall back to the root. + assertNull(ConcordChannelPlanner.writePlane(entry, state(), privateChannelId)) + } + + @Test + fun holdingTheKeyMakesThePrivateChannelAppear() { + val planes = ConcordChannelPlanner.channelPlanes(entry(privateChannels = listOf(heldPrivate)), state()) + assertEquals(setOf(publicChannelId, privateChannelId), planes.map { it.channelIdHex }.toSet()) + } + + @Test + fun aBundleHeldPrivateChannelShowsBeforeItsDefinitionFolds() { + // A fresh join holds its private-channel keys before the Control Plane catches up; the room + // shouldn't vanish in the meantime. Also covers a null state (nothing folded at all). + val entry = entry(privateChannels = listOf(heldPrivate)) + assertEquals(listOf(privateChannelId), ConcordChannelPlanner.channelPlanes(entry, null).map { it.channelIdHex }) + + val planes = ConcordChannelPlanner.channelPlanesFor(entry, null, privateChannelId)!! + assertEquals(true, planes.isPrivate) + assertEquals(privateEpoch, planes.write.epoch) + } + + @Test + fun publicChannelReadsSpanHeldEpochsAndPrivateOnesDoNot() { + val entry = + entry( + privateChannels = listOf(heldPrivate), + heldRoots = listOf(HeldRoot(rootEpoch - 1, priorRoot)), + ) + val planes = ConcordChannelPlanner.channelPlanes(entry, state()).associateBy { it.channelIdHex } + + // Public: current epoch first, then the prior-epoch plane that holds pre-Refounding history. + val public = planes.getValue(publicChannelId) + assertEquals(listOf(rootEpoch, rootEpoch - 1), public.reads.map { it.epoch }) + assertEquals( + public.write.key.publicKeyHex, + public.reads + .first() + .key.publicKeyHex, + ) + assertEquals( + ConcordActions.publicChannel(priorRoot.hexToByteArray(), publicChannelId.hexToByteArray(), rootEpoch - 1).publicKeyHex, + public.reads[1].key.publicKeyHex, + ) + + // Private: exactly one plane. A per-channel rekey replaces the delivered key rather than + // accumulating epochs, and a held ROOT never derives a private channel's address. + val private = planes.getValue(privateChannelId) + assertEquals(listOf(privateEpoch), private.reads.map { it.epoch }) + } + + @Test + fun heldRootsContainingTheCurrentEpochDoNotDuplicateThePlane() { + // Stranded recovery folds the current root into heldRoots, so the same address can arrive + // twice; subscribing to it twice would just pad every REQ's author list. + val entry = entry(heldRoots = listOf(HeldRoot(rootEpoch, root), HeldRoot(rootEpoch - 1, priorRoot))) + val reads = ConcordChannelPlanner.channelPlanesFor(entry, state(), publicChannelId)!!.reads + + assertEquals(reads.size, reads.distinctBy { it.key.publicKeyHex }.size) + assertEquals(listOf(rootEpoch, rootEpoch - 1), reads.map { it.epoch }) + } + + @Test + fun anUnknownChannelHasNoPlanes() { + assertNull(ConcordChannelPlanner.channelPlanesFor(entry(), state(), "ff".repeat(32))) + } + + @Test + fun everyResolvedPlaneCarriesItsOwnChannelId() { + // The id travels with the plane because ingest routes an inbound wrap by address and needs to + // know which channel it landed in — a mismatch here would file messages under the wrong room. + val planes = ConcordChannelPlanner.channelPlanes(entry(privateChannels = listOf(heldPrivate)), state()) + assertTrue(planes.all { channel -> channel.reads.all { it.channelIdHex == channel.channelIdHex } }) + } +} diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt index 8f60160cd2..8aa2c65c86 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/chats/PostingGateTest.kt @@ -47,10 +47,10 @@ import kotlin.test.assertTrue * Concord member got `canPost() == false` and an empty slot, because the only explanatory branch on * the screen tested dissolution. * - * [PostingGate.NoKey] has no producer to exercise here: it is the mapping for - * `ConcordMembership.NONE`, which the channel fold cannot currently reach (it derives membership for - * communities we already hold the key to). It exists so that enum value cannot silently resolve to - * "allowed". + * [PostingGate.NoKey] covers both "we can't place this account in the community" (the mapping for + * `ConcordMembership.NONE`, which the fold cannot currently reach) and "this is a private channel + * whose key we were never granted" — the second of which is real, and is the backstop for reaching + * such a channel despite it being hidden from every list. */ class PostingGateTest { // ---- Concord ------------------------------------------------------------------------------ @@ -110,6 +110,18 @@ class PostingGateTest { assertFalse(channel.canPost()) } + @Test + fun aPrivateChannelWeHoldNoKeyForExplainsItselfInsteadOfOfferingAComposer() { + // Such a channel is normally omitted from every list (ConcordChannelPlanner drops it), so this + // is the arrive-anyway path: a stale route must not present a composer whose message would + // have nowhere valid to go. + val channel = ConcordChannel(ConcordChannelId(owner, channelIdHex)) + channel.updateFrom(concordState(), emptySet(), owner, holdsChannelKey = false) + + assertEquals(PostingGate.NoKey, channel.postingGate()) + assertFalse(channel.canPost()) + } + @Test fun dissolutionOutranksAPersonalBan() { // Both apply. Dissolution is the complete answer — it blocks everyone — so naming the ban too diff --git a/docs/community-status-matrix.md b/docs/community-status-matrix.md index faaef4b60c..cb6a67ab34 100644 --- a/docs/community-status-matrix.md +++ b/docs/community-status-matrix.md @@ -144,7 +144,7 @@ retrying can ever help): | **Exists** | a non-tombstoned `CHANNEL` edition in the fold | Gated by `MANAGE_CHANNELS`. | | **Deleted** | `deleted: true` | Terminal — the channel id is never reused. | | **Voice** | `voice: true` | Audio channel (`Mic` icon); CORD-07 broker token + kind-23313 voice presence. | -| **Private** | `private: true` | Derived-key visibility: a separate plane key delivered per member in the kind-13302 entry's `privateChannels`. | +| **Private** | `private: true` | Derived-key visibility: a separate plane key delivered per member in the kind-13302 entry's `privateChannels`, at that channel's own epoch. Resolved by `ConcordChannelPlanner`; a member holding no key for it sees no channel at all. | | **Typing** | ephemeral kind **23311** | Wired, with a staleness window. | | **Posting allowed** | `membership.isMember() && !dissolved` | | @@ -241,7 +241,7 @@ others), and **invisible** (nothing renders it at all). | `dissolved` | The same `PostingGateNotice` replaces the composer, inside an opened channel only — not on the community row, the channel list, or the Messages inbox. (There is also **no write path** to dissolve: an owner cannot do it from Amethyst.) | partial | | **stranded / excluded epoch** | nothing. `recoverStrandedConcordCommunities()` runs at startup, `Log.w` on failure. A stranded community renders as an ordinary *empty* one. | **invisible** | | current epoch / `heldRoots` | nothing | **invisible** | -| `private` channel | `Lock` icon in the channel list — but the row navigates like any other into a permanently empty room, because the plane key is never derived (see §8.1). No "you don't hold this key" state. | misleading | +| `private` channel | `Lock` icon in the channel list, shown only to members who hold its key; others get no row (and no inbox row). Reaching one anyway explains itself via `PostingGate.NoKey`. | **shown** | | `voice` channel | `Mic` icon + preview-line suppression in the list; the opened channel has no voice affordance | partial | | **kicked** (3309) | nothing — unwired | **invisible** | | Guestbook join/leave | feeds the members roster; the motion itself isn't shown | implied | @@ -273,12 +273,22 @@ Statuses that exist in the protocol/Quartz layer with no client behavior behind §8.1–8.2 are the two that look like defects rather than unfinished features; §8.7–8.8 are UI-only (the state is tracked correctly, nothing tells the user): -1. **Concord private channels are listed but never opened.** `ConcordPlaneRegistry.registerChannels` - derives only `ConcordChannelKeys.publicChannel`; the per-member keys in - `ConcordCommunityListEntry.privateChannels` are parsed, round-tripped and carried through - stranded recovery, but `privateChannel(...)` is called **only from a Quartz test**. The row - renders with a `Lock` icon and navigates like any other channel — into a permanently empty - timeline, with no "you don't have the key" state either. +1. ~~**Concord private channels are listed but never opened.**~~ **Fixed.** The read *and* write + paths derived every channel — `private: true` included — from the `community_root`, at six + independent call sites. So a private channel was addressed on a plane every member of the + community could derive: readable **and postable** by people never granted its key, and + simultaneously invisible to any conformant client (which addresses it by the delivered key). Now + one resolver, `ConcordChannelPlanner`, decides which secret addresses a channel — the root for a + public one, the bundle's per-member `channel_key` at the channel's own epoch for a private one — + and every consumer (plane registry, subscription planner, session re-fold, five send paths, + channel list, Messages inbox) reads it. A private channel we hold no key for is **omitted + entirely**, matching Armada's `channelsView` ("omit rather than tease"); arriving at one anyway + yields `PostingGate.NoKey`. Note the epoch now travels with the plane: a private channel's wraps + bind to its channel epoch, so the old code would have rejected them even with the right key. + **Migration:** "private" channels created by earlier Amethyst builds have their history on the + root-derived plane, which is no longer read for a private channel. That history is orphaned by + design — keeping it would mean continuing to read (and legitimise) traffic on a plane the whole + community can write to. 2. **`RoleScope` is decode-only.** `RoleEntity.scope` distinguishes `{"kind":"server"}` from `{"kind":"channel","channel_id":…}`, but `AuthorityResolver.effectivePermissions` unions every held role's bits regardless of scope — and nothing else reads it. A channel-scoped