diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index aa9a38f897..fc03aa2f99 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Event @@ -965,6 +966,11 @@ object LocalPreferences { LatestEventSlot.EPHEMERAL_LIST to settings.backupEphemeralChatList?.let { OptimizedJsonMapper.toJson(it) }, LatestEventSlot.RELAY_GROUP_LIST to settings.backupRelayGroupList?.let { OptimizedJsonMapper.toJson(it) }, LatestEventSlot.CONCORD_LIST to settings.backupConcordList?.let { OptimizedJsonMapper.toJson(it) }, + // Event JSON never holds a raw newline, so one event per line is unambiguous. + LatestEventSlot.CONCORD_LIST_FRAGMENTS to + settings.backupConcordListFragments + .takeIf { it.isNotEmpty() } + ?.joinToString("\n") { OptimizedJsonMapper.toJson(it) }, LatestEventSlot.TRUST_PROVIDER_LIST to settings.backupTrustProviderList?.let { OptimizedJsonMapper.toJson(it) }, LatestEventSlot.KEY_PACKAGE_RELAY_LIST to settings.backupKeyPackageRelayList?.let { OptimizedJsonMapper.toJson(it) }, LatestEventSlot.FAVORITE_ALGO_FEEDS_LIST to settings.backupFavoriteAlgoFeedsList?.let { OptimizedJsonMapper.toJson(it) }, @@ -1457,6 +1463,7 @@ object LocalPreferences { backupEphemeralChatList = latestEphemeralListResolved, backupRelayGroupList = latestRelayGroupListResolved, backupConcordList = latestConcordListResolved, + backupConcordListFragments = parseConcordListFragments(stores.latestEvents[LatestEventSlot.CONCORD_LIST_FRAGMENTS]), backupTrustProviderList = latestTrustProviderListResolved, backupKeyPackageRelayList = latestKeyPackageRelayListResolved, backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved, @@ -1623,6 +1630,16 @@ object LocalPreferences { } } + /** + * The saved kind-33302 Community List fragments, one event JSON per line. Kept out of the + * account loader's coroutine body, which already sits at the JVM's method-size limit. + */ + private fun parseConcordListFragments(value: String?): List = + value + ?.split('\n') + ?.mapNotNull { parseEventOrNull(it) } + .orEmpty() + private inline fun parseEventOrNull(value: String?): T? { if (value.isNullOrEmpty() || value == "null") { return null 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/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index fcb92a4a6a..9f67f7629f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -399,12 +399,8 @@ fun ConcordChannelListScreen( items(channels, key = { it.key }) { entry -> val def = entry.value.definition val name = def.name.ifBlank { entry.key } - val icon = - when { - def.voice == true -> MaterialSymbols.Mic - def.private == true -> MaterialSymbols.Lock - else -> MaterialSymbols.Tag - } + // Every Channel is callable (CORD-07), so there is no voice-only icon. + val icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag val typingAuthors = remember(typingMap, typingNow, entry.key) { (typingMap[entry.key] ?: emptyMap()) @@ -417,7 +413,6 @@ fun ConcordChannelListScreen( channelKey = entry.key, channelName = name, icon = icon, - isVoice = def.voice == true, typingAuthors = typingAuthors, canManageChannels = canManageChannels, accountViewModel = accountViewModel, @@ -445,7 +440,6 @@ private fun ConcordChannelListRow( channelKey: String, channelName: String, icon: MaterialSymbol, - isVoice: Boolean, typingAuthors: List, canManageChannels: Boolean, accountViewModel: AccountViewModel, @@ -506,7 +500,7 @@ private fun ConcordChannelListRow( // Line 2: the last-message preview (or a live "typing…"), then the unread-message badge. Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Box(Modifier.weight(1f)) { - ConcordChannelPreviewLine(lastNote, isVoice, typingAuthors, accountViewModel) + ConcordChannelPreviewLine(lastNote, typingAuthors, accountViewModel) } ConcordUnreadBadge(unread) } @@ -535,7 +529,6 @@ private val FAB_CLEARANCE = 96.dp @Composable private fun ConcordChannelPreviewLine( lastNote: Note?, - isVoice: Boolean, typingAuthors: List, accountViewModel: AccountViewModel, ) { @@ -572,8 +565,6 @@ private fun ConcordChannelPreviewLine( } else if (event != null) { event.content.take(80) } else { - // Voice channels never carry chat notes, so "No messages yet" would read oddly — leave blank. - if (isVoice) return stringRes(Res.string.concord_channel_no_messages) } Text( 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/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt index 01c517f2b3..62045385f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordHomeScreen.kt @@ -224,12 +224,7 @@ fun ConcordHomeScreen( communityId = entry.id, channelKey = ch.key, channelName = def.name.ifBlank { ch.key }, - icon = - when { - def.voice == true -> MaterialSymbols.Mic - def.private == true -> MaterialSymbols.Lock - else -> MaterialSymbols.Tag - }, + icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag, hideIfRead = mode == ChannelExpand.UNREAD, accountViewModel = accountViewModel, onClick = { nav.nav(Route.Concord(entry.id, ch.key)) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt index 8b09e9d267..9b040cfbdd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/BottomBarSettingsScreen.kt @@ -685,12 +685,7 @@ private fun ConcordServerPickerGroup( channels.forEach { channel -> val entry = BottomBarEntry.ConcordChannel(community.id, channel.channelIdHex, community.relays) val def = channel.definition - val icon = - when { - def.voice -> MaterialSymbols.Mic - def.private -> MaterialSymbols.Lock - else -> MaterialSymbols.Tag - } + val icon = if (def.private) MaterialSymbols.Lock else MaterialSymbols.Tag AvailableRow( leading = { LeadingGlyph(icon) }, label = def.name.ifBlank { channel.channelIdHex.take(8) }, diff --git a/cli/README.md b/cli/README.md index a6eb26f49e..519e277554 100644 --- a/cli/README.md +++ b/cli/README.md @@ -668,23 +668,28 @@ author** — every 46010 gate names its approver in a `p` tag — and matched to Encrypted, serverless communities (the CORD specs). Community secrets persist in `~/.amy//concord.json`; your joined-community list is -also carried on-relay as an encrypted kind:13302. +also carried on-relay as the encrypted, fragmented kind:33302 Community List +(CORD-02 §8; the retired kind:13302 is still read on import). | Command | What it does | |---|---| | `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 kind:13302 community list (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 invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. | +| `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; `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 (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator). | | `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. | +| `amy concord join URL` | Redeem an invite link, save the community, and publish a Guestbook Join echoing the link's attribution (CORD-05 §1/§6). | +| `amy concord rekey [COMMUNITY]` | Follow a Refounding we were re-keyed for. Honors only a BAN-holding rotator whose `vac` cites a Grant our fold has synced (the owner cites none); racing rotations converge on the lowest root (CORD-06 §3). | +| `amy concord recover [COMMUNITY] [--rejoin]` | Report whether a Refounding left us behind (our joined-through link resolves to a higher epoch). A bundle never moves the base on its own (CORD-06 §2); `--rejoin` explicitly re-accepts the link. Ban-gated, fails closed. | +| `amy concord refound COMMUNITY --remove U[,U…]` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after; reserves its keys so a re-run resumes with the same root; refused for a dissolved community. | | `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04). | -| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). | +| `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`; also `MANAGE_ROLES`, `MANAGE_CHANNELS`, `MANAGE_METADATA`, `MANAGE_MESSAGES`, `CREATE_INVITE`, `VIEW_AUDIT_LOG`, `MENTION_EVERYONE`, `PIN_MESSAGES`). | | `amy concord grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. | +| `amy concord dissolve COMMUNITY --yes` | Owner only, irreversible: publish the `eid`-bound dissolution tombstone that seals the community read-only (CORD-02 §9). | ### cordn (MLS over an MCP coordinator) 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 618ae59581..312c3b88d3 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.utils.TimeUtils @@ -56,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, "voice" to it.definition.voice, "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), + ) }, ), ) @@ -79,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)) @@ -126,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)) @@ -152,13 +178,23 @@ object ConcordChannelCommands { ): ConcordCommunityState { val controlPlane = ConcordCommands.controlPlaneKeysFor(sc) val relays = ConcordCommands.relaysFor(ctx, sc) - // The relays gate the plane's kind-1059 behind NIP-42 as the stream key — register it so - // the drain's AUTH challenge is answered as the control plane, not the account. On a split - // epoch only staff hold that secret (CORD-02 §2); a plain member registers nothing and - // relies on the relay serving the plane unauthenticated. - ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey)) - val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(controlPlane.address)) }, pendingOnAuthRequired = true).map { it.second } - return ConcordActions.foldCommunity(wraps, controlPlane, sc.owner) + // The dissolution tombstone lives at its own id-derived address (CORD-02 §9), drained alongside. + val dissolved = ConcordDissolution.planeKey(sc.communityId) + val dissolvedAddress = dissolved.publicKeyHex + // The relays gate each plane's kind-1059 behind NIP-42 as the stream key — register them so + // the drain's AUTH challenge is answered as the plane, not the account. On a split epoch + // only staff hold the control secret (CORD-02 §2); a plain member relies on the relay + // serving that plane unauthenticated. The dissolved plane's key derives from the public + // community id, so every member can always answer for it. + ctx.registerConcordStreamKeys(relays, listOfNotNull(controlPlane.signer?.secretKey, dissolved.secretKey)) + val wraps = + ctx + .drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(listOf(controlPlane.address, dissolvedAddress))) }, pendingOnAuthRequired = true) + .map { it.second } + val (graveWraps, controlWraps) = wraps.partition { it.pubKey == dissolvedAddress } + return ConcordActions + .foldCommunity(controlWraps, controlPlane, sc.communityId.hexToByteArray(), sc.owner) + .withDissolved(ConcordDissolution.isDissolved(graveWraps, sc.communityId, sc.owner)) } /** Resolve a channel handle: the `general` shortcut, a full hex id, or a folded name/id-prefix match. */ 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 9e49d9b6e9..dd7a23c1f4 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,12 +27,19 @@ 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 import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution +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.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument @@ -40,6 +47,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -60,7 +68,7 @@ object ConcordCommands { | concord create --name NAME [--about T] create an encrypted Concord community | [--relay wss://a,wss://b] (--relays is accepted as an alias) | concord list list joined Concord communities - | concord import fetch + decrypt this account's kind:13302 + | concord import fetch + decrypt this account's kind:33302 | community list (carries heldRoots, CORD-06) | concord channels COMMUNITY list a community's channels | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) @@ -73,9 +81,11 @@ object ConcordCommands { | concord join URL redeem an invite link and save the community | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: | open our blob and adopt the new epoch - | concord recover [COMMUNITY] re-resolve the joined-through invite link and - | follow a Refounding we were left out of - | (CORD-06); refuses if that epoch banned us + | concord recover [COMMUNITY] [--rejoin] re-resolve the joined-through invite link and + | report whether a Refounding left us behind; + | --rejoin re-accepts that link (a bundle never + | moves the base on its own, CORD-06 §2); + | refuses if that epoch banned us | concord roles COMMUNITY list live roles + current banlist (CORD-04) | concord role COMMUNITY NAME POSITION PERM… define a role (perms by name, e.g. BAN KICK) | concord grant COMMUNITY USER ROLE-ID grant a role to a member @@ -84,6 +94,8 @@ object ConcordCommands { | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the | control_root) so removed members lose every | key — the hard removal a ban cannot give + | concord dissolve COMMUNITY --yes CORD-02 §9: owner-only, IRREVERSIBLE tombstone + | that seals the community read-only for everyone """.trimMargin() suspend fun dispatch( @@ -93,7 +105,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -114,6 +126,7 @@ object ConcordCommands { "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, + "dissolve" to { rest -> ConcordModCommands.dissolve(dataDir, rest) }, ), ) @@ -129,6 +142,9 @@ object ConcordCommands { val relaysAlias = args.flag("relays") val relayArg = parseRelays(args.flag("relay") ?: relaysAlias) args.rejectUnknown() + // CORD-02 §6 caps, which every reader also enforces at fold. + if (!ConcordLimits.nameFits(name)) return Output.error("bad_args", "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes").let { 2 } + if (!ConcordLimits.descriptionFits(about)) return Output.error("bad_args", "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes").let { 2 } Context.open(dataDir).use { ctx -> ctx.prepare() @@ -181,7 +197,8 @@ object ConcordCommands { } /** - * Fetch this account's own encrypted kind-13302 Concord community list, decrypt it, and + * Fetch this account's own encrypted Concord Community List (the kind-33302 fragments, plus the + * retired kind-13302 event as a rescue source), decrypt it, and * upsert every community into the local store — crucially carrying each community's * `heldRoots` (the prior-epoch access roots Amethyst accumulates across Refoundings, CORD-06). * With those persisted, `amy concord read --epoch ` can re-derive a pre-refounding Chat @@ -194,18 +211,20 @@ object ConcordCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val relays = (ctx.outboxRelays() + ctx.bootstrapRelays()) - val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey)) + // The fragmented List (33302, CORD-02 §8) plus the retired single event (13302), which is + // still read as a rescue source for memberships only it carries. + val filter = Filter(kinds = listOf(ConcordCommunityListFragmentEvent.KIND, ConcordCommunityListEvent.KIND), authors = listOf(ctx.signer.pubKey)) val events = ctx.drain(relays.associateWith { listOf(filter) }).map { it.second } - val newest = - events.filterIsInstance().maxByOrNull { it.createdAt } - ?: return Output.error("not_found", "no kind-13302 Concord list published by this account").let { 1 } - - val entries = - try { - newest.decrypt(ctx.signer) - } catch (e: Exception) { - return Output.error("decrypt_failed", "could not decrypt kind-13302: ${e.message}").let { 1 } - } + val set = ConcordListFragmentSet.resolve(events.filterIsInstance(), ctx.signer) + val legacy = events.filterIsInstance().maxByOrNull { it.createdAt } + if (set.isEmpty && legacy == null) { + return Output.error("not_found", "no Concord Community List (kind 33302 or 13302) published by this account").let { 1 } + } + val legacyPlaintext = legacy?.decryptPlaintext(ctx.signer) + if (set.held.isEmpty() && legacyPlaintext == null) { + return Output.error("decrypt_failed", "could not decrypt this account's Concord Community List").let { 1 } + } + val entries = ConcordCommunityList.decodeDocument(ConcordCommunityList.readWithLegacy(set, legacyPlaintext)).entries val store = ConcordStore(dataDir.concordFile) val existing = store.load().associateBy { it.communityId } val imported = @@ -239,6 +258,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( @@ -269,7 +289,9 @@ object ConcordCommands { ctx.prepare() // The joiner cannot derive the Control Plane address, so the invite carries it // (CORD-05 §1); omitted for a legacy community, which has none to carry. - val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }) + // The creator rides in the bundle so joiners echo it in their Guestbook Join (CORD-05 §1). + val invite = ConcordActions.inviteFor(sc.communityId, sc.owner, sc.ownerSalt, sc.root, sc.rootEpoch, sc.name, sc.relays, sc.controlPk.ifBlank { null }, creator = ctx.signer.pubKey) + // At most 3 bootstrap relays ride in the fragment (CORD-05 §3); the codec truncates. val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), sc.relays) // Record the link BEFORE publishing the bundle (CORD-05, kind 13303): a link whose // `signer_sk` was never stored can never be refreshed, so the next Refounding orphans @@ -417,7 +439,7 @@ object ConcordCommands { // relay that kept the old version hand out a link its creator revoked — and it cannot // tell the user which of "revoked", "expired" or "gone" they are looking at. val bundle = - when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) { is InviteBundleStatus.Live -> status.invite is InviteBundleStatus.Expired -> return Output.error("expired", "this invite link has expired and can no longer be joined") InviteBundleStatus.Revoked -> return Output.error("revoked", "this invite link was revoked by its creator") @@ -449,11 +471,11 @@ object ConcordCommands { if (joinEditions.isEmpty()) { return Output.error("control_plane_unreadable", "could not fold this community's Control Plane, so whether it has banned you is unknown — refusing to join") } - if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(ctx.signer.pubKey)) { + if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(ctx.signer.pubKey)) { return Output.error("banned", "this community has banned this account; the link works but the roster does not admit you (CORD-04)") } - ConcordStore(dataDir.concordFile).upsert( + val stored = StoredCommunity( name = bundle.name, communityId = bundle.communityId, @@ -468,15 +490,54 @@ 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) ?: "", - ), - ) - Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) + privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, + ) + ConcordStore(dataDir.concordFile).upsert(stored) + + // Announce the membership (CORD-05 §6 / CORD-02 §5): a Guestbook Join is how a later + // Refounding finds this member to re-key, and it echoes the link's attribution so link + // holders can count per-link joins. Best-effort, like every Guestbook motion. + val announced = announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label) + Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays, "guestbook_join" to announced)) return 0 } } // ---- shared helpers (used by ConcordChannelCommands too) ------------------ + private val HEX64 = Regex("^[0-9a-f]{64}$") + + /** Publishes a Guestbook Join for [sc] at its current epoch, echoing invite attribution; true if a relay took it. */ + suspend fun announceGuestbookJoin( + ctx: Context, + sc: StoredCommunity, + inviteCreator: String?, + inviteLabel: String?, + ): Boolean { + val creator = inviteCreator?.lowercase()?.takeIf { HEX64.matches(it) } + val label = inviteLabel?.takeIf { creator != null && it.isNotBlank() } + val guestbook = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val wrap = ConcordActions.buildGuestbookJoin(ctx.signer, guestbook, TimeUtils.now(), creator, label) + val relays = relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, listOf(guestbook.secretKey)) + return ctx.publish(wrap, relays).values.any { it.accepted } + } + + /** + * Whether [sc] carries a valid owner tombstone (CORD-02 §9). Death wins every race: no rekey, + * recovery or Refounding moves a dissolved community forward. + */ + suspend fun isDissolved( + ctx: Context, + sc: StoredCommunity, + ): Boolean { + val grave = ConcordDissolution.planeKey(sc.communityId) + val relays = relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, listOf(grave.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(grave.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + return ConcordDissolution.isDissolved(wraps, sc.communityId, sc.owner) + } + fun parseRelays(csv: String?): List = csv?.split(",")?.map { it.trim() }?.filter { it.isNotBlank() } ?: emptyList() fun normalize(urls: List): Set = urls.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet() @@ -519,7 +580,7 @@ object ConcordCommands { editions: List, ): Pair? { val entry = entryFor(sc) - val authority = AuthorityResolver.resolve(editions, sc.owner) + val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner) val delivered = ConcordReceive.deliveredControlRoot(entry, editions, authority, ctx.signer) ?: return null val updated = sc.copy(controlRoot = delivered) ConcordStore(dataDir.concordFile).upsert(updated) @@ -537,6 +598,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 }, @@ -555,26 +617,25 @@ 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) }, ) /** - * `concord recover [COMMUNITY]` — the stranded-recovery receive path (CORD-05/06 A2). + * `concord recover [COMMUNITY] [--rejoin]` — stranded detection (CORD-05/06). * * A Refounding carries only `(newRoot, newEpoch, rotator)` and **no recipient list**, so a - * member simply left out of the rekey receives nothing and sits on the dead epoch forever while - * everyone else moves on. There is no message to miss, which is why the rekey drain cannot help. - * The way back is the invite link the membership was joined through: the community keeps - * re-minting its bundle at the same addressable coordinate, so a live bundle at a **strictly - * higher** epoch than ours proves we were left behind — and carries the new root. + * member simply left out of the rekey receives nothing and sits on the dead epoch while + * everyone else moves on. The invite link the membership was joined through is re-minted at + * the current epoch, so a live bundle there at a **strictly higher** epoch says we were left + * behind. * - * Amethyst sweeps this on a timer; amy makes it an explicit verb, so it stays deterministic and - * scriptable rather than a background loop. + * A bundle is NOT proof of continuity (nothing binds `community_root` to `community_id`), so + * this verb only reports by default — a link creator must not be able to relocate everyone who + * joined through their link (CORD-06 §2: the base moves only by a verifiable rekey). + * `--rejoin` is the user explicitly re-accepting that link: the same trust decision as `join`. * - * The ban gate is the point of care. A removed member keeps the link's unlock token forever, so - * without it this walks them straight back into the epoch they were rotated out of. It reads the - * banlist of the epoch we are **leaving** (the last Control Plane we can still fold) and **fails - * closed**: a community whose plane will not fold yields no verdict and is skipped, never - * recovered. + * Ban-gated at the epoch we are leaving and **fails closed** (no fold, no verdict, no rejoin); + * a dissolved community is never moved (CORD-02 §9). */ private suspend fun recover( dataDir: DataDir, @@ -582,6 +643,7 @@ object ConcordCommands { ): Int { val args = Args(rest) val handle = args.positionalOrNull(0) + val rejoin = args.bool("rejoin") args.rejectUnknown() val store = ConcordStore(dataDir.concordFile) val targets = @@ -595,54 +657,68 @@ object ConcordCommands { ctx.prepare() val results = mutableListOf>() for (sc in targets) { + fun skip(reason: String) = mapOf("community_id" to sc.communityId, "name" to sc.name, "stranded" to false, "recovered" to false, "reason" to reason) + val inviteRef = sc.inviteRef.ifBlank { null } if (inviteRef == null) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_invite_ref") + results += skip("no_invite_ref") continue } val parsed = ConcordActions.parseInviteLink(inviteRef) if (parsed == null) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "bad_invite_ref") + results += skip("bad_invite_ref") + continue + } + if (isDissolved(ctx, sc)) { + results += skip("dissolved") continue } val relays = (normalize(parsed.fragment.relays) + normalize(sc.relays)).ifEmpty { ctx.outboxRelays() } val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }).map { it.second } - // Only a LIVE bundle recovers: an expired or revoked link is not a rotation we missed. - val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite + // Only a LIVE bundle counts: an expired or revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite if (bundle == null) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "no_live_bundle") + results += skip("no_live_bundle") continue } - // Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict, - // no recovery — the gate fails closed rather than assuming "not banned". + // Fold the epoch we are leaving to learn whether it banned us. No fold, no verdict. val cp = controlPlaneKeysFor(sc) ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } val editions = ConcordActions.controlEditions(controlWraps, cp) if (editions.isEmpty()) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded") + results += skip("control_plane_not_folded") continue } - val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey) - - val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere) - if (merged == null) { + val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey) + val entry = entryFor(sc) + if (!ConcordActions.isStranded(entry, bundle, bannedHere)) { + results += skip(if (bannedHere) "banned" else "already_current") + ("root_epoch" to sc.rootEpoch) + continue + } + if (!rejoin) { results += mapOf( "community_id" to sc.communityId, "name" to sc.name, + "stranded" to true, "recovered" to false, - "reason" to if (bannedHere) "banned" else "already_current", + "reason" to "rejoin_required", "root_epoch" to sc.rootEpoch, + "link_epoch" to bundle.rootEpoch, ) continue } - store.upsert(storedFrom(sc, merged)) + val merged = ConcordActions.rejoinStranded(entry, bundle, bannedHere) ?: continue + val stored = storedFrom(sc, merged) + store.upsert(stored) + announceGuestbookJoin(ctx, stored, bundle.creatorNpub, bundle.label) results += mapOf( "community_id" to sc.communityId, "name" to sc.name, + "stranded" to true, "recovered" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to merged.rootEpoch, @@ -662,8 +738,11 @@ object ConcordCommands { * strands every other CLI member even though their blob is sitting on the relay. * * The rotator is authorized against the roster of the epoch being **left** — `hasPermission`, - * never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed: - * a plane that will not fold yields no verdict and the community is skipped. + * never `effectivePermissions`, so a banned BAN-holder cannot rotate us — and must cite the + * Grant it acts under (`vac`, CORD-06 §3) at a version that fold has synced; the owner cites + * nothing. Racing honored rotations converge on the lowest new root. Fails closed: a plane that + * will not fold yields no verdict and the community is skipped. A dissolved community is never + * moved (CORD-02 §9). */ private suspend fun rekey( dataDir: DataDir, @@ -679,21 +758,12 @@ object ConcordCommands { ctx.prepare() val results = mutableListOf>() for (sc in targets) { + if (isDissolved(ctx, sc)) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "dissolved", "root_epoch" to sc.rootEpoch) + continue + } val relays = relaysFor(ctx, sc) - val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) - ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey)) - val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } - val received = - ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch) - if (received == null) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch) - continue - } - if (received.newEpoch <= sc.rootEpoch) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch) - continue - } - // Authorize the rotator against the epoch we are LEAVING — the last plane we can fold. + // Authorize against the epoch we are LEAVING — the last plane we can fold. val cp = controlPlaneKeysFor(sc) ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } @@ -702,12 +772,28 @@ object ConcordCommands { results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded") continue } - if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) { - results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator) + val entry = entryFor(sc) + val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner) + val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) } + + val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + val received = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch, accept = honored) + if (received == null) { + // Distinguish "no blob at all" from "only rotations we refuse to honor". + val any = ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch) + val reason = if (any == null) "no_blob_for_us" else "unauthorized_rotator" + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to reason, "root_epoch" to sc.rootEpoch) + (if (any != null) mapOf("rotator" to any.rotator) else emptyMap()) continue } - val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) - store.upsert(storedFrom(sc, adopted)) + if (received.newEpoch <= sc.rootEpoch) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch) + continue + } + val adopted = ConcordReceive.withAdoptedRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + // A rotation we adopted supersedes any Refounding of ours still reserved. + store.upsert(storedFrom(sc, adopted).copy(pendingRefounding = null)) results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to true, "from_epoch" to sc.rootEpoch, "root_epoch" to received.newEpoch, "rotator" to received.rotator) } Output.emit(mapOf("communities" to results)) 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 9b52786df6..8e5102440c 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 @@ -26,14 +26,20 @@ import com.vitorpamplona.amethyst.cli.DataDir 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.StoredPendingRefounding import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException +import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -55,7 +61,7 @@ object ConcordModCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val (_, editions) = load(ctx, sc, dataDir) - val state = ConcordCommunityState.fold(editions, sc.owner) + val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) Output.emit( mapOf( "roles" to @@ -91,6 +97,9 @@ object ConcordModCommands { val position = args.positional(2, "position").toLongOrNull() ?: return Output.error("bad_args", "position must be an integer").let { 2 } val permBits = args.positional.drop(3).mapNotNull { permByName(it) } args.rejectUnknown() + // The CORD-04 §2/§3 rules every reader enforces at fold, refused here as bad input. + if (!ConcordLimits.nameFits(name)) return Output.error("bad_args", "role name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes").let { 2 } + if (position < 1) return Output.error("bad_args", "position must be 1 or greater (position 0 is the owner's)").let { 2 } val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) Context.open(dataDir).use { ctx -> @@ -99,7 +108,7 @@ object ConcordModCommands { writeGuard(cp)?.let { return it } val roleId = RandomInstance.bytes(32) val role = RoleEntity(name = name, position = position, permissions = ConcordPermissions.of(*permBits.toIntArray()).toWire()) - val wrap = ConcordModeration.defineRole(ctx.signer, cp, roleId, role, editions, TimeUtils.now(), owner = sc.owner) + val wrap = ConcordModeration.defineRole(ctx.signer, cp, sc.communityId.hexToByteArray(), roleId, role, editions, TimeUtils.now(), owner = sc.owner) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } Output.emit(mapOf("role_id" to roleId.toHexKey(), "name" to name, "position" to position) + RawEventSupport.ackFields(ack)) @@ -157,6 +166,38 @@ object ConcordModCommands { rest: Array, ): Int = banOrUnban(dataDir, rest, ban = true) + /** + * Dissolves a community (CORD-02 §9): `dissolve --yes`. Publishes the owner-signed, + * `eid`-bound tombstone at the community's dissolved address. Owner-only and irreversible, hence + * the mandatory `--yes`. + */ + suspend fun dissolve( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val confirmed = args.bool("yes") + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + if (!confirmed) return Output.error("confirm", "dissolving '$handle' is irreversible; re-run with --yes") + + Context.open(dataDir).use { ctx -> + ctx.prepare() + if (!sc.owner.equals(ctx.signer.pubKey, ignoreCase = true)) { + return Output.error("not_owner", "only the owner can dissolve '$handle' (CORD-02 §9)") + } + val wrap = ConcordDissolution.build(ctx.signer, sc.communityId) + val relays = ConcordCommands.relaysFor(ctx, sc) + // A relay that gates the plane on NIP-42 wants AUTH as the stream key the wrap is signed by. + ctx.registerConcordStreamKeys(relays, listOf(ConcordDissolution.planeKey(sc.communityId).secretKey)) + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + Output.emit(mapOf("community" to sc.communityId, "dissolved" to true) + RawEventSupport.ackFields(ack)) + return 0 + } + } + /** Unbans a member: `unban `. */ suspend fun unban( dataDir: DataDir, @@ -249,9 +290,14 @@ object ConcordModCommands { .toSet() if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + // Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored. + if (ConcordCommands.isDissolved(ctx, sc)) { + return Output.error("dissolved", "community '$handle' has been dissolved; a Refounding cannot cross the tombstone (CORD-02 §9)") + } + val loaded = load(ctx, sc, dataDir) val (cp, editions) = loaded - val state = ConcordCommunityState.fold(editions, sc.owner) + val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) val authority = state.authority val me = ctx.signer.pubKey @@ -269,8 +315,22 @@ object ConcordModCommands { // split epoch it takes the current control_root (CORD-02 §2). writeGuard(cp)?.let { return it } + // The rotation cites the Grant it acts under (CORD-06 §3 "Authority"), or no receiver + // honors it; the owner cites nothing. + val citation = ConcordReceive.rotationCitation(ConcordCommands.entryFor(loaded.community), editions, me) + if (citation == null && !authority.isOwner(me)) { + return Output.error("forbidden", "no Grant of yours in this community's fold to cite; receivers would drop the rotation (CORD-06 §3)") + } + val relays = ConcordCommands.relaysFor(ctx, sc) + // 0. Acquire the WHOLE plane before the first publish (CORD-06 §3: a Refounder that cannot + // fold every Control event must abort). Paged to completion, not a single capped REQ. + val swept = ctx.drainAllPages(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }).map { it.second } + if (swept.isEmpty()) { + return Output.error("control_plane_unreadable", "could not page this community's Control Plane; refusing to compact a partial plane (CORD-06 §3)") + } + // 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the // new epoch — carries the ban. Each edition chains onto the updated banlist head. var chain = editions @@ -297,45 +357,67 @@ object ConcordModCommands { // 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff // get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one). - val newRoot = RandomInstance.bytes(32) - val newControlRoot = RandomInstance.bytes(32) - // Compact from what we KNOW the plane holds: the wraps we drained plus the bans we just + // The keys are RESERVED and persisted before anything is published, so a retried + // `refound` re-delivers the same root instead of minting a sibling (CORD-06 §3). + val priorRoot = sc.root.hexToByteArray() + val keys = + ConcordRefounding.reserveKeys( + loaded.community.pendingRefounding?.let { PendingRefounding(sc.communityId, it.rootEpoch, it.prevCommit, it.newRoot.hexToByteArray(), it.newControlRoot.hexToByteArray()) }, + sc.communityId, + sc.rootEpoch, + priorRoot, + ) + val reserved = loaded.community.copy(pendingRefounding = StoredPendingRefounding(keys.rootEpoch, keys.prevCommit, keys.newRoot.toHexKey(), keys.newControlRoot.toHexKey())) + ConcordStore(dataDir.concordFile).upsert(reserved) + // Compact from what we KNOW the plane holds: the paged sweep plus the bans we just // published. Re-draining alone would race the relay's indexing, and a relay that has not // yet echoed the ban back (or that ACKed and stored nothing) would produce a new epoch // whose roster never banned the member we are removing. - val drained = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } - val controlWraps = (drained + banWraps).distinctBy { it.id } + val controlWraps = (swept + banWraps).distinctBy { it.id } val build = - ConcordActions.buildRefounding( - rotatorSigner = ctx.signer, - communityId = sc.communityId, - priorRoot = sc.root.hexToByteArray(), - newRoot = newRoot, - newControlRoot = newControlRoot, - rootEpoch = sc.rootEpoch, - priorControlWraps = controlWraps, - priorControlKeys = cp, - recipientsXOnly = recipients, - staffXOnly = authority.staffMembers(), - createdAt = TimeUtils.now(), - ownerPubKey = sc.owner, - ) + try { + ConcordActions.buildRefounding( + rotatorSigner = ctx.signer, + communityId = sc.communityId, + priorRoot = priorRoot, + newRoot = keys.newRoot, + newControlRoot = keys.newControlRoot, + rootEpoch = sc.rootEpoch, + priorControlWraps = controlWraps, + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = authority.staffMembers(), + createdAt = TimeUtils.now(), + ownerPubKey = sc.owner, + authority = citation, + // Every head our own fold honors (bans included) must survive the compaction. + mustCarry = ConcordRefounding.headVersions(chain, sc.communityId.hexToByteArray(), sc.owner), + ) + } catch (e: IncompleteControlPlaneException) { + return Output.error("control_plane_incomplete", "${e.missing.size} Control Plane head(s) could not be carried into the new epoch; aborted before publishing the rotation (CORD-06 §3)") + } - // 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it). - build.controlWraps.forEach { ctx.publish(it, relays) } - build.rekeyWraps.forEach { ctx.publish(it, relays) } + // 4. The root roll FIRST, every chunk confirmed; the compacted plane only after it + // (CORD-06 §3). A chunk no relay took aborts with nothing adopted — the reserved keys + // make re-running this command re-deliver the same root. + for (wrap in build.rekeyWraps) { + if (ctx.publish(wrap, relays).values.none { it.accepted }) { + return Output.error("rekey_not_published", "a rekey chunk was not accepted by any relay; re-run to resume with the same keys") + } + } + val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } } // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the - // epoch we are leaving for the anti-rollback floor. + // epoch we are leaving for the anti-rollback floor — and drop the reservation. val adopted = ConcordReceive.withAdoptedRoot( ConcordCommands.entryFor(loaded.community), - newRoot, + keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), - newControlRoot, + keys.newControlRoot, ) - val stored = ConcordCommands.storedFrom(loaded.community, adopted) + val stored = ConcordCommands.storedFrom(loaded.community, adopted).copy(pendingRefounding = null) ConcordStore(dataDir.concordFile).upsert(stored) // 6. Refresh every link we minted, at its OWN coordinate, so it now resolves to the new @@ -363,7 +445,7 @@ object ConcordModCommands { // grants, icon, label — survive the rotation, and so a coordinate whose newest // event is a revocation tombstone is left revoked instead of being re-opened. val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.bundleFilter(link.signerPubKeyHex())) }).map { it.second } - val live = ConcordActions.classifyInvite(wraps, token) as? InviteBundleStatus.Live ?: return@runCatching + val live = ConcordActions.classifyInvite(wraps, link.signerPubKeyHex(), token) as? InviteBundleStatus.Live ?: return@runCatching val moved = live.invite.copy( communityRoot = stored.root, @@ -385,6 +467,7 @@ object ConcordModCommands { "recipients" to recipients.size, "control_wraps" to build.controlWraps.size, "rekey_wraps" to build.rekeyWraps.size, + "compaction_failures" to compactionFailures, "invites_refreshed" to refreshed, ), ) @@ -453,12 +536,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 @@ -513,6 +599,9 @@ object ConcordModCommands { "BAN" -> ConcordPermissions.BAN "MANAGE_MESSAGES" -> ConcordPermissions.MANAGE_MESSAGES "CREATE_INVITE" -> ConcordPermissions.CREATE_INVITE + "VIEW_AUDIT_LOG" -> ConcordPermissions.VIEW_AUDIT_LOG + "MENTION_EVERYONE" -> ConcordPermissions.MENTION_EVERYONE + "PIN_MESSAGES" -> ConcordPermissions.PIN_MESSAGES else -> null } } 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..cbfc24fd48 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,29 @@ 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(), + // Keys reserved for a Refounding this account started but has not yet adopted (CORD-06 §3): a + // retried `refound` must re-deliver the SAME root, never mint a sibling that splits the members. + val pendingRefounding: StoredPendingRefounding? = null, +) + +/** 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 Refounding's reserved keys, mirroring quartz `PendingRefounding`. */ +data class StoredPendingRefounding( + val rootEpoch: Long = 0, + val prevCommit: String = "", + val newRoot: String = "", + val newControlRoot: 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..40ff004467 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,8 +29,10 @@ 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.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.ConcordDirectInvite @@ -68,15 +70,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 +148,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* @@ -190,6 +282,19 @@ object ConcordActions { rootEpoch: Long, ): GroupKey = ConcordKeyDerivation.baseRekeyAddress(communityRoot, communityId, rootEpoch + 1) + /** + * The base-rekey address the rotation INTO [entry]'s current epoch rode on, derived from the + * prior epoch's (canonical) held root — or null when we hold none (a fresh joiner at this + * epoch). Watching it after adopting is what lets the same-epoch race heal (CORD-06 §3): a + * racing sibling rotation sealed under the same prior root arrives here, and a strictly lower + * one replaces the root we adopted. + */ + fun siblingBaseRekeyPlane(entry: ConcordCommunityListEntry): GroupKey? { + if (entry.rootEpoch <= 0) return null + val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: return null + return ConcordKeyDerivation.baseRekeyAddress(prior.key.hexToByteArray(), entry.id.hexToByteArray(), entry.rootEpoch) + } + // ---- relay filters (what to REQ) ----------------------------------------- /** Wraps at a plane/channel address: kind-1059 events authored by the stream key. */ @@ -226,21 +331,26 @@ object ConcordActions { icon: ImagePointer? = null, ): NewConcordCommunity = ConcordCommunityFactory.create(ownerSigner, name, createdAt, description, relays, icon) - /** Opens the control-plane [wraps] into their [ControlEdition]s (drops any that don't open/parse). */ + /** + * Opens the control-plane [wraps] into their [ControlEdition]s, dropping any that don't open or + * parse — including an edition under an encrypted seal, which the Control Plane never carries + * (CORD-02 §5: its seals MUST be plaintext kind 20014). + */ fun controlEditions( wraps: List, controlPlane: ControlPlaneKeys, ): List = wraps.mapNotNull { wrap -> - ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromRumor(it.rumor) } + ConcordStreamEnvelope.openOrNull(wrap, controlPlane)?.let { ControlEdition.fromOpened(it) } } /** Opens the control-plane [wraps] and folds them into the live community state. */ fun foldCommunity( wraps: List, controlPlane: ControlPlaneKeys, + communityId: ByteArray, ownerPubKey: HexKey, - ): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), ownerPubKey) + ): ConcordCommunityState = ConcordCommunityState.fold(controlEditions(wraps, controlPlane), communityId, ownerPubKey) // ---- channel chat --------------------------------------------------------- @@ -344,6 +454,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 +504,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 +514,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 +533,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 -------------------------------------------------------------- @@ -426,6 +565,8 @@ object ConcordActions { name: String, relays: List, controlPk: HexKey? = null, + creator: HexKey? = null, + label: String? = null, ): CommunityInvite = CommunityInvite( communityId = communityIdHex, @@ -436,6 +577,10 @@ object ConcordActions { controlPk = controlPk, relays = relays, name = name, + // Optional attribution (CORD-05 §1): echoed in the joiner's Guestbook Join, so link + // holders can count per-link usage. Inside the token-encrypted bundle only. + creatorNpub = creator, + label = label, ) /** Mints a shareable public invite link + bundle event (see [ConcordInviteBundle.mintLink]). */ @@ -495,15 +640,25 @@ object ConcordActions { fun bareInviteRef(url: String): String? = ConcordInviteLink.bareForm(url) /** - * Merges a stranded membership forward onto a higher-epoch [bundle] resolved at - * its own stored invite link, or null when there is nothing to recover. See - * [ConcordStrandedRecovery]. + * True when a live [bundle] resolved at [entry]'s own stored invite link says a Refounding + * left us behind (a higher epoch, and we are not banned). Detection only: a bundle may never + * move a held community's base on its own (CORD-06 §2) — see [ConcordStrandedRecovery]. */ - fun recoverStranded( + fun isStranded( entry: ConcordCommunityListEntry, bundle: CommunityInvite, bannedAtCurrentEpoch: Boolean, - ): ConcordCommunityListEntry? = ConcordStrandedRecovery.mergeForward(entry, bundle, bannedAtCurrentEpoch) + ): Boolean = ConcordStrandedRecovery.isStranded(entry, bundle, bannedAtCurrentEpoch) + + /** + * The entry after the user **explicitly** re-accepts the invite link a stranded [entry] was + * joined through, or null when not stranded. Only ever from a user action — never a sweep. + */ + fun rejoinStranded( + entry: ConcordCommunityListEntry, + bundle: CommunityInvite, + bannedAtCurrentEpoch: Boolean, + ): ConcordCommunityListEntry? = ConcordStrandedRecovery.rejoinForward(entry, bundle, bannedAtCurrentEpoch) /** Decrypts + validates a fetched bundle event with the link token; null if invalid. */ fun openBundle( @@ -516,13 +671,15 @@ object ConcordActions { * [InviteBundleStatus] (live / expired / revoked / unreadable / absent) per CORD-05 * §2, so a redeeming client honours a `vsk=9` revocation tombstone and an * `expires_at` in the past, and reports why a link can't be opened instead of - * retrying blindly. [nowMs] is unix milliseconds. + * retrying blindly. [nowMs] is unix milliseconds. Only events genuinely at the link's + * coordinate count — signed by [linkSignerPubKey], `d == ""` — never what a relay claims is. */ fun classifyInvite( wraps: List, + linkSignerPubKey: HexKey, token: ByteArray, nowMs: Long = TimeUtils.nowMillis(), - ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, token, nowMs) + ): InviteBundleStatus = ConcordInviteBundle.classify(wraps, linkSignerPubKey, token, nowMs) /** * The Control Plane keys described by a redeemed [invite] so the joiner can @@ -610,6 +767,8 @@ object ConcordActions { staffXOnly: Set, createdAt: Long, ownerPubKey: HexKey, + authority: AuthorityCitation? = null, + mustCarry: Map = emptyMap(), ): RefoundingBuild = ConcordRefounding.build( rotatorSigner = rotatorSigner, @@ -624,6 +783,8 @@ object ConcordActions { staffXOnly = staffXOnly, createdAt = createdAt, ownerPubKey = ownerPubKey, + authority = authority, + mustCarry = mustCarry, ) /** @@ -632,7 +793,9 @@ object ConcordActions { * scope, epoch and continuity against the [priorRoot] the member holds — and, * on a staff blob, that the delivered `control_root` derives to the delivered * `control_pk` (CORD-06 §1). Returns the new root + Control keys + rotator - * (for the caller to authorize) or null if not re-keyed. + * or null if not re-keyed. [accept] is the caller's authority check (see + * [ConcordReceive.isHonoredRotation]); racing rotations it admits converge on + * the lowest new root (CORD-06 §3). */ suspend fun openBaseRekey( wraps: List, @@ -641,5 +804,6 @@ object ConcordActions { communityId: HexKey, priorRoot: ByteArray, rootEpoch: Long, - ): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch) + accept: (ReceivedRefounding) -> Boolean = { true }, + ): ReceivedRefounding? = ConcordRefounding.findNewRoot(wraps, baseRekey, recipientSigner, communityId.hexToByteArray(), priorRoot, rootEpoch, accept) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt index e5ba1e55d1..8881ee593b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModeration.kt @@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitations import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder @@ -41,6 +43,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer @@ -52,12 +55,19 @@ import kotlinx.serialization.builtins.serializer * re-encryption across epochs) and wrapped on the community's Control Plane. The * caller passes the community's **current** editions so this can chain the next * version onto the entity's head (`version = head.version + 1`, `prevHash = - * head.hash`) and union the banlist. Authority is enforced at *fold* time by the - * `AuthorityResolver`, not here — an edition whose author doesn't outrank its - * target (or trace to the owner via [citation]) is simply dropped by every client. + * head.hash`, a new entity starting at version 1) and read the banlist. Authority is + * enforced at *fold* time by the `AuthorityResolver`, not here — an edition whose + * author doesn't outrank its target is simply dropped by every client. * - * The owner needs no [citation]; a delegated moderator must cite the grant they - * act under so the fold can verify the chain terminates at the owner. + * Every non-owner edition carries the `vac` authority citation (CORD-04 §1/§5): the + * actor's own Grant head as the same [current] editions fold it + * ([AuthorityCitations.forActor]), which every reader, the reference client included, + * requires before honoring the action. The owner cites nothing. A caller may still pass + * an explicit [AuthorityCitation] to override it. + * + * The CORD-04 §2 / CORD-02 §6 caps ([ConcordLimits]) are enforced here too: an edition + * every reader would drop is refused with an [IllegalArgumentException] rather than + * published. */ object ConcordModeration { /** @@ -83,42 +93,66 @@ object ConcordModeration { */ private fun headOf( current: List, + communityId: ByteArray, entityId: ByteArray, owner: HexKey, - ): ControlEdition? = ConcordCommunityState.authorizedHeads(current, owner)[entityId.toHexKey()]?.known + ): ControlEdition? = ConcordCommunityState.authorizedHeads(current, communityId, owner)[entityId.toHexKey()]?.known - /** version/prevHash to chain onto the current head of [entityId], or genesis. */ - private fun versioning( - current: List, - entityId: ByteArray, - owner: HexKey, - ): Pair { - val head = headOf(current, entityId, owner) - return if (head != null) (head.version + 1) to head.hash else 0L to null - } + /** version/prevHash to chain onto the current head of [entityId], or a genesis at version 1 (CORD-04 §1). */ + private fun versioning(head: ControlEdition?): Pair = if (head != null) (head.version + 1) to head.hash else 1L to null private suspend fun wrap( actor: NostrSigner, controlPlane: ControlPlaneKeys, + communityId: ByteArray, kind: ControlEntityKind, entityId: ByteArray, - version: Long, - prevHash: ByteArray?, + head: ControlEdition?, content: String, + current: List, createdAt: Long, citation: AuthorityCitation?, + owner: HexKey, ): Event { - val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, citation) + val (version, prevHash) = versioning(head) + val vac = citation ?: AuthorityCitations.forActor(current, communityId, owner, actor.pubKey) + val rumor = ControlEditionBuilder.rumor(actor.pubKey, kind, entityId, version, prevHash, content, createdAt, vac) return ConcordStreamEnvelope.wrap(rumor, controlPlane, actor, encrypted = false, createdAt = createdAt) } + /** + * Writes [value] as the next edition of [entityId]: laid over the current authorized head's + * content, so every field the head carries that [serializer] does not model survives the edit + * (CORD-02 §6 — renaming never wipes another client's `custom` or a newer protocol field), + * chained onto that head and cited. + */ + private suspend fun edit( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + kind: ControlEntityKind, + entityId: ByteArray, + serializer: KSerializer, + value: T, + current: List, + createdAt: Long, + citation: AuthorityCitation?, + owner: HexKey, + ): Event { + val head = headOf(current, communityId, entityId, owner) + val content = ConcordJson.encodePreserving(serializer, value, head?.content) + return wrap(actor, controlPlane, communityId, kind, entityId, head, content, current, createdAt, citation, owner) + } + /** * Defines (or updates) a role. [roleId] is the role's stable 32-byte entity id - * — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it. + * — generate one for a new role and reuse it to edit or [RoleEntity.deleted] it. The + * content always carries it as `role_id` (CORD-04 §2), which the reference client requires. */ suspend fun defineRole( actor: NostrSigner, controlPlane: ControlPlaneKeys, + communityId: ByteArray, roleId: ByteArray, role: RoleEntity, current: List, @@ -126,21 +160,23 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { - val (version, prev) = versioning(current, roleId, owner) - val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role) - return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) + require(ConcordLimits.nameFits(role.name)) { "role name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" } + // CORD-04 §3: position 0 is the owner's alone — refuse here rather than have every reader drop it. + require(role.deleted || role.position >= 1) { "role position must be 1 or greater (position 0 is the owner's)" } + val stamped = role.copy(roleId = roleId.toHexKey()) + return edit(actor, controlPlane, communityId, ControlEntityKind.ROLE, roleId, RoleEntity.serializer(), stamped, current, createdAt, citation, owner) } /** * Defines (or updates) a channel (CORD-03/04, `vsk=2`). [channelId] is the channel's stable * 32-byte entity id — generate one for a new channel and reuse it to rename, flip its * private/voice flags, or [ChannelEntity.deleted] it (terminal; the id is never reused). - * Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner) tracing to the owner - * via [citation]. + * Honored at fold only when [actor] holds MANAGE_CHANNELS (or is the owner). */ suspend fun defineChannel( actor: NostrSigner, controlPlane: ControlPlaneKeys, + communityId: ByteArray, channelId: ByteArray, channel: ChannelEntity, current: List, @@ -148,16 +184,34 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { - val (version, prev) = versioning(current, channelId, owner) - val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel) - return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) + // Every reader drops an edition naming an empty or over-cap Channel (CORD-03 §2), so + // refuse to mint one rather than publish an edition nobody will honor. + require(channel.hasValidName()) { "Channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes" } + return edit(actor, controlPlane, communityId, ControlEntityKind.CHANNEL, channelId, ChannelEntity.serializer(), channel, current, createdAt, citation, owner) } + /** + * Sets the community's disappearing-messages timer (CORD-08 §1) to [secs] seconds, or turns it + * off when null. It is a metadata edition like any other — same chain, same MANAGE_METADATA + * gate — laid over the folded [standing] metadata so nothing else changes. + */ + suspend fun setMessageExpiration( + actor: NostrSigner, + controlPlane: ControlPlaneKeys, + communityId: ByteArray, + standing: MetadataEntity, + secs: Long?, + current: List, + createdAt: Long, + citation: AuthorityCitation? = null, + owner: HexKey, + ): Event = editMetadata(actor, controlPlane, communityId, standing.withMessageExpiration(secs), current, createdAt, citation, owner) + /** * Replaces the community metadata (name / icon / description / relays). The * metadata entity id is the community id itself (as in genesis), so this chains * the next version onto the metadata head. Honored at fold only when [actor] - * holds MANAGE_METADATA (or is the owner) tracing to the owner via [citation]. + * holds MANAGE_METADATA (or is the owner), and only within the CORD-02 §6 caps. */ suspend fun editMetadata( actor: NostrSigner, @@ -169,9 +223,9 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { - val (version, prev) = versioning(current, communityId, owner) - val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata) - return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation) + require(ConcordLimits.nameFits(metadata.name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" } + require(ConcordLimits.descriptionFits(metadata.description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" } + return edit(actor, controlPlane, communityId, ControlEntityKind.METADATA, communityId, MetadataEntity.serializer(), metadata, current, createdAt, citation, owner) } /** @@ -195,10 +249,10 @@ object ConcordModeration { owner: HexKey, controlWrap: String? = null, ): Event { + require(roleIds.size <= ConcordLimits.MAX_ROLES_PER_MEMBER) { "a member holds at most ${ConcordLimits.MAX_ROLES_PER_MEMBER} roles" } val entityId = ConcordKeyDerivation.grantCoordinate(communityId, member.hexToByteArray()) - val (version, prev) = versioning(current, entityId, owner) - val content = ConcordJson.instance.encodeToString(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap)) - return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation) + val grant = GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap) + return edit(actor, controlPlane, communityId, ControlEntityKind.GRANT, entityId, GrantEntity.serializer(), grant, current, createdAt, citation, owner) } /** @@ -227,7 +281,7 @@ object ConcordModeration { epoch: Long, ): Event { val wrap = - if (controlRoot != null && makesStaff(roleIds, current, owner)) { + if (controlRoot != null && makesStaff(roleIds, current, communityId, owner)) { ControlRootWrap.build(actor, member, epoch, controlRoot) } else { null @@ -239,14 +293,19 @@ object ConcordModeration { fun makesStaff( roleIds: List, current: List, + communityId: ByteArray, owner: HexKey, ): Boolean { if (roleIds.isEmpty()) return false - val roles = AuthorityResolver.resolve(current, owner).roles() + val roles = AuthorityResolver.resolve(current, communityId, owner).roles() return roleIds.any { roles[it]?.permissionBits()?.hasAny(ConcordPermissions.STAFF_BITS) == true } } - /** Adds [member] to the banlist (union with the current head). */ + /** + * Adds [member] to the banlist, written over the current folded head. Another admin's + * concurrent edition at the same version may win the fold (CORD-04 §4); calling this again + * after the refold re-applies the ban atop the winner — the spec's re-heal. + */ suspend fun ban( actor: NostrSigner, controlPlane: ControlPlaneKeys, @@ -271,19 +330,19 @@ object ConcordModeration { ): Event = setBanlist(actor, controlPlane, communityId, currentBanned(current, communityId, owner) - member.lowercase(), current, createdAt, citation, owner) /** - * The current banlist union across the head editions (lowercase hex). + * The current banlist (lowercase hex): the folded head's list. * * Read through the [AuthorityResolver] rather than by decoding the head's content directly, so - * this is the *honored* banlist: the resolver heals concurrent forks into the union (CORD-04 §4 - * re-heal) and drops entries whose signer did not outrank them (§3's rank rule, enforced as a - * delta rule). Decoding the raw head instead would make every ban/unban we author re-publish - * entries our own fold refuses — laundering an unauthorized ban into a list signed by us. + * this is the *honored* banlist: the resolver drops entries whose signer did not outrank them + * (§3's rank rule, enforced as a delta rule). Decoding the raw head instead would make every + * ban/unban we author re-publish entries our own fold refuses — laundering an unauthorized ban + * into a list signed by us. */ fun currentBanned( current: List, communityId: ByteArray, owner: HexKey, - ): Set = AuthorityResolver.resolve(current, owner).bannedMembers() + ): Set = AuthorityResolver.resolve(current, communityId, owner).bannedMembers() private suspend fun setBanlist( actor: NostrSigner, @@ -296,8 +355,8 @@ object ConcordModeration { owner: HexKey, ): Event { val entityId = ConcordKeyDerivation.banlistCoordinate(communityId) - val (version, prev) = versioning(current, entityId, owner) + val head = headOf(current, communityId, entityId, owner) val content = ConcordJson.instance.encodeToString(ListSerializer(String.serializer()), banned.sorted()) - return wrap(actor, controlPlane, ControlEntityKind.BANLIST, entityId, version, prev, content, createdAt, citation) + return wrap(actor, controlPlane, communityId, ControlEntityKind.BANLIST, entityId, head, content, current, createdAt, citation, owner) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt index b807ae24de..6a70c3ff29 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.actions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions @@ -29,6 +30,9 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority +import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -106,6 +110,71 @@ object ConcordReceive { rotator: HexKey, ): Boolean = authority.isOwner(rotator) || authority.hasPermission(rotator, ConcordPermissions.BAN) + /** + * Whether a received base rotation may be adopted (CORD-06 §3 "Authority"): its rotator holds + * BAN (or is the owner) in our fold, AND it cites the Grant it acts under (`vac`) at a version + * our fold has synced — so a just-demoted admin's rotation is never honored by a client that + * lags the demotion, and a rotation citing a Grant we have not seen yet waits for it. The owner + * cites nothing. [editions] are the current epoch's Control editions the citation is checked + * against. + */ + fun isHonoredRotation( + entry: ConcordCommunityListEntry, + editions: Collection, + authority: AuthorityResolver, + received: ReceivedRefounding, + ): Boolean { + if (!isAuthorizedRotator(authority, received.rotator)) return false + val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner) + return ConcordRotationAuthority.citationSatisfied(entry.id, received.rotator, entry.owner, received.authority, heads) + } + + /** + * The `vac` citation [actor] stamps on a rotation it launches (CORD-06 §3): their own Grant's + * head in our fold, or null for the owner (who cites nothing). + */ + fun rotationCitation( + entry: ConcordCommunityListEntry, + editions: Collection, + actor: HexKey, + ): AuthorityCitation? = ConcordRotationAuthority.citationFor(entry.id, actor, entry.owner, ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner)) + + /** + * The down-only same-epoch heal (CORD-06 §3): [entry] holds a root at its current epoch, and + * [sibling] is a rotation to that same epoch (from the same prior root) that we did not adopt. + * Returns the entry moved onto the sibling when its root is **strictly lower** — the losing + * (higher) root we held is kept as a held root of the same epoch, so the messages sent into + * that fork stay readable — or null when the sibling does not win. + * + * The losing root keeps no control material: its Control Plane is the losing fork's + * compaction, not the community's ([ConcordRefounding.canonicalHeldRoots] never folds it). + */ + fun withHealedRoot( + entry: ConcordCommunityListEntry, + sibling: ReceivedRefounding, + ): ConcordCommunityListEntry? { + if (sibling.newEpoch != entry.rootEpoch) return null + if (!ConcordRefounding.healsTo(entry.root.hexToByteArray(), sibling.newRoot)) return null + return ConcordCommunityListEntry( + id = entry.id, + owner = entry.owner, + ownerSalt = entry.ownerSalt, + root = sibling.newRoot.toHexKey(), + rootEpoch = entry.rootEpoch, + // The control pair is the winner's blob's, never inherited from the losing fork. + controlPk = sibling.newControlPk?.toHexKey(), + controlRoot = sibling.newControlRoot?.toHexKey(), + heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch to it.key.lowercase() }, + privateChannels = entry.privateChannels, + relays = entry.relays, + name = entry.name, + addedAt = entry.addedAt, + inviteRef = entry.inviteRef, + excludedAtEpoch = entry.excludedAtEpoch, + residue = entry.residue, + ) + } + /** * The entry that results from adopting a base rotation to [newEpoch] — a pure rewrite, so the * caller can diff, persist and publish it however its platform does. @@ -133,7 +202,9 @@ object ConcordReceive { rootEpoch = newEpoch, controlPk = newControlPk?.toHexKey(), controlRoot = newControlRoot?.toHexKey(), - heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch }, + // Keyed by (epoch, key), not epoch alone: a healed race leaves a losing fork's root at the + // same epoch, kept so its messages stay readable (CORD-06 §3). + heldRoots = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() }, privateChannels = entry.privateChannels, relays = entry.relays, name = entry.name, 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 213121d0d4..18f47edd00 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 @@ -25,7 +25,9 @@ import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -77,7 +79,9 @@ object ConcordSubscriptionPlanner { // the entry, per epoch, exactly as it was delivered. val cp = ConcordActions.controlPlaneKeysFor(e) val historical = - e.heldRoots + // Losing-fork roots of a healed race are kept for their messages only (CORD-06 §3). + ConcordRefounding + .canonicalHeldRoots(e.heldRoots) .filter { it.epoch < e.rootEpoch } .sortedByDescending { it.epoch } .take(ConcordActions.MAX_BACKFILL_EPOCHS) @@ -92,7 +96,8 @@ object ConcordSubscriptionPlanner { * The off-channel planes every joined community subscribes to upfront (known * from the entry alone): the Guestbook Plane (membership motions) and the * next-epoch base-rekey address (so an inbound Refounding is received live, - * CORD-06). Both are kind-1059 wraps authored by their derived stream address. + * CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059 + * wraps authored by their derived stream address. */ fun auxiliaryPlaneSubs(entries: List): List = entries.flatMap { e -> @@ -101,9 +106,14 @@ object ConcordSubscriptionPlanner { val relays = normalize(e.relays) val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch) val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch) - listOf( + val dissolved = ConcordDissolution.planeKey(e.id) + val sibling = ConcordActions.siblingBaseRekeyPlane(e) + listOfNotNull( ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays), ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays), + ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays), + // The current epoch's own rekey address, so a racing sibling can heal us (CORD-06 §3). + sibling?.let { ConcordPlaneSub(channelId = null, pubKeyHex = it.publicKeyHex, relays = relays) }, ) } @@ -118,24 +128,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 7f82a96786..0ebe09d709 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 @@ -28,17 +28,25 @@ import com.vitorpamplona.amethyst.commons.model.ConcordInviteResult import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.filter import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel +import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannelListState import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession import com.vitorpamplona.amethyst.commons.model.concordChannelLastReadRoute +import com.vitorpamplona.amethyst.commons.util.ConcurrentSet import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeException 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.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -50,6 +58,10 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException +import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -57,13 +69,16 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PagedFetchResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPages import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm 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 @@ -71,10 +86,12 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap -import com.vitorpamplona.quartz.utils.concurrent.ConcurrentSet import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** Name of the default Concord community Admin role minted by "Make admin". */ private const val CONCORD_ADMIN_ROLE = "Admin" @@ -96,6 +113,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L */ private const val MAX_REFOUNDING_RECIPIENTS = 5_000 +/** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */ +private val HEX64 = Regex("^[0-9a-f]{64}$") + /** * Concord (encrypted communities) orchestration for an [Account]: join/create/ * invite flows, channel messages/reactions/edits/typing, roles and moderation, @@ -109,7 +129,7 @@ class AccountConcordActions( private val account: Account, ) { /** - * Add a joined Concord community (secret-bearing entry) to the private kind-13302 + * Add a joined Concord community (secret-bearing entry) to the private Community List (kind 33302) * list, and announce a self-signed Guestbook JOIN so this member is visible to * whoever later refounds the community (CORD-06 re-keys the Guestbook membership). */ @@ -118,10 +138,41 @@ class AccountConcordActions( inviteCreator: HexKey? = null, inviteLabel: String? = null, ) { - account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(entry)) + if (!persistConcordEntry(entry)) return announceConcordGuestbookJoin(entry, inviteCreator, inviteLabel) } + /** + * Makes the Community List fetched before any write can depend on it: an empty fragment set + * only means "no List" once the relays have been asked (CORD-02 §8 — a write built on an + * unloaded List replaces fragments another device published). + */ + private suspend fun ensureConcordListLoaded() { + if (!account.concordChannelList.relaysConfirmed) importConcordCommunities() + } + + /** + * Read-modify-writes the Community List through [change] and publishes the fragments it + * produced. Returns false — logged, never thrown into a UI coroutine — when the List can't be + * written safely yet (fragments unreadable or not loaded) or a fragment would pass the ceiling. + */ + private suspend fun writeConcordList(change: suspend (ConcordChannelListState) -> List): Boolean { + ensureConcordListLoaded() + return try { + account.sendMyPublicAndPrivateOutbox(change(account.concordChannelList)) + true + } catch (e: ConcordListIncompleteException) { + Log.w("Concord") { "Community List not written: ${e.message}" } + false + } catch (e: ConcordListTooLargeException) { + Log.w("Concord") { "Community List not written: ${e.message}" } + false + } + } + + /** Adds or replaces [entry] in the Community List; false when it could not be written. */ + private suspend fun persistConcordEntry(entry: ConcordCommunityListEntry): Boolean = writeConcordList { it.follow(entry) } + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ private suspend fun announceConcordGuestbookJoin( entry: ConcordCommunityListEntry, @@ -139,7 +190,7 @@ class AccountConcordActions( /** * Create a new Concord community: mint its genesis (metadata + #general), * publish the owner-signed genesis wraps to [relays] (or our outbox), and add - * the secret-bearing entry to the kind-13302 joined list. Returns the new + * the secret-bearing entry to the Community List (kind 33302). Returns the new * community id, or null if not writeable. */ suspend fun createConcordCommunity( @@ -149,6 +200,8 @@ class AccountConcordActions( icon: ImagePointer? = null, ): String? { if (!account.isWriteable()) return null + // CORD-02 §6 caps: every reader drops metadata past them, so never mint a genesis they'd refuse. + if (!ConcordLimits.nameFits(name) || !ConcordLimits.descriptionFits(description)) return null val relayUrls = relays.ifEmpty { account.outboxRelays.flow.value @@ -172,7 +225,7 @@ class AccountConcordActions( controlRoot = community.controlRoot.toHexKey(), relays = relayUrls, name = name, - addedAt = TimeUtils.now() * 1000, + addedAt = TimeUtils.nowMillis(), ), ) return community.communityIdHex @@ -291,7 +344,7 @@ class AccountConcordActions( val token = link.token.hexToByteArray() // Classify per coordinate, never over the pooled set: one link's newer // revocation tombstone must not decide another link's status. - val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false + val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), author, token) as? InviteBundleStatus.Live ?: return@runCatching false val moved = current.invite.copy( communityRoot = entry.root, @@ -349,7 +402,11 @@ class AccountConcordActions( // The joiner can never derive the Control Plane address, so the bundle carries // it (CORD-05 §1). Null on a legacy community, which has none to carry. controlPk = entry.controlPk, + // Attribution the joiner echoes in their Guestbook Join (CORD-05 §1). + creator = account.signer.pubKey, ) + // The fragment carries at most 3 bootstrap relays (CORD-05 §3); the codec truncates a longer + // list (the stock set stays a single flag), and the bundle keeps the full relay set. val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays) val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value } @@ -450,14 +507,14 @@ class AccountConcordActions( return true } - /** Drop a joined Concord community from the private kind-13302 list by its id. */ - suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) + /** Leave a joined Concord community: drop it from the Community List and tombstone it (CORD-02 §8). */ + suspend fun leaveConcordCommunity(communityId: String): Boolean = writeConcordList { it.unfollow(communityId) } /** * Redeem a Concord invite link (`…/invite/#`): parse it, fetch * the kind-33301 public bundle from the link's relays (+ our outbox), unlock it * with the fragment token, and add the resulting secret-bearing entry to the - * kind-13302 joined list. + * Community List (kind 33302). * * Returns a [ConcordInviteResult] that separates the failure modes so the UI can * both explain what went wrong and decide whether a retry could ever help — a @@ -494,7 +551,7 @@ class AccountConcordActions( // stale openable copy) so we honour revocation and can tell the user *why* a link won't open // instead of stranding them on a spinner that retries a link we can never redeem. val bundle = - when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) { + when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) { is InviteBundleStatus.Live -> status.invite is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked @@ -506,10 +563,25 @@ class AccountConcordActions( // Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an // old invite is reopened, so short-circuit to Joined — the screen forwards to the community // either way ("take me there", not "join again"). - if (account.concordChannelList.liveCommunities.value - .any { it.id == bundle.communityId } - ) { - return ConcordInviteResult.Joined(bundle.communityId) + // + // The one exception is a membership a Refounding left behind: the background sweep only + // DETECTS that (a bundle may never move a held community's base on its own, CORD-06 §2), so + // the user explicitly re-accepting the link is the way forward — the same trust decision as + // their first join, taken by them. + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == bundle.communityId } + var rejoined: ConcordCommunityListEntry? = null + if (held != null) { + val heldState = + account.concordSessions + .sessionFor(held.id) + ?.state + ?.value + // Death wins every race (CORD-02 §9): nothing moves a dissolved community forward. + if (heldState == null || heldState.dissolved) return ConcordInviteResult.Joined(bundle.communityId) + rejoined = ConcordActions.rejoinStranded(held, bundle, heldState.authority.isBanned(account.signer.pubKey)) + ?: return ConcordInviteResult.Joined(bundle.communityId) } // Refuse a link that readmits us after we were removed. A Refounding re-mints every @@ -544,10 +616,23 @@ class AccountConcordActions( ) { event, _ -> planeWraps.add(event) } val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys) if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable - if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) { + if (AuthorityResolver.resolve(joinEditions, bundle.communityId.hexToByteArray(), bundle.owner).isBanned(account.signer.pubKey)) { return ConcordInviteResult.Banned } + // Invite attribution (CORD-05 §1): the joiner echoes the link's creator + label in their + // Guestbook Join, which is what makes per-link usage counters possible. + val inviteCreator = bundle.creatorNpub?.lowercase()?.takeIf { HEX64.matches(it) } + val inviteLabel = bundle.label?.takeIf { inviteCreator != null && it.isNotBlank() } + + if (rejoined != null) { + if (!adoptedConcordRotations.add("${rejoined.id}:${rejoined.rootEpoch}")) return ConcordInviteResult.Joined(bundle.communityId) + Log.i("Concord") { "Stranded rejoin by explicit invite: ${rejoined.id} -> epoch ${rejoined.rootEpoch}" } + joinConcordCommunity(rejoined, inviteCreator, inviteLabel) + _strandedConcordCommunities.value -= rejoined.id + return ConcordInviteResult.Joined(bundle.communityId) + } + val entry = ConcordCommunityListEntry( id = bundle.communityId, @@ -558,15 +643,18 @@ 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, + addedAt = TimeUtils.nowMillis(), // Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a // Refounding that leaves us out of the recipient set is recoverable later. See // recoverStrandedConcordCommunities(). inviteRef = ConcordActions.bareInviteRef(url), ) - joinConcordCommunity(entry) + joinConcordCommunity(entry, inviteCreator, inviteLabel) return ConcordInviteResult.Joined(bundle.communityId) } @@ -589,7 +677,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). @@ -606,13 +697,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 @@ -634,14 +725,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 } @@ -663,9 +755,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 = @@ -673,7 +767,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 } @@ -684,7 +778,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( @@ -694,25 +789,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); @@ -734,8 +891,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) } @@ -895,7 +1052,12 @@ class AccountConcordActions( return true } - /** The default community Admin role: position 1, holding every management + moderation permission. */ + /** + * The default community Admin role: position 1, holding every currently-defined permission — + * the reference client's `ADMIN_ALL` bit for bit, so an Admin minted here can pin, read the + * audit log and mention everyone exactly like one minted there. There is no all-powerful bit + * (CORD-04 §3): a permission added later is not inherited. + */ private fun concordAdminRole() = RoleEntity( name = CONCORD_ADMIN_ROLE, @@ -910,6 +1072,9 @@ class AccountConcordActions( ConcordPermissions.BAN, ConcordPermissions.MANAGE_MESSAGES, ConcordPermissions.CREATE_INVITE, + ConcordPermissions.VIEW_AUDIT_LOG, + ConcordPermissions.MENTION_EVERYONE, + ConcordPermissions.PIN_MESSAGES, ).toWire(), ) @@ -956,7 +1121,7 @@ class AccountConcordActions( val roleIdHex = existing?.key ?: run { val roleId = RandomInstance.bytes(32) - val roleWrap = ConcordModeration.defineRole(account.signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val roleWrap = ConcordModeration.defineRole(account.signer, cp, communityId.hexToByteArray(), roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, roleWrap) roleId.toHexKey() } @@ -1079,6 +1244,12 @@ class AccountConcordActions( if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false val state = session.state.value ?: return false + // Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored, so a + // Refounding of a dissolved community would only strand whoever follows it. + if (state.dissolved) { + Log.w("Concord") { "Refusing to refound ${session.entry.id}: the community was dissolved (CORD-02 §9)" } + return false + } val authority = state.authority // hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol // and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the @@ -1100,6 +1271,19 @@ class AccountConcordActions( // compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A // rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery. val cp = controlKeysForWrite(session) ?: return false + val entry = session.entry + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isEmpty()) return false + + // 0. Acquire the whole Control Plane BEFORE the first publish (CORD-06 §3: "If the Refounder + // cannot reliably fold all Control events, the Refounding must be aborted"). The live + // buffer is whatever the subscription happened to deliver; a paged sweep that a majority + // of the community's relays drained is what makes the compaction the whole plane. + val swept = sweepConcordControlPlane(cp.address, publishTo) + if (swept == null) { + Log.w("Concord") { "Refounding ${entry.id} aborted: too few relays served the whole Control Plane" } + return false + } // 1. Ban the removed members on the current Control Plane so the compacted snapshot — // and thus the new epoch — carries the ban. publishConcordWrap folds it in locally @@ -1119,7 +1303,7 @@ class AccountConcordActions( // // Still a floor, not a census (see allMembers): a member who joined without a Guestbook // motion, holds no role, and has never posted leaves no trace to find, so a Refounding - // cannot re-key them. Stranded recovery is what gets those members back. + // cannot re-key them. val recipients = (session.allMembers() + account.signer.pubKey) .mapTo(HashSet()) { it.lowercase() } @@ -1129,52 +1313,113 @@ class AccountConcordActions( }.let { candidates -> boundRecipients(candidates, authority) } // 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs. - val entry = session.entry - val newRoot = RandomInstance.bytes(32) - // A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2), - // so a demoted staffer's retained secret dies with the epoch — and a legacy community - // upgrades to the split as a side effect of its next ban (CORD-06 §3). - val newControlRoot = RandomInstance.bytes(32) + // The keys are RESERVED per (epoch, prior root): a retry after a failed publish re-delivers + // the same root instead of minting a sibling that would split the members (CORD-06 §3). + // A fresh control_root rides beside the new root at every Refounding (CORD-02 §2), so a + // demoted staffer's retained secret dies with the epoch — and a legacy community upgrades + // to the split as a side effect of its next ban (CORD-06 §3). + val priorRoot = entry.root.hexToByteArray() + val keys = ConcordRefounding.reserveKeys(pendingConcordRefoundings[entry.id], entry.id, entry.rootEpoch, priorRoot) + pendingConcordRefoundings[entry.id] = keys + val editions = session.controlEditions() + // The rotation cites the Grant it acts under (CORD-06 §3 "Authority"); the owner cites none. + // A non-owner with no Grant in our own fold has nothing to cite, so no receiver would honor it. + val citation = ConcordReceive.rotationCitation(entry, editions, account.signer.pubKey) + if (citation == null && !authority.isOwner(account.signer.pubKey)) { + Log.w("Concord") { "Refounding ${entry.id} aborted: no Grant of ours to cite (CORD-06 §3)" } + return false + } // The staff set the new secret goes to: the owner plus everyone holding a // Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other // recipient the 104-byte one carrying the pubkey alone. (The builder mints a // blob per recipient, so staff who aren't recipients are simply never reached.) val staff = authority.staffMembers() val build = - ConcordActions.buildRefounding( - rotatorSigner = account.signer, - communityId = communityId, - priorRoot = entry.root.hexToByteArray(), - newRoot = newRoot, - newControlRoot = newControlRoot, - rootEpoch = entry.rootEpoch, - priorControlWraps = session.controlPlaneWraps(), - priorControlKeys = cp, - recipientsXOnly = recipients, - staffXOnly = staff, - createdAt = TimeUtils.now(), - ownerPubKey = entry.owner, - ) + try { + ConcordActions.buildRefounding( + rotatorSigner = account.signer, + communityId = communityId, + priorRoot = priorRoot, + newRoot = keys.newRoot, + newControlRoot = keys.newControlRoot, + rootEpoch = entry.rootEpoch, + priorControlWraps = (session.controlPlaneWraps() + swept).distinctBy { it.id }, + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = staff, + createdAt = TimeUtils.now(), + ownerPubKey = entry.owner, + authority = citation, + // Every head our own fold honors must survive into the new epoch. + mustCarry = ConcordRefounding.headVersions(editions, entry.id.hexToByteArray(), entry.owner), + ) + } catch (e: IncompleteControlPlaneException) { + Log.w("Concord", "Refounding ${entry.id} aborted: the Control Plane could not be folded in full", e) + return false + } - // 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs - // (the key that unlocks it) to the community relays. - val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } - if (publishTo.isNotEmpty()) { - build.controlWraps.forEach { account.client.publish(it, publishTo) } - build.rekeyWraps.forEach { account.client.publish(it, publishTo) } + // 4. The root roll FIRST, each chunk confirmed (CORD-06 §3): the compacted plane is + // republished only after the rekey blobs are known to have landed. A chunk no relay + // accepted aborts here with nothing adopted; the reserved keys make the retry idempotent. + for (wrap in build.rekeyWraps) { + if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) { + Log.w("Concord") { "Refounding ${entry.id} aborted: a rekey chunk was not accepted by any relay; retrying reuses the same root" } + return false + } } - // 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and - // re-folds the compacted Control Plane (with the ban), dropping the removed members. - val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot) + // 5. The compacted Control Plane, at the new epoch's address. The root roll is committed, so + // a head that fails to land is reported, not rolled back — members already hold the new + // root, and the next Refounding re-compacts from the same signed heads. + var compactionLanded = true + for (wrap in build.controlWraps) { + if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) compactionLanded = false + } + if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" } - // 6. Move every link we minted to the new epoch. Without this the Refounding orphans them, + // 6. Adopt the new epoch ourselves. This rebuilds our session under the new root and + // re-folds the compacted Control Plane (with the ban), dropping the removed members. + val adopted = adoptConcordRoot(entry, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), keys.newControlRoot) + pendingConcordRefoundings.remove(entry.id) + + // 7. Move every link we minted to the new epoch. Without this the Refounding orphans them, // and a member it left out — no rekey blob, no message to miss — has no way back at all. // Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so // reading it here would hand us the epoch we just left and re-mint every link onto it. val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0 Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" } - return true + return compactionLanded + } + + // Keys reserved for a Refounding in flight, per community (CORD-06 §3): a retry of the same + // rotation reuses them. Process-local — a restart mid-rotation mints afresh, which is why the + // rekey chunks are all confirmed before anything is adopted. + private val pendingConcordRefoundings = ConcurrentMap() + + /** + * Pages the whole Control Plane at [address] off every relay in [relays], or null when fewer + * than a majority of them drained it (a dead relay must not block rotation forever, but too few + * would compact a partial plane and roll the community back for everyone who follows). + */ + private suspend fun sweepConcordControlPlane( + address: HexKey, + relays: Set, + ): List? { + val filter = ConcordActions.planeFilter(address) + val perRelay = + coroutineScope { + relays + .map { relay -> + async { + val events = ArrayList() + val result = runCatching { account.client.fetchAllPages(relay, listOf(filter)) { events.add(it) } }.getOrNull() + if (result?.end == PagedFetchResult.End.DRAINED) events else null + } + }.awaitAll() + } + val drained = perRelay.filterNotNull() + if (drained.size < relays.size / 2 + 1) return null + return drained.flatten().distinctBy { it.id } } /** @@ -1245,7 +1490,11 @@ class AccountConcordActions( // is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and // the Guestbook re-announce below are Android's. val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot) - account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next)) + if (!persistConcordEntry(next)) { + // Not persisted, so not adopted: let the next drain retry it. + adoptedConcordRotations.remove("${entry.id}:$newEpoch") + return null + } announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null) return next } @@ -1267,43 +1516,79 @@ class AccountConcordActions( * * A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list, * so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the - * owner included) by omission — nothing on this receive path can prevent it. The - * cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves - * the invite link the membership was joined through and merges forward. + * owner included) by omission — nothing on this receive path can prevent it. + * [recoverStrandedConcordCommunities] detects it; re-opening the invite link rejoins. + * + * Also runs the same-epoch race heal (CORD-06 §3): racing rotations converge on the + * lowest authorized root, and a sibling seen after we adopted replaces our root only + * when strictly lower. Nothing is adopted for a dissolved community (CORD-02 §9). */ internal suspend fun drainConcordRekeys() { if (!account.isWriteable()) return for (session in account.concordSessions.sessions()) { - val wraps = session.pendingBaseRekeyWraps() - if (wraps.isEmpty()) continue val entry = session.entry - val received = + val state = session.state.value ?: continue + // Death wins every race (CORD-02 §9): no epoch advance past a tombstone is honored. + if (state.dissolved) continue + val authority = state.authority + val editions = session.controlEditions() + // Authority is the roster plus the cited Grant, never key possession (CORD-06 §3): + // hasPermission (not effectivePermissions, which ignores the banlist) and a `vac` our + // fold has synced, so a just-demoted admin's rotation is not honored while we lag. + val honored = { r: ReceivedRefounding -> ConcordReceive.isHonoredRotation(entry, editions, authority, r) } + + val wraps = session.pendingBaseRekeyWraps() + if (wraps.isNotEmpty()) { + // Racing authorized rotations converge on the lowest new root (CORD-06 §3). + val received = + ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = session.nextBaseRekeyKey(), + recipientSigner = account.signer, + communityId = entry.id, + priorRoot = entry.root.hexToByteArray(), + rootEpoch = entry.rootEpoch, + accept = honored, + ) + if (received != null && received.newEpoch > entry.rootEpoch) { + val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + // A rotation we did not launch superseded the one we may have had in flight. + pendingConcordRefoundings.remove(entry.id) + // Move our own links onto the epoch we just adopted. Rotating is not the only way + // to end up on a new epoch — being re-keyed is the common one — and a link creator + // who is merely re-keyed would otherwise leave every link they handed out pointing + // at the dead root. + adopted?.let { next -> + val moved = refreshConcordInviteLinks(next) + if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" } + } + continue + } + } + + // The same-epoch heal (CORD-06 §3): a racing rotation into the epoch we hold, sealed under + // the same prior root, wins only when its root is strictly lower. Our losing root stays + // held so the messages sent into that fork stay readable. + val siblingKey = session.siblingBaseRekeyKey() ?: continue + val siblingWraps = session.pendingSiblingRekeyWraps() + if (siblingWraps.isEmpty()) continue + val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).firstOrNull { it.epoch == entry.rootEpoch - 1 } ?: continue + val sibling = ConcordActions.openBaseRekey( - wraps = wraps, - baseRekey = session.nextBaseRekeyKey(), + wraps = siblingWraps, + baseRekey = siblingKey, recipientSigner = account.signer, communityId = entry.id, - priorRoot = entry.root.hexToByteArray(), - rootEpoch = entry.rootEpoch, + priorRoot = prior.key.hexToByteArray(), + rootEpoch = prior.epoch, + accept = honored, ) ?: continue - if (received.newEpoch <= entry.rootEpoch) continue - val authority = session.state.value?.authority ?: continue - - // hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder - // who has themselves been banned could still rotate the whole community. - val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN) - if (!authorized) continue - val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) - - // Move our own links onto the epoch we just adopted. Rotating is not the only way to end - // up on a new epoch — being re-keyed is the common one — and a link creator who is merely - // re-keyed would otherwise leave every link they handed out pointing at the dead root, - // which is exactly the orphaning this branch exists to stop. Stranded recovery reads the - // bundle's epoch, so a link nobody re-mints is a member nobody can recover. - adopted?.let { next -> - val moved = refreshConcordInviteLinks(next) - if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" } - } + val healed = ConcordReceive.withHealedRoot(entry, sibling) ?: continue + if (!adoptedConcordRotations.add("${healed.id}:${healed.rootEpoch}:${healed.root}")) continue + Log.i("Concord") { "Rekey race ${entry.id}: epoch ${entry.rootEpoch} converged on the lower sibling root" } + account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(healed)) + announceConcordGuestbookJoin(healed, inviteCreator = null, inviteLabel = null) + refreshConcordInviteLinks(healed) } } @@ -1333,12 +1618,18 @@ class AccountConcordActions( // [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's. val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue - account.sendMyPublicAndPrivateOutbox( - account.concordChannelList.follow(entry.withControlRoot(delivered)), - ) + persistConcordEntry(entry.withControlRoot(delivered)) } } + private val _strandedConcordCommunities = MutableStateFlow>(emptySet()) + + /** + * Communities whose own invite link resolves to a higher epoch than we hold — a Refounding left + * us behind (see [recoverStrandedConcordCommunities]). Re-opening that link rejoins them. + */ + val strandedConcordCommunities: StateFlow> = _strandedConcordCommunities.asStateFlow() + // Last time we re-resolved each community's invite_ref, so the recovery sweep rides the // Concord revision tick (which fires on every structural change) without turning it into a // relay-fetch loop. @@ -1352,20 +1643,18 @@ class AccountConcordActions( * included, and [drainConcordRekeys] cannot prevent it: there is no message to * miss detecting. * - * The way back is the invite link the membership was joined through + * The signal is the invite link the membership was joined through * ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and * carried through every rotation by [adoptConcordRoot]). The community keeps * re-minting its bundle at that same addressable coordinate, so a bundle there at - * a **strictly higher** epoch than ours proves we were left behind — and carries - * the new root. Same or lower epoch is a no-op. Memberships with no link (direct - * invites, legacy entries) are inert here; that is expected, not an error. + * a **strictly higher** epoch than ours says we were left behind. Memberships with + * no link (direct invites, legacy entries) are inert here. * - * The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps - * both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the - * entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays - * derivable). We then re-announce the Guestbook at the new epoch, exactly as an - * ordinary rotation does, so the recovered member is visible to whoever refounds - * next instead of being silently dropped again. + * Detection ONLY ([strandedConcordCommunities]). The bundle is not proof of + * continuity — nothing binds `community_root` to `community_id` — so adopting its + * root here would let any link creator relocate every member who joined through + * their link (CORD-06 §2: the base advances only by a verifiable rekey). The way + * forward is the user explicitly re-opening the link ([joinConcordViaInvite]). * * Called on the Concord revision tick, but rate-limited per community * ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup. @@ -1379,6 +1668,21 @@ class AccountConcordActions( if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue lastConcordRecoveryCheck[entry.id] = now + // Fails CLOSED: no fold, no verdict — a banned member's cold-start window must not read + // as "not banned". Retried on the next sweep once the roster is known. + val state = + account.concordSessions + .sessionFor(entry.id) + ?.state + ?.value + if (state == null) { + Log.i("Concord") { "Stranded check deferred for ${entry.id}: control plane not folded yet" } + lastConcordRecoveryCheck.remove(entry.id) + continue + } + // Death wins every race (CORD-02 §9): a dissolved community is never "behind". + if (state.dissolved) continue + val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue val relays = ( @@ -1389,36 +1693,14 @@ class AccountConcordActions( val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val wraps = account.client.fetchAll(filters = filters) - // Only a live bundle recovers: an expired/revoked link is not a rotation we missed. - val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue + // Only a live bundle counts: an expired/revoked link is not a rotation we missed. + val bundle = (ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue - // A removed member holds the link's unlock token forever, so without this the sweep - // walks them straight back into the epoch they were rotated out of — see A2 in - // docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last - // one whose Control Plane we can still fold. - // - // Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not - // exist yet or whose first fold has not landed, and this sweep runs on the revision tick - // — so a banned member's own client would have hit that window on cold start and - // recovered itself, which is precisely the bypass this gate exists to stop. No verdict - // means no recovery; the next sweep retries once the roster is known. - val authority = - account.concordSessions - .sessionFor(entry.id) - ?.state - ?.value - ?.authority - if (authority == null) { - Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" } - lastConcordRecoveryCheck.remove(entry.id) - continue - } - val bannedHere = authority.isBanned(account.signer.pubKey) - val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue - if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue - Log.i("Concord") { "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}" } - account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged)) - announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) + // Detection only (CORD-06 §2): the bundle is not proof of continuity, so it never moves + // our base. The user re-accepting the link is the way forward (joinConcordViaInvite). + val stranded = ConcordActions.isStranded(entry, bundle, state.authority.isBanned(account.signer.pubKey)) + _strandedConcordCommunities.value = if (stranded) _strandedConcordCommunities.value + entry.id else _strandedConcordCommunities.value - entry.id + if (stranded) Log.i("Concord") { "Stranded: ${entry.id} is at epoch ${entry.rootEpoch}, its invite link at ${bundle.rootEpoch}; re-open the link to rejoin" } } } @@ -1438,12 +1720,32 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false - val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays) + // Start from the folded metadata so a field this form doesn't edit — the CORD-08 timer, above + // all — is carried forward instead of reset (CORD-02 §6 round-trip). + val standing = session.state.value?.metadata ?: MetadataEntity() + val metadata = standing.copy(name = name, icon = icon, banner = banner, description = description, relays = relays) + // CORD-02 §6 caps are fold gates too: an edition past them would be dropped by every reader. + if (!ConcordLimits.metadataFits(metadata)) return false val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } + /** + * Dissolve [communityId] for good (CORD-02 §9): publish the owner-signed, `eid`-bound tombstone + * at the community's dissolved address. Owner-only — every verifier ignores anyone else's — and + * irreversible: there is no un-dissolve. Returns false when this account is not the owner or + * cannot sign. + */ + suspend fun dissolveConcordCommunity(communityId: String): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + if (!session.entry.owner.equals(account.signer.pubKey, ignoreCase = true)) return false + val wrap = ConcordDissolution.build(account.signer, communityId) + publishConcordWrap(session.entry, wrap) + return true + } + /** * Create a new public text channel in [communityId] (CORD-03/04 channel edition). Honored at fold * only when this account holds MANAGE_CHANNELS (or is the owner); the button should be gated on @@ -1458,7 +1760,9 @@ class AccountConcordActions( val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) - val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + // 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, communityId.hexToByteArray(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -1473,15 +1777,16 @@ class AccountConcordActions( if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false // Carry the standing definition forward and change only the name. A ChannelEntity built from - // scratch defaults `private` and `voice` to false, so renaming a private channel used to - // publish an edition declaring it PUBLIC — and a voice channel became a text channel. + // scratch defaults `private` to false, so renaming a private channel used to publish an + // edition declaring it PUBLIC. Fields we don't model ride through ConcordModeration. val standing = session.state.value ?.channels ?.get(channelIdHex) ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false) - val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val channel = (standing ?: ChannelEntity()).copy(name = name.trim()) + if (!channel.hasValidName()) return false + val wrap = ConcordModeration.defineChannel(account.signer, cp, communityId.hexToByteArray(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -1502,8 +1807,9 @@ class AccountConcordActions( ?.channels ?.get(channelIdHex) ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) - val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + val channel = (standing ?: ChannelEntity()).copy(name = name.trim(), deleted = true) + if (!channel.hasValidName()) return false + val wrap = ConcordModeration.defineChannel(account.signer, cp, communityId.hexToByteArray(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true } @@ -1521,25 +1827,28 @@ class AccountConcordActions( if (relays.isEmpty()) return null val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) } val wraps = account.client.fetchAll(filters = filters) - return wraps.firstNotNullOfOrNull { ConcordActions.openBundle(it, parsed.fragment.token) } + // Resolved like a join (newest per coordinate, signer-verified): a revoked link previews as + // nothing, never as the stale bundle a relay still serves. An expired one still renders. + return when (val status = ConcordActions.classifyInvite(wraps, parsed.linkSignerPubKey, parsed.fragment.token)) { + is InviteBundleStatus.Live -> status.invite + is InviteBundleStatus.Expired -> status.invite + else -> null + } } /** - * Bootstrap the Concord hub from the network: fetch this account's kind-13302 - * joined-communities list and fold the newest into [LocalCache], so communities - * we joined on another Concord client with this key surface here. + * Bootstrap the Concord hub from the network: fetch this account's Community List + * fragments (kind 33302, CORD-02 §8) and the retired kind-13302 list, and fold them into + * [LocalCache], so communities we joined on another Concord client with this key surface here. * * We query a wide relay set because different Concord clients publish this * private list to different places: the reference clients (Armada/Vector) push * it to the Concord **stock relays** (e.g. relay.ditto.pub), while a user may - * also have copied it onto their **own** outbox/read relays. Our normal account - * subscription never asks for kind 13302, so without this explicit fetch a - * community joined on Armada would never appear — even if the list sits on the - * user's own outbox. + * also have copied it onto their **own** outbox/read relays. * - * Read-only import: kind 13302 is replaceable, so folding an older copy is a - * no-op and this is safe to call on every hub open. Merging our own edits with - * a foreign writer's is a separate concern (newest-wins replaceable). + * Read-only except for one migration: when the relays hold no fragment at all but a 13302 + * list exists, its memberships are written out as fragments. Folding an older copy of + * either is a no-op, so this is safe to call on every hub open. * * [extraRelays] are additional relays to query — the bootstrap relays saved on the * bottom-bar tabs of pinned communities. A community's private list frequently lives @@ -1550,17 +1859,37 @@ class AccountConcordActions( val stock = InviteRelayDictionary.STOCK.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } val relays = (stock + account.mineRelays.flow.value + account.outboxRelays.flow.value + extraRelays).toSet() if (relays.isEmpty()) return - val filter = Filter(kinds = listOf(ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey)) + // The fragmented List (33302) plus the retired single event (13302), read once more as a + // rescue source so a membership only it carries is migrated by the next write. + val filter = Filter(kinds = listOf(ConcordCommunityListFragmentEvent.KIND, ConcordCommunityListEvent.KIND), authors = listOf(account.signer.pubKey)) // Stock relays like relay.ditto.pub can be slow (~10–20s to first response), so give // the fetch a generous window to drain every relay before we pick the newest copy. val events = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = 30_000L) - val newest = events.filterIsInstance().maxByOrNull { it.createdAt } - val entryCount = newest?.let { runCatching { it.decrypt(account.signer).size }.getOrElse { -1 } } ?: 0 + val fragments = events.filterIsInstance() + val legacy = events.filterIsInstance().maxByOrNull { it.createdAt } Log.d("Concord") { - "importConcordCommunities: queried ${relays.size} relays, fetched ${events.size} 13302 event(s), " + - "newest=${newest?.id?.take(8)}@${newest?.createdAt}, decoded $entryCount entr${if (entryCount == 1) "y" else "ies"}" + "importConcordCommunities: queried ${relays.size} relays, fetched ${fragments.size} 33302 fragment(s) " + + "and ${if (legacy == null) "no" else "a"} retired 13302 list" + } + fragments.forEach { account.cache.justConsumeMyOwnEvent(it) } + legacy?.let { account.cache.justConsumeMyOwnEvent(it) } + // The relays have now been asked: an empty fragment set from here on means "no List yet". + account.concordChannelList.markRelaysConfirmed() + // Seed the fragments from the retired event only once the relays confirmed none exist: a + // seeding write made while fragments are merely unloaded would replace them (CORD-02 §8). + if (fragments.isEmpty() && legacy != null) { + try { + val seeded = account.concordChannelList.republish() + if (seeded.isNotEmpty()) { + Log.d("Concord") { "importConcordCommunities: migrated the 13302 list into ${seeded.size} fragment(s)" } + account.sendMyPublicAndPrivateOutbox(seeded) + } + } catch (e: ConcordListIncompleteException) { + Log.w("Concord") { "importConcordCommunities: 13302 migration deferred: ${e.message}" } + } catch (e: ConcordListTooLargeException) { + Log.w("Concord") { "importConcordCommunities: 13302 migration deferred: ${e.message}" } + } } - newest?.let { account.cache.justConsumeMyOwnEvent(it) } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountSettings.kt index 475f4c3d17..3aa61ce30a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountSettings.kt @@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.Address @@ -211,6 +212,8 @@ class AccountSettings( var backupEphemeralChatList: EphemeralChatListEvent? = null, var backupRelayGroupList: SimpleGroupListEvent? = null, var backupConcordList: ConcordCommunityListEvent? = null, + /** The newest kind-33302 Community List fragment per index (CORD-02 §8). */ + var backupConcordListFragments: List = emptyList(), var backupTrustProviderList: TrustProviderListEvent? = null, var backupCashuWallet: CashuWalletEvent? = null, var backupNutzapInfo: NutzapInfoEvent? = null, @@ -1381,6 +1384,17 @@ class AccountSettings( override fun concordList() = backupConcordList + override fun concordListFragments() = backupConcordListFragments + + override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) { + val index = fragment.index() ?: return + val prior = backupConcordListFragments.firstOrNull { it.index() == index } + // Newest copy per index, as a relay resolves the coordinate (ties to the lower id). + if (prior != null && (prior.createdAt > fragment.createdAt || (prior.createdAt == fragment.createdAt && prior.id <= fragment.id))) return + backupConcordListFragments = backupConcordListFragments.filterNot { it.index() == index } + fragment + saveAccountSettings() + } + override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) { // The joined list lives entirely in NIP-44-encrypted content (secrets), // so an empty `tags` is NOT an empty list — guard only on null. 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 992eb25bcf..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 @@ -137,6 +137,8 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent 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 @@ -970,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). @@ -3837,6 +3844,8 @@ open class EventCache : // so — exactly like the 10009 list above — it must be stored replaceably or the Concord // hub stays empty even after the event arrives. is ConcordCommunityListEvent, + // Its successor (CORD-02 §8): the List split into addressable fragments at d = index. + is ConcordCommunityListFragmentEvent, // The relay-signed NIP-29 39004 AV-participants addressable is durable group state. is GroupParticipantsEvent, is ExternalIdentitiesEvent, 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 b72056d333..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 @@ -52,10 +52,15 @@ class ConcordChannel( var channelName: String? = null private set - var isVoice: Boolean = false + var isPrivate: Boolean = false private set - var isPrivate: Boolean = false + /** + * 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. */ @@ -110,30 +115,30 @@ 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. val newChannelName = def?.name ?: channelName - val newVoice = def?.voice ?: isVoice val newPrivate = def?.private ?: isPrivate val newCommunityName = state.metadata?.name val newCommunityIcon = state.metadata?.icon val newCommunityBanner = state.metadata?.banner val newMembership = ConcordMembership.of(state.authority, myPubKey) val newDissolved = state.dissolved + val newKeyHeld = !newPrivate || keyHeld val changed = channelName != newChannelName || - isVoice != newVoice || isPrivate != newPrivate || communityName != newCommunityName || communityIcon != newCommunityIcon || communityBanner != newCommunityBanner || membership != newMembership || - dissolved != newDissolved + dissolved != newDissolved || + this.keyHeld != newKeyHeld channelName = newChannelName - isVoice = newVoice isPrivate = newPrivate communityName = newCommunityName communityIcon = newCommunityIcon @@ -141,6 +146,7 @@ class ConcordChannel( communityRelays = relays membership = newMembership dissolved = newDissolved + this.keyHeld = newKeyHeld return changed } @@ -154,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/ConcordChannelListState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt index f503e34687..1b6fa04690 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListState.kt @@ -20,44 +20,72 @@ */ package com.vitorpamplona.amethyst.commons.model.concord -import com.vitorpamplona.amethyst.commons.model.Note -import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.model.AddressableNote import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withAddedAt import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListDocument import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListResidue +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.IO +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.concurrent.Volatile -/** Persistence hook for the last-known kind 13302 event (offline backup). */ +/** + * Persistence hook for the account's Community List (offline backup): the kind-33302 fragments + * (CORD-02 §8) plus the retired kind-13302 single event, which is still read as a rescue source. + */ interface ConcordListRepository { fun concordList(): ConcordCommunityListEvent? fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) + + fun concordListFragments(): List = emptyList() + + fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) {} } /** - * The account's home base for Concord Channels: the kind-13302 - * [ConcordCommunityListEvent] (self-encrypted joined-communities list). This is - * the Concord analog of NIP-29's [RelayGroupListState], but the entries carry the - * community secrets (root/salt/epoch/private-channel keys), so decryption yields - * everything needed to re-derive each plane on any device. + * The account's home base for Concord Channels: the member's Community List (CORD-02 §8) — kind + * 33302 fragments, NIP-44-encrypted to self, at `d` = the fragment index. The entries carry the + * community secrets (root/salt/epoch/private-channel keys), so decryption yields everything needed + * to re-derive each plane on any device. * - * Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and - * [liveServers] (the distinct community ids — the "server" rail). [follow]/ - * [unfollow] read-modify-write the list; the caller publishes the returned event. + * The List reads as the union of every fragment below the declared count, merged with the retired + * kind-13302 event if one exists — so a membership only the old event carries is still joined, and + * the next write migrates it into the fragments. Nothing ever writes 13302 again. + * + * Exposes [liveCommunities] (the joined [ConcordCommunityListEntry] set) and [liveServers] (the + * distinct community ids — the "server" rail). [follow]/[unfollow] read-modify-write the List and + * return the fragment events to publish: a full repack when every fragment is held, or a write + * scoped to the fragment holding the change when some are still missing (so a fragment stranded on + * an unreachable relay never blocks a join or a leave). */ class ConcordChannelListState( val signer: NostrSigner, @@ -65,35 +93,103 @@ class ConcordChannelListState( val scope: CoroutineScope, val settings: ConcordListRepository, ) { - // Long-term reference so the GC doesn't collect the note itself. + // Long-term references so the GC doesn't collect the notes themselves. val concordListNote = cache.getOrCreateAddressableNote(getConcordListAddress()) + private val fragmentNotes = HashMap() + private val fragmentNotesLock = KmpLock() + /** How many fragment coordinates we watch: at least index 0, and every index the List declares. */ + private val watchedFragments = MutableStateFlow(1) + + /** Serializes read-modify-writes so two quick edits can't both build on the same base. */ + private val writeLock = Mutex() + + /** + * Plaintext per fragment/legacy event id. Every List change re-reads the whole List, and each + * decrypt is a signer round trip (an IPC hop to Amber, a relay hop to a bunker); an event id's + * plaintext never changes, so each is decrypted once. Failures are not cached, so a transient + * signer error is retried on the next read. + */ + private val plaintextById = LinkedHashMap() + private val plaintextLock = KmpLock() + + private suspend fun plaintextOf( + id: String, + decrypt: suspend () -> String?, + ): String? { + plaintextLock.withLock { plaintextById[id] }?.let { return it } + val plaintext = decrypt() ?: return null + plaintextLock.withLock { + plaintextById[id] = plaintext + // Bounded: only the newest copy per index is ever read, so a few dozen ids is plenty. + while (plaintextById.size > MAX_CACHED_PLAINTEXTS) plaintextById.remove(plaintextById.keys.first()) + } + return plaintext + } + + /** + * Whether the relays have been asked for this account's fragments since start-up + * ([markRelaysConfirmed], after the import fetch). Until then an empty fragment set means + * "not loaded yet", not "no List", and a write would replace fragments another device or + * client published — so [follow]/[unfollow] refuse rather than guess. + */ + @Volatile + var relaysConfirmed = false + private set + + fun markRelaysConfirmed() { + relaysConfirmed = true + } + + /** The retired single-event list's coordinate, still read for migration. */ fun getConcordListAddress() = ConcordCommunityListEvent.createAddress(signer.pubKey) - fun getConcordListFlow(): StateFlow = concordListNote.flow().metadata.stateFlow - fun getConcordList(): ConcordCommunityListEvent? = concordListNote.event as? ConcordCommunityListEvent - /** - * Decrypts the current list (or the offline backup) into the full document — entries plus - * the residue (unknown keys, tombstones) a read-modify-write has to hand back untouched. - */ - suspend fun documentWithBackup(note: Note): ConcordCommunityListDocument { - val event = note.event as? ConcordCommunityListEvent ?: settings.concordList() - return event?.decryptDocument(signer) ?: ConcordCommunityListDocument(emptyList()) + private fun fragmentNote(index: Int): AddressableNote = + fragmentNotesLock.withLock { + fragmentNotes.getOrPut(index) { cache.getOrCreateAddressableNote(ConcordCommunityListFragmentEvent.createAddress(signer.pubKey, index)) } + } + + /** Every fragment event we hold, from the cache and the offline backup (newest per index wins later). */ + private fun heldFragments(): List { + val fromCache = (0 until watchedFragments.value).mapNotNull { fragmentNote(it).event as? ConcordCommunityListFragmentEvent } + return fromCache + settings.concordListFragments() } - /** Decrypts the current list (or the offline backup) into its entries. */ - suspend fun entriesWithBackup(note: Note): List = documentWithBackup(note).entries + /** Resolves the fragments we hold, widening the watch when the List declares more of them. */ + suspend fun fragmentSet(): ConcordListFragmentSet { + val set = ConcordListFragmentSet.resolve(heldFragments(), signer.pubKey) { e -> plaintextOf(e.id) { e.decryptPlaintext(signer) } } + if (set.declared > watchedFragments.value) watchedFragments.value = set.declared + return set + } + + /** The fragments plus the merged, decoded List a read-modify-write starts from. */ + private suspend fun snapshot(): Pair { + val set = fragmentSet() + val legacyEvent = getConcordList() ?: settings.concordList() + val legacy = legacyEvent?.let { e -> plaintextOf(e.id) { e.decryptPlaintext(signer) } } + return set to ConcordCommunityList.decodeDocument(ConcordCommunityList.readWithLegacy(set, legacy)) + } + + /** The whole decoded List — entries plus the residue a read-modify-write must hand back. */ + suspend fun document(): ConcordCommunityListDocument = snapshot().second + + /** The joined entries. */ + suspend fun entries(): List = document().entries + + @OptIn(ExperimentalCoroutinesApi::class) + private val listChanges: Flow = + watchedFragments.flatMapLatest { n -> + combine((0 until n).map { fragmentNote(it).flow().metadata.stateFlow } + concordListNote.flow().metadata.stateFlow) { it } + } @OptIn(ExperimentalCoroutinesApi::class) val liveCommunities: StateFlow> = - getConcordListFlow() - .transformLatest { noteState -> - emit(entriesWithBackup(noteState.note)) - }.onStart { - emit(entriesWithBackup(concordListNote)) - }.flowOn(Dispatchers.IO) + listChanges + .transformLatest { emit(entries()) } + .onStart { emit(entries()) } + .flowOn(Dispatchers.IO) .stateIn( scope, SharingStarted.Eagerly, @@ -108,38 +204,91 @@ class ConcordChannelListState( .flowOn(Dispatchers.IO) .stateIn(scope, SharingStarted.Eagerly, emptySet()) - /** Add or replace [entry] (by community id) and return the new signed list event to publish. */ - suspend fun follow(entry: ConcordCommunityListEntry): ConcordCommunityListEvent { - // Seed from the offline backup as well as the live cache event: the saved list is - // consumed into the cache asynchronously in `init`, so a join that races that load - // would otherwise start from an empty `current` and wipe every prior membership. - val doc = documentWithBackup(concordListNote) - val next = doc.entries.filterNot { it.id == entry.id } + entry - return ConcordCommunityListEvent.create(signer, next, residue = doc.residue) + /** + * Encrypts and signs the fragments that make the wire hold [entries] + [residue]. The new + * document replaces the current memberships wholesale (never a merge), so a same-epoch + * update — a delivered `control_root`, a rename — can't lose the snapshot tie-break to the + * state it is replacing. + */ + private suspend fun write( + set: ConcordListFragmentSet, + entries: List, + residue: ConcordListResidue, + ): List { + if (set.isEmpty && !relaysConfirmed) { + throw ConcordListIncompleteException("the Community List has not been fetched from relays yet; refusing to overwrite it") + } + val newDoc = ConcordCommunityList.encodeInternal(entries, residue) + return set.planWrites(newDoc, TimeUtils.now()).map { w -> + ConcordCommunityListFragmentEvent.create(signer, w.index, w.plaintext, w.createdAt).also { settings.updateConcordListFragmentTo(it) } + } } - /** Drop the community with [communityId] and return the new list event, or null if none existed. */ - suspend fun unfollow(communityId: String): ConcordCommunityListEvent? { - val doc = documentWithBackup(concordListNote) - if (doc.entries.none { it.id == communityId }) return null - val next = doc.entries.filterNot { it.id == communityId } - return ConcordCommunityListEvent.create(signer, next, residue = doc.residue) + /** + * Add or replace [entry] (by community id) and return the fragment events to publish. + * + * Adding a community we once left is a re-join, which must outrank the tombstone + * (`added_at > removed_at`, CORD-02 §8): an entry that doesn't — a join in the same second as + * the leave, or a stale snapshot — gets its `added_at` lifted just past the removal. + * + * Throws [ConcordListIncompleteException] when the List isn't loaded well enough to write + * without destroying a fragment, and [ConcordListTooLargeException] when a fragment would + * pass the event ceiling. + */ + suspend fun follow(entry: ConcordCommunityListEntry): List = + writeLock.withLock { + val (set, doc) = snapshot() + val removedAt = doc.residue.removedAt(entry.id) + val live = if (removedAt != null && entry.addedAt <= removedAt) entry.withAddedAt(maxOf(TimeUtils.nowMillis(), removedAt + 1)) else entry + write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue) + } + + /** + * Leave [communityId]: drop its membership and tombstone it (CORD-02 §8 — only a tombstone + * subtracts a membership; a missing entry is just unseen news another fragment may still + * carry). Returns the fragment events to publish, or empty when we were not a member. + */ + suspend fun unfollow(communityId: String): List = + writeLock.withLock { + val (set, doc) = snapshot() + if (doc.entries.none { it.id == communityId }) return@withLock emptyList() + write(set, doc.entries.filterNot { it.id == communityId }, doc.residue.withTombstone(communityId, TimeUtils.nowMillis())) + } + + /** + * Writes the List as it currently reads — used to seed the fragments from the retired 13302 + * event once relays confirmed none exist yet. Empty when there is nothing to write. + */ + suspend fun republish(): List = + writeLock.withLock { + val (set, doc) = snapshot() + if (doc.entries.isEmpty() && doc.residue.tombstones.isEmpty()) return@withLock emptyList() + write(set, doc.entries, doc.residue) + } + + companion object { + private const val MAX_CACHED_PLAINTEXTS = 64 } init { - settings.concordList()?.let { event -> + val savedLegacy = settings.concordList() + val savedFragments = settings.concordListFragments() + if (savedLegacy != null || savedFragments.isNotEmpty()) { Log.d("AccountRegisterObservers", "Loading saved concord list") @OptIn(DelicateCoroutinesApi::class) scope.launch(Dispatchers.IO) { - cache.justConsumeMyOwnEvent(event) + savedLegacy?.let { cache.justConsumeMyOwnEvent(it) } + savedFragments.forEach { cache.justConsumeMyOwnEvent(it) } + if (savedFragments.isNotEmpty()) watchedFragments.value = maxOf(watchedFragments.value, savedFragments.mapNotNull { it.index() }.max() + 1) } } scope.launch(Dispatchers.IO) { Log.d("AccountRegisterObservers", "ConcordList Collector Start") - getConcordListFlow().collect { noteState -> - (noteState.note.event as? ConcordCommunityListEvent)?.let { - settings.updateConcordListTo(it) + listChanges.collect { + getConcordList()?.let { settings.updateConcordListTo(it) } + for (i in 0 until watchedFragments.value) { + (fragmentNote(i).event as? ConcordCommunityListFragmentEvent)?.let { settings.updateConcordListFragmentTo(it) } } } } 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 d21038c171..864f4e808d 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,16 +20,19 @@ */ 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 import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope @@ -137,15 +140,41 @@ class ConcordCommunitySession( */ private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch) + /** + * The rekey address the rotation INTO this epoch rode on (from the prior held root), or null + * for a joiner who holds no prior root. A racing sibling rotation to this same epoch lands + * here; the app drains it for the down-only heal (CORD-06 §3). + */ + private val siblingBaseRekeyKey: GroupKey? = ConcordActions.siblingBaseRekeyPlane(entry) + + /** + * The dissolution tombstone address (CORD-02 §9): derived from the community id alone, so it + * is the same for every epoch and every member past or present. + */ + private val dissolvedKey: GroupKey = ConcordDissolution.planeKey(entry.id) + + /** + * Set once a valid owner tombstone bound to this community arrives at [dissolvedAddress]. One + * way: there is no un-dissolve, so a later fold can never clear it. + */ + @Volatile + private var dissolved = false + /** The Control Plane stream address to subscribe to (known from the entry alone). */ val controlPlaneAddress: HexKey get() = controlKeys.address + /** The dissolution tombstone stream address to subscribe to (known from the community id alone). */ + val dissolvedAddress: HexKey get() = dissolvedKey.publicKeyHex + /** The Guestbook Plane stream address to subscribe to (known from the entry alone). */ val guestbookAddress: HexKey get() = guestbookKey.publicKeyHex /** The next-epoch base-rekey stream address to watch for an inbound Refounding. */ val nextBaseRekeyAddress: HexKey get() = nextBaseRekeyKey.publicKeyHex + /** The current epoch's own base-rekey address (see [siblingBaseRekeyKey]), or null. */ + val siblingBaseRekeyAddress: HexKey? get() = siblingBaseRekeyKey?.publicKeyHex + /** * The Control Plane of every **prior** epoch we still hold a root for (address -> * key + epoch), newest-held first and bounded like the channel backfill. @@ -159,7 +188,9 @@ class ConcordCommunitySession( * survives a process restart without any new storage. */ private val historicalControlKeys: Map> = - entry.heldRoots + // Losing-fork roots of a healed race carry no Control Plane of the community's (CORD-06 §3). + ConcordRefounding + .canonicalHeldRoots(entry.heldRoots) .filter { it.epoch < entry.rootEpoch } .sortedByDescending { it.epoch } .take(ConcordActions.MAX_BACKFILL_EPOCHS) @@ -221,15 +252,22 @@ class ConcordCommunitySession( private val channelWrapsById = HashMap>() // channelIdHex -> (wrapId -> wrap) private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() + private val siblingRekeyWraps = 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 @@ -311,11 +349,32 @@ class ConcordCommunitySession( address == controlPlaneAddress || address == guestbookAddress || address == nextBaseRekeyAddress || + address == siblingBaseRekeyAddress || + address == dissolvedAddress || address in historicalControlKeys || lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } /** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */ - fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key } + 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 @@ -326,8 +385,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) } @@ -337,6 +396,12 @@ class ConcordCommunitySession( /** The buffered kind-3303 base-rotation wraps seen at [nextBaseRekeyAddress], for the account to drain. */ fun pendingBaseRekeyWraps(): List = lock.withLock { baseRekeyWraps.values.toList() } + /** The base-rekey [GroupKey] of the rotation into this epoch (sibling heal), or null. */ + fun siblingBaseRekeyKey(): GroupKey? = siblingBaseRekeyKey + + /** The buffered kind-3303 wraps seen at [siblingBaseRekeyAddress], for the account's heal drain. */ + fun pendingSiblingRekeyWraps(): List = lock.withLock { siblingRekeyWraps.values.toList() } + /** * Every stream key whose kind-1059 wraps this session reads: the Control Plane plus * one per folded channel. These are the identities a NIP-42 relay must see the @@ -362,13 +427,16 @@ 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 } } - /** The CORD-06 auxiliary plane keys (Guestbook + next base-rekey) for their own isolated AUTH. */ - fun auxStreamKeys(): List = listOf(guestbookKey, nextBaseRekeyKey) + /** + * The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address) + * for their own isolated AUTH. + */ + fun auxStreamKeys(): List = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) } @@ -414,6 +482,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 @@ -449,6 +544,17 @@ class ConcordCommunitySession( refoldGuestbook() return ConcordIngestOutcome.STRUCTURAL } + dissolvedAddress -> { + // Anyone holding the (public) community id can sign here, so only an owner-signed, + // eid-bound tombstone counts (CORD-02 §9); everything else is noise we still claim. + if (dissolved || !ConcordDissolution.isTombstoneWrap(wrap, entry.id, entry.owner)) return ConcordIngestOutcome.NON_STRUCTURAL + lock.withLock { + dissolved = true + _state.value = _state.value?.withDissolved(true) + } + // The state watcher bumps the revision off the changed fold, as for a control wrap. + return ConcordIngestOutcome.STRUCTURAL_FOLD + } nextBaseRekeyAddress -> { // Buffer only — decrypting a base-rotation blob needs the account signer, so the // app layer drains [pendingBaseRekeyWraps] with it and authorizes the rotator. That @@ -457,6 +563,11 @@ class ConcordCommunitySession( lock.withLock { baseRekeyWraps[wrap.id] = wrap } return ConcordIngestOutcome.STRUCTURAL } + siblingBaseRekeyAddress -> { + // Same as above for a racing rotation into THIS epoch (the down-only heal). + lock.withLock { siblingRekeyWraps[wrap.id] = wrap } + return ConcordIngestOutcome.STRUCTURAL + } else -> { // A prior-epoch Control Plane wrap: buffer it and re-fold, so the anti-rollback // floor rises as the old epochs drain in. Structural — the floor can change the @@ -471,15 +582,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) } } } @@ -519,8 +628,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 — @@ -552,35 +663,37 @@ class ConcordCommunitySession( val folded = ConcordCommunityState.fold( editionsLocked(wraps, controlKeys), + communityIdBytes, entry.owner, 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 - folded.channels.keys.filterNot { it in prevChannels } + _state.value = folded.withDissolved(dissolved) + 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) } @@ -596,7 +709,7 @@ class ConcordCommunitySession( if (editionByWrapId.containsKey(wrap.id)) { editionByWrapId[wrap.id] } else { - val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromRumor(it.rumor) } + val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKeys)?.let { ControlEdition.fromOpened(it) } editionByWrapId[wrap.id] = edition edition } @@ -621,7 +734,7 @@ class ConcordCommunitySession( val wraps = historicalControlWraps[address]?.values?.toList() ?: continue val editions = editionsLocked(wraps, keyAtEpoch.first) if (editions.isEmpty()) continue - floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors) + floors = ConcordCommunityState.authorizedHeads(editions, communityIdBytes, entry.owner, floors) } return floors } @@ -645,9 +758,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) } /** @@ -675,6 +788,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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt index f743239662..5238625689 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStore.kt @@ -41,6 +41,8 @@ import okio.IOException */ enum class LatestEventSlot( val prefKey: String, + /** False for a slot added after the SharedPreferences era: there is nothing to migrate. */ + val existedInLegacyPrefs: Boolean = true, ) { CONTACT_LIST("latestContactList"), USER_METADATA("latestUserMetadata"), @@ -61,6 +63,9 @@ enum class LatestEventSlot( EPHEMERAL_LIST("latestEphemeralChatList"), RELAY_GROUP_LIST("latestRelayGroupList"), CONCORD_LIST("latestConcordList"), + + /** The kind-33302 Community List fragments (CORD-02 §8), one event JSON per line. */ + CONCORD_LIST_FRAGMENTS("latestConcordListFragments", existedInLegacyPrefs = false), TRUST_PROVIDER_LIST("latestTrustProviderList"), KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"), FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"), @@ -94,7 +99,7 @@ class LatestEventCacheStore( val legacyTable = LegacyKeyTable( "migrated.latestEvents", - LatestEventSlot.entries.map { LegacyStringKey(it.prefKey, it.key) }, + LatestEventSlot.entries.filter { it.existedInLegacyPrefs }.map { LegacyStringKey(it.prefKey, it.key) }, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index 54af32e88e..242c4b6d0e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterUserAsser import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -98,6 +99,7 @@ val AccountInfoAndListsFromKeyKinds2 = // Armada reference client, sharing this key) surface in the Concord hub at login, // instead of only appearing after creating/redeeming an invite in Amethyst itself. ConcordCommunityListEvent.KIND, + ConcordCommunityListFragmentEvent.KIND, // NIP-60 Cashu wallet + NIP-61 nutzap info. Replaceables, always // useful to have available — wallet event holds the user's P2PK key // + mint list, nutzap info tells other clients which mints to lock diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt index 82169a7708..134d9a0c1a 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActionsTest.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.actions +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal @@ -38,7 +39,7 @@ class ConcordActionsTest { val community = ConcordActions.createCommunity(owner, "Test Server", createdAt = 1L, relays = listOf("wss://r.example")) // Fold genesis -> live state - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) assertEquals("Test Server", state.metadata?.name) assertTrue(state.channels.containsKey(community.generalChannelIdHex)) @@ -78,7 +79,7 @@ class ConcordActionsTest { // The joiner can derive the control plane and read the genesis. val controlPlane = ConcordActions.controlPlaneFor(opened) - val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.owner) + val state = ConcordActions.foldCommunity(community.genesisWraps, controlPlane, opened.communityId.hexToByteArray(), opened.owner) assertEquals("Nostrichs", state.metadata?.name) } @@ -149,7 +150,7 @@ class ConcordActionsTest { rootEpoch = aliceGot.newEpoch, controlPk = deliveredControlPk.toHexKey(), ) - val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.ownerPubKey) + val state = ConcordActions.foldCommunity(build.controlWraps, newControl, community.communityId, community.ownerPubKey) assertEquals("Test", state.metadata?.name) assertTrue(state.channels.isNotEmpty()) } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt index c5ae3af6a9..e7a18df07c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordModerationTest.kt @@ -29,13 +29,18 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.ControlRootWrap import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold import com.vitorpamplona.quartz.concord.cord04Roles.GrantEntity +import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal 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 @@ -70,7 +75,7 @@ class ConcordModerationTest { position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.KICK).toWire(), ) - add(ConcordModeration.defineRole(owner, cp, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey)) + add(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, adminRole, editions, createdAt = 2L, owner = community.ownerPubKey)) // Owner grants that role to the admin user. add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleIdHex), editions, createdAt = 3L, owner = community.ownerPubKey)) @@ -78,7 +83,7 @@ class ConcordModerationTest { // Owner bans the troll. add(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 4L, owner = community.ownerPubKey)) - val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.ownerPubKey) + val state: ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) // The role exists, the admin holds BAN + the role id, and the troll is banned. assertTrue(state.roles.containsKey(roleIdHex)) @@ -89,19 +94,19 @@ class ConcordModerationTest { // Revoking (an empty grant, as "Remove admin" does) strips the role and its permissions. add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, emptyList(), editions, createdAt = 7L, owner = community.ownerPubKey)) - val demoted = ConcordCommunityState.fold(editions, community.ownerPubKey) + val demoted = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) assertFalse(demoted.authority.effectivePermissions(admin.pubKey).has(ConcordPermissions.BAN)) assertTrue(demoted.authority.rolesOf(admin.pubKey).isEmpty()) // Unbanning the troll clears the flag (version chains onto the ban). add(ConcordModeration.unban(owner, cp, communityId, troll.pubKey, editions, createdAt = 5L, owner = community.ownerPubKey)) - val healed = ConcordCommunityState.fold(editions, community.ownerPubKey) + val healed = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) assertFalse(healed.authority.isBanned(troll.pubKey)) // A grant forged by the troll (who outranks nobody) is dropped by the fold. val forged = ConcordModeration.grant(troll, cp, communityId, troll.pubKey, listOf(roleIdHex), editions, createdAt = 6L, owner = community.ownerPubKey) val forgedEditions: List = editions + ConcordActions.controlEditions(listOf(forged), cp) - val afterForgery = ConcordCommunityState.fold(forgedEditions, community.ownerPubKey) + val afterForgery = ConcordCommunityState.fold(forgedEditions, community.communityId, community.ownerPubKey) assertFalse(afterForgery.authority.effectivePermissions(troll.pubKey).has(ConcordPermissions.BAN)) } @@ -115,8 +120,8 @@ class ConcordModerationTest { * masked by the down-only healing union, but an unban is not, so an unban simply * failed to apply. * - * Here the banlist has two editions (v0 bans [troll], v1 also bans [stranger]) before - * the unban. The unban must chain off v1 — the folded head — at v2, not off v0. + * Here the banlist has two editions (v1 bans [troll], v2 also bans [stranger]) before + * the unban. The unban must chain off v2 — the folded head — at v3, not off v1. */ @Test fun thirdBanlistEditionChainsOffTheFoldedHeadNotTheFirstArrival() = @@ -127,36 +132,36 @@ class ConcordModerationTest { val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() - // v0: ban the troll. v1: ban the stranger too (chains onto v0). + // v1: ban the troll. v2: ban the stranger too (chains onto v1). Versions start at 1 (CORD-04 §1). editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp) val banlistSoFar = editions.filter { it.entityKind == ControlEntityKind.BANLIST } assertEquals(2, banlistSoFar.size) val head = EditionFold.foldEntity(banlistSoFar)!! - assertEquals(1L, head.version) - // The stale v0 sorts first in arrival order — exactly what the old firstOrNull picked up. - assertEquals(0L, banlistSoFar.first().version) + assertEquals(2L, head.version) + // The stale v1 sorts first in arrival order — exactly what the old firstOrNull picked up. + assertEquals(1L, banlistSoFar.first().version) // Now unban the troll. The head must be found regardless of arrival order — in - // particular in the natural order, where the stale v0 comes first and is exactly + // particular in the natural order, where the stale v1 comes first and is exactly // what the old firstOrNull latched onto. for (arrival in listOf(editions.toList(), editions.reversed())) { val unbanWrap = ConcordModeration.unban(owner, cp, communityId, troll.pubKey, arrival, createdAt = 4L, owner = community.ownerPubKey) val unban = ConcordActions.controlEditions(listOf(unbanWrap), cp).single() - // Chains onto the folded head (v1), not the first-arrival v0. - assertEquals(2L, unban.version) + // Chains onto the folded head (v2), not the first-arrival v1. + assertEquals(3L, unban.version) assertEquals(head.hashHex, unban.prevHash!!.toHexKey()) // And the resulting state is the one the moderator asked for: troll freed, stranger still banned. - val state = ConcordCommunityState.fold(editions + unban, community.ownerPubKey) + val state = ConcordCommunityState.fold(editions + unban, community.communityId, community.ownerPubKey) assertFalse(state.authority.isBanned(troll.pubKey)) assertTrue(state.authority.isBanned(stranger.pubKey)) } } - /** The same stale-head trap on a versioned entity: a third role edition must be v2. */ + /** The same stale-head trap on a versioned entity: a third role edition must be v3. */ @Test fun thirdRoleEditionChainsOffTheFoldedHead() = runTest { @@ -168,14 +173,14 @@ class ConcordModerationTest { fun role(name: String) = RoleEntity(name = name, position = 1, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()) - editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp) - editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Mod"), editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Admin"), editions, createdAt = 3L, owner = community.ownerPubKey)), cp) for (arrival in listOf(editions.toList(), editions.reversed())) { - val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single() - assertEquals(2L, third.version) + val third = ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role("Owner"), arrival, createdAt = 4L, owner = community.ownerPubKey)), cp).single() + assertEquals(3L, third.version) - val state = ConcordCommunityState.fold(editions + third, community.ownerPubKey) + val state = ConcordCommunityState.fold(editions + third, community.communityId, community.ownerPubKey) assertEquals("Owner", state.roles[roleId.toHexKey()]?.name) } } @@ -195,27 +200,27 @@ class ConcordModerationTest { val communityId = community.communityId val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() - // v0: the owner bans the troll. + // v1: the owner bans the troll. editions += ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, communityId, troll.pubKey, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) - // v1: the stranger — who holds nothing — forges an empty banlist at the tip. + // v2: the stranger — who holds nothing — forges an empty banlist at the tip. val rogue = ConcordActions.controlEditions(listOf(ConcordModeration.unban(stranger, cp, communityId, troll.pubKey, editions, createdAt = 3L, owner = community.ownerPubKey)), cp).single() - assertEquals(1L, rogue.version) + assertEquals(2L, rogue.version) val poisoned = editions + rogue - // The owner now bans the stranger. It must chain onto v0 — the head the fold - // honors — not onto the rogue v1, so the version is 1, not 2. + // The owner now bans the stranger. It must chain onto v1 — the head the fold + // honors — not onto the rogue v2, so the version is 2, not 3. val next = ConcordActions .controlEditions( listOf(ConcordModeration.ban(owner, cp, communityId, stranger.pubKey, poisoned, createdAt = 4L, owner = community.ownerPubKey)), cp, ).single() - assertEquals(1L, next.version, "the rogue tip must not inflate the honest edition's version") + assertEquals(2L, next.version, "the rogue tip must not inflate the honest edition's version") // And, critically, the owner's banlist is computed from the GATED head, so it still // carries the troll. Reading the rogue's content instead would launder the forged // unban into an owner-signed edition and free the troll for good. - val state = ConcordCommunityState.fold(poisoned + next, community.ownerPubKey) + val state = ConcordCommunityState.fold(poisoned + next, community.communityId, community.ownerPubKey) assertTrue(state.authority.isBanned(troll.pubKey), "the forged unban must not free the troll") assertTrue(state.authority.isBanned(stranger.pubKey)) } @@ -242,22 +247,22 @@ class ConcordModerationTest { editions += ConcordActions.controlEditions( listOf( - ConcordModeration.defineRole(owner, cp, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey), + ConcordModeration.defineRole(owner, cp, community.communityId, staffRoleId, RoleEntity(name = "Mod", position = 2, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()), editions, createdAt = 2L, owner = community.ownerPubKey), ), cp, ) editions += ConcordActions.controlEditions( listOf( - ConcordModeration.defineRole(owner, cp, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey), + ConcordModeration.defineRole(owner, cp, community.communityId, kickRoleId, RoleEntity(name = "Bouncer", position = 3, permissions = ConcordPermissions.of(ConcordPermissions.KICK).toWire()), editions, createdAt = 3L, owner = community.ownerPubKey), ), cp, ) - assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.ownerPubKey)) - assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.ownerPubKey)) - assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.ownerPubKey), "a revoke hands out nothing") - assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.ownerPubKey), "an unresolvable role must not trigger a delivery") + assertTrue(ConcordModeration.makesStaff(listOf(staffRoleIdHex), editions, community.communityId, community.ownerPubKey)) + assertFalse(ConcordModeration.makesStaff(listOf(kickRoleIdHex), editions, community.communityId, community.ownerPubKey)) + assertFalse(ConcordModeration.makesStaff(emptyList(), editions, community.communityId, community.ownerPubKey), "a revoke hands out nothing") + assertFalse(ConcordModeration.makesStaff(listOf("ee".repeat(32)), editions, community.communityId, community.ownerPubKey), "an unresolvable role must not trigger a delivery") suspend fun grantEntity( roleIds: List, @@ -296,4 +301,96 @@ class ConcordModerationTest { assertNull(grantEntity(listOf("ee".repeat(32)), community.controlRoot).controlWrap, "an unresolved role conservatively delivers nothing") assertNull(grantEntity(listOf(staffRoleIdHex), controlRoot = null).controlWrap, "no held secret, no delivery (legacy community or keyless granter)") } + + /** + * CORD-04 §1/§5: every non-owner authority action cites the Grant it acts under (`vac`), and a + * reader — this client and the reference one alike — drops a non-owner edition that doesn't. We + * never wrote it, so every edition a delegated admin authored here was invisible to Armada. + */ + @Test + fun aDelegatedAdminsEditionsCiteTheirGrantAndAreHonored() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L) + val cp = community.controlPlane + val communityId = community.communityId + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + + fun add(wrap: Event) = ConcordActions.controlEditions(listOf(wrap), cp).single().also { editions += it } + + val roleId = ByteArray(32) { 0x31 } + val adminBits = ConcordPermissions.of(ConcordPermissions.BAN, ConcordPermissions.MANAGE_METADATA, ConcordPermissions.MANAGE_ROLES) + val role = add(ConcordModeration.defineRole(owner, cp, communityId, roleId, RoleEntity(name = "Admin", position = 1, permissions = adminBits.toWire()), editions, 2L, owner = community.ownerPubKey)) + val grant = add(ConcordModeration.grant(owner, cp, communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, 3L, owner = community.ownerPubKey)) + assertNull(role.authorityCitation, "the owner cites nothing") + assertNull(grant.authorityCitation) + + val ban = add(ConcordModeration.ban(admin, cp, communityId, troll.pubKey, editions, 4L, owner = community.ownerPubKey)) + val vac = ban.authorityCitation + assertNotNull(vac, "a delegated admin's edition must cite its Grant") + assertEquals(ConcordKeyDerivation.grantCoordinate(communityId, admin.pubKey.hexToByteArray()).toHexKey(), vac.grantId.toHexKey()) + assertEquals(grant.version, vac.grantVersion) + assertEquals(grant.hashHex, vac.grantHash.toHexKey()) + + val rename = add(ConcordModeration.editMetadata(admin, cp, communityId, MetadataEntity(name = "Renamed"), editions, 5L, owner = community.ownerPubKey)) + assertNotNull(rename.authorityCitation) + + val state = ConcordCommunityState.fold(editions, communityId, community.ownerPubKey) + assertTrue(state.authority.isBanned(troll.pubKey), "the cited ban is honored") + assertEquals("Renamed", state.metadata?.name, "and so is the cited metadata edit") + + // The same actions stripped of their citation are dropped. + val uncited = editions.map { if (it.author == admin.pubKey) it.withCitation(null) else it } + val stripped = ConcordCommunityState.fold(uncited, communityId, community.ownerPubKey) + assertFalse(stripped.authority.isBanned(troll.pubKey)) + assertEquals("Nostrichs", stripped.metadata?.name) + } + + /** CORD-04 §1/§2: a new entity starts at version 1, and a Role carries its own id as `role_id`. */ + @Test + fun newEntitiesStartAtVersionOneAndRolesCarryTheirRoleId() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L) + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp) + val roleId = ByteArray(32) { 0x41 } + + val role = + ConcordActions + .controlEditions( + listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, RoleEntity(name = "Mod", position = 2), editions, 2L, owner = community.ownerPubKey)), + cp, + ).single() + assertEquals(1L, role.version) + assertNull(role.prevHash) + assertEquals(roleId.toHexKey(), ConcordJson.decodeOrNull(role.content)?.roleId) + + val ban = ConcordActions.controlEditions(listOf(ConcordModeration.ban(owner, cp, community.communityId, troll.pubKey, editions, 3L, owner = community.ownerPubKey)), cp).single() + assertEquals(1L, ban.version) + } + + /** CORD-04 §2 / CORD-02 §6: a write every reader would drop is refused instead of published. */ + @Test + fun writesPastTheProtocolCapsAreRefused() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L) + val cp = community.controlPlane + val cid = community.communityId + val editions = ConcordActions.controlEditions(community.genesisWraps, cp) + + assertFailsWith { + ConcordModeration.defineRole(owner, cp, cid, ByteArray(32) { 1 }, RoleEntity(name = "r".repeat(65), position = 2), editions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.defineRole(owner, cp, cid, ByteArray(32) { 1 }, RoleEntity(name = "Peer", position = 0), editions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.editMetadata(owner, cp, cid, MetadataEntity(name = "n".repeat(65)), editions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.editMetadata(owner, cp, cid, MetadataEntity(name = "ok", description = "d".repeat(10_001)), editions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.grant(owner, cp, cid, admin.pubKey, (1..65).map { it.toString(16).padStart(64, '0') }, editions, 2L, owner = community.ownerPubKey) + } + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt new file mode 100644 index 0000000000..4b0d3bfc8b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt @@ -0,0 +1,263 @@ +/* + * 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.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +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.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The receive side of CORD-06 rotations as commons wires it: the `vac`-cited authority check a + * receiver runs before adopting (I9), racing rotations converging on the lowest authorized root + * and the down-only same-epoch heal (I12), and the sibling address a session watches for it. + */ +class ConcordRotationReceiveTest { + private val owner = NostrSignerInternal(KeyPair()) + private val admin = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + private val now = 1_700_000_000L + + private class Fixture( + val community: NewConcordCommunity, + val editions: List, + ) + + /** A community whose owner granted [admin] a BAN role. */ + private suspend fun withAdmin(): Fixture { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val cp = community.controlPlane + val editions = ConcordActions.controlEditions(community.genesisWraps, cp).toMutableList() + val roleId = ByteArray(32) { (it + 1).toByte() } + val role = RoleEntity(name = "Admin", position = 1, permissions = ConcordPermissions.of(ConcordPermissions.BAN).toWire()) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineRole(owner, cp, community.communityId, roleId, role, editions, createdAt = 2L, owner = community.ownerPubKey)), cp) + editions += ConcordActions.controlEditions(listOf(ConcordModeration.grant(owner, cp, community.communityId, admin.pubKey, listOf(roleId.toHexKey()), editions, createdAt = 3L, owner = community.ownerPubKey)), cp) + return Fixture(community, editions) + } + + private fun entryFor( + community: NewConcordCommunity, + root: String = community.communityRoot.toHexKey(), + epoch: Long = community.rootEpoch, + heldRoots: List = emptyList(), + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = root, + rootEpoch = epoch, + controlPk = community.controlPkHex, + heldRoots = heldRoots, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + private suspend fun rotate( + community: NewConcordCommunity, + rotator: NostrSigner, + newRoot: ByteArray, + authority: AuthorityCitation?, + ): List { + val newEpoch = community.rootEpoch + 1 + val controlRoot = ByteArray(32) { 0x6B } + return ConcordRefounding.buildBaseRekeyWraps( + rotatorSigner = rotator, + baseRekeyKey = ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch), + recipientsXOnly = listOf(member.pubKey), + staffXOnly = emptySet(), + newRoot = newRoot, + newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey, + newControlRoot = controlRoot, + newEpoch = newEpoch, + prevEpoch = community.rootEpoch, + prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(), + createdAt = now, + authority = authority, + ) + } + + private suspend fun receive( + f: Fixture, + wraps: List, + ): ReceivedRefounding? { + val entry = entryFor(f.community) + val authority = ConcordCommunityState.fold(f.editions, f.community.communityId, f.community.ownerPubKey).authority + return ConcordActions.openBaseRekey( + wraps = wraps, + baseRekey = ConcordActions.nextBaseRekeyPlane(f.community.communityRoot, f.community.communityId, f.community.rootEpoch), + recipientSigner = member, + communityId = f.community.communityIdHex, + priorRoot = f.community.communityRoot, + rootEpoch = f.community.rootEpoch, + accept = { ConcordReceive.isHonoredRotation(entry, f.editions, authority, it) }, + ) + } + + // ---- I9 ---------------------------------------------------------------------------------- + + @Test + fun anAdminsRotationIsHonoredOnlyWithItsGrantCited() = + runTest { + val f = withAdmin() + val entry = entryFor(f.community) + val citation = ConcordReceive.rotationCitation(entry, f.editions, admin.pubKey) + assertNotNull(citation, "a BAN-holding admin has a Grant to cite") + assertNull(ConcordReceive.rotationCitation(entry, f.editions, owner.pubKey), "the owner cites nothing") + + val root = ByteArray(32) { 0x22 } + assertContentEquals(root, receive(f, rotate(f.community, admin, root, citation))?.newRoot) + assertNull(receive(f, rotate(f.community, admin, root, authority = null)), "an uncited delegated rotation is dropped") + assertContentEquals(root, receive(f, rotate(f.community, owner, root, authority = null))?.newRoot, "the owner needs no citation") + } + + @Test + fun aRotationCitingAGrantWeHaveNotSyncedIsParked() = + runTest { + val f = withAdmin() + val real = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey)!! + val ahead = AuthorityCitation(real.grantId, real.grantVersion + 1, real.grantHash) + assertNull(receive(f, rotate(f.community, admin, ByteArray(32) { 0x22 }, ahead))) + } + + @Test + fun aStrangerCannotRotateEvenCitingSomeonesGrant() = + runTest { + val f = withAdmin() + val stranger = NostrSignerInternal(KeyPair()) + val adminsCitation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey) + assertNull(receive(f, rotate(f.community, stranger, ByteArray(32) { 0x22 }, adminsCitation))) + } + + // ---- I12 --------------------------------------------------------------------------------- + + @Test + fun racingHonoredRotationsConvergeOnTheLowestRoot() = + runTest { + val f = withAdmin() + val citation = ConcordReceive.rotationCitation(entryFor(f.community), f.editions, admin.pubKey) + val high = ByteArray(32) { 0x70 } + val low = ByteArray(32) { 0x05 } + val wraps = rotate(f.community, owner, high, null) + rotate(f.community, admin, low, citation) + assertContentEquals(low, receive(f, wraps)?.newRoot) + assertContentEquals(low, receive(f, wraps.reversed())?.newRoot) + + // An uncited (dishonored) lower root never wins the race. + val rogue = rotate(f.community, admin, ByteArray(32) { 0x01 }, null) + assertContentEquals(high, receive(f, rotate(f.community, owner, high, null) + rogue)?.newRoot) + } + + @Test + fun theHealMovesOnlyToAStrictlyLowerSiblingAndKeepsTheLoser() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L) + val prior = HeldRoot(0, "aa".repeat(32), community.controlPkHex) + val adopted = entryFor(community, root = "70".repeat(32), epoch = 1, heldRoots = listOf(prior)) + + fun sibling(root: String) = ReceivedRefounding(root.hexToByteArray(), 1, owner.pubKey, ByteArray(32) { 3 }, null) + + val healed = ConcordReceive.withHealedRoot(adopted, sibling("05".repeat(32))) + assertNotNull(healed) + assertEquals("05".repeat(32), healed.root) + assertEquals(1L, healed.rootEpoch) + assertEquals(ByteArray(32) { 3 }.toHexKey(), healed.controlPk, "the control pair is the winner's") + assertTrue(healed.heldRoots.any { it.epoch == 1L && it.key == "70".repeat(32) && it.controlPk == null }, "the losing fork's root is kept for its messages") + assertEquals(prior.key, ConcordRefounding.canonicalHeldRoots(healed.heldRoots).first { it.epoch == 0L }.key) + + assertNull(ConcordReceive.withHealedRoot(adopted, sibling("90".repeat(32))), "down-only: a higher sibling never re-forks the epoch") + assertNull(ConcordReceive.withHealedRoot(adopted, sibling("70".repeat(32)))) + + // The next adoption keeps both same-epoch roots instead of collapsing them by epoch. + val next = ConcordReceive.withAdoptedRoot(healed, ByteArray(32) { 9 }, 2) + assertEquals( + setOf("05".repeat(32), "70".repeat(32)), + next.heldRoots + .filter { it.epoch == 1L } + .map { it.key } + .toSet(), + ) + assertEquals("05".repeat(32), ConcordRefounding.canonicalHeldRoots(next.heldRoots).first { it.epoch == 1L }.key) + } + + @Test + fun aSessionWatchesTheRotationIntoItsOwnEpoch() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L) + val prior = HeldRoot(community.rootEpoch, community.communityRoot.toHexKey(), community.controlPkHex) + val entry = entryFor(community, root = "70".repeat(32), epoch = community.rootEpoch + 1, heldRoots = listOf(prior)) + + val sibling = ConcordActions.siblingBaseRekeyPlane(entry) + assertNotNull(sibling) + assertEquals(ConcordActions.nextBaseRekeyPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex, sibling.publicKeyHex) + assertNull(ConcordActions.siblingBaseRekeyPlane(entryFor(community)), "a joiner holding no prior root has nothing to watch") + + val session = ConcordCommunitySession(entry, member.pubKey) + assertEquals(sibling.publicKeyHex, session.siblingBaseRekeyAddress) + assertTrue(session.ownsPlane(sibling.publicKeyHex)) + assertTrue(session.auxStreamKeys().any { it.publicKeyHex == sibling.publicKeyHex }) + + val wraps = rotate(community, owner, ByteArray(32) { 0x05 }, null) + wraps.forEach { session.ingest(it) } + assertEquals(wraps.map { it.id }.toSet(), session.pendingSiblingRekeyWraps().map { it.id }.toSet()) + + assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == sibling.publicKeyHex }) + } + + @Test + fun aLosingForkRootIsNotFoldedAsAControlPlane() { + val community = "11".repeat(32) + val entry = + ConcordCommunityListEntry( + id = community, + owner = "22".repeat(32), + ownerSalt = "33".repeat(32), + root = "44".repeat(32), + rootEpoch = 2, + heldRoots = listOf(HeldRoot(1, "05".repeat(32)), HeldRoot(1, "70".repeat(32))), + relays = listOf("wss://r.example"), + ) + // One Control Plane for epoch 1 (the winner's), plus the current one. + assertEquals(2, ConcordSubscriptionPlanner.controlPlaneSubs(listOf(entry)).size) + assertFalse(ConcordRefounding.canonicalHeldRoots(entry.heldRoots).any { it.key == "70".repeat(32) }) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt index 0412057a24..a96427d84c 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlannerTest.kt @@ -58,7 +58,7 @@ class ConcordSubscriptionPlannerTest { relays = listOf("wss://r.example"), name = "Nostrichs", ) - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) // Both the current-epoch and the prior-epoch #general planes are subscribed. @@ -93,7 +93,7 @@ class ConcordSubscriptionPlannerTest { assertTrue(controlSubs[0].channelId == null) // Channel-plane subs cover the folded #general channel. - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) val channelSubs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, state) val general = channelSubs.firstOrNull { it.channelId?.channelId == community.generalChannelIdHex } assertTrue(general != null) @@ -127,7 +127,7 @@ class ConcordSubscriptionPlannerTest { relays = listOf("wss://r.example"), name = "Nostrichs", ) - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) // Never read (lastRead == 0) ⇒ the newest few wraps (previewLimit), no `since`. val previews = ConcordSubscriptionPlanner.channelPreviewFilters(entry, state, lastReadFor = { 0L }, previewLimit = 10) @@ -158,7 +158,7 @@ class ConcordSubscriptionPlannerTest { relays = listOf("wss://r.example"), name = "Nostrichs", ) - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) // Read before (lastRead > 0) ⇒ everything since, capped, so the unread badge is accurate. val lastRead = 1_700_000_000L @@ -225,7 +225,7 @@ class ConcordSubscriptionPlannerTest { relays = listOf("wss://r.example"), name = "Nostrichs", ) - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) val controlPk = community.controlPlane.address val guestbookPk = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch).publicKeyHex diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelDissolvedTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelDissolvedTest.kt index e4da0cf52d..efe37a5d63 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelDissolvedTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelDissolvedTest.kt @@ -46,14 +46,11 @@ class ConcordChannelDissolvedTest { author: String = owner, ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) - private fun state(dissolved: Boolean): ConcordCommunityState { - val editions = - buildList { - add(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}""")) - if (dissolved) add(ed(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}""")) - } - return ConcordCommunityState.fold(editions, owner) - } + // The tombstone lives on its own plane (CORD-02 §9); the session sets the flag from there. + private fun state(dissolved: Boolean): ConcordCommunityState = + ConcordCommunityState + .fold(listOf(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}""")), "cc".repeat(32).hexToByteArray(), owner) + .withDissolved(dissolved) @Test fun liveCommunityLetsTheOwnerPost() { diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt index 272e9da38c..da51b3d5c1 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelListLeaveTest.kt @@ -26,8 +26,13 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListIncompleteException import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -40,14 +45,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull +import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** - * The "leave a Concord community" path: `unfollow` read-modify-writes the private kind-13302 list. - * Everything that makes leaving safe lives here — it must drop only the named community, keep the - * other memberships (and their secrets) intact, and be a pure local list edit that never depends on - * the community's own (possibly dead) relays. + * The "leave a Concord community" path: `unfollow` read-modify-writes the Community List (CORD-02 §8). + * Everything that makes leaving safe lives here — it must remove only the named community, keep the + * other memberships (and their secrets) intact, leave a tombstone behind (only a tombstone subtracts + * a membership), and be a pure local list edit that never depends on the community's own (possibly + * dead) relays. */ class ConcordChannelListLeaveTest { private val signer = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000007".hexToByteArray())) @@ -72,12 +78,19 @@ class ConcordChannelListLeaveTest { /** Serves the list only from the offline backup — the state a dead-relay community lands in. */ private class BackupOnlyRepository( var saved: ConcordCommunityListEvent?, + var fragments: List = emptyList(), ) : ConcordListRepository { override fun concordList() = saved override fun updateConcordListTo(newConcordList: ConcordCommunityListEvent?) { saved = newConcordList } + + override fun concordListFragments() = fragments + + override fun updateConcordListFragmentTo(fragment: ConcordCommunityListFragmentEvent) { + fragments = fragments.filterNot { it.index() == fragment.index() } + fragment + } } private class StubCache : ICacheProvider { @@ -106,64 +119,123 @@ class ConcordChannelListLeaveTest { override fun justConsumeMyOwnEvent(event: Event): Boolean = false } - private suspend fun state(vararg entries: ConcordCommunityListEntry) = - ConcordChannelListState( - signer = signer, - cache = StubCache(), - scope = CoroutineScope(Dispatchers.Unconfined), - // The cached note is empty (nothing folded from relays), so every read falls back to the - // offline backup — exactly the situation for a community whose relays no longer answer. - settings = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, entries.toList())), - ) + /** The cached notes are empty (nothing folded from relays), so every read falls back to the offline backup. */ + private suspend fun state(vararg entries: ConcordCommunityListEntry): Pair { + val repo = BackupOnlyRepository(null) + val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo) + list.markRelaysConfirmed() + for (e in entries) list.follow(e) + return list to repo + } + + private suspend fun readBack(fragments: List) = ConcordListFragmentSet.resolve(fragments.map { it as ConcordCommunityListFragmentEvent }, signer) @Test - fun leavingDropsOnlyThatCommunity() = + fun leavingDropsOnlyThatCommunityAndTombstonesIt() = runTest { - val list = state(entry(alpha, "Alpha"), entry(beta, "Beta")) + val (list, repo) = state(entry(alpha, "Alpha"), entry(beta, "Beta")) - val left = list.unfollow(alpha)!! - val remaining = left.decrypt(signer) + val left = list.unfollow(alpha) + assertEquals(1, left.size) - assertEquals(1, remaining.size) - assertEquals(beta, remaining[0].id) + val remaining = list.entries() + assertEquals(listOf(beta), remaining.map { it.id }) // The surviving membership keeps its secrets — leaving one community must not damage another. assertEquals("2".repeat(64), remaining[0].root) assertEquals(3L, remaining[0].rootEpoch) + + val doc = readBack(repo.fragments).doc + val tombstoned = ConcordListFragments.removals(doc) + assertTrue(alpha in tombstoned, "a leave must leave a tombstone, or another fragment can re-add it") } @Test fun leavingTheLastCommunityEmptiesTheList() = runTest { - val list = state(entry(alpha, "Alpha")) - - val left = list.unfollow(alpha)!! - - assertTrue(left.decrypt(signer).isEmpty()) + val (list, _) = state(entry(alpha, "Alpha")) + list.unfollow(alpha) + assertTrue(list.entries().isEmpty()) } - /** Nothing to publish when we weren't a member: the caller's publish is a no-op on null. */ + /** Nothing to publish when we weren't a member. */ @Test fun leavingSomethingWeNeverJoinedIsANoOp() = runTest { - val list = state(entry(alpha, "Alpha")) + val (list, _) = state(entry(alpha, "Alpha")) + assertTrue(list.unfollow(beta).isEmpty()) + } - assertNull(list.unfollow(beta)) + @Test + fun reJoiningAfterALeaveResurrectsTheMembership() = + runTest { + val (list, _) = state(entry(alpha, "Alpha")) + list.unfollow(alpha) + val rejoin = + ConcordCommunityListEntry( + id = alpha, + owner = signer.pubKey, + ownerSalt = "1".repeat(64), + root = "2".repeat(64), + rootEpoch = 3, + name = "Alpha", + addedAt = Long.MAX_VALUE / 2, + ) + list.follow(rejoin) + assertEquals(listOf(alpha), list.entries().map { it.id }) + } + + @Test + fun reJoiningInTheSameMillisecondStillOutranksTheLeave() = + runTest { + val (list, _) = state(entry(alpha, "Alpha")) + list.unfollow(alpha) + // A stale entry (added long before the leave) re-followed: it must come back live. + list.follow(entry(alpha, "Alpha")) + assertEquals(listOf(alpha), list.entries().map { it.id }) + } + + @Test + fun anUnloadedListRefusesToWrite() = + runTest { + // Nothing held and the relays not asked yet: writing would replace fragments we never saw. + val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = BackupOnlyRepository(null)) + assertFailsWith { list.follow(entry(alpha, "Alpha")) } + } + + @Test + fun aMembershipOnlyTheRetiredListCarriesIsMigratedByTheNextWrite() = + runTest { + val repo = BackupOnlyRepository(ConcordCommunityListEvent.create(signer, listOf(entry(alpha, "Alpha")))) + val list = ConcordChannelListState(signer = signer, cache = StubCache(), scope = CoroutineScope(Dispatchers.Unconfined), settings = repo) + list.markRelaysConfirmed() + assertEquals(listOf(alpha), list.entries().map { it.id }) + + list.follow(entry(beta, "Beta")) + val migrated = + ConcordCommunityList + .decodeDocument(readBack(repo.fragments).doc) + .entries + .map { it.id } + .toSet() + assertEquals(setOf(alpha, beta), migrated) } /** - * The list is only readable by its owner, so the leave write must stay self-encrypted — a leave + * The list is only readable by its owner, so every fragment must stay self-encrypted — a leave * that leaked the remaining memberships in cleartext would be worse than not leaving at all. */ @Test fun theRewrittenListStaysSelfEncrypted() = runTest { - val list = state(entry(alpha, "Alpha"), entry(beta, "Beta")) + val (list, _) = state(entry(alpha, "Alpha"), entry(beta, "Beta")) - val left = list.unfollow(alpha)!! + val left = list.unfollow(alpha).single() as ConcordCommunityListFragmentEvent - assertEquals(ConcordCommunityListEvent.KIND, left.kind) + assertEquals(ConcordCommunityListFragmentEvent.KIND, left.kind) + assertEquals("0", left.dTag()) assertTrue(beta !in left.content) + assertTrue(ConcordListFragments.hexToB64(beta) !in left.content) val stranger = NostrSignerInternal(KeyPair("0000000000000000000000000000000000000000000000000000000000000009".hexToByteArray())) - assertTrue(left.decrypt(stranger).isEmpty()) + assertEquals(null, left.decryptPlaintext(stranger)) } } 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..544d97152d --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt @@ -0,0 +1,308 @@ +/* + * 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, + community.communityId, + 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, community.communityId, secretId, ChannelEntity(name = "x".repeat(65)), community.genesisEditions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.defineChannel(owner, community.controlPlane, community.communityId, secretId, ChannelEntity(name = ""), community.genesisEditions, 2L, owner = community.ownerPubKey) + } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt index 95e7721ff8..dd56764a26 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySessionTest.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair @@ -31,6 +33,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue class ConcordCommunitySessionTest { @@ -162,4 +165,39 @@ class ConcordCommunitySessionTest { val outsider = ConcordCommunityFactory.create(owner, "Other", createdAt = 1L, relays = listOf("wss://r.example")) assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(outsider.genesisWraps.first())) } + + private fun entryFor(community: NewConcordCommunity) = + 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(), + relays = listOf("wss://r.example"), + name = "Doomed", + ) + + @Test + fun anOwnerTombstoneAtTheDissolvedAddressSealsTheCommunity() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Doomed", createdAt = 1L, relays = listOf("wss://r.example")) + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + community.genesisWraps.forEach { session.ingest(it) } + assertFalse(session.state.value!!.dissolved) + assertTrue(session.auxStreamKeys().any { it.publicKeyHex == session.dissolvedAddress }, "the grave must be AUTHed for") + + // A stranger can sign at the (public) address, but only the owner's tombstone counts. + val stranger = NostrSignerInternal(KeyPair()) + session.ingest(ConcordDissolution.build(stranger, community.communityIdHex)) + assertFalse(session.state.value!!.dissolved) + + session.ingest(ConcordDissolution.build(owner, community.communityIdHex)) + assertTrue(session.state.value!!.dissolved) + + // One-way: a later control fold never clears it. + community.genesisWraps.forEach { session.ingest(it) } + assertTrue(session.state.value!!.dissolved) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt index af29a84fff..09b2891776 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordListLateArrivalTest.kt @@ -26,8 +26,11 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.amethyst.commons.model.cache.ICacheEventStream import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragments import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -131,4 +134,29 @@ class ConcordListLateArrivalTest { assertEquals(1, state.liveCommunities.value.size) assertEquals(alpha, state.liveCommunities.value[0].id) } + + @Test + fun lateArrivingFragmentDecryptsAndSurfaces() = + runTest { + val cache = StubCache() + val state = + ConcordChannelListState( + signer = signer, + cache = cache, + scope = CoroutineScope(Dispatchers.Unconfined), + settings = NoBackupRepository(), + ) + assertEquals(emptyList(), state.liveCommunities.value) + + // A relay delivers fragment 0 of the kind-33302 List into the note the state watches. + val internal = ConcordCommunityList.encodeInternal(listOf(entry(alpha, "Alpha"))) + val fragment = ConcordCommunityListFragmentEvent.create(signer, 0, ConcordListFragments.pack(internal).single()) + val author = User(signer.pubKey) { addr -> Note(addr.toValue()) } + cache.getOrCreateAddressableNote(fragment.address()).loadEvent(fragment, author, emptyList()) + + withTimeout(5000) { + while (state.liveCommunities.value.isEmpty()) yield() + } + assertEquals(listOf(alpha), state.liveCommunities.value.map { it.id }) + } } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt index 770e7ed98e..170bd9e7aa 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordMembershipTest.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.commons.model.concord import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey import kotlin.test.Test import kotlin.test.assertEquals @@ -42,13 +44,20 @@ class ConcordMembershipTest { author: String = owner, ) = ControlEdition(kind, eid.hexToByteArray(), 0, null, null, content, author, "r-$eid", 0) + private val communityId = "cc".repeat(32).hexToByteArray() + private val authority = AuthorityResolver.resolve( listOf( ed(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), // KICK|BAN|MANAGE_ROLES - ed(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$admin","role_ids":["$adminRole"]}"""), - ed(ControlEntityKind.BANLIST, "44".repeat(32), """["$banned"]"""), + ed( + ControlEntityKind.GRANT, + ConcordKeyDerivation.grantCoordinate(communityId, admin.hexToByteArray()).toHexKey(), + """{"member":"$admin","role_ids":["$adminRole"]}""", + ), + ed(ControlEntityKind.BANLIST, ConcordKeyDerivation.banlistCoordinate(communityId).toHexKey(), """["$banned"]"""), ), + communityId, owner, ) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt index 25b14d7fd1..266a1c585b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistryTest.kt @@ -66,7 +66,7 @@ class ConcordPlaneRegistryTest { assertEquals(community.communityIdHex, routedControl.plane.communityId) // After folding + registering channels, a channel message routes to CHANNEL. - val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.ownerPubKey) + val state = ConcordActions.foldCommunity(community.genesisWraps, community.controlPlane, community.communityId, community.ownerPubKey) registry.registerChannels(entry, state) val channel = ConcordActions.publicChannel(community.communityRoot, community.generalChannelId, community.rootEpoch) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt index ae06a84891..116cdf644d 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordRollbackFloorTest.kt @@ -78,7 +78,7 @@ class ConcordRollbackFloorTest { // new epoch's plane is split and addressed by the derived signer, not the root. val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) - val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey) + val rolledBack = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -145,7 +145,7 @@ class ConcordRollbackFloorTest { val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) // Honest: compacted from the FULL prior plane, so each entity's head (metadata v1) survives. - val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.ownerPubKey) + val honest = ConcordRefounding.compactControlPlane(epoch0Wraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -186,7 +186,7 @@ class ConcordRollbackFloorTest { // new epoch's plane is split and addressed by the derived signer, not the root. val newControlRoot = ByteArray(32) { 0x44 } val newControl = ControlPlaneKeys.forStaff(newRoot, community.communityId, newEpoch, newControlRoot) - val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.ownerPubKey) + val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, community.ownerPubKey) val entry = ConcordCommunityListEntry( @@ -246,10 +246,11 @@ class ConcordRollbackFloorTest { val floors = ConcordCommunityState.authorizedHeads( ConcordActions.controlEditions(community.genesisWraps + rogueEdit, community.controlPlane), + community.communityId, community.ownerPubKey, ) val metadataFloor = floors[community.communityIdHex] - assertEquals(0L, metadataFloor?.version, "an unauthorized edition must not raise the floor") + assertEquals(1L, metadataFloor?.version, "an unauthorized edition must not raise the floor") } } diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt index 32d0003c47..549d12b5f1 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LatestEventCacheStoreTest.kt @@ -107,10 +107,11 @@ class LatestEventCacheStoreTest { assertEquals("latestUserMetadata", LatestEventSlot.USER_METADATA.prefKey) assertEquals("latestContactList", LatestEventSlot.CONTACT_LIST.prefKey) assertEquals("latestNIP65RelayList", LatestEventSlot.NIP65_RELAY_LIST.prefKey) - assertEquals(26, LatestEventSlot.entries.size) + assertEquals("latestConcordListFragments", LatestEventSlot.CONCORD_LIST_FRAGMENTS.prefKey) + assertEquals(27, LatestEventSlot.entries.size) assertEquals( "prefKeys must be unique", - 26, + 27, LatestEventSlot.entries .map { it.prefKey } .toSet() diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt index 8b5787bc83..e5343a4287 100644 --- a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/model/preferences/LegacyKeyTableTest.kt @@ -209,7 +209,7 @@ class LegacyKeyTableTest { ) assertEquals(FollowListSlot.entries.size, TopNavFollowListStore.legacyTable.keys.size) - assertEquals(LatestEventSlot.entries.size, LatestEventCacheStore.legacyTable.keys.size) + assertEquals(LatestEventSlot.entries.count { it.existedInLegacyPrefs }, LatestEventCacheStore.legacyTable.keys.size) } /** Several tables share one store, so their markers must not collide. */ 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 new file mode 100644 index 0000000000..b4a9ec4eb9 --- /dev/null +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -0,0 +1,108 @@ +# Concord spec conformance review (spec `b84554e`, 2026-08-15) + +## Why + +Our Concord implementation last tracked the spec at `bbc67b6` (2026-08-06, CORD-02 §2 +`control_root`). Since then the spec moved on, and several sections that predate that +point were never implemented. This review compares every CORD against + at `b84554e`, cross-checked against the +Armada reference client (`soapbox-pub/armada` at `7588884`, 2026-09-28), and records what +we fixed and what is left. + +Method: one audit per spec area (CORD-01/03, CORD-02 §2/§5/§6 + CORD-04, CORD-02 §7/§9 + +CORD-05/06, CORD-07/08), each reading our code against the spec text and against +Armada. CORD-02 §8 (the Community List) was reviewed by hand against Armada's +`listFrag.ts` / `communityList.ts`. Status legend: **fixed** (this branch, with tests), +**open** (not done, with the reason). + +## What changed upstream since our last pass + +| Commit | Date | Change | Our state before this review | +|---|---|---|---| +| `d584686` | 07-27 | CORD-02 §9: the dissolution tombstone's `eid` is the `community_id`; verifiers MUST refuse anything else, including the old all-zero `eid` | Not implemented at all: we never derived `dissolved_pk`, never read or wrote a tombstone, and folded a vsk-10 *Control Plane* edition as dissolution with no `eid` check | +| `e8c4eeb` | 08-02 | CORD-04 §7: provable, rotation-proof Pins (vsk 11, `PIN_MESSAGES` bit 11, `concord/pins` coordinate, NIP-44 key-disclosure proof bundles) | Missing (only the permission bit existed) | +| `93fbecf` | 08-03 | CORD-08: Disappearing Messages (`message_expiration` in metadata, NIP-40 tags on rumor + wrap, kind 1740 timer notice) | Missing, and worse: a metadata edit dropped the field, silently turning off Armada's default 30-day timer for everyone | +| `96f0647`, `bbc67b6` | 08-06 | CORD-02 §2 `control_root` write gate | Implemented (v1.14.0) | +| `759448a`, `ee6f639` | 08-11/15 | CORD-02 §8: the Community List becomes fragmented kind **33302** (13302 retired), unpadded base64url for every 32-byte value, `seed` omitted when equal to `current`, mandatory tombstones, read-modify-write, a byte ceiling instead of a 50-entry cap | Still on 13302, hex, no tombstones (leaving just dropped the entry) | +| `01.md` | 08-11 | Encrypted application documents may choose their own encoding (only §8 does) | n/a | + +Still-open upstream PRs worth tracking: **#23** (authenticate `community_root` at accept; +would make our stranded-recovery root move an explicit MUST violation), **#22** +(community-owned `av_brokers` in metadata), **#17** (Community Signals, vsk 12), +**#16** (CORD-09 blinded mention locators), **#7** (drop the `ms` tag). #12 (split +read/write planes) is superseded by the merged `control_root` design. + +## Findings + +Ranked security > interop > feature inside each group. + +### Security + +| # | 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 | **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 | **fixed** — `ConcordStrandedRecovery` only detects (`isStranded`); the background sweep never moves the base (exposes `strandedConcordCommunities`); moving forward from a bundle needs the user to re-open the link (`joinConcordViaInvite`, `amy concord recover --rejoin`). PR #23's accept-time root gate not implemented (text unavailable; genuine owner editions re-wrap into any plane, so it needs the PR's exact rule) | +| 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 | **fixed** — `AuthorityResolver.resolve` / `ConcordCommunityState.fold` / `authorizedHeads` take the `community_id`; a Grant edition counts only when its `eid == grant_locator(cid, content.member)` | +| 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 | **fixed** — the Banlist folds to ONE authority-gated head at `banlist_locator(cid)`; the rank-delta rule is kept; re-heal = the writer re-applying atop the winner (`ConcordModeration.ban` again after refold) | +| 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 | **fixed** — `EditionFold.foldEntityGated`/`foldGated` take an author rank: authority first, then rumor id, both in the head pick (Armada `pickHead`) and in the walk's anchor/next-link choice. The walk part goes past Armada (whose `version.fold` walks on rumor id only, so a lower-id fork can strand an edition chained on the owner's sibling) — spec followed | +| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | **fixed** — metadata is read only at `eid == community_id` (a fold gate, `AuthorityResolver.isWellFormed`) | +| 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` | **fixed** — control: `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction; chat: `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 | +| 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 | **fixed** — `ConcordInviteBundle.bound`: >256 channels refused, `relays` de-duplicated + truncated to 5, applied in `parse` (link bundles) and `ConcordDirectInvite.parse`; fragments decode ≤3 relays | +| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | **fixed** — `compactControlPlane(…, mustCarry)` throws `IncompleteControlPlaneException` on a missing honored head; app sweeps the plane paged (majority of relays DRAINED) and CLI pages it; rekey chunks are `publishAndConfirm`ed first, compaction published only after | +| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | **fixed** — dissolved check in `drainConcordRekeys`, `recoverStrandedConcordCommunities`, `refoundConcordCommunity`, the explicit rejoin, and CLI `rekey`/`recover`/`refound` (`ConcordCommands.isDissolved`) | +| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | **fixed** — `classify(wraps, linkSignerPubKey, token)` keeps only events with kind 33301, author == link signer, `d == ""` and a valid signature (`isAtCoordinate`); every caller passes the signer | + +### Interop (Armada drops or diverges) + +| # | Spec | Finding | Status | +|---|---|---|---| +| I1 | 02 §8 | Community List on retired 13302, hex, no fragments, no tombstones | in progress (this branch) | +| I2 | 02 §6 | Metadata/Channel edits rebuilt from scratch, wiping `custom`, `message_expiration` (CORD-08), `av_brokers` | **fixed** — `ConcordJson.encodePreserving` lays every edit over the authorized head; metadata/channel forms start from the folded entity | +| I3 | 03 §2 | Per-channel `voice` flag still modeled and rendered (every Channel is callable since `23dcea5`) | **fixed** — field removed (rides through as an unknown key), Mic icon and blank-preview special case removed | +| I4 | 04 §1/§5 | `vac` never written or verified — Armada drops every non-owner edition we author | **fixed** — `ConcordModeration` stamps every non-owner edition with `AuthorityCitations.forActor` (own grant coordinate, folded head version + hash); every fold gate (roles, grants, banlist, metadata, channels, unmodeled kinds, floors/compaction) requires it per Armada `citationSatisfied` | +| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | **fixed** — `RoleEntity.roleId` written by `defineRole`; read accepts a legacy role without it, refuses a mismatching one | +| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | **fixed** — genesis and every new entity start at v1; v0 chains still read | +| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | **fixed** — a canonical unmodeled `vsk` parses with `entityKind == null` + raw `vsk`; floors and compaction carry its gated head verbatim (11 by `PIN_MESSAGES`, 12 by `MANAGE_CHANNELS`, others by any staff bit). vsk 6/7/9/10 are no longer parsed as Control editions | +| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | **fixed** — `ConcordRekey.tags` takes a 1-based index (`require 1..n`); `chunkOf` parses strict decimals and refuses 0 / `i > n`; receivers drop malformed chunks | +| I9 | 06 §3 | Rotations carry no `vac` | **fixed** — rotations carry `vac` on every chunk (`ConcordRotationAuthority.citationFor`, owner none); receivers require `ConcordReceive.isHonoredRotation` (BAN + `citationSatisfied`, Armada semantics) in the app drain and CLI `rekey`; a rotator whose chunks cite different Grants is dropped | +| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | **fixed** — `ConcordRekey.chunkBlobs` budgets the rumor JSON at 40,960 bytes (Armada `REKEY_RUMOR_MAX_BYTES`) plus the 120 count cap; test pins the seal (wrap plaintext) ≤ 65,535 with 136-byte blobs | +| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | **fixed** — `encodeFragment` truncates non-stock lists to 3 (stock set stays a flag); `decodeFragment` refuses count > 3 | +| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | **fixed** — `findNewRoot` converges on the lowest authorized root (`accept` filter before `converge`); sessions watch the current epoch's own rekey address and `drainConcordRekeys` heals down-only (`ConcordReceive.withHealedRoot`), keeping the losing root as a same-epoch held root (only the lowest per epoch is folded: `canonicalHeldRoots`); retries reuse reserved keys (`ConcordRefounding.reserveKeys`; in-memory in the app, persisted in amy's store). Not done: the CLI has no heal step; re-issuing a losing branch's channel keys (no private channels yet, F7) | +| 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 | **fixed** — `ConcordLimits`; refused on write (factory, `ConcordModeration`, app verbs, CLI) and enforced at fold like Armada: over-cap role/metadata editions fall back, a Grant's `role_ids` trim to 64, the Community keeps its 100 lowest `role_id`s. Also: `ev`/`vac`/`vsk` must be canonical decimals and a duplicate `vsk`/`eid`/`ev`/`ep`/`vac` invalidates the edition; CLI knows `VIEW_AUDIT_LOG`/`MENTION_EVERYONE`/`PIN_MESSAGES`; the app's Admin role matches Armada's `ADMIN_ALL` | +| 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 | **fixed** — join echoes `creator_npub`/`label` in the Guestbook Join (mints now set `creator_npub`); `amy concord join` publishes a Guestbook Join; Invite List merge is first-wins per token; untyped tombstones carried as `opaqueTombstones` and still retire their token | + +### Features + +| # | Spec | Finding | Status | +|---|---|---|---| +| F1 | 04 §7 | Pins | open → pins batch | +| F2 | 08 | Disappearing Messages (sender tags, reader refusal/hiding/purge, 1740 notice, settings UI) | metadata field + parse **fixed**; the rest open → chat-plane batch | +| F3 | 07 | A/V calls: only key derivation, the 27235 grant and 23313 presence builders exist; no broker/SFU client, no media E2EE. Needs a LiveKit client whose license must be checked first | open — out of scope for this pass | +| F4 | 07 | Broker token has no nonce (same-second requests collide in the broker's replay set); presence fold doesn't take latest-per-author | open → chat-plane batch (quartz only) | +| F5 | 05 §5 | Invite Registry (vsk 8) not published or folded | open | +| F6 | 05 §6 | Direct invites: wire format only, no send/receive | open | +| F7 | 06 §1-2 | Channel-scope rekeys; private-channel keys in invites | open (depends on S2) | +| F8 | 06 §2, 02 §8 | Walk forward from `seed`; we still keep intermediate roots in a `held_roots` List extension the spec says doesn't belong there | open | +| F9 | 04 §6 | Kick (kind 3309) | open | +| F10 | 03 | WebXDC (kind 3310) | open | + +## Spec issues to raise upstream + +- CORD-06 §1 counts rekey capacity in blobs ("up to 120 participants per event"), but 120 base + blobs overflow NIP-44's 65,535-byte plaintext once wrapped; the spec should state the byte budget + (Armada uses a 40,960-byte rumor ceiling). +- The rekey blob plaintext is base64-encoded before NIP-44 (signer APIs take strings); unpinned by + the spec, as Armada's own comment notes. +- Rekey seal kind (20013) and the `chunk` indexing base are implied by examples only; worth a MUST. +- 02 §8 and examples §6.2 cite "a dissolution payload (CORD-06 §1)", but CORD-06 defines no + such payload, and Armada has none. Dangling reference. +- CORD-07 §2 should require a nonce in the 27235 grant (Armada already adds one): two + members requesting in the same second otherwise produce the same event id and collide + in the broker's mandatory replay set. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt index bf3dd5fb98..6b200dab9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactory.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind @@ -86,6 +87,10 @@ object ConcordCommunityFactory { relays: List = emptyList(), icon: ImagePointer? = null, ): NewConcordCommunity { + // CORD-02 §6 caps, which every reader also enforces at fold: an over-long genesis name + // would leave the community with no metadata at all. + require(ConcordLimits.nameFits(name)) { "community name exceeds ${ConcordLimits.NAME_MAX_BYTES} bytes" } + require(ConcordLimits.descriptionFits(description)) { "description exceeds ${ConcordLimits.DESCRIPTION_MAX_BYTES} bytes" } val ownerXOnly = ownerSigner.pubKey.hexToByteArray() val ownerSalt = ConcordKeyDerivation.newOwnerSalt() val communityId = ConcordKeyDerivation.communityId(ownerXOnly, ownerSalt) @@ -114,7 +119,7 @@ object ConcordCommunityFactory { authorPubKey = ownerSigner.pubKey, entityKind = ControlEntityKind.METADATA, entityId = communityId, // metadata eid == community id - version = 0, + version = 1, prevHash = null, content = metadataJson, createdAt = createdAt, @@ -124,7 +129,7 @@ object ConcordCommunityFactory { authorPubKey = ownerSigner.pubKey, entityKind = ControlEntityKind.CHANNEL, entityId = generalChannelId, // channel eid == channel id - version = 0, + version = 1, prevHash = null, content = channelJson, createdAt = createdAt, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 11ce25695e..ceda69c3cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt @@ -126,6 +126,29 @@ class ConcordListResidue( val tombstones: List = emptyList(), val unparsedEntries: List = emptyList(), ) { + /** + * This residue with [communityId] tombstoned at [removedAtMs] (CORD-02 §8). There is exactly + * one tombstone per community: a later removal replaces an earlier one (keeping its unknown + * keys), an earlier one changes nothing. + */ + fun withTombstone( + communityId: String, + removedAtMs: Long, + ): ConcordListResidue { + val prior = tombstones.firstOrNull { (it["community_id"] as? JsonPrimitive)?.contentOrNull == communityId } + val priorAt = (prior?.get("removed_at") as? JsonPrimitive)?.longOrNull + if (priorAt != null && priorAt >= removedAtMs) return this + val next = JsonObject((prior ?: NoExtras) + mapOf("community_id" to JsonPrimitive(communityId), "removed_at" to JsonPrimitive(removedAtMs))) + return ConcordListResidue(extras, tombstones.filterNot { it === prior } + next, unparsedEntries) + } + + /** The latest `removed_at` this residue holds for [communityId], or null when it was never left. */ + fun removedAt(communityId: String): Long? = + tombstones + .filter { (it["community_id"] as? JsonPrimitive)?.contentOrNull == communityId } + .mapNotNull { (it["removed_at"] as? JsonPrimitive)?.longOrNull } + .maxOrNull() + companion object { val EMPTY = ConcordListResidue() } @@ -434,6 +457,39 @@ object ConcordCommunityList { return ConcordJson.instance.encodeToString(JsonObject.serializer(), merged) } + /** + * [encode] as a JSON object: the internal document shape [ConcordListFragments] merges and + * packs into kind-33302 fragments. + */ + fun encodeInternal( + entries: List, + residue: ConcordListResidue = ConcordListResidue.EMPTY, + ): JsonObject = ConcordJson.instance.parseToJsonElement(encode(entries, residue)).jsonObject + + /** + * The List as a reader sees it (CORD-02 §8): the union of the kind-33302 fragments in [set] + * and, when one exists, the retired kind-13302 document's [legacyPlaintext] — read as a rescue + * source, so a membership only the old event carries stays joined until a write migrates it. + * A legacy document that does not parse contributes nothing. + */ + fun readWithLegacy( + set: ConcordListFragmentSet, + legacyPlaintext: String?, + ): JsonObject { + val legacy = + legacyPlaintext?.let { + try { + ConcordJson.instance.parseToJsonElement(it) as? JsonObject + } catch (_: Exception) { + null + } + } ?: return set.doc + return ConcordListFragments.mergeDocs(legacy, set.doc) + } + + /** Decodes an internal document (a merged fragment set, or a legacy 13302 plaintext). */ + fun decodeDocument(doc: JsonObject): ConcordCommunityListDocument = decodeDocument(ConcordJson.instance.encodeToString(JsonObject.serializer(), doc)) + /** * Parses the decrypted plaintext JSON document back into live entries, or empty on * failure. An entry is live unless a tombstone for the same community removed it @@ -585,6 +641,26 @@ object ConcordCommunityList { residue = residue, ) + /** Copy of this entry with [addedAt] (ms); every other field untouched. */ + fun ConcordCommunityListEntry.withAddedAt(addedAt: Long) = + ConcordCommunityListEntry( + id = id, + owner = owner, + ownerSalt = ownerSalt, + root = root, + rootEpoch = rootEpoch, + controlPk = controlPk, + controlRoot = controlRoot, + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = relays, + name = name, + addedAt = addedAt, + inviteRef = inviteRef, + excludedAtEpoch = excludedAtEpoch, + residue = residue, + ) + /** Copy of this entry carrying [inviteRef]; every other field untouched. */ fun ConcordCommunityListEntry.withInviteRef(inviteRef: String?) = ConcordCommunityListEntry( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt index 9f001418c9..d491161752 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListEvent.kt @@ -29,8 +29,14 @@ import com.vitorpamplona.quartz.nip01Core.diff.ContentChange import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException /** + * **Retired** (CORD-02 §8): the single-event Community List, superseded by the fragmented kind + * 33302 [ConcordCommunityListFragmentEvent] once it outgrew one event. Still read, as a rescue + * source unioned into the fragments, so memberships only this event carries are migrated by the + * next write; never written. + * * The member's private, self-encrypted list of joined Concord communities (kind * 13302, CORD-05). A replaceable event whose * `content` is the NIP-44 self-encryption of the [ConcordCommunityListEntry] JSON @@ -59,6 +65,16 @@ class ConcordCommunityListEvent( override fun isContentEncoded() = true + /** The decrypted plaintext (the internal document shape), or null when it does not open. */ + suspend fun decryptPlaintext(signer: NostrSigner): String? = + try { + signer.nip44Decrypt(content, signer.pubKey) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + /** Decrypts this list's entries with [signer], or empty on failure / wrong key. */ suspend fun decrypt(signer: NostrSigner): List = decryptDocument(signer).entries diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt new file mode 100644 index 0000000000..4c58ba75df --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt @@ -0,0 +1,108 @@ +/* + * 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.quartz.concord.cord02Community + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.diff.ContentChange +import com.vitorpamplona.quartz.nip01Core.diff.DiffableEvent +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException + +/** + * One fragment of a member's Community List (CORD-02 §8, kind 33302): addressable at + * `d` = the fragment index in decimal, NIP-44-encrypted to self, signed by the member's real key. + * + * The List is split across as many of these as it needs — it has no membership limit, only a + * per-event byte ceiling — and a reader unions every fragment below the declared `frags` count + * ([ConcordListFragmentSet]). It supersedes the single replaceable kind-13302 + * [ConcordCommunityListEvent], which a replaceable kind could never fragment. + */ +@Immutable +class ConcordCommunityListFragmentEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + DiffableEvent { + override fun diffFrom(older: Event): ConcordCommunityListDiff? { + if (older !is ConcordCommunityListFragmentEvent || older.pubKey != pubKey || older.dTag() != dTag()) return null + return ConcordCommunityListDiff(ContentChange.between(older.content, content)) + } + + override fun isContentEncoded() = true + + /** + * The fragment index this event occupies, or null when its `d` is not a canonical decimal + * (no sign, no leading zeros) — such an event sits at no index and is ignored. + */ + fun index(): Int? = parseIndex(dTag()) + + /** + * The decrypted plaintext, or null when it does not open for [signer]. A null makes the + * fragment's index unreadable, which blocks any repack — so a transient signer failure (a + * timed-out bunker, a backgrounded signer app) costs a write, never a membership. + * Cancellation is rethrown. + */ + suspend fun decryptPlaintext(signer: NostrSigner): String? = + try { + signer.nip44Decrypt(content, signer.pubKey) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + companion object { + const val KIND = 33302 + const val ALT = "Private list of joined Concord communities" + + fun createAddress( + pubKey: HexKey, + index: Int, + ) = Address(KIND, pubKey, index.toString()) + + fun parseIndex(d: String): Int? { + if (d.isEmpty() || d.length > 9) return null + if (d.length > 1 && d[0] == '0') return null + if (!d.all { it in '0'..'9' }) return null + return d.toInt() + } + + /** Encrypts [plaintext] to self and signs it as fragment [index]. */ + suspend fun create( + signer: NostrSigner, + index: Int, + plaintext: String, + createdAt: Long = TimeUtils.now(), + ): ConcordCommunityListFragmentEvent { + val content = signer.nip44Encrypt(plaintext, signer.pubKey) + return signer.sign(createdAt, KIND, arrayOf(arrayOf("d", index.toString())), content) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index b2c8db0179..34771f3f23 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord04Roles.asFloor +import com.vitorpamplona.quartz.nip01Core.core.toHexKey /** A channel id paired with its current folded definition. */ data class ConcordChannel( @@ -57,7 +58,16 @@ data class ConcordCommunityState( val authority: AuthorityResolver, val dissolved: Boolean, ) { + /** + * This state with [dissolved] set from the community's dissolution plane + * ([ConcordDissolution.isDissolved]). One-way by the caller's contract: there is no un-dissolve. + */ + fun withDissolved(dissolved: Boolean): ConcordCommunityState = if (dissolved == this.dissolved) this else copy(dissolved = dissolved) + companion object { + /** The Community Signals sub-kind (CORD-04 §8, upstream PR #17), carried but not modeled here. */ + private const val VSK_SIGNALS = "12" + /** * The permission bit an edition of each entity kind must be authored under. * `null` means owner-only (no bit grants it). Mirrors the per-kind gating @@ -71,12 +81,40 @@ data class ConcordCommunityState( ControlEntityKind.BANLIST -> ConcordPermissions.BAN ControlEntityKind.INVITE_LIVE, ControlEntityKind.INVITE_REGISTRY, ControlEntityKind.INVITE_REVOKED -> ConcordPermissions.CREATE_INVITE ControlEntityKind.DISSOLVED -> null + ControlEntityKind.PIN_LIST -> ConcordPermissions.PIN_MESSAGES + } + + /** + * Whether a reader honors [edition] as its entity's head: well-formed at its coordinate, + * authored by the owner or a holder of the kind's bit, citing the Grant it acts under. + * + * A sub-kind we do not model is still gated — a floor or a compaction must only remember + * editions some reader honors: a Signal by `MANAGE_CHANNELS` (the one gate Armada implements, `pause`), and anything newer by any + * staff bit, the set whose actions are Control editions at all (CORD-04 §3). + */ + private fun honors( + authority: AuthorityResolver, + edition: ControlEdition, + ): Boolean { + val kind = edition.entityKind ?: return honorsUnmodeled(authority, edition) + return authority.admits(edition, requiredPermission(kind)) + } + + private fun honorsUnmodeled( + authority: AuthorityResolver, + edition: ControlEdition, + ): Boolean = + when (edition.vsk) { + VSK_SIGNALS -> authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS) + else -> authority.isOwner(edition.author) || (authority.isStaff(edition.author) && authority.citationSatisfied(edition)) } /** * The authority-gated structural head of **every** control entity, keyed by * [ControlEdition.entityIdHex] — the source of the anti-rollback [EntityFloor]s a - * client carries across a CORD-06 Refounding. + * client carries across a CORD-06 Refounding, and the set of heads a Refounding's + * compaction re-wraps (sub-kinds we do not model included, so another client's Pins + * or Signals survive our Refounding). * * It is deliberately gated the same way [fold] gates each entity kind (and, for * [ControlEntityKind.DISSOLVED], owner-only): an *ungated* head map would let any @@ -86,6 +124,7 @@ data class ConcordCommunityState( */ fun authorizedHeads( editions: Collection, + communityId: ByteArray, ownerPubKey: String, floors: Map = emptyMap(), ): Map { @@ -96,15 +135,14 @@ data class ConcordCommunityState( // mentioned correctly keeps chain-walk semantics. val snapshot = editions.mapTo(HashSet(editions.size)) { it.rumorId } val pool = EditionFold.admissible(editions, floors, snapshot = snapshot) - val authority = AuthorityResolver.resolve(pool, ownerPubKey) + val authority = AuthorityResolver.resolve(pool, communityId, ownerPubKey) val out = HashMap(floors) - for ((kind, list) in pool.groupBy { it.entityKind }) { - val bit = requiredPermission(kind) + for ((_, list) in pool.groupBy { it.entityKind }) { // Gate the CANDIDATES, don't pre-filter the chain: a rejected edition mid-chain must // stay inert instead of orphaning the authorized editions above it (EditionFold.candidates). val heads = - EditionFold.foldGated(list, floors, snapshot = snapshot) { - authority.isOwner(it.author) || (bit != null && authority.hasPermission(it.author, bit)) + EditionFold.foldGated(list, floors, snapshot = snapshot, rank = authority::tieBreakRank) { + honors(authority, it) } for ((entity, head) in heads) { // Monotonic: a floor only ever rises. Folding epoch by epoch, an entity the @@ -116,8 +154,14 @@ data class ConcordCommunityState( return out } + /** + * Folds one epoch's Control Plane [editions] of the community [communityId] (which pins + * every derived entity coordinate, CORD-04 §1) owned by [ownerPubKey] into its current + * state, honoring the anti-rollback [floors] carried from earlier epochs. + */ fun fold( editions: Collection, + communityId: ByteArray, ownerPubKey: String, floors: Map = emptyMap(), ): ConcordCommunityState { @@ -133,11 +177,10 @@ data class ConcordCommunityState( @Suppress("NAME_SHADOWING") val editions = EditionFold.admissible(editions, floors, snapshot = snapshot) - val heads = EditionFold.fold(editions, floors, snapshot = snapshot).values // Resolve authority from the FULL edition set (not the structural heads): the resolver // folds each role/grant chain through authorized editions only, so a rogue higher-version // edition can't supersede a legit one before authority is even judged. - val authority = AuthorityResolver.resolve(editions, ownerPubKey) + val authority = AuthorityResolver.resolve(editions, communityId, ownerPubKey) // CORD-04 §1: "an edition whose signer isn't authorized is dropped." Authority is // owner-rooted (the AuthorityResolver resolves it from the owner outward via the grant @@ -149,25 +192,46 @@ data class ConcordCommunityState( // remaining editions version-descending), never as a pre-filter on the chain: dropping a // rejected edition out of the middle of a chain permanently orphans every honest edition // above it, freezing the entity. See EditionFold.candidates. + // + // Every gate also demands the edition sit at its derived coordinate and cite the Grant + // its author acts under (CORD-04 §5, `vac`) — AuthorityResolver.admits — and an + // equal-version tie goes to the higher-ranked author before the rumor id (§1). fun foldGatedBy( kind: ControlEntityKind, bit: Int, ): Map = - EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot) { - authority.isOwner(it.author) || authority.hasPermission(it.author, bit) + EditionFold.foldGated(editions.filter { it.entityKind == kind }, floors, snapshot = snapshot, rank = authority::tieBreakRank) { + authority.admits(it, bit) } - // Metadata is one entity (== community id), gated by MANAGE_METADATA. Take the - // highest-version gated head (guarding against strays). + // Metadata is ONE entity, at the community_id itself (CORD-04 §1): an edition at any + // other coordinate is not this community's metadata however high its version, so it can + // neither shadow the chain nor bypass it (S8). Name and description caps are fold gates. val metadata = - foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA) - .values - .maxByOrNull { it.version } + foldGatedBy(ControlEntityKind.METADATA, ConcordPermissions.MANAGE_METADATA)[communityId.toHexKey()] ?.let { ConcordJson.decodeOrNull(it.content) } // Channels are gated by MANAGE_CHANNELS, per channel entity, dropping the tombstoned ones. + // The gate also enforces the name rule (non-empty, <= 64 UTF-8 bytes, CORD-03 §2): an + // edition breaking it is unauthorized and the fold falls back to the previous candidate. + val channelEditions = editions.filter { it.entityKind == ControlEntityKind.CHANNEL } + // The same gate every entity folds under (well-formed, owner or MANAGE_CHANNELS holder, `vac` + // satisfied), plus the Channel name rule: an empty or over-cap name is an edition no reader honors. + val channelGate = { edition: ControlEdition -> + authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS) && + ConcordJson.decodeOrNull(edition.content)?.hasValidName() == true + } + // Deletion is terminal (CORD-03 §2): any gated edition anywhere in a channel's accepted + // chain that says `deleted` retires it for good, even if a later edition "restores" it — + // members may already have discarded its keys, so a resurrection would split them. + val everDeleted = + channelEditions + .filter { edition -> + channelGate(edition) && ConcordJson.decodeOrNull(edition.content)?.deleted == true + }.mapTo(HashSet()) { it.entityIdHex } val channels = LinkedHashMap() - for (head in foldGatedBy(ControlEntityKind.CHANNEL, ConcordPermissions.MANAGE_CHANNELS).values) { + for (head in EditionFold.foldGated(channelEditions, floors, snapshot = snapshot, rank = authority::tieBreakRank, gate = channelGate).values) { + if (head.entityIdHex in everDeleted) continue val def = ConcordJson.decodeOrNull(head.content) ?: continue if (def.deleted) continue channels[head.entityIdHex] = ConcordChannel(head.entityIdHex, def) @@ -179,8 +243,12 @@ data class ConcordCommunityState( // so we take the roles the AuthorityResolver actually accepted from the owner outward. val roles = authority.roles() - // Dissolution is owner-only — a rogue tombstone must not appear to kill the community. - val dissolved = heads.any { it.entityKind == ControlEntityKind.DISSOLVED && authority.isOwner(it.author) } + // Dissolution is NOT read from the Control Plane. The tombstone is chainless and lives at its + // own address (CORD-02 §9, [ConcordDissolution]) where it must also name this community in its + // `eid`; a vsk-10 edition folded here would skip that binding check, so an owner's tombstone + // for another community re-wrapped onto this plane would kill this one. The caller that reads + // the dissolved plane sets [dissolved] via [withDissolved]. + val dissolved = false return ConcordCommunityState( ownerPubKey = ownerPubKey.lowercase(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolution.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolution.kt new file mode 100644 index 0000000000..3b481c91a3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolution.kt @@ -0,0 +1,137 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.GroupKey +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 +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Community Dissolution (CORD-02 §9): the owner-signed, chainless tombstone that ends a + * Community for good. + * + * The tombstone is a kind-3308 rumor carrying only `["vsk","10"]` and `["eid", community_id]`, + * plaintext-sealed (20014) by the owner and wrapped at [planeKey] — an address derived from the + * `community_id` alone, so no epoch or key is needed to find it and a Refounding can never strand + * the grave. It is **not** a Control Plane edition: it has no `ev`/`ep`/`vac`, and presence of one + * valid tombstone is the whole state. + * + * ## The `eid` binding is the security boundary + * + * The plane is public: anyone holding the `community_id` (it ships in every invite) derives the + * whole keypair and can sign wraps at it. The one thing they cannot make is an owner-signed + * `vsk 10` rumor — but a plaintext seal re-wraps verbatim, so an owner's genuine tombstone for + * community X could be lifted and re-wrapped at the address of any other community Y the same + * owner runs. [isTombstoneFor] therefore requires `eid == community_id`, and refuses anything + * else, including the all-zero placeholder earlier spec revisions used. Refusing that legacy + * value leaves an old dissolution reading alive (the owner re-dissolves); accepting it lets any + * multi-community owner's other communities be killed irrecoverably. + */ +object ConcordDissolution { + /** The dissolution tombstone's address + keys for [communityIdHex] (CORD-02 §9, A.6). */ + fun planeKey(communityIdHex: HexKey): GroupKey = ConcordKeyDerivation.dissolvedPlaneKey(communityIdHex.hexToByteArray()) + + /** The unsigned tombstone rumor for [communityIdHex], authored by [ownerPubKey]. */ + fun rumor( + ownerPubKey: HexKey, + communityIdHex: HexKey, + createdAt: Long = TimeUtils.now(), + ): Event = + RumorAssembler.assembleRumor( + ownerPubKey, + // Chainless: exactly vsk + eid, no ev/ep/vac (CORD-02 §9). + eventTemplate(ControlEditionEvent.KIND, "", createdAt) { + add(VskTag.assemble(ControlEntityKind.DISSOLVED)) + add(EidTag.assemble(communityIdHex.hexToByteArray())) + }, + ) + + /** + * Seals the tombstone with [ownerSigner] (plaintext 20014, so the owner's signature survives + * any re-wrap) and wraps it at the community's dissolved address. Only the owner's signature + * counts, so a non-owner [ownerSigner] produces a wrap every verifier ignores. + */ + suspend fun build( + ownerSigner: NostrSigner, + communityIdHex: HexKey, + createdAt: Long = TimeUtils.now(), + ): Event { + val plane = planeKey(communityIdHex) + val seal = ConcordStreamEnvelope.seal(rumor(ownerSigner.pubKey, communityIdHex, createdAt), plane, ownerSigner, encrypted = false) + return ConcordStreamEnvelope.wrapSeal(seal, plane, createdAt = createdAt) + } + + /** + * Whether [opened] is a valid tombstone for [communityIdHex] owned by [ownerPubKey]: a + * plaintext-sealed kind-3308 rumor, authored (seal signer) by the owner, `vsk 10`, and an + * `eid` equal to this community's id. Anything else — a wrong or all-zero `eid`, an encrypted + * seal, another author — is noise. + */ + fun isTombstoneFor( + opened: OpenedStreamEvent, + communityIdHex: HexKey, + ownerPubKey: HexKey, + ): Boolean { + if (!opened.author.equals(ownerPubKey, ignoreCase = true)) return false + if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT) return false + val rumor = opened.rumor + if (rumor.kind != ControlEditionEvent.KIND) return false + val vsk = rumor.tags.firstOrNull { it.size >= 2 && it[0] == VskTag.TAG_NAME }?.get(1) + val eid = rumor.tags.firstOrNull { it.size >= 2 && it[0] == EidTag.TAG_NAME }?.get(1) + return vsk == ControlEntityKind.DISSOLVED.wire && eid != null && eid.equals(communityIdHex, ignoreCase = true) + } + + /** Opens [wrap] at [communityIdHex]'s dissolved address and checks it with [isTombstoneFor]. */ + fun isTombstoneWrap( + wrap: Event, + communityIdHex: HexKey, + ownerPubKey: HexKey, + ): Boolean { + val opened = ConcordStreamEnvelope.openOrNull(wrap, planeKey(communityIdHex)) ?: return false + return isTombstoneFor(opened, communityIdHex, ownerPubKey) + } + + /** True when any of [wraps] is a valid tombstone for this community (CORD-02 §9). */ + fun isDissolved( + wraps: Collection, + communityIdHex: HexKey, + ownerPubKey: HexKey, + ): Boolean { + if (wraps.isEmpty()) return false + val plane = planeKey(communityIdHex) + return wraps.any { wrap -> + val opened = ConcordStreamEnvelope.openOrNull(wrap, plane) ?: return@any false + isTombstoneFor(opened, communityIdHex, ownerPubKey) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt new file mode 100644 index 0000000000..21afc71596 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt @@ -0,0 +1,260 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** Thrown when a write would need fragments this client does not hold. */ +class ConcordListIncompleteException( + message: String, +) : IllegalStateException(message) + +/** Thrown when a fragment would exceed the event ceiling (CORD-02 §8) and cannot be split here. */ +class ConcordListTooLargeException( + message: String, +) : IllegalStateException(message) + +/** + * The fragments of a member's Community List as this client holds them (CORD-02 §8): per index, + * the newest copy (newest `created_at`, ties to the lowest id, as relays resolve an addressable + * coordinate), the declared fragment count, whether the set is complete, and their union. + * + * "Absence is never a fact": a missing or unreadable fragment is news not yet heard. Reading an + * incomplete set is safe — every merge is commutative and idempotent — but a **repack** (any + * write that changes `frags`) needs the complete set, or it silently drops every membership in + * the fragments it never read. [planWrites] enforces that, falling back to a scoped write that + * only rewrites the fragments holding the changed memberships. + */ +class ConcordListFragmentSet private constructor( + /** The fragment count the newest held fragment declares (ties to the larger), 0 when none is held. */ + val declared: Int, + /** Newest readable copy per index. */ + val held: Map, + /** Newest `created_at` per index, readable or not — a write must exceed it. */ + private val createdAtFloor: Map, + /** Indices whose newest copy did not decrypt or parse. */ + val unreadable: Set, + /** A declared count past [ConcordListFragments.MAX_DECLARED_FRAGS] was clamped. */ + private val overflow: Boolean, +) { + class Held( + val createdAt: Long, + val plaintext: String, + val fragment: ConcordListFragments.DecodedFragment, + ) + + /** One copy of a fragment as fetched; [plaintext] is null when it did not decrypt. */ + class Copy( + val index: Int, + val createdAt: Long, + val id: String, + val plaintext: String?, + ) + + /** One fragment to (re)publish. */ + class Write( + val index: Int, + val plaintext: String, + val createdAt: Long, + ) + + /** + * True when every index below [declared] is held and readable **and** no fragment we saw failed + * to open. An unreadable copy may declare a larger count than any readable one, or be the only + * copy of its index, so its presence always means "not the whole List" — never grounds for a + * repack that would overwrite it. Vacuously true only when no fragment was seen at all. + */ + val complete: Boolean = !overflow && unreadable.isEmpty() && (0 until declared).all { it in held } + + /** True when no fragment has been seen at all. */ + val isEmpty: Boolean get() = createdAtFloor.isEmpty() + + /** + * The union of every held fragment below [declared], in the internal shape. Fragments at or + * past [declared] are out of range — an emptied index's stale memberships stay dormant. + * Fragment-level unknown keys belong to the List; where two fragments carry the same key the + * lowest index wins. + */ + val doc: JsonObject by lazy { + held.keys + .filter { it < declared } + .sortedDescending() + .fold(EMPTY_DOC) { acc, i -> ConcordListFragments.mergeDocs(acc, held.getValue(i).fragment.doc) } + } + + /** + * The fragments to publish so the wire holds [newDoc] (internal shape: this set's [doc] with + * the caller's change applied — a read-modify-write, never local state alone). + * + * With the complete List this repacks: every fragment whose bytes change, plus an emptied + * copy of each index a shrinking count drops (an abandoned index would come back into range + * later). Without it, it only rewrites the held fragments that mention a changed membership + * (or the lowest held one for a new membership), keeping the declared count. + * + * Every write carries a `created_at` strictly above that index's previous one. + */ + fun planWrites( + newDoc: JsonObject, + now: Long, + ): List { + val out = ArrayList() + + fun stamp(index: Int) = maxOf(now, (createdAtFloor[index] ?: 0L) + 1) + + if (complete) { + val packed = ConcordListFragments.pack(newDoc) + for ((i, plaintext) in packed.withIndex()) { + guardSize(i, plaintext) + if (held[i]?.plaintext != plaintext) out.add(Write(i, plaintext, stamp(i))) + } + val empty = ConcordListFragments.emptyFragment(packed.size) + for (i in packed.size until maxOf(declared, packed.size)) { + if (held[i]?.plaintext != empty) out.add(Write(i, empty, stamp(i))) + } + return out + } + + val changed = changedIds(newDoc) + if (changed.isEmpty()) return out + val inRange = held.keys.filter { it < declared }.sorted() + if (inRange.isEmpty()) throw ConcordListIncompleteException("no readable Community List fragment is held; refusing to write") + val targets = HashMap>() + for (id in changed) { + val holders = inRange.filter { id in ConcordListFragments.idsIn(held.getValue(it).fragment.doc) } + for (i in holders.ifEmpty { listOf(inRange.first()) }) targets.getOrPut(i) { HashSet() }.add(id) + } + for ((i, ids) in targets.entries.sortedBy { it.key }) { + val plaintext = ConcordListFragments.rewriteFragment(held.getValue(i).fragment.doc, newDoc, ids, declared) + guardSize(i, plaintext) + if (held[i]?.plaintext != plaintext) out.add(Write(i, plaintext, stamp(i))) + } + return out + } + + private fun guardSize( + index: Int, + plaintext: String, + ) { + val projected = ConcordListFragments.projectedEventBytes(plaintext.encodeToByteArray().size) + if (projected <= ConcordListFragments.EVENT_CEILING_BYTES) return + // A write that strictly shrinks the fragment is exempt: leaving must stay possible for a + // member already over the ceiling (CORD-02 §8). + val previous = held[index]?.plaintext ?: throw ConcordListTooLargeException("fragment $index would be $projected bytes") + if (plaintext.encodeToByteArray().size >= previous.encodeToByteArray().size) { + throw ConcordListTooLargeException("fragment $index would be $projected bytes; a split needs the complete List") + } + } + + /** Community ids whose entry or tombstone differs between [doc] and [newDoc]. */ + private fun changedIds(newDoc: JsonObject): Set { + fun byId( + d: JsonObject, + key: String, + ) = (d[key] as? JsonArray) + .orEmpty() + .mapNotNull { it as? JsonObject } + .associateBy { (it["community_id"] as? JsonPrimitive)?.content.orEmpty() } + + val out = HashSet() + for (key in listOf("entries", "tombstones")) { + val before = byId(doc, key) + val after = byId(newDoc, key) + for (id in before.keys + after.keys) { + val a = before[id]?.let { ConcordListFragments.canonicalString(it) } + val b = after[id]?.let { ConcordListFragments.canonicalString(it) } + if (a != b && id.isNotEmpty()) out.add(id) + } + } + return out + } + + companion object { + private val EMPTY_DOC = JsonObject(mapOf("entries" to JsonArray(emptyList()), "tombstones" to JsonArray(emptyList()))) + + /** Resolves already-decrypted [copies]. Pure, for tests and callers that decrypt elsewhere. */ + fun of(copies: Collection): ConcordListFragmentSet { + val newest = + copies + .groupBy { it.index } + .mapValues { (_, list) -> list.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }).first() } + val held = HashMap() + val unreadable = HashSet() + for ((i, copy) in newest) { + // Resolve the coordinate BEFORE decrypting: an unreadable head is a missing index, + // never a cue to fall back to an older copy a write would then be based on. + val text = copy.plaintext + val decoded = + text?.let { + try { + ConcordListFragments.decodeFragment(it) + } catch (_: Exception) { + null + } + } + if (text == null || decoded == null) unreadable.add(i) else held[i] = Held(copy.createdAt, text, decoded) + } + var best: Held? = null + for (h in held.values) { + val b = best + if (b == null || h.createdAt > b.createdAt || (h.createdAt == b.createdAt && h.fragment.frags > b.fragment.frags)) best = h + } + val wireDeclared = best?.fragment?.frags?.coerceAtLeast(1) ?: 0 + return ConcordListFragmentSet( + declared = minOf(wireDeclared, ConcordListFragments.MAX_DECLARED_FRAGS), + held = held, + createdAtFloor = newest.mapValues { it.value.createdAt }, + unreadable = unreadable, + overflow = wireDeclared > ConcordListFragments.MAX_DECLARED_FRAGS, + ) + } + + /** Decrypts [events] with [signer] and resolves them. Events at a non-canonical `d` are ignored. */ + suspend fun resolve( + events: Collection, + signer: NostrSigner, + ): ConcordListFragmentSet = resolve(events, signer.pubKey) { it.decryptPlaintext(signer) } + + /** + * [resolve] with a caller-supplied [decrypt], so a caller can memoize plaintext by event id + * instead of paying a signer round trip per fragment per read. Each event id is decrypted + * at most once per call, and only the newest copy per index is decrypted at all. + */ + suspend fun resolve( + events: Collection, + owner: String, + decrypt: suspend (ConcordCommunityListFragmentEvent) -> String?, + ): ConcordListFragmentSet { + val newestPerIndex = + events + .asSequence() + .filter { it.pubKey == owner } + .mapNotNull { e -> e.index()?.let { it to e } } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, list) -> list.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }).first() } + return of(newestPerIndex.map { (index, e) -> Copy(index, e.createdAt, e.id, decrypt(e)) }) + } + + val EMPTY: ConcordListFragmentSet = of(emptyList()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt new file mode 100644 index 0000000000..9fc0c8218c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt @@ -0,0 +1,613 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlin.io.encoding.Base64 + +/** + * The wire layer of the fragmented Community List (CORD-02 §8, kind 33302). + * + * Two JSON shapes meet here: + * + * - **internal** — what [ConcordCommunityList] reads and writes, and what the retired kind-13302 + * document always was: 32-byte values in lowercase hex, every snapshot carrying its own + * `community_id`, `seed` always present. Merges ([mergeDocs]) and the canonical-bytes + * tie-break run on this shape, as they do in the reference client. + * - **wire** — one fragment's plaintext: `{"frags": n, "entries": […], "tombstones": […]}`, every + * 32-byte value **this section names** as unpadded base64url (43 chars) at any depth, embedded + * snapshots without `community_id`, `seed` omitted when it equals `current`, and entries a + * tombstone outranks left out. + * + * ## Byte identity is the contract + * + * Two devices holding identical state must serialize identical bytes, or the tie-break flaps + * between their republishes. So the emitter here is hand-ordered to match the reference + * implementations (Armada `listFrag.ts`, Vector `list_frag.rs`): named fields in their declared + * order, optional ones omitted rather than null, then unknown fields in sorted key order with + * recursively key-sorted values. The packer is the same greedy first-fit, against the same + * 56 KiB target, so identical state also fragments identically. + * + * Unknown fields are never decoded or re-encoded: base64url and hex are indistinguishable for + * a string we don't know the meaning of, so an unknown field keeps its author's spelling. + */ +object ConcordListFragments { + /** Pack target for one fragment's fully encoded event (the reference client's 56 KiB). */ + const val PACK_TARGET_BYTES = 57_344 + + /** The refusal line: no fragment event may exceed this many bytes, fully encoded. */ + const val EVENT_CEILING_BYTES = 65_536 + + /** Junk ceiling on a declared `frags`, so one bad fragment can't drive an unbounded scan. */ + const val MAX_DECLARED_FRAGS = 4096 + + /** Non-content event bytes (id, pubkey, sig, tags, scaffolding) — deliberately generous. */ + private const val EVENT_ENVELOPE_BYTES = 320 + + private val MATERIAL_KEYS = setOf("owner", "owner_salt", "community_root", "root_epoch", "control_pk", "control_root", "channels", "relays", "name") + private val CHANNEL_KEYS = setOf("id", "key", "epoch", "name") + private val ENTRY_KEYS = setOf("community_id", "seed", "current", "added_at") + private val TOMBSTONE_KEYS = setOf("community_id", "removed_at") + private val LIST_KEYS = setOf("frags", "entries", "tombstones") + + private val json = Json { prettyPrint = false } + + private val B64 = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT) + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + private val B64URL43 = Regex("^[A-Za-z0-9_-]{43}$") + + // ---- encoding -------------------------------------------------------------------------- + + /** 32 bytes of hex to unpadded base64url; anything else passes through untouched. */ + fun hexToB64(value: String): String = if (HEX64.matches(value)) B64.encode(value.hexToByteArray()) else value + + /** Unpadded base64url of 32 bytes back to lowercase hex; anything else passes through. */ + fun b64ToHex(value: String): String { + if (!B64URL43.matches(value)) return value + val bytes = + try { + B64.decode(value) + } catch (_: Exception) { + return value + } + return if (bytes.size == 32) bytes.toHexKey() else value + } + + // ---- canonical JSON -------------------------------------------------------------------- + + /** [element] with every object's keys sorted, recursively — a total order for tie-breaks. */ + fun canonical(element: JsonElement): JsonElement = + when (element) { + is JsonObject -> JsonObject(element.keys.sorted().associateWithTo(LinkedHashMap()) { canonical(element.getValue(it)) }) + is JsonArray -> JsonArray(element.map { canonical(it) }) + else -> element + } + + fun canonicalString(element: JsonElement): String = json.encodeToString(JsonElement.serializer(), canonical(element)) + + private fun str(element: JsonElement): String = json.encodeToString(JsonElement.serializer(), element) + + /** Appends [source]'s keys outside [named] in sorted order, values canonicalized. */ + private fun MutableMap.putExtras( + source: JsonObject, + named: Set, + ) { + for (k in source.keys.filter { it !in named }.sorted()) { + val v = source.getValue(k) + if (v is JsonNull) continue + put(k, canonical(v)) + } + } + + // ---- small typed readers (strict, like the reference parser) ------------------------------- + + private class FragmentParseException( + message: String, + ) : IllegalArgumentException(message) + + private fun obj( + v: JsonElement?, + what: String, + ): JsonObject = v as? JsonObject ?: throw FragmentParseException("$what is not an object") + + private fun string( + v: JsonElement?, + what: String, + ): String { + val p = v as? JsonPrimitive + if (p == null || !p.isString) throw FragmentParseException("$what is not a string") + return p.content + } + + private fun u64( + v: JsonElement?, + what: String, + ): Long { + val p = v as? JsonPrimitive + if (p == null || p.isString) throw FragmentParseException("$what is not an unsigned integer") + val n = p.longOrNull ?: throw FragmentParseException("$what is not an unsigned integer") + if (n < 0) throw FragmentParseException("$what is negative") + return n + } + + private fun absent(v: JsonElement?) = v == null || v is JsonNull + + private fun array( + v: JsonElement?, + what: String, + ): List = + when { + v == null -> emptyList() + v is JsonArray -> v + else -> throw FragmentParseException("$what is not an array") + } + + // ---- wire -> internal ------------------------------------------------------------------ + + /** One decoded fragment: its declared `frags` and its content in the internal shape. */ + class DecodedFragment( + val frags: Int, + val doc: JsonObject, + ) + + /** + * Parses one fragment's decrypted plaintext into the internal shape. Throws on anything the + * reference parser rejects; a caller treats that as "this index is missing", never as an + * empty fragment. + */ + fun decodeFragment(plaintext: String): DecodedFragment { + val root = obj(json.parseToJsonElement(plaintext), "fragment") + val frags = u64(root["frags"], "frags") + val entries = array(root["entries"], "entries").map { decodeEntry(it) } + val tombstones = array(root["tombstones"], "tombstones").map { decodeTombstone(it) } + val doc = LinkedHashMap() + for ((k, v) in root) if (k !in LIST_KEYS) doc[k] = v + doc["entries"] = JsonArray(entries) + doc["tombstones"] = JsonArray(tombstones) + return DecodedFragment(frags.coerceAtMost(Int.MAX_VALUE.toLong()).toInt(), JsonObject(doc)) + } + + private fun decodeEntry(v: JsonElement): JsonObject { + val o = obj(v, "entry") + val cid = b64ToHex(string(o["community_id"], "entry.community_id")) + val current = decodeMaterial(o["current"], "entry.current", cid) + val addedAt = u64(o["added_at"], "entry.added_at") + val seed = if (absent(o["seed"])) current else decodeMaterial(o["seed"], "entry.seed", cid) + val out = LinkedHashMap() + for ((k, value) in o) if (k !in ENTRY_KEYS) out[k] = value + out["community_id"] = JsonPrimitive(cid) + out["seed"] = seed + out["current"] = current + out["added_at"] = JsonPrimitive(addedAt) + return JsonObject(out) + } + + private fun decodeMaterial( + v: JsonElement?, + what: String, + communityId: String, + ): JsonObject { + val o = obj(v, what) + val out = LinkedHashMap() + for ((k, value) in o) if (k !in MATERIAL_KEYS && k != "community_id") out[k] = value + out["community_id"] = JsonPrimitive(communityId) + out["owner"] = JsonPrimitive(b64ToHex(string(o["owner"], "$what.owner"))) + out["owner_salt"] = JsonPrimitive(b64ToHex(string(o["owner_salt"], "$what.owner_salt"))) + out["community_root"] = JsonPrimitive(b64ToHex(string(o["community_root"], "$what.community_root"))) + out["root_epoch"] = JsonPrimitive(u64(o["root_epoch"], "$what.root_epoch")) + if (!absent(o["control_pk"])) out["control_pk"] = JsonPrimitive(b64ToHex(string(o["control_pk"], "$what.control_pk"))) + if (!absent(o["control_root"])) out["control_root"] = JsonPrimitive(b64ToHex(string(o["control_root"], "$what.control_root"))) + out["channels"] = JsonArray(array(o["channels"], "$what.channels").map { decodeChannel(it) }) + out["relays"] = JsonArray(array(o["relays"], "$what.relays").map { JsonPrimitive(string(it, "$what.relays[]")) }) + out["name"] = JsonPrimitive(string(o["name"], "$what.name")) + return JsonObject(out) + } + + private fun decodeChannel(v: JsonElement): JsonObject { + val o = obj(v, "channel") + val out = LinkedHashMap() + for ((k, value) in o) if (k !in CHANNEL_KEYS) out[k] = value + out["id"] = JsonPrimitive(b64ToHex(string(o["id"], "channel.id"))) + if (!absent(o["key"])) out["key"] = JsonPrimitive(b64ToHex(string(o["key"], "channel.key"))) + out["epoch"] = JsonPrimitive(u64(o["epoch"], "channel.epoch")) + out["name"] = JsonPrimitive(string(o["name"], "channel.name")) + return JsonObject(out) + } + + private fun decodeTombstone(v: JsonElement): JsonObject { + val o = obj(v, "tombstone") + val out = LinkedHashMap() + for ((k, value) in o) if (k !in TOMBSTONE_KEYS) out[k] = value + out["community_id"] = JsonPrimitive(b64ToHex(string(o["community_id"], "tombstone.community_id"))) + out["removed_at"] = JsonPrimitive(u64(o["removed_at"], "tombstone.removed_at")) + return JsonObject(out) + } + + // ---- internal -> wire ------------------------------------------------------------------ + + private fun stringOr( + v: JsonElement?, + default: String, + ): String = (v as? JsonPrimitive)?.takeIf { it.isString }?.content ?: default + + private fun number(v: JsonElement?): JsonPrimitive? = (v as? JsonPrimitive)?.takeIf { !it.isString && it.longOrNull != null } + + /** + * An internal snapshot in the wire shape: named 32-byte values as base64url, `community_id` + * dropped (the entry carries it), empty `channels`/`relays` omitted. Throws on a snapshot + * missing its keys rather than sealing a corrupt membership. + */ + private fun materialToWire(m: JsonObject): JsonObject { + val owner = stringOr(m["owner"], "") + val ownerSalt = stringOr(m["owner_salt"], "") + val root = stringOr(m["community_root"], "") + val epoch = number(m["root_epoch"]) + require(owner.isNotEmpty() && ownerSalt.isNotEmpty() && root.isNotEmpty() && epoch != null) { + "malformed join material — refusing to serialize a corrupt snapshot" + } + val out = LinkedHashMap() + out["owner"] = JsonPrimitive(hexToB64(owner)) + out["owner_salt"] = JsonPrimitive(hexToB64(ownerSalt)) + out["community_root"] = JsonPrimitive(hexToB64(root)) + out["root_epoch"] = epoch + (m["control_pk"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["control_pk"] = JsonPrimitive(hexToB64(it.content)) } + (m["control_root"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["control_root"] = JsonPrimitive(hexToB64(it.content)) } + val channels = (m["channels"] as? JsonArray)?.mapNotNull { (it as? JsonObject)?.let(::channelToWire) }.orEmpty() + if (channels.isNotEmpty()) out["channels"] = JsonArray(channels) + val relays = (m["relays"] as? JsonArray)?.filter { it is JsonPrimitive && it.isString }.orEmpty() + if (relays.isNotEmpty()) out["relays"] = JsonArray(relays) + out["name"] = JsonPrimitive(stringOr(m["name"], "")) + out.putExtras(m, MATERIAL_KEYS + "community_id") + return JsonObject(out) + } + + private fun channelToWire(c: JsonObject): JsonObject? { + val id = stringOr(c["id"], "") + val epoch = number(c["epoch"]) + if (id.isEmpty() || epoch == null) return null + val out = LinkedHashMap() + out["id"] = JsonPrimitive(hexToB64(id)) + (c["key"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out["key"] = JsonPrimitive(hexToB64(it.content)) } + out["epoch"] = epoch + out["name"] = JsonPrimitive(stringOr(c["name"], "")) + out.putExtras(c, CHANNEL_KEYS) + return JsonObject(out) + } + + /** + * `seed` with its cosmetic fields (`name`, `relays`, each channel's `name`) overwritten from + * `current`: they are not the anchor's own, and leaving a stale label in place would fork the + * two snapshots forever after one rename (CORD-02 §8). + */ + private fun withCurrentCosmetics( + seed: JsonObject, + current: JsonObject, + ): JsonObject { + val currentNames = + (current["channels"] as? JsonArray) + .orEmpty() + .mapNotNull { it as? JsonObject } + .associate { stringOr(it["id"], "") to (it["name"] ?: JsonPrimitive("")) } + // Rebuilt in declared order so the result serializes exactly like a fresh wire snapshot. + val out = LinkedHashMap() + for (k in listOf("owner", "owner_salt", "community_root", "root_epoch", "control_pk", "control_root")) seed[k]?.let { out[k] = it } + (seed["channels"] as? JsonArray)?.let { channels -> + out["channels"] = + JsonArray( + channels.map { ch -> + val o = ch as? JsonObject ?: return@map ch + val name = currentNames[stringOr(o["id"], "")] ?: return@map o + JsonObject(o.mapValuesTo(LinkedHashMap()) { (k, v) -> if (k == "name") name else v }) + }, + ) + } + current["relays"]?.let { out["relays"] = it } + out["name"] = current["name"] ?: JsonPrimitive("") + // The seed's unknown keys, already in sorted order behind the named ones. + for ((k, v) in seed) if (k !in MATERIAL_KEYS) out[k] = v + return JsonObject(out) + } + + /** + * [entryToWire], or null when the entry's join material is too incomplete to encode — only a + * membership the typed reader already could not parse (kept verbatim from a retired 13302 + * document). It stays in that document; the fragments simply don't re-emit it, rather than one + * unreadable legacy entry blocking every join and leave. + */ + private fun entryToWireOrNull(entry: JsonObject): JsonObject? = + try { + entryToWire(entry) + } catch (_: IllegalArgumentException) { + null + } + + /** One internal entry in the wire shape, or throws on corrupt join material. */ + fun entryToWire(entry: JsonObject): JsonObject { + val cid = stringOr(entry["community_id"], "") + require(cid.isNotEmpty()) { "entry without a community_id" } + val currentInternal = (entry["current"] as? JsonObject) ?: (entry["seed"] as? JsonObject) ?: throw IllegalArgumentException("entry without join material") + val seedInternal = (entry["seed"] as? JsonObject) ?: currentInternal + val current = materialToWire(currentInternal) + val seed = withCurrentCosmetics(materialToWire(seedInternal), current) + val out = LinkedHashMap() + out["community_id"] = JsonPrimitive(hexToB64(cid)) + if (str(seed) != str(current)) out["seed"] = seed + out["current"] = current + out["added_at"] = number(entry["added_at"]) ?: JsonPrimitive(0L) + out.putExtras(entry, ENTRY_KEYS) + return JsonObject(out) + } + + fun tombstoneToWire(tombstone: JsonObject): JsonObject? { + val cid = stringOr(tombstone["community_id"], "") + if (cid.isEmpty()) return null + val out = LinkedHashMap() + out["community_id"] = JsonPrimitive(hexToB64(cid)) + out["removed_at"] = number(tombstone["removed_at"]) ?: JsonPrimitive(0L) + out.putExtras(tombstone, TOMBSTONE_KEYS) + return JsonObject(out) + } + + /** A fragment's exact plaintext: what NIP-44 seals and relays store. */ + fun serializeFragment( + frags: Int, + entries: List, + tombstones: List, + extras: JsonObject = JsonObject(emptyMap()), + ): String { + val out = LinkedHashMap() + out["frags"] = JsonPrimitive(frags) + if (entries.isNotEmpty()) out["entries"] = JsonArray(entries) + if (tombstones.isNotEmpty()) out["tombstones"] = JsonArray(tombstones) + out.putExtras(extras, LIST_KEYS) + return str(JsonObject(out)) + } + + /** The zero-element List an emptied index republishes (CORD-02 §8). */ + fun emptyFragment(frags: Int): String = serializeFragment(frags, emptyList(), emptyList()) + + // ---- sizing ---------------------------------------------------------------------------- + + /** NIP-44 v2 padded plaintext length. */ + fun nip44PaddedLen(unpadded: Int): Int { + if (unpadded <= 32) return 32 + val nextPower = 1 shl (32 - (unpadded - 1).countLeadingZeroBits()) + val chunk = if (nextPower <= 256) 32 else nextPower / 8 + return chunk * ((unpadded - 1) / chunk + 1) + } + + /** The encoded event a plaintext of [plaintextBytes] becomes (NIP-44 v2 payload, base64). */ + fun projectedEventBytes(plaintextBytes: Int): Int { + val raw = 1 + 32 + 2 + nip44PaddedLen(plaintextBytes) + 32 + return (raw + 2) / 3 * 4 + EVENT_ENVELOPE_BYTES + } + + private fun byteLen(s: String) = s.encodeToByteArray().size + + // ---- merge (internal shape) ------------------------------------------------------------ + + private fun epochOf(m: JsonObject?): Long = number(m?.get("root_epoch"))?.longOrNull ?: 0L + + /** Higher epoch wins; a tie goes to the lexicographically lowest canonical bytes. */ + private fun freshest( + a: JsonObject, + b: JsonObject, + ): JsonObject { + val ea = epochOf(a) + val eb = epochOf(b) + if (ea != eb) return if (ea > eb) a else b + return if (canonicalString(a) <= canonicalString(b)) a else b + } + + /** Lower epoch wins; a tie goes to the lowest canonical bytes. */ + private fun earliest( + a: JsonObject, + b: JsonObject, + ): JsonObject { + val ea = epochOf(a) + val eb = epochOf(b) + if (ea != eb) return if (ea < eb) a else b + return if (canonicalString(a) <= canonicalString(b)) a else b + } + + private fun currentOf(e: JsonObject): JsonObject? = (e["current"] as? JsonObject) ?: (e["seed"] as? JsonObject) + + private fun seedOf(e: JsonObject): JsonObject? = (e["seed"] as? JsonObject) ?: (e["current"] as? JsonObject) + + private fun mergeEntry( + x: JsonObject, + y: JsonObject, + ): JsonObject { + val cx = currentOf(x) + val cy = currentOf(y) + val current = if (cx != null && cy != null) freshest(cx, cy) else cx ?: cy + val sx = seedOf(x) + val sy = seedOf(y) + val seed = if (sx != null && sy != null) earliest(sx, sy) else sx ?: sy + val addedAt = maxOf(number(x["added_at"])?.longOrNull ?: 0L, number(y["added_at"])?.longOrNull ?: 0L) + val merged = LinkedHashMap(x) + merged.putAll(y) + merged["community_id"] = x.getValue("community_id") + if (seed != null) merged["seed"] = seed + if (current != null) merged["current"] = current + merged["added_at"] = JsonPrimitive(addedAt) + // An exclusion marker only means something while it is beyond the held epoch. + val excluded = listOfNotNull(number(x["excluded_at_epoch"])?.longOrNull, number(y["excluded_at_epoch"])?.longOrNull).maxOrNull() + if (excluded != null && excluded > epochOf(current)) merged["excluded_at_epoch"] = JsonPrimitive(excluded) else merged.remove("excluded_at_epoch") + return JsonObject(merged) + } + + private fun idOf(o: JsonObject): String? = stringOr(o["community_id"], "").ifEmpty { null } + + /** + * Merges two internal documents: one membership per `community_id` (current keeps the higher + * epoch, seed the lower, `added_at` the later), one tombstone per id (the later `removed_at`), + * other keys with [b] winning. Commutative and idempotent on everything but those unknown + * document keys, which is what lets fragments and devices merge in any order. + */ + fun mergeDocs( + a: JsonObject, + b: JsonObject, + ): JsonObject { + val entries = LinkedHashMap() + for (e in listOf(a, b).flatMap { (it["entries"] as? JsonArray).orEmpty() }) { + val o = e as? JsonObject ?: continue + val id = idOf(o) ?: continue + entries[id] = entries[id]?.let { mergeEntry(it, o) } ?: o + } + val tombstones = LinkedHashMap() + for (t in listOf(a, b).flatMap { (it["tombstones"] as? JsonArray).orEmpty() }) { + val o = t as? JsonObject ?: continue + val id = idOf(o) ?: continue + val prev = tombstones[id] + if (prev == null || (number(o["removed_at"])?.longOrNull ?: 0L) > (number(prev["removed_at"])?.longOrNull ?: 0L)) tombstones[id] = o + } + val out = LinkedHashMap() + for ((k, v) in a) if (k != "entries" && k != "tombstones") out[k] = v + for ((k, v) in b) if (k != "entries" && k != "tombstones") out[k] = v + out["entries"] = JsonArray(entries.keys.sorted().map { entries.getValue(it) }) + out["tombstones"] = JsonArray(tombstones.keys.sorted().map { tombstones.getValue(it) }) + return JsonObject(out) + } + + /** Per community id, the latest `removed_at` in [doc]. */ + fun removals(doc: JsonObject): Map { + val out = HashMap() + for (t in (doc["tombstones"] as? JsonArray).orEmpty()) { + val o = t as? JsonObject ?: continue + val id = idOf(o) ?: continue + val at = number(o["removed_at"])?.longOrNull ?: 0L + if (at > (out[id] ?: Long.MIN_VALUE)) out[id] = at + } + return out + } + + /** A membership is live only while its entry outranks its removal (CORD-02 §8). */ + fun isLive( + entry: JsonObject, + removals: Map, + ): Boolean { + val removedAt = removals[idOf(entry) ?: return false] ?: return true + return (number(entry["added_at"])?.longOrNull ?: 0L) > removedAt + } + + // ---- packing --------------------------------------------------------------------------- + + private class Packing( + val entries: MutableList = ArrayList(), + val tombstones: MutableList = ArrayList(), + val extras: JsonObject = JsonObject(emptyMap()), + ) { + fun serialize(frags: Int) = serializeFragment(frags, entries, tombstones, extras) + } + + /** + * Splits an internal document into fragment plaintexts, greedily (first fragment with room) + * under [PACK_TARGET_BYTES]. Entries a tombstone outranks are dropped — the tombstone alone + * carries the state — and document-level unknown keys ride on fragment 0. + */ + fun pack(doc: JsonObject): List { + val removals = removals(doc) + val entries = + (doc["entries"] as? JsonArray) + .orEmpty() + .mapNotNull { it as? JsonObject } + .filter { isLive(it, removals) } + .sortedBy { idOf(it) } + .mapNotNull { entryToWireOrNull(it) } + val tombstones = + (doc["tombstones"] as? JsonArray) + .orEmpty() + .mapNotNull { it as? JsonObject } + .sortedBy { idOf(it) } + .mapNotNull { tombstoneToWire(it) } + val docExtras = JsonObject(doc.filterKeys { it != "entries" && it != "tombstones" && it !in LIST_KEYS }) + + val frags = mutableListOf(Packing(extras = docExtras)) + + fun fits( + f: Packing, + add: Int, + ) = projectedEventBytes(byteLen(f.serialize(1)) + add) <= PACK_TARGET_BYTES + + for (e in entries) { + val cost = byteLen(str(e)) + 1 + val last = frags.last() + if (last.entries.isEmpty() || fits(last, cost)) last.entries.add(e) else frags.add(Packing(entries = mutableListOf(e))) + } + // A fragment's first tombstone is placed without a size check, exactly as the reference + // packer does: identical state must fragment identically across clients, and the overshoot + // is one tombstone (~80 bytes) against an 8 KiB margin under the ceiling. + for (t in tombstones) { + val cost = byteLen(str(t)) + 1 + val last = frags.last() + if (last.tombstones.isEmpty() || fits(last, cost)) last.tombstones.add(t) else frags.add(Packing(tombstones = mutableListOf(t))) + } + val total = frags.size + return frags.map { it.serialize(total) } + } + + /** + * Re-serializes one held fragment ([fragmentDoc], internal shape) after replacing its + * memberships and removals for [ids] with [merged]'s, keeping its declared [frags]. This is + * the scoped write (CORD-02 §8): it touches only the fragment holding the changed membership, + * so a fragment stranded on an unreachable relay never blocks a join or a leave. + */ + fun rewriteFragment( + fragmentDoc: JsonObject, + merged: JsonObject, + ids: Set, + frags: Int, + ): String { + fun pick( + doc: JsonObject, + key: String, + ) = (doc[key] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject } + + val keptEntries = pick(fragmentDoc, "entries").filter { idOf(it) !in ids } + pick(merged, "entries").filter { idOf(it) in ids } + val keptTombs = pick(fragmentDoc, "tombstones").filter { idOf(it) !in ids } + pick(merged, "tombstones").filter { idOf(it) in ids } + val doc = + JsonObject( + fragmentDoc.filterKeys { it != "entries" && it != "tombstones" } + + mapOf("entries" to JsonArray(keptEntries), "tombstones" to JsonArray(keptTombs)), + ) + val removals = removals(doc) + return serializeFragment( + frags, + keptEntries.filter { isLive(it, removals) }.sortedBy { idOf(it) }.mapNotNull { entryToWireOrNull(it) }, + keptTombs.sortedBy { idOf(it) }.mapNotNull { tombstoneToWire(it) }, + JsonObject(doc.filterKeys { it != "entries" && it != "tombstones" && it !in LIST_KEYS }), + ) + } + + /** The community ids a document mentions, in entries or tombstones. */ + fun idsIn(doc: JsonObject): Set = + ((doc["entries"] as? JsonArray).orEmpty() + (doc["tombstones"] as? JsonArray).orEmpty()) + .mapNotNullTo(HashSet()) { (it as? JsonObject)?.let(::idOf) } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt index 42ae4f03ea..318dfff466 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChat.kt @@ -22,11 +22,15 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag +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 import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip09Deletions.DeletionRequestEvent import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionAlgo import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionKey import com.vitorpamplona.quartz.nip17Dm.files.tags.EncryptionNonce @@ -66,12 +70,13 @@ object ChannelChat { text: String, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( authorPubKey, ChatEvent.build(text, createdAt) { - channelBinding(channelId, epoch) - extraTags.forEach { addUnique(it) } + channelBinding(channelId, epoch, ms) + withoutBinding(extraTags).forEach { add(it) } }, ) @@ -82,6 +87,10 @@ object ChannelChat { * into a minichat), an inline quote stays in the main chat timeline — the two * reply modes the composer offers. Matches Armada, where a kind-9 `q` is an * inline quote deliberately kept out of threads. + * + * The `q` tag is the four-element NIP-C7 form `["q", , "", ]` the spec's + * examples (§2.1) and Armada write: an empty relay hint (a rumor lives on no relay) and the + * quoted author, so a reader can render the card before the quoted rumor arrives. */ fun inlineReply( authorPubKey: HexKey, @@ -92,6 +101,7 @@ object ChannelChat { parentAuthor: HexKey, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = message( authorPubKey = authorPubKey, @@ -99,7 +109,8 @@ object ChannelChat { epoch = epoch, text = text, createdAt = createdAt, - extraTags = arrayOf(arrayOf("q", parentId), arrayOf("p", parentAuthor)) + extraTags, + extraTags = arrayOf(arrayOf("q", parentId, "", parentAuthor), arrayOf("p", parentAuthor)) + extraTags, + ms = ms, ) /** @@ -124,20 +135,48 @@ object ChannelChat { newText: String, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, kind = ConcordChatEditEvent.KIND, - tags = - arrayOf( - ChannelTag.assemble(channelId), - EpochTag.assemble(epoch), - arrayOf("e", targetId), - ) + extraTags, + tags = bindingTags(channelId, epoch, ms) + arrayOf(arrayOf("e", targetId)) + withoutBinding(extraTags), content = newText, ) + /** + * Builds an unsigned kind-5 **delete** rumor (CORD-01 Deletions, examples §2.4) retracting + * the author's own [targets] inside the channel, bound to [channelId]/[epoch]. + * + * NIP-09 shape: one `["e", ]` per target, then one `["k", ]` per distinct + * target kind (`9` for a message, `1111` for a thread reply, `7` for a reaction), and the + * optional [reason] as content. It names *rumor* ids relays never saw, so it must be wrapped + * on the channel plane like any other Chat rumor — never published as a signed kind 5 or a + * NIP-17 DM, both of which would leak the rumor ids outside the community. Receivers honor it + * only for targets the delete's own author wrote. A delete never expires (CORD-08). + */ + fun delete( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + targets: List, + createdAt: Long, + reason: String = "", + ms: Int = MsTag.remainderFor(createdAt), + ): Event { + require(targets.isNotEmpty()) { "A delete must name at least one target" } + val eTags = targets.map { arrayOf("e", it.id) } + val kTags = targets.map { it.kind }.distinct().map { arrayOf("k", it.toString()) } + return RumorAssembler.assembleRumor( + pubKey = authorPubKey, + createdAt = createdAt, + kind = DeletionRequestEvent.KIND, + tags = bindingTags(channelId, epoch, ms) + eTags.toTypedArray() + kTags.toTypedArray(), + content = reason, + ) + } + /** * Builds an unsigned kind-1111 **thread reply** ([CommentEvent], NIP-22) to * [parent], bound to [channelId]/[epoch]. @@ -160,12 +199,13 @@ object ChannelChat { parent: Event, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( authorPubKey, CommentEvent.replyBuilder(text, EventHintBundle(parent), createdAt) { - channelBinding(channelId, epoch) - extraTags.forEach { add(it) } + channelBinding(channelId, epoch, ms) + withoutBinding(extraTags).forEach { add(it) } }, ) @@ -186,6 +226,7 @@ object ChannelChat { parent: Event, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event { val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") @@ -197,6 +238,7 @@ object ChannelChat { parent = parent, createdAt = createdAt, extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ms = ms, ) } @@ -218,19 +260,19 @@ object ChannelChat { content: String, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, kind = ReactionEvent.KIND, tags = - arrayOf( - ChannelTag.assemble(channelId), - EpochTag.assemble(epoch), - arrayOf("e", targetId), - arrayOf("p", targetAuthor), - arrayOf("k", targetKind.toString()), - ) + extraTags, + bindingTags(channelId, epoch, ms) + + arrayOf( + arrayOf("e", targetId), + arrayOf("p", targetAuthor), + arrayOf("k", targetKind.toString()), + ) + withoutBinding(extraTags), content = content, ) @@ -250,6 +292,7 @@ object ChannelChat { imetas: List, createdAt: Long, extraTags: Array> = emptyArray(), + ms: Int = MsTag.remainderFor(createdAt), ): Event { val extraUrls = imetas.map { it.url }.filter { it.isNotBlank() && !text.contains(it) } val finalText = (listOf(text) + extraUrls).filter { it.isNotBlank() }.joinToString("\n") @@ -260,6 +303,7 @@ object ChannelChat { text = finalText, createdAt = createdAt, extraTags = imetas.map { it.toTagArray() }.toTypedArray() + extraTags, + ms = ms, ) } @@ -337,12 +381,13 @@ object ChannelChat { channelId: HexKey, epoch: Long, createdAt: Long, + ms: Int = MsTag.remainderFor(createdAt), ): Event = RumorAssembler.assembleRumor( pubKey = authorPubKey, createdAt = createdAt, kind = KIND_TYPING, - tags = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch)), + tags = bindingTags(channelId, epoch, ms), content = "", ) @@ -364,6 +409,75 @@ object ChannelChat { channelId: HexKey, epoch: Long, ): Boolean = rumor.tags.isConcordBoundTo(channelId, epoch) + + /** Timer notice (CORD-08 §4), a Chat Plane kind. */ + const val KIND_TIMER_NOTICE = 1740 + + /** + * Every rumor kind a Chat Plane may carry into the app (CORD-02 Appendix B): messages, + * thread replies, reactions, deletes, edits, the typing heartbeat and the CORD-08 timer + * notice. Chat ingest refuses anything else — above all the other planes' kinds (a Control + * edition 3308, a Guestbook 3306/3309/3312, a rekey 3303, a direct invite 3313): the planes + * share one store, so without this a channel key-holder could inject a rumor another reader + * would take for a Control edition (the reference client's `PLANE_KINDS` refusal). + */ + val CHAT_KINDS: Set = + setOf( + ChatEvent.KIND, + CommentEvent.KIND, + ReactionEvent.KIND, + DeletionRequestEvent.KIND, + ConcordChatEditEvent.KIND, + KIND_TYPING, + KIND_TIMER_NOTICE, + ) + + /** True when [kind] may ride a Chat Plane ([CHAT_KINDS]). */ + fun isChatKind(kind: Int): Boolean = kind in CHAT_KINDS + + /** + * The Chat Plane ingest gate for a wrap already opened under [channelId]'s key at [epoch]: + * returns its rumor only when every Chat rule holds, else null (drop it). + * - the seal is the encrypted kind 20013 (CORD-02 §5: a plaintext 20014 seal is Control-only); + * - the rumor kind is a Chat kind ([CHAT_KINDS]), never another plane's; + * - the binding is strict: exactly one `channel` and one `epoch`, equal to the plane's + * ([isBoundTo], CORD-03 §3); + * - its `ms` tag, if any, is well formed (CORD-02 §4/§5 — a malformed one is dropped, never + * interpreted). + */ + fun acceptOpened( + opened: OpenedStreamEvent, + channelId: HexKey, + epoch: Long, + ): Event? { + if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null + val rumor = opened.rumor + if (!isChatKind(rumor.kind)) return null + if (!isBoundTo(rumor, channelId, epoch)) return null + if (orderingMs(rumor) == null) return null + return rumor + } + + /** + * The rumor's CORD-02 §4 ordering time, `createdAt * 1000 + ms`, or null when its `ms` tag is + * malformed or duplicated (such a rumor is dropped, never interpreted). See [MsTag]. + */ + fun orderingMs(rumor: Event): Long? = MsTag.orderingMs(rumor.createdAt, rumor.tags) + + /** The binding every Chat rumor commits, in the examples' order: channel, epoch, ms. */ + private fun bindingTags( + channelId: HexKey, + epoch: Long, + ms: Int, + ): Array> = arrayOf(ChannelTag.assemble(channelId), EpochTag.assemble(epoch), MsTag.assemble(ms)) + + private val BINDING_TAG_NAMES = setOf(ChannelTag.TAG_NAME, EpochTag.TAG_NAME, MsTag.TAG_NAME) + + /** + * [extraTags] minus any binding tag: a caller's extra `channel`/`epoch`/`ms` would make the + * binding ambiguous, and strict receivers (ours included) drop a duplicated binding. + */ + private fun withoutBinding(extraTags: Array>): Array> = extraTags.filterNot { it.isNotEmpty() && it[0] in BINDING_TAG_NAMES }.toTypedArray() } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt index 01c413f27d..2bbe2cec53 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordChatEditEvent.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.tags.events.firstTaggedEvent @@ -65,16 +66,7 @@ class ConcordChatEditEvent( * remainder tag (CORD-02 §4). Used to order competing edits at sub-second precision, matching the * reference client (an absent/malformed `ms` tag reads as 0). "Latest edit wins" compares this. */ - fun orderingMs(): Long { - val remainder = - tags - .firstOrNull { it.size > 1 && it[0] == "ms" } - ?.get(1) - ?.toIntOrNull() - ?.takeIf { it in 0..999 } - ?: 0 - return createdAt * 1000 + remainder - } + fun orderingMs(): Long = MsTag.orderingMs(createdAt, tags) ?: (createdAt * 1000) companion object { const val KIND = 3302 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt new file mode 100644 index 0000000000..45ae8bd11b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt @@ -0,0 +1,127 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Disappearing Messages (CORD-08): one Community-wide timer, `message_expiration` in the metadata + * entity, after which every Chat-plane message expires. + * + * - **Sender (§2):** while the timer is set, every durable Chat rumor carries + * `["expiration", created_at + timer]` inside the signed rumor, and its kind-1059 wrap carries the + * same tag so NIP-40 relays delete the ciphertext. Deletes (5) and timer notices (1740) never + * carry it — an expiring delete would resurrect what it erased, an expiring notice would erase + * why history is missing — and ephemeral kinds (typing 23311, voice presence 23313) carry nothing. + * - **Reader (§3):** only the rumor's own tag counts (the wrap's is relay hygiene). An expired rumor + * is refused at ingest, never displayed, and purged from local storage. + * - **Notice (§4):** kind 1740 with `["timer", ""]` and the channel binding, shown only when + * its author holds MANAGE_METADATA. + * + * A timer change is never retroactive: a rumor keeps the expiry it was sent under. + */ +object ConcordDisappearing { + /** The timer-notice kind, shared with NIP-17 disappearing DMs (§4). */ + const val KIND_TIMER_NOTICE = ChannelChat.KIND_TIMER_NOTICE + + const val TIMER_TAG = "timer" + + private const val KIND_DELETE = 5 + private const val KIND_TYPING = 23311 + private const val KIND_VOICE_PRESENCE = 23313 + + private val DECIMAL = Regex("^(0|[1-9][0-9]*)$") + + /** True when a rumor of [kind] must carry the expiration tag while the timer is set (§2). */ + fun expires(kind: Int): Boolean = kind != KIND_DELETE && kind != KIND_TIMER_NOTICE && kind != KIND_TYPING && kind != KIND_VOICE_PRESENCE + + /** + * The expiration a rumor created at [createdAt] of [kind] must carry under [timerSecs], or null + * when the timer is off or the kind is exempt. + */ + fun expirationFor( + kind: Int, + createdAt: Long, + timerSecs: Long?, + ): Long? { + if (timerSecs == null || timerSecs < 1 || !expires(kind)) return null + return createdAt + timerSecs + } + + /** + * [tags] with the expiration tag applied: added (or replaced) when [expiration] is set, left as is + * otherwise. The rumor's copy is what readers enforce, so it must be in the signed tags. + */ + fun withExpiration( + tags: TagArray, + expiration: Long?, + ): TagArray { + if (expiration == null) return tags + return tags.filterNot { it.isNotEmpty() && it[0] == ExpirationTag.TAG_NAME }.toTypedArray() + ExpirationTag.assemble(expiration) + } + + /** The rumor's own expiration, the only one a reader judges by (§3). */ + fun expirationOf(rumor: Event): Long? = rumor.tags.firstNotNullOfOrNull(ExpirationTag::parse) + + /** True when [rumor] carries an expiration at or before [now] (NIP-40: `exp <= now`). */ + fun isExpired( + rumor: Event, + now: Long = TimeUtils.now(), + ): Boolean { + val exp = expirationOf(rumor) ?: return false + return exp <= now + } + + /** The timer-notice rumor: the new value in seconds (`0` = off) bound to [channelId] at [epoch]. */ + fun timerNotice( + authorPubKey: HexKey, + channelId: HexKey, + epoch: Long, + timerSecs: Long, + createdAt: Long = TimeUtils.now(), + ): Event = + RumorAssembler.assembleRumor( + authorPubKey, + eventTemplate(KIND_TIMER_NOTICE, "", createdAt) { + channelBinding(channelId, epoch) + add(arrayOf(TIMER_TAG, timerSecs.coerceAtLeast(0).toString())) + }, + ) + + /** + * The timer a notice announces, in seconds (`0` = turned off), or null when [rumor] isn't a + * well-formed notice — a malformed value is dropped, never guessed at. + */ + fun noticeTimerSecs(rumor: Event): Long? { + if (rumor.kind != KIND_TIMER_NOTICE) return null + val values = rumor.tags.filter { it.size >= 2 && it[0] == TIMER_TAG } + if (values.size != 1) return null + val raw = values[0][1] + if (!DECIMAL.matches(raw)) return null + return raw.toLongOrNull() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt index d1f517ad72..0b17cc4220 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayBuilderExt.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord03Channels import com.vitorpamplona.quartz.concord.cord03Channels.tags.ChannelTag import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder @@ -36,6 +37,9 @@ fun TagArrayBuilder.channel(channelId: HexKey) = addUnique(Channe fun TagArrayBuilder.epoch(epoch: Long) = addUnique(EpochTag.assemble(epoch)) +/** The CORD-02 §4 sub-second remainder (0..999) of the rumor's send time. */ +fun TagArrayBuilder.ms(ms: Int) = addUnique(MsTag.assemble(ms)) + /** Binds an event to [channelId] at [epoch] — both tags every Chat Plane rumor carries. */ fun TagArrayBuilder.channelBinding( channelId: HexKey, @@ -44,3 +48,14 @@ fun TagArrayBuilder.channelBinding( channel(channelId) epoch(epoch) } + +/** [channelBinding] plus the `["ms", …]` remainder every Chat rumor carries (CORD-02 §4). */ +fun TagArrayBuilder.channelBinding( + channelId: HexKey, + epoch: Long, + ms: Int, +) = apply { + channel(channelId) + epoch(epoch) + ms(ms) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt index 7158224348..471d8f55f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/TagArrayExt.kt @@ -25,17 +25,39 @@ import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArray -/** The channel id this Chat Plane rumor is bound to, or null if unbound. */ -fun TagArray.concordChannel(): HexKey? = firstNotNullOfOrNull(ChannelTag::parse) +/** + * The value of the one tag named [name], or null when it is absent **or appears more than once** + * (a binding must be unambiguous — the reference client's `uniqueTag`). Every tag whose name + * matches counts toward the duplicate check, even one too short to carry a value. + */ +private fun TagArray.uniqueTagValue(name: String): String? { + var found: String? = null + var count = 0 + for (tag in this) { + if (tag.isEmpty() || tag[0] != name) continue + count++ + if (count > 1) return null + found = tag.getOrNull(1) + } + return found +} -/** The epoch this Chat Plane rumor is bound to, or null if unbound/malformed. */ -fun TagArray.concordEpoch(): Long? = firstNotNullOfOrNull(EpochTag::parse) +/** The channel id this Chat Plane rumor is bound to, or null if unbound or ambiguous (duplicated). */ +fun TagArray.concordChannel(): HexKey? = uniqueTagValue(ChannelTag.TAG_NAME)?.takeIf { it.isNotEmpty() } /** - * True when these tags bind to exactly [channelId] and [epoch]. Recipients must - * reject any Chat Plane event whose binding does not match the plane it arrived on. + * The epoch this Chat Plane rumor is bound to, or null if unbound, ambiguous (duplicated), or not + * in canonical decimal form ([EpochTag.parse]). + */ +fun TagArray.concordEpoch(): Long? = uniqueTagValue(EpochTag.TAG_NAME)?.let { EpochTag.parse(arrayOf(EpochTag.TAG_NAME, it)) } + +/** + * True when these tags bind to exactly [channelId] and [epoch] (CORD-03 §3): exactly one + * `channel` tag strict-equal to [channelId], and exactly one `epoch` tag strict-equal to the + * canonical decimal of [epoch] (`"04"` or `"+4"` never match 4). Recipients must reject any Chat + * Plane event whose binding does not match the plane it arrived on. */ fun TagArray.isConcordBoundTo( channelId: HexKey, epoch: Long, -): Boolean = concordChannel() == channelId && concordEpoch() == epoch +): Boolean = uniqueTagValue(ChannelTag.TAG_NAME) == channelId && uniqueTagValue(EpochTag.TAG_NAME) == epoch.toString() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt index 804697e196..9c5fac6d71 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/EpochTag.kt @@ -35,11 +35,19 @@ class EpochTag { fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + /** + * The epoch, or null when the value is not its canonical decimal form (CORD-01 Encoding: + * "no leading zeros"). `"04"`, `"+4"`, `"-1"` and `" 4"` are all refused: the binding is a + * strict string comparison, so a spelling that merely parses to the same number is a + * different binding. + */ fun parse(tag: Array): Long? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } ensure(tag[1].isNotEmpty()) { return null } - return tag[1].toLongOrNull() + val epoch = tag[1].toLongOrNull() ?: return null + ensure(epoch >= 0 && epoch.toString() == tag[1]) { return null } + return epoch } fun assemble(epoch: Long) = arrayOf(TAG_NAME, epoch.toString()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt new file mode 100644 index 0000000000..d976ae4d4c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt @@ -0,0 +1,86 @@ +/* + * 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.quartz.concord.cord03Channels.tags + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * The `["ms", "<0..999>"]` sub-second remainder every Concord Chat rumor carries (CORD-02 §4): + * `created_at` stays whole unix seconds, untweaked (CORD-01), and the true send time is + * `created_at * 1000 + ms`. Every comparison the protocol makes (message order, edit recency) + * uses that basis. + * + * Parsing is strict decimal, like the reference client's `resolveMs`: `"0"` or `"1"`…`"999"` + * with no leading zero, sign, whitespace or exponent. A tag outside that shape is malformed, and + * a malformed rumor is dropped rather than interpreted (CORD-02 §5), so the excess can never + * smuggle ordering the author's clock did not produce. + */ +class MsTag { + companion object { + const val TAG_NAME = "ms" + + private val CANONICAL = Regex("^(0|[1-9][0-9]{0,2})$") + + /** The remainder in [tag], or null when it is not an `ms` tag or is malformed. */ + fun parse(tag: Array): Int? { + if (tag.isEmpty() || tag[0] != TAG_NAME) return null + val raw = tag.getOrNull(1) ?: return null + if (!CANONICAL.matches(raw)) return null + return raw.toInt() + } + + fun assemble(ms: Int): Array { + require(ms in 0..999) { "ms remainder must be in 0..999, was $ms" } + return arrayOf(TAG_NAME, ms.toString()) + } + + /** + * The sub-second remainder of "now" when [createdAt] is the current second, else 0. A + * builder handed the current `TimeUtils.now()` thus stamps the real millisecond; one + * handed any other second (a fixed test time, a backdated rumor, a rollover between the + * two clock reads) stamps 0, which is still a well-formed, ordering-safe tag. + */ + fun remainderFor(createdAt: Long): Int { + val nowMs = TimeUtils.nowMillis() + return if (nowMs / 1000 == createdAt) (nowMs % 1000).toInt() else 0 + } + + /** + * The rumor's ordering basis `createdAt * 1000 + ms` (CORD-02 §4). A missing tag counts + * as 0; a malformed or duplicated one yields null, and the caller drops the rumor. + */ + fun orderingMs( + createdAt: Long, + tags: TagArray, + ): Long? { + var found: Int? = null + var count = 0 + for (tag in tags) { + if (tag.isEmpty() || tag[0] != TAG_NAME) continue + count++ + found = parse(tag) ?: return null + } + if (count > 1) return null + return createdAt * 1000 + (found ?: 0) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityCitations.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityCitations.kt new file mode 100644 index 0000000000..0270379cae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityCitations.kt @@ -0,0 +1,74 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * The `vac` authority citation (CORD-04 §1/§5) for writers and for verifiers outside the + * Control Plane fold — Kicks, rekey rotations (kind 3303), and anything else that acts under + * a Grant. + * + * Every non-owner authority action cites the exact Grant edition its actor holds their rank + * under — their own `grant_locator(community_id, actor)` coordinate, at the version and edition + * hash of the head the roster folded. A reader drops (parks) the action until it holds that + * Grant at or past the cited version, then judges the actor's rank against its **current** + * roster, so a stale citation grandfathers nothing. The owner is proven by the `community_id` + * and cites nothing. Mirrors Armada `citationSatisfied` (control.ts). + */ +object AuthorityCitations { + /** + * The citation [actor] must attach to an authority action, resolved from the community's + * folded [authority] roster; null for the owner, and for an actor holding no honored Grant + * (no reader would honor their action anyway). + */ + fun forActor( + authority: AuthorityResolver, + actor: HexKey, + ): AuthorityCitation? = authority.citationFor(actor) + + /** + * [forActor] straight from the Control Plane [editions]: folds the roster of the community + * [communityId], owned by [ownerPubKey], and cites [actor]'s Grant head in it. Short-circuits + * for the owner without folding. + */ + fun forActor( + editions: Collection, + communityId: ByteArray, + ownerPubKey: HexKey, + actor: HexKey, + ): AuthorityCitation? { + if (actor.equals(ownerPubKey, ignoreCase = true)) return null + return AuthorityResolver.resolve(editions, communityId, ownerPubKey).citationFor(actor) + } + + /** + * Whether [citation] satisfies CORD-04 §5's sync floor for an action by [actor] against the + * folded [authority] roster: the owner needs none; anyone else must cite their own Grant + * coordinate, held at or past the cited version (hash matching at equality). Completeness + * only — the caller still checks the actor's bit and rank against [authority]. + */ + fun isSatisfied( + authority: AuthorityResolver, + actor: HexKey, + citation: AuthorityCitation?, + ): Boolean = authority.citationSatisfied(actor, citation) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt index 5677e56244..ab7a521f61 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolver.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.Log @@ -30,28 +33,48 @@ import com.vitorpamplona.quartz.utils.Log * "The Roster is owner-rooted: every Grant and Role is signed by an npub the * Roster ranks strictly above it, and the chain terminates at the owner." * - * Build one with [resolve] from the current entity heads (the values of - * [EditionFold.fold]) plus the community's known owner pubkey. It then answers: + * Build one with [resolve] from the community's Control Plane editions, its + * `community_id` (which pins every derived coordinate) and its known owner pubkey. + * It then answers: * - [rank] — a member's authority (lower is higher; owner is [OWNER_RANK]; a * member with no validly-granted role has no rank). * - [effectivePermissions] — the union of a member's roles' bits (owner: all). - * - [isBanned] — membership in the healed banlist union. + * - [isBanned] — membership in the folded Banlist head. * - [canActOn] — whether an actor may take a permissioned action on a target: * the actor must hold the bit, must strictly outrank the target (equal cannot * act on equal), and the owner is unremovable. + * - [citationFor] / [citationSatisfied] — the `vac` authority citation (CORD-04 §5) + * an actor writes, and whether an edition's citation resolves against this roster. * * Grants are validated by a fixpoint that only ever empowers members reachable - * from the owner: a Grant is honored when its signer already outranks every - * assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never - * touch the owner can never bootstrap themselves. + * from the owner: a Grant is honored when it sits at its member's own coordinate, + * its signer already outranks every assigned Role, holds + * [ConcordPermissions.MANAGE_ROLES], and cites the Grant it acts under. Cycles that + * never touch the owner can never bootstrap themselves. */ @ConsistentCopyVisibility data class AuthorityResolver private constructor( + private val communityIdHex: String, private val ownerLower: String, private val roles: Map, private val memberRoles: Map>, private val banned: Set, + /** Each role-holder's honored Grant head, keyed by member — what a `vac` must pin. */ + private val grantHeads: Map, ) { + // Derived from the constructor values, so deliberately outside the data class's equality. + private val communityId: ByteArray by lazy { communityIdHex.hexToByteArray() } + private val banlistEidHex: String by lazy { banlistCoordinateHex(communityId) } + + /** + * A member's honored Grant head: the edition every reader folded their roles from, and so + * the one their authority actions cite (CORD-04 §5). + */ + data class GrantHead( + val version: Long, + val hashHex: String, + ) + /** The resolved role definitions (authority-gated), keyed by role id. Safe for display. */ fun roles(): Map = roles @@ -73,7 +96,7 @@ data class AuthorityResolver private constructor( */ fun roleHolders(): Set = memberRoles.keys - /** The healed banlist union (lowercase hex). */ + /** The folded Banlist (lowercase hex). */ fun bannedMembers(): Set = banned /** The member's rank, lower being higher authority; null = no authority. Owner = [OWNER_RANK]. */ @@ -84,6 +107,12 @@ data class AuthorityResolver private constructor( return held.mapNotNull { roles[it]?.position }.minOrNull() } + /** + * [author]'s standing for an equal-version tie-break (CORD-04 §1, "authority first"): + * the owner first, then by Role position, a roleless author last. + */ + fun tieBreakRank(author: String): Long = rank(author) ?: Long.MAX_VALUE + /** The union of a member's roles' permission bits (owner holds every bit). */ fun effectivePermissions(pubKey: String): ConcordPermissions { val m = pubKey.lowercase() @@ -132,6 +161,68 @@ data class AuthorityResolver private constructor( return actorRank < targetRank } + /** [member]'s honored Grant head, or null when they hold none (the owner never does). */ + fun grantHead(member: String): GrantHead? = grantHeads[member.lowercase()] + + /** + * The `vac` citation (CORD-04 §1/§5) [actor] must attach to a Control-authority action: + * their own Grant coordinate, pinned at the version and hash of the head this roster + * folded. Null for the owner (who cites nothing) and for an actor holding no honored + * Grant (whose actions no reader would honor anyway). + */ + fun citationFor(actor: String): AuthorityCitation? { + val m = actor.lowercase() + if (m == ownerLower) return null + val head = grantHeads[m] ?: return null + val coordinate = grantCoordinateOrNull(communityId, m) ?: return null + return AuthorityCitation(coordinate.hexToByteArray(), head.version, head.hashHex.hexToByteArray()) + } + + /** + * Whether [citation] satisfies CORD-04 §5 for an action by [actor] against this roster: + * the owner needs none; anyone else must cite their **own** Grant coordinate, and this + * roster must hold that Grant at the cited version with the cited hash, or past it. A + * citation ahead of what we hold (not yet synced), at a forked hash, or naming someone + * else's Grant parks the action — here, drops it until a later fold. It is a sync floor, + * never the verdict: the caller still judges rank against the current roster. + */ + fun citationSatisfied( + actor: String, + citation: AuthorityCitation?, + ): Boolean { + val m = actor.lowercase() + if (m == ownerLower) return true + val coordinate = grantCoordinateOrNull(communityId, m) ?: return false + return citationMatches(citation, coordinate, grantHeads[m]) + } + + /** [citationSatisfied] for [edition]'s author and `vac`. */ + fun citationSatisfied(edition: ControlEdition): Boolean = citationSatisfied(edition.author, edition.authorityCitation) + + /** + * Whether an edition's content may stand at its coordinate at all, independent of who + * signed it: the entity coordinates CORD-04 §1 derives from the `community_id` (Metadata + * at the `community_id`, a Grant at its member's `grant_locator`, the Banlist at + * `banlist_locator`, an Invite Registry at its author's locator) and the content caps + * (CORD-04 §2, CORD-02 §6). A sub-kind we do not model is judged by its signer alone. + */ + fun isWellFormed(edition: ControlEdition): Boolean = wellFormed(edition, communityId, communityIdHex, banlistEidHex) + + /** + * Whether a reader honors [edition] as an action gated by [bit] (CORD-04 §5): it is + * [isWellFormed], its author is the owner or holds [bit] (and is not banned), and its + * `vac` resolves ([citationSatisfied]). A null [bit] is owner-only. + */ + fun admits( + edition: ControlEdition, + bit: Int?, + ): Boolean { + if (!isWellFormed(edition)) return false + if (isOwner(edition.author)) return true + if (bit == null || !hasPermission(edition.author, bit)) return false + return citationSatisfied(edition) + } + companion object { private const val TAG = "ConcordAuthorityResolver" @@ -146,11 +237,74 @@ data class AuthorityResolver private constructor( */ private const val MAX_BAN_RESOLUTION_PASSES = 4 + /** `grant_locator(community_id, member)` as hex, or null when [member] is not a 32-byte hex key. */ + internal fun grantCoordinateOrNull( + communityId: ByteArray, + member: String, + ): String? { + val xOnly = member.hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null + return ConcordKeyDerivation.grantCoordinate(communityId, xOnly).toHexKey() + } + + private fun banlistCoordinateHex(communityId: ByteArray): String = ConcordKeyDerivation.banlistCoordinate(communityId).toHexKey() + + /** + * The CORD-04 §5 citation test against the Grant head [head] a verifier holds at the + * actor's coordinate [expectedGrantIdHex] — Armada's `citationSatisfied`, case for case: + * the citation must name that coordinate, and the head must be past the cited version, + * or at it with the same hash. Behind it (unsynced) or at a forked hash, it parks. + */ + internal fun citationMatches( + citation: AuthorityCitation?, + expectedGrantIdHex: String, + head: GrantHead?, + ): Boolean { + if (citation == null || head == null) return false + if (citation.grantId.toHexKey() != expectedGrantIdHex) return false + if (head.version > citation.grantVersion) return true + if (head.version == citation.grantVersion) return head.hashHex == citation.grantHash.toHexKey() + return false + } + + /** The Grant content at [edition], or null when it does not sit at its member's own coordinate (S5). */ + private fun grantAt( + edition: ControlEdition, + communityId: ByteArray, + ): GrantEntity? { + val g = ConcordJson.decodeOrNull(edition.content) ?: return null + val coordinate = grantCoordinateOrNull(communityId, g.member.lowercase()) ?: return null + return g.takeIf { coordinate == edition.entityIdHex } + } + + /** See [isWellFormed]. */ + private fun wellFormed( + edition: ControlEdition, + communityId: ByteArray, + communityIdHex: String, + banlistEidHex: String, + ): Boolean = + when (edition.entityKind) { + ControlEntityKind.METADATA -> + edition.entityIdHex == communityIdHex && + ConcordJson.decodeOrNull(edition.content)?.let(ConcordLimits::metadataFits) == true + ControlEntityKind.ROLE -> ConcordJson.decodeOrNull(edition.content)?.isWellFormedAt(edition.entityIdHex) == true + ControlEntityKind.GRANT -> grantAt(edition, communityId) != null + ControlEntityKind.BANLIST -> edition.entityIdHex == banlistEidHex && ConcordJson.decodeBanlist(edition.content) != null + ControlEntityKind.INVITE_REGISTRY -> + edition.entityIdHex == ConcordKeyDerivation.inviteLinksCoordinate(communityId, edition.author.hexToByteArray()).toHexKey() + else -> true + } + /** * The owner-rooted authority state of a community, with the banlist honored **against the * Control Plane itself** (CORD-04 §4: a reader "drops every event from a banned npub — * message, reaction, edit, or authority action"). * + * [communityId] pins every derived coordinate: a Grant is honored only at its member's own + * `grant_locator(community_id, member)` and the Banlist only at `banlist_locator(community_id)` + * (CORD-02 A.6), so a second chain minted at any other coordinate cannot override the + * canonical one. + * * This is a bounded two-pass, because the rule is circular as stated: you cannot know who is * banned until you fold the Banlist, and you cannot decide who may write the Banlist without * knowing who is banned. `docs/concord-banlist-rank-conformance.md` §4 row 3 flagged that to @@ -178,9 +332,10 @@ data class AuthorityResolver private constructor( */ fun resolve( editions: Collection, + communityId: ByteArray, ownerPubKey: String, ): AuthorityResolver { - val passA = resolveOnce(editions, ownerPubKey, bannedAuthors = emptySet()) + val passA = resolveOnce(editions, communityId, ownerPubKey, bannedAuthors = emptySet()) // A further pass costs a whole fold, so skip it unless it could change something. Nobody // banned, or nobody banned who ever wrote to the Control Plane — the overwhelmingly common // shape, since most bans land on plain members who hold no role and author no editions — @@ -204,7 +359,7 @@ data class AuthorityResolver private constructor( var mask = passA.banned var result = passA repeat(MAX_BAN_RESOLUTION_PASSES) { - result = resolveOnce(editions, ownerPubKey, bannedAuthors = mask) + result = resolveOnce(editions, communityId, ownerPubKey, bannedAuthors = mask) if (result.banned == mask) return result mask = result.banned } @@ -226,10 +381,17 @@ data class AuthorityResolver private constructor( */ private fun resolveOnce( editions: Collection, + communityId: ByteArray, ownerPubKey: String, bannedAuthors: Set, ): AuthorityResolver { val ownerLower = ownerPubKey.lowercase() + val communityIdHex = communityId.toHexKey() + + // grant_locator(community_id, member) for every author the gates ask about, derived once. + val grantCoordinates = HashMap() + + fun grantCoordinateOf(member: String): String? = grantCoordinates.getOrPut(member) { grantCoordinateOrNull(communityId, member) } // Chains grouped by entity: one role chain per role id, one grant chain per member // coordinate. We fold each chain through AUTHORIZED editions only, so a rogue cannot @@ -240,6 +402,7 @@ data class AuthorityResolver private constructor( var roles: Map = emptyMap() var memberRoles: Map> = emptyMap() + var grantHeads: Map = emptyMap() // Authority helpers read the CURRENT (previous-pass) roster, so within a pass a granter's // rank is judged by the chain already settled behind it — the owner-rooted resolution the @@ -250,6 +413,9 @@ data class AuthorityResolver private constructor( return held.mapNotNull { roles[it]?.position }.minOrNull() } + // Authority first at an equal-version tie (CORD-04 §1), judged by the same settled roster. + fun tieRank(author: String): Long = rankOf(author.lowercase()) ?: Long.MAX_VALUE + // The bits a member currently holds, evaluated against the chain settled so far — the same // owner-rooted basis as rankOf. Needed inside the fixpoint; effectivePermissionsOf below is // the post-settlement view. @@ -267,6 +433,17 @@ data class AuthorityResolver private constructor( return held.any { roles[it]?.permissionBits()?.has(ConcordPermissions.MANAGE_ROLES) == true } } + // CORD-04 §5: a non-owner edition must cite the exact Grant it acts under — its author's + // own coordinate — and we must hold that Grant at or past the cited version, hash + // matching at equality. Judged against the Grant heads settled so far, so the owner's + // grants settle first and delegation bootstraps outward (Armada `citedOk`). + fun cited(e: ControlEdition): Boolean { + val author = e.author.lowercase() + if (author == ownerLower) return true + val coordinate = grantCoordinateOf(author) ?: return false + return citationMatches(e.authorityCitation, coordinate, grantHeads[author]) + } + // Owner-rooted fixpoint: each pass only ever empowers members reachable from the owner, so // the roster grows monotonically and settles. Bounded by the edition count as a backstop. val maxPasses = editions.size + 1 @@ -279,12 +456,16 @@ data class AuthorityResolver private constructor( entity: String, e: ControlEdition, ): Boolean { + // Well-formed first, for every author: a role_id naming another coordinate, an + // over-long name, or a live role at the owner's position 0 is no role at all. + val r = ConcordJson.decodeOrNull(e.content) ?: return false + if (!r.isWellFormedAt(entity)) return false val author = e.author.lowercase() if (author == ownerLower) return true if (author in bannedAuthors) return false if (!holdsManageRoles(author)) return false + if (!cited(e)) return false val authorRank = rankOf(author) ?: return false - val r = ConcordJson.decodeOrNull(e.content) ?: return false // MANAGE_ROLES alone was the whole test, which let any holder rewrite the // role they hold — position 1 with every bit — and then demote the real // admins beneath them. Grants are gated on rank (a granter must outrank @@ -302,7 +483,7 @@ data class AuthorityResolver private constructor( val newRoles = HashMap() for ((entity, chain) in roleChains) { - val head = EditionFold.foldEntityGated(chain) { roleGate(entity, it) } ?: continue + val head = EditionFold.foldEntityGated(chain, rank = ::tieRank) { roleGate(entity, it) } ?: continue val r = ConcordJson.decodeOrNull(head.content) ?: continue if (r.deleted || r.position < 1) continue // no role may claim the owner's position 0 newRoles[entity] = r @@ -312,14 +493,18 @@ data class AuthorityResolver private constructor( // AND strictly outranks every role it hands out. Same candidate-then-gate shape, so a // rogue grant is dropped without orphaning the honest grants chained above it. fun grantGate(e: ControlEdition): Boolean { + // The coordinate must be the member's own grant_locator (CORD-04 §1, S5): a chain + // minted anywhere else is not that member's Grant, whoever signed it. + val g = grantAt(e, communityId) ?: return false val granter = e.author.lowercase() if (granter == ownerLower) return true if (granter in bannedAuthors) return false if (!holdsManageRoles(granter)) return false + if (!cited(e)) return false val granterRank = rankOf(granter) ?: return false - val g = ConcordJson.decodeOrNull(e.content) ?: return false // Must strictly outrank each assigned role that actually exists... - if (!g.roleIds.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false + val assigned = g.roleIds.take(ConcordLimits.MAX_ROLES_PER_MEMBER) + if (!assigned.all { rid -> newRoles[rid]?.let { granterRank < it.position } ?: true }) return false // ...and outrank the member being edited. A grant is an action ON that // member, and a REVOKE carries no role ids at all — `all {}` over an // empty list is vacuously true, so without this any MANAGE_ROLES holder @@ -330,15 +515,36 @@ data class AuthorityResolver private constructor( } val newMemberRoles = HashMap>() + val newGrantHeads = HashMap() for ((_, chain) in grantChains) { - val head = EditionFold.foldEntityGated(chain, gate = ::grantGate) ?: continue - val g = ConcordJson.decodeOrNull(head.content) ?: continue - newMemberRoles[g.member.lowercase()] = g.roleIds.filter { newRoles.containsKey(it) }.toSet() + val head = EditionFold.foldEntityGated(chain, rank = ::tieRank, gate = ::grantGate) ?: continue + val g = grantAt(head, communityId) ?: continue + val member = g.member.lowercase() + // A member holds at most 64 Roles (CORD-04 §2): the rest of the list is ignored. + newMemberRoles[member] = + g.roleIds + .take(ConcordLimits.MAX_ROLES_PER_MEMBER) + .filter { newRoles.containsKey(it) } + .toSet() + newGrantHeads[member] = GrantHead(head.version, head.hashHex) } - if (newRoles == roles && newMemberRoles == memberRoles) break + if (newRoles == roles && newMemberRoles == memberRoles && newGrantHeads == grantHeads) break roles = newRoles memberRoles = newMemberRoles + grantHeads = newGrantHeads + } + + // A Community carries at most 100 Roles (CORD-04 §2): fold the 100 lowest role_ids and + // ignore the rest, after authorization, exactly where Armada trims them. + if (roles.size > ConcordLimits.MAX_ROLES_PER_COMMUNITY) { + val kept = + roles.keys + .sorted() + .take(ConcordLimits.MAX_ROLES_PER_COMMUNITY) + .toSet() + roles = roles.filterKeys { it in kept } + memberRoles = memberRoles.mapValues { (_, held) -> held.filterTo(HashSet()) { it in kept } } } // The union of a member's roles' permission bits (owner holds every bit). @@ -350,22 +556,22 @@ data class AuthorityResolver private constructor( return acc } - // Banlist: honored only from a signer holding BAN (or the owner). The banlist is a single - // replaced doc, so fold its chain to the head first — that honors a legitimate unban, which - // is a *chained* edition replacing the previous set (e.g. ban→unban). Then heal concurrent - // forks: two moderators who ban different abusers at the same chain version fork the doc, and - // folding to one head would silently drop the other's ban. Union in every authorized edition - // that is NOT an ancestor of the head — those are the parallel bans the chain never absorbed. - // Ancestors (superseded by the chain, including an unban's now-cleared target) are already - // reflected by the head and must not be resurrected. This is CORD-06's "down-only healing": - // a concurrent ban is never lost, while an on-chain unban still takes effect. - val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST } + // Banlist: honored only from a signer holding BAN (or the owner), at the one coordinate + // CORD-02 A.6 derives for it. It is a single replaced document folded to ONE head like any + // entity (CORD-04 §4): two admins banning different members at the same version collide, + // the fold keeps one edition (authority first, then the lower rumor id), and the loser's + // addition drops until its writer re-heals it on top of the winner. Unioning every fork + // instead made a ban on a losing fork impossible to lift — no later edition supersedes a + // fork — and diverged from every other client's fold. + val banlistEid = banlistCoordinateHex(communityId) + val allBanlist = editions.filter { it.entityKind == ControlEntityKind.BANLIST && it.entityIdHex == banlistEid } fun banGate(e: ControlEdition): Boolean { + if (ConcordJson.decodeBanlist(e.content) == null) return false val author = e.author.lowercase() - return author == ownerLower || (author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN)) + if (author == ownerLower) return true + return author !in bannedAuthors && effectivePermissionsOf(author).has(ConcordPermissions.BAN) && cited(e) } - val authorizedBanlist = allBanlist.filter(::banGate) // CORD-04 §3's rank rule binds "every action", and it names banning as its example ("an // admin cannot ban a peer admin"); §5 step 3 restates it. Only §4, which defines the @@ -428,46 +634,13 @@ data class AuthorityResolver private constructor( return result } - val banned = HashSet() // Candidate-then-gate, like roles and grants: an unauthorized banlist edition in the // middle of the chain must not orphan the authorized ones chained above it (which, on // a banlist, would silently resurrect every ban a later unban had cleared). - val banHead = EditionFold.foldEntityGated(allBanlist, gate = ::banGate) - if (banHead != null) { - banned.addAll(effectiveList(banHead, HashSet())) - // Ancestry is a STRUCTURAL fact, so it is walked over the full pool: an unauthorized - // edition on the head's back-chain still supersedes what is beneath it, and walking - // only the authorized subset would stop there and mis-read those genuine ancestors as - // concurrent forks — un-doing the unban the chain already recorded. - val ancestry = banlistAncestry(banHead, allBanlist) - for (edition in authorizedBanlist) { - if (edition.hashHex !in ancestry) { - banned.addAll(effectiveList(edition, HashSet())) - } - } - } + val banHead = EditionFold.foldEntityGated(allBanlist, rank = ::tieRank, gate = ::banGate) + val banned = banHead?.let { effectiveList(it, HashSet()) } ?: emptySet() - return AuthorityResolver(ownerLower, roles, memberRoles.toMap(), banned) - } - - /** - * The set of edition hashes on [head]'s back-chain (head itself plus every edition it chains - * from via `prevHash`), among [pool]. Used to tell a superseded ancestor (already reflected by - * the head) from a concurrent fork (a parallel ban to heal). The `add`-guarded walk also - * terminates on any cycle. - */ - private fun banlistAncestry( - head: ControlEdition, - pool: List, - ): Set { - val byHash = pool.associateBy { it.hashHex } - val acc = HashSet() - var cur: ControlEdition? = head - while (cur != null && acc.add(cur.hashHex)) { - val prev = cur.prevHash?.toHexKey() - cur = if (prev != null) byHash[prev] else null - } - return acc + return AuthorityResolver(communityIdHex, ownerLower, roles, memberRoles.toMap(), banned, grantHeads) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordLimits.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordLimits.kt new file mode 100644 index 0000000000..e1f6b11261 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordLimits.kt @@ -0,0 +1,51 @@ +/* + * 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.quartz.concord.cord04Roles + +/** + * The protocol's size caps on Control Plane entities (CORD-04 §2, CORD-02 §6), counted as + * UTF-8 bytes. They are **read-side rules as well as write-side ones**: a writer refuses to + * mint an edition past them, and the fold drops (or, for the role counts, trims) whatever + * another client minted past them, exactly as the reference client (Armada `roles.ts` / + * `control.ts`) does, so both converge on the same state. + */ +object ConcordLimits { + /** The protocol-wide name cap: Roles, Channels and the Community name (CORD-02 §6). */ + const val NAME_MAX_BYTES = 64 + + /** The Community description cap (CORD-02 §6). */ + const val DESCRIPTION_MAX_BYTES = 10_000 + + /** A member holds at most this many Roles; a Grant's extra `role_ids` are ignored (CORD-04 §2). */ + const val MAX_ROLES_PER_MEMBER = 64 + + /** A Community folds at most this many Roles: the lowest `role_id`s win (CORD-04 §2). */ + const val MAX_ROLES_PER_COMMUNITY = 100 + + fun utf8Size(s: String): Int = s.encodeToByteArray().size + + fun nameFits(name: String): Boolean = utf8Size(name) <= NAME_MAX_BYTES + + fun descriptionFits(description: String?): Boolean = description == null || utf8Size(description) <= DESCRIPTION_MAX_BYTES + + /** Whether [metadata] is within the Community metadata caps (CORD-02 §6). */ + fun metadataFits(metadata: MetadataEntity): Boolean = nameFits(metadata.name) && descriptionFits(metadata.description) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt index 3183f41d2b..000a7bd064 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEdition.kt @@ -23,10 +23,15 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.eid import com.vitorpamplona.quartz.concord.cord04Roles.control.ev +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.CanonicalDecimal +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EidTag import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EpTag +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.EvTag import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag -import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VskTag import com.vitorpamplona.quartz.concord.crypto.EditionHash +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.toHexKey @@ -51,9 +56,15 @@ class AuthorityCitation( * verbatim entity [content]. Its identity is [hash] — a domain-separated hash of * exactly those fields (see [EditionHash]) — which the next edition cites in `ep`, * forming an unforgeable chain. + * + * [entityKind] is null for a sub-kind this client does not model (Pins, vsk 11; + * Signals, vsk 12; anything newer): nothing here folds such an edition into state, but + * it is kept — [vsk] carries its raw sub-kind — so the anti-rollback floor and a + * Refounding's compaction carry its head forward verbatim instead of silently dropping + * another client's state (CORD-04 §7, CORD-06 §3). */ class ControlEdition( - val entityKind: ControlEntityKind, + val entityKind: ControlEntityKind?, val entityId: ByteArray, val version: Long, val prevHash: ByteArray?, @@ -64,6 +75,8 @@ class ControlEdition( /** The rumor's event id — the deterministic tie-break key at equal version. */ val rumorId: String, val createdAt: Long, + /** The raw `vsk` sub-kind on the wire; [entityKind]'s wire value when it is modeled. */ + val vsk: String = entityKind?.wire ?: "", ) { /** Domain-separated edition identity; the next edition's `ep` cites this. */ val hash: ByteArray by lazy { EditionHash.hash(entityId, version, prevHash, content) } @@ -71,20 +84,59 @@ class ControlEdition( val entityIdHex: String get() = entityId.toHexKey() val hashHex: String get() = hash.toHexKey() + /** This edition carrying [citation] as its `vac`. The hash does not cover it, so the chain is unchanged. */ + fun withCitation(citation: AuthorityCitation?): ControlEdition = ControlEdition(entityKind, entityId, version, prevHash, citation, content, author, rumorId, createdAt, vsk) + companion object { + /** The machinery tags an edition may carry at most once each (Armada `parseEdition`). */ + private val SINGLE_VALUED_TAGS = arrayOf(VskTag.TAG_NAME, EidTag.TAG_NAME, EvTag.TAG_NAME, EpTag.TAG_NAME, VacTag.TAG_NAME) + + /** + * Sub-kinds that are never Control Plane editions (CORD-02 Appendix B): 6 and 9 are + * claimed by the addressable kind-33301 invite marker, 7 is retired (the v1 owner + * attestation), and 10 is the dissolution tombstone, which lives at its own address and + * must never be read off this plane (see `ConcordDissolution`). + */ + private val NOT_CONTROL_EDITIONS = + setOf(ControlEntityKind.INVITE_LIVE.wire, "7", ControlEntityKind.INVITE_REVOKED.wire, ControlEntityKind.DISSOLVED.wire) + + /** + * Parses an opened Control Plane wrap into a [ControlEdition], or null when it is not + * one. On top of [fromRumor] it enforces the plane's seal kind: a Control edition + * **MUST** ride a plaintext kind-20014 seal (CORD-02 §5, Appendix B), since only a + * plaintext seal survives a compaction re-wrap with its signature intact. An edition + * under an encrypted 20013 seal is refused, as the reference client refuses it. + */ + fun fromOpened(opened: OpenedStreamEvent): ControlEdition? { + if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT) return null + return fromRumor(opened.rumor, opened.author) + } + /** * Parses a decrypted, verified kind-3308 [rumor] (its [author] is the * rumor's pubkey) into a [ControlEdition], or returns null if it is not a - * well-formed control edition (unknown/absent `vsk`, missing `eid`/`ev`, - * malformed hex, …) so the caller drops it rather than folding garbage. - * Reads the typed tags of [ControlEditionEvent]. + * well-formed control edition, so the caller drops it rather than folding + * garbage: an absent or non-canonical `vsk`, a sub-kind that is not a Control + * edition (6, 7, 9, 10), a missing or malformed `eid`/`ev`, a malformed `ep`/`vac`, + * or any of those machinery tags appearing more than once (an ambiguous edition + * two readers could parse differently). A canonical `vsk` this client does not + * model parses with a null [ControlEdition.entityKind] (see the class doc). + * + * Prefer [fromOpened] for anything read off a plane: this does not see the seal. */ fun fromRumor( rumor: Event, author: String = rumor.pubKey, ): ControlEdition? { if (rumor.kind != ControlEditionEvent.KIND) return null - val entityKind = rumor.tags.vsk() ?: return null + for (name in SINGLE_VALUED_TAGS) { + if (rumor.tags.count { it.isNotEmpty() && it[0] == name } > 1) return null + } + + val vskWire = rumor.tags.firstOrNull { it.size >= 2 && it[0] == VskTag.TAG_NAME }?.get(1) ?: return null + if (!CanonicalDecimal.isCanonical(vskWire) || vskWire in NOT_CONTROL_EDITIONS) return null + val entityKind = ControlEntityKind.of(vskWire) + val entityId = rumor.tags.eid() ?: return null val version = rumor.tags.ev() ?: return null @@ -107,6 +159,7 @@ class ControlEdition( author = author, rumorId = rumor.id, createdAt = rumor.createdAt, + vsk = vskWire, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 33c3aba220..a7577dfe7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt @@ -21,11 +21,20 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.elementNames import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.jsonObject +import kotlin.math.floor /** * JSON facility for Concord Control Plane entity content. Unknown keys are @@ -47,6 +56,37 @@ object ConcordJson { null } + /** + * Encodes [value] as the next edition's content **without losing what the previous edition + * carried and we don't model** (CORD-02 §6: "an editor MUST round-trip fields it doesn't + * understand"). Every key [serializer] declares is ours to set — including to absent, so a form + * can clear an optional field — and every other key of [previousContent] (another client's + * `custom`, a newer protocol field like `av_brokers`) rides through verbatim. + * + * [previousContent] is the entity's current authorized head, or null for a genesis edition. A + * head that is not a JSON object contributes nothing. + */ + @OptIn(ExperimentalSerializationApi::class) + fun encodePreserving( + serializer: KSerializer, + value: T, + previousContent: String?, + ): String { + val next = instance.encodeToJsonElement(serializer, value).jsonObject + val previous = + previousContent?.let { + try { + instance.parseToJsonElement(it) as? JsonObject + } catch (_: Exception) { + null + } + } ?: return instance.encodeToString(JsonObject.serializer(), next) + val managed = serializer.descriptor.elementNames.toSet() + val kept = previous.filterKeys { it !in managed } + if (kept.isEmpty()) return instance.encodeToString(JsonObject.serializer(), next) + return instance.encodeToString(JsonObject.serializer(), JsonObject(next + kept)) + } + /** Parses a Banlist edition's content (a bare JSON array of hex pubkeys). */ fun decodeBanlist(content: String): List? = try { @@ -71,11 +111,17 @@ data class RoleScope( /** * A Role's content (CORD-04): a named bundle of permissions at a [position]. - * The role's id is the edition's entity id, not a content field. Lower [position] - * ranks higher; no role may claim position 0 (reserved for the owner). + * Lower [position] ranks higher; no role may claim position 0 (reserved for the owner). + * + * [roleId] is the role's own id, which the spec puts in the content (CORD-04 §2) and which + * must equal the edition's `eid`. The reference client drops a role without it, so every + * role we write carries it; roles minted before this client wrote it are still read (the + * `eid` is then the id), but a role whose [roleId] names a *different* coordinate is refused + * ([isWellFormedAt]). */ @Serializable data class RoleEntity( + @SerialName("role_id") val roleId: String? = null, val name: String = "", val position: Long = 0, /** u64 permission bitfield as a decimal string. */ @@ -86,6 +132,18 @@ data class RoleEntity( val deleted: Boolean = false, ) { fun permissionBits(): ConcordPermissions = ConcordPermissions.fromWireOrNull(permissions) ?: ConcordPermissions.NONE + + /** + * Whether this content may stand as the role at coordinate [entityIdHex] (CORD-04 §2/§3): + * its [roleId], when present, names that coordinate; its name fits the 64-byte cap; and a + * live role claims a position below the owner's 0. Mirrors Armada's `roleFromJSON`, except + * that a legacy role with no [roleId] is still accepted. + */ + fun isWellFormedAt(entityIdHex: String): Boolean { + if (roleId != null && !roleId.equals(entityIdHex, ignoreCase = true)) return false + if (!ConcordLimits.nameFits(name)) return false + return deleted || position >= 1 + } } /** @@ -110,16 +168,35 @@ data class GrantEntity( /** * A Channel's content (CORD-03). The channel id is the edition entity id. - * [private] selects derived-key visibility; [voice] flags an audio channel. - * A [deleted] channel is terminal — its id is never reused. + * [private] selects derived-key visibility. A [deleted] channel is terminal — its id is never + * reused. + * + * There is no voice flag: every Channel is callable (CORD-07). A `voice` key an older client + * wrote is not ours to interpret; it rides through edits untouched like any unknown field + * ([ConcordJson.encodePreserving]), as does the optional `custom` object (CORD-02 §6). */ @Serializable data class ChannelEntity( val name: String = "", val private: Boolean = false, - val voice: Boolean = false, val deleted: Boolean = false, -) +) { + /** True when [name] is within the protocol's name rule ([isValidName]). */ + fun hasValidName(): Boolean = isValidName(name) + + companion object { + /** The protocol-wide name cap, in UTF-8 bytes (CORD-03 §2, CORD-04). */ + const val NAME_MAX_BYTES = 64 + + /** + * A Channel name must be non-empty and at most [NAME_MAX_BYTES] UTF-8 bytes. Enforced when + * building an edition and again when folding one: an edition naming an empty or over-cap + * Channel is unauthorized, and the fold falls back to the previous candidate (the reference + * client's channel gate). + */ + fun isValidName(name: String): Boolean = name.isNotEmpty() && name.encodeToByteArray().size <= NAME_MAX_BYTES + } +} /** * A community's Metadata content (CORD-02): display [name], optional [description], the community's @@ -137,4 +214,28 @@ data class MetadataEntity( val banner: ImagePointer? = null, val description: String? = null, val relays: List = emptyList(), -) + /** + * The disappearing-messages timer (CORD-08 §1) exactly as the edition carried it. Kept raw so + * a malformed value is carried through an edit untouched; read it through [messageExpirationSecs]. + */ + @SerialName("message_expiration") val messageExpiration: JsonElement? = null, +) { + /** + * The disappearing-messages timer in whole seconds, or null when it is off (CORD-08 §1). + * Absent, `0`, negative or malformed (a string, an object, a non-finite number) all read as + * off — a reader MUST NOT guess a default from garbage. A fractional value floors, as the + * reference client does. + */ + fun messageExpirationSecs(): Long? { + val primitive = messageExpiration as? JsonPrimitive ?: return null + if (primitive.isString) return null + val value = primitive.doubleOrNull ?: return null + if (!value.isFinite()) return null + val secs = floor(value) + if (secs < 1 || secs > Long.MAX_VALUE.toDouble()) return null + return secs.toLong() + } + + /** This metadata with the timer set to [secs], or turned off when [secs] is null or below 1. */ + fun withMessageExpiration(secs: Long?): MetadataEntity = copy(messageExpiration = secs?.takeIf { it >= 1 }?.let { JsonPrimitive(it) }) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt index f399690a1e..f04f47afdd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityKind.kt @@ -56,6 +56,9 @@ enum class ControlEntityKind( /** The dissolution tombstone (terminal). */ DISSOLVED("10"), + + /** A Channel's Pin List (CORD-04 §7), gated by PIN_MESSAGES. */ + PIN_LIST("11"), ; companion object { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt index 956a0ec461..d94c54afbd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/EditionFold.kt @@ -60,6 +60,12 @@ fun ControlEdition.asFloor(): EntityFloor = EntityFloor(version, hashHex, this) */ typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion: Long) -> Unit +/** + * An author's standing for the equal-version tie-break ([EditionFold.pickHead]): lower is + * higher authority — the owner lowest, then Role position, a roleless author last. + */ +typealias AuthorRank = (author: String) -> Long + /** * Folds Control Plane editions into the current head of each entity (CORD-04 * §Edition Hashing & Chain Integrity). @@ -88,12 +94,14 @@ typealias GapReporter = (entityIdHex: String, floorVersion: Long, offeredVersion * - **Intact chain / no downgrades** — the head advances to `version + 1` only * when that edition's `ep` cites the current head's [ControlEdition.hash]. * Lower or non-chaining versions are ignored. - * - **Deterministic convergence** — at equal version, ties break on the lower - * rumor id, so every honest client folds to the same head. + * - **Deterministic convergence** — at equal version the spec's tie-break is + * "authority first, then the lower rumor id, never the author-settable + * timestamp" (CORD-04 §1). The bare structural fold only knows the rumor id; + * [foldEntityGated] / [foldGated] with a rank apply authority first both in the + * walk (which sibling anchors and extends the chain) and in the head pick + * ([pickHead]), so every honest client lands on the same head. * - * Authority-weighted tie-break ("authority first, then the lower rumor id") and - * the owner-rooted `vac` verification are applied by the resolver layer on top of - * this structural fold; this class is purely the chain walk. + * The owner-rooted `vac` verification rides the callers' gates. */ object EditionFold { private const val TAG = "ConcordEditionFold" @@ -148,10 +156,17 @@ object EditionFold { private fun bootstrapHead( editions: List, floorVersion: Long, + tie: Comparator, ): ControlEdition? = editions .filter { it.version >= floorVersion && it.version - floorVersion <= MAX_COMPACTION_VERSION_JUMP } - .minWithOrNull(compareByDescending { it.version }.thenBy { it.rumorId }) + .minWithOrNull(compareByDescending { it.version }.then(tie)) + + /** + * The structural tie-break among equal-version siblings when nothing better is known: the + * lower rumor id. [foldEntityGated] replaces it with authority first (CORD-04 §1). + */ + val BY_RUMOR_ID: Comparator = compareBy { it.rumorId } /** * How far above the floor the compaction arm will follow an edition in one step. @@ -183,30 +198,46 @@ object EditionFold { private fun chainHead( editions: List, floor: EntityFloor, + tie: Comparator, ): ControlEdition? { val byVersion = HashMap>() for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() ?: return null - val winner = byVersion[lowest]?.minByOrNull { it.rumorId } ?: return null - var head = - when (lowest) { - floor.version -> winner.takeIf { it.hashHex == floor.hashHex } - floor.version + 1 -> winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex } - else -> null - } ?: return null - - while (true) { - val next = - byVersion[head.version + 1] - ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } - ?.minByOrNull { it.rumorId } - ?: break - head = next - } + var head = floorAnchor(byVersion[lowest] ?: return null, lowest, floor, tie) ?: return null + while (true) head = nextLink(byVersion, head, tie) ?: break return head } + /** + * The edition at the lowest offered version [lowest] (at or above the floor) that connects + * to [floor], or null. Only two shapes connect: the floor edition itself (same version AND + * hash — a same-version sibling is a fork, not our chain, but its presence does not hide + * the edition we hold), or the floor's immediate successor citing the floor's hash, the + * [tie] winner among several. + */ + private fun floorAnchor( + siblings: List, + lowest: Long, + floor: EntityFloor, + tie: Comparator, + ): ControlEdition? = + when (lowest) { + floor.version -> siblings.firstOrNull { it.hashHex == floor.hashHex } + floor.version + 1 -> siblings.filter { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex }.minWithOrNull(tie) + else -> null + } + + /** The [tie] winner among the `version + 1` editions citing [head], or null when the chain ends there. */ + private fun nextLink( + byVersion: Map>, + head: ControlEdition, + tie: Comparator, + ): ControlEdition? = + byVersion[head.version + 1] + ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } + ?.minWithOrNull(tie) + /** * Groups mixed [editions] by entity id and folds each to its head, honoring the * per-entity anti-rollback [floors] (keyed by [ControlEdition.entityIdHex]). @@ -225,7 +256,7 @@ object EditionFold { val byEntity = editions.groupBy { it.entityIdHex } val out = HashMap(byEntity.size) for ((entity, list) in byEntity) { - foldEntity(list, floors[entity], snapshot, onGap)?.let { out[entity] = it } + foldEntity(list, floors[entity], snapshot, onGap = onGap)?.let { out[entity] = it } } return out } @@ -246,11 +277,18 @@ object EditionFold { * the version-anchored compaction arm instead of the chain walk — see the arm * itself for why. Null (the default) keeps the pure chain walk, which is right for * a single-epoch fold and for every caller that has no epoch to speak of. + * + * [tie] picks among equal-version siblings wherever the walk has a choice (the genesis + * anchor, each next link, the compaction head): the lower rumor id by default, authority + * first when [foldEntityGated] supplies one. Without authority in the walk, a lower-ranked + * member's fork with a grindable low rumor id would anchor the chain, and an honest + * successor chained onto the owner's sibling would be unreachable. */ fun foldEntity( editions: List, floor: EntityFloor? = null, snapshot: Set? = null, + tie: Comparator = BY_RUMOR_ID, onGap: GapReporter = LOG_GAP, ): ControlEdition? { if (editions.isEmpty()) return floor?.known @@ -269,8 +307,8 @@ object EditionFold { // strictly better evidence than "highest number wins", and preferring it denies a stray // high-version edition its free win in every ordinary fold. The bootstrap keeps the // cross-epoch case working, now bounded by MAX_COMPACTION_VERSION_JUMP. - chainHead(editions, floor)?.let { return it } - return bootstrapHead(editions, floor.version) + chainHead(editions, floor, tie)?.let { return it } + return bootstrapHead(editions, floor.version, tie) ?: run { // Nothing admissible at or above the floor was served: the head we already // accepted vanished from the offered set — withheld, so fail closed. @@ -279,8 +317,7 @@ object EditionFold { } } - // Index editions by version, keeping the tie-break winner where several - // share a version (lower rumor id wins). + // Index editions by version; where several share one, [tie] picks among them. val byVersion = HashMap>() for (e in editions) byVersion.getOrPut(e.version) { ArrayList() }.add(e) @@ -295,15 +332,7 @@ object EditionFold { // refuse; walking up from the anchor also makes a head below the floor version // structurally impossible. val lowest = byVersion.keys.filter { it >= floor.version }.minOrNull() - val winner = lowest?.let { v -> byVersion[v]?.minByOrNull { it.rumorId } } - val anchor = - when { - winner == null -> null - lowest == floor.version -> winner.takeIf { it.hashHex == floor.hashHex } - lowest == floor.version + 1 -> - winner.takeIf { it.prevHash != null && it.prevHash.toHexKey() == floor.hashHex } - else -> null - } + val anchor = lowest?.let { v -> floorAnchor(byVersion[v] ?: emptyList(), v, floor, tie) } anchor ?: run { onGap(editions[0].entityIdHex, floor.version, editions.maxOf { it.version }) @@ -315,22 +344,16 @@ object EditionFold { // Refounded community carries a prev citing the prior epoch — a fresh joiner // anchors at the lowest-version edition it does hold and accepts it as the // baseline (CORD-04 §1 / CORD-06 §3). `editions` is non-empty here. + val byVersionThenTie = compareBy { it.version }.then(tie) editions .filter { it.prevHash == null } - .minWithOrNull(compareBy({ it.version }, { it.rumorId })) - ?: editions.minWithOrNull(compareBy({ it.version }, { it.rumorId })) + .minWithOrNull(byVersionThenTie) + ?: editions.minWithOrNull(byVersionThenTie) ?: return null } // Walk the chain upward while the next version chains from the current head. - while (true) { - val next = - byVersion[head.version + 1] - ?.filter { it.prevHash != null && it.prevHash.toHexKey() == head.hashHex } - ?.minByOrNull { it.rumorId } - ?: break - head = next - } + while (true) head = nextLink(byVersion, head, tie) ?: break return head } @@ -370,13 +393,14 @@ object EditionFold { floor: EntityFloor? = null, snapshot: Set? = null, onGap: GapReporter = LOG_GAP, + tie: Comparator = BY_RUMOR_ID, ): List { // Ask the fold whether it gapped rather than re-deriving the condition here: with the // compaction arm and the successor anchor there are three ways to connect, and a second // copy of that test is a bug waiting to drift out of sync with the first. var gapped = false val head = - foldEntity(editions, floor, snapshot) { e, f, o -> + foldEntity(editions, floor, snapshot, tie) { e, f, o -> gapped = true onGap(e, f, o) } ?: return emptyList() @@ -389,7 +413,7 @@ object EditionFold { out.add(head) editions .filterTo(ArrayList()) { it.rumorId != head.rumorId && (floor == null || it.version >= floor.version) } - .sortedWith(compareByDescending { it.version }.thenBy { it.rumorId }) + .sortedWith(compareByDescending { it.version }.then(tie)) .let(out::addAll) return out } @@ -397,31 +421,83 @@ object EditionFold { /** * The head of one entity: the highest-priority [candidates] entry that passes * [gate], or null when none does. See [candidates] for why the gate is applied - * *after* the chain walk rather than before it. + * *after* the chain walk rather than before it, and [pickHead] for how [rank] + * settles an equal-version tie. */ fun foldEntityGated( editions: List, floor: EntityFloor? = null, snapshot: Set? = null, onGap: GapReporter = LOG_GAP, + rank: AuthorRank? = null, gate: (ControlEdition) -> Boolean, - ): ControlEdition? = candidates(editions, floor, snapshot, onGap).firstOrNull(gate) + ): ControlEdition? { + if (rank == null) return pickHead(candidates(editions, floor, snapshot, onGap), null, gate) + // Authority first everywhere the walk has a choice, judged only over editions the gate + // admits (a rejected sibling ranks last, so it can never anchor the chain on authority it + // does not hold), then the lower rumor id. Memoized: a gate decodes the content. + val score = HashMap() + val tie = + compareBy { e -> score.getOrPut(e.rumorId) { if (gate(e)) rank(e.author) else Long.MAX_VALUE } } + .then(BY_RUMOR_ID) + return pickHead(candidates(editions, floor, snapshot, onGap, tie), rank, gate) + } + + /** + * The first of the ordered [candidates] passing [gate], with an equal-version tie broken + * **authority first** (CORD-04 §1: "authority first, then the lower rumor id, never the + * author-settable timestamp"): among the gate-passing candidates at the winner's version, + * the one whose author [rank]s highest (lowest number) takes it, and only a rank tie falls + * back to the candidate order — the chain-verified head, then the lower rumor id. + * + * A rumor id is author-grindable, so without this a lower-ranked bit holder could mint + * editions until one sorted below the owner's at the same version and win the entity. + * This is Armada's `pickHead` (control.ts), which both clients must agree on. With no + * [rank] the first passing candidate wins, the old rumor-id-only rule. + */ + fun pickHead( + candidates: List, + rank: AuthorRank?, + gate: (ControlEdition) -> Boolean, + ): ControlEdition? { + var head: ControlEdition? = null + var headRank = 0L + for (c in candidates) { + if (!gate(c)) continue + if (head == null) { + if (rank == null) return c + head = c + headRank = rank(c.author) + continue + } + // Candidates are version-descending after the head: nothing at a lower version can win. + if (c.version != head.version) break + val r = rank!!(c.author) + if (r < headRank) { + head = c + headRank = r + } + } + return head + } /** * Groups mixed [editions] by entity id and folds each to the highest-priority - * head passing [gate] — the gated counterpart of [fold]. See [candidates]. + * head passing [gate] — the gated counterpart of [fold]. See [candidates] and + * [pickHead]. */ fun foldGated( editions: Collection, floors: Map = emptyMap(), snapshot: Set? = null, onGap: GapReporter = LOG_GAP, + rank: AuthorRank? = null, gate: (ControlEdition) -> Boolean, ): Map { val byEntity = editions.groupBy { it.entityIdHex } val out = HashMap(byEntity.size) for ((entity, list) in byEntity) { - foldEntityGated(list, floors[entity], snapshot, onGap, gate)?.let { out[entity] = it } + foldEntityGated(list, floors[entity], snapshot, onGap, rank, gate)?.let { out[entity] = it } } return out } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/CanonicalDecimal.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/CanonicalDecimal.kt new file mode 100644 index 0000000000..edc7bac9cc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/CanonicalDecimal.kt @@ -0,0 +1,42 @@ +/* + * 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.quartz.concord.cord04Roles.control.tags + +/** + * The strict tag-number form CORD-01 §5 fixes for the Control Plane's machinery tags + * (`vsk`, `ev`, a `vac` version): decimal with no sign and no leading zeros — `0`, `4`, + * `12`, never `04`, `+4`, `0x4` or `1e2`. + * + * `String.toLongOrNull()` accepts `+4` and `04`, so two clients reading the same edition + * could disagree about which version it is. The reference client (Armada `isTagDecimal`) + * refuses anything else, and so do we. + */ +object CanonicalDecimal { + fun isCanonical(s: String): Boolean { + if (s.isEmpty()) return false + if (s.length > 1 && s[0] == '0') return false + for (c in s) if (c !in '0'..'9') return false + return true + } + + /** [s] as a non-negative Long when it is canonical and fits, else null. */ + fun parse(s: String): Long? = if (isCanonical(s)) s.toLongOrNull() else null +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt index edaf67d3ae..3553d194f2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/EvTag.kt @@ -33,7 +33,8 @@ class EvTag { fun parse(tag: Array): Long? { ensure(tag.has(1)) { return null } ensure(tag[0] == TAG_NAME) { return null } - return tag[1].toLongOrNull()?.takeIf { it >= 0 } + // Canonical decimal only: "04" or "+4" is not a version (CORD-01 §5). + return CanonicalDecimal.parse(tag[1]) } fun assemble(version: Long) = arrayOf(TAG_NAME, version.toString()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt index 486b1cd3b2..72a539dddd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/VacTag.kt @@ -42,7 +42,7 @@ class VacTag { ensure(tag.has(3)) { return null } ensure(tag[0] == TAG_NAME) { return null } val grantId = tag[1].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null - val grantVersion = tag[2].toLongOrNull() ?: return null + val grantVersion = CanonicalDecimal.parse(tag[2]) ?: return null val grantHash = tag[3].hexToByteArrayOrNull()?.takeIf { it.size == 32 } ?: return null return AuthorityCitation(grantId, grantVersion, grantHash) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt new file mode 100644 index 0000000000..2c549b9cc5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt @@ -0,0 +1,65 @@ +/* + * 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.quartz.concord.cord04Roles.pins + +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Folds the Control Plane's Pin Lists (CORD-04 §7): one vsk-11 entity per Channel, at + * `pins_locator(community_id, channel_id)`, gated like any edition by `PIN_MESSAGES` (the owner + * always passes). An edition at any other coordinate — a list minted for another Community or a + * Channel that isn't folded here — binds to nothing and is ignored. + */ +object ConcordPinLists { + /** The Pin List coordinate (hex) of [channelIdHex] in [communityIdHex]. */ + fun coordinate( + communityIdHex: HexKey, + channelIdHex: HexKey, + ): HexKey = ConcordKeyDerivation.pinsCoordinate(communityIdHex.hexToByteArray(), channelIdHex.hexToByteArray()).toHexKey() + + /** Per channel id, the authorized head edition of its Pin List. Channels with no list are absent. */ + fun heads( + editions: Collection, + authority: AuthorityResolver, + communityIdHex: HexKey, + channelIds: Collection, + floors: Map = emptyMap(), + ): Map { + if (channelIds.isEmpty()) return emptyMap() + val channelByCoordinate = channelIds.associateBy { coordinate(communityIdHex, it) } + val lists = editions.filter { it.entityKind == ControlEntityKind.PIN_LIST && it.entityIdHex in channelByCoordinate } + if (lists.isEmpty()) return emptyMap() + return EditionFold + // Same gate every other entity folds under: well-formed, owner or a PIN_MESSAGES holder, vac satisfied. + .foldGated(lists, floors, rank = authority::tieBreakRank) { authority.admits(it, ConcordPermissions.PIN_MESSAGES) } + .mapNotNull { (coordinate, head) -> channelByCoordinate[coordinate]?.let { it to head } } + .toMap() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt new file mode 100644 index 0000000000..bbea36f329 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt @@ -0,0 +1,355 @@ +/* + * 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.quartz.concord.cord04Roles.pins + +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.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.longOrNull + +/** + * Concord Pins (CORD-04 §7): a pin does not quote a message, it **proves** one. + * + * One Pin List per Channel lives on the Control Plane (vsk 11, coordinate + * `pins_locator(community_id, channel_id)`), replaced entire per edit. Each entry carries the + * message's original kind-20013 seal, verbatim, plus the 76-byte NIP-44 key disclosure for that one + * message ([PinKeyDisclosure]) — so any reader of the Control Plane, with no Chat-plane history and + * no old keys, can verify author, words, Channel and time. Compaction (CORD-06) re-wraps the list + * across rotations, which is how a pin reaches members who joined long after the message. + * + * An entry's wire shape is `{ "seal": {…}, "keys": "<152 hex>", "wrap"?: "", "edit"?: + * {"seal", "keys"} }`. Entries are kept as the raw JSON they arrived as: the seal's fields must be + * carried exactly (its signature covers them), and fields we don't model must survive a republish. + */ +object ConcordPins { + /** At most this many entries — judged by whoever can open the form. */ + const val MAX_ENTRIES = 25 + + /** At most this many bytes of edition `content`, judged on the carried bytes, both forms. */ + const val MAX_CONTENT_BYTES = 32_768 + + const val KIND_MESSAGE = 9 + const val KIND_COMMENT = 1111 + const val KIND_EDIT = 3302 + + private val json = Json { prettyPrint = false } + private val HEX64 = Regex("^[0-9a-f]{64}$") + private val DECIMAL = Regex("^(0|[1-9][0-9]*)$") + + /** A pin entry that passed every §7 check — safe to render. */ + class VerifiedPin( + /** Recomputed from the decrypted bytes; never an embedded id. The entry's identity. */ + val rumorId: HexKey, + /** The seal's signer, equal to the rumor's author. */ + val author: HexKey, + val kind: Int, + /** The newest proven words: the attached Edit's when one verified, else the original's. */ + val content: String, + val tags: Array>, + /** The message's own `epoch` tag, for jump-to-context. */ + val epoch: String?, + /** Ordering basis: `created_at * 1000 + ms`. */ + val orderMs: Long, + val createdAt: Long, + /** The unverifiable locator hint the entry carried, if any. */ + val wrapHint: HexKey?, + /** True when a proven Edit supplied [content]. */ + val edited: Boolean, + /** The proven Edit's rumor id, when one verified. */ + val editRumorId: HexKey?, + /** The wire entry, verbatim, for republishing. */ + val entry: JsonObject, + ) + + /** How a Pin List's content read (§7 Limits). */ + class PinListRead( + val entries: List, + /** + * True when the list is the sealed form under an epoch key this reader doesn't hold. Such a + * list is *unavailable*, not empty — a writer MUST NOT build an edition from it. + */ + val sealedUnavailable: Boolean, + /** True when the content broke a cap or the format, so every reader treats it as empty. */ + val violating: Boolean, + ) { + companion object { + val EMPTY = PinListRead(emptyList(), sealedUnavailable = false, violating = false) + val VIOLATING = PinListRead(emptyList(), sealedUnavailable = false, violating = true) + } + } + + // ---- reading --------------------------------------------------------------------------- + + private fun parse(s: String): JsonElement? = + try { + json.parseToJsonElement(s) + } catch (_: Exception) { + null + } + + /** + * Reads a Pin List edition's [content]. A cap-violating or malformed edition reads as an empty + * list (it still folds and chains — refusing it would fork the version chain between + * implementations). The sealed form opens with [unsealKey], the Channel's conversation key at + * the named epoch, or reads as [PinListRead.sealedUnavailable] when this reader lacks it. + */ + fun read( + content: String, + unsealKey: (epoch: Long) -> ByteArray?, + ): PinListRead { + if (content.encodeToByteArray().size > MAX_CONTENT_BYTES) return PinListRead.VIOLATING + val root = parse(content) as? JsonObject ?: return PinListRead.VIOLATING + val entries = root["entries"] + if (entries != null) return entriesOf(entries) + + val epoch = (root["epoch"] as? JsonPrimitive)?.takeIf { it.isString }?.content + val sealed = (root["sealed"] as? JsonPrimitive)?.takeIf { it.isString }?.content + if (epoch == null || sealed == null || !DECIMAL.matches(epoch)) return PinListRead.VIOLATING + val epochValue = epoch.toLongOrNull() ?: return PinListRead.VIOLATING + val key = unsealKey(epochValue) ?: return PinListRead(emptyList(), sealedUnavailable = true, violating = false) + val inner = + try { + parse(Nip44.v2.decrypt(sealed, key)) as? JsonObject + } catch (_: Exception) { + null + } ?: return PinListRead.VIOLATING + return entriesOf(inner["entries"] ?: return PinListRead.VIOLATING) + } + + private fun entriesOf(element: JsonElement): PinListRead { + val array = element as? JsonArray ?: return PinListRead.VIOLATING + if (array.size > MAX_ENTRIES) return PinListRead.VIOLATING + // A non-object entry is just an invalid entry, dropped alone by the verifier. + return PinListRead(array.mapNotNull { it as? JsonObject }, sealedUnavailable = false, violating = false) + } + + // ---- verification ---------------------------------------------------------------------- + + private class OpenedRumor( + val pubKey: HexKey, + val kind: Int, + val createdAt: Long, + val tags: Array>, + val content: String, + ) { + val id: HexKey by lazy { EventHasher.hashId(pubKey, createdAt, kind, tags, content) } + + fun tag(name: String): String? = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1) + + fun orderMs(): Long { + val raw = tag("ms") + val ms = raw?.takeIf { DECIMAL.matches(it) }?.toLongOrNull()?.takeIf { it <= 999 } ?: 0L + return createdAt * 1000 + ms + } + } + + /** The seal object as an [Event], only if it is a correctly signed kind-20013 seal. */ + private fun sealOf(element: JsonElement?): Event? { + val obj = element as? JsonObject ?: return null + val seal = + try { + Event.fromJson(json.encodeToString(JsonObject.serializer(), obj)) + } catch (_: Exception) { + return null + } + if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null + val valid = + try { + seal.verify() + } catch (_: Exception) { + false + } + return seal.takeIf { valid } + } + + /** Steps 2–4 up to the rumor: MAC, decrypt, unpad, parse, author equality. */ + private fun openRumor( + seal: Event, + keysHex: String?, + ): OpenedRumor? { + val keys = PinKeyDisclosure.decode(keysHex ?: return null) ?: return null + val plaintext = PinKeyDisclosure.decryptWith(seal.content, keys) ?: return null + val obj = parse(plaintext) as? JsonObject ?: return null + val pubKey = (obj["pubkey"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null + val kind = (obj["kind"] as? JsonPrimitive)?.takeIf { !it.isString }?.intOrNull ?: return null + val createdAt = (obj["created_at"] as? JsonPrimitive)?.takeIf { !it.isString }?.longOrNull ?: return null + val content = (obj["content"] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: return null + val tags = + (obj["tags"] as? JsonArray)?.map { tag -> + (tag as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content ?: return null }?.toTypedArray() ?: return null + } ?: return null + // NIP-59's impersonation check: renderers display rumor fields. + if (pubKey != seal.pubKey) return null + return OpenedRumor(pubKey, kind, createdAt, tags.toTypedArray(), content) + } + + /** + * The five §7 verification steps, holding nothing but [entry] and its list's [channelIdHex]: + * a signed kind-20013 seal; the MAC under the disclosed HMAC key; decrypt, unpad and parse; the + * rumor's author equals the seal's, its kind is 9 or 1111, and it carries + * `["channel", channelIdHex]`; and its id recomputed from the bytes. Null on any failure — the + * entry is then dropped alone. A failing `edit` bundle costs only the revision, never the pin. + */ + fun verify( + entry: JsonObject, + channelIdHex: HexKey, + ): VerifiedPin? { + if (!HEX64.matches(channelIdHex)) return null + val seal = sealOf(entry["seal"]) ?: return null + val rumor = openRumor(seal, (entry["keys"] as? JsonPrimitive)?.takeIf { it.isString }?.content) ?: return null + if (rumor.kind != KIND_MESSAGE && rumor.kind != KIND_COMMENT) return null + // The binding stops a private Channel's keyholder from pinning its messages into a public list. + if (rumor.tag("channel") != channelIdHex) return null + val rumorId = rumor.id + + val edit = (entry["edit"] as? JsonObject)?.let { verifyEdit(it, seal.pubKey, rumorId, channelIdHex) } + return VerifiedPin( + rumorId = rumorId, + author = seal.pubKey, + kind = rumor.kind, + content = edit?.content ?: rumor.content, + tags = rumor.tags, + epoch = rumor.tag("epoch"), + orderMs = rumor.orderMs(), + createdAt = rumor.createdAt, + wrapHint = (entry["wrap"] as? JsonPrimitive)?.contentOrNull?.takeIf { HEX64.matches(it) }, + edited = edit != null, + editRumorId = edit?.id, + entry = entry, + ) + } + + /** An Edit bundle: the same steps with kind 3302, plus the same author and an `e` naming the original. */ + private fun verifyEdit( + bundle: JsonObject, + originalAuthor: HexKey, + originalRumorId: HexKey, + channelIdHex: HexKey, + ): OpenedRumor? { + val seal = sealOf(bundle["seal"]) ?: return null + if (seal.pubKey != originalAuthor) return null + val rumor = openRumor(seal, (bundle["keys"] as? JsonPrimitive)?.takeIf { it.isString }?.content) ?: return null + if (rumor.kind != KIND_EDIT) return null + if (rumor.tag("channel") != channelIdHex) return null + if (rumor.tag("e") != originalRumorId) return null + return rumor + } + + // ---- writing --------------------------------------------------------------------------- + + private fun sealJson(seal: Event): JsonObject = parse(seal.toJson()) as JsonObject + + /** + * Builds the entry for a message this client opened: its verbatim seal, the disclosure derived + * with the Channel's [conversationKey] at the message's epoch, and the [wrapId] hint. Null when + * the result would not verify (an unencrypted seal, a wrong epoch key — the MAC catches it). + */ + fun buildEntry( + opened: OpenedStreamEvent, + conversationKey: ByteArray, + channelIdHex: HexKey, + wrapId: HexKey?, + ): JsonObject? { + val seal = opened.seal + if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return null + val keys = PinKeyDisclosure.discloseFor(seal.content, conversationKey) ?: return null + val fields = LinkedHashMap() + fields["seal"] = sealJson(seal) + fields["keys"] = JsonPrimitive(PinKeyDisclosure.encode(keys)) + if (wrapId != null) fields["wrap"] = JsonPrimitive(wrapId) + val entry = JsonObject(fields) + return entry.takeIf { verify(it, channelIdHex) != null } + } + + /** + * [entry] with the proof of [edit] attached (§7 Edits), replacing any earlier one — an entry + * carries at most the newest provable Edit. Returns [entry] unchanged when the bundle would not + * verify, so a refresh never downgrades it. + */ + fun withEdit( + entry: JsonObject, + edit: OpenedStreamEvent, + conversationKey: ByteArray, + channelIdHex: HexKey, + ): JsonObject { + val seal = edit.seal + if (seal.kind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) return entry + val keys = PinKeyDisclosure.discloseFor(seal.content, conversationKey) ?: return entry + val bundle = JsonObject(mapOf("seal" to sealJson(seal), "keys" to JsonPrimitive(PinKeyDisclosure.encode(keys)))) + val candidate = JsonObject(entry + ("edit" to bundle)) + return if (verify(candidate, channelIdHex)?.edited == true) candidate else entry + } + + private fun listJson(entries: List) = json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("entries" to JsonArray(entries)))) + + /** Thrown instead of publishing an edition every reader would read as empty. */ + class PinListTooLargeException( + message: String, + ) : IllegalArgumentException(message) + + private fun checkCaps( + count: Int, + content: String, + ): String { + if (count > MAX_ENTRIES) throw PinListTooLargeException("pin list exceeds $MAX_ENTRIES entries") + val bytes = content.encodeToByteArray().size + if (bytes > MAX_CONTENT_BYTES) throw PinListTooLargeException("pin list content is $bytes bytes (cap $MAX_CONTENT_BYTES)") + return content + } + + /** A public Channel's list: plaintext, since the Control Plane's wrap is the gate. */ + fun serializePublic(entries: List): String = checkCaps(entries.size, listJson(entries)) + + /** A private Channel's list, sealed under its [conversationKey] at [epoch]; caps judged on the final bytes. */ + fun serializeSealed( + entries: List, + conversationKey: ByteArray, + epoch: Long, + ): String { + if (entries.size > MAX_ENTRIES) throw PinListTooLargeException("pin list exceeds $MAX_ENTRIES entries") + val sealed = Nip44.v2.encrypt(listJson(entries), conversationKey).encodePayload() + val content = json.encodeToString(JsonObject.serializer(), JsonObject(mapOf("epoch" to JsonPrimitive(epoch.toString()), "sealed" to JsonPrimitive(sealed)))) + return checkCaps(entries.size, content) + } + + // ---- deletion -------------------------------------------------------------------------- + + /** + * True when a kind-5 delete by [deleteAuthor] with [deleteTags] kills [pin]: self-erasure + * outranks curation, so only the pin's proven author can, by naming its recomputed rumor id. + */ + fun killedBy( + pin: VerifiedPin, + deleteAuthor: HexKey, + deleteTags: Array>, + ): Boolean = deleteAuthor == pin.author && deleteTags.any { it.size >= 2 && it[0] == "e" && it[1] == pin.rumorId } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/PinKeyDisclosure.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/PinKeyDisclosure.kt new file mode 100644 index 0000000000..49f98644c3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/PinKeyDisclosure.kt @@ -0,0 +1,99 @@ +/* + * 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.quartz.concord.cord04Roles.pins + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 +import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf +import com.vitorpamplona.quartz.utils.equalsConstantTime + +/** + * NIP-44 v2 per-message key disclosure — the primitive a Concord Pin is built on (CORD-04 §7). + * + * NIP-44 derives each message's keys as `hkdf-expand(conversation_key, nonce, 76)` = + * `chacha_key[32] ‖ chacha_nonce[12] ‖ hmac_key[32]`. The expansion is one-way, so disclosing it + * opens exactly that one message: not the conversation key, not the epoch, not the author's other + * traffic. Wire form: 76 bytes as 152 lowercase hex characters. + */ +object PinKeyDisclosure { + const val MESSAGE_KEYS_BYTES = 76 + + private val HEX = Regex("^[0-9a-f]{152}$") + private val chaCha = ChaCha20() + + /** The 76-byte lowercase-hex wire form of [keys]. */ + fun encode(keys: Hkdf.MessageKey): String = (keys.chachaKey + keys.chachaNonce + keys.hmacKey).toHexKey() + + /** Parses a disclosure; null unless it is exactly 152 lowercase hex characters. */ + fun decode(hex: String): Hkdf.MessageKey? { + if (!HEX.matches(hex)) return null + val bytes = hex.hexToByteArrayOrNull() ?: return null + return Hkdf.MessageKey(bytes.copyOfRange(0, 32), bytes.copyOfRange(32, 44), bytes.copyOfRange(44, 76)) + } + + private fun decodePayload(payload: String): Nip44v2.EncryptedInfo? = + try { + Nip44v2.EncryptedInfo.decodePayload(payload) + } catch (_: Exception) { + null + } + + /** The disclosure for one NIP-44 [payload], derived with the stream's [conversationKey]; null if the payload is malformed. */ + fun discloseFor( + payload: String, + conversationKey: ByteArray, + ): Hkdf.MessageKey? { + val decoded = decodePayload(payload) ?: return null + return Nip44.v2.getMessageKeys(conversationKey, decoded.nonce) + } + + /** + * Opens a NIP-44 v2 [payload] with disclosed [keys] (a pin's proof): MAC over nonce‖ciphertext + * with the disclosed HMAC key, then ChaCha20, then a strict unpad. Null on any failure. Only the + * standard u16-prefixed form is accepted — a Concord payload never exceeds 65,535 bytes. + */ + fun decryptWith( + payload: String, + keys: Hkdf.MessageKey, + ): String? { + val decoded = decodePayload(payload) ?: return null + val mac = + try { + Nip44.v2.hmacAad(keys.hmacKey, decoded.ciphertext, decoded.nonce) + } catch (_: Exception) { + return null + } + if (!mac.equalsConstantTime(decoded.mac)) return null + val padded = chaCha.decrypt(decoded.ciphertext, keys.chachaNonce, keys.chachaKey) + if (padded.size < 2) return null + val len = (padded[0].toInt() and 0xFF shl 8) or (padded[1].toInt() and 0xFF) + if (len < 1) return null + if (padded.size.toLong() != 2 + Nip44.v2.calcPaddedLen(len)) return null + return try { + padded.decodeToString(2, 2 + len, throwOnInvalidSequence = true) + } catch (_: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt index f4b1277a9e..ac0ce7bd16 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordDirectInvite.kt @@ -83,6 +83,7 @@ object ConcordDirectInvite { if (seal !is SealEvent) return null val rumor = seal.unsealOrNull(recipientSigner) ?: return null if (rumor.kind != KIND) return null - return ConcordJson.decodeOrNull(rumor.content) + // Bounded like a fetched bundle (CORD-05 §6: "the §1 bounds apply"). + return ConcordJson.decodeOrNull(rumor.content)?.let { ConcordInviteBundle.bound(it) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt index 50ec38bd10..0f57cf1d7e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteBundle.kt @@ -26,9 +26,11 @@ import com.vitorpamplona.quartz.concord.cord04Roles.control.vsk import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 import com.vitorpamplona.quartz.utils.RandomInstance @@ -94,6 +96,52 @@ class MintedInviteLink( object ConcordInviteBundle { const val KIND = ConcordInviteBundleEvent.KIND + /** + * A bundle naming more Channels than this is refused before anything is allocated for it + * (CORD-05 §1: "reject a bundle carrying more than a sane channel count (Vector's ceiling is + * 256)"). A bundle is attacker-crafted input reached by following a link. + */ + const val MAX_BUNDLE_CHANNELS = 256 + + /** + * A bundle's `relays` are truncated to the Community's relay cap before anything connects to + * them (CORD-05 §1, CORD-02 §6's "up to 5"): a hostile link must not be a connect storm. + */ + const val MAX_COMMUNITY_RELAYS = 5 + + /** + * Bounds an attacker-crafted [invite] (CORD-05 §1 MUST): null when it names more than + * [MAX_BUNDLE_CHANNELS] Channels, otherwise the invite with `relays` de-duplicated and + * truncated to [MAX_COMMUNITY_RELAYS]. Every redeem path — link bundle, Direct Invite — + * goes through [validate], which applies this. + */ + fun bound(invite: CommunityInvite): CommunityInvite? { + if (invite.channels.size > MAX_BUNDLE_CHANNELS) return null + val relays = + invite.relays + .filter { it.isNotBlank() } + .distinct() + .take(MAX_COMMUNITY_RELAYS) + return if (relays == invite.relays) invite else invite.copy(relays = relays) + } + + /** + * True when [event] is really the bundle coordinate `(33301, linkSigner, d="")` (CORD-05 §2): + * the right kind, authored by [linkSignerPubKey], an empty `d`, and a valid signature. A relay + * filter is a hint, not a proof — a relay (or anyone who can write to one) could otherwise + * serve a forged newer `vsk 9` and revoke a link it never owned. + */ + fun isAtCoordinate( + event: Event, + linkSignerPubKey: HexKey, + ): Boolean { + if (event.kind != KIND) return false + if (!event.pubKey.equals(linkSignerPubKey, ignoreCase = true)) return false + val d = event.tags.firstOrNull { it.isNotEmpty() && it[0] == "d" }?.getOrNull(1) ?: "" + if (d != "") return false + return event.verify() + } + private fun json(invite: CommunityInvite) = ConcordJson.instance.encodeToString(CommunityInvite.serializer(), invite) /** Builds a kind-33301 bundle event carrying [invite], encrypted under [token] and signed by [linkSignerPrivKey]. */ @@ -125,7 +173,10 @@ object ConcordInviteBundle { createdAt: Long, ): Event = NostrSignerSync(KeyPair(privKey = linkSignerPrivKey)).sign(ConcordInviteBundleEvent.buildRevocation(createdAt)) - /** Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. */ + /** + * Decrypts a kind-33301 bundle [event] with the link [token], or null if it isn't a valid bundle. + * The result is already [bound]ed (CORD-05 §1), so an over-long relay list never reaches a caller. + */ fun parse( event: Event, token: ByteArray, @@ -133,7 +184,7 @@ object ConcordInviteBundle { if (event.kind != KIND) return null return try { val bundleKey = ConcordKeyDerivation.inviteBundleKey(token) - ConcordJson.decodeOrNull(Nip44.v2.decrypt(event.content, bundleKey)) + ConcordJson.decodeOrNull(Nip44.v2.decrypt(event.content, bundleKey))?.let { bound(it) } } catch (_: Exception) { null } @@ -152,11 +203,25 @@ object ConcordInviteBundle { * milliseconds) resolves to [InviteBundleStatus.Expired] rather than * [InviteBundleStatus.Live], so the expiry is actually enforced at the one place * every redeeming client already funnels through. + * + * Only events really at the coordinate count ([isAtCoordinate]: kind, author == + * [linkSignerPubKey], `d == ""`, valid signature) — the relay filter is not trusted, so a + * forged newer revocation cannot kill a link, nor a forged bundle hijack one. */ fun classify( wraps: List, + linkSignerPubKey: HexKey, token: ByteArray, nowMs: Long = TimeUtils.nowMillis(), + ): InviteBundleStatus { + val genuine = wraps.filter { isAtCoordinate(it, linkSignerPubKey) } + return classifyGenuine(genuine, token, nowMs) + } + + private fun classifyGenuine( + wraps: List, + token: ByteArray, + nowMs: Long, ): InviteBundleStatus { val newest = wraps.maxByOrNull { it.createdAt } ?: return InviteBundleStatus.Absent if (newest.tags.vsk() == ControlEntityKind.INVITE_REVOKED) return InviteBundleStatus.Revoked @@ -197,6 +262,7 @@ object ConcordInviteBundle { * member. Raise with the Concord/Armada authors before diverging. */ fun validate(invite: CommunityInvite): Boolean { + if (invite.channels.size > MAX_BUNDLE_CHANNELS) return false val owner = invite.owner.hexToByteArrayOrNull() ?: return false val salt = invite.ownerSalt.hexToByteArrayOrNull() ?: return false return ConcordKeyDerivation.communityId(owner, salt).toHexKey() == invite.communityId diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt index cfeb071e83..d6f49bc5e4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLink.kt @@ -62,6 +62,13 @@ object ConcordInviteLink { const val VERSION = 4 const val FLAG_STOCK_RELAYS = 0x01 + /** + * The fragment carries at most this many bootstrap relays (CORD-05 §3): it only has to *find* + * the bundle, which then carries the Community's authoritative relay set. The reference + * client's decoder refuses a longer list, so an encoder must never emit one. + */ + const val MAX_BOOTSTRAP_RELAYS = 3 + private const val MARKER_WSS_HOST = 0 private const val MARKER_FULL_URL = 255 private const val WSS_PREFIX = "wss://" @@ -69,13 +76,10 @@ object ConcordInviteLink { /** * Encodes the fragment for [token] and optional [relays]. Passing null or the - * exact stock set uses flag `0x01` and emits no relay bytes; otherwise every - * relay is encoded (dictionary id, `wss://` host, or full URL). - * - * The only ceiling is the format's own: the relay count is a single byte, so at - * most 255 relays fit. Each carries its own byte cost, so a long list makes a long - * link — pick relays that can actually serve the bundle rather than pasting a - * whole relay list in. + * exact stock set uses flag `0x01` and emits no relay bytes (the stock set is + * flag-selected, so exempt from the cap); otherwise the first + * [MAX_BOOTSTRAP_RELAYS] relays are encoded (dictionary id, `wss://` host, or full + * URL) and the rest dropped (CORD-05 §3) — the bundle carries the full set. */ @OptIn(ExperimentalEncodingApi::class) fun encodeFragment( @@ -90,10 +94,10 @@ object ConcordInviteLink { if (useStock) { out.add(FLAG_STOCK_RELAYS.toByte()) } else { - require(relays.size <= 255) { "relay count must fit in one byte, was ${relays.size}" } + val bounded = relays.distinct().take(MAX_BOOTSTRAP_RELAYS) out.add(0) - out.add(relays.size.toByte()) - for (r in relays) { + out.add(bounded.size.toByte()) + for (r in bounded) { val id = InviteRelayDictionary.idOf(r) when { id != null -> out.add(id.toByte()) @@ -119,8 +123,9 @@ object ConcordInviteLink { } /** - * Decodes an invite [fragment]. Throws for a malformed fragment or a version - * other than [VERSION] (lower = legacy, higher = newer than this client). + * Decodes an invite [fragment]. Throws for a malformed fragment, a version + * other than [VERSION] (lower = legacy, higher = newer than this client), or more + * than [MAX_BOOTSTRAP_RELAYS] relays (CORD-05 §3, as the reference client does). * Unknown dictionary ids are skipped rather than aborting the parse. */ @OptIn(ExperimentalEncodingApi::class) @@ -138,7 +143,9 @@ object ConcordInviteLink { relays.addAll(InviteRelayDictionary.STOCK) usedStock = true } else { + require(pos < bytes.size) { "fragment truncated" } val count = bytes[pos++].toInt() and 0xFF + require(count <= MAX_BOOTSTRAP_RELAYS) { "too many bootstrap relays ($count, cap $MAX_BOOTSTRAP_RELAYS)" } repeat(count) { val marker = bytes[pos++].toInt() and 0xFF when (marker) { @@ -162,8 +169,9 @@ object ConcordInviteLink { } /** - * Builds a full shareable invite URL under [base], carrying every relay in [relays] - * as the bootstrap set (or the stock flag when null / exactly the stock set). + * Builds a full shareable invite URL under [base], carrying the first + * [MAX_BOOTSTRAP_RELAYS] of [relays] as the bootstrap set (or the stock flag when + * null / exactly the stock set). */ fun buildUrl( base: String, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt index f136aed3fd..cfa66df555 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteList.kt @@ -33,6 +33,7 @@ import kotlinx.serialization.descriptors.elementNames import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonTransformingSerializer import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -86,12 +87,16 @@ class ConcordInviteListTombstone( * a newer schema. They are carried verbatim rather than dropped (re-encoding without them would * delete somebody's `signer_sk`) and rather than failing the whole read (which would refuse every * future mint and revoke for this account until someone else repaired the list). + * + * [opaqueTombstones] is the same for tombstones: one that does not type-check is residue we carry + * verbatim, never drop — dropping a retirement is how a stale device resurrects a revoked link. */ class ConcordInviteListDocument( val entries: List = emptyList(), val tombstones: List = emptyList(), val residue: JsonObject = NoExtras, val opaqueEntries: List = emptyList(), + val opaqueTombstones: List = emptyList(), ) { companion object { val EMPTY = ConcordInviteListDocument() @@ -201,14 +206,16 @@ object ConcordInviteList { } } + val opaqueTombstones = mutableListOf() val tombstones = (root["tombstones"]?.jsonArray ?: JsonArray(emptyList())).mapNotNull { element -> try { val it = ConcordJson.instance.decodeFromJsonElement(WireTombstoneSerializer, element.jsonObject) ConcordInviteListTombstone(it.token, it.communityId, it.extras) } catch (_: Exception) { - // A tombstone we cannot read must not silently un-retire its link, but we - // have no token to key it by, so it can only ride along as document residue. + // A tombstone we cannot type must not silently un-retire its link: carry it + // verbatim as residue (and still honor its token in the merge, if it has one). + opaqueTombstones.add(element) null } } @@ -218,6 +225,7 @@ object ConcordInviteList { tombstones = tombstones, residue = JsonObject(root - "entries" - "tombstones"), opaqueEntries = opaque, + opaqueTombstones = opaqueTombstones, ) } catch (_: Exception) { null @@ -238,30 +246,43 @@ object ConcordInviteList { ), ).jsonObject - // Entries we could not type ride back out untouched. Dropping them here is the data loss - // this whole class exists to prevent — they are somebody's link signer too. - if (doc.opaqueEntries.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire) - val entries = JsonArray((wire["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries) - return ConcordJson.instance.encodeToString(JsonObject.serializer(), JsonObject(wire + ("entries" to entries))) + // Entries and tombstones we could not type ride back out untouched. Dropping them here is + // the data loss this whole class exists to prevent — a link signer, or a link's retirement. + if (doc.opaqueEntries.isEmpty() && doc.opaqueTombstones.isEmpty()) return ConcordJson.instance.encodeToString(JsonObject.serializer(), wire) + var out = wire + if (doc.opaqueEntries.isNotEmpty()) { + out = JsonObject(out + ("entries" to JsonArray((out["entries"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueEntries))) + } + if (doc.opaqueTombstones.isNotEmpty()) { + out = JsonObject(out + ("tombstones" to JsonArray((out["tombstones"]?.jsonArray ?: JsonArray(emptyList())) + doc.opaqueTombstones))) + } + return ConcordJson.instance.encodeToString(JsonObject.serializer(), out) } + /** The `token` an untyped tombstone still names, if it names one as a string. */ + private fun opaqueTombstoneToken(element: JsonElement): String? = ((element as? JsonObject)?.get("token") as? JsonPrimitive)?.takeIf { it.isString }?.content + /** - * Merges [patch] onto [base], keyed by `token` — the spec's own merge key. A token present in - * either side's tombstones is dropped from the result and kept tombstoned, so a retired link - * cannot be resurrected by a device that still has it cached. [patch] wins field-by-field on a - * token both sides carry, which is what makes "read remote, apply my change, publish" converge. + * Merges [patch] onto [base], keyed by `token` — the spec's own merge key (CORD-05 §4). An entry + * is **immutable once minted**, so the first copy of a token wins ([base], the published list, + * before [patch]) and a later copy can never rewrite its `signer_sk` or url; tombstones union + * (first wins likewise), and a tombstone always beats an entry — terminally, so a retired link + * cannot be resurrected by a device that still has it cached. Mirrors the reference client's + * `mergeInviteLists`. A tombstone we could not type still retires the token it names. */ fun merge( base: ConcordInviteListDocument, patch: ConcordInviteListDocument, ): ConcordInviteListDocument { val tombstones = LinkedHashMap() - for (t in base.tombstones + patch.tombstones) tombstones[t.token] = t + for (t in base.tombstones + patch.tombstones) tombstones.getOrPut(t.token) { t } + val opaqueTombstones = (base.opaqueTombstones + patch.opaqueTombstones).distinct() + val retired = tombstones.keys + opaqueTombstones.mapNotNull { opaqueTombstoneToken(it) } val entries = LinkedHashMap() for (e in base.entries + patch.entries) { - if (e.token in tombstones) continue - entries[e.token] = e + if (e.token in retired) continue + entries.getOrPut(e.token) { e } } return ConcordInviteListDocument( entries = entries.values.toList(), @@ -270,6 +291,7 @@ object ConcordInviteList { // Untyped entries survive the merge for the same reason they survive a decode: we cannot // read them, so we are in no position to decide they are disposable. opaqueEntries = (base.opaqueEntries + patch.opaqueEntries).distinct(), + opaqueTombstones = opaqueTombstones, ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt index 9869cadb91..e4dec672c0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecovery.kt @@ -24,38 +24,37 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot /** - * Stranded recovery (CORD-05/06). + * Stranded detection (CORD-05/06). * * A Refounding carries only `(newRoot, newEpoch, rotator)` — there is **no * recipient list** — so a member who is simply left out of the rekey recipient - * set receives nothing and is silently stranded on the dead epoch forever, while - * everyone else moves on. This is true of any member, the owner included, and - * cannot be prevented on the receive side. + * set receives nothing and is silently stranded on the dead epoch, while everyone + * else moves on. The invite link the membership was joined through + * ([ConcordCommunityListEntry.inviteRef]) is re-minted at the current epoch by its + * creator, so re-resolving it and finding a **higher** epoch tells a member they + * were left behind. * - * The way out is the invite link the membership was joined through - * ([ConcordCommunityListEntry.inviteRef]): the community keeps publishing its - * bundle at that same addressable coordinate, re-minted at the current epoch. So - * a member who re-resolves their own join link and finds a **higher** epoch than - * the one they hold knows they were left behind, and can merge forward. - * - * This object holds only the pure decision + merge; fetching and unlocking the - * bundle at the link is the caller's job. + * What a bundle may NOT do is move the base (CORD-06 §2, and the reference client's + * `isCatchUpBundle`: "It may never move the base"). Nothing binds `community_root` + * to `community_id`, so a bundle is not proof of continuity: a link creator — or + * anyone who ever held a link's signer — could serve a higher-epoch bundle carrying + * a root of their own and silently relocate every member who joined through that + * link onto streams they read. The base advances only by a CORD-06 §2 rekey blob + * whose `prevcommit` proves it extends the key we hold, from a rotator our roster + * authorizes. So this object only **detects** ([isStranded]); the background sweep + * never adopts anything, and the one way forward from a bundle is the user + * explicitly accepting the link again ([rejoinForward]) — the same trust decision + * as the first join, never taken on their behalf. */ object ConcordStrandedRecovery { /** - * True when [bundle], resolved at [entry]'s stored invite link, proves we were + * True when [bundle], resolved at [entry]'s stored invite link, says we were * left behind: it must describe the same community and sit at a strictly higher * epoch. Same or lower is a no-op (we are current, or the bundle is stale). * * [bannedAtCurrentEpoch] is the caller's answer to "does the community, as I fold - * it right now, have me on its banlist?" — and a `true` refuses the recovery - * outright. It is a required argument rather than a caller-side `if` because - * getting it wrong turns this mechanism inside out: recovery exists so a member - * *wrongly* omitted from a rotation can catch up, but the test it performs (a - * higher epoch at a link whose unlock token an ex-member keeps forever) cannot - * tell that member apart from one the community deliberately removed. Without - * this, a Refounding — the only hard removal Concord has — is undone by our own - * background sweep a few minutes later. + * it right now, have me on its banlist?" — and a `true` answers false outright: + * a removed member is not stranded, they are removed. */ fun isStranded( entry: ConcordCommunityListEntry, @@ -68,21 +67,18 @@ object ConcordStrandedRecovery { bundle.rootEpoch > entry.rootEpoch /** - * Merges [entry] forward onto the higher-epoch [bundle], or returns null when - * there is nothing to do ([isStranded] is false) — so the caller can treat null - * as "stay put" without a second check. + * The entry that results from the user **explicitly** re-accepting the invite + * link [bundle] was resolved from, while stranded on [entry] — or null when + * [isStranded] is false. Never call this from a background sweep: adopting a + * bundle's root is a join decision (see the class note), and only the user can + * make it. * - * The merge is epoch-monotonic (it never moves backwards, by construction of - * [isStranded]) and preserves two things the naive "adopt the bundle" would - * destroy: - * - * - the [ConcordCommunityListEntry.inviteRef] anchor, so the next Refounding we - * are left out of is recoverable too; and - * - the existing [ConcordCommunityListEntry.heldRoots], plus the root we are - * leaving, so prior-epoch history the member legitimately holds stays - * derivable instead of going dark on catch-up. + * The merge is epoch-monotonic and preserves what a fresh join would lose: the + * [ConcordCommunityListEntry.inviteRef] anchor, and the existing + * [ConcordCommunityListEntry.heldRoots] plus the root we are leaving, so + * prior-epoch history stays derivable. */ - fun mergeForward( + fun rejoinForward( entry: ConcordCommunityListEntry, bundle: CommunityInvite, bannedAtCurrentEpoch: Boolean, @@ -92,7 +88,7 @@ object ConcordStrandedRecovery { // Bank the epoch we are leaving with its control_pk, so its Control Plane // stays re-subscribable for the anti-rollback floor (a split epoch's address // is held, never derivable — CORD-02 §2). - val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch } + val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root, entry.controlPk, entry.controlRoot)).distinctBy { it.epoch to it.key.lowercase() } return ConcordCommunityListEntry( id = entry.id, @@ -109,10 +105,8 @@ object ConcordStrandedRecovery { name = entry.name.ifEmpty { bundle.name }, addedAt = entry.addedAt, inviteRef = entry.inviteRef, - // We were excluded from the epoch we were sitting on when we found the gap. - excludedAtEpoch = entry.rootEpoch, // Unknown keys another client wrote are data we hold in trust: carry them forward, - // or this recovery write silently deletes them. + // or this write silently deletes them. residue = entry.residue, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 74fb73b594..34472a9f1b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt @@ -21,7 +21,10 @@ package com.vitorpamplona.quartz.concord.cord06Rekey import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys import com.vitorpamplona.quartz.concord.crypto.GroupKey @@ -33,6 +36,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.RandomInstance /** * The events a Refounding produces (CORD-06 §3): the [controlWraps] (the current @@ -74,11 +78,51 @@ class ReceivedRefounding( val rotator: HexKey, val newControlPk: ByteArray? = null, val newControlRoot: ByteArray? = null, + /** + * The Grant the rotator claims to act under (`vac`, CORD-06 §3 "Authority"), null when absent + * (the owner cites nothing). The caller verifies it against its fold before adopting — see + * [ConcordRotationAuthority.citationSatisfied]. + */ + val authority: AuthorityCitation? = null, ) { /** True when this was a legacy pre-split rotation (72-byte base blob). */ val legacy: Boolean get() = newControlPk == null } +/** + * A Refounding the rotator has already minted keys for (CORD-06 §3 "Failure and races"): the + * fresh [newRoot] + [newControlRoot] reserved for the rotation from ([rootEpoch], [prevCommit]). + * A retry of the same rotation MUST reuse them — rotations correlate by (rotator, newepoch, + * prevcommit), so a retry with a fresh root would merge into the first attempt's set and split + * the members across two roots at one epoch. Keep it until the rotation is adopted, then drop it. + */ +class PendingRefounding( + val communityId: HexKey, + val rootEpoch: Long, + val prevCommit: HexKey, + val newRoot: ByteArray, + val newControlRoot: ByteArray, +) { + /** True when this reservation is for the rotation that leaves [priorRoot] at [rootEpoch]. */ + fun matches( + communityId: HexKey, + rootEpoch: Long, + priorRoot: ByteArray, + ): Boolean = + this.communityId.equals(communityId, ignoreCase = true) && + this.rootEpoch == rootEpoch && + prevCommit == ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() +} + +/** + * Thrown when a Refounding cannot carry the whole Control Plane into the new epoch (CORD-06 §3: + * "If the Refounder cannot reliably fold all Control events, the Refounding must be aborted"). + * [missing] names the entities (`eid` hex) whose honored head is not in the compaction. + */ +class IncompleteControlPlaneException( + val missing: List, +) : IllegalStateException("Refounding aborted: the Control Plane could not be folded in full (${missing.size} entity head(s) missing)") + /** * Whole-community Refounding (CORD-06 §3): rotate `community_root` to sever a * removed member absolutely. Public Channels and the Control/Guestbook planes all @@ -111,6 +155,11 @@ object ConcordRefounding { * @param recipientsXOnly the retained members' x-only pubkeys (hex) to re-key * @param staffXOnly the subset of [recipientsXOnly] that is staff (owner + Control-writing * permission holders, CORD-04 §3) and receives the 136-byte blob + * @param authority the rotator's `vac` citation (CORD-06 §3 "Authority"), stamped on every + * rekey chunk; null when the owner rotates + * @param mustCarry the entity heads (`eid` hex -> version) the rotator currently honors; the + * compaction must carry each at or above that version, or the Refounding + * aborts with [IncompleteControlPlaneException] (CORD-06 §3) */ suspend fun build( rotatorSigner: NostrSigner, @@ -125,11 +174,15 @@ object ConcordRefounding { staffXOnly: Set, createdAt: Long, ownerPubKey: HexKey, + authority: AuthorityCitation? = null, + mustCarry: Map = emptyMap(), ): RefoundingBuild { val newEpoch = rootEpoch + 1 val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) - val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey) + // Acquired in full BEFORE anything is published (CORD-06 §3): throws when the plane cannot be + // carried whole, so a failed fold never leaves a half rotation as the only copy. + val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey, mustCarry) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() @@ -146,6 +199,7 @@ object ConcordRefounding { prevEpoch = rootEpoch, prevCommit = prevCommit, createdAt = createdAt, + authority = authority, ) return RefoundingBuild(newRoot, newControlRoot, newEpoch, newControlKeys, controlWraps, rekeyWraps) @@ -163,19 +217,28 @@ object ConcordRefounding { * must hold the new signer. A Rotator MUST NOT mirror editions to the new * epoch's legacy-derived address to appease stale readers — the mirror * re-opens exactly the member-writable surface the split closes. + * + * Only plaintext-sealed editions are carried ([ControlEdition.fromOpened]): an + * encrypted-seal edition is not a Control edition (CORD-02 §5), and re-wrapping one + * would republish it under a signature over ciphertext no reader can keep. Heads of + * sub-kinds this client does not model (Pins, Signals, anything newer) ride through + * verbatim like every other head, so our Refounding never erases another client's state + * (CORD-06 §3: the compaction re-wraps each entity's current head). */ fun compactControlPlane( priorWraps: List, priorControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys, + communityId: ByteArray, ownerPubKey: HexKey, + mustCarry: Map = emptyMap(), ): List { // entity coordinate -> every edition we can open, paired with its verified seal. val byCoordinate = HashMap>>() for (wrap in priorWraps) { val opened = ConcordStreamEnvelope.openOrNull(wrap, priorControlKeys) ?: continue - val edition = ControlEdition.fromRumor(opened.rumor) ?: continue - val coord = edition.entityKind.wire + ":" + edition.entityIdHex + val edition = ControlEdition.fromOpened(opened) ?: continue + val coord = edition.vsk + ":" + edition.entityIdHex byCoordinate.getOrPut(coord) { ArrayList() }.add(edition to opened.seal) } @@ -195,21 +258,48 @@ object ConcordRefounding { // unprivileged author, and it is exactly what ConcordCommunityState.fold would seat, so the // compacted epoch starts where the previous one left off. val editions = byCoordinate.values.flatten() - val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, ownerPubKey) + val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, communityId, ownerPubKey) val out = ArrayList(honored.size) - for ((_, floor) in honored) { - val head = floor.known ?: continue - val seal = editions.firstOrNull { it.first.rumorId == head.rumorId }?.second ?: continue + val missing = ArrayList() + for ((entity, floor) in honored) { + val head = floor.known + val seal = head?.let { h -> editions.firstOrNull { it.first.rumorId == h.rumorId }?.second } + if (seal == null) { + // A head we honor whose signed seal we cannot re-wrap would silently drop the entity + // from the new epoch: abort instead (fold-all-or-abort, CORD-06 §3). + missing.add(entity) + continue + } out.add(ConcordStreamEnvelope.wrapSeal(seal, newControlKeys, createdAt = seal.createdAt)) } + // Every head the caller already folds must survive at or above the version it honors: a + // shorter compaction means the fetch we compacted from was partial, and publishing it would + // roll the community back for every member who follows the new epoch. + for ((entity, version) in mustCarry) { + val carried = honored[entity]?.known?.version + if (carried == null || carried < version) missing.add(entity) + } + if (missing.isNotEmpty()) throw IncompleteControlPlaneException(missing.distinct()) return out } + /** + * The version of every entity head [editions] honor (`eid` hex -> version) — what a + * Refounder passes as `mustCarry`, so the compaction aborts rather than drop a head the + * Refounder itself folds (CORD-06 §3). + */ + fun headVersions( + editions: Collection, + communityId: ByteArray, + ownerPubKey: HexKey, + ): Map = ConcordCommunityState.authorizedHeads(editions, communityId, ownerPubKey).mapValues { it.value.version } + /** * Mints the base-rotation rekey blobs delivering [newRoot] + [newControlPk] to * [recipientsXOnly] — the [staffXOnly] subset also receiving [newControlRoot] - * in the 136-byte staff form (CORD-06 §1) — chunked at - * [ConcordRekey.MAX_BLOBS_PER_CHUNK] and wrapped (encrypted seal, + * in the 136-byte staff form (CORD-06 §1) — chunked by count and bytes + * ([ConcordRekey.chunkBlobs], 1-based `chunk` tags, [authority] cited on every + * chunk) and wrapped (encrypted seal, * rotator-signed) on the [baseRekeyKey] address so every current member — who * precomputes that address from the prior root — receives it live. */ @@ -225,6 +315,7 @@ object ConcordRefounding { prevEpoch: Long, prevCommit: HexKey, createdAt: Long, + authority: AuthorityCitation? = null, ): List { if (recipientsXOnly.isEmpty()) return emptyList() val staffLower = staffXOnly.mapTo(HashSet()) { it.lowercase() } @@ -240,10 +331,19 @@ object ConcordRefounding { newControlRoot = if (recipient.lowercase() in staffLower) newControlRoot else null, ) } - val chunks = blobs.chunked(ConcordRekey.MAX_BLOBS_PER_CHUNK) + // The envelope is measured once at the widest `chunk` tag this rotation could carry. + val widest = blobs.size.coerceAtLeast(1) + val envelopeTags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, widest, widest, authority) + val envelope = + RumorAssembler + .assembleRumor(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, envelopeTags, "") + .toJson() + .encodeToByteArray() + .size + val chunks = ConcordRekey.chunkBlobs(blobs, envelope) val total = chunks.size return chunks.mapIndexed { index, chunk -> - val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index, total) + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, prevEpoch, prevCommit, index + 1, total, authority) val rumor = RumorAssembler.assembleRumor(rotatorSigner.pubKey, createdAt, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(chunk)) ConcordStreamEnvelope.wrap(rumor, baseRekeyKey, rotatorSigner, encrypted = true, createdAt = createdAt) } @@ -252,14 +352,25 @@ object ConcordRefounding { /** * Receives a base rotation for the member behind [recipientSigner]: opens the * kind-3303 [wraps] at the member's next base-rekey address ([baseRekeyKey]), - * verifies each is a well-formed root rotation to [newEpoch] whose `prevcommit` - * continues the [priorRoot] the member holds, and returns the delivered new - * root and Control Plane keys (with the rotator's real pubkey, so the caller - * can authorize it against the folded roster). A staff blob's delivered secret - * must derive to exactly the delivered `control_pk` (CORD-02 §5) — a - * mismatched pair is refused rather than adopting a plane split from its - * readers. Null if no chunk carries this member's blob — which only means - * "removed" once the caller confirms it holds every chunk of the rotation. + * verifies each is a well-formed root rotation to `rootEpoch + 1` whose + * `prevepoch`/`prevcommit` continue the [priorRoot] the member holds, and returns + * the delivered new root and Control Plane keys with the rotator's real pubkey and + * `vac` citation, so the caller can authorize it against the folded roster. + * + * A rekey rumor must ride an **encrypted** (20013) seal (CORD-02 §5, Appendix B); a + * malformed `chunk` tag (0-based, `i > n`, non-decimal) or `vac` tag drops the + * chunk, and a rotator whose chunks cite different Grants is distrusted whole. A + * staff blob's delivered secret must derive to exactly the delivered `control_pk` + * (CORD-02 §5) — a mismatched pair is refused rather than adopting a plane split + * from its readers. + * + * Race convergence (CORD-06 §3): among the candidates [accept] admits (the + * caller's authority check — authorize BEFORE converging, or an unauthorized + * lower root would win), the lexicographically lowest new base key wins; the + * control pair rides the winner's blob and is never compared. + * + * Null if no chunk carries this member's blob — which only means "removed" once + * the caller confirms it holds every chunk of the rotation. */ suspend fun findNewRoot( wraps: List, @@ -268,31 +379,138 @@ object ConcordRefounding { communityId: ByteArray, priorRoot: ByteArray, rootEpoch: Long, - ): ReceivedRefounding? { + accept: (ReceivedRefounding) -> Boolean = { true }, + ): ReceivedRefounding? = converge(findNewRoots(wraps, baseRekeyKey, recipientSigner, communityId, priorRoot, rootEpoch).filter(accept)) + + /** + * Every candidate rotation delivering this member a new root from [priorRoot] at + * [rootEpoch] (one per rotator; see [findNewRoot] for the checks), unauthorized and + * unconverged. Callers normally want [findNewRoot]. + */ + suspend fun findNewRoots( + wraps: List, + baseRekeyKey: GroupKey, + recipientSigner: NostrSigner, + communityId: ByteArray, + priorRoot: ByteArray, + rootEpoch: Long, + ): List { val newEpoch = rootEpoch + 1 val expectedScope = ConcordRekey.ROOT_SCOPE.toHexKey() val expectedCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() + + // Pass 1: the well-formed chunks of this continuity point, grouped by rotator. + val byRotator = LinkedHashMap>() for (wrap in wraps) { val opened = ConcordStreamEnvelope.openOrNull(wrap, baseRekeyKey) ?: continue + // Rekey seals are encrypted (CORD-02 §5): a plaintext seal would expose the rotator. + if (opened.sealKind != ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED) continue val rumor = opened.rumor if (rumor.kind != ConcordRekey.KIND) continue - if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE) != expectedScope) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_SCOPE)?.lowercase() != expectedScope) continue if (rumor.tags.firstTagValue(ConcordRekey.TAG_NEWEPOCH)?.toLongOrNull() != newEpoch) continue - if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT) != expectedCommit) continue - - val blobs = ConcordRekey.decodeContent(rumor.content) - val rotatorXOnly = opened.author.hexToByteArray() - val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue - val controlRoot = payload.newControlRoot - val controlPk = payload.newControlPk - if (controlRoot != null && controlPk != null) { - // The staff derive-check (CORD-06 §1): refuse a pair whose secret does not - // derive to the pk the other members were handed — fails closed. - val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey - if (!derived.contentEquals(controlPk)) continue - } - return ReceivedRefounding(payload.newKey, newEpoch, opened.author, controlPk, controlRoot) + if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVEPOCH)?.toLongOrNull() != rootEpoch) continue + if (rumor.tags.firstTagValue(ConcordRekey.TAG_PREVCOMMIT)?.lowercase() != expectedCommit) continue + if (ConcordRekey.chunkOf(rumor.tags) == null) continue + // A present-but-malformed citation is a corrupt chunk; absent means the owner acts. + val vacTag = rumor.tags.firstOrNull { it.isNotEmpty() && it[0] == VacTag.TAG_NAME } + val citation = if (vacTag == null) null else VacTag.parse(vacTag) ?: continue + byRotator.getOrPut(opened.author.lowercase()) { ArrayList() }.add(RekeyChunk(opened.author, rumor.content, citation)) } - return null + + // Pass 2: per rotator, find this member's blob. + val out = ArrayList() + for ((_, chunks) in byRotator) { + // Every chunk of one rotation must cite the same Grant; a disagreeing set is distrusted. + val citations = chunks.map { c -> c.citation?.let { VacTag.assemble(it).joinToString(",") } }.distinct() + if (citations.size > 1) continue + val rotator = chunks.first().rotator + val rotatorXOnly = rotator.hexToByteArray() + for (chunk in chunks) { + val blobs = ConcordRekey.decodeContent(chunk.content) + val payload = ConcordRekey.findPayloadWithSigner(blobs, recipientSigner, rotatorXOnly, ConcordRekey.ROOT_SCOPE, newEpoch) ?: continue + val controlRoot = payload.newControlRoot + val controlPk = payload.newControlPk + if (controlRoot != null && controlPk != null) { + // The staff derive-check (CORD-06 §1): refuse a pair whose secret does not + // derive to the pk the other members were handed — fails closed. + val derived = ConcordKeyDerivation.controlSignerKey(controlRoot, communityId, newEpoch).publicKey + if (!derived.contentEquals(controlPk)) continue + } + out.add(ReceivedRefounding(payload.newKey, newEpoch, rotator, controlPk, controlRoot, chunk.citation)) + break + } + } + return out + } + + private class RekeyChunk( + val rotator: HexKey, + val content: String, + val citation: AuthorityCitation?, + ) + + /** + * The winner among authorized candidates racing to one epoch (CORD-06 §3): the + * lexicographically lowest new base key. Every client computes the same winner, so + * concurrent Refoundings converge; null on no candidates. + */ + fun converge(candidates: List): ReceivedRefounding? = candidates.minWithOrNull { a, b -> compareKeys(a.newRoot, b.newRoot) } + + /** Unsigned lexicographic order of two keys — the order the convergence rule compares in. */ + fun compareKeys( + a: ByteArray, + b: ByteArray, + ): Int { + for (i in 0 until minOf(a.size, b.size)) { + val x = a[i].toInt() and 0xFF + val y = b[i].toInt() and 0xFF + if (x != y) return x - y + } + return a.size - b.size + } + + /** + * The down-only heal (CORD-06 §3): true when [candidate] should replace the [held] root + * of the same epoch — only a **strictly lower** sibling does, so a flaky fetch that + * returns only the higher sibling can never re-fork a settled epoch. + */ + fun healsTo( + held: ByteArray, + candidate: ByteArray, + ): Boolean = compareKeys(candidate, held) < 0 + + /** + * The held roots a client folds Control from: per epoch, the lowest key — the one the + * convergence rule settled on. A higher sibling at the same epoch is a losing fork's root, + * kept only so the messages sent into that fork stay readable (CORD-06 §3: "Both forks' + * keys are retained"); its Control Plane is not the community's. + */ + fun canonicalHeldRoots(heldRoots: List): List = + heldRoots + .groupBy { it.epoch } + .values + .mapNotNull { sameEpoch -> sameEpoch.minWithOrNull { a, b -> a.key.lowercase().compareTo(b.key.lowercase()) } } + + /** + * The keys for the Refounding that leaves [priorRoot] at [rootEpoch]: [pending] when it + * was reserved for exactly this rotation (a retry — CORD-06 §3 requires every step to be + * idempotent, so a retry must re-deliver the SAME root), a fresh pair otherwise. The + * caller persists the result before publishing anything and drops it once adopted. + */ + fun reserveKeys( + pending: PendingRefounding?, + communityId: HexKey, + rootEpoch: Long, + priorRoot: ByteArray, + ): PendingRefounding { + if (pending != null && pending.matches(communityId, rootEpoch, priorRoot)) return pending + return PendingRefounding( + communityId = communityId.lowercase(), + rootEpoch = rootEpoch, + prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey(), + newRoot = RandomInstance.bytes(32), + newControlRoot = RandomInstance.bytes(32), + ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt index 84d9c1910d..7e4774ee5d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekey.kt @@ -20,7 +20,9 @@ */ package com.vitorpamplona.quartz.concord.cord06Rekey +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.ConcordJson +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray @@ -85,7 +87,13 @@ object ConcordRekey { return RekeyBlob(locator, wrapped) } - /** The kind-3303 rumor tags for a rekey chunk. */ + /** + * The kind-3303 rumor tags for a rekey chunk. [chunkIndex] is **1-based** (CORD-06 §2's + * "chunk i of n"; the reference client refuses an index below 1, so a 0-based chunk makes the + * whole rotation unreadable to it). [authority] is the rotator's `vac` citation (CORD-06 §3 + * "Authority", CORD-04 §5), carried on EVERY chunk so a partial holder can judge authority; + * null when the owner rotates. + */ fun tags( scopeId: ByteArray, newEpoch: Long, @@ -93,14 +101,78 @@ object ConcordRekey { prevCommit: HexKey, chunkIndex: Int, chunkTotal: Int, - ): Array> = - arrayOf( - arrayOf(TAG_SCOPE, scopeId.toHexKey()), - arrayOf(TAG_NEWEPOCH, newEpoch.toString()), - arrayOf(TAG_PREVEPOCH, prevEpoch.toString()), - arrayOf(TAG_PREVCOMMIT, prevCommit), - arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()), - ) + authority: AuthorityCitation? = null, + ): Array> { + require(chunkTotal >= 1 && chunkIndex in 1..chunkTotal) { "chunk must be 1..n, was $chunkIndex of $chunkTotal" } + val base = + arrayOf( + arrayOf(TAG_SCOPE, scopeId.toHexKey()), + arrayOf(TAG_NEWEPOCH, newEpoch.toString()), + arrayOf(TAG_PREVEPOCH, prevEpoch.toString()), + arrayOf(TAG_PREVCOMMIT, prevCommit), + arrayOf(TAG_CHUNK, chunkIndex.toString(), chunkTotal.toString()), + ) + return if (authority == null) base else base + arrayOf(VacTag.assemble(authority)) + } + + /** Strict decimal (`0|[1-9][0-9]*`): digits only, no sign, exponent, radix prefix, padding or leading zero. */ + private fun strictDecimal(value: String?): Int? { + if (value.isNullOrEmpty() || value.length > 9 || !value.all { it in '0'..'9' }) return null + if (value.length > 1 && value[0] == '0') return null + return value.toInt() + } + + /** + * The `["chunk", i, n]` position of a kind-3303 rumor as a 1-based `(i, n)` pair, or null when + * the tag is malformed: non-decimal, `i < 1`, `n < 1` or `i > n` (a 0-based chunk included). + * An absent tag reads as the single chunk `(1, 1)`, as the reference client does. + */ + fun chunkOf(tags: Array>): Pair? { + val tag = tags.firstOrNull { it.isNotEmpty() && it[0] == TAG_CHUNK } ?: return 1 to 1 + val index = strictDecimal(tag.getOrNull(1)) ?: return null + val count = strictDecimal(tag.getOrNull(2)) ?: return null + if (index < 1 || count < 1 || index > count) return null + return index to count + } + + /** + * Splits [blobs] into chunks that each fit one kind-3303 rumor: at most + * [MAX_BLOBS_PER_CHUNK] blobs AND a rumor JSON of at most [REKEY_RUMOR_MAX_BYTES], given the + * [envelopeBytes] the rumor costs with an empty content (measure it at the widest `chunk` tag + * the rotation can carry — over-reserving only makes chunks smaller). Mirrors the reference + * client's budget byte for byte: the content's own `[]`, one comma between blobs, and each + * blob at its JSON-escaped length inside the content string. A lone over-budget blob is kept + * (blobs are indivisible). Always yields at least one (possibly empty) chunk. + */ + fun chunkBlobs( + blobs: List, + envelopeBytes: Int, + ): List> { + val budget = REKEY_RUMOR_MAX_BYTES - envelopeBytes + val chunks = ArrayList>() + var current = ArrayList() + var used = 2 // the content's own "[]" + for (blob in blobs) { + val cost = escapedJsonLength(blob) + if (current.isNotEmpty() && (current.size >= MAX_BLOBS_PER_CHUNK || used + 1 + cost > budget)) { + chunks.add(current) + current = ArrayList() + used = 2 + } + used += cost + (if (current.isNotEmpty()) 1 else 0) + current.add(blob) + } + if (current.isNotEmpty() || chunks.isEmpty()) chunks.add(current) + return chunks + } + + /** A blob's byte length inside the rumor's JSON-escaped `content` string (without outer quotes). */ + private fun escapedJsonLength(blob: RekeyBlob): Int { + val raw = encodeContent(listOf(blob)).let { it.substring(1, it.length - 1) } + var extra = 0 + for (c in raw) if (c == '"' || c == '\\') extra++ + return raw.encodeToByteArray().size + extra + } /** Serializes a chunk's blobs into the kind-3303 rumor content. */ fun encodeContent(blobs: List): String = ConcordJson.instance.encodeToString(ListSerializer(RekeyBlob.serializer()), blobs) @@ -118,6 +190,16 @@ object ConcordRekey { /** CORD-06 §1: a single kind-3303 event carries at most this many per-recipient blobs. */ const val MAX_BLOBS_PER_CHUNK = 120 + /** + * Byte ceiling on one kind-3303 rumor's JSON, beside the 120-blob count cap. Base blobs are + * wider than channel ones, and 120 of them pushed the wrap's NIP-44 plaintext (the seal, which + * carries the rumor's own NIP-44 ciphertext as base64) past the 65,535-byte cap. 40,960 is the + * top of the NIP-44 padding bucket that still wraps — the reference client's + * `REKEY_RUMOR_MAX_BYTES`. Capacity: 120 blobs at 72 bytes (the count cap binds), 99 at 104, + * 90 at 136. Finer chunking is always wire-legal. + */ + const val REKEY_RUMOR_MAX_BYTES = 40_960 + /** * Builds a rekey blob for one recipient using [rotatorSigner] instead of a raw * private key, so a NIP-46 bunker rotator can mint blobs without exposing its diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt new file mode 100644 index 0000000000..1fe360ba89 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt @@ -0,0 +1,95 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * The `vac` authority citation on a rotation (CORD-06 §3 "Authority", CORD-04 §5): a rotation + * cites the Grant its rotator acts under like any authority action, so a just-demoted admin's + * rotation is never honored by a lagging client. The owner cites nothing — the `community_id` + * proves them. + * + * The citation is a *sync floor*, not the verdict: a verifier refuses the rotation until it + * holds at least the cited Grant edition, then resolves the rotator's rank against its current + * roster (the caller's `hasPermission(BAN)` check). Mirrors the reference client's + * `citationSatisfied`: a newer Grant head than the cited one satisfies, the same version must + * match the edition hash (a fork is refused), an older head parks the rotation (fail closed). + * + * [heads] is the authority-gated head of every Control entity keyed by `eid` hex — exactly + * [ConcordCommunityState.authorizedHeads] over the current epoch's editions ([headsOf]). + */ +object ConcordRotationAuthority { + /** The authority-gated entity heads a citation is minted from and checked against. */ + fun headsOf( + editions: Collection, + communityIdHex: HexKey, + ownerPubKey: HexKey, + ): Map = ConcordCommunityState.authorizedHeads(editions, communityIdHex.hexToByteArray(), ownerPubKey) + + /** + * The citation [actor] puts on a rotation: their own Grant's current head, or null for the + * owner (who cites nothing) and for an actor with no Grant (whose rotation nobody honors). + */ + fun citationFor( + communityIdHex: HexKey, + actor: HexKey, + ownerPubKey: HexKey, + heads: Map, + ): AuthorityCitation? { + if (actor.equals(ownerPubKey, ignoreCase = true)) return null + val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray()) + val head = heads[eid.toHexKey()] ?: return null + val hash = head.hashHex.hexToByteArrayOrNull() ?: return null + return AuthorityCitation(eid, head.version, hash) + } + + /** Whether [citation] authorizes [actor]'s rotation under the verifier's [heads]. */ + fun citationSatisfied( + communityIdHex: HexKey, + actor: HexKey, + ownerPubKey: HexKey, + citation: AuthorityCitation?, + heads: Map, + ): Boolean { + if (actor.equals(ownerPubKey, ignoreCase = true)) return true + if (citation == null) return false + // Must name the actor's OWN Grant coordinate. + val eid = ConcordKeyDerivation.grantCoordinate(communityIdHex.hexToByteArray(), actor.lowercase().hexToByteArray()).toHexKey() + if (citation.grantId.toHexKey() != eid) return false + val head = heads[eid] ?: return false + return when { + head.version > citation.grantVersion -> true + // At exactly it: the hash must match our fold's winner, not a fork. + head.version == citation.grantVersion -> head.hashHex.equals(citation.grantHash.toHexKey(), ignoreCase = true) + // Behind it: park until the Grant arrives. + else -> false + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index 6ff9df015d..36958c3b18 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt @@ -226,6 +226,25 @@ object ConcordKeyDerivation { memberXOnly: ByteArray, ): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.GRANT, memberXOnly)) + /** + * The dissolution tombstone address for a community (CORD-02 §9, A.6): + * `group_key("concord/dissolved", community_id, 0…0)`, no epoch. + * + * Derived from the public `community_id` alone, so every member past or present finds + * the same grave whatever epoch they hold — and so can anyone else, which is why a + * tombstone read here is honored only when owner-signed and bound to this id. + */ + fun dissolvedPlaneKey(communityId: ByteArray): GroupKey = groupKey(ConcordLabels.DISSOLVED, communityId, ByteArray(32)) + + /** + * A Channel's Pin List entity id (CORD-04 §7, A.6): `hkdf32(communityId, "concord/pins" ‖ 0x00 ‖ + * channel_id)`. Derived from the community id, so a list binds to its Community by construction. + */ + fun pinsCoordinate( + communityId: ByteArray, + channelId: ByteArray, + ): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.PINS, channelId)) + /** The community-wide Banlist entity id: `hkdf32(communityId, "concord/banlist" ‖ 0x00 ‖ ZERO32)`. */ fun banlistCoordinate(communityId: ByteArray): ByteArray = hkdf32(communityId, buildInfo(ConcordLabels.BANLIST, ByteArray(32))) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt index dcdd50c616..9b29fb4774 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordLabels.kt @@ -57,6 +57,9 @@ object ConcordLabels { /** Banlist coordinate derivation (CORD-04). */ const val BANLIST = "concord/banlist" + /** A Channel's Pin List coordinate (CORD-04 §7). */ + const val PINS = "concord/pins" + /** Invite-link coordinate derivation (CORD-05). */ const val INVITE_LINKS = "concord/invite-links" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt index 7975d3aa37..a2bda26d86 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelope.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.crypto.verifyId import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip44Encryption.Nip44 +import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import com.vitorpamplona.quartz.utils.TimeUtils /** @@ -79,7 +80,7 @@ object ConcordStreamEnvelope { ): Event { val content = if (encrypted) { - Nip44.v2.encrypt(rumor.toJson(), stream.conversationKey).encodePayload() + encryptChecked(rumor.toJson(), stream.conversationKey) } else { rumor.toJson() } @@ -115,7 +116,7 @@ object ConcordStreamEnvelope { createdAt: Long = TimeUtils.now(), ): Event { val streamSigner = NostrSignerSync(KeyPair(privKey = signerKey.secretKey)) - val content = Nip44.v2.encrypt(seal.toJson(), readConversationKey).encodePayload() + val content = encryptChecked(seal.toJson(), readConversationKey) val ephemeralP = KeyPair().pubKey.toHexKey() val kind = if (ephemeral) KIND_WRAP_EPHEMERAL else KIND_WRAP return streamSigner.signNormal(createdAt, kind, arrayOf(arrayOf("p", ephemeralP)), content) @@ -200,7 +201,7 @@ object ConcordStreamEnvelope { } require(wrap.verify()) { "Wrap signature/id is invalid" } - val seal = Event.fromJson(Nip44.v2.decrypt(wrap.content, readConversationKey)) + val seal = Event.fromJson(decryptChecked(wrap.content, readConversationKey)) require(seal.kind == KIND_SEAL_ENCRYPTED || seal.kind == KIND_SEAL_PLAINTEXT) { "Not a Concord seal: kind ${seal.kind}" } @@ -208,7 +209,7 @@ object ConcordStreamEnvelope { val rumorJson = if (seal.kind == KIND_SEAL_ENCRYPTED) { - Nip44.v2.decrypt(seal.content, readConversationKey) + decryptChecked(seal.content, readConversationKey) } else { seal.content } @@ -257,6 +258,47 @@ object ConcordStreamEnvelope { ): OpenedStreamEvent? = openOrNull(wrap, keys.address, keys.readKey.conversationKey) private val EMPTY_TAGS = emptyArray>() + + /** + * NIP-44's hard plaintext cap (CORD-02 Appendix B). Every layer of a Concord event is a NIP-44 + * plaintext, and the spec makes enforcing the cap each implementation's job: quartz's NIP-44 + * silently switches to its extended (u32-prefixed) format past it, which strict readers — + * the reference client among them — cannot decrypt. + */ + const val NIP44_MAX_PLAINTEXT = 65_535 + + /** The largest standard-format NIP-44 v2 ciphertext: the u16 prefix plus the 64 KiB pad bucket. */ + private const val MAX_STANDARD_CIPHERTEXT = 2 + 65_536 + + /** base64 of version (1) + nonce (32) + [MAX_STANDARD_CIPHERTEXT] + mac (32): anything longer is not standard NIP-44. */ + private const val MAX_STANDARD_PAYLOAD = 87_472 + + /** + * NIP-44 v2 encrypt that refuses a plaintext over [NIP44_MAX_PLAINTEXT] UTF-8 bytes instead of + * minting an extended-format payload (the reference client's `encryptChecked`). + */ + private fun encryptChecked( + plaintext: String, + conversationKey: ByteArray, + ): String { + val size = plaintext.encodeToByteArray().size + require(size <= NIP44_MAX_PLAINTEXT) { "Concord plaintext is $size bytes, over the NIP-44 cap of $NIP44_MAX_PLAINTEXT (CORD-02 Appendix B)" } + return Nip44.v2.encrypt(plaintext, conversationKey).encodePayload() + } + + /** + * NIP-44 v2 decrypt that only accepts the standard format: a payload or ciphertext too large + * for the u16 length prefix is the extended format, which no strict Concord client can read + * and none of ours ever writes, so it is refused before any decryption work. + */ + private fun decryptChecked( + payload: String, + conversationKey: ByteArray, + ): String { + val info = Nip44v2.EncryptedInfo.decodePayload(payload, MAX_STANDARD_PAYLOAD) + require(info.ciphertext.size <= MAX_STANDARD_CIPHERTEXT) { "Extended-format NIP-44 payload refused (CORD-02 Appendix B)" } + return Nip44.v2.decrypt(info, conversationKey) + } } /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index e122de8707..0c6598867e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -99,6 +99,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent 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.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent @@ -873,6 +874,7 @@ class EventFactory { RepostEvent.KIND -> RepostEvent(id, pubKey, createdAt, tags, content, sig) RequestToVanishEvent.KIND -> RequestToVanishEvent(id, pubKey, createdAt, tags, content, sig) ConcordCommunityListEvent.KIND -> ConcordCommunityListEvent(id, pubKey, createdAt, tags, content, sig) + ConcordCommunityListFragmentEvent.KIND -> ConcordCommunityListFragmentEvent(id, pubKey, createdAt, tags, content, sig) ControlEditionEvent.KIND -> ControlEditionEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteListEvent.KIND -> ConcordInviteListEvent(id, pubKey, createdAt, tags, content, sig) ConcordInviteBundleEvent.KIND -> ConcordInviteBundleEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt new file mode 100644 index 0000000000..c8c2a74146 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt @@ -0,0 +1,113 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-03 §2 channel fold rules: deletion is terminal across the whole accepted chain, and a + * Channel name is 1..64 UTF-8 bytes — an edition breaking the name rule is unauthorized and the + * fold falls back to the previous candidate (the reference client's channel gate). + */ +class ChannelFoldConformanceTest { + private val owner = "0f".repeat(32) + private val chan = "c1".repeat(32) + + private fun chained( + version: Long, + prev: ControlEdition?, + content: String, + author: String = owner, + ) = ControlEdition(ControlEntityKind.CHANNEL, chan.hexToByteArray(), version, prev?.hash, null, content, author, "r-$version", version) + + @Test + fun aDeletedChannelCannotBeResurrectedByALaterEdition() { + val c0 = chained(0, null, """{"name":"general"}""") + val c1 = chained(1, c0, """{"name":"general","deleted":true}""") + val c2 = chained(2, c1, """{"name":"general","deleted":false}""") + + val state = ControlFixtures.fold(listOf(c0, c1, c2), owner) + assertNull(state.channels[chan], "a deletion anywhere in the accepted chain is terminal") + } + + @Test + fun anUnauthorizedDeleteDoesNotRetireTheChannel() { + val troll = "77".repeat(32) + val c0 = chained(0, null, """{"name":"general"}""") + val forged = chained(1, c0, """{"name":"general","deleted":true}""", author = troll) + + val state = ControlFixtures.fold(listOf(c0, forged), owner) + assertEquals("general", state.channels[chan]?.definition?.name) + } + + @Test + fun anOverCapOrEmptyNameFallsBackToThePreviousEdition() { + val c0 = chained(0, null, """{"name":"general"}""") + val tooLong = chained(1, c0, """{"name":"${"x".repeat(65)}"}""") + assertEquals( + "general", + ControlFixtures + .fold(listOf(c0, tooLong), owner) + .channels[chan] + ?.definition + ?.name, + ) + + val empty = chained(1, c0, """{"name":""}""") + assertEquals( + "general", + ControlFixtures + .fold(listOf(c0, empty), owner) + .channels[chan] + ?.definition + ?.name, + ) + + // Exactly 64 bytes is fine — counted in UTF-8, so 16 four-byte emoji hit the cap too. + val atCap = chained(1, c0, """{"name":"${"x".repeat(64)}"}""") + assertEquals( + "x".repeat(64), + ControlFixtures + .fold(listOf(c0, atCap), owner) + .channels[chan] + ?.definition + ?.name, + ) + } + + @Test + fun theNameRuleCountsUtf8Bytes() { + val emoji = "😀" // 4 bytes in UTF-8 + assertTrue(ChannelEntity.isValidName(emoji.repeat(16))) + assertFalse(ChannelEntity.isValidName(emoji.repeat(16) + "a")) + assertFalse(ChannelEntity.isValidName("")) + assertTrue(ChannelEntity(name = "general").hasValidName()) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt index e42b0e0c79..10aa7012d8 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityFactoryTest.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotEquals import kotlin.test.assertNotNull @@ -87,7 +88,13 @@ class ConcordCommunityFactoryTest { val community = ConcordCommunityFactory.create(owner, name = "Gamers", createdAt = 1L, relays = listOf("wss://r.example")) - val state = ConcordCommunityState.fold(community.genesisEditions, community.ownerPubKey) + val state = ConcordCommunityState.fold(community.genesisEditions, community.communityId, community.ownerPubKey) + + // CORD-04 §1: versions start at 1. + community.genesisEditions.forEach { + assertEquals(1L, it.version) + assertEquals(null, it.prevHash) + } assertEquals("Gamers", state.metadata?.name) assertEquals(listOf("wss://r.example"), state.metadata?.relays) @@ -103,6 +110,16 @@ class ConcordCommunityFactoryTest { assertFalse(state.dissolved) } + @Test + fun refusesAGenesisPastTheMetadataCaps() = + runTest { + // CORD-02 §6: every reader drops metadata past 64 bytes of name / 10,000 of description, + // so a genesis past them would found a community with no metadata at all. + assertFailsWith { ConcordCommunityFactory.create(owner, "n".repeat(65), 1L) } + assertFailsWith { ConcordCommunityFactory.create(owner, "ok", 1L, description = "d".repeat(10_001)) } + assertEquals("n".repeat(64), ConcordCommunityFactory.create(owner, "n".repeat(64), 1L).let { ConcordCommunityState.fold(it.genesisEditions, it.communityId, it.ownerPubKey).metadata?.name }) + } + @Test fun differentCommunitiesFromSameOwnerHaveDistinctIds() = runTest { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt index e4ad7e8718..e789cb77fd 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.concord.cord02Community import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.ControlFixtures import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertEquals @@ -46,23 +47,23 @@ class ConcordCommunityStateTest { fun foldsMetadataChannelsRolesAndAuthority() { val editions = listOf( - edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"My Server","description":"hi"}"""), + edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"My Server","description":"hi"}"""), edition(ControlEntityKind.CHANNEL, "c1".repeat(32), """{"name":"general","private":false}"""), edition(ControlEntityKind.CHANNEL, "c2".repeat(32), """{"name":"voice-lounge","private":false,"voice":true}"""), edition(ControlEntityKind.CHANNEL, "c3".repeat(32), """{"name":"old","deleted":true}"""), edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"25"}"""), - edition(ControlEntityKind.GRANT, "ab".repeat(32), """{"member":"$alice","role_ids":["$adminRole"]}"""), + edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), """{"member":"$alice","role_ids":["$adminRole"]}"""), ) - val state = ConcordCommunityState.fold(editions, owner) + val state = ControlFixtures.fold(editions, owner) assertEquals("My Server", state.metadata?.name) assertEquals("hi", state.metadata?.description) - // deleted channel excluded; general + voice channel kept + // deleted channel excluded; the other two kept (a legacy `voice` key is just an unknown field) assertEquals(2, state.channels.size) assertEquals("general", state.channels["c1".repeat(32)]?.definition?.name) - assertTrue(state.channels["c2".repeat(32)]?.definition?.voice == true) + assertEquals("voice-lounge", state.channels["c2".repeat(32)]?.definition?.name) assertNull(state.channels["c3".repeat(32)]) assertNotNull(state.roles[adminRole]) @@ -79,7 +80,7 @@ class ConcordCommunityStateTest { @Test fun anUnauthorizedChannelOrMetadataEditionMidChainDoesNotOrphanTheEditionsAboveIt() { val chan = "c1".repeat(32) - val meta = "00".repeat(32) + val meta = ControlFixtures.COMMUNITY_ID_HEX val troll = "77".repeat(32) // holds no roles at all fun chained( @@ -99,20 +100,86 @@ class ConcordCommunityStateTest { val m1 = chained(ControlEntityKind.METADATA, meta, 1, m0, """{"name":"HACKED"}""", troll) val m2 = chained(ControlEntityKind.METADATA, meta, 2, m1, """{"name":"Renamed Server"}""", owner) - val state = ConcordCommunityState.fold(listOf(c0, c1, c2, m0, m1, m2), owner) + val state = ControlFixtures.fold(listOf(c0, c1, c2, m0, m1, m2), owner) assertEquals("renamed", state.channels[chan]?.definition?.name, "the owner's v2 rename must apply") assertEquals("Renamed Server", state.metadata?.name, "the owner's v2 metadata edit must apply") } + private fun chainedMeta( + eid: String, + version: Long, + prev: ControlEdition?, + content: String, + author: String = owner, + rumorId: String = "m-$eid-$version-$author", + ) = ControlEdition(ControlEntityKind.METADATA, eid.hexToByteArray(), version, prev?.hash, null, content, author, rumorId, version) + @Test - fun dissolutionTombstoneMarksCommunityDissolved() { + fun metadataAtAnyCoordinateButTheCommunityIdIsIgnored() { + // CORD-04 §1: the metadata entity's eid IS the community_id. A fresh chain minted at any + // other coordinate — even owner-signed, even at a far higher version — is not this + // community's metadata, so it can neither shadow the real chain nor bypass it (S8). + val real = chainedMeta(ControlFixtures.COMMUNITY_ID_HEX, 1, null, """{"name":"Real"}""") + val decoy = chainedMeta("99".repeat(32), 50, null, """{"name":"Decoy"}""") + assertEquals("Real", ControlFixtures.fold(listOf(real, decoy), owner).metadata?.name) + assertNull(ControlFixtures.fold(listOf(decoy), owner).metadata, "a decoy alone is no metadata at all") + } + + @Test + fun metadataPastTheNameOrDescriptionCapFallsBackToThePreviousEdition() { + // CORD-02 §6 caps are fold gates, as in the reference client: an edition past them is + // dropped and the entity keeps the edition below it. + val cid = ControlFixtures.COMMUNITY_ID_HEX + val v1 = chainedMeta(cid, 1, null, """{"name":"Fine"}""") + val longName = chainedMeta(cid, 2, v1, """{"name":"${"n".repeat(65)}"}""") + assertEquals("Fine", ControlFixtures.fold(listOf(v1, longName), owner).metadata?.name) + + // 64 bytes of UTF-8 is the cap, not 64 characters: 33 two-byte characters is 66 bytes. + val wideName = chainedMeta(cid, 2, v1, """{"name":"${"é".repeat(33)}"}""") + assertEquals("Fine", ControlFixtures.fold(listOf(v1, wideName), owner).metadata?.name) + + val longDescription = chainedMeta(cid, 2, v1, """{"name":"Fine2","description":"${"d".repeat(10_001)}"}""") + assertEquals("Fine", ControlFixtures.fold(listOf(v1, longDescription), owner).metadata?.name) + + val atTheCaps = chainedMeta(cid, 2, v1, """{"name":"${"n".repeat(64)}","description":"${"d".repeat(10_000)}"}""") + assertEquals("n".repeat(64), ControlFixtures.fold(listOf(v1, atTheCaps), owner).metadata?.name) + } + + @Test + fun anEqualVersionMetadataForkGoesToTheHigherAuthorityNotTheLowerRumorId() { + // CORD-04 §1: "authority first, then the lower rumor id". Alice holds MANAGE_METADATA and + // grinds a rumor id below the owner's at the same version; the owner still wins. + val cid = ControlFixtures.COMMUNITY_ID_HEX + val v1 = chainedMeta(cid, 1, null, """{"name":"Genesis"}""") + val ownerV2 = chainedMeta(cid, 2, v1, """{"name":"Owner's"}""", rumorId = "ffff") + val aliceV2 = chainedMeta(cid, 2, v1, """{"name":"Alice's"}""", author = alice, rumorId = "0000") val editions = listOf( - edition(ControlEntityKind.METADATA, "00".repeat(32), """{"name":"Doomed"}"""), + edition(ControlEntityKind.ROLE, adminRole, """{"name":"Admin","position":1,"permissions":"4"}"""), // MANAGE_METADATA + edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), """{"member":"$alice","role_ids":["$adminRole"]}"""), + v1, + aliceV2, + ownerV2, + ) + assertEquals("Owner's", ControlFixtures.fold(editions, owner).metadata?.name) + + // And an edition chained onto the owner's sibling extends the chain from there. + val v3 = chainedMeta(cid, 3, ownerV2, """{"name":"Next"}""", author = alice) + assertEquals("Next", ControlFixtures.fold(editions + v3, owner).metadata?.name) + } + + @Test + fun aControlPlaneVsk10EditionDoesNotDissolve() { + // CORD-02 §9: the tombstone lives at `dissolved_pk` and must name its community. A vsk-10 + // edition on the Control Plane skips that binding, so it must never seal the community. + val editions = + listOf( + edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"Doomed"}"""), edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""), ) - val state = ConcordCommunityState.fold(editions, owner) - assertTrue(state.dissolved) + val state = ControlFixtures.fold(editions, owner) + assertFalse(state.dissolved) + assertTrue(state.withDissolved(true).dissolved) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolutionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolutionTest.kt new file mode 100644 index 0000000000..066184a5f2 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolutionTest.kt @@ -0,0 +1,109 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +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.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ConcordDissolutionTest { + private val owner = NostrSignerInternal(KeyPair()) + private val stranger = NostrSignerInternal(KeyPair()) + private val communityX = ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), ByteArray(32) { 1 }).toHexKey() + private val communityY = ConcordKeyDerivation.communityId(owner.pubKey.hexToByteArray(), ByteArray(32) { 2 }).toHexKey() + + @Test + fun tombstoneWireShapeIsChainlessAndBound() { + val rumor = ConcordDissolution.rumor(owner.pubKey, communityX, createdAt = 1725000000) + assertEquals(ControlEditionEvent.KIND, rumor.kind) + assertEquals("", rumor.content) + assertEquals(listOf(listOf("vsk", "10"), listOf("eid", communityX)), rumor.tags.map { it.toList() }) + } + + @Test + fun addressDerivesFromTheCommunityIdAlone() { + // A.6: `concord/dissolved`, ikm = community_id, id = 0…0, no epoch. + val expected = ConcordKeyDerivation.groupKey("concord/dissolved", communityX.hexToByteArray(), ByteArray(32)) + assertEquals(expected.publicKeyHex, ConcordDissolution.planeKey(communityX).publicKeyHex) + } + + @Test + fun ownerTombstoneDissolvesItsOwnCommunity() = + runTest { + val wrap = ConcordDissolution.build(owner, communityX) + assertEquals(ConcordDissolution.planeKey(communityX).publicKeyHex, wrap.pubKey) + assertTrue(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey)) + } + + @Test + fun nonOwnerTombstoneIsNoise() = + runTest { + val wrap = ConcordDissolution.build(stranger, communityX) + assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey)) + } + + @Test + fun aTombstoneForXReWrappedAtYDoesNotKillY() = + runTest { + // The replay CORD-02 §9 closes: lift X's genuine owner seal and re-wrap it verbatim at Y's + // (public) dissolved address. The seal still verifies, but its eid names X. + val planeX = ConcordDissolution.planeKey(communityX) + val sealForX = ConcordStreamEnvelope.seal(ConcordDissolution.rumor(owner.pubKey, communityX), planeX, owner, encrypted = false) + val reWrappedAtY = ConcordStreamEnvelope.wrapSeal(sealForX, ConcordDissolution.planeKey(communityY)) + assertFalse(ConcordDissolution.isDissolved(listOf(reWrappedAtY), communityY, owner.pubKey)) + } + + @Test + fun theLegacyAllZeroEidIsRefused() = + runTest { + val plane = ConcordDissolution.planeKey(communityX) + val legacy = + RumorAssembler.assembleRumor( + owner.pubKey, + eventTemplate(ControlEditionEvent.KIND, "") { + add(arrayOf("vsk", ControlEntityKind.DISSOLVED.wire)) + add(arrayOf("eid", "00".repeat(32))) + }, + ) + val wrap = ConcordStreamEnvelope.wrap(legacy, plane, owner, encrypted = false) + assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey)) + } + + @Test + fun anEncryptedSealIsRefused() = + runTest { + val plane = ConcordDissolution.planeKey(communityX) + val wrap = ConcordStreamEnvelope.wrap(ConcordDissolution.rumor(owner.pubKey, communityX), plane, owner, encrypted = true) + assertFalse(ConcordDissolution.isDissolved(listOf(wrap), communityX, owner.pubKey)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt new file mode 100644 index 0000000000..de3ae953a7 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt @@ -0,0 +1,498 @@ +/* + * 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.quartz.concord.cord02Community + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * CORD-02 §8 wire conformance, pinned to bytes produced by the reference client's own serializer + * (Armada `src/concord/lib/listFrag.ts` @ 7588884, run under Node against the same fixtures). + * Byte identity is the contract: identical state must fragment and serialize identically across + * implementations, or the canonical-bytes tie-break flaps between two clients' republishes. + */ +class ConcordListFragmentsTest { + private val json = Json + + private fun parse(s: String) = json.parseToJsonElement(s).jsonObject + + /** Every §8 rule once: seed omitted after a rename, kept across a refounding with current's cosmetics, a tombstoned entry dropped, a re-join kept, unknown keys sorted and untouched. */ + private val smallInput = + """{"vendor/flag":{"z":1,"a":[2,{"y":1,"b":2}]},"entries":[{"community_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","seed":{"co""" + + """mmunity_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","owner":"f2047532a0e8f1ca30e2391636330b1f05768679a7d9e78191d66ef6919662""" + + """3e","owner_salt":"ba76992a6079074311285334177ddda598e8d8f9bae4df70875886d5229c26ac","community_root":"82e42e2dc2fc1a7957f3b625f5ec2cfbafe7c2546d6c92db""" + + """dc239953c0333bec","root_epoch":0,"control_pk":"840aa4c586fff4d34e8ffb6bcf73acf4d5e033273934bfb980e899f51aa2fd4a","channels":[],"relays":["wss://relay.""" + + """example.com","wss://r0.example"],"name":"Gone"},"current":{"community_id":"1a960f3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","owner":"""" + + """f2047532a0e8f1ca30e2391636330b1f05768679a7d9e78191d66ef69196623e","owner_salt":"ba76992a6079074311285334177ddda598e8d8f9bae4df70875886d5229c26ac","com""" + + """munity_root":"82e42e2dc2fc1a7957f3b625f5ec2cfbafe7c2546d6c92dbdc239953c0333bec","root_epoch":0,"control_pk":"840aa4c586fff4d34e8ffb6bcf73acf4d5e033273""" + + """934bfb980e899f51aa2fd4a","channels":[],"relays":["wss://relay.example.com","wss://r0.example"],"name":"Gone"},"added_at":1719800000002},{"community_id""" + + """":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c3679dc705","seed":{"community_id":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c36""" + + """79dc705","owner":"00155506969316836e9c4649c39eb76e244c720731a5ea708709ff3bcb3212a8","owner_salt":"51e0ed5c000f3205a88bca9498b39922f08def28b2da77f063cf""" + + """88b9708607a0","community_root":"aea3ef4992ed46d0190f47392d69a647b28e1b57260698c3462afa76f53fd428","root_epoch":0,"control_pk":"ee8bb460e7251e4194e7b5c""" + + """8a56a7eaeb9e3f7e7aabc834bf2f53a081868fd28","channels":[],"relays":["wss://relay.example.com","wss://r1.example"],"name":"Back"},"current":{"community_""" + + """id":"5ec89d515d1ab8e34fcadabc030883587429fbb00092d573b0d7f8c3679dc705","owner":"00155506969316836e9c4649c39eb76e244c720731a5ea708709ff3bcb3212a8","own""" + + """er_salt":"51e0ed5c000f3205a88bca9498b39922f08def28b2da77f063cf88b9708607a0","community_root":"aea3ef4992ed46d0190f47392d69a647b28e1b57260698c3462afa76""" + + """f53fd428","root_epoch":0,"control_pk":"ee8bb460e7251e4194e7b5c8a56a7eaeb9e3f7e7aabc834bf2f53a081868fd28","channels":[],"relays":["wss://relay.example.""" + + """com","wss://r1.example"],"name":"Back"},"added_at":1722500000000},{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d600170507cf47b2249d7de9e74b119623f4","""" + + """seed":{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d600170507cf47b2249d7de9e74b119623f4","owner":"713a34a3c313be426d15b5c62ca9c115a1fa6a3a1b51a8f4c6e3""" + + """74b001a45310","owner_salt":"dbc4579ae2b3ab293213f42bb852706ea995c3b5c3987f8aa9faae5004acb3cf","community_root":"6f432a684ce828f64eee716b22121f3c6eccda""" + + """6752dd1c2e5413c9272d12d714","root_epoch":1,"control_pk":"bb30490c32fa152d1d7ed3135f7106626a0cf6b8c78d0664f762a25287a04f8c","channels":[{"id":"e83a3324""" + + """ddbbbcecac7111372041ea2744331b22301a97b834c8db26f9493c71","key":"f9ee91030abe276e839e1e790bbcc68777610df7f4200f75dd5d3a01a4ab2f89","epoch":1,"name":"s""" + + """taff-Stale"}],"relays":["wss://relay.example.com","wss://r2.example"],"name":"Stale"},"current":{"community_id":"5ef38335d3bbd0a5d0241fdcdd02d60017050""" + + """7cf47b2249d7de9e74b119623f4","owner":"713a34a3c313be426d15b5c62ca9c115a1fa6a3a1b51a8f4c6e374b001a45310","owner_salt":"dbc4579ae2b3ab293213f42bb852706e""" + + """a995c3b5c3987f8aa9faae5004acb3cf","community_root":"4e62eea03e8bc82ee7871fc927b8a0768e39116a2b9ad8cc9ab0c1e2c40c15a4","root_epoch":3,"control_pk":"b22""" + + """cb88bb1cfbb8c4f8697fb982e79c22065e4c5e8afc9336b8da44c550f03a6","channels":[{"id":"e83a3324ddbbbcecac7111372041ea2744331b22301a97b834c8db26f9493c71","k""" + + """ey":"e21fda697ca9afaf39201db8e25ae77f4bd32c69b4a7db8e5214a349f3e404df","epoch":3,"name":"staff-Fresh"}],"relays":["wss://relay.example.com","wss://r2.""" + + """example"],"name":"Fresh","control_root":"170ef9cce8cce1cacb0fa729f43db38756632e3b5e134408f85bb7d3163e41fd"},"added_at":1719800000001,"held_roots":[{"e""" + + """poch":1,"key":"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"}]},{"community_id":"a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825""" + + """b5cd915382bdf65","seed":{"community_id":"a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65","owner":"1557f949eb074ee1de813d3598b394ea65""" + + """4e9066ba7dd5ffe290848c31a8c9c8","owner_salt":"dc90cf07de907ccc64636ceddb38e552a1a0d984743b1f36a447b73877012c39","community_root":"e6e642c51a2df47d476e""" + + """8961b0a9a9db2bef4116761960ac74c19cb7c46fb397","root_epoch":0,"control_pk":"e412e33f694277b150dbdd801f378cc1886769de7a398f2b06e3713616c5133e","channels""" + + """":[{"id":"797e5887b3e390bc65acb5a81c47d1fe418c9bc160a4adb71fb31fa18981bc18","key":"b76205818bb9254a3af1e9900cdb486617b60265c4e21e600d6bbcd979ac3389","""" + + """epoch":0,"name":"staff-Old Name"}],"relays":["wss://relay.example.com","wss://r1.example"],"name":"Old Name"},"current":{"community_id":"a8e2754881acd""" + + """a66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65","owner":"1557f949eb074ee1de813d3598b394ea654e9066ba7dd5ffe290848c31a8c9c8","owner_salt":"dc90cf07""" + + """de907ccc64636ceddb38e552a1a0d984743b1f36a447b73877012c39","community_root":"e6e642c51a2df47d476e8961b0a9a9db2bef4116761960ac74c19cb7c46fb397","root_ep""" + + """och":0,"control_pk":"e412e33f694277b150dbdd801f378cc1886769de7a398f2b06e3713616c5133e","channels":[{"id":"797e5887b3e390bc65acb5a81c47d1fe418c9bc160a4""" + + """adb71fb31fa18981bc18","key":"b76205818bb9254a3af1e9900cdb486617b60265c4e21e600d6bbcd979ac3389","epoch":0,"name":"staff-New Name"}],"relays":["wss://re""" + + """lay.example.com","wss://r1.example"],"name":"New Name"},"added_at":1719800000000,"invite_ref":"naddr1xyz#frag"}],"tombstones":[{"community_id":"1a960f""" + + """3f84cfe558f94489b844cb38332ff466891b2e77a543f3e96c62634657","removed_at":1722400000000,"vendor/why":"left"},{"community_id":"5ec89d515d1ab8e34fcadabc0""" + + """30883587429fbb00092d573b0d7f8c3679dc705","removed_at":1722400000000}]}""" + + private val smallExpected = + """{"frags":1,"entries":[{"community_id":"XsidUV0auONPytq8AwiDWHQp-7AAktVzsNf4w2edxwU","current":{"owner":"ABVVBpaTFoNunEZJw563biRMcgcxpepwhwn_O8syEqg","""" + + """owner_salt":"UeDtXAAPMgWoi8qUmLOZIvCN7yiy2nfwY8-IuXCGB6A","community_root":"rqPvSZLtRtAZD0c5LWmmR7KOG1cmBpjDRir6dvU_1Cg","root_epoch":0,"control_pk":"""" + + """7ou0YOclHkGU57XIpWp-rrnj9-eqvINL8vU6CBho_Sg","relays":["wss://relay.example.com","wss://r1.example"],"name":"Back"},"added_at":1722500000000},{"commun""" + + """ity_id":"XvODNdO70KXQJB_c3QLWABcFB89HsiSdfennSxGWI_Q","seed":{"owner":"cTo0o8MTvkJtFbXGLKnBFaH6ajobUaj0xuN0sAGkUxA","owner_salt":"28RXmuKzqykyE_QruFJw""" + + """bqmVw7XDmH-KqfquUASss88","community_root":"b0MqaEzoKPZO7nFrIhIfPG7M2mdS3RwuVBPJJy0S1xQ","root_epoch":1,"control_pk":"uzBJDDL6FS0dftMTX3EGYmoM9rjHjQZk9""" + + """2KiUoegT4w","channels":[{"id":"6DozJN27vOyscRE3IEHqJ0QzGyIwGpe4NMjbJvlJPHE","key":"-e6RAwq-J26Dnh55C7zGh3dhDff0IA913V06AaSrL4k","epoch":1,"name":"staf""" + + """f-Fresh"}],"relays":["wss://relay.example.com","wss://r2.example"],"name":"Fresh"},"current":{"owner":"cTo0o8MTvkJtFbXGLKnBFaH6ajobUaj0xuN0sAGkUxA","o""" + + """wner_salt":"28RXmuKzqykyE_QruFJwbqmVw7XDmH-KqfquUASss88","community_root":"TmLuoD6LyC7nhx_JJ7igdo45EWormtjMmrDB4sQMFaQ","root_epoch":3,"control_pk":"s""" + + """iy4i7HPu4xPhpf7mC55wiBl5MXor8kza42kTFUPA6Y","control_root":"Fw75zOjM4crLD6cp9D2zh1ZjLjteE0QI-Fu30xY-Qf0","channels":[{"id":"6DozJN27vOyscRE3IEHqJ0QzGy""" + + """IwGpe4NMjbJvlJPHE","key":"4h_aaXypr685IB244lrnf0vTLGm0p9uOUhSjSfPkBN8","epoch":3,"name":"staff-Fresh"}],"relays":["wss://relay.example.com","wss://r2.""" + + """example"],"name":"Fresh"},"added_at":1719800000001,"held_roots":[{"epoch":1,"key":"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828"}]""" + + """},{"community_id":"qOJ1SIGs2mbkfcWoENDxajhHQv9bACKCW1zZFTgr32U","current":{"owner":"FVf5SesHTuHegT01mLOU6mVOkGa6fdX_4pCEjDGoycg","owner_salt":"3JDPB96""" + + """QfMxkY2zt2zjlUqGg2YR0Ox82pEe3OHcBLDk","community_root":"5uZCxRot9H1HbolhsKmp2yvvQRZ2GWCsdMGct8Rvs5c","root_epoch":0,"control_pk":"5BLjP2lCd7FQ292AHzeM""" + + """wYhnad56OY8rBuNxNhbFEz4","channels":[{"id":"eX5Yh7PjkLxlrLWoHEfR_kGMm8FgpK23H7MfoYmBvBg","key":"t2IFgYu5JUo68emQDNtIZhe2AmXE4h5gDWu82XmsM4k","epoch":0""" + + ""","name":"staff-New Name"}],"relays":["wss://relay.example.com","wss://r1.example"],"name":"New Name"},"added_at":1719800000000,"invite_ref":"naddr1xyz""" + + """#frag"}],"tombstones":[{"community_id":"GpYPP4TP5Vj5RIm4RMs4My_0ZokbLnelQ_PpbGJjRlc","removed_at":1722400000000,"vendor/why":"left"},{"community_id":"""" + + """XsidUV0auONPytq8AwiDWHQp-7AAktVzsNf4w2edxwU","removed_at":1722400000000}],"vendor/flag":{"a":[2,{"b":2,"y":1}],"z":1}}""" + + @Test + fun smallFixtureMatchesTheReferenceBytes() { + assertEquals(listOf(smallExpected), ConcordListFragments.pack(parse(smallInput))) + } + + @Test + fun decodingTheReferenceBytesAndRepackingIsStable() { + val decoded = ConcordListFragments.decodeFragment(smallExpected) + assertEquals(1, decoded.frags) + assertEquals(listOf(smallExpected), ConcordListFragments.pack(decoded.doc)) + } + + @Test + fun decodeRestoresHexAndInheritsTheCommunityId() { + val doc = ConcordListFragments.decodeFragment(smallExpected).doc + val first = doc["entries"]!!.jsonArray[0].jsonObject + val cid = first["community_id"]!!.jsonPrimitive.content + assertEquals(64, cid.length) + val current = first["current"]!!.jsonObject + assertEquals(cid, current["community_id"]!!.jsonPrimitive.content) + assertEquals(64, current["owner"]!!.jsonPrimitive.content.length) + // An absent seed reads as equal to current. + assertEquals(current, first["seed"]) + } + + @Test + fun unknownFieldsKeepTheirAuthorsSpelling() { + // held_roots is not a field §8 names, so its hex key stays hex on the wire. + assertTrue(smallExpected.contains("\"held_roots\":[{\"epoch\":1,\"key\":\"82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828\"}]")) + val out = ConcordListFragments.pack(ConcordListFragments.decodeFragment(smallExpected).doc).single() + assertTrue(out.contains("82f3e9c695dc6b8d1b11818d5701919e286de8d47f7c3eb3100c485f79e57828")) + } + + @Test + fun base64urlIsUnpaddedAndCaseSensitive() { + val hex = "a8e2754881acda66e47dc5a810d0f16a384742ff5b0022825b5cd915382bdf65" + val b64 = ConcordListFragments.hexToB64(hex) + assertEquals(43, b64.length) + assertFalse(b64.contains('=')) + assertEquals(hex, ConcordListFragments.b64ToHex(b64)) + // Not 32 bytes either way: passes through untouched. + assertEquals("wss://relay", ConcordListFragments.hexToB64("wss://relay")) + assertEquals("short", ConcordListFragments.b64ToHex("short")) + } + + private fun h(s: String) = sha256(s.encodeToByteArray()).toHexKey() + + private fun mat( + cid: String, + i: Int, + epoch: Int, + name: String, + withChan: Boolean, + ): JsonObject = + buildJsonObject { + put("community_id", cid) + put("owner", h("owner$i")) + put("owner_salt", h("salt$i")) + put("community_root", h("root$i:$epoch")) + put("root_epoch", epoch) + put("control_pk", h("cpk$i:$epoch")) + put( + "channels", + buildJsonArray { + if (withChan) { + add( + buildJsonObject { + put("id", h("chan$i")) + put("key", h("key$i:$epoch")) + put("epoch", epoch) + put("name", "staff-$name") + }, + ) + } + }, + ) + put( + "relays", + buildJsonArray { + add(JsonPrimitive("wss://relay.example.com")) + add(JsonPrimitive("wss://r${i % 3}.example")) + }, + ) + put("name", name) + } + + private fun bigList(): JsonObject { + val entries = + (0 until 300).map { i -> + val cid = h("big$i") + buildJsonObject { + put("community_id", cid) + put("seed", mat(cid, i, 0, "Community $i", i % 4 == 0)) + put("current", mat(cid, i, i % 3, "Community $i", i % 4 == 0)) + put("added_at", 1719800000000L + i) + } + } + val tombs = + (0 until 40).map { i -> + buildJsonObject { + put("community_id", h("gone$i")) + put("removed_at", 1722400000000L + i) + } + } + return buildJsonObject { + put("entries", JsonArray(entries)) + put("tombstones", JsonArray(tombs)) + } + } + + @Test + fun largeListFragmentsExactlyLikeTheReference() { + val frags = ConcordListFragments.pack(bigList()) + assertEquals( + listOf( + "f25fb53425472f4249e5801192d05a4fa589d58e8fa420a2ea0489c99c0f3acc", + "9470fb19cf3fa850b737280bd11be99e599ea9670932a5cf5fd6bb305f9738c4", + "f73480bbce3a92ee651a04373197bf70116f6cf1db6d99686ba3596b1030f942", + "c3f6318681a9a1fbcd8e50129b7678b0df5e7cf59ef6527d5bd6b485435d865e", + "d66d7973f215a3dd2b44b7c6760cd21955baaa6687e02accaa05f708660e0698", + "35d3b110eb57dd2aa116d0faee8d3858a4608d7a1d5d681775a077671eae13ac", + ), + frags.map { sha256(it.encodeToByteArray()).toHexKey() }, + ) + for (f in frags) { + assertTrue(ConcordListFragments.projectedEventBytes(f.encodeToByteArray().size) <= ConcordListFragments.PACK_TARGET_BYTES) + assertEquals(6, ConcordListFragments.decodeFragment(f).frags) + } + } + + // ---- merges ---------------------------------------------------------------------------- + + private fun entry( + cid: String, + seed: JsonObject, + current: JsonObject, + addedAt: Long, + ) = buildJsonObject { + put("community_id", cid) + put("seed", seed) + put("current", current) + put("added_at", addedAt) + } + + private fun doc( + entries: List = emptyList(), + tombstones: List = emptyList(), + ) = JsonObject(mapOf("entries" to JsonArray(entries), "tombstones" to JsonArray(tombstones))) + + private fun tomb( + cid: String, + at: Long, + ) = buildJsonObject { + put("community_id", cid) + put("removed_at", at) + } + + @Test + fun seedMovesBackwardAndCurrentForward() { + val cid = h("m") + val a = doc(listOf(entry(cid, mat(cid, 1, 1, "A", false), mat(cid, 1, 2, "A", false), 10))) + val b = doc(listOf(entry(cid, mat(cid, 1, 0, "B", false), mat(cid, 1, 3, "B", false), 20))) + for (merged in listOf(ConcordListFragments.mergeDocs(a, b), ConcordListFragments.mergeDocs(b, a))) { + val e = merged["entries"]!!.jsonArray.single().jsonObject + assertEquals("0", e["seed"]!!.jsonObject["root_epoch"]!!.jsonPrimitive.content) + assertEquals("3", e["current"]!!.jsonObject["root_epoch"]!!.jsonPrimitive.content) + assertEquals("20", e["added_at"]!!.jsonPrimitive.content) + } + } + + @Test + fun oneTombstonePerCommunityTheLaterWins() { + val cid = h("t") + val merged = ConcordListFragments.mergeDocs(doc(tombstones = listOf(tomb(cid, 5))), doc(tombstones = listOf(tomb(cid, 9)))) + assertEquals( + "9", + merged["tombstones"]!! + .jsonArray + .single() + .jsonObject["removed_at"]!! + .jsonPrimitive.content, + ) + } + + @Test + fun aStaleFragmentCannotResurrectALeave() { + val cid = h("x") + val joined = doc(listOf(entry(cid, mat(cid, 1, 0, "X", false), mat(cid, 1, 0, "X", false), 100))) + val left = doc(tombstones = listOf(tomb(cid, 200))) + val merged = ConcordListFragments.mergeDocs(left, joined) + assertEquals(emptyList(), ConcordCommunityList.decodeDocument(merged).entries) + // And the packed List carries the tombstone alone. + val packed = ConcordListFragments.decodeFragment(ConcordListFragments.pack(merged).single()).doc + assertEquals(0, packed["entries"]!!.jsonArray.size) + assertEquals(1, packed["tombstones"]!!.jsonArray.size) + } + + // ---- fragment sets --------------------------------------------------------------------- + + private fun frag( + frags: Int, + entries: List = emptyList(), + tombstones: List = emptyList(), + ) = ConcordListFragments.serializeFragment( + frags, + entries.map { ConcordListFragments.entryToWire(it) }, + tombstones.mapNotNull { ConcordListFragments.tombstoneToWire(it) }, + ) + + private fun membership(i: Int): JsonObject { + val cid = h("member$i") + return entry(cid, mat(cid, i, 0, "M$i", false), mat(cid, i, 0, "M$i", false), 1000L + i) + } + + @Test + fun theNewestFragmentDeclaresTheCountAndATieGoesLarger() { + val set = + ConcordListFragmentSet.of( + listOf( + ConcordListFragmentSet.Copy(0, 100, "a", frag(2, listOf(membership(0)))), + ConcordListFragmentSet.Copy(1, 100, "b", frag(3, listOf(membership(1)))), + ), + ) + assertEquals(3, set.declared) + assertFalse(set.complete, "index 2 is unseen") + assertEquals(2, ConcordCommunityList.decodeDocument(set.doc).entries.size) + } + + @Test + fun anUnreadableHeadIsAMissingIndexNotAnOlderCopy() { + val set = + ConcordListFragmentSet.of( + listOf( + ConcordListFragmentSet.Copy(0, 100, "old", frag(1, listOf(membership(0)))), + ConcordListFragmentSet.Copy(0, 200, "new", null), + ), + ) + assertTrue(0 in set.unreadable) + assertTrue(set.held.isEmpty()) + } + + @Test + fun anUnreadableOnlyFragmentIsNeverOverwritten() { + // The one fragment on the wire didn't decrypt (a timed-out signer): not "no List". + val set = ConcordListFragmentSet.of(listOf(ConcordListFragmentSet.Copy(0, 100, "a", null))) + assertFalse(set.complete) + assertFalse(set.isEmpty) + assertFailsWith { set.planWrites(doc(listOf(membership(0))), now = 1000) } + } + + @Test + fun anUnreadableNewerFragmentBlocksARepackThatWouldShrinkTheCount() { + // Fragment 1 may declare more fragments than readable fragment 0 does; a repack to 1 + // fragment would push its memberships out of range. + val set = + ConcordListFragmentSet.of( + listOf( + ConcordListFragmentSet.Copy(0, 100, "a", frag(1, listOf(membership(0)))), + ConcordListFragmentSet.Copy(1, 200, "b", null), + ), + ) + assertFalse(set.complete) + val writes = set.planWrites(ConcordListFragments.mergeDocs(set.doc, doc(listOf(membership(5)))), now = 1000) + assertEquals(listOf(0), writes.map { it.index }, "only a scoped write into the readable fragment") + assertEquals(1, ConcordListFragments.decodeFragment(writes.single().plaintext).frags) + } + + @Test + fun anEntryWithUnencodableMaterialIsSkippedNotFatal() { + val broken = + buildJsonObject { + put("community_id", h("broken")) + put("current", buildJsonObject { put("name", "no keys") }) + put("added_at", 1) + } + val packed = ConcordListFragments.pack(doc(listOf(membership(0), broken))).single() + assertEquals( + 1, + ConcordListFragments + .decodeFragment(packed) + .doc["entries"]!! + .jsonArray.size, + ) + } + + @Test + fun fragmentsPastTheCountStayDormant() { + val set = + ConcordListFragmentSet.of( + listOf( + ConcordListFragmentSet.Copy(0, 300, "a", frag(1, listOf(membership(0)))), + ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1)))), + ), + ) + assertEquals(1, set.declared) + assertTrue(set.complete) + assertEquals(listOf(h("member0")), ConcordCommunityList.decodeDocument(set.doc).entries.map { it.id }) + } + + @Test + fun aRepackThatShrinksEmptiesTheDroppedIndices() { + val set = + ConcordListFragmentSet.of( + listOf( + ConcordListFragmentSet.Copy(0, 100, "a", frag(2, listOf(membership(0)))), + ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1)))), + ), + ) + assertTrue(set.complete) + val writes = set.planWrites(set.doc, now = 50) + // Both memberships fit one fragment: 0 is rewritten with frags=1, 1 is emptied — never abandoned. + assertEquals(listOf(0, 1), writes.map { it.index }) + assertEquals( + 2, + ConcordListFragments + .decodeFragment(writes[0].plaintext) + .doc["entries"]!! + .jsonArray.size, + ) + assertEquals(ConcordListFragments.emptyFragment(1), writes[1].plaintext) + // created_at always climbs past the index's previous copy, even with a clock behind it. + assertTrue(writes.all { it.createdAt == 101L }) + } + + @Test + fun anIncompleteListOnlyRewritesTheFragmentHoldingTheChange() { + val held0 = frag(3, listOf(membership(0))) + val set = + ConcordListFragmentSet.of( + listOf( + ConcordListFragmentSet.Copy(0, 100, "a", held0), + ConcordListFragmentSet.Copy(2, 100, "c", frag(3, listOf(membership(2)))), + ), + ) + assertFalse(set.complete) + // Leave membership 2: a tombstone lands in fragment 2 only; fragment 1 (unseen) is untouched. + val next = ConcordListFragments.mergeDocs(set.doc, doc(tombstones = listOf(tomb(h("member2"), 5000)))) + val writes = set.planWrites(next, now = 1000) + assertEquals(listOf(2), writes.map { it.index }) + val written = ConcordListFragments.decodeFragment(writes.single().plaintext) + assertEquals(3, written.frags, "a scoped write never changes the count") + assertEquals(0, written.doc["entries"]!!.jsonArray.size) + assertEquals(1, written.doc["tombstones"]!!.jsonArray.size) + } + + @Test + fun anIncompleteListPutsANewMembershipInTheLowestHeldFragment() { + val set = + ConcordListFragmentSet.of( + listOf(ConcordListFragmentSet.Copy(1, 100, "b", frag(2, listOf(membership(1))))), + ) + val next = ConcordListFragments.mergeDocs(set.doc, doc(listOf(membership(9)))) + val writes = set.planWrites(next, now = 1000) + assertEquals(listOf(1), writes.map { it.index }) + assertEquals( + 2, + ConcordListFragments + .decodeFragment(writes.single().plaintext) + .doc["entries"]!! + .jsonArray.size, + ) + } + + @Test + fun anUnchangedListWritesNothing() { + val set = ConcordListFragmentSet.of(listOf(ConcordListFragmentSet.Copy(0, 100, "a", ConcordListFragments.pack(doc(listOf(membership(0)))).single()))) + assertEquals(emptyList(), set.planWrites(set.doc, now = 1000).map { it.index }) + } + + @Test + fun theTypedLayerTombstonesOnLeave() { + val entry = ConcordCommunityList.decodeDocument(doc(listOf(membership(0)))).entries.single() + val residue = ConcordListResidue.EMPTY.withTombstone(entry.id, 5000) + val internal = ConcordCommunityList.encodeInternal(emptyList(), residue) + val back = ConcordCommunityList.decodeDocument(internal) + assertEquals(emptyList(), back.entries) + assertEquals( + "5000", + back.residue.tombstones + .single()["removed_at"]!! + .jsonPrimitive.content, + ) + // An earlier removal never lowers the tombstone. + assertEquals(residue, residue.withTombstone(entry.id, 10)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt new file mode 100644 index 0000000000..1a4228e159 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt @@ -0,0 +1,243 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.concord.cord03Channels.tags.EpochTag +import com.vitorpamplona.quartz.concord.cord03Channels.tags.MsTag +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 com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-01/02/03 Chat Plane conformance: the strict binding (I13), the `ms` tag (I15), the + * four-element inline quote (I16), the in-stream delete (S3), the Chat ingest gate (S9) and the + * private-channel keying (S2), each pinned against the spec text and examples.md §2. + */ +class ChannelChatConformanceTest { + private val communityRoot = ByteArray(32) { 0x5A } + private val channelId = ByteArray(32) { 0x42 } + private val channelIdHex = channelId.toHexKey() + private val author = KeyPair().pubKey.toHexKey() + + private fun rumorWithTags(vararg tags: Array): Event = RumorAssembler.assembleRumor(author, 1_700_000_000L, 9, arrayOf(*tags), "x") + + // ---- I13 strict binding ------------------------------------------------------------------- + + @Test + fun bindingRequiresExactlyOneChannelAndOneCanonicalEpoch() { + val ok = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4")) + assertTrue(ok.tags.isConcordBoundTo(channelIdHex, 4)) + + // Non-canonical spellings of 4 are a different binding (CORD-01 Encoding: no leading zeros). + for (spelling in listOf("04", "+4", " 4", "4 ", "4.0", "0x4")) { + val bad = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", spelling)) + assertFalse(bad.tags.isConcordBoundTo(channelIdHex, 4), "epoch \"$spelling\" must not bind to 4") + assertNull(bad.tags.concordEpoch(), "epoch \"$spelling\" must not parse") + } + assertNull(EpochTag.parse(arrayOf("epoch", "-1"))) + assertEquals(0L, EpochTag.parse(arrayOf("epoch", "0"))) + + // A duplicated tag is ambiguous, even when both copies agree. + val dupChannel = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel", channelIdHex), arrayOf("epoch", "4")) + assertFalse(dupChannel.tags.isConcordBoundTo(channelIdHex, 4)) + assertNull(dupChannel.tags.concordChannel()) + val dupEpoch = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("epoch", "4"), arrayOf("epoch", "4")) + assertFalse(dupEpoch.tags.isConcordBoundTo(channelIdHex, 4)) + assertNull(dupEpoch.tags.concordEpoch()) + // A valueless duplicate still counts as a second binding tag (Armada's uniqueTag). + val shortDup = rumorWithTags(arrayOf("channel", channelIdHex), arrayOf("channel"), arrayOf("epoch", "4")) + assertFalse(shortDup.tags.isConcordBoundTo(channelIdHex, 4)) + } + + @Test + fun extraTagsCannotSmuggleASecondBinding() { + val rumor = + ChannelChat.message( + author, + channelIdHex, + 0, + "hi", + createdAt = 1L, + extraTags = arrayOf(arrayOf("channel", "00".repeat(32)), arrayOf("epoch", "9"), arrayOf("ms", "5"), arrayOf("emoji", "a", "u1"), arrayOf("emoji", "b", "u2")), + ) + assertTrue(ChannelChat.isBoundTo(rumor, channelIdHex, 0)) + assertEquals(1, rumor.tags.count { it[0] == "ms" }) + // Every extra (non-binding) tag survives, including repeated names. + assertEquals(2, rumor.tags.count { it[0] == "emoji" }) + } + + // ---- I15 ms tag --------------------------------------------------------------------------- + + @Test + fun everyChatBuilderStampsAWellFormedMsTag() { + val parent = ChannelChat.message(author, channelIdHex, 0, "root", createdAt = 1L, ms = 417) + val built = + listOf( + parent, + ChannelChat.inlineReply(author, channelIdHex, 0, "q", parent.id, parent.pubKey, 2L), + ChannelChat.reply(author, channelIdHex, 0, "t", parent, 3L), + ChannelChat.reaction(author, channelIdHex, 0, parent.id, parent.pubKey, 9, "+", 4L), + ChannelChat.edit(author, channelIdHex, 0, parent.id, "e", 5L), + ChannelChat.delete(author, channelIdHex, 0, listOf(parent), 6L), + ChannelChat.typing(author, channelIdHex, 0, 7L), + ChannelChat.imageMessage(author, channelIdHex, 0, "i", emptyList(), 8L), + ) + for (rumor in built) { + val ms = rumor.tags.filter { it[0] == "ms" } + assertEquals(1, ms.size, "kind ${rumor.kind} must carry exactly one ms tag") + assertNotNull(MsTag.parse(ms.single()), "kind ${rumor.kind} ms tag must be well formed") + } + // The examples' order: channel, epoch, ms first. + assertContentEquals(arrayOf("channel", channelIdHex), parent.tags[0]) + assertContentEquals(arrayOf("epoch", "0"), parent.tags[1]) + assertContentEquals(arrayOf("ms", "417"), parent.tags[2]) + assertEquals(1_417L, ChannelChat.orderingMs(parent)) + } + + @Test + fun msParsingIsStrictAndOrderingUsesTheMillisecondBasis() { + assertEquals(0, MsTag.parse(arrayOf("ms", "0"))) + assertEquals(999, MsTag.parse(arrayOf("ms", "999"))) + for (bad in listOf("1000", "-1", "007", "+5", " 5", "1e2", "0x1f", "")) { + assertNull(MsTag.parse(arrayOf("ms", bad)), "ms \"$bad\" is malformed") + } + assertFailsWith { MsTag.assemble(1000) } + + assertEquals(5_000L, MsTag.orderingMs(5, emptyArray())) // absent = 0 + assertEquals(5_123L, MsTag.orderingMs(5, arrayOf(arrayOf("ms", "123")))) + assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1000")))) + assertNull(MsTag.orderingMs(5, arrayOf(arrayOf("ms", "1"), arrayOf("ms", "2")))) + + // Same second, the ms remainder decides the order. + val early = ChannelChat.message(author, channelIdHex, 0, "a", createdAt = 10L, ms = 900) + val late = ChannelChat.message(author, channelIdHex, 0, "b", createdAt = 11L, ms = 5) + val mid = ChannelChat.message(author, channelIdHex, 0, "c", createdAt = 10L, ms = 950) + assertEquals(listOf("a", "c", "b"), listOf(late, mid, early).sortedBy { ChannelChat.orderingMs(it) }.map { it.content }) + } + + @Test + fun msRemainderTracksTheCurrentSecondOnly() { + assertEquals(0, MsTag.remainderFor(1L)) + val r = MsTag.remainderFor(TimeUtils.now()) + assertTrue(r in 0..999) + } + + // ---- I16 inline quote --------------------------------------------------------------------- + + @Test + fun inlineQuoteUsesTheFourElementQTag() { + val parentAuthor = KeyPair().pubKey.toHexKey() + val quote = ChannelChat.inlineReply(author, channelIdHex, 0, "Welcome!", "ab".repeat(32), parentAuthor, 2L) + val q = quote.tags.single { it[0] == "q" } + assertContentEquals(arrayOf("q", "ab".repeat(32), "", parentAuthor), q) + assertEquals(9, quote.kind) + } + + // ---- S3 delete ---------------------------------------------------------------------------- + + @Test + fun deleteIsAChannelBoundKind5WithETagsThenKTags() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "oops", createdAt = 1L) + val reply = ChannelChat.reply(alice.pubKey, channelIdHex, 0, "also oops", message, 2L) + val reaction = ChannelChat.reaction(alice.pubKey, channelIdHex, 0, message.id, message.pubKey, 9, "+", 3L) + + val delete = ChannelChat.delete(alice.pubKey, channelIdHex, 0, listOf(message, reply, reaction), 4L, ms = 533) + assertEquals(5, delete.kind) + assertEquals("", delete.content) + assertContentEquals(arrayOf("channel", channelIdHex), delete.tags[0]) + assertContentEquals(arrayOf("epoch", "0"), delete.tags[1]) + assertContentEquals(arrayOf("ms", "533"), delete.tags[2]) + assertEquals(listOf(message.id, reply.id, reaction.id), delete.tags.filter { it[0] == "e" }.map { it[1] }) + assertEquals(listOf("9", "1111", "7"), delete.tags.filter { it[0] == "k" }.map { it[1] }) + + // It rides the channel plane in an encrypted seal like any other Chat rumor. + val wrap = ConcordStreamEnvelope.wrap(delete, channel, alice, encrypted = true) + val opened = ConcordStreamEnvelope.open(wrap, channel) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, opened.sealKind) + assertNotNull(ChannelChat.acceptOpened(opened, channelIdHex, 0)) + assertFailsWith { ChannelChat.delete(alice.pubKey, channelIdHex, 0, emptyList(), 4L) } + } + + // ---- S9 chat ingest gate ------------------------------------------------------------------ + + @Test + fun chatIngestAcceptsOnlyEncryptedSealsAndChatKinds() = + runTest { + val alice = NostrSignerInternal(KeyPair()) + val channel = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + + suspend fun open( + rumor: Event, + encrypted: Boolean = true, + ) = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, channel, alice, encrypted = encrypted), channel) + + val message = ChannelChat.message(alice.pubKey, channelIdHex, 0, "hi", createdAt = 1L) + assertNotNull(ChannelChat.acceptOpened(open(message), channelIdHex, 0)) + + // A plaintext seal is Control-only (CORD-02 §5). + assertNull(ChannelChat.acceptOpened(open(message, encrypted = false), channelIdHex, 0)) + + // Another plane's kind, correctly bound, is still refused (Armada PLANE_KINDS). + for (kind in listOf(3308, 3306, 3309, 3312, 3303, 3313, 1)) { + val foreign = RumorAssembler.assembleRumor(alice.pubKey, 1L, kind, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0")), "{}") + assertNull(ChannelChat.acceptOpened(open(foreign), channelIdHex, 0), "kind $kind must not enter a Chat Plane") + } + for (kind in listOf(9, 1111, 7, 5, 3302, 23311, 1740)) { + assertTrue(ChannelChat.isChatKind(kind), "kind $kind is a Chat kind") + } + + // A malformed ms drops the rumor instead of being interpreted. + val badMs = RumorAssembler.assembleRumor(alice.pubKey, 1L, 9, arrayOf(arrayOf("channel", channelIdHex), arrayOf("epoch", "0"), arrayOf("ms", "1500")), "hi") + assertNull(ChannelChat.acceptOpened(open(badMs), channelIdHex, 0)) + + // And a binding mismatch is dropped as before. + assertNull(ChannelChat.acceptOpened(open(message), channelIdHex, 1)) + } + + // ---- S2 private channel keying ------------------------------------------------------------ + + @Test + fun aPrivateChannelLivesOnItsOwnKeyNotTheRootPlane() { + val channelKey = ByteArray(32) { 0x33 } + val public = ConcordChannelKeys.publicChannel(communityRoot, channelId, 0) + val private = ConcordChannelKeys.privateChannel(channelKey, channelId, 1) + assertNotEquals(public.publicKeyHex, private.publicKeyHex) + // The channel epoch is part of the derivation: a stale key generation is a different plane. + assertNotEquals(private.publicKeyHex, ConcordChannelKeys.privateChannel(channelKey, channelId, 2).publicKeyHex) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt new file mode 100644 index 0000000000..25352275fa --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt @@ -0,0 +1,72 @@ +/* + * 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.quartz.concord.cord03Channels + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ConcordDisappearingTest { + private val author = KeyPair().pubKey.toHexKey() + private val channel = "cc".repeat(32) + + @Test + fun onlyDurableChatKindsExpire() { + assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(9, 1_000, 86_400)) + assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(1111, 1_000, 86_400)) + assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(7, 1_000, 86_400)) + assertEquals(1_000 + 86_400L, ConcordDisappearing.expirationFor(3302, 1_000, 86_400)) + // Deletes and notices never expire; ephemeral kinds carry nothing; an unset timer tags nothing. + assertNull(ConcordDisappearing.expirationFor(5, 1_000, 86_400)) + assertNull(ConcordDisappearing.expirationFor(1740, 1_000, 86_400)) + assertNull(ConcordDisappearing.expirationFor(23311, 1_000, 86_400)) + assertNull(ConcordDisappearing.expirationFor(9, 1_000, null)) + assertNull(ConcordDisappearing.expirationFor(9, 1_000, 0)) + } + + @Test + fun theRumorsOwnTagDecidesExpiry() { + val tags = ConcordDisappearing.withExpiration(arrayOf(arrayOf("channel", channel)), 2_000) + val rumor = ChannelChat.message(author, channel, 0, "gone soon", 1_000, extraTags = tags) + assertEquals(2_000L, ConcordDisappearing.expirationOf(rumor)) + assertFalse(ConcordDisappearing.isExpired(rumor, now = 1_999)) + assertTrue(ConcordDisappearing.isExpired(rumor, now = 2_000), "NIP-40: exp <= now") + assertFalse(ConcordDisappearing.isExpired(ChannelChat.message(author, channel, 0, "forever", 1_000), now = Long.MAX_VALUE)) + } + + @Test + fun timerNoticeRoundTripsAndRejectsGarbage() { + val notice = ConcordDisappearing.timerNotice(author, channel, 4, 2_592_000, 10) + assertEquals(1740, notice.kind) + assertEquals("", notice.content) + assertEquals(channel, ChannelChat.channelOf(notice)) + assertEquals(4L, ChannelChat.epochOf(notice)) + assertEquals(2_592_000L, ConcordDisappearing.noticeTimerSecs(notice)) + assertEquals(0L, ConcordDisappearing.noticeTimerSecs(ConcordDisappearing.timerNotice(author, channel, 4, 0, 10))) + + val bad = ChannelChat.message(author, channel, 4, "", 10, extraTags = arrayOf(arrayOf("timer", "04"))) + assertNull(ConcordDisappearing.noticeTimerSecs(bad), "not a 1740") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt index 1d60fe7d91..be1abb9a32 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityResolverTest.kt @@ -50,6 +50,7 @@ class AuthorityResolverTest { json: String, ) = ControlEdition(ControlEntityKind.ROLE, roleId.hexToByteArray(), 0, null, null, json, owner, "role-$roleId", 0) + /** A genesis Grant of [member] at their own coordinate; [grantId] only names the rumor. */ private fun grant( grantId: String, member: String, @@ -57,7 +58,7 @@ class AuthorityResolverTest { granter: String, ) = ControlEdition( ControlEntityKind.GRANT, - grantId.hexToByteArray(), + ControlFixtures.grantEid(member).hexToByteArray(), 0, null, null, @@ -75,7 +76,7 @@ class AuthorityResolverTest { vararg banned: String, ) = ControlEdition( ControlEntityKind.BANLIST, - "44".repeat(32).hexToByteArray(), + ControlFixtures.banlistEid().hexToByteArray(), 0, null, null, @@ -106,7 +107,7 @@ class AuthorityResolverTest { ) val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf( role(adminRole, adminJson), // position 1, owner-authored modV0, // position 5, owner-authored @@ -130,7 +131,7 @@ class AuthorityResolverTest { // strip anyone, the owner's admins included. Armada gates this the same way: a grant is an // action ON the member, so demotion must be at least as hard as promotion. val modWithManageRoles = """{"name":"Mod","position":5,"permissions":"9"}""" // KICK|MANAGE_ROLES - val adminGrantId = "31".repeat(32) + val adminGrantId = ControlFixtures.grantEid(alice) val adminGrant = grant(adminGrantId, alice, listOf(adminRole), granter = owner) val revokeByMod = ControlEdition( @@ -146,7 +147,7 @@ class AuthorityResolverTest { ) val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf( role(adminRole, adminJson), // position 1 role(modRole, modWithManageRoles), // position 5 @@ -163,7 +164,7 @@ class AuthorityResolverTest { @Test fun anAdminCanStillRevokeAMemberBeneathIt() { // The gate must not block legitimate moderation: an admin may revoke a moderator's roles. - val modGrantId = "32".repeat(32) + val modGrantId = ControlFixtures.grantEid(bob) val modGrant = grant(modGrantId, bob, listOf(modRole), granter = owner) val revokeByAdmin = ControlEdition( @@ -179,7 +180,7 @@ class AuthorityResolverTest { ) val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf( role(adminRole, adminJson), role(modRole, modJson), @@ -211,7 +212,7 @@ class AuthorityResolverTest { ) val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf( role(adminRole, adminJson), modV0, @@ -235,7 +236,7 @@ class AuthorityResolverTest { grant("ba".repeat(32), bob, listOf(modRole), granter = alice), grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertTrue(r.isOwner(owner)) assertEquals(0L, r.rank(owner)) @@ -263,7 +264,7 @@ class AuthorityResolverTest { // carol has no authority, so her grant to dave is dropped grant("cd".repeat(32), dave, listOf(adminRole), granter = carol), ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertNull(r.rank(dave)) assertEquals(ConcordPermissions.NONE.bits, r.effectivePermissions(dave).bits) } @@ -277,7 +278,7 @@ class AuthorityResolverTest { grant("ab".repeat(32), alice, listOf(modRole), granter = owner), // alice is a mod (no MANAGE_ROLES) grant("ae".repeat(32), dave, listOf(adminRole), granter = alice), // mod cannot grant admin ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertEquals(5L, r.rank(alice)) assertNull(r.rank(dave)) // rejected: alice lacks MANAGE_ROLES and doesn't outrank admin } @@ -290,7 +291,7 @@ class AuthorityResolverTest { grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), banlist(alice), ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertTrue(r.isBanned(alice)) // A banned actor can take no action even though the role bit is present. assertFalse(r.hasPermission(alice, BAN)) @@ -298,11 +299,11 @@ class AuthorityResolverTest { } @Test - fun concurrentBansHealIntoAUnionAndAreNeverDropped() { - // Two authorized moderators ban different abusers at the same banlist version — a - // fork of the single banlist doc. Folding to one chain tip would silently drop the - // loser's ban and let that abuser back in; the union keeps both (M1 / CORD-06 - // down-only healing). + fun concurrentBansForkAndTheFoldKeepsOneEdition() { + // Two authorized members ban different abusers at the same banlist version — a fork of + // the single replaced document. CORD-04 §4: "the fold keeps one edition and the other's + // addition drops until re-applied". Authority breaks the tie (§1), so the owner's edition + // wins even though alice's rumor id sorts first; a union of forks is NOT the fold. val heads = listOf( role(adminRole, adminJson), @@ -310,9 +311,71 @@ class AuthorityResolverTest { banlistBy(owner, "ban-owner", bob), // owner bans bob banlistBy(alice, "ban-alice", carol), // alice concurrently bans carol ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) + assertTrue(r.isBanned(bob), "the owner's edition wins the fork") + assertFalse(r.isBanned(carol), "the losing fork's addition drops until re-applied") + } + + @Test + fun theLosingBanIsReHealedByReApplyingItAtopTheWinner() { + // §4 re-heal: after publishing, re-fold, and if your addition isn't in the head, re-apply + // it on top of the winner. That converges on the union without the fold ever unioning. + val ownerFork = banlistBy(owner, "ban-owner", bob) + val base = + listOf( + role(adminRole, adminJson), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + ownerFork, + banlistBy(alice, "ban-alice", carol), + ) + val reHeal = + ControlEdition( + ControlEntityKind.BANLIST, + ControlFixtures.banlistEid().hexToByteArray(), + 1, + ownerFork.hash, // atop the winner + null, + "[\"$bob\",\"$carol\"]", + alice, + "ban-alice-reheal", + 1, + ) + val r = ControlFixtures.resolve(base + reHeal, owner) assertTrue(r.isBanned(bob)) - assertTrue(r.isBanned(carol)) + assertTrue(r.isBanned(carol), "re-applied atop the winner, alice's ban lands") + } + + @Test + fun aBanOnALosingForkNeverSticks() { + // The union's worst failure: a ban that lost the fork could never be lifted, because no + // later edition supersedes a fork. Folding to one head, the losing fork is simply inert, and + // an unban chained onto the head sticks. + val ownerV0 = banlistBy(owner, "ban-owner", bob) + val unban = + ControlEdition(ControlEntityKind.BANLIST, ControlFixtures.banlistEid().hexToByteArray(), 1, ownerV0.hash, null, "[]", owner, "unban", 1) + val r = + ControlFixtures.resolve( + listOf( + role(adminRole, adminJson), + grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), + ownerV0, + banlistBy(alice, "ban-alice", dave), // a v0 fork that loses to the owner + unban, + ), + owner, + ) + assertFalse(r.isBanned(bob), "the owner's unban applies") + assertFalse(r.isBanned(dave), "and the losing fork's ban never took effect") + } + + @Test + fun aBanlistAtAnyOtherCoordinateIsIgnored() { + // The Banlist lives at banlist_locator(community_id) (CORD-02 A.6). A list at any other eid + // is not this community's Banlist, even owner-signed. + val stray = + ControlEdition(ControlEntityKind.BANLIST, "44".repeat(32).hexToByteArray(), 0, null, null, "[\"$bob\"]", owner, "stray", 0) + val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), stray), owner) + assertFalse(r.isBanned(bob)) } @Test @@ -323,7 +386,7 @@ class AuthorityResolverTest { role(adminRole, adminJson), banlistBy(carol, "ban-carol", dave), ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertFalse(r.isBanned(dave)) } @@ -335,7 +398,7 @@ class AuthorityResolverTest { role(modRole, """{"name":"Peer","position":0,"permissions":"25"}"""), // illegal position 0 grant("ab".repeat(32), alice, listOf(adminRole, modRole), granter = owner), ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertNull(r.rank(alice)) // both assigned roles are invalid } @@ -352,7 +415,7 @@ class AuthorityResolverTest { role(adminRole, """{"name":"Admin","position":1,"permissions":"25","scope":{"kind":"server"},"color":0}"""), grant("ab".repeat(32), alice, listOf(adminRole), granter = owner), ) - val r = AuthorityResolver.resolve(heads, owner) + val r = ControlFixtures.resolve(heads, owner) assertEquals(1L, r.rank(alice)) assertTrue(r.effectivePermissions(alice).has(BAN)) } @@ -365,7 +428,7 @@ class AuthorityResolverTest { */ @Test fun rogueHigherVersionGrantCannotSupersedeALegitGrant() { - val grantId = "ab".repeat(32) + val grantId = ControlFixtures.grantEid(alice) val ownerGrant = grant(grantId, alice, listOf(adminRole), granter = owner) // v0, prev null val rogueV1 = ControlEdition( @@ -379,7 +442,7 @@ class AuthorityResolverTest { "grant-$grantId-rogue", 1, ) - val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner) + val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), ownerGrant, rogueV1), owner) assertEquals(1L, r.rank(alice)) // rogue v1 dropped; the owner's v0 grant stands } @@ -411,7 +474,7 @@ class AuthorityResolverTest { */ @Test fun anUnauthorizedEditionMidChainDoesNotOrphanTheHonestEditionsAboveIt() { - val grantId = "ab".repeat(32) + val grantId = ControlFixtures.grantEid(alice) val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(modRole)), owner, "g0") val v1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(listOf(adminRole)), owner, "g1") // carol holds ZERO roles — correctly rejected, at any position in the chain. @@ -421,7 +484,7 @@ class AuthorityResolverTest { val v5 = edition(ControlEntityKind.GRANT, grantId, 5, v4, grantJson(listOf(modRole)), owner, "g5") val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf(role(adminRole, adminJson), role(modRole, modJson), v0, v1, v2, v3, v4, v5), owner, ) @@ -446,7 +509,7 @@ class AuthorityResolverTest { val v4 = edition(ControlEntityKind.ROLE, modRole, 4, v3, mod(7, "8"), owner, "r4") val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf(role(adminRole, adminJson), v0, v1, v2, v3, v4, grant("32".repeat(32), bob, listOf(modRole), granter = owner)), owner, ) @@ -463,7 +526,7 @@ class AuthorityResolverTest { */ @Test fun anUnauthorizedBanlistEditionMidChainDoesNotOrphanOrResurrectBans() { - val banId = "44".repeat(32) + val banId = ControlFixtures.banlistEid() fun list(vararg keys: String) = "[${keys.joinToString(",") { "\"$it\"" }}]" @@ -472,7 +535,7 @@ class AuthorityResolverTest { val v2 = edition(ControlEntityKind.BANLIST, banId, 2, v1, list(), dave, "b2") // dave holds no BAN val v3 = edition(ControlEntityKind.BANLIST, banId, 3, v2, list(carol), owner, "b3") // owner unbans bob - val r = AuthorityResolver.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner) + val r = ControlFixtures.resolve(listOf(role(adminRole, adminJson), v0, v1, v2, v3), owner) assertTrue(r.isBanned(carol), "carol's ban survives to the head") assertFalse(r.isBanned(bob), "the owner's v3 unban must apply — v2 may not orphan it") @@ -481,7 +544,7 @@ class AuthorityResolverTest { /** A forged edition takes effect at NO position: not at the tip, and not mid-chain. */ @Test fun aForgedEditionNeverTakesEffectAtAnyPosition() { - val grantId = "ab".repeat(32) + val grantId = ControlFixtures.grantEid(alice) val v0 = edition(ControlEntityKind.GRANT, grantId, 0, null, grantJson(listOf(adminRole)), owner, "g0") // carol holds nothing; her revoke is the forgery, and it must apply at NO position. val forgedV1 = edition(ControlEntityKind.GRANT, grantId, 1, v0, grantJson(emptyList()), carol, "g1") @@ -490,18 +553,18 @@ class AuthorityResolverTest { // Mid-chain: the honest v2 above it still resolves (this arm needs the fix), and the // forged revoke never empties alice's roles. - val mid = AuthorityResolver.resolve(base + listOf(v0, forgedV1, v2), owner) + val mid = ControlFixtures.resolve(base + listOf(v0, forgedV1, v2), owner) assertEquals(setOf(modRole), mid.rolesOf(alice)) assertEquals(5L, mid.rank(alice)) // At the tip: the chain-verified head fails the gate, so the fold falls back to v0. - val tip = AuthorityResolver.resolve(base + listOf(v0, forgedV1), owner) + val tip = ControlFixtures.resolve(base + listOf(v0, forgedV1), owner) assertEquals(setOf(adminRole), tip.rolesOf(alice), "the forged revoke must not strip alice") assertEquals(1L, tip.rank(alice)) // Above the tip, dangling: a higher version is never a shortcut past the gate. val danglingV9 = edition(ControlEntityKind.GRANT, grantId, 9, null, grantJson(emptyList()), carol, "g9") - val above = AuthorityResolver.resolve(base + listOf(v0, danglingV9), owner) + val above = ControlFixtures.resolve(base + listOf(v0, danglingV9), owner) assertEquals(setOf(adminRole), above.rolesOf(alice)) assertEquals(1L, above.rank(alice)) } @@ -522,7 +585,7 @@ class AuthorityResolverTest { private val modWithBanJson = """{"name":"Mod","position":5,"permissions":"24"}""" // KICK|BAN private fun rankedBanScenario(vararg extra: ControlEdition) = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf( role(adminRole, adminJson), // position 1 role(modRole, modWithBanJson), // position 5, holds BAN @@ -592,7 +655,7 @@ class AuthorityResolverTest { // PIN_MESSAGES alone (bit 11 = 2048) writes Control editions, so it is a staff bit. val pinJson = """{"name":"Curator","position":6,"permissions":"2048"}""" val r = - AuthorityResolver.resolve( + ControlFixtures.resolve( listOf( role(adminRole, adminJson), // MANAGE_ROLES|KICK|BAN → staff via MANAGE_ROLES/BAN role(modRole, modJson), // KICK only → Guestbook writer, NOT staff diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt index 3bed1c8575..6692361e08 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/BannedStaffEscalationTest.kt @@ -46,8 +46,8 @@ import kotlin.test.assertTrue * [AuthorityResolver.resolve]. Note that a chain-local rule ("the author must not be banned by the * state their edition chains from") would NOT have been enough: * [aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan] forks at genesis so no - * parent ever mentions the ban, and CORD-04 §4's re-heal union would carry it in anyway. The rule - * had to bind the union too, which is why it is expressed as a whole-pass mask. + * parent ever mentions the ban. The rule had to bind every fork too, which is why it is expressed as + * a whole-pass mask. * * Three tests pin behaviour the fix had to *preserve* rather than change: * [selfUnbanIsStillRefused], [aJuniorPuppetCannotLiftASeniorsBan], and @@ -65,11 +65,11 @@ class BannedStaffEscalationTest { private val modRole = "22".repeat(32) private val puppetRole = "33".repeat(32) - private val banlistEntity = "44".repeat(32) + private val banlistEntity = ControlFixtures.banlistEid() private val channelEntity = "55".repeat(32) - private val metadataEntity = "66".repeat(32) - private val bobGrantEntity = "32".repeat(32) - private val puppetGrantEntity = "35".repeat(32) + private val metadataEntity = ControlFixtures.COMMUNITY_ID_HEX + private val bobGrantEntity = ControlFixtures.grantEid(bob) + private val puppetGrantEntity = ControlFixtures.grantEid(puppet) // MANAGE_ROLES|MANAGE_CHANNELS|MANAGE_METADATA|KICK|BAN|CREATE_INVITE = 1+2+4+8+16+64 private val adminJson = """{"name":"Admin","position":1,"permissions":"95"}""" @@ -102,7 +102,8 @@ class BannedStaffEscalationTest { prev: ByteArray? = null, ) = edition( ControlEntityKind.GRANT, - coordinate, + // A Grant lives at its member's own coordinate (CORD-04 §1); [coordinate] only names the rumor. + ControlFixtures.grantEid(member), version, prev, """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""", @@ -167,7 +168,7 @@ class BannedStaffEscalationTest { @Test fun aBanStripsTheAuthorityCheckedByFoldButNotTheOneCheckedByTheResolver() { - val r = AuthorityResolver.resolve(community() + ownerBansAlice, owner) + val r = ControlFixtures.resolve(community() + ownerBansAlice, owner) assertTrue(r.isBanned(alice), "the owner's ban lands") assertFalse(r.hasPermission(alice, ConcordPermissions.MANAGE_ROLES), "the ban-aware check refuses her") @@ -182,7 +183,7 @@ class BannedStaffEscalationTest { @Test fun aBannedAdminPromotesAFreshSockpuppetToAdmin() { - val r = AuthorityResolver.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner) + val r = ControlFixtures.resolve(community() + ownerBansAlice + aliceMintsAPuppet(), owner) assertEquals(null, r.rank(puppet), "the banned admin's role and grant editions are both dropped") assertFalse(r.isBanned(puppet), "the puppet itself is a clean npub — it is never banned, just powerless") @@ -200,7 +201,7 @@ class BannedStaffEscalationTest { channel("""{"name":"general","deleted":true}""", puppet, 1, channelV0.hash) + metadata("""{"name":"Owned by the guy you banned"}""", puppet, 1, metadataV0.hash) - val state = ConcordCommunityState.fold(editions, owner) + val state = ControlFixtures.fold(editions, owner) assertEquals(1, state.channels.size, "the puppet holds nothing, so its tombstone is inert") assertEquals("My Community", state.metadata?.name, "and the community keeps its identity") @@ -210,7 +211,7 @@ class BannedStaffEscalationTest { fun theSockpuppetBansEveryMemberBeneathIt() { val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash, alice, bob, carol) - val r = AuthorityResolver.resolve(editions, owner) + val r = ControlFixtures.resolve(editions, owner) assertFalse(r.isBanned(bob), "the puppet's banlist edition is unauthorized, so the moderator stands") assertFalse(r.isBanned(carol), "and so do the plain members") @@ -220,7 +221,7 @@ class BannedStaffEscalationTest { fun aBannedAdminBansEveryoneBeneathThemWithoutNeedingAPuppetAtAll() { val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash, alice, bob, carol) - val r = AuthorityResolver.resolve(editions, owner) + val r = ControlFixtures.resolve(editions, owner) assertTrue(r.isBanned(alice), "her own ban stands — it was the owner's") assertFalse(r.isBanned(bob), "banGate now drops a banned author's edition outright") @@ -231,14 +232,14 @@ class BannedStaffEscalationTest { fun aBannedAdminForksTheBanlistAtGenesisRatherThanChainingOntoTheirOwnBan() { // The same attack as above, except her edition does NOT chain onto the edition that banned // her — it forks at genesis. So a rule that only asks "was the author banned by this - // edition's parent?" never sees her ban, and CORD-04 §4's re-heal union carries her bans in - // regardless. Any fix has to bind the union, not just the chain. + // edition's parent?" never sees her ban. The equal-version fork now resolves authority + // first (CORD-04 §1): the owner's edition takes it whatever the rumor ids say. val editions = community() + ownerBansAlice + banlist(alice, 0, null, bob, carol) - val r = AuthorityResolver.resolve(editions, owner) + val r = ControlFixtures.resolve(editions, owner) - assertTrue(r.isBanned(alice), "the owner's ban survives the fork — the union is down-only") - assertFalse(r.isBanned(bob), "the fix binds the UNION too: her fork is dropped before it can be healed in") + assertTrue(r.isBanned(alice), "the owner's ban wins the fork") + assertFalse(r.isBanned(bob), "her fork loses, and her authorship is masked besides") assertFalse(r.isBanned(carol), "same") } @@ -246,7 +247,7 @@ class BannedStaffEscalationTest { fun aBannedAdminRevokesTheSurvivingModerators() { val editions = community() + ownerBansAlice + grant(bobGrantEntity, bob, emptyList(), author = alice, version = 1, prev = bobGrantV0.hash) - val r = AuthorityResolver.resolve(editions, owner) + val r = ControlFixtures.resolve(editions, owner) assertEquals(5, r.rank(bob), "a banned admin's revoke is dropped, so the moderator keeps their role") assertTrue(r.hasPermission(bob, ConcordPermissions.BAN), "and keeps the authority that comes with it") @@ -256,7 +257,7 @@ class BannedStaffEscalationTest { fun aBannedAdminDeletesEveryRoleBeneathThem() { val tombstone = role(modRole, """{"name":"Mod","position":5,"permissions":"24","deleted":true}""", author = alice, version = 1, prev = modRoleV0.hash) - val r = AuthorityResolver.resolve(community() + ownerBansAlice + tombstone, owner) + val r = ControlFixtures.resolve(community() + ownerBansAlice + tombstone, owner) assertEquals(5, r.roles()[modRole]?.position, "a banned admin's tombstone is dropped, so the role survives") assertEquals(5, r.rank(bob), "and its holders keep their standing") @@ -268,7 +269,7 @@ class BannedStaffEscalationTest { // gates removals too, and strict outranking means nobody outranks themselves. val editions = community() + ownerBansAlice + banlist(alice, 1, ownerBansAlice.hash) - assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban") + assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "a banned member may not lift their own ban") } @Test @@ -278,7 +279,7 @@ class BannedStaffEscalationTest { // can outrank her, and so nothing she mints can unban her. val editions = community() + ownerBansAlice + aliceMintsAPuppet() + banlist(puppet, 1, ownerBansAlice.hash) - assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator") + assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "the puppet does not outrank its creator") } @Test @@ -289,22 +290,21 @@ class BannedStaffEscalationTest { // to win the head fold. The head's own effective list then never carried his ban, so there is // nothing to remove and the rank rule never fires. // - // §4's re-heal is what closes it: the owner's edition is authorized and is NOT on the forked - // head's back-chain, so it is unioned back in as a concurrent ban. + // A dangling high version never wins the fold: the chain anchors at the genesis (the lowest + // version without a `prev`), and the chain-verified head outranks every edition off it. val editions = community() + ownerBansAlice + banlist(alice, 99, null, carol) - assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "the re-heal union must put the owner's ban back") + assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "the owner's chain keeps the head") } @Test fun aPrivateBanlistChainOfHisOwnCannotLaunderTheBanAway() { // The same idea two editions deep, so the winning head has a clean ancestry entirely of his - // own making. Ancestry is walked over the full pool, so the owner's ban is still recognised - // as a concurrent fork rather than a superseded ancestor. + // own making. It is still a chain off the side of the genesis the owner's edition anchors. val mine = banlist(alice, 50, null) val editions = community() + ownerBansAlice + mine + banlist(alice, 51, mine.hash) - assertTrue(AuthorityResolver.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away") + assertTrue(ControlFixtures.resolve(editions, owner).isBanned(alice), "a self-authored chain must not launder the ban away") } @Test @@ -318,15 +318,15 @@ class BannedStaffEscalationTest { // whole defense, so this splits the community in two: clients that already folded the ban // refuse the rollback, while fresh joiners have no floor to refuse with and see no ban at all. val editions = community() + ownerBansAlice - val floors = ConcordCommunityState.authorizedHeads(editions, owner) + val floors = ControlFixtures.authorizedHeads(editions, owner) val compacted = editions.filter { it.entityKind != ControlEntityKind.BANLIST } assertFalse( - ConcordCommunityState.fold(compacted, owner).authority.isBanned(alice), + ControlFixtures.fold(compacted, owner).authority.isBanned(alice), "ESCALATION: a fresh joiner holds no floor, so the omitted ban simply never existed", ) assertTrue( - ConcordCommunityState.fold(compacted, owner, floors).authority.isBanned(alice), + ControlFixtures.fold(compacted, owner, floors).authority.isBanned(alice), "a client that already folded the ban must refuse the rollback", ) } @@ -340,10 +340,10 @@ class BannedStaffEscalationTest { // to re-issue. See B2 in docs/concord-soft-ban-audit.md. val promoted = grant("36".repeat(32), carol, listOf(modRole), author = alice) - val before = AuthorityResolver.resolve(community() + promoted, owner) + val before = ControlFixtures.resolve(community() + promoted, owner) assertEquals(5, before.rank(carol), "while alice is in good standing, her grant stands") - val after = AuthorityResolver.resolve(community() + promoted + ownerBansAlice, owner) + val after = ControlFixtures.resolve(community() + promoted + ownerBansAlice, owner) assertEquals(null, after.rank(carol), "banning alice retroactively drops the grant she authored") } @@ -356,7 +356,7 @@ class BannedStaffEscalationTest { // only honored when authored by someone who outranks it — the owner does, bob does not. val carolByOwner = grant("38".repeat(32), carol, listOf(modRole), author = owner) - val r = AuthorityResolver.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner) + val r = ControlFixtures.resolve(community() + ownerBansAlice + carolByBob + carolByOwner, owner) assertTrue(r.isBanned(alice), "alice is the only one banned") assertEquals(5, r.rank(bob), "bob is untouched") @@ -387,7 +387,7 @@ class BannedStaffEscalationTest { // ...while the owner concurrently bans the rogue, never naming bob ownerBansAlice - val r = AuthorityResolver.resolve(editions, owner) + val r = ControlFixtures.resolve(editions, owner) assertTrue(r.isBanned(alice), "the owner's ban of the rogue stands") assertFalse(r.isBanned(bob), "and the rogue's ban of the moderator falls with them") diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt index 16ca3ddc93..3f10bca9c6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEditionTest.kt @@ -21,11 +21,15 @@ package com.vitorpamplona.quartz.concord.cord04Roles import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation import com.vitorpamplona.quartz.concord.crypto.EditionHash +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.assertContentEquals import kotlin.test.assertEquals @@ -109,6 +113,77 @@ class ControlEditionTest { ) } + private fun parse(vararg tags: Array) = ControlEdition.fromRumor(RumorAssembler.assembleRumor(author, 1L, ControlEditionEvent.KIND, arrayOf(*tags), "{}")) + + private val cite = arrayOf("vac", ByteArray(32) { 0x02 }.toHexKey(), "2", ByteArray(32) { 0x03 }.toHexKey()) + + @Test + fun versionTagsMustBeCanonicalDecimals() { + // CORD-01 §5: no sign, no leading zeros. toLongOrNull() would read all of these as 4. + assertNotNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"))) + for (bad in listOf("04", "+4", "-4", "4.0", "0x4", "1e2", "", " 4")) { + assertNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", bad)), "ev '$bad'") + } + assertNotNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")), "a legacy v0 chain still reads") + + // The vac version too, and a malformed vac rejects the edition rather than reading as "no citation". + assertEquals(2L, parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"), cite)?.authorityCitation?.grantVersion) + for (bad in listOf("02", "+2")) { + val vac = arrayOf("vac", cite[1], bad, cite[3]) + assertNull(parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"), vac), "vac version '$bad'") + } + + // And the sub-kind: "03" is not the Grant sub-kind. + assertNull(parse(arrayOf("vsk", "03"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4"))) + } + + @Test + fun aDuplicatedMachineryTagMakesTheEditionInvalid() { + // Two readers could each take a different copy, so the edition is ambiguous (Armada `parseEdition`). + val base = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "4")) + assertNotNull(parse(*base)) + assertNull(parse(*base, arrayOf("vsk", "2")), "duplicate vsk") + assertNull(parse(*base, arrayOf("eid", ByteArray(32).toHexKey())), "duplicate eid") + assertNull(parse(*base, arrayOf("ev", "5")), "duplicate ev") + assertNull(parse(*base, arrayOf("ep", ByteArray(32).toHexKey()), arrayOf("ep", ByteArray(32) { 1 }.toHexKey())), "duplicate ep") + assertNull(parse(*base, cite, cite), "duplicate vac") + // Even a duplicate that fails to parse on its own: "04" beside "4" is still two ev tags. + assertNull(parse(*base, arrayOf("ev", "04")), "a second, non-canonical ev") + } + + @Test + fun subKindsThatAreNotControlEditionsAreRefusedAndUnknownOnesKept() { + // 6/9 belong to the kind-33301 invite marker, 7 is retired, 10 is the dissolution tombstone. + for (vsk in listOf("6", "7", "9", "10")) { + assertNull(parse(arrayOf("vsk", vsk), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1")), "vsk $vsk") + } + // A sub-kind this client does not model (Signals 12, anything newer) is kept, raw. + val signals = parse(arrayOf("vsk", "12"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1")) + assertNotNull(signals) + assertNull(signals.entityKind) + assertEquals("12", signals.vsk) + // Pins (11) are modeled. + assertEquals(ControlEntityKind.PIN_LIST, parse(arrayOf("vsk", "11"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))?.entityKind) + assertEquals(ControlEntityKind.CHANNEL.wire, parse(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1"))?.vsk) + } + + @Test + fun anEditionUnderAnEncryptedSealIsNotAControlEdition() = + runTest { + // CORD-02 §5: Control Plane seals MUST be plaintext (20014) — only those survive a + // compaction re-wrap with the author's signature intact. + val signer = NostrSignerInternal(KeyPair()) + val plane = ConcordKeyDerivation.controlPlaneKey(ByteArray(32) { 1 }, ByteArray(32) { 2 }, 0) + val rumor = ControlEditionBuilder.rumor(signer.pubKey, ControlEntityKind.CHANNEL, eid, 1, null, """{"name":"general"}""", 1L) + + val plaintext = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = false, createdAt = 1L), plane) + assertNotNull(ControlEdition.fromOpened(plaintext)) + + val encrypted = ConcordStreamEnvelope.open(ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = true, createdAt = 1L), plane) + assertEquals(ConcordStreamEnvelope.KIND_SEAL_ENCRYPTED, encrypted.sealKind) + assertNull(ControlEdition.fromOpened(encrypted)) + } + @Test fun genesisHasNullPrevWhenEpAbsent() { val tags = arrayOf(arrayOf("vsk", "2"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "0")) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityRoundTripTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityRoundTripTest.kt new file mode 100644 index 0000000000..4d3c7bee20 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityRoundTripTest.kt @@ -0,0 +1,93 @@ +/* + * 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.quartz.concord.cord04Roles + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +class ControlEntityRoundTripTest { + private fun parse(json: String): JsonObject = ConcordJson.instance.parseToJsonElement(json).jsonObject + + @Test + fun renameCarriesUnknownMetadataFieldsThrough() { + // CORD-02 §6: another client's `custom`, a newer protocol field, and the CORD-08 timer must + // all survive a rename by a client that models none of them. + val head = """{"name":"Old","message_expiration":2592000,"custom":{"rules":"be nice"},"av_brokers":["https://b.example"]}""" + val renamed = ConcordJson.decodeOrNull(head)!!.copy(name = "New") + val out = parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), renamed, head)) + assertEquals("New", (out["name"] as JsonPrimitive).content) + assertEquals(JsonPrimitive(2592000), out["message_expiration"]) + assertEquals(parse("""{"rules":"be nice"}"""), out["custom"]) + assertEquals(parse(head)["av_brokers"], out["av_brokers"]) + } + + @Test + fun aModeledFieldCanStillBeCleared() { + val head = """{"name":"X","description":"gone soon","custom":{"k":1}}""" + val cleared = ConcordJson.decodeOrNull(head)!!.copy(description = null) + val out = parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), cleared, head)) + assertFalse("description" in out) + assertEquals(parse("""{"k":1}"""), out["custom"]) + } + + @Test + fun channelEditKeepsLegacyVoiceFlagAndCustom() { + val head = """{"name":"lounge","private":false,"voice":true,"custom":{"topic":"x"}}""" + val renamed = ConcordJson.decodeOrNull(head)!!.copy(name = "hangout") + val out = parse(ConcordJson.encodePreserving(ChannelEntity.serializer(), renamed, head)) + assertEquals(JsonPrimitive(true), out["voice"]) + assertEquals(parse("""{"topic":"x"}"""), out["custom"]) + assertEquals(JsonPrimitive("hangout"), out["name"]) + } + + @Test + fun genesisHasNothingToPreserve() { + val out = parse(ConcordJson.encodePreserving(ChannelEntity.serializer(), ChannelEntity(name = "general"), null)) + assertEquals(JsonPrimitive("general"), out["name"]) + } + + @Test + fun messageExpirationParsesPerCord08() { + fun secs(json: String) = ConcordJson.decodeOrNull(json)!!.messageExpirationSecs() + assertEquals(2592000L, secs("""{"name":"a","message_expiration":2592000}""")) + assertEquals(86400L, secs("""{"name":"a","message_expiration":86400.9}""")) + assertNull(secs("""{"name":"a"}""")) + assertNull(secs("""{"name":"a","message_expiration":0}""")) + assertNull(secs("""{"name":"a","message_expiration":-5}""")) + assertNull(secs("""{"name":"a","message_expiration":"2592000"}""")) + assertNull(secs("""{"name":"a","message_expiration":{"v":1}}""")) + assertNull(secs("""{"name":"a","message_expiration":null}""")) + } + + @Test + fun settingAndClearingTheTimer() { + val on = MetadataEntity(name = "a").withMessageExpiration(604800) + assertEquals(604800L, on.messageExpirationSecs()) + val off = on.withMessageExpiration(null) + assertNull(off.messageExpirationSecs()) + assertFalse("message_expiration" in parse(ConcordJson.encodePreserving(MetadataEntity.serializer(), off, null))) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlFixtures.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlFixtures.kt new file mode 100644 index 0000000000..e4eb1d2ffe --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlFixtures.kt @@ -0,0 +1,102 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Shared scaffolding for tests that hand-build Control Plane editions. + * + * The fold pins every derived coordinate to the `community_id` (a Grant at its member's + * `grant_locator`, the Banlist at `banlist_locator`, Metadata at the id itself) and honors a + * non-owner edition only when it cites its author's Grant (`vac`, CORD-04 §5). Tests that are + * about something else build editions at [grantEid] / [banlistEid] / [COMMUNITY_ID_HEX] and run + * them through [cited], which stamps every uncited non-owner edition with the citation an honest + * client would have written — its author's Grant head as the fold sees it. + */ +object ControlFixtures { + const val COMMUNITY_ID_HEX = "c0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedf" + val communityId: ByteArray = COMMUNITY_ID_HEX.hexToByteArray() + + /** `grant_locator(community_id, member)` as hex. */ + fun grantEid( + member: String, + cid: ByteArray = communityId, + ): String = ConcordKeyDerivation.grantCoordinate(cid, member.hexToByteArray()).toHexKey() + + /** `banlist_locator(community_id)` as hex. */ + fun banlistEid(cid: ByteArray = communityId): String = ConcordKeyDerivation.banlistCoordinate(cid).toHexKey() + + /** + * [editions] with every non-owner edition that carries no `vac` given the citation its author + * would have written: their Grant head in the resolved roster. Iterated because a delegated + * granter's own Grant only resolves once the editions above it are cited. Editions that already + * carry a citation (a test's deliberate forgery, say) are left exactly as they are. + */ + fun cited( + editions: List, + owner: String, + cid: ByteArray = communityId, + ): List { + var current = editions + repeat(editions.size + 1) { + val authority = AuthorityResolver.resolve(current, cid, owner) + var changed = false + val next = + current.map { e -> + if (e.authorityCitation != null || e.author.equals(owner, ignoreCase = true)) { + e + } else { + authority.citationFor(e.author)?.let { + changed = true + e.withCitation(it) + } ?: e + } + } + if (!changed) return current + current = next + } + return current + } + + fun resolve( + editions: List, + owner: String, + cid: ByteArray = communityId, + ): AuthorityResolver = AuthorityResolver.resolve(cited(editions, owner, cid), cid, owner) + + fun fold( + editions: List, + owner: String, + floors: Map = emptyMap(), + cid: ByteArray = communityId, + ): ConcordCommunityState = ConcordCommunityState.fold(cited(editions, owner, cid), cid, owner, floors) + + fun authorizedHeads( + editions: List, + owner: String, + floors: Map = emptyMap(), + cid: ByteArray = communityId, + ): Map = ConcordCommunityState.authorizedHeads(cited(editions, owner, cid), cid, owner, floors) +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneConformanceTest.kt new file mode 100644 index 0000000000..70af96e332 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneConformanceTest.kt @@ -0,0 +1,293 @@ +/* + * 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.quartz.concord.cord04Roles + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions.Companion.BAN +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * CORD-04 §1/§2/§5 fold rules the reference client (Armada `control.ts`) enforces and this client + * did not: Grant coordinates bound to their member (S5), authority-first equal-version ties (S7), + * the `vac` authority citation (I4), `role_id` in Role content (I5) and the Role caps (I17). + */ +class ControlPlaneConformanceTest { + private val owner = "0f".repeat(32) + private val alice = "a1".repeat(32) + private val bob = "b2".repeat(32) + private val carol = "c3".repeat(32) + + private val adminRole = "11".repeat(32) + private val modRole = "22".repeat(32) + + private val cid = ControlFixtures.communityId + + private fun ed( + kind: ControlEntityKind, + eid: String, + version: Long, + prev: ControlEdition?, + content: String, + author: String, + rumorId: String, + vac: AuthorityCitation? = null, + ) = ControlEdition(kind, eid.hexToByteArray(), version, prev?.hash, vac, content, author, rumorId, version) + + private fun roleJson( + name: String, + position: Int, + permissions: String, + roleId: String? = null, + ) = if (roleId == null) { + """{"name":"$name","position":$position,"permissions":"$permissions"}""" + } else { + """{"role_id":"$roleId","name":"$name","position":$position,"permissions":"$permissions"}""" + } + + private fun grantJson( + member: String, + roleIds: List, + ) = """{"member":"$member","role_ids":[${roleIds.joinToString(",") { "\"$it\"" }}]}""" + + // Admin: position 1, MANAGE_ROLES|KICK|BAN = 25. Mod: position 5, KICK|BAN = 24. + private val adminDef = ed(ControlEntityKind.ROLE, adminRole, 1, null, roleJson("Admin", 1, "25"), owner, "role-admin") + private val modDef = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24"), owner, "role-mod") + private val aliceIsAdmin = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(adminRole)), owner, "grant-alice") + + private fun banlist( + version: Long, + prev: ControlEdition?, + author: String, + rumorId: String, + vararg banned: String, + vac: AuthorityCitation? = null, + ) = ed(ControlEntityKind.BANLIST, ControlFixtures.banlistEid(), version, prev, "[${banned.joinToString(",") { "\"$it\"" }}]", author, rumorId, vac) + + private fun citation( + grant: ControlEdition, + version: Long = grant.version, + hash: ByteArray = grant.hash, + ) = AuthorityCitation(grant.entityId, version, hash) + + // ---- S5: a Grant only counts at its member's own coordinate -------------------------------- + + @Test + fun aGrantChainAtAForeignCoordinateCannotOverrideTheMembersOwn() { + // grant_locator(community_id, member) is THE Grant coordinate (CORD-04 §1). A second chain for + // the same member anywhere else used to be grouped as its own entity, and whichever chain the + // map iterated last set the member's roles — so a MANAGE_ROLES holder could override the + // owner's revoke, or the owner's grant, by arrival order. + val foreignRevoke = ed(ControlEntityKind.GRANT, "99".repeat(32), 7, null, grantJson(alice, emptyList()), owner, "foreign") + for (order in listOf(listOf(adminDef, aliceIsAdmin, foreignRevoke), listOf(foreignRevoke, adminDef, aliceIsAdmin))) { + val r = AuthorityResolver.resolve(order, cid, owner) + assertEquals(setOf(adminRole), r.rolesOf(alice), "a Grant at a foreign coordinate is not alice's Grant") + } + + // And a foreign-coordinate grant alone confers nothing, however well signed. + val foreignGrant = ed(ControlEntityKind.GRANT, "98".repeat(32), 1, null, grantJson(bob, listOf(adminRole)), owner, "foreign-bob") + assertNull(AuthorityResolver.resolve(listOf(adminDef, foreignGrant), cid, owner).rank(bob)) + } + + // ---- S7: equal-version ties go to authority first ------------------------------------------ + + @Test + fun anEqualVersionRoleForkGoesToTheOwnerOverALowerRumorId() { + // Alice (Admin, MANAGE_ROLES at position 1) and the owner both edit the Mod role at v2. Alice's + // rumor id sorts first — rumor ids are grindable — but authority decides (CORD-04 §1). + val aliceV2 = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Alice's", 5, "8"), alice, "0000") + val ownerV2 = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Owner's", 5, "8"), owner, "ffff") + val r = ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, aliceV2, ownerV2), owner) + assertEquals("Owner's", r.roles()[modRole]?.name) + + // Among peers of equal rank the lower rumor id still settles it. + val ownerV2b = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("Owner's other", 5, "8"), owner, "0001") + assertEquals("Owner's other", ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, ownerV2, ownerV2b), owner).roles()[modRole]?.name) + } + + @Test + fun anEqualVersionGrantForkGoesToTheHigherRankedGranter() { + // Bob's Grant at v2: alice (rank 1) makes him Mod, the owner (rank 0) revokes him, same version. + val bobV1 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, emptyList()), owner, "bob-1") + val aliceV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 2, bobV1, grantJson(bob, listOf(modRole)), alice, "0000") + val ownerV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 2, bobV1, grantJson(bob, emptyList()), owner, "ffff") + val r = ControlFixtures.resolve(listOf(adminDef, modDef, aliceIsAdmin, bobV1, aliceV2, ownerV2), owner) + assertNull(r.rank(bob), "the owner's revoke wins the tie") + } + + @Test + fun pickHeadBreaksAnEqualVersionTieOnRankBeforeRumorId() { + val low = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 3, null, "{}", alice, "0000") + val high = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 3, null, "{}", owner, "ffff") + val older = ed(ControlEntityKind.CHANNEL, "cc".repeat(32), 2, null, "{}", owner, "0001") + val rank: AuthorRank = { if (it == owner) 0L else 1L } + + assertEquals(high, EditionFold.pickHead(listOf(low, high, older), rank) { true }) + assertEquals(low, EditionFold.pickHead(listOf(low, high, older), null) { true }, "without a rank: first passing candidate") + assertEquals(low, EditionFold.pickHead(listOf(low, high, older), rank) { it !== high }, "a gated-out sibling never wins") + assertEquals(older, EditionFold.pickHead(listOf(low, high, older), rank) { it === older }, "a lower version only when nothing above passes") + } + + // ---- I4: the vac authority citation -------------------------------------------------------- + + @Test + fun aNonOwnerEditionWithoutACitationIsDropped() { + // Alice holds BAN, but her banlist edition does not cite the Grant she acts under. + val uncited = banlist(1, null, alice, "b1", carol) + assertFalse(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, uncited), cid, owner).isBanned(carol)) + + // The identical edition citing her Grant head is honored. + val cited = banlist(1, null, alice, "b1", carol, vac = citation(aliceIsAdmin)) + assertTrue(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, cited), cid, owner).isBanned(carol)) + + // The owner cites nothing. + assertTrue(AuthorityResolver.resolve(listOf(banlist(1, null, owner, "b1", carol)), cid, owner).isBanned(carol)) + } + + @Test + fun aChannelEditionWithoutACitationIsDroppedToo() { + // Channels fold under their own gate (the name rule), which must still require the vac. + val channelsRole = "33".repeat(32) + val channelsDef = ed(ControlEntityKind.ROLE, channelsRole, 1, null, roleJson("Chans", 3, ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire()), owner, "role-chans") + val aliceChans = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(channelsRole)), owner, "grant-alice-chans") + val channelId = "ce".repeat(32) + + fun channel(vac: AuthorityCitation?) = ed(ControlEntityKind.CHANNEL, channelId, 1, null, """{"name":"news"}""", alice, "chan-1", vac) + + val uncited = ConcordCommunityState.fold(listOf(channelsDef, aliceChans, channel(null)), cid, owner) + assertTrue(uncited.channels.isEmpty(), "an uncited channel edition is not honored") + val cited = ConcordCommunityState.fold(listOf(channelsDef, aliceChans, channel(citation(aliceChans))), cid, owner) + assertEquals("news", cited.channels[channelId]?.definition?.name) + } + + @Test + fun aCitationMustNameTheActorsOwnGrantAtAVersionAndHashTheVerifierHolds() { + val bobIsMod = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(modRole)), owner, "grant-bob") + val base = listOf(adminDef, modDef, aliceIsAdmin, bobIsMod) + + fun bansCarolCiting(vac: AuthorityCitation) = AuthorityResolver.resolve(base + banlist(1, null, alice, "b1", carol, vac = vac), cid, owner).isBanned(carol) + + assertTrue(bansCarolCiting(citation(aliceIsAdmin))) + assertFalse(bansCarolCiting(citation(bobIsMod)), "someone else's Grant is not the actor's authority") + assertFalse(bansCarolCiting(citation(aliceIsAdmin, version = 2)), "a version the verifier has not synced parks the action") + assertFalse(bansCarolCiting(citation(aliceIsAdmin, hash = ByteArray(32) { 7 })), "a forked or forged hash parks it too") + } + + @Test + fun aCitationOfASupersededGrantPassesTheSyncFloorButRankStillDecides() { + // The head moved on (compaction discards superseded versions), so an older citation is + // satisfied — the verdict is the CURRENT roster's. Promoted: honored. Demoted: dropped. + val aliceV2 = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 2, aliceIsAdmin, grantJson(alice, listOf(adminRole)) + " ", owner, "grant-alice-2") + val ban = banlist(1, null, alice, "b1", carol, vac = citation(aliceIsAdmin)) + assertTrue(AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, aliceV2, ban), cid, owner).isBanned(carol)) + + val demoted = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 2, aliceIsAdmin, grantJson(alice, emptyList()), owner, "grant-alice-revoke") + assertFalse( + AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin, demoted, ban), cid, owner).isBanned(carol), + "citing the old valid Grant grandfathers nothing", + ) + } + + @Test + fun delegatedGrantsNeedTheGranterToCiteTheirOwnGrant() { + // Alice (Admin) makes bob a Mod. Without her citation the grant does not stand. + val bobByAlice = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(modRole)), alice, "grant-bob") + assertNull(AuthorityResolver.resolve(listOf(adminDef, modDef, aliceIsAdmin, bobByAlice), cid, owner).rank(bob)) + val cited = bobByAlice.withCitation(citation(aliceIsAdmin)) + assertEquals(5L, AuthorityResolver.resolve(listOf(adminDef, modDef, aliceIsAdmin, cited), cid, owner).rank(bob)) + } + + @Test + fun authorityCitationsCiteTheActorsFoldedGrantHead() { + val r = AuthorityResolver.resolve(listOf(adminDef, aliceIsAdmin), cid, owner) + + assertNull(AuthorityCitations.forActor(r, owner), "the owner cites nothing") + assertNull(AuthorityCitations.forActor(r, carol), "nor does someone holding no Grant") + + val vac = AuthorityCitations.forActor(r, alice) + assertNotNull(vac) + assertEquals(ControlFixtures.grantEid(alice), vac.grantId.toHexKey()) + assertEquals(aliceIsAdmin.version, vac.grantVersion) + assertContentEquals(aliceIsAdmin.hash, vac.grantHash) + assertTrue(AuthorityCitations.isSatisfied(r, alice, vac)) + + // The editions overload folds the same roster. + assertContentEquals(vac.grantHash, AuthorityCitations.forActor(listOf(adminDef, aliceIsAdmin), cid, owner, alice)?.grantHash) + } + + // ---- I5: role_id --------------------------------------------------------------------------- + + @Test + fun aRoleWhoseRoleIdNamesAnotherCoordinateIsRefused() { + val grant = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(modRole)), owner, "g") + + val matching = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24", roleId = modRole), owner, "r") + assertEquals(5L, AuthorityResolver.resolve(listOf(matching, grant), cid, owner).rank(alice)) + + val legacy = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24"), owner, "r") + assertEquals(5L, AuthorityResolver.resolve(listOf(legacy, grant), cid, owner).rank(alice), "a legacy role without role_id still reads") + + val mismatched = ed(ControlEntityKind.ROLE, modRole, 1, null, roleJson("Mod", 5, "24", roleId = adminRole), owner, "r") + assertNull(AuthorityResolver.resolve(listOf(mismatched, grant), cid, owner).rank(alice), "role_id must equal the eid") + } + + // ---- I17: caps ----------------------------------------------------------------------------- + + @Test + fun aRoleNamePastSixtyFourBytesFallsBackToThePreviousEdition() { + val renamed = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("m".repeat(65), 5, "24"), owner, "r2") + assertEquals("Mod", ControlFixtures.resolve(listOf(modDef, renamed), owner).roles()[modRole]?.name) + val atCap = ed(ControlEntityKind.ROLE, modRole, 2, modDef, roleJson("m".repeat(64), 5, "24"), owner, "r2") + assertEquals("m".repeat(64), ControlFixtures.resolve(listOf(modDef, atCap), owner).roles()[modRole]?.name) + } + + private fun roleIdOf(i: Int) = i.toString(16).padStart(64, '0') + + @Test + fun aMemberHoldsAtMostSixtyFourRoles() { + val roles = (1..70).map { ed(ControlEntityKind.ROLE, roleIdOf(it), 1, null, roleJson("R$it", 10 + it, "8"), owner, "r$it") } + val everything = (1..70).map { roleIdOf(it) } + val grant = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, everything), owner, "g") + + val held = AuthorityResolver.resolve(roles + grant, cid, owner).rolesOf(alice) + assertEquals(everything.take(64).toSet(), held, "the first 64 role_ids, the rest ignored") + } + + @Test + fun aCommunityFoldsAtMostOneHundredRolesTheLowestRoleIds() { + val roles = (1..101).map { ed(ControlEntityKind.ROLE, roleIdOf(it), 1, null, roleJson("R$it", 10, "16"), owner, "r$it") } + val lowest = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(alice), 1, null, grantJson(alice, listOf(roleIdOf(1))), owner, "ga") + val highest = ed(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 1, null, grantJson(bob, listOf(roleIdOf(101))), owner, "gb") + + val r = AuthorityResolver.resolve(roles.reversed() + lowest + highest, cid, owner) + assertEquals(100, r.roles().size) + assertTrue(roleIdOf(101) !in r.roles(), "the highest role_id is the one ignored") + assertTrue(r.hasPermission(alice, BAN)) + assertFalse(r.hasPermission(bob, BAN), "a grant of an ignored role confers nothing") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt index c97ed0dba9..aac6221104 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.quartz.concord.cord04Roles -import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import kotlin.test.Test import kotlin.test.assertEquals @@ -60,7 +59,7 @@ import kotlin.test.assertTrue * it now picks the chain head instead. * * The banlist was the one entity that survived, and by accident: `AuthorityResolver` folds it with - * its own floor-less chain walk and then re-heals the union across authorized editions, so an + * its own floor-less chain walk, where the chain-verified head outranks a dangling version, so an * honest ban landed even when the head was poisoned. [aPoisonedBanlistStillAcceptsTheOwnersBan] * keeps pinning that, because it was the only thing standing between this bug and a permanently * unmoderatable community. @@ -70,9 +69,9 @@ class ControlPlaneVersionExhaustionTest { private val bob = "b2".repeat(32) private val modRole = "22".repeat(32) - private val metadataEntity = "66".repeat(32) + private val metadataEntity = ControlFixtures.COMMUNITY_ID_HEX private val channelEntity = "55".repeat(32) - private val banlistEntity = "44".repeat(32) + private val banlistEntity = ControlFixtures.banlistEid() private fun edition( kind: ControlEntityKind, @@ -90,7 +89,7 @@ class ControlPlaneVersionExhaustionTest { vararg rest: ControlEdition, ) = listOf( edition(ControlEntityKind.ROLE, modRole, 0, null, """{"name":"Mod","position":5,"permissions":"$permissions"}""", owner, "role-mod"), - edition(ControlEntityKind.GRANT, "32".repeat(32), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"), + edition(ControlEntityKind.GRANT, ControlFixtures.grantEid(bob), 0, null, """{"member":"$bob","role_ids":["$modRole"]}""", owner, "grant-bob"), ) + rest @Test @@ -99,11 +98,11 @@ class ControlPlaneVersionExhaustionTest { val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) // A client that has folded this community once holds a floor for the metadata entity. - val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + val floorsBefore = ControlFixtures.authorizedHeads(community, owner) assertEquals(0, floorsBefore[metadataEntity]?.version, "an ordinary floor at the genesis edition") val poison = edition(ControlEntityKind.METADATA, metadataEntity, Long.MAX_VALUE, metadataV0.hash, """{"name":"PWNED"}""", bob, "meta-poison") - val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore) assertEquals(0, floorsAfter[metadataEntity]?.version, "the floor must not follow a stray to the top of the version space") // The owner tries to repair it, chaining honestly onto their own genesis. @@ -112,39 +111,66 @@ class ControlPlaneVersionExhaustionTest { assertEquals( "My Community", - ConcordCommunityState.fold(pool, owner).metadata?.name, + ControlFixtures.fold(pool, owner).metadata?.name, "a fresh joiner walks the chain and is unaffected", ) assertEquals( "My Community", - ConcordCommunityState.fold(pool, owner, floorsAfter).metadata?.name, + ControlFixtures.fold(pool, owner, floorsAfter).metadata?.name, "a client holding a floor follows the honest chain, not the stray", ) assertEquals( "My Community", - ConcordCommunityState.fold(community + repair, owner, floorsAfter).metadata?.name, + ControlFixtures.fold(community + repair, owner, floorsAfter).metadata?.name, "and a Refounding that drops the poison stays repaired", ) } + /** + * The poison here renames rather than deletes: CORD-03 §2 makes a Channel deletion by any + * authorized holder terminal across the whole accepted edition set (the reference client's + * `everDeleted`), whatever its version, so a max-version *delete* from bob now retires the + * Channel by design. What must still hold is that a max-version edition cannot pin the Channel + * to bob's content. + */ @Test - fun oneEditionAtMaxVersionNoLongerDeletesAChannel() { + fun oneEditionAtMaxVersionNoLongerPinsAChannel() { val channelV0 = edition(ControlEntityKind.CHANNEL, channelEntity, 0, null, """{"name":"general"}""", owner, "chan-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_CHANNELS).toWire(), channelV0) - val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) - val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"general","deleted":true}""", bob, "chan-poison") - val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + val floorsBefore = ControlFixtures.authorizedHeads(community, owner) + val poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"PWNED"}""", bob, "chan-poison") + val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore) val repair = edition(ControlEntityKind.CHANNEL, channelEntity, 1, channelV0.hash, """{"name":"general"}""", owner, "chan-1") val pool = community + poison + repair - assertEquals(1, ConcordCommunityState.fold(pool, owner).channels.size, "a fresh joiner still sees the channel") - assertEquals(1, ConcordCommunityState.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor") assertEquals( - 1, - ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size, - "the channel survives a Refounding too", + "general", + ControlFixtures + .fold(pool, owner) + .channels[channelEntity] + ?.definition + ?.name, + "a fresh joiner follows the honest chain", + ) + assertEquals( + "general", + ControlFixtures + .fold(pool, owner, floorsAfter) + .channels[channelEntity] + ?.definition + ?.name, + "and so does a client holding a floor", + ) + assertEquals( + "general", + ControlFixtures + .fold(community + repair, owner, floorsAfter) + .channels[channelEntity] + ?.definition + ?.name, + "the channel stays repaired across a Refounding too", ) } @@ -152,25 +178,25 @@ class ControlPlaneVersionExhaustionTest { fun aPoisonedBanlistStillAcceptsTheOwnersBan() { // This was the saving grace before the fix — the reason the bug was "community with a broken // name" rather than "community nobody can moderate". AuthorityResolver folds the banlist on - // its own floor-less chain walk and re-heals the union across every authorized edition, so - // the owner's ban landed even while the banlist's floor sat at Long.MAX_VALUE. The floor can - // no longer be poisoned, but keep this: do not "unify" the banlist onto the floored fold - // without replacing the protection. + // its own floor-less chain walk, where the chain-verified head (the owner's v1) outranks any + // dangling higher version, so the owner's ban lands even while the banlist's floor sat at + // Long.MAX_VALUE. (It used to union every authorized fork too; CORD-04 §4 folds to one head.) + // The floor can no longer be poisoned, but keep this. val banlistV0 = edition(ControlEntityKind.BANLIST, banlistEntity, 0, null, "[]", owner, "ban-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.BAN).toWire(), banlistV0) - val floorsBefore = ConcordCommunityState.authorizedHeads(community, owner) + val floorsBefore = ControlFixtures.authorizedHeads(community, owner) val poison = edition(ControlEntityKind.BANLIST, banlistEntity, Long.MAX_VALUE, banlistV0.hash, "[]", bob, "ban-poison") - val floorsAfter = ConcordCommunityState.authorizedHeads(community + poison, owner, floorsBefore) + val floorsAfter = ControlFixtures.authorizedHeads(community + poison, owner, floorsBefore) assertEquals(0, floorsAfter[banlistEntity]?.version, "the banlist floor is no longer poisonable either") val ownerBansBob = edition(ControlEntityKind.BANLIST, banlistEntity, 1, banlistV0.hash, """["$bob"]""", owner, "ban-1") val pool = community + poison + ownerBansBob - assertTrue(ConcordCommunityState.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban") + assertTrue(ControlFixtures.fold(pool, owner).authority.isBanned(bob), "a fresh joiner honors the ban") assertTrue( - ConcordCommunityState.fold(pool, owner, floorsAfter).authority.isBanned(bob), - "the re-heal union must keep the banlist working even with a poisoned floor", + ControlFixtures.fold(pool, owner, floorsAfter).authority.isBanned(bob), + "the chain-verified head keeps the banlist working even with a poisoned floor", ) } @@ -182,14 +208,14 @@ class ControlPlaneVersionExhaustionTest { // so it connects to nothing, and its version is legitimately several ahead of our floor. val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) - val floors = ConcordCommunityState.authorizedHeads(community, owner) + val floors = ControlFixtures.authorizedHeads(community, owner) val danglingPrev = ByteArray(32) { 0x7f } val compacted = edition(ControlEntityKind.METADATA, metadataEntity, 4, danglingPrev, """{"name":"Renamed While We Were Away"}""", owner, "meta-compacted") assertEquals( "Renamed While We Were Away", - ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name, + ControlFixtures.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + compacted, owner, floors).metadata?.name, "a compacted head whose prev dangles by design must still be adopted", ) } @@ -200,7 +226,7 @@ class ControlPlaneVersionExhaustionTest { // missed, so the fold reports a gap and keeps what it already had rather than following it. val metadataV0 = edition(ControlEntityKind.METADATA, metadataEntity, 0, null, """{"name":"My Community"}""", owner, "meta-0") val community = communityWhereBobHolds(ConcordPermissions.of(ConcordPermissions.MANAGE_METADATA).toWire(), metadataV0) - val floors = ConcordCommunityState.authorizedHeads(community, owner) + val floors = ControlFixtures.authorizedHeads(community, owner) val danglingPrev = ByteArray(32) { 0x7f } val tooFar = @@ -216,7 +242,7 @@ class ControlPlaneVersionExhaustionTest { assertEquals( "My Community", - ConcordCommunityState.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name, + ControlFixtures.fold(community.filter { it.entityKind != ControlEntityKind.METADATA } + tooFar, owner, floors).metadata?.name, "a jump past the bound is a gap, not a head", ) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt new file mode 100644 index 0000000000..5a86d25d55 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt @@ -0,0 +1,190 @@ +/* + * 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.quartz.concord.cord04Roles.pins + +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +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 + +class ConcordPinsTest { + private val alice = NostrSignerInternal(KeyPair()) + private val mallory = NostrSignerInternal(KeyPair()) + private val root = ByteArray(32) { 7 } + private val channelA = "aa".repeat(32) + private val channelB = "bb".repeat(32) + private val plane = ConcordChannelKeys.publicChannel(root, channelA.hexToByteArray(), 3) + + private suspend fun sendAndOpen( + rumor: Event, + signer: NostrSignerInternal = alice, + encrypted: Boolean = true, + ) = ConcordStreamEnvelope.wrap(rumor, plane, signer, encrypted = encrypted).let { wrap -> wrap.id to ConcordStreamEnvelope.open(wrap, plane) } + + @Test + fun theCoordinateMatchesTheSpecDerivation() { + // A.6: hkdf(ikm = community_id, salt = empty, info = "concord/pins" ‖ 0x00 ‖ channel_id), 32 bytes. + // Expected value computed independently with Node's crypto.hkdfSync. + assertEquals( + "13ff5d549aa39c9f11993e2066c9122700a622553fddf22e68c107d3ea00d242", + ConcordPinLists.coordinate("11".repeat(32), "22".repeat(32)), + ) + } + + @Test + fun aPinnedMessageVerifiesFromTheEntryAlone() = + runTest { + val rumor = ChannelChat.message(alice.pubKey, channelA, 3, "ship it", 1_700_000_000) + val (wrapId, opened) = sendAndOpen(rumor) + val entry = assertNotNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, wrapId)) + + // A reader holding nothing but the entry and the list's channel. + val pin = assertNotNull(ConcordPins.verify(entry, channelA)) + assertEquals(rumor.id, pin.rumorId) + assertEquals(alice.pubKey, pin.author) + assertEquals("ship it", pin.content) + assertEquals("3", pin.epoch) + assertEquals(wrapId, pin.wrapHint) + assertFalse(pin.edited) + } + + @Test + fun theDisclosureIsOneMessagesKeysNotTheConversationKey() = + runTest { + val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "hi", 1)) + val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!! + val keys = (entry["keys"] as JsonPrimitive).content + assertEquals(152, keys.length) + assertFalse(keys.contains(plane.conversationKey.toHexKey())) + // The same keys do not open a second message on the same plane. + val (_, other) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "other", 2)) + assertNull(PinKeyDisclosure.decryptWith(other.seal.content, PinKeyDisclosure.decode(keys)!!)) + } + + @Test + fun aPinReplayedIntoAnotherChannelsListFails() = + runTest { + val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "private words", 1)) + val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!! + assertNull(ConcordPins.verify(entry, channelB), "the channel binding is step 4") + } + + @Test + fun tamperedKeysOrSealAreDroppedAlone() = + runTest { + val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "x", 1)) + val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!! + val badKeys = JsonObject(entry + ("keys" to JsonPrimitive("00".repeat(76)))) + assertNull(ConcordPins.verify(badKeys, channelA)) + val seal = entry["seal"] as JsonObject + val forgedSeal = JsonObject(seal + ("pubkey" to JsonPrimitive(mallory.pubKey))) + assertNull(ConcordPins.verify(JsonObject(entry + ("seal" to forgedSeal)), channelA)) + assertNull(ConcordPins.verify(JsonObject(entry - "keys"), channelA)) + } + + @Test + fun onlyMessagesAndRepliesArePinnable() = + runTest { + val reaction = ChannelChat.reaction(alice.pubKey, channelA, 3, "cc".repeat(32), alice.pubKey, 9, "+", 1) + val (_, opened) = sendAndOpen(reaction) + assertNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)) + } + + @Test + fun aPlaintextSealCannotBePinned() = + runTest { + val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "x", 1), encrypted = false) + assertNull(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)) + } + + @Test + fun anEditByTheAuthorRevisesThePinAndAForeignEditIsIgnored() = + runTest { + val original = ChannelChat.message(alice.pubKey, channelA, 3, "teh plan", 1) + val (_, opened) = sendAndOpen(original) + val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!! + + val (_, edit) = sendAndOpen(ChannelChat.edit(alice.pubKey, channelA, 3, original.id, "the plan", 2)) + val edited = ConcordPins.withEdit(entry, edit, plane.conversationKey, channelA) + val pin = ConcordPins.verify(edited, channelA)!! + assertTrue(pin.edited) + assertEquals("the plan", pin.content) + assertEquals(original.id, pin.rumorId, "the identity stays the original's") + + val (_, foreign) = sendAndOpen(ChannelChat.edit(mallory.pubKey, channelA, 3, original.id, "pwned", 3), signer = mallory) + assertEquals(edited, ConcordPins.withEdit(edited, foreign, plane.conversationKey, channelA), "an unprovable edit never downgrades the entry") + } + + @Test + fun publicAndSealedListsRoundTrip() = + runTest { + val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "pinned", 1)) + val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!! + + val public = ConcordPins.read(ConcordPins.serializePublic(listOf(entry))) { null } + assertEquals(listOf(entry), public.entries) + + val sealed = ConcordPins.serializeSealed(listOf(entry), plane.conversationKey, 3) + val opened3 = ConcordPins.read(sealed) { epoch -> plane.conversationKey.takeIf { epoch == 3L } } + assertEquals(listOf(entry), opened3.entries) + val noKey = ConcordPins.read(sealed) { null } + assertTrue(noKey.sealedUnavailable, "unreadable is not empty: a writer must not build on it") + assertTrue(noKey.entries.isEmpty()) + } + + @Test + fun capsReadAsEmptyAndRefuseToWrite() = + runTest { + val (_, opened) = sendAndOpen(ChannelChat.message(alice.pubKey, channelA, 3, "p", 1)) + val entry = ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!! + val tooMany = List(ConcordPins.MAX_ENTRIES + 1) { entry } + assertFailsWith { ConcordPins.serializePublic(tooMany) } + val handMade = """{"entries":[${tooMany.joinToString(",") { it.toString() }}]}""" + assertTrue(ConcordPins.read(handMade) { null }.violating) + assertTrue(ConcordPins.read("x".repeat(ConcordPins.MAX_CONTENT_BYTES + 1)) { null }.violating) + assertTrue(ConcordPins.read("not json") { null }.violating) + } + + @Test + fun onlyTheAuthorsDeleteKillsAPin() = + runTest { + val rumor = ChannelChat.message(alice.pubKey, channelA, 3, "oops", 1) + val (_, opened) = sendAndOpen(rumor) + val pin = ConcordPins.verify(ConcordPins.buildEntry(opened, plane.conversationKey, channelA, null)!!, channelA)!! + val tags = arrayOf(arrayOf("e", rumor.id), arrayOf("k", "9")) + assertTrue(ConcordPins.killedBy(pin, alice.pubKey, tags)) + assertFalse(ConcordPins.killedBy(pin, mallory.pubKey, tags)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt index 064ec219e7..dfe2fa8070 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteClassifyTest.kt @@ -31,9 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.crypto.verify import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -55,21 +58,14 @@ class ConcordInviteClassifyTest { name = "Nostrichs", ) - /** A raw kind-33301 event at the link-signer coordinate carrying an arbitrary [vsk] wire value. */ + /** A kind-33301 event at the coordinate of [linkSigner], signed by it, carrying an arbitrary [vsk] wire value. */ private fun coordinateEvent( - linkSignerPubKey: String, + linkSigner: ByteArray, vsk: String, createdAt: Long, content: String = "", - ) = Event( - id = "00".repeat(32), - pubKey = linkSignerPubKey, - createdAt = createdAt, - kind = ConcordInviteBundleEvent.KIND, - tags = arrayOf(arrayOf("d", ""), VskTag.TAG_NAME.let { arrayOf(it, vsk) }), - content = content, - sig = "00".repeat(64), - ) + dTag: String = "", + ): Event = NostrSignerSync(KeyPair(privKey = linkSigner)).sign(createdAt, ConcordInviteBundleEvent.KIND, arrayOf(arrayOf("d", dTag), arrayOf(VskTag.TAG_NAME, vsk)), content) @Test fun liveBundleOpens() = @@ -77,7 +73,7 @@ class ConcordInviteClassifyTest { val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) - val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token) + val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token) assertTrue(status is InviteBundleStatus.Live) assertEquals(community.communityIdHex, status.invite.communityId) } @@ -89,11 +85,11 @@ class ConcordInviteClassifyTest { val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) // A newer vsk=9 tombstone at the same coordinate buries the still-openable bundle. - val tombstone = coordinateEvent(minted.linkSignerPubKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L) + val tombstone = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L) - assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.token)) + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, tombstone), minted.linkSignerPubKey, minted.token)) // Order of the fetched list must not matter — newest createdAt wins regardless. - assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.token)) + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(tombstone, minted.bundleEvent), minted.linkSignerPubKey, minted.token)) } @Test @@ -111,7 +107,7 @@ class ConcordInviteClassifyTest { // Both fetch orders must resolve to the re-mint — `fetchAll` gives no ordering guarantee. listOf(listOf(minted.bundleEvent, remint), listOf(remint, minted.bundleEvent)).forEach { wraps -> - val status = ConcordInviteBundle.classify(wraps, minted.token) + val status = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token) assertTrue(status is InviteBundleStatus.Live) assertEquals("bb".repeat(32), status.invite.communityRoot) assertEquals(2L, status.invite.rootEpoch) @@ -123,8 +119,9 @@ class ConcordInviteClassifyTest { runTest { // A mis-posted registry (vsk=8) at the bundle coordinate — the exact shape of the // relayop.xyz link that hung — is present but not a vsk=6 bundle we can open. - val registry = coordinateEvent("aa".repeat(32), ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable") - assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), ByteArray(16))) + val signer = KeyPair() + val registry = coordinateEvent(signer.privKey!!, ControlEntityKind.INVITE_REGISTRY.wire, createdAt = 1L, content = "unopenable") + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(registry), signer.pubKey.toHexKey(), ByteArray(16))) } /** @@ -152,11 +149,11 @@ class ConcordInviteClassifyTest { val wraps = listOf(minted.bundleEvent) // Before the expiry the very same bundle still opens… - val live = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs - 1) + val live = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs - 1) assertTrue(live is InviteBundleStatus.Live) // …and after it, the join path must refuse it (not Live) while the preview data survives. - val expired = ConcordInviteBundle.classify(wraps, minted.token, nowMs = expiresAtMs + 1) + val expired = ConcordInviteBundle.classify(wraps, minted.linkSignerPubKey, minted.token, nowMs = expiresAtMs + 1) assertTrue(expired is InviteBundleStatus.Expired) assertEquals(community.communityIdHex, expired.invite.communityId) } @@ -167,12 +164,12 @@ class ConcordInviteClassifyTest { runTest { val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L, relays = listOf("wss://relay.example")) - assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live) + assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token, nowMs = Long.MAX_VALUE) is InviteBundleStatus.Live) } @Test fun emptyFetchIsAbsent() { - assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), ByteArray(16))) + assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(emptyList(), "aa".repeat(32), ByteArray(16))) } @Test @@ -201,12 +198,12 @@ class ConcordInviteClassifyTest { // End to end: what the creator publishes is what every redeemer then resolves. val grave = ConcordInviteBundle.buildRevocation(minted.linkSignerPrivKey, createdAt = 2L) - assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.token)) + assertEquals(InviteBundleStatus.Revoked, ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave), minted.linkSignerPubKey, minted.token)) // And a re-mint that lands AFTER the grave un-revokes the link, which is exactly why the // refresh path must skip a coordinate it did not resolve Live first. val remint = ConcordInviteBundle.build(minted.linkSignerPrivKey, minted.token, inviteFor(community), createdAt = 3L) - assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.token) is InviteBundleStatus.Live) + assertTrue(ConcordInviteBundle.classify(listOf(minted.bundleEvent, grave, remint), minted.linkSignerPubKey, minted.token) is InviteBundleStatus.Live) } @Test @@ -218,6 +215,86 @@ class ConcordInviteClassifyTest { """{"content":"ApoDjyzcHUg2imEiqw6Gsfpc2O86r+CMtMor+jc8ZlgrYwlI6CCmX7qGGEQvEJ5537nINE9H09Ro8RtEghpYgwkhdPHS274RpklFmuyLMdcoC5u1EVhppu8BrlHZ0YBfw3GX1Ui0uwy3V/J+rvrYiLhdREmwlK39JAX8sZfzCUhVtDMCgLVy03dwdpTC1Kj/ZeZJTYhJ8qmaN2273jgBTno/bFLzJlYvbANss69Tg53mljcmdSyhMlZ8z1kuenm1zkrPO5yHvi//r25tXkXb580OCkWxTmEwFzo20ntMgFnVSwVRvLZelOZt++tMevqi2Z5asvDgG7RytHP/0vLxxPzmjH0No+nITsxcmDbEweoKvSSzoc/7DYzENmfmrLXgP2KU/eE6CpTcSNaedLVKbAu9XptdtV8ruZxHjVBh1wpOwXkETEdqqvbCiR4TCNWzqbmwRKJ+acvZLBxhXcpfqmRsolaATU4sZKLs4iu92YpMIuUDh2Pquu0Daiz/IGnVe7BPb7E/gSd9NBFIxds6Nk1DbP8XKMRtYmWdTforUPWZqdM4EOtt8AcNpALRmsbEF26Gyd6t4/81bQPh+7WhI97lR/KkdWtKxNjjJ4CoJLgceyHuwbxXnFR23IWhzvQpBY12MBeYOw9oizvEzEGhEqpUns6LkH2sUNRRXbneNNvVgCEk6BK7j6Dxi95mcGJDEtOW+coE1SjhnfrwjIsdJL7cUEyC5DHFKuvxUi0iw/1I6b3AfZV5+A1tssEE2dhDv8uw6B3/a5EfMURFDqSfmGw1btdPPJ3+yjo1yYu2BtbYa4U++GtaAJfmNPrsB9lm4YgXuwCCRSpI2+TR9H2ntWM2j3HVdXqOpg3kfX82o9KFndo2g+7vGrOAyfL1jcybluq7AxPEV6D5yBky82MjoMeS0vSM6ytYu+0jheWPwDVs/3iPTELHPeDXAZOaw76ISBvNsXcxHvFsSiZBguBr+ucZOUnazVRAYIsmm/WNcIJu+6tfbyupqFCo5wkus6lKN2RNYIH1SRIi163cdBDhTBOdZoI2WcDr+SSW2fHtZutk7fW5IkJvSuy5xlke+YW/u3uzvriAIRmVDtk/fKISKEnMj2G47JdGn6EiHf+2+XfUSuDiliJb62pPXWBupinbb9HEW0tuyPHYGACH0/GA/egr6KMgI6YSh+BWS8vniMRTkmouKCzL5Csvc+2txC9LrfodrMF2R3jFZ1nig0mYzTQ9HvhqA2Uc+YG06iZtRaU7KqH6fMZYzPbjrxVOliyXR2G6","created_at":1784122846,"id":"112701bc1541c10b92f5a105e2e1f1813e591936e20075ec6a53c8bb8d235d81","kind":33301,"pubkey":"7177ccb8e8786c152e4960765f03fbceb7419d36a26e693a6399319760e7fd30","sig":"80eb4b49d70d73d35c1026b9c06d0fab280787950b5df412ebe4cdd05fcacadb4d20419c4e732749ed2698186a321069b4329ebd93fe21d8594d7392bf6445e0","tags":[["d",""],["vsk","8"]]}""" val event = Event.fromJson(json) val token = "c0277c415fe2ecc901a22b2f23dca5bf".hexToByteArray() - assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), token)) + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(event), event.pubKey, token)) } + + // ---- S14: the relay filter is a hint, not a proof (CORD-05 §2) ---------------------- + + @Test + fun aForgedNewerRevocationByAnotherKeyDoesNotRevoke() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + + // A relay serves a newer vsk=9 "at the coordinate" — but signed by someone else. + val forger = KeyPair() + val forged = coordinateEvent(forger.privKey!!, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L) + assertIs(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token)) + } + + @Test + fun aNewerRevocationClaimingTheSignerButBadlySignedDoesNotRevoke() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + + // Right pubkey, garbage signature: exactly what a relay could fabricate without the secret. + val forged = + Event( + id = "00".repeat(32), + pubKey = minted.linkSignerPubKey, + createdAt = 2L, + kind = ConcordInviteBundleEvent.KIND, + tags = arrayOf(arrayOf("d", ""), arrayOf(VskTag.TAG_NAME, ControlEntityKind.INVITE_REVOKED.wire)), + content = "", + sig = "00".repeat(64), + ) + assertIs(ConcordInviteBundle.classify(listOf(minted.bundleEvent, forged), minted.linkSignerPubKey, minted.token)) + } + + @Test + fun aRevocationAtAnotherDTagIsNotThisCoordinate() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + + // Genuinely signed by the link signer, but at a different `d` — a different coordinate. + val elsewhere = coordinateEvent(minted.linkSignerPrivKey, ControlEntityKind.INVITE_REVOKED.wire, createdAt = 2L, dTag = "x") + assertIs(ConcordInviteBundle.classify(listOf(minted.bundleEvent, elsewhere), minted.linkSignerPubKey, minted.token)) + } + + @Test + fun aBundleFromTheWrongAuthorIsAbsent() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community), createdAt = 1L) + assertEquals(InviteBundleStatus.Absent, ConcordInviteBundle.classify(listOf(minted.bundleEvent), "aa".repeat(32), minted.token)) + } + + // ---- S11: bundle bounds (CORD-05 §1) -------------------------------------------------- + + @Test + fun aBundleNamingTooManyChannelsIsRefused() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val huge = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS + 1) { InviteChannel(id = it.toString(), epoch = 0) }) + val minted = ConcordInviteBundle.mintLink("https://vector.chat", huge, createdAt = 1L) + assertNull(ConcordInviteBundle.bound(huge)) + assertEquals(InviteBundleStatus.Unreadable, ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token)) + + val atCap = inviteFor(community).copy(channels = List(ConcordInviteBundle.MAX_BUNDLE_CHANNELS) { InviteChannel(id = it.toString(), epoch = 0) }) + val ok = ConcordInviteBundle.mintLink("https://vector.chat", atCap, createdAt = 1L) + assertIs(ConcordInviteBundle.classify(listOf(ok.bundleEvent), ok.linkSignerPubKey, ok.token)) + } + + @Test + fun aBundlesRelaysAreTruncatedToTheCommunityCap() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + val relays = List(40) { "wss://r$it.example" } + val minted = ConcordInviteBundle.mintLink("https://vector.chat", inviteFor(community).copy(relays = relays + relays), createdAt = 1L) + val status = ConcordInviteBundle.classify(listOf(minted.bundleEvent), minted.linkSignerPubKey, minted.token) + assertIs(status) + assertEquals(relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), status.invite.relays) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt index 5d6d9209ca..2da90cb09f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteJoinFlowTest.kt @@ -89,7 +89,7 @@ class ConcordInviteJoinFlowTest { ) assertFalse(controlPlane.canWrite, "an invite must never hand a joiner the write key") val editions = community.genesisWraps.mapNotNull { ControlEdition.fromRumor(ConcordStreamEnvelope.open(it, controlPlane).rumor) } - val state = ConcordCommunityState.fold(editions, invite.owner) + val state = ConcordCommunityState.fold(editions, invite.communityId.hexToByteArray(), invite.owner) assertEquals("Nostrichs", state.metadata?.name) assertTrue(state.channels.isNotEmpty()) // #general is visible to the new member } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt index ef63c80014..236156c180 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteLinkTest.kt @@ -32,6 +32,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue class ConcordInviteLinkTest { @@ -67,9 +68,10 @@ class ConcordInviteLinkTest { } @Test - fun buildUrlCarriesEveryRelayOfAnOversizedList() { - // A community with five relays used to crash the mint ("at most 3 relays, was 5"). - // There is no cap below the format's own, so all five make the round trip. + fun buildUrlTruncatesAnOversizedListToTheBootstrapCap() { + // A community with five relays used to crash the mint ("at most 3 relays, was 5"), and was + // then fixed by carrying all five — which the reference client's decoder refuses. The + // fragment only has to find the bundle (CORD-05 §3), so the first three make the trip. val relays = listOf( "wss://one.example", @@ -80,7 +82,7 @@ class ConcordInviteLinkTest { ) val parsed = ConcordInviteLink.parseUrl(ConcordInviteLink.buildUrl("https://vector.chat", signer, token, relays)) assertNotNull(parsed) - assertEquals(relays, parsed.fragment.relays) + assertEquals(relays.take(3), parsed.fragment.relays) assertContentEquals(token, parsed.fragment.token) } @@ -94,18 +96,6 @@ class ConcordInviteLinkTest { assertEquals(InviteRelayDictionary.STOCK, parsed.fragment.relays) } - @Test - fun encodesTheLargestRelayListTheCountByteCanHold() { - // 255 is the format ceiling, not a policy one: the relay count is a single byte. - val relays = List(255) { "wss://relay$it.example" } - val frag = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) - assertEquals(relays, frag.relays) - assertContentEquals(token, frag.token) - - // One more would silently wrap the count byte to 0 and strand every relay, so it throws. - assertFailsWith { ConcordInviteLink.encodeFragment(token, relays + "wss://overflow.example") } - } - @Test @OptIn(ExperimentalEncodingApi::class) fun rejectsWrongVersion() { @@ -134,4 +124,32 @@ class ConcordInviteLinkTest { assertContentEquals(k, ConcordKeyDerivation.inviteBundleKey(token)) assertFalse(k.toHexKey() == ConcordKeyDerivation.inviteBundleKey(ByteArray(16) { 0x09 }).toHexKey()) } + + // ---- I11: at most 3 bootstrap relays (CORD-05 §3) ------------------------------------- + + @Test + fun encodingTruncatesToThreeBootstrapRelays() { + val token = ByteArray(16) { 7 } + val relays = listOf("wss://a.example", "wss://b.example", "wss://c.example", "wss://d.example", "wss://e.example") + val decoded = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(token, relays)) + assertEquals(relays.take(ConcordInviteLink.MAX_BOOTSTRAP_RELAYS), decoded.relays) + } + + @Test + fun theStockSetIsExemptFromTheCap() { + val decoded = ConcordInviteLink.decodeFragment(ConcordInviteLink.encodeFragment(ByteArray(16), InviteRelayDictionary.STOCK)) + assertEquals(InviteRelayDictionary.STOCK, decoded.relays) + assertTrue(decoded.usedStockRelays) + } + + @OptIn(ExperimentalEncodingApi::class) + @Test + fun decodingRefusesMoreThanThreeBootstrapRelays() { + // version 4, flags 0, count 4, four dictionary ids, then the token — what a non-conforming + // encoder would emit and the reference client's decoder throws on. + val bytes = byteArrayOf(4, 0, 4, 1, 2, 3, 4) + ByteArray(16) + val fragment = Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT).encode(bytes) + assertFailsWith { ConcordInviteLink.decodeFragment(fragment) } + assertNull(ConcordInviteLink.parseUrl("https://x/invite/" + ConcordInviteLink.buildUrl("https://x", "aa".repeat(32), ByteArray(16)).substringAfter("/invite/").substringBefore('#') + "#" + fragment)) + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt index b3a6dbde5e..3f0447b2a6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteListTest.kt @@ -194,4 +194,40 @@ class ConcordInviteListTest { assertTrue(!live.isExpired(nowSecs = 99)) assertTrue(!forever.isExpired(nowSecs = Long.MAX_VALUE), "no expiry means it never elapses") } + + // ---- I18: entries are immutable; malformed tombstones ride as residue (CORD-05 §4) ---- + + @Test + fun anExistingTokensEntryIsImmutableSoTheFirstCopyWins() { + // The published list (base) already holds t1; a device's patch carrying a different copy of + // the same token must not rewrite its signer_sk or url (the reference client's first-wins). + val base = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t1", "sk-original", "c", "url-original", createdAt = 1))) + val patch = ConcordInviteListDocument(entries = listOf(ConcordInviteListEntry("t1", "sk-other", "c", "url-other", label = "late", createdAt = 9))) + + val merged = ConcordInviteList.merge(base, patch) + + assertEquals(1, merged.entries.size) + assertEquals("sk-original", merged.entries.first().signerSk) + assertEquals("url-original", merged.entries.first().url) + assertEquals(null, merged.entries.first().label) + } + + @Test + fun aTombstoneThatFailsToTypeCheckIsCarriedNotDropped() { + val json = + """ + { "entries": [ { "token": "aa", "signer_sk": "bb", "community_id": "cc", "url": "u" } ], + "tombstones": [ { "token": "aa", "community_id": {"weird": true}, "mark": "grave" } ] } + """.trimIndent() + + val doc = ConcordInviteList.decodeOrNull(json)!! + assertEquals(0, doc.tombstones.size) + assertEquals(1, doc.opaqueTombstones.size, "the untyped tombstone is kept") + assertTrue(ConcordInviteList.encode(doc).contains("grave"), "an untyped tombstone was lost on re-encode") + + // It still retires the token it names: a merge must not let the entry stay live. + val merged = ConcordInviteList.merge(doc, ConcordInviteListDocument.EMPTY) + assertTrue(merged.entries.none { it.token == "aa" }, "an untyped tombstone must still beat its entry") + assertTrue(ConcordInviteList.encode(merged).contains("grave"), "merge dropped an untyped tombstone") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt index 63bed6cb5a..11fd70a156 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordInviteRelayopInteropTest.kt @@ -61,7 +61,7 @@ class ConcordInviteRelayopInteropTest { assertEquals("https://blossom.primal.net/a85a6b8f68cf602591b16846e1605f8034587b7220b44d7076d09f5e3bf5af71.jpg", invite.icon?.url) assertEquals(false, invite.icon?.isResolvable()) - val status = ConcordInviteBundle.classify(listOf(event), token) + val status = ConcordInviteBundle.classify(listOf(event), event.pubKey, token) assertIs(status) assertEquals("3rd times a charm?", status.invite.name) } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt index 96c1124e57..4a8bdda07f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord05Invites/ConcordStrandedRecoveryTest.kt @@ -75,14 +75,14 @@ class ConcordStrandedRecoveryTest { name = "Gamers", ) - // ---- merge forward -------------------------------------------------------- + // ---- explicit re-join (the user accepts the link again) --------------------- @Test - fun higherEpochBundleMergesForwardKeepingAnchorAndHistory() { + fun anExplicitRejoinOfAHigherEpochBundleKeepsAnchorAndHistory() { val prior = HeldRoot(0L, "aa".repeat(32)) val stranded = entry(epoch = 1, heldRoots = listOf(prior)) - val merged = ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false) + val merged = ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false) assertNotNull(merged, "a higher-epoch bundle at our own invite link means we were left behind") // adopted the new epoch's access root @@ -98,22 +98,21 @@ class ConcordStrandedRecoveryTest { assertTrue(merged.heldRoots.any { it.epoch == 0L && it.key == prior.key }) assertTrue(merged.heldRoots.any { it.epoch == 1L && it.key == "bb".repeat(32) }) - // identity is untouched and we record where we were dropped + // identity is untouched assertEquals(communityId, merged.id) assertEquals(stranded.addedAt, merged.addedAt) - assertEquals(1L, merged.excludedAtEpoch) } @Test fun sameEpochBundleIsANoOp() { - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) + assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) assertFalse(ConcordStrandedRecovery.isStranded(entry(epoch = 5), bundle(epoch = 5), bannedAtCurrentEpoch = false)) } @Test fun lowerEpochBundleIsANoOp() { // Epoch-monotonic: a stale bundle must never walk the membership backwards. - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false)) + assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 7), bundle(epoch = 3), bannedAtCurrentEpoch = false)) } @Test @@ -121,12 +120,12 @@ class ConcordStrandedRecoveryTest { // Direct invites and legacy entries have no anchor — expected, not an error. val noAnchor = entry(epoch = 1, ref = null) assertFalse(ConcordStrandedRecovery.isStranded(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) - assertNull(ConcordStrandedRecovery.mergeForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) + assertNull(ConcordStrandedRecovery.rejoinForward(noAnchor, bundle(epoch = 9), bannedAtCurrentEpoch = false)) } @Test fun bundleForAnotherCommunityIsIgnored() { - assertNull(ConcordStrandedRecovery.mergeForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false)) + assertNull(ConcordStrandedRecovery.rejoinForward(entry(epoch = 1), bundle(epoch = 9, id = "99".repeat(32)), bannedAtCurrentEpoch = false)) } // ---- the bare `#` anchor form ---------------------------- @@ -260,8 +259,24 @@ class ConcordStrandedRecoveryTest { // very epoch a Refounding rotated them out of. See A2 in docs/concord-soft-ban-audit.md. val stranded = entry(epoch = 1) assertFalse(ConcordStrandedRecovery.isStranded(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) - assertNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) + assertNull(ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = true)) // ...and the legitimate case still works, so the gate is not just "recovery off". - assertNotNull(ConcordStrandedRecovery.mergeForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)) + assertNotNull(ConcordStrandedRecovery.rejoinForward(stranded, bundle(epoch = 5), bannedAtCurrentEpoch = false)) + } + + /** + * S4: detection is all a bundle may do on its own. The class exposes no function that moves a + * held community's base from a bundle without the user re-accepting the link — a link creator + * could otherwise relocate every member who joined through their link onto a root they chose. + */ + @Test + fun aHostileHigherEpochBundleIsOnlyDetectedNeverAdoptedBySweep() { + val held = entry(epoch = 1) + val hostile = bundle(epoch = 2, root = "ee".repeat(32)) + // The sweep's question: are we stranded? Yes — and that is all it learns. + assertTrue(ConcordStrandedRecovery.isStranded(held, hostile, bannedAtCurrentEpoch = false)) + // The held entry itself is untouched by detection. + assertEquals("bb".repeat(32), held.root) + assertEquals(1L, held.rootEpoch) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index dbc6e9c81d..31e9e043fb 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt @@ -29,10 +29,15 @@ import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.ControlEditionBuilder import com.vitorpamplona.quartz.concord.cord04Roles.ControlEntityKind import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity +import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys 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.assertContentEquals @@ -127,7 +132,7 @@ class ConcordRefoundingTest { .fromRumor(it.rumor) } } - val folded = ConcordCommunityState.fold(editions, owner.pubKey) + val folded = ConcordCommunityState.fold(editions, communityId, owner.pubKey) assertEquals("Test", folded.metadata?.name) assertTrue(folded.authority.isOwner(owner.pubKey)) @@ -161,14 +166,14 @@ class ConcordRefoundingTest { val icon = ImagePointer(url = "https://media/icon.enc", key = "1a".repeat(32), nonce = "2b".repeat(16), hash = "3c".repeat(32)) - // v1 metadata: add the icon, chained onto genesis. + // The next metadata edition: add the icon, chained onto genesis. val metaV1Json = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), MetadataEntity(name = "NosFabrica", icon = icon)) - val metaV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.METADATA, communityId, 1, genesisMeta.hash, metaV1Json, now + 1) + val metaV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.METADATA, communityId, genesisMeta.version + 1, genesisMeta.hash, metaV1Json, now + 1) val metaV1Wrap = ConcordStreamEnvelope.wrap(metaV1Rumor, control, owner, encrypted = false, createdAt = now + 1) - // v1 channel: rename #general, chained onto genesis. + // The next channel edition: rename #general, chained onto genesis. val chanV1Json = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), ChannelEntity(name = "lobby", private = false)) - val chanV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.CHANNEL, community.generalChannelId, 1, genesisChannel.hash, chanV1Json, now + 1) + val chanV1Rumor = ControlEditionBuilder.rumor(owner.pubKey, ControlEntityKind.CHANNEL, community.generalChannelId, genesisChannel.version + 1, genesisChannel.hash, chanV1Json, now + 1) val chanV1Wrap = ConcordStreamEnvelope.wrap(chanV1Rumor, control, owner, encrypted = false, createdAt = now + 1) val priorWraps = community.genesisWraps + metaV1Wrap + chanV1Wrap @@ -194,7 +199,7 @@ class ConcordRefoundingTest { build.controlWraps.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, newControl)?.let { ControlEdition.fromRumor(it.rumor) } } - val folded = ConcordCommunityState.fold(editions, owner.pubKey) + val folded = ConcordCommunityState.fold(editions, communityId, owner.pubKey) // A fresh joiner MUST see the compacted heads — name, icon, and the renamed channel. assertEquals("NosFabrica", folded.metadata?.name, "fresh joiner lost the community name after refounding") @@ -294,7 +299,7 @@ class ConcordRefoundingTest { val newControl = com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys .forStaff(newRoot, communityId, newEpoch, newControlRoot) - val compacted = ConcordRefounding.compactControlPlane(listOf(realHead, forged), control, newControl, owner.pubKey) + val compacted = ConcordRefounding.compactControlPlane(listOf(realHead, forged), control, newControl, communityId, owner.pubKey) val carried = compacted @@ -306,4 +311,74 @@ class ConcordRefoundingTest { assertEquals(50, carried.single().version, "the owner's real head, not the forged genesis") assertEquals("Real", ConcordJson.decodeOrNull(carried.single().content)?.name) } + + /** + * CORD-06 §3 re-wraps each entity's CURRENT HEAD — including sub-kinds this client does not + * model. Another client's Pin List (vsk 11) or Signal (vsk 12) used to be dropped by our + * Refounding, because the parser returned null for a vsk it did not know (I7). And CORD-02 §5 + * allows the Control Plane only plaintext seals, so an edition under an encrypted seal is + * never a head to carry, however high its version (S9). + */ + @Test + fun compactionCarriesUnmodeledHeadsVerbatimAndRefusesEncryptedSeals() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val communityId = community.communityId + val control = community.controlPlane + + fun raw( + vsk: String, + eid: ByteArray, + version: Long, + prev: ControlEdition?, + content: String, + ): Event { + val tags = mutableListOf(arrayOf("vsk", vsk), arrayOf("eid", eid.toHexKey()), arrayOf("ev", version.toString())) + prev?.let { tags.add(arrayOf("ep", it.hashHex)) } + return RumorAssembler.assembleRumor(owner.pubKey, now + version, ControlEditionEvent.KIND, tags.toTypedArray(), content) + } + + val pinsEid = ByteArray(32) { 0x11 } + val pinsV1 = raw("11", pinsEid, 1, null, """{"entries":[]}""") + val pinsV2 = raw("11", pinsEid, 2, ControlEdition.fromRumor(pinsV1), """{"entries":["pinned"]}""") + val signal = raw("12", ByteArray(32) { 0x12 }, 1, null, """{"paused":true}""") + + // The owner's metadata at v60, but under an ENCRYPTED seal: not a Control edition. + val encryptedMeta = + ControlEditionBuilder.rumor( + owner.pubKey, + ControlEntityKind.METADATA, + communityId, + 60, + community.genesisEditions.first { it.entityKind == ControlEntityKind.METADATA }.hash, + """{"name":"Encrypted"}""", + now, + ) + + val priorWraps = + community.genesisWraps + + listOf(pinsV1, pinsV2, signal).map { ConcordStreamEnvelope.wrap(it, control, owner, encrypted = false, createdAt = now) } + + ConcordStreamEnvelope.wrap(encryptedMeta, control, owner, encrypted = true, createdAt = now) + + val newControl = ControlPlaneKeys.forStaff(newRoot, communityId, community.rootEpoch + 1, newControlRoot) + val carried = + ConcordRefounding + .compactControlPlane(priorWraps, control, newControl, communityId, owner.pubKey) + .mapNotNull { ConcordStreamEnvelope.openOrNull(it, newControl) } + + // Every carried seal is the original plaintext seal, byte for byte. + assertTrue(carried.all { it.sealKind == ConcordStreamEnvelope.KIND_SEAL_PLAINTEXT }) + val editions = carried.mapNotNull { ControlEdition.fromOpened(it) } + + val pins = editions.filter { it.vsk == "11" } + assertEquals(1, pins.size, "the Pin List head rides through") + assertEquals(pinsV2.id, pins.single().rumorId, "its current head, verbatim") + assertEquals(ControlEntityKind.PIN_LIST, pins.single().entityKind) + val signals = editions.single { it.vsk == "12" } + assertEquals(signal.id, signals.rumorId, "the Signal head rides through") + assertNull(signals.entityKind, "a sub-kind we don't model") + + val meta = editions.single { it.entityKind == ControlEntityKind.METADATA } + assertEquals("Test", ConcordJson.decodeOrNull(meta.content)?.name, "the encrypted-seal edition is never the head") + } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt new file mode 100644 index 0000000000..7e154dd58a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt @@ -0,0 +1,368 @@ +/* + * 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.quartz.concord.cord06Rekey + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.EntityFloor +import com.vitorpamplona.quartz.concord.cord04Roles.control.tags.VacTag +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.crypto.GroupKey +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +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.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * CORD-06 wire conformance against the spec and the reference client: 1-based chunks (I8), + * byte-budgeted chunks under the NIP-44 cap (I10), the `vac` citation on rotations (I9), + * race convergence + idempotent retry (I12), fold-all-or-abort compaction (S12), and the + * encrypted rekey seal. + */ +class ConcordRekeyConformanceTest { + private val owner = NostrSignerInternal(KeyPair()) + private val admin = NostrSignerInternal(KeyPair()) + private val member = NostrSignerInternal(KeyPair()) + private val now = 1_700_000_000L + private val controlRoot = ByteArray(32) { 0x6B } + + private suspend fun rotation( + community: NewConcordCommunity, + rotator: NostrSigner, + newRoot: ByteArray, + recipients: List, + staff: Set = emptySet(), + authority: AuthorityCitation? = null, + ): List { + val newEpoch = community.rootEpoch + 1 + return ConcordRefounding.buildBaseRekeyWraps( + rotatorSigner = rotator, + baseRekeyKey = baseRekey(community), + recipientsXOnly = recipients, + staffXOnly = staff, + newRoot = newRoot, + newControlPk = ConcordKeyDerivation.controlSignerKey(controlRoot, community.communityId, newEpoch).publicKey, + newControlRoot = controlRoot, + newEpoch = newEpoch, + prevEpoch = community.rootEpoch, + prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey(), + createdAt = now, + authority = authority, + ) + } + + private fun baseRekey(community: NewConcordCommunity): GroupKey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, community.rootEpoch + 1) + + private fun rumorsOf( + wraps: List, + key: GroupKey, + ) = wraps.map { assertNotNull(ConcordStreamEnvelope.openOrNull(it, key)) } + + private fun members(n: Int) = List(n) { KeyPair().pubKey.toHexKey() } + + // ---- I8: chunk indices are 1-based -------------------------------------------------- + + @Test + fun chunksAreNumberedFromOne() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val wraps = rotation(community, owner, ByteArray(32) { 1 }, members(250) + member.pubKey) + val opened = rumorsOf(wraps, baseRekey(community)) + val chunks = opened.map { o -> o.rumor.tags.first { it[0] == ConcordRekey.TAG_CHUNK } } + assertEquals((1..wraps.size).map { it.toString() }, chunks.map { it[1] }) + assertTrue(chunks.all { it[2] == wraps.size.toString() }) + assertTrue(opened.all { ConcordRekey.chunkOf(it.rumor.tags) != null }) + } + + @Test + fun chunkTagParsingIsStrict() { + fun chunk(vararg v: String) = arrayOf(arrayOf(ConcordRekey.TAG_CHUNK, *v)) + assertEquals(1 to 1, ConcordRekey.chunkOf(emptyArray()), "absent reads as the only chunk") + assertEquals(2 to 3, ConcordRekey.chunkOf(chunk("2", "3"))) + assertNull(ConcordRekey.chunkOf(chunk("0", "2")), "0-based is malformed") + assertNull(ConcordRekey.chunkOf(chunk("3", "2"))) + assertNull(ConcordRekey.chunkOf(chunk("1", "0"))) + assertNull(ConcordRekey.chunkOf(chunk("01", "2"))) + assertNull(ConcordRekey.chunkOf(chunk("+1", "2"))) + assertNull(ConcordRekey.chunkOf(chunk("1"))) + assertFailsWith { ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, 1, 0, "ab".repeat(32), 0, 1) } + } + + @Test + fun aZeroBasedChunkIsDropped() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val newEpoch = community.rootEpoch + 1 + val blob = ConcordRekey.blobForSigner(owner, member.pubKey.hexToByteArray(), ConcordRekey.ROOT_SCOPE, newEpoch, ByteArray(32) { 1 }) + val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() + val tags = + arrayOf( + arrayOf(ConcordRekey.TAG_SCOPE, ConcordRekey.ROOT_SCOPE.toHexKey()), + arrayOf(ConcordRekey.TAG_NEWEPOCH, newEpoch.toString()), + arrayOf(ConcordRekey.TAG_PREVEPOCH, community.rootEpoch.toString()), + arrayOf(ConcordRekey.TAG_PREVCOMMIT, prevCommit), + arrayOf(ConcordRekey.TAG_CHUNK, "0", "1"), + ) + val rumor = RumorAssembler.assembleRumor(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) + val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey(community), owner, encrypted = true, createdAt = now) + assertNull(ConcordRefounding.findNewRoot(listOf(wrap), baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)) + } + + // ---- rekey seals must be encrypted (CORD-02 §5) ------------------------------------ + + @Test + fun aPlaintextSealedRekeyIsIgnored() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val encrypted = rotation(community, owner, ByteArray(32) { 1 }, listOf(member.pubKey)) + val rumor = rumorsOf(encrypted, baseRekey(community)).single().rumor + val plaintext = ConcordStreamEnvelope.wrap(rumor, baseRekey(community), owner, encrypted = false, createdAt = now) + + assertNotNull(ConcordRefounding.findNewRoot(encrypted, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)) + assertNull(ConcordRefounding.findNewRoot(listOf(plaintext), baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)) + } + + // ---- I10: chunk by bytes so the wrap stays under NIP-44's 65,535-byte plaintext --------- + + @Test + fun staffChunksStayUnderTheNip44Cap() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val recipients = members(300) + member.pubKey + // Every recipient staff: the widest (136-byte) blob. 120 of these overflowed the cap. + val wraps = rotation(community, owner, ByteArray(32) { 1 }, recipients, staff = recipients.toSet()) + val opened = rumorsOf(wraps, baseRekey(community)) + + for (o in opened) { + assertTrue( + o.rumor + .toJson() + .encodeToByteArray() + .size <= ConcordRekey.REKEY_RUMOR_MAX_BYTES, + "rumor over the byte budget", + ) + assertTrue( + o.seal + .toJson() + .encodeToByteArray() + .size <= 65_535, + "the wrap's NIP-44 plaintext (the seal) must fit the cap", + ) + assertTrue(ConcordRekey.decodeContent(o.rumor.content).size <= 90, "the reference client fits 90 staff blobs per chunk") + } + assertEquals(recipients.size, opened.sumOf { ConcordRekey.decodeContent(it.rumor.content).size }) + // And everyone still finds their key across the chunks. + assertNotNull(ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch)) + } + + @Test + fun memberChunksStayUnderTheNip44Cap() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val wraps = rotation(community, owner, ByteArray(32) { 1 }, members(250)) + for (o in rumorsOf(wraps, baseRekey(community))) { + assertTrue( + o.seal + .toJson() + .encodeToByteArray() + .size <= 65_535, + ) + // ~99 per chunk (the reference client's figure; our slimmer rumor envelope fits a few more). + assertTrue( + o.rumor + .toJson() + .encodeToByteArray() + .size <= ConcordRekey.REKEY_RUMOR_MAX_BYTES, + ) + assertTrue(ConcordRekey.decodeContent(o.rumor.content).size < ConcordRekey.MAX_BLOBS_PER_CHUNK, "the byte budget, not the count cap, binds for 104-byte blobs") + } + } + + @Test + fun chunkingKeepsTheCountCap() { + val tiny = List(300) { RekeyBlob("a", "b") } + val chunks = ConcordRekey.chunkBlobs(tiny, envelopeBytes = 300) + assertEquals(listOf(120, 120, 60), chunks.map { it.size }) + assertEquals(listOf(0), ConcordRekey.chunkBlobs(emptyList(), 300).map { it.size }) + } + + // ---- I9: the vac citation -------------------------------------------------------------- + + @Test + fun everyChunkCarriesTheRotatorsCitation() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val citation = AuthorityCitation(ByteArray(32) { 3 }, 4, ByteArray(32) { 5 }) + val wraps = rotation(community, admin, ByteArray(32) { 1 }, members(250) + member.pubKey, authority = citation) + for (o in rumorsOf(wraps, baseRekey(community))) { + assertEquals( + VacTag.assemble(citation).toList(), + o.rumor.tags + .first { it[0] == VacTag.TAG_NAME } + .toList(), + ) + } + val got = ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch) + assertNotNull(got) + assertEquals(admin.pubKey, got.rotator) + assertContentEquals(citation.grantId, got.authority?.grantId) + assertEquals(4L, got.authority?.grantVersion) + + // The owner cites nothing. + val byOwner = rotation(community, owner, ByteArray(32) { 1 }, listOf(member.pubKey)) + assertTrue(rumorsOf(byOwner, baseRekey(community)).all { o -> o.rumor.tags.none { it[0] == VacTag.TAG_NAME } }) + } + + @Test + fun citationsAreVerifiedAgainstTheFoldedGrantHead() { + val cid = "11".repeat(32) + val ownerHex = owner.pubKey + val grantEid = ConcordKeyDerivation.grantCoordinate(cid.hexToByteArray(), admin.pubKey.hexToByteArray()).toHexKey() + val hash = "ab".repeat(32) + val heads = mapOf(grantEid to EntityFloor(3, hash)) + + val minted = ConcordRotationAuthority.citationFor(cid, admin.pubKey, ownerHex, heads) + assertNotNull(minted) + assertEquals(grantEid, minted.grantId.toHexKey()) + assertEquals(3L, minted.grantVersion) + assertNull(ConcordRotationAuthority.citationFor(cid, ownerHex, ownerHex, heads), "the owner cites nothing") + + fun ok(c: AuthorityCitation?) = ConcordRotationAuthority.citationSatisfied(cid, admin.pubKey, ownerHex, c, heads) + assertTrue(ok(minted)) + assertTrue(ConcordRotationAuthority.citationSatisfied(cid, ownerHex, ownerHex, null, heads), "the owner needs no citation") + assertFalse(ok(null), "a delegated rotator must cite") + assertTrue(ok(AuthorityCitation(minted.grantId, 2, ByteArray(32))), "our head is newer than the cited Grant: rank decides") + assertFalse(ok(AuthorityCitation(minted.grantId, 4, ByteArray(32))), "cites a Grant we have not synced: park") + assertFalse(ok(AuthorityCitation(minted.grantId, 3, ByteArray(32) { 9 })), "same version, different hash: a fork") + val otherEid = ConcordKeyDerivation.grantCoordinate(cid.hexToByteArray(), member.pubKey.hexToByteArray()) + assertFalse(ok(AuthorityCitation(otherEid, 3, hash.hexToByteArray())), "must cite the rotator's OWN Grant") + } + + // ---- I12: race convergence + idempotent retry -------------------------------------------- + + @Test + fun racingRotationsConvergeOnTheLowestAuthorizedRoot() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now) + val high = ByteArray(32) { 0x5A } + val low = ByteArray(32) { 0x10 } + val wraps = rotation(community, owner, high, listOf(member.pubKey)) + rotation(community, admin, low, listOf(member.pubKey)) + + val all = ConcordRefounding.findNewRoots(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch) + assertEquals(2, all.size) + + // Fetch order must not matter: every client picks the same winner. + for (order in listOf(wraps, wraps.reversed())) { + val won = ConcordRefounding.findNewRoot(order, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch) + assertNotNull(won) + assertContentEquals(low, won.newRoot) + } + + // Authorize before converging: an unauthorized lower root never wins. + val onlyOwner = ConcordRefounding.findNewRoot(wraps, baseRekey(community), member, community.communityId, community.communityRoot, community.rootEpoch) { it.rotator == owner.pubKey } + assertNotNull(onlyOwner) + assertContentEquals(high, onlyOwner.newRoot) + } + + @Test + fun theSameEpochHealIsDownOnly() { + val low = ByteArray(32) { 0x10 } + val high = ByteArray(32) { 0x5A } + assertTrue(ConcordRefounding.healsTo(held = high, candidate = low)) + assertFalse(ConcordRefounding.healsTo(held = low, candidate = high), "a flaky fetch of the higher sibling must not re-fork") + assertFalse(ConcordRefounding.healsTo(held = low, candidate = low)) + // Unsigned order: 0x80 sorts above 0x7F. + assertTrue(ConcordRefounding.compareKeys(byteArrayOf(0x7F), byteArrayOf(0x80.toByte())) < 0) + } + + @Test + fun losingForkRootsAreKeptButNotFoldedFrom() { + val held = + listOf( + HeldRoot(1, "5a".repeat(32), controlPk = null), + HeldRoot(1, "10".repeat(32), controlPk = "cc".repeat(32)), + HeldRoot(0, "aa".repeat(32)), + ) + val canonical = ConcordRefounding.canonicalHeldRoots(held) + assertEquals(setOf(0L to "aa".repeat(32), 1L to "10".repeat(32)), canonical.map { it.epoch to it.key }.toSet()) + } + + @Test + fun aRetriedRefoundingReusesItsReservedKeys() { + val cid = "11".repeat(32) + val priorRoot = ByteArray(32) { 2 } + val first = ConcordRefounding.reserveKeys(null, cid, 3, priorRoot) + val retry = ConcordRefounding.reserveKeys(first, cid, 3, priorRoot) + assertSame(first, retry, "a retry must re-deliver the same root, never mint a sibling") + + // A different rotation (another epoch, or another prior root) gets fresh keys. + val nextEpoch = ConcordRefounding.reserveKeys(first, cid, 4, priorRoot) + assertFalse(nextEpoch.newRoot.contentEquals(first.newRoot)) + val otherRoot = ConcordRefounding.reserveKeys(first, cid, 3, ByteArray(32) { 9 }) + assertFalse(otherRoot.newRoot.contentEquals(first.newRoot)) + } + + // ---- S12: fold-all-or-abort compaction --------------------------------------------------- + + @Test + fun compactionAbortsWhenAnHonoredHeadIsMissing() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Test", now, description = "A place") + val newControl = ControlPlaneKeys.forStaff(ByteArray(32) { 7 }, community.communityId, community.rootEpoch + 1, controlRoot) + val heads = + community.genesisWraps + .mapNotNull { ConcordStreamEnvelope.openOrNull(it, community.controlPlane)?.let { o -> ControlEdition.fromRumor(o.rumor) } } + .associate { it.entityIdHex to it.version } + + // Everything we honor is present: the compaction goes ahead. + val compacted = ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = heads) + assertEquals(heads.size, compacted.size) + + // An entity we fold (or a newer version of one) that the fetched plane lacks: abort. + val missing = + assertFailsWith { + ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = heads + ("ff".repeat(32) to 0L)) + } + assertEquals(listOf("ff".repeat(32)), missing.missing) + val first = heads.keys.first() + assertFailsWith { + ConcordRefounding.compactControlPlane(community.genesisWraps, community.controlPlane, newControl, community.communityId, owner.pubKey, mustCarry = mapOf(first to heads.getValue(first) + 1)) + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt index 7a4440e1a0..5f7d9d05f6 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt @@ -167,7 +167,7 @@ class ControlRootRotationTest { build.controlWraps.mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, memberView)?.let { ControlEdition.fromRumor(it.rumor) } } - val folded = ConcordCommunityState.fold(editions, owner.pubKey) + val folded = ConcordCommunityState.fold(editions, community.communityId, owner.pubKey) assertEquals("Test", folded.metadata?.name) assertTrue(folded.channels.isNotEmpty()) } @@ -194,7 +194,7 @@ class ControlRootRotationTest { val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() - val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1) + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 1, 1) val rumor = RumorAssembler.assembleRumor(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now) @@ -224,7 +224,7 @@ class ControlRootRotationTest { val baseRekey = ConcordKeyDerivation.baseRekeyAddress(community.communityRoot, community.communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(community.rootEpoch, community.communityRoot).toHexKey() - val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 0, 1) + val tags = ConcordRekey.tags(ConcordRekey.ROOT_SCOPE, newEpoch, community.rootEpoch, prevCommit, 1, 1) val rumor = RumorAssembler.assembleRumor(owner.pubKey, now, ConcordRekey.KIND, tags, ConcordRekey.encodeContent(listOf(blob))) val wrap = ConcordStreamEnvelope.wrap(rumor, baseRekey, owner, encrypted = true, createdAt = now) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt new file mode 100644 index 0000000000..0893db6ab6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt @@ -0,0 +1,93 @@ +/* + * 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.quartz.concord.envelope + +import com.vitorpamplona.quartz.concord.crypto.ConcordKeyDerivation +import com.vitorpamplona.quartz.concord.crypto.ConcordLabels +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.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip44Encryption.Nip44 +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.assertNull + +/** + * CORD-02 Appendix B: NIP-44 hard-caps plaintext at 65,535 bytes and every Concord layer must + * enforce it itself — quartz's NIP-44 would otherwise switch to its extended format, which strict + * readers (the reference client) cannot decrypt. + */ +class ConcordStreamEnvelopeCapTest { + private val authorSigner = NostrSignerInternal(KeyPair()) + private val stream = ConcordKeyDerivation.groupKey(ConcordLabels.CHANNEL, ByteArray(32) { 7 }, ByteArray(32) { 0x33 }, 0) + + private fun rumor(content: String): Event = + RumorAssembler.assembleRumor( + pubKey = authorSigner.pubKey, + createdAt = 1_700_000_000L, + kind = 9, + tags = arrayOf(arrayOf("channel", "abc"), arrayOf("epoch", "0")), + content = content, + ) + + @Test + fun anOversizeRumorIsRefusedAtTheSealLayer() = + runTest { + val big = rumor("x".repeat(ConcordStreamEnvelope.NIP44_MAX_PLAINTEXT)) + assertFailsWith { ConcordStreamEnvelope.seal(big, stream, authorSigner, encrypted = true) } + } + + @Test + fun anOversizeSealIsRefusedAtTheWrapLayer() = + runTest { + // A plaintext seal carries the rumor verbatim, so only the wrap layer sees its size. + val big = rumor("x".repeat(ConcordStreamEnvelope.NIP44_MAX_PLAINTEXT - 200)) + val seal = ConcordStreamEnvelope.seal(big, stream, authorSigner, encrypted = false) + assertFailsWith { ConcordStreamEnvelope.wrapSeal(seal, stream) } + } + + @Test + fun aRumorJustUnderTheCapStillRoundTrips() = + runTest { + // Leave room for the seal and rumor JSON around the content, well within the cap. + val text = "y".repeat(30_000) + val wrap = ConcordStreamEnvelope.wrap(rumor(text), stream, authorSigner, encrypted = true) + assertEquals(text, ConcordStreamEnvelope.open(wrap, stream).rumor.content) + } + + @Test + fun anExtendedFormatWrapIsRefusedOnOpen() = + runTest { + // A lenient publisher: a genuine seal, wrapped with NIP-44's extended format (> 65,535 + // bytes of plaintext), correctly signed by the stream key. It must not open. + val seal = ConcordStreamEnvelope.seal(rumor("z".repeat(70_000)), stream, authorSigner, encrypted = false) + val content = Nip44.v2.encrypt(seal.toJson(), stream.conversationKey).encodePayload() + val streamSigner = NostrSignerSync(KeyPair(privKey = stream.secretKey)) + val wrap = streamSigner.signNormal(1_700_000_000L, ConcordStreamEnvelope.KIND_WRAP, arrayOf(arrayOf("p", KeyPair().pubKey.toHexKey())), content) + + assertNull(ConcordStreamEnvelope.openOrNull(wrap, stream)) + } +}