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