From 4714144bf0afda44e0a8412e64a8c0ae8686b639 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:21:33 +0000 Subject: [PATCH] fix(concord): private channels on their own keys, in-channel deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2 — a `private: true` channel was keyed from community_root at the root epoch, so posts under the Lock icon landed on a plane every member decrypts. Now (CORD-03 §1): - ConcordActions.currentChannelPlane / historicalChannelPlanes derive a private channel only from the held key in the Community List entry's `privateChannels`, bound to the channel epoch; with no key there is no plane at all — never the root-derived one. - The session, subscription planner, plane registry, the app's verbs (send, image, reply, react, edit, typing) and amy all resolve planes through it. A key delivered later is adopted in place (ConcordCommunitySession.adoptPrivateChannels), and keys an invite carries are stored on join. - A keyless private channel is locked: ConcordChannel.keyHeld / canPost() false, the composer is replaced by a notice, and `amy concord send` fails with `no_channel_key` (`channels` reports `readable`). Creating private channels (key delivery) stays open (F7). S3 — deleting your own Concord message went out as a NIP-17 kind 5 to the p-tagged users, leaking the rumor id outside the community, and never reached the channel. Account.delete / deletePrivately now route Concord notes (messages, replies, reactions) to AccountConcordActions.deleteConcordRumors: the in-stream kind 5, sealed 20013 on the plane of each target's bound epoch. Tapping your own Concord reaction again retracts it the same way. S9 — chat ingest (session, typing, ConcordActions.channelRumors) goes through ChannelChat.acceptOpened, and EventCache.consumeConcordRumor refuses non-chat kinds as defense in depth. channelMessages orders by created_at*1000+ms. CORD-03 §3 — your own kind-1111 thread replies can be edited like kind-9 messages. Review statuses updated for S2, S3, S9 (chat half), S10, I13-I16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../chats/feed/ChatMessageActionSheet.kt | 7 +- .../concord/ConcordChannelScreen.kt | 14 +- cli/README.md | 6 +- .../cli/commands/ConcordChannelCommands.kt | 35 +- .../amethyst/cli/commands/ConcordCommands.kt | 6 + .../cli/commands/ConcordModCommands.kt | 9 +- .../amethyst/cli/stores/ConcordStore.kt | 12 + .../commons/actions/ConcordActions.kt | 146 ++++++++- .../actions/ConcordSubscriptionPlanner.kt | 32 +- .../amethyst/commons/model/Account.kt | 21 +- .../commons/model/AccountConcordActions.kt | 114 ++++++- .../commons/model/cache/EventCache.kt | 6 + .../commons/model/concord/ConcordChannel.kt | 19 +- .../model/concord/ConcordCommunitySession.kt | 138 +++++--- .../model/concord/ConcordPlaneRegistry.kt | 16 +- .../model/concord/ConcordSessionRegistry.kt | 3 + .../ConcordChatPlaneConformanceTest.kt | 307 ++++++++++++++++++ .../composeResources/values/strings.xml | 1 + .../commons/viewmodels/AccountViewModel.kt | 6 +- .../2026-09-29-concord-spec-conformance.md | 16 +- 20 files changed, 789 insertions(+), 125 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 889d177e43..90a35e9cc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -278,15 +279,15 @@ fun ChatMessageActionSheet( // Editing my own chat message. Two surfaces publish an edit today, gated by type: // - Buzz: kind-40002 stream message → a kind-40003 edit. - // - Concord: kind-9 channel message (carries a ConcordChannel gatherer) → a - // kind-1010 edit wrapped on the channel plane. + // - Concord: kind-9 channel message or kind-1111 thread reply (carries a + // ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane. // Both restrict to my own messages; a note is only ever one of the two, so at // most one tile shows and both route through the same edit callback. val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine val canEditConcord = onWantsToEditChatMessage != null && - note.event is ChatEvent && + (note.event is ChatEvent || note.event is CommentEvent) && isMine && note.inGatherers?.any { it is ConcordChannel } == true // Marmot: my own text message in a group -> a kind-1009 edit inside the group. A diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index d79851774f..92a08474f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only +import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_no_key import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one @@ -123,6 +124,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -264,6 +266,10 @@ fun ConcordChannelScreen( // CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is // gone (canPost() is false) and this replaces it so the seal is explained, not silent. ConcordDissolvedNotice() + } else if (!channel.keyHeld) { + // CORD-03 §1: a Private Channel is keyed independently; without its key there is no + // plane only its members can read, so nothing may be posted (never to the root plane). + ConcordReadOnlyNotice(Res.string.concord_private_channel_no_key) } } } @@ -274,9 +280,13 @@ fun ConcordChannelScreen( * The tombstone seals the community: history stays readable, but no member may post again. */ @Composable -private fun ConcordDissolvedNotice() { +private fun ConcordDissolvedNotice() = ConcordReadOnlyNotice(Res.string.concord_dissolved_read_only) + +/** A one-line explanation shown where the composer would be when this channel cannot be posted to. */ +@Composable +private fun ConcordReadOnlyNotice(message: StringResource) { Text( - text = stringRes(Res.string.concord_dissolved_read_only), + text = stringRes(message), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), diff --git a/cli/README.md b/cli/README.md index 799d94bb3b..3749dcc2ea 100644 --- a/cli/README.md +++ b/cli/README.md @@ -676,9 +676,9 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord create --name NAME [--about T] [--relay wss://a,wss://b]` | Create an encrypted Concord community. `--relay` is canonical; `--relays` is accepted as an alias. | | `amy concord list` | List joined Concord communities. | | `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). | -| `amy concord channels COMMUNITY` | List a community's channels. | -| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | -| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. | +| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). | +| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | +| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord join URL` | Redeem an invite link and save the community. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 9e2565c088..06a0f5ba93 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -57,7 +57,13 @@ object ConcordChannelCommands { "banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, "channels" to state.channels.values.map { - mapOf("id" to it.channelIdHex, "name" to it.definition.name, "private" to it.definition.private) + mapOf( + "id" to it.channelIdHex, + "name" to it.definition.name, + "private" to it.definition.private, + // False for a Private Channel whose key this account does not hold (CORD-03 §1). + "readable" to ConcordActions.canAccessChannel(ConcordCommands.entryFor(sc), state, it.channelIdHex), + ) }, ), ) @@ -80,12 +86,18 @@ object ConcordChannelCommands { ctx.prepare() // CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but // nothing new is honored, so refuse to post before we ever build/publish a wrap. - if (foldState(ctx, sc).dissolved) { + val state = foldState(ctx, sc) + if (state.dissolved) { return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)") } val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") - val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) - val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now()) + // The channel's own plane (CORD-03 §1): root-derived when Public, its held key when + // Private — and a refusal, never the root plane, for a Private Channel we hold no key for. + val plane = + ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId) + ?: return Output.error("no_channel_key", "channel '$channelRef' is not folded, or is private and this account holds no key for it (CORD-03 §1)") + val channel = plane.key + val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now()) val relays = ConcordCommands.relaysFor(ctx, sc) // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) @@ -127,7 +139,20 @@ object ConcordChannelCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") - val channel = ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch) + val state = foldState(ctx, sc) + // A Private Channel is read only on its own key's plane (CORD-03 §1); --root/--epoch pick a + // root-derived plane and so apply to Public Channels only. + val privatePlane = + if (state.channels[channelId]?.definition?.private == true) { + ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId) + ?: return Output.error("no_channel_key", "channel '$channelRef' is private and this account holds no key for it (CORD-03 §1)") + } else { + null + } + val channel = privatePlane?.key ?: ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch) + + @Suppress("NAME_SHADOWING") + val epoch = privatePlane?.epoch ?: epoch val relays = ConcordCommands.relaysFor(ctx, sc) // The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 6c2212a4e5..0ab8e4c87c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot +import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList @@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList @@ -248,6 +250,7 @@ object ConcordCommands { // Survives every merge: losing the anchor makes the NEXT exclusion // unrecoverable, so a list entry without one must not clear ours. inviteRef = e.inviteRef ?: prior?.inviteRef ?: "", + privateChannels = e.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, ), ) mapOf( @@ -477,6 +480,7 @@ object ConcordCommands { // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. inviteRef = ConcordActions.bareInviteRef(url) ?: "", + privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, ), ) Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) @@ -546,6 +550,7 @@ object ConcordCommands { controlPk = sc.controlPk.ifBlank { null }, controlRoot = sc.controlRoot.ifBlank { null }, heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) }, + privateChannels = sc.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name) }, relays = sc.relays, name = sc.name, inviteRef = sc.inviteRef.ifBlank { null }, @@ -564,6 +569,7 @@ object ConcordCommands { relays = entry.relays, name = entry.name.ifBlank { sc.name }, inviteRef = entry.inviteRef ?: sc.inviteRef, + privateChannels = entry.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, ) /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 4a4bf10a49..1f3b1e84e9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -483,12 +483,15 @@ object ConcordModCommands { ): Set { val out = HashSet() val relays = ConcordCommands.relaysFor(ctx, sc) - for ((channelIdHex, _) in state.channels) { + val entry = ConcordCommands.entryFor(sc) + for (channelIdHex in state.channels.keys) { + // A Private Channel we hold no key for has no plane we may read (CORD-03 §1). + val plane = ConcordActions.currentChannelPlane(entry, state, channelIdHex) ?: continue runCatching { - val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch) + val key = plane.key ctx.registerConcordStreamKeys(relays, listOf(key.secretKey)) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } - ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() } + ConcordActions.channelMessages(wraps, key, channelIdHex, plane.epoch).mapTo(out) { it.author.lowercase() } } } return out diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index c36b1ac915..575f06b26c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,6 +53,18 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", + // Private Channel keys this account holds (CORD-03 §1), from the Community List or an invite. + // A private channel is read and written ONLY on the plane its own key derives; without one it + // is unreadable and `send` refuses rather than fall back to the root-derived plane. + val privateChannels: List = emptyList(), +) + +/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */ +data class StoredPrivateChannel( + val channelId: String = "", + val key: String = "", + val epoch: Long = 0, + val name: String = "", ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ 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 0a9699bd1c..1707126224 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 @@ -29,6 +29,7 @@ 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.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -68,15 +69,19 @@ data class ConcordChatMessage( ) /** - * 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. + * One channel's Chat Plane: the epoch-invariant [channelIdHex], the [epoch] its rumors are bound to + * (for `isBoundTo` validation — the root epoch for a Public Channel, the channel's own epoch for a + * Private one, CORD-03 §1), and the derived [key] its wraps are addressed by and decrypt under. */ -data class HistoricalChannelPlane( +data class ChannelPlane( val channelIdHex: HexKey, val epoch: Long, val key: GroupKey, ) +/** A [ChannelPlane] at a prior epoch (pre-Refounding history). */ +typealias HistoricalChannelPlane = ChannelPlane + /** * Concord community verbs — pure builders, plane-key derivation, relay-filter * assembly, and event folding usable from amy CLI, the Android app, and any other @@ -142,6 +147,92 @@ object ConcordActions { rootEpoch: Long, ): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + /** + * The independent key this account holds for Private Channel [channelIdHex] (delivered on grant + * and carried in the Community List's `privateChannels`, CORD-03 §1 / CORD-02 §8), or null when + * it holds none. A keyless entry (a writer listing a public channel as `{id, epoch}`) is not a key. + */ + fun heldPrivateChannelKey( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) } + + /** + * The Chat Plane a channel is **written** on, or null when this account cannot write it + * (CORD-03 §1): + * - Public: `group_key("concord/channel", community_root, channel_id, root_epoch)`, bound to + * the root epoch; + * - Private: `group_key("concord/channel", channel_key, channel_id, channel_epoch)` from the + * held key, bound to the **channel** epoch — and null when no key is held. A Private Channel + * must never fall back to the root-derived plane: every member decrypts that one, so a post + * there would be public to the whole community under a Lock icon. + */ + fun currentChannelPlane( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + isPrivate: Boolean, + ): ChannelPlane? { + val channelId = channelIdHex.hexToByteArray() + if (isPrivate) { + val held = heldPrivateChannelKey(entry, channelIdHex) ?: return null + return ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch)) + } + return ChannelPlane(channelIdHex, entry.rootEpoch, publicChannel(entry.root.hexToByteArray(), channelId, entry.rootEpoch)) + } + + /** + * [currentChannelPlane] for a channel of the folded [state], or null when the channel is not in + * the fold (unknown or deleted) or is Private with no held key. + */ + fun currentChannelPlane( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + channelIdHex: HexKey, + ): ChannelPlane? { + val def = state.channels[channelIdHex]?.definition ?: return null + return currentChannelPlane(entry, channelIdHex, def.private) + } + + /** + * The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]: + * - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the + * private-era plane when a channel key is held (a channel that was Private before); + * - Private: none. Only the channel-key planes are its own; the root-derived plane is readable + * by every member, so showing it would present public content as private (Armada + * `channelsView`). With no priors kept per channel key, that leaves nothing. + */ + fun historicalChannelPlanes( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + isPrivate: Boolean, + ): List { + if (isPrivate) return emptyList() + val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex)) + val privateEra = + heldPrivateChannelKey(entry, channelIdHex)?.let { held -> + ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch)) + } + return rootEras + listOfNotNull(privateEra) + } + + /** + * The Private Channel keys an [invite] delivers (CORD-05 §1), as Community List entries. A + * keyless listing (a public channel written as `{id, epoch}`) delivers nothing. + */ + fun privateChannelKeysOf(invite: CommunityInvite): List = + invite.channels + .filter { HEX64.matches(it.id) && HEX64.matches(it.key) } + .map { PrivateChannelKey(it.id.lowercase(), it.key.lowercase(), it.epoch, it.name) } + + /** True when this account can read and write [channelIdHex] as folded in [state]. */ + fun canAccessChannel( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + channelIdHex: HexKey, + ): Boolean = currentChannelPlane(entry, state, channelIdHex) != null + /** * 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* @@ -344,6 +435,24 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an encrypted-seal **delete** wrap (kind-5 [ChannelChat.delete] of the author's own + * [targets]) on the [channel] plane — the in-stream delete of CORD-01. Never publish a Concord + * delete any other way: a signed kind 5 or a NIP-17 delete would carry the rumor ids outside + * the community. + */ + suspend fun buildChannelDelete( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + targets: List, + createdAt: Long, + ): Event { + val rumor = ChannelChat.delete(authorSigner.pubKey, channelId, epoch, targets, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ suspend fun buildChannelReaction( authorSigner: NostrSigner, @@ -376,8 +485,9 @@ object ConcordActions { } /** - * Opens the channel [wraps], keeps the kind-9 messages correctly bound to - * [channelId]/[epoch], and returns them oldest-first (createdAt, then id). + * Opens the channel [wraps], keeps the kind-9 messages that pass the Chat ingest gate + * ([channelRumors]), and returns them oldest-first by their CORD-02 §4 send time + * (`created_at * 1000 + ms`), then id. */ fun channelMessages( wraps: List, @@ -385,11 +495,11 @@ object ConcordActions { channelId: HexKey, epoch: Long, ): List = - wraps - .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } - .filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) } + channelRumors(wraps, channel, channelId, epoch) + .filter { it.kind == ChatEvent.KIND } + .distinctBy { it.id } + .sortedWith(compareBy({ ChannelChat.orderingMs(it) }, { it.id })) .map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) } - .sortedWith(compareBy({ it.createdAt }, { it.id })) /** * Opens the channel [wraps] and returns every validated inner rumor bound to @@ -404,10 +514,20 @@ object ConcordActions { channel: GroupKey, channelId: HexKey, epoch: Long, - ): List = - wraps - .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } - .filter { ChannelChat.isBoundTo(it, channelId, epoch) } + ): List = wraps.mapNotNull { wrap -> openChannelRumor(wrap, channel, channelId, epoch) } + + /** + * Opens one channel [wrap] and returns its rumor only when it passes the Chat ingest gate + * ([ChannelChat.acceptOpened]): an encrypted 20013 seal, a Chat kind (never another plane's + * kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. Anything else is dropped + * here, before it can reach the store. + */ + fun openChannelRumor( + wrap: Event, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + ): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) } // ---- invites -------------------------------------------------------------- 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 3215cbc06d..9d7768a11f 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 @@ -122,24 +122,28 @@ object ConcordSubscriptionPlanner { entry: ConcordCommunityListEntry, state: ConcordCommunityState, ): List { - val root = entry.root.hexToByteArray() val relays = normalize(entry.relays) + // A Private Channel is subscribed on its own key's plane only, and not at all without a held + // key (CORD-03 §1): never on the root-derived plane every member can read. 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, - ) + state.channels.values.mapNotNull { channel -> + ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private)?.let { plane -> + ConcordPlaneSub( + channelId = ConcordChannelId(entry.id, plane.channelIdHex), + pubKeyHex = plane.key.publicKeyHex, + relays = relays, + ) + } } val historical = - ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane -> - ConcordPlaneSub( - channelId = ConcordChannelId(entry.id, plane.channelIdHex), - pubKeyHex = plane.key.publicKeyHex, - relays = relays, - ) + state.channels.values.flatMap { channel -> + ConcordActions.historicalChannelPlanes(entry, channel.channelIdHex, channel.definition.private).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/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 0a511017c8..20ec82acb0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore @@ -783,7 +784,9 @@ class Account( // 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() + // A Private Channel is readable/postable only with its held key (CORD-03 §1). + val keyHeld = ConcordActions.canAccessChannel(session.entry, state, channelIdHex) + if (channel.updateFrom(state, relays, myPubKey, keyHeld)) channel.updateChannelInfo() channel.notes .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } .forEach { channel.removeNote(it) } @@ -1747,11 +1750,18 @@ class Account( // Marmot messages are retracted inside their group. A public NIP-09 here would e-tag // the group's private rumor ids onto public relays. - val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null } + val (marmotNotes, nonMarmotNotes) = notes.partition { marmot.marmotGroupOf(it) != null } marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) -> marmot.deleteMarmotMessages(groupId, groupNotes) } + // Concord rumors are retracted inside their channel's plane (CORD-01 Deletions), for the + // same reason: any other route carries the community's rumor ids outside it. + val (concordNotes, otherNotes) = nonMarmotNotes.partition { concord.concordChannelOf(it) != null } + concordNotes.groupBy { concord.concordChannelOf(it)!! }.forEach { (channel, channelNotes) -> + concord.deleteConcordRumors(channel, channelNotes) + } + val (myRumors, myNotes) = otherNotes .filter { it.author == userProfile() && it.event != null } @@ -1796,6 +1806,13 @@ class Account( return } + // In a Concord channel it is an in-channel kind-5 on the channel's plane (CORD-01), never a + // NIP-17 DM to the p-tagged users, which would leak the rumor ids outside the community. + concord.concordChannelOf(target)?.let { channel -> + concord.deleteConcordRumors(channel, notes) + return + } + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } if (myRumors.isEmpty()) return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 7af62cb035..d7d15cdf40 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions @@ -66,6 +67,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCon import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -560,6 +562,9 @@ class AccountConcordActions( // Read access to the Control Plane, never write (CORD-05 §1). Absent = the // community is still pre-split, so we fold it at the legacy address. controlPk = bundle.controlPk, + // Private Channel keys the invite delivered (CORD-03 §1 / CORD-05 §1), so those + // channels are read and written on their own planes from the first fold. + privateChannels = ConcordActions.privateChannelKeysOf(bundle), relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.now() * 1000, @@ -591,7 +596,10 @@ class AccountConcordActions( if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // The channel's own plane: root-derived for a Public Channel, its held key for a Private one, + // and no plane at all (refuse) for a Private Channel whose key we do not hold (CORD-03 §1). + val plane = session.currentChannelPlane(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). @@ -608,13 +616,13 @@ class AccountConcordActions( // 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(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags) parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) parent != null -> - ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) else -> - ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags) } sendConcordChannelWrap(entry, channelKey, wrap) return true @@ -636,14 +644,15 @@ class AccountConcordActions( if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val plane = session.currentChannelPlane(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 = account.emoji .findEmojiTags(text) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags) sendConcordChannelWrap(entry, channelKey, wrap) return true } @@ -665,9 +674,11 @@ class AccountConcordActions( val target = note.event ?: return false val communityId = channel.channelId.communityId val channelIdHex = channel.channelId.channelId - val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val plane = session.currentChannelPlane(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 = @@ -675,7 +686,7 @@ class AccountConcordActions( .findEmojiTags(reaction) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } @@ -686,7 +697,8 @@ class AccountConcordActions( * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays * inside the encrypted channel (a public edit would e-tag the private rumor id onto * public relays). The receiving side overlays the newest edit onto the target message; - * only the *original author's* edits are applied, so we gate to my own kind-9 messages. + * only the *original author's* edits are applied, so we gate to my own messages — a kind-9 + * message or a kind-1111 thread reply (CORD-03 §3: edits target either by rumor id). * Returns false if [note] isn't an editable Concord message I authored. */ suspend fun editConcordChannelMessage( @@ -696,25 +708,87 @@ class AccountConcordActions( if (!account.isWriteable()) return false val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false val target = note.event ?: return false - // Edits only apply to plain kind-9 messages, and only the author may edit their own. - if (target !is ChatEvent || target.pubKey != account.signer.pubKey) return false + // Edits apply to messages and thread replies, and only the author may edit their own. + if (!isConcordEditable(target)) return false val communityId = channel.channelId.communityId val channelIdHex = channel.channelId.channelId - val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val plane = session.currentChannelPlane(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 = account.emoji .findEmojiTags(newText) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } + /** True when [target] is a Concord message this account may edit: my own kind-9 message or kind-1111 reply. */ + fun isConcordEditable(target: Event): Boolean = (target is ChatEvent || target is CommentEvent) && target.pubKey == account.signer.pubKey + + /** + * The Concord channel [note] belongs to: its own gatherer for a message or thread reply, else + * (a reaction, a delete) the channel of the note it points at. Null when it is not Concord. + */ + fun concordChannelOf(note: Note): ConcordChannel? = + note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + ?: note.replyTo?.firstNotNullOfOrNull { target -> target.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } } + + /** + * Delete my own Concord rumors [notes] (messages, thread replies, reactions) in [channel] the + * way CORD-01 prescribes: a kind-5 rumor with `e` + `k` tags, sealed (20013) and wrapped on the + * channel's own plane, so only the community sees it. Never a signed kind 5 or a NIP-17 delete, + * both of which would carry the rumor ids to people and relays outside the community. + * + * Each target is retracted on the plane that carried it (its bound epoch), falling back to the + * channel's current plane when this account no longer holds that one. A member's delete of + * their own message stays honored after Dissolution (CORD-02 §9), so this is not gated on it. + * Returns false when nothing could be sent (not writeable, no session, no plane, no own notes). + */ + suspend fun deleteConcordRumors( + channel: ConcordChannel, + notes: List, + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(channel.channelId.communityId) ?: return false + val channelIdHex = channel.channelId.channelId + val mine = notes.mapNotNull { it.event }.filter { it.pubKey == account.signer.pubKey }.distinctBy { it.id } + if (mine.isEmpty()) return false + val current = session.currentChannelPlane(channelIdHex) + val byPlane = + mine.groupBy { target -> + target.tags.concordEpoch()?.let { session.channelPlaneFor(channelIdHex, it) } ?: current + } + var sent = false + for ((plane, targets) in byPlane) { + if (plane == null) continue + val wrap = ConcordActions.buildChannelDelete(account.signer, plane.key, channelIdHex, plane.epoch, targets, TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + sent = true + } + return sent + } + + /** + * Toggle my [reaction] on Concord message [note]: retract my existing reactions of that content + * with an in-channel delete, else add it ([reactToConcordMessage]). Returns false when nothing + * was sent. + */ + suspend fun toggleConcordReaction( + note: Note, + reaction: String, + ): Boolean { + val channel = concordChannelOf(note) ?: return false + val mine = note.allReactionsOfContentByAuthor(account.userProfile(), reaction) + return if (mine.isNotEmpty()) deleteConcordRumors(channel, mine) else reactToConcordMessage(note, reaction) + } + /** * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at * most every few seconds while composing. Not folded locally (we never show our own typing); @@ -736,8 +810,8 @@ class AccountConcordActions( return } val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val plane = session.currentChannelPlane(channelIdHex) ?: return + val wrap = ConcordActions.buildChannelTyping(account.signer, plane.key, channelIdHex, plane.epoch, TimeUtils.now()) val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } if (relays.isNotEmpty()) account.client.publish(wrap, relays) } @@ -1478,6 +1552,8 @@ class AccountConcordActions( val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) + // Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one. + if (!channel.hasValidName()) return false val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -1501,6 +1577,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = (standing ?: ChannelEntity()).copy(name = name.trim()) + if (!channel.hasValidName()) return false val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -1523,6 +1600,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = (standing ?: ChannelEntity()).copy(name = name.trim(), deleted = true) + if (!channel.hasValidName()) return false val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 4db15d9ec0..8fb3e1e39f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent @@ -971,6 +972,11 @@ open class EventCache : rumor: Event, seenOnRelays: Set = emptySet(), ) { + // Defense in depth behind the session's Chat ingest gate: a channel plane carries Chat kinds + // only (CORD-02 Appendix B). Another plane's kind — a Control edition, a Guestbook motion, a + // rekey blob — must never land in the store as if it came from its own plane. + if (!ChannelChat.isChatKind(rumor.kind)) return + // Attach to the channel BEFORE justConsume sets the event and notifies feeds, // so the note already carries its ConcordChannel gatherer when it flows through // the Messages-list incremental filter (which routes rows by that gatherer). 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 83cbd73088..09fb253ac1 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 @@ -55,6 +55,14 @@ class ConcordChannel( var isPrivate: Boolean = false private set + /** + * False for a Private Channel whose independent key this account does not hold (CORD-03 §1): + * its plane can be neither read nor written, and it must never fall back to the root-derived + * plane every member can decrypt. Always true for a Public Channel. + */ + var keyHeld: Boolean = true + private set + /** The parent community's display name, from its folded metadata. */ var communityName: String? = null private set @@ -107,6 +115,7 @@ class ConcordChannel( state: ConcordCommunityState, relays: Set, myPubKey: HexKey, + keyHeld: Boolean = true, ): Boolean { val def = state.channels[channelId.channelId]?.definition // Channel fields keep their prior value until the channel edition folds. @@ -117,6 +126,7 @@ class ConcordChannel( val newCommunityBanner = state.metadata?.banner val newMembership = ConcordMembership.of(state.authority, myPubKey) val newDissolved = state.dissolved + val newKeyHeld = !newPrivate || keyHeld val changed = channelName != newChannelName || @@ -125,7 +135,8 @@ class ConcordChannel( communityIcon != newCommunityIcon || communityBanner != newCommunityBanner || membership != newMembership || - dissolved != newDissolved + dissolved != newDissolved || + this.keyHeld != newKeyHeld channelName = newChannelName isPrivate = newPrivate @@ -135,6 +146,7 @@ class ConcordChannel( communityRelays = relays membership = newMembership dissolved = newDissolved + this.keyHeld = newKeyHeld return changed } @@ -148,8 +160,11 @@ class ConcordChannel( * ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 — * a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even * after dissolution and does not go through this gate. + * + * A Private Channel whose key this account does not hold is never postable ([keyHeld]): there + * is no plane to write to that only its members can read. */ - fun canPost(): Boolean = membership.isMember() && !dissolved + fun canPost(): Boolean = membership.isMember() && !dissolved && keyHeld // Synthetic note representing this channel in the Messages list before any // message has loaded (so a just-joined channel appears immediately). Mirrors 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 8f40a07e67..7020c2a552 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 @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.concord +import com.vitorpamplona.amethyst.commons.actions.ChannelPlane import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock @@ -239,14 +240,20 @@ 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>() + // Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control + // re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from + // its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has + // no entry at all: it is neither subscribed, read, nor written. + 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>() + // Older channel plane pubkey -> plane, for history: a Public Channel's plane under each held + // prior root (a CORD-06 Refounding rotates the root per epoch) plus its private-era plane when a + // channel key is held. Subscribed, AUTHed and decrypted alongside the current planes. + private var historicalChannelKeysByAddress = HashMap() + + // The held Private Channel keys the current channel planes were derived from, so a later list + // entry carrying different keys re-derives them (see [adoptPrivateChannels]). + private var derivedPrivateKeys = privateKeySet(entry) private val _state = MutableStateFlow(null) val state: StateFlow = _state @@ -333,7 +340,26 @@ class ConcordCommunitySession( 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 } + fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key } + + /** + * The plane [channelIdHex] is written on now, or null when it is not folded yet or is a Private + * Channel this account holds no key for (CORD-03 §1) — the caller must then refuse to post. + */ + fun currentChannelPlane(channelIdHex: HexKey): ChannelPlane? = lock.withLock { channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex } } + + /** + * The plane of [channelIdHex] bound to [epoch] — current or historical — or null when this + * account holds none. A delete of an older message goes back onto the plane that carried it. + */ + fun channelPlaneFor( + channelIdHex: HexKey, + epoch: Long, + ): ChannelPlane? = + lock.withLock { + channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch } + ?: historicalChannelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch } + } /** * Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each @@ -344,8 +370,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) } @@ -380,9 +406,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.mapNotNull { it.first.signer } + - 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 } } /** @@ -435,6 +461,33 @@ class ConcordCommunitySession( true } + /** + * Adopt a change to the Private Channel keys the Community List carries for this same community, + * root and epoch (a key delivered on grant, CORD-03 §1): the entry is swapped in place and the + * channel planes re-derived, so a newly held Private Channel is subscribed, read and written on + * its own plane without dropping the buffered Control Plane wraps a rebuild would lose. + * + * Returns false, changing nothing, when [newEntry] is not the same community at the same root, + * epoch and Control Plane material (the caller rebuilds, or adopts that first), or when the held + * channel keys did not change. + */ + fun adoptPrivateChannels(newEntry: ConcordCommunityListEntry): Boolean { + val changed = + lock.withLock { + val cur = entry + if (newEntry.id != cur.id || newEntry.root != cur.root || newEntry.rootEpoch != cur.rootEpoch) return false + if (newEntry.controlPk != cur.controlPk || newEntry.controlRoot != cur.controlRoot) return false + // Compared with what the planes were derived from, not with [entry]: an adoption of + // Control material may already have swapped in an entry carrying the new keys. + if (privateKeySet(newEntry) == derivedPrivateKeys) return false + entry = newEntry + true + } + // Nothing folded yet: the first control wrap derives the planes from the swapped-in entry. + if (changed && lock.withLock { controlWraps.isNotEmpty() }) refold() + return changed + } + /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { val s = _state.value ?: return ConcordMembership.MEMBER @@ -503,15 +556,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. + // An older plane (pre-Refounding history, or a Public Channel's private era). Decrypt + // with that plane's key and bind-check against its epoch. Keyed separately from the + // current buffer so a re-fold 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) } } } @@ -551,8 +602,10 @@ class ConcordCommunitySession( key: GroupKey, epoch: Long, ) { - val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return - if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return + val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: return + // The same Chat gate as a stored rumor: encrypted seal, strict binding, well-formed ms. + val rumor = ChannelChat.acceptOpened(opened, channelIdHex, epoch) ?: return + if (!ChannelChat.isTyping(rumor)) return val who = rumor.pubKey.lowercase() if (who == myPubKey.lowercase()) return // never show my own typing back to me // A banned member's messages are dropped everywhere, so their typing heartbeat must be too — @@ -588,31 +641,32 @@ 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 prevAddresses = channelKeysByAddress.keys.toHashSet() + val next = HashMap() + // Re-derive the older planes for the same (epoch-invariant) channel ids, so older + // history is subscribed/AUTHed/decrypted. Channels are known only after a fold, hence + // derived here rather than up front. + val historical = HashMap() + for ((channelIdHex, channel) in folded.channels) { + val isPrivate = channel.definition.private + // Null for a Private Channel with no held key: never the root-derived plane. + ConcordActions.currentChannelPlane(entry, channelIdHex, isPrivate)?.let { next[it.key.publicKeyHex] = it } + for (plane in ConcordActions.historicalChannelPlanes(entry, channelIdHex, isPrivate)) { + 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 + derivedPrivateKeys = privateKeySet(entry) _state.value = folded.withDissolved(dissolved) - folded.channels.keys.filterNot { it in prevChannels } + next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex } } - // Project only channels appearing for the first time. Existing channels' wraps were already - // emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so - // a channel's buffer never pre-dates its first fold) — re-projecting all channels on every - // control edition would be O(channels × history) of redundant decryption. + // Project only channels whose current plane is new (a first fold, or a plane that moved when a + // Private Channel's key arrived). Existing planes' wraps were already emitted incrementally as + // they arrived — re-projecting all channels on every control edition would be + // O(channels × history) of redundant decryption. for (channelIdHex in newChannels) reprojectChannel(channelIdHex) } @@ -677,9 +731,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 = currentChannelPlane(channelIdHex) ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return - emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps) + emitChannelRumors(channelIdHex, plane.key, plane.epoch, wraps) } /** @@ -707,6 +761,8 @@ class ConcordCommunitySession( } companion object { + private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) } + /** A typing heartbeat is considered current for this many seconds after it's seen. */ const val TYPING_STALE_SECS = 8L } 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 cd0cbe7979..4ee986734f 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 @@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray /** What kind of plane an address belongs to. */ enum class ConcordPlaneKind { @@ -105,17 +104,18 @@ class ConcordPlaneRegistry { } } - /** Registers the Chat Plane address of every channel in a folded community [state]. */ + /** + * Registers the current Chat Plane address of every channel in a folded community [state] this + * account can read: a Public Channel's root-derived plane, a Private Channel's held-key plane, + * and nothing for a Private Channel whose key is not held (CORD-03 §1). + */ 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 state.channels.values) { + val plane = ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private) ?: continue + planes[plane.key.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, plane.channelIdHex), plane.key) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 22c7dd47de..3ae9882837 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 @@ -85,6 +85,9 @@ class ConcordSessionRegistry( // buffered wraps and fold the community empty, and the plane's address is // invariant under adoption anyway (CORD-02 §5). existing.adoptControlMaterial(entry) + // Likewise a Private Channel key delivered on grant (CORD-03 §1): re-derive the + // channel planes in place so the channel becomes readable and writable. + existing.adoptPrivateChannels(entry) } } created diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt new file mode 100644 index 0000000000..9605a45227 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt @@ -0,0 +1,307 @@ +/* + * 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.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Chat Plane batch at the commons layer: Private Channels on their own keys (S2), in-stream + * deletes (S3), the Chat ingest gate (S9) and the channel-name build cap (I14). + */ +class ConcordChatPlaneConformanceTest { + private val owner = NostrSignerInternal(KeyPair()) + private val secretId = ByteArray(32) { 0x5C } + private val secretIdHex = secretId.toHexKey() + private val channelKey = ByteArray(32) { 0x3C } + + private fun entryFor( + community: NewConcordCommunity, + privateChannels: List = emptyList(), + heldRoots: List = emptyList(), + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + /** Genesis plus a `private:true` channel edition, as the Control Plane delivers them. */ + private suspend fun communityWithPrivateChannel(): Pair> { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val define = + ConcordModeration.defineChannel( + owner, + community.controlPlane, + secretId, + ChannelEntity(name = "secret", private = true), + community.genesisEditions, + createdAt = 2L, + owner = community.ownerPubKey, + ) + return community to (community.genesisWraps + define) + } + + @Test + fun aPrivateChannelWithoutAHeldKeyIsNeverDerivedOnTheRootPlane() = + runTest { + val (community, control) = communityWithPrivateChannel() + val captured = mutableListOf() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + control.forEach { session.ingest(it) } + val state = session.state.value!! + assertTrue(state.channels[secretIdHex]!!.definition.private) + + val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch) + assertFalse(rootPlane.publicKeyHex in session.channelAddresses(), "a private channel must never be subscribed on the root plane") + assertTrue(session.streamKeys().none { it.publicKeyHex == rootPlane.publicKeyHex }) + assertNull(session.currentChannelPlane(secretIdHex), "no plane to write without the key") + assertNull(ConcordActions.currentChannelPlane(session.entry, state, secretIdHex)) + assertFalse(ConcordActions.canAccessChannel(session.entry, state, secretIdHex)) + + // The planner and the plane registry agree: nothing for the keyless private channel. + val subs = ConcordSubscriptionPlanner.channelPlaneSubs(session.entry, state) + assertTrue(subs.none { it.channelId?.channelId == secretIdHex }) + val registry = ConcordPlaneRegistry().apply { registerChannels(session.entry, state) } + assertFalse(registry.isKnownPlane(rootPlane.publicKeyHex)) + + // A post someone made on the root-derived plane never reaches the store. + val leaked = ConcordActions.buildChannelMessage(owner, rootPlane, secretIdHex, community.rootEpoch, "psst", 3L) + assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(leaked)) + assertTrue(captured.none { it.content == "psst" }) + + // The channel object is locked: no composer, no post. + val channel = ConcordChannel(ConcordChannelId(community.communityIdHex, secretIdHex)) + channel.updateFrom(state, emptySet(), owner.pubKey, keyHeld = false) + assertFalse(channel.keyHeld) + assertFalse(channel.canPost()) + + // The public #general is untouched. + assertNotNull(session.currentChannelPlane(community.generalChannelIdHex)) + } + + @Test + fun aHeldPrivateKeyReadsAndWritesOnItsOwnPlaneAtTheChannelEpoch() = + runTest { + val (community, control) = communityWithPrivateChannel() + val channelEpoch = 3L + val entry = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), channelEpoch, "secret"))) + val captured = mutableListOf() + val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> captured += rumor } + control.forEach { session.ingest(it) } + + val privatePlane = ConcordChannelKeys.privateChannel(channelKey, secretId, channelEpoch) + val plane = session.currentChannelPlane(secretIdHex)!! + assertEquals(privatePlane.publicKeyHex, plane.key.publicKeyHex) + assertEquals(channelEpoch, plane.epoch, "a private channel binds to its own epoch, not the root epoch") + assertTrue(privatePlane.publicKeyHex in session.channelAddresses()) + assertTrue(session.streamKeys().any { it.publicKeyHex == privatePlane.publicKeyHex }) + val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch) + assertFalse(rootPlane.publicKeyHex in session.channelAddresses()) + + val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, session.state.value!!) + assertEquals(listOf(privatePlane.publicKeyHex), subs.filter { it.channelId?.channelId == secretIdHex }.map { it.pubKeyHex }) + + val msg = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, channelEpoch, "members only", 4L) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msg)) + assertEquals(1, captured.count { it.content == "members only" }) + + // Bound to the ROOT epoch on the private plane: a binding mismatch, dropped. + val wrongEpoch = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, community.rootEpoch, "wrong epoch", 5L) + session.ingest(wrongEpoch) + assertTrue(captured.none { it.content == "wrong epoch" }) + } + + @Test + fun aKeyDeliveredLaterIsAdoptedInPlace() = + runTest { + val (community, control) = communityWithPrivateChannel() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + control.forEach { session.ingest(it) } + assertNull(session.currentChannelPlane(secretIdHex)) + + val withKey = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 1L))) + assertTrue(session.adoptPrivateChannels(withKey)) + assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 1L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex) + assertFalse(session.adoptPrivateChannels(withKey), "adopting the same keys again is a no-op") + // The buffered Control Plane survived: still folded. + assertEquals( + "Nostrichs", + session.state.value + ?.metadata + ?.name, + ) + } + + @Test + fun aKeyArrivingWithControlMaterialIsStillAdoptedThroughTheRegistry() = + runTest { + val (community, control) = communityWithPrivateChannel() + // A plain member: holds the control_pk but not the control_root. + val member = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + relays = listOf("wss://r.example"), + ) + val registry = ConcordSessionRegistry() + registry.sync(listOf(member), owner.pubKey) + val session = registry.sessionFor(community.communityIdHex)!! + control.forEach { session.ingest(it) } + assertNull(session.currentChannelPlane(secretIdHex)) + + // One list update delivers both the staff write key and the private channel key: the + // Control adoption swaps the entry first, and the channel planes must still follow. + registry.sync(listOf(entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 2L)))), owner.pubKey) + assertEquals(session, registry.sessionFor(community.communityIdHex), "adopted in place, not rebuilt") + assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 2L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex) + } + + @Test + fun aDeleteRidesTheChannelPlaneAndLandsAsAChannelBoundKind5() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val captured = mutableListOf() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + community.genesisWraps.forEach { session.ingest(it) } + val plane = session.currentChannelPlane(community.generalChannelIdHex)!! + + session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "oops", 2L)) + val message = captured.single { it.content == "oops" } + + val delete = ConcordActions.buildChannelDelete(owner, plane.key, plane.channelIdHex, plane.epoch, listOf(message), 3L) + // The wrap is authored by the channel plane, never the author: nothing outside the + // community learns the rumor id. + assertEquals(plane.key.publicKeyHex, delete.pubKey) + assertEquals(ConcordStreamEnvelope.KIND_WRAP, delete.kind) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(delete)) + val kind5 = captured.single { it.kind == 5 } + assertEquals(listOf(message.id), kind5.tags.filter { it[0] == "e" }.map { it[1] }) + assertEquals(listOf("9"), kind5.tags.filter { it[0] == "k" }.map { it[1] }) + assertTrue(ChannelChat.isBoundTo(kind5, plane.channelIdHex, plane.epoch)) + + // A delete of an older message goes back to the plane that carried it. + assertEquals(plane, session.channelPlaneFor(plane.channelIdHex, plane.epoch)) + } + + @Test + fun theHistoricalPlaneOfAnOlderMessageIsFoundForItsDelete() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val priorRoot = KeyPair().pubKey + val session = ConcordCommunitySession(entryFor(community, heldRoots = listOf(HeldRoot(7L, priorRoot.toHexKey()))), owner.pubKey) + community.genesisWraps.forEach { session.ingest(it) } + val prior = session.channelPlaneFor(community.generalChannelIdHex, 7L) + assertEquals(ConcordActions.publicChannel(priorRoot, community.generalChannelId, 7L).publicKeyHex, prior?.key?.publicKeyHex) + } + + @Test + fun chatIngestDropsOtherPlanesKindsAndPlaintextSeals() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val captured = mutableListOf() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + community.genesisWraps.forEach { session.ingest(it) } + val plane = session.currentChannelPlane(community.generalChannelIdHex)!! + val binding = arrayOf(arrayOf("channel", plane.channelIdHex), arrayOf("epoch", plane.epoch.toString())) + + // A channel key-holder forging a Control edition (kind 3308) into the chat plane. + val forgedControl = RumorAssembler.assembleRumor(owner.pubKey, 2L, 3308, binding, "{}") + session.ingest(ConcordStreamEnvelope.wrap(forgedControl, plane.key, owner, encrypted = true)) + // A Guestbook join (3306) likewise. + val forgedJoin = RumorAssembler.assembleRumor(owner.pubKey, 3L, 3306, binding, "join") + session.ingest(ConcordStreamEnvelope.wrap(forgedJoin, plane.key, owner, encrypted = true)) + // A proper chat message in a plaintext (Control-only) seal. + val plaintextSeal = ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, "plaintext", 4L) + session.ingest(ConcordStreamEnvelope.wrap(plaintextSeal, plane.key, owner, encrypted = false)) + // A chat message with a malformed ms. + val badMs = RumorAssembler.assembleRumor(owner.pubKey, 5L, 9, binding + arrayOf(arrayOf("ms", "01")), "bad ms") + session.ingest(ConcordStreamEnvelope.wrap(badMs, plane.key, owner, encrypted = true)) + + assertTrue(captured.isEmpty(), "none of these may reach the store: ${captured.map { it.kind }}") + + // And the good path still lands. + session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "ok", 6L)) + assertEquals(listOf("ok"), captured.map { it.content }) + } + + @Test + fun channelMessagesSortByTheMillisecondBasis() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val plane = ConcordActions.currentChannelPlane(entryFor(community), community.generalChannelIdHex, isPrivate = false)!! + + suspend fun wrap( + text: String, + secs: Long, + ms: Int, + ) = ConcordStreamEnvelope.wrap(ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, text, secs, ms = ms), plane.key, owner, encrypted = true) + val msgs = ConcordActions.channelMessages(listOf(wrap("third", 11, 0), wrap("second", 10, 950), wrap("first", 10, 100)), plane.key, plane.channelIdHex, plane.epoch) + assertEquals(listOf("first", "second", "third"), msgs.map { it.content }) + } + + @Test + fun aChannelNameOverTheCapIsNeverMinted() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + assertFailsWith { + ConcordModeration.defineChannel(owner, community.controlPlane, secretId, ChannelEntity(name = "x".repeat(65)), community.genesisEditions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.defineChannel(owner, community.controlPlane, secretId, ChannelEntity(name = ""), community.genesisEditions, 2L, owner = community.ownerPubKey) + } + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index e50c6fd0f3..1003a3ee26 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -599,6 +599,7 @@ Add a banner Where this community's encrypted planes are published and read. This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. + This is a private channel and you don't hold its key, so you can't read it or post here. Name About (optional) Ban diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index 54f5d4a7aa..d29cb0a26a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -515,10 +515,10 @@ class AccountViewModel( reaction: String, ) { // Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto - // public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an - // existing Concord reaction is a follow-up; for now this only adds one.) + // public relays. Route the reaction through a channel-plane wrap instead; tapping it again + // retracts it with an in-channel kind-5 delete (CORD-01), never a NIP-17 one. if (note.inGatherers?.any { it is ConcordChannel } == true) { - launchSigner { account.concord.reactToConcordMessage(note, reaction) } + launchSigner { account.concord.toggleConcordReaction(note, reaction) } return } diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 712d3bf2f3..f6fdd10cdf 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -41,15 +41,15 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| | S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 | -| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | open → chat-plane batch | -| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | open → chat-plane batch | +| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) | +| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way | | S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch | | S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | open → control-plane batch | | S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | open → control-plane batch | | S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | open → control-plane batch | | S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch | -| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | open → control-plane + chat-plane batches | -| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch | +| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | chat half **fixed** — `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too. Control half → control-plane batch | +| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting | | S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch | | S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch | | S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch | @@ -71,10 +71,10 @@ Ranked security > interop > feature inside each group. | I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch | | I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch | | I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | open → rekey/invite batch | -| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | open → chat-plane batch | -| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch | -| I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch | -| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch | +| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` | +| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one | +| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) | +| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages | | I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | open → control-plane batch | | I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | open → rekey/invite batch |