From 13505338502fde99485df3fc3763daf2463adeca Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:33:52 +0000 Subject: [PATCH 1/9] feat(concord): fragmented Community List, bound dissolution, lossless entity edits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Concord implementation up to spec main b84554e for the three changes that landed since we last tracked it, and adds the conformance review that drives the rest. CORD-02 §8 — the Community List moves from the retired kind 13302 to fragmented addressable kind 33302 (d = fragment index): - ConcordListFragments: the wire codec. Unpadded base64url for every 32-byte value the section names, snapshots without community_id, seed omitted when equal to current (after rewriting its cosmetic fields), tombstoned entries dropped, unknown fields verbatim and sorted. Packing and bytes match the reference client exactly: the tests pin fragments produced by Armada's own listFrag.ts serializer. - ConcordListFragmentSet: newest copy per index, declared count (ties to the larger), completeness, union; writes repack only with the complete List and otherwise rewrite just the fragment holding the change, always with created_at above the previous copy and under the byte ceiling. - Leaving now writes a tombstone instead of just dropping the entry. - The retired 13302 event is still read and unioned in, so the next write (or the import, once relays confirm no fragment exists) migrates it. - Offline backup, cache storage, account-list REQ and amy concord import follow the new kind. CORD-02 §9 — dissolution: - ConcordDissolution derives dissolved_pk, builds the owner-signed tombstone with eid = community_id, and verifies it (owner author, plaintext seal, eid bound; the old all-zero eid is refused). - Sessions subscribe/AUTH the dissolved plane and seal the community on a valid tombstone; the Control Plane fold no longer reads vsk 10, which skipped the binding check. Owners can dissolve from the app's actions and with `amy concord dissolve --yes`. CORD-02 §6 / CORD-08 — Control entity edits keep what they don't model (ConcordJson.encodePreserving over the authorized head), so renaming a community no longer wipes custom fields or turns off another client's disappearing-messages timer. MetadataEntity parses message_expiration. The per-channel voice flag is gone (every Channel is callable). Review: quartz/plans/2026-09-29-concord-spec-conformance.md Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../amethyst/LocalPreferences.kt | 17 + .../concord/ConcordChannelListScreen.kt | 15 +- .../concord/ConcordHomeScreen.kt | 7 +- .../settings/BottomBarSettingsScreen.kt | 7 +- cli/README.md | 6 +- .../cli/commands/ConcordChannelCommands.kt | 15 +- .../amethyst/cli/commands/ConcordCommands.kt | 37 +- .../cli/commands/ConcordModCommands.kt | 30 + .../commons/actions/ConcordModeration.kt | 39 +- .../actions/ConcordSubscriptionPlanner.kt | 6 +- .../commons/model/AccountConcordActions.kt | 79 ++- .../amethyst/commons/model/AccountSettings.kt | 14 + .../commons/model/cache/EventCache.kt | 3 + .../commons/model/concord/ConcordChannel.kt | 6 - .../model/concord/ConcordChannelListState.kt | 187 ++++-- .../model/concord/ConcordCommunitySession.kt | 38 +- .../preferences/LatestEventCacheStore.kt | 3 + .../FilterAccountInfoAndListsFromKey.kt | 2 + .../concord/ConcordChannelDissolvedTest.kt | 13 +- .../concord/ConcordChannelListLeaveTest.kt | 116 +++- .../concord/ConcordCommunitySessionTest.kt | 38 ++ .../concord/ConcordListLateArrivalTest.kt | 28 + .../2026-09-29-concord-spec-conformance.md | 102 +++ .../cord02Community/ConcordCommunityList.kt | 49 ++ .../ConcordCommunityListEvent.kt | 13 + .../ConcordCommunityListFragmentEvent.kt | 100 +++ .../cord02Community/ConcordCommunityState.kt | 15 +- .../cord02Community/ConcordDissolution.kt | 137 ++++ .../cord02Community/ConcordListFragmentSet.kt | 242 +++++++ .../cord02Community/ConcordListFragments.kt | 597 ++++++++++++++++++ .../concord/cord04Roles/ControlEntities.kt | 75 ++- .../concord/crypto/ConcordKeyDerivation.kt | 10 + .../quartz/utils/EventFactory.kt | 2 + .../ConcordCommunityStateTest.kt | 11 +- .../cord02Community/ConcordDissolutionTest.kt | 109 ++++ .../ConcordListFragmentsTest.kt | 453 +++++++++++++ .../cord04Roles/ControlEntityRoundTripTest.kt | 93 +++ 37 files changed, 2531 insertions(+), 183 deletions(-) create mode 100644 quartz/plans/2026-09-29-concord-spec-conformance.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolution.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordDissolutionTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntityRoundTripTest.kt 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/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/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..799d94bb3b 100644 --- a/cli/README.md +++ b/cli/README.md @@ -668,13 +668,14 @@ 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 import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). | | `amy concord channels COMMUNITY` | List a community's channels. | | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. | @@ -685,6 +686,7 @@ also carried on-relay as an encrypted kind:13302. | `amy concord role COMMUNITY NAME POSITION PERM…` | Define a role (perms by name, e.g. `BAN KICK`). | | `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..9e2565c088 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,7 @@ 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) }, ), ) @@ -157,8 +158,16 @@ object ConcordChannelCommands { // 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 dissolvedAddress = ConcordDissolution.planeKey(sc.communityId).publicKeyHex + 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.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..6c2212a4e5 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 @@ -29,8 +29,11 @@ import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot 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.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -60,7 +63,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) @@ -84,6 +87,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 +98,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -114,6 +119,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) }, ), ) @@ -181,7 +187,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 +201,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 = 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..4a4bf10a49 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 @@ -30,6 +30,7 @@ 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.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity @@ -157,6 +158,35 @@ 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 ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) + 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, 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..75eb69da6c 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 @@ -41,6 +41,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 @@ -97,6 +98,19 @@ object ConcordModeration { return if (head != null) (head.version + 1) to head.hash else 0L to null } + /** + * The next edition's content for [entityId]: [value] 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). + */ + private fun contentOver( + serializer: KSerializer, + value: T, + current: List, + entityId: ByteArray, + owner: HexKey, + ): String = ConcordJson.encodePreserving(serializer, value, headOf(current, entityId, owner)?.content) + private suspend fun wrap( actor: NostrSigner, controlPlane: ControlPlaneKeys, @@ -127,7 +141,7 @@ object ConcordModeration { owner: HexKey, ): Event { val (version, prev) = versioning(current, roleId, owner) - val content = ConcordJson.instance.encodeToString(RoleEntity.serializer(), role) + val content = contentOver(RoleEntity.serializer(), role, current, roleId, owner) return wrap(actor, controlPlane, ControlEntityKind.ROLE, roleId, version, prev, content, createdAt, citation) } @@ -149,10 +163,27 @@ object ConcordModeration { owner: HexKey, ): Event { val (version, prev) = versioning(current, channelId, owner) - val content = ConcordJson.instance.encodeToString(ChannelEntity.serializer(), channel) + val content = contentOver(ChannelEntity.serializer(), channel, current, channelId, owner) return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) } + /** + * 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 @@ -170,7 +201,7 @@ object ConcordModeration { owner: HexKey, ): Event { val (version, prev) = versioning(current, communityId, owner) - val content = ConcordJson.instance.encodeToString(MetadataEntity.serializer(), metadata) + val content = contentOver(MetadataEntity.serializer(), metadata, current, communityId, owner) return wrap(actor, controlPlane, ControlEntityKind.METADATA, communityId, version, prev, content, createdAt, citation) } @@ -197,7 +228,7 @@ object ConcordModeration { ): Event { 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)) + val content = contentOver(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap), current, entityId, owner) return wrap(actor, controlPlane, ControlEntityKind.GRANT, entityId, version, prev, content, createdAt, citation) } 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..3215cbc06d 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,6 +25,7 @@ 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.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -92,7 +93,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 +103,11 @@ object ConcordSubscriptionPlanner { val relays = normalize(e.relays) val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch) val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch) + val dissolved = ConcordDissolution.planeKey(e.id) listOf( ConcordPlaneSub(channelId = null, pubKeyHex = guestbook.publicKeyHex, relays = relays), ConcordPlaneSub(channelId = null, pubKeyHex = nextRekey.publicKeyHex, relays = relays), + ConcordPlaneSub(channelId = null, pubKeyHex = dissolved.publicKeyHex, relays = relays), ) } 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..7af62cb035 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 @@ -34,6 +34,8 @@ 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.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat @@ -109,7 +111,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). */ @@ -139,7 +141,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( @@ -450,14 +452,14 @@ class AccountConcordActions( return true } - /** Drop a joined Concord community from the private kind-13302 list by its id. */ + /** Leave a joined Concord community: drop it from the Community List and tombstone it (CORD-02 §8). */ suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.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 @@ -1438,12 +1440,30 @@ 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) 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 @@ -1473,14 +1493,14 @@ 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 channel = (standing ?: ChannelEntity()).copy(name = name.trim()) val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -1502,7 +1522,7 @@ class AccountConcordActions( ?.channels ?.get(channelIdHex) ?.definition - val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true) + val channel = (standing ?: ChannelEntity()).copy(name = name.trim(), deleted = true) val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -1525,21 +1545,18 @@ class AccountConcordActions( } /** - * 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 +1567,29 @@ 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) } + // 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) { + val seeded = account.concordChannelList.republish() + if (seeded.isNotEmpty()) { + Log.d("Concord") { "importConcordCommunities: migrated the 13302 list into ${seeded.size} fragment(s)" } + account.sendMyPublicAndPrivateOutbox(seeded) + } } - 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..4db15d9ec0 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,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.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent @@ -3837,6 +3838,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..83cbd73088 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,9 +52,6 @@ class ConcordChannel( var channelName: String? = null private set - var isVoice: Boolean = false - private set - var isPrivate: Boolean = false private set @@ -114,7 +111,6 @@ class ConcordChannel( 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 @@ -124,7 +120,6 @@ class ConcordChannel( val changed = channelName != newChannelName || - isVoice != newVoice || isPrivate != newPrivate || communityName != newCommunityName || communityIcon != newCommunityIcon || @@ -133,7 +128,6 @@ class ConcordChannel( dissolved != newDissolved channelName = newChannelName - isVoice = newVoice isPrivate = newPrivate communityName = newCommunityName communityIcon = newCommunityIcon 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..33903f1636 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,69 @@ */ 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.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.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 -/** 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 +90,65 @@ 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() + + /** 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) + 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 legacy = (getConcordList() ?: settings.concordList())?.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 +163,72 @@ 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 { + 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. */ + suspend fun follow(entry: ConcordCommunityListEntry): List = + writeLock.withLock { + val (set, doc) = snapshot() + write(set, doc.entries.filterNot { it.id == entry.id } + entry, 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.now() * 1000)) + } + + /** + * 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) + } 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..8f40a07e67 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 @@ -25,6 +25,7 @@ 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 @@ -137,9 +138,25 @@ class ConcordCommunitySession( */ private val nextBaseRekeyKey: GroupKey = ConcordActions.nextBaseRekeyPlane(root, communityIdBytes, entry.rootEpoch) + /** + * 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 @@ -311,6 +328,7 @@ class ConcordCommunitySession( address == controlPlaneAddress || address == guestbookAddress || address == nextBaseRekeyAddress || + address == dissolvedAddress || address in historicalControlKeys || lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } @@ -367,8 +385,11 @@ class ConcordCommunitySession( historicalChannelKeysByAddress.values.map { it.second } } - /** 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 = listOf(guestbookKey, nextBaseRekeyKey, dissolvedKey) /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) } @@ -449,6 +470,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 @@ -573,7 +605,7 @@ class ConcordCommunitySession( } historicalChannelKeysByAddress = historical - _state.value = folded + _state.value = folded.withDissolved(dissolved) folded.channels.keys.filterNot { it in prevChannels } } 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..bde302eeec 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 @@ -61,6 +61,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"), TRUST_PROVIDER_LIST("latestTrustProviderList"), KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"), FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"), 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/model/concord/ConcordChannelDissolvedTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannelDissolvedTest.kt index e4da0cf52d..9cbdf06fa4 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"}""")), 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..0bcfa138d4 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,12 @@ 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.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -40,14 +44,14 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertNull 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 +76,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 +117,103 @@ 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) + 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 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) + 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/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/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..712d3bf2f3 --- /dev/null +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -0,0 +1,102 @@ +# 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 | open → chat-plane batch | +| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | open → chat-plane batch | +| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch | +| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | open → control-plane batch | +| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | open → control-plane batch | +| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | open → control-plane batch | +| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch | +| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | open → control-plane + chat-plane batches | +| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch | +| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch | +| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch | +| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch | +| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | open → rekey/invite batch | + +### 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 | open → control-plane batch | +| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | open → control-plane batch | +| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | open → control-plane batch | +| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | open → control-plane batch | +| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | open → rekey/invite batch | +| I9 | 06 §3 | Rotations carry no `vac` | open → rekey/invite batch | +| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch | +| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch | +| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | open → rekey/invite batch | +| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | open → chat-plane batch | +| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch | +| I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch | +| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch | +| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | open → control-plane batch | +| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | open → rekey/invite batch | + +### 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 + +- 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/ConcordCommunityList.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityList.kt index 11ce25695e..c88e4f8a57 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,22 @@ 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) + } + companion object { val EMPTY = ConcordListResidue() } @@ -434,6 +450,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 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..b60892645b 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 @@ -31,6 +31,11 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils /** + * **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 +64,14 @@ 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 (_: 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..91aa4c1895 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt @@ -0,0 +1,100 @@ +/* + * 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 + +/** + * 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]. */ + suspend fun decryptPlaintext(signer: NostrSigner): String? = + try { + signer.nip44Decrypt(content, signer.pubKey) + } 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..0199c137c3 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 @@ -57,6 +57,12 @@ 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 permission bit an edition of each entity kind must be authored under. @@ -133,7 +139,6 @@ 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. @@ -179,8 +184,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..8e80ea0305 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt @@ -0,0 +1,242 @@ +/* + * 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. Vacuously true when nothing is. */ + val complete: Boolean = !overflow && (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 = + of( + events.mapNotNull { e -> + val index = e.index() ?: return@mapNotNull null + if (e.pubKey != signer.pubKey) return@mapNotNull null + Copy(index, e.createdAt, e.id, e.decryptPlaintext(signer)) + }, + ) + + 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..aee6b7e067 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt @@ -0,0 +1,597 @@ +/* + * 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) + } + + /** 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) } + .map { entryToWire(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))) + } + 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) }.map { entryToWire(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/cord04Roles/ControlEntities.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlEntities.kt index 33c3aba220..616e7efed5 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 { @@ -110,14 +150,17 @@ 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, ) @@ -137,4 +180,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/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index 6ff9df015d..009a9372ba 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,16 @@ 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)) + /** 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/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/ConcordCommunityStateTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityStateTest.kt index e4ad7e8718..2d834cc15e 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 @@ -59,10 +59,10 @@ class ConcordCommunityStateTest { 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]) @@ -106,13 +106,16 @@ class ConcordCommunityStateTest { } @Test - fun dissolutionTombstoneMarksCommunityDissolved() { + 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, "00".repeat(32), """{"name":"Doomed"}"""), edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""), ) val state = ConcordCommunityState.fold(editions, owner) - assertTrue(state.dissolved) + 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..6ac1b214df --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt @@ -0,0 +1,453 @@ +/* + * 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.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 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/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))) + } +} From 85b0497716cd634fb021e5a5704edafa219e6817 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 13:52:04 +0000 Subject: [PATCH 2/9] test(prefs): account for the Community List fragments slot The new kind-33302 backup slot never existed in the legacy SharedPreferences file, so it stays out of the one-shot migration table. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../commons/model/preferences/LatestEventCacheStore.kt | 6 ++++-- .../commons/model/preferences/LatestEventCacheStoreTest.kt | 5 +++-- .../commons/model/preferences/LegacyKeyTableTest.kt | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) 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 bde302eeec..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"), @@ -63,7 +65,7 @@ enum class LatestEventSlot( CONCORD_LIST("latestConcordList"), /** The kind-33302 Community List fragments (CORD-02 §8), one event JSON per line. */ - CONCORD_LIST_FRAGMENTS("latestConcordListFragments"), + CONCORD_LIST_FRAGMENTS("latestConcordListFragments", existedInLegacyPrefs = false), TRUST_PROVIDER_LIST("latestTrustProviderList"), KEY_PACKAGE_RELAY_LIST("latestKeyPackageRelayList"), FAVORITE_ALGO_FEEDS_LIST("latestFavoriteAlgoFeedsList"), @@ -97,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/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. */ From f9f195f303de89d72922964c556d90fe955075a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:01:50 +0000 Subject: [PATCH 3/9] fix(concord): never overwrite Community List fragments we could not read Audit fixes for the kind-33302 Community List: - An unreadable fragment (a timed-out or backgrounded signer, a copy that fails the strict parse) made the set "complete" when it was the only one, or hid the larger count a newer copy declared; the next write repacked over it and erased its memberships. Any unreadable copy now makes the set incomplete, which only allows a scoped write into readable fragments. - Nothing is written before the relays have been asked for the List: an empty set right after start-up means "not loaded", not "no List". Writes go through one helper that fetches first and logs instead of crashing when a write can't be made safely. - A membership the typed reader couldn't parse (kept verbatim from a retired 13302 list) no longer makes every join and leave throw; the fragments just don't re-emit it. - Leaving and re-joining in the same second left the re-join dead (added_at == removed_at). Tombstones and joins use millisecond clocks, and a re-join is lifted just past its tombstone. - Decrypt only the newest copy per index and memoize plaintext by event id, instead of decrypting every fragment twice plus the legacy list on every List change (a signer round trip each with Amber or a bunker). - decryptPlaintext rethrows cancellation. - amy registers the dissolved plane's key for NIP-42 AUTH when folding and when publishing a dissolution. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../cli/commands/ConcordChannelCommands.kt | 14 ++-- .../cli/commands/ConcordModCommands.kt | 5 +- .../commons/model/AccountConcordActions.kt | 75 +++++++++++++++---- .../model/concord/ConcordChannelListState.kt | 70 +++++++++++++++-- .../concord/ConcordChannelListLeaveTest.kt | 22 ++++++ .../cord02Community/ConcordCommunityList.kt | 27 +++++++ .../ConcordCommunityListEvent.kt | 3 + .../ConcordCommunityListFragmentEvent.kt | 10 ++- .../cord02Community/ConcordListFragmentSet.kt | 38 +++++++--- .../cord02Community/ConcordListFragments.kt | 20 ++++- .../ConcordListFragmentsTest.kt | 45 +++++++++++ 11 files changed, 290 insertions(+), 39 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 9e2565c088..e1fc21c8f0 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 @@ -153,13 +153,15 @@ 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)) // The dissolution tombstone lives at its own id-derived address (CORD-02 §9), drained alongside. - val dissolvedAddress = ConcordDissolution.planeKey(sc.communityId).publicKeyHex + 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) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 4a4bf10a49..643fe6ffd3 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 @@ -180,7 +180,10 @@ object ConcordModCommands { return Output.error("not_owner", "only the owner can dissolve '$handle' (CORD-02 §9)") } val wrap = ConcordDissolution.build(ctx.signer, sc.communityId) - val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) + 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 diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 7af62cb035..d4e80c252f 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,14 +28,18 @@ 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 @@ -73,7 +77,6 @@ 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 @@ -120,10 +123,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, @@ -174,7 +208,7 @@ class AccountConcordActions( controlRoot = community.controlRoot.toHexKey(), relays = relayUrls, name = name, - addedAt = TimeUtils.now() * 1000, + addedAt = TimeUtils.nowMillis(), ), ) return community.communityIdHex @@ -453,7 +487,7 @@ class AccountConcordActions( } /** Leave a joined Concord community: drop it from the Community List and tombstone it (CORD-02 §8). */ - suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId)) + suspend fun leaveConcordCommunity(communityId: String): Boolean = writeConcordList { it.unfollow(communityId) } /** * Redeem a Concord invite link (`…/invite/#`): parse it, fetch @@ -562,7 +596,7 @@ class AccountConcordActions( controlPk = bundle.controlPk, 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(). @@ -1247,7 +1281,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 } @@ -1335,9 +1373,7 @@ 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)) } } @@ -1419,7 +1455,10 @@ class AccountConcordActions( 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)) + if (!persistConcordEntry(merged)) { + adoptedConcordRotations.remove("${entry.id}:${merged.rootEpoch}") + continue + } announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null) } } @@ -1581,13 +1620,21 @@ class AccountConcordActions( } 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) { - val seeded = account.concordChannelList.republish() - if (seeded.isNotEmpty()) { - Log.d("Concord") { "importConcordCommunities: migrated the 13302 list into ${seeded.size} fragment(s)" } - account.sendMyPublicAndPrivateOutbox(seeded) + 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}" } } } } 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 33903f1636..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 @@ -25,11 +25,13 @@ 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 @@ -53,6 +55,7 @@ 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 account's Community List (offline backup): the kind-33302 fragments @@ -101,6 +104,43 @@ class ConcordChannelListState( /** 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) @@ -119,7 +159,7 @@ class ConcordChannelListState( /** 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) + 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 } @@ -127,7 +167,8 @@ class ConcordChannelListState( /** The fragments plus the merged, decoded List a read-modify-write starts from. */ private suspend fun snapshot(): Pair { val set = fragmentSet() - val legacy = (getConcordList() ?: settings.concordList())?.decryptPlaintext(signer) + 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)) } @@ -174,17 +215,32 @@ class ConcordChannelListState( 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) } } } - /** Add or replace [entry] (by community id) and return the fragment events to publish. */ + /** + * 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() - write(set, doc.entries.filterNot { it.id == entry.id } + entry, doc.residue) + 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) } /** @@ -196,7 +252,7 @@ class ConcordChannelListState( 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.now() * 1000)) + write(set, doc.entries.filterNot { it.id == communityId }, doc.residue.withTombstone(communityId, TimeUtils.nowMillis())) } /** @@ -210,6 +266,10 @@ class ConcordChannelListState( write(set, doc.entries, doc.residue) } + companion object { + private const val MAX_CACHED_PLAINTEXTS = 64 + } + init { val savedLegacy = settings.concordList() val savedFragments = settings.concordListFragments() 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 0bcfa138d4..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 @@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.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 @@ -44,6 +45,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue /** @@ -121,6 +123,7 @@ class ConcordChannelListLeaveTest { 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 } @@ -181,11 +184,30 @@ class ConcordChannelListLeaveTest { 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")) 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 c88e4f8a57..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 @@ -142,6 +142,13 @@ class ConcordListResidue( 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() } @@ -634,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 b60892645b..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,6 +29,7 @@ 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 @@ -68,6 +69,8 @@ class ConcordCommunityListEvent( suspend fun decryptPlaintext(signer: NostrSigner): String? = try { signer.nip44Decrypt(content, signer.pubKey) + } catch (e: CancellationException) { + throw e } catch (_: Exception) { null } 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 index 91aa4c1895..4c58ba75df 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityListFragmentEvent.kt @@ -29,6 +29,7 @@ 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 @@ -62,10 +63,17 @@ class ConcordCommunityListFragmentEvent( */ fun index(): Int? = parseIndex(dTag()) - /** The decrypted plaintext, or null when it does not open for [signer]. */ + /** + * 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 } 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 index 8e80ea0305..21afc71596 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentSet.kt @@ -79,8 +79,13 @@ class ConcordListFragmentSet private constructor( val createdAt: Long, ) - /** True when every index below [declared] is held and readable. Vacuously true when nothing is. */ - val complete: Boolean = !overflow && (0 until declared).all { it in held } + /** + * 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() @@ -228,14 +233,27 @@ class ConcordListFragmentSet private constructor( suspend fun resolve( events: Collection, signer: NostrSigner, - ): ConcordListFragmentSet = - of( - events.mapNotNull { e -> - val index = e.index() ?: return@mapNotNull null - if (e.pubKey != signer.pubKey) return@mapNotNull null - Copy(index, e.createdAt, e.id, e.decryptPlaintext(signer)) - }, - ) + ): 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 index aee6b7e067..9fc0c8218c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragments.kt @@ -334,6 +334,19 @@ object ConcordListFragments { 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"], "") @@ -527,7 +540,7 @@ object ConcordListFragments { .mapNotNull { it as? JsonObject } .filter { isLive(it, removals) } .sortedBy { idOf(it) } - .map { entryToWire(it) } + .mapNotNull { entryToWireOrNull(it) } val tombstones = (doc["tombstones"] as? JsonArray) .orEmpty() @@ -548,6 +561,9 @@ object ConcordListFragments { 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() @@ -584,7 +600,7 @@ object ConcordListFragments { val removals = removals(doc) return serializeFragment( frags, - keptEntries.filter { isLive(it, removals) }.sortedBy { idOf(it) }.map { entryToWire(it) }, + 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 }), ) 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 index 6ac1b214df..de3ae953a7 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordListFragmentsTest.kt @@ -34,6 +34,7 @@ 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 @@ -350,6 +351,50 @@ class ConcordListFragmentsTest { 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 = From 7b5217286c13f7393e7769970c487619d2c79768 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:20:13 +0000 Subject: [PATCH 4/9] fix(concord): conform the Control Plane fold to CORD-04 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Grants count only at grant_locator(community_id, member), the Banlist only at banlist_locator and as ONE authority-gated head (no fork union), and metadata only at eid == community_id. The fold, authorizedHeads and the resolver now take the community id. - Equal-version ties break authority-first, then lower rumor id. - Control editions must arrive in a plaintext 20014 seal. - Every non-owner edition carries a vac citation of the actor's Grant, and every fold gate, floor and compaction verifies it (AuthorityCitations). - Roles carry role_id; new entities start at version 1 (v0 still reads). - Unmodeled vsk heads (pins, signals) survive compaction, gated per kind. - Caps from CORD-02 §6 / CORD-04 §2 enforced on write and at fold (ConcordLimits); strict canonical decimals and no duplicate edition tags. - amy accepts PIN_MESSAGES, VIEW_AUDIT_LOG and MENTION_EVERYONE; the app's Admin role matches the reference client's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- cli/README.md | 2 +- .../cli/commands/ConcordChannelCommands.kt | 2 +- .../amethyst/cli/commands/ConcordCommands.kt | 12 +- .../cli/commands/ConcordModCommands.kt | 13 +- .../commons/actions/ConcordActions.kt | 11 +- .../commons/actions/ConcordModeration.kt | 143 +++++---- .../commons/model/AccountConcordActions.kt | 25 +- .../model/concord/ConcordCommunitySession.kt | 5 +- .../commons/actions/ConcordActionsTest.kt | 7 +- .../commons/actions/ConcordModerationTest.kt | 165 ++++++++-- .../actions/ConcordSubscriptionPlannerTest.kt | 10 +- .../concord/ConcordChannelDissolvedTest.kt | 2 +- .../model/concord/ConcordMembershipTest.kt | 13 +- .../model/concord/ConcordPlaneRegistryTest.kt | 2 +- .../model/concord/ConcordRollbackFloorTest.kt | 9 +- .../2026-09-29-concord-spec-conformance.md | 20 +- .../ConcordCommunityFactory.kt | 9 +- .../cord02Community/ConcordCommunityState.kt | 73 ++++- .../concord/cord04Roles/AuthorityCitations.kt | 74 +++++ .../concord/cord04Roles/AuthorityResolver.kt | 303 ++++++++++++++---- .../concord/cord04Roles/ConcordLimits.kt | 51 +++ .../concord/cord04Roles/ControlEdition.kt | 65 +++- .../concord/cord04Roles/ControlEntities.kt | 22 +- .../quartz/concord/cord04Roles/EditionFold.kt | 180 ++++++++--- .../control/tags/CanonicalDecimal.kt | 42 +++ .../concord/cord04Roles/control/tags/EvTag.kt | 3 +- .../cord04Roles/control/tags/VacTag.kt | 2 +- .../concord/cord06Rekey/ConcordRefounding.kt | 16 +- .../ConcordCommunityFactoryTest.kt | 19 +- .../ConcordCommunityStateTest.kt | 78 ++++- .../cord04Roles/AuthorityResolverTest.kt | 133 ++++++-- .../cord04Roles/BannedStaffEscalationTest.kt | 68 ++-- .../concord/cord04Roles/ControlEditionTest.kt | 73 +++++ .../concord/cord04Roles/ControlFixtures.kt | 102 ++++++ .../ControlPlaneConformanceTest.kt | 276 ++++++++++++++++ .../ControlPlaneVersionExhaustionTest.kt | 55 ++-- .../ConcordInviteJoinFlowTest.kt | 2 +- .../cord06Rekey/ConcordRefoundingTest.kt | 87 ++++- .../cord06Rekey/ControlRootRotationTest.kt | 2 +- 39 files changed, 1775 insertions(+), 401 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/AuthorityCitations.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ConcordLimits.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/control/tags/CanonicalDecimal.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlFixtures.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneConformanceTest.kt diff --git a/cli/README.md b/cli/README.md index 799d94bb3b..931d327d6f 100644 --- a/cli/README.md +++ b/cli/README.md @@ -683,7 +683,7 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `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 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). | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 9e2565c088..902ebc7a31 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 @@ -166,7 +166,7 @@ object ConcordChannelCommands { .map { it.second } val (graveWraps, controlWraps) = wraps.partition { it.pubKey == dissolvedAddress } return ConcordActions - .foldCommunity(controlWraps, controlPlane, sc.owner) + .foldCommunity(controlWraps, controlPlane, sc.communityId.hexToByteArray(), sc.owner) .withDissolved(ConcordDissolution.isDissolved(graveWraps, sc.communityId, sc.owner)) } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 6c2212a4e5..5be83bb995 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 @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFrag import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot 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 @@ -135,6 +136,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() @@ -458,7 +462,7 @@ 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)") } @@ -528,7 +532,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) @@ -633,7 +637,7 @@ object ConcordCommands { results += mapOf("community_id" to sc.communityId, "name" to sc.name, "recovered" to false, "reason" to "control_plane_not_folded") continue } - val bannedHere = AuthorityResolver.resolve(editions, sc.owner).isBanned(ctx.signer.pubKey) + val bannedHere = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner).isBanned(ctx.signer.pubKey) val merged = ConcordActions.recoverStranded(entryFor(sc), bundle, bannedHere) if (merged == null) { @@ -711,7 +715,7 @@ 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)) { + if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), 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) continue } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 4a4bf10a49..63a47ac7b8 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 @@ -31,6 +31,7 @@ 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 @@ -56,7 +57,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 @@ -92,6 +93,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 -> @@ -100,7 +104,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)) @@ -281,7 +285,7 @@ object ConcordModCommands { 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 @@ -543,6 +547,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/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..deb7f9b0bd 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 @@ -226,21 +226,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 --------------------------------------------------------- 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 75eb69da6c..1efb5b1066 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 @@ -53,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 { /** @@ -84,55 +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 - } - - /** - * The next edition's content for [entityId]: [value] 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). - */ - private fun contentOver( - serializer: KSerializer, - value: T, - current: List, - entityId: ByteArray, - owner: HexKey, - ): String = ConcordJson.encodePreserving(serializer, value, headOf(current, entityId, owner)?.content) + /** 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, @@ -140,32 +160,30 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { - val (version, prev) = versioning(current, roleId, owner) - val content = contentOver(RoleEntity.serializer(), role, current, roleId, owner) - 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, createdAt: Long, citation: AuthorityCitation? = null, owner: HexKey, - ): Event { - val (version, prev) = versioning(current, channelId, owner) - val content = contentOver(ChannelEntity.serializer(), channel, current, channelId, owner) - return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) - } + ): Event = 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 @@ -188,7 +206,7 @@ object ConcordModeration { * 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, @@ -200,9 +218,9 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { - val (version, prev) = versioning(current, communityId, owner) - val content = contentOver(MetadataEntity.serializer(), metadata, current, communityId, owner) - 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) } /** @@ -226,10 +244,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 = contentOver(GrantEntity.serializer(), GrantEntity(member = member, roleIds = roleIds, controlWrap = controlWrap), current, entityId, owner) - 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) } /** @@ -258,7 +276,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 @@ -270,14 +288,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, @@ -302,19 +325,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, @@ -327,8 +350,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/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 7af62cb035..71871c69c7 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 @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat 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 @@ -151,6 +152,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 @@ -546,7 +549,7 @@ 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 } @@ -897,7 +900,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, @@ -912,6 +920,9 @@ class AccountConcordActions( ConcordPermissions.BAN, ConcordPermissions.MANAGE_MESSAGES, ConcordPermissions.CREATE_INVITE, + ConcordPermissions.VIEW_AUDIT_LOG, + ConcordPermissions.MENTION_EVERYONE, + ConcordPermissions.PIN_MESSAGES, ).toWire(), ) @@ -958,7 +969,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() } @@ -1444,6 +1455,8 @@ class AccountConcordActions( // 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 @@ -1478,7 +1491,7 @@ 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) + 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 } @@ -1501,7 +1514,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = (standing ?: ChannelEntity()).copy(name = name.trim()) - val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + 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 } @@ -1523,7 +1536,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = (standing ?: ChannelEntity()).copy(name = name.trim(), deleted = true) - val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) + 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 } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 8f40a07e67..e89a5a5216 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 @@ -584,6 +584,7 @@ class ConcordCommunitySession( val folded = ConcordCommunityState.fold( editionsLocked(wraps, controlKeys), + communityIdBytes, entry.owner, controlFloorsLocked(), ) @@ -628,7 +629,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 } @@ -653,7 +654,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 } 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/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 9cbdf06fa4..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 @@ -49,7 +49,7 @@ class ConcordChannelDissolvedTest { // 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"}""")), owner) + .fold(listOf(ed(ControlEntityKind.CHANNEL, channelId, """{"name":"general"}""")), "cc".repeat(32).hexToByteArray(), owner) .withDissolved(dissolved) @Test 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/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 712d3bf2f3..ebc51e67f6 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -44,11 +44,11 @@ Ranked security > interop > feature inside each group. | S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | open → chat-plane batch | | S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | open → chat-plane batch | | S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch | -| S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | open → control-plane batch | -| S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | open → control-plane batch | -| S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | open → control-plane batch | -| S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch | -| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | open → control-plane + chat-plane batches | +| 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` | Control half **fixed** — `ControlEdition.fromOpened` refuses a non-20014 seal; used by the session, `ConcordActions.controlEditions` (CLI) and Refounding compaction. Chat half open → chat-plane batch | | S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch | | S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch | | S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch | @@ -62,10 +62,10 @@ Ranked security > interop > feature inside each group. | 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 | open → control-plane batch | -| I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | open → control-plane batch | -| I6 | 04 §1 | First edition is v0; spec says versions start at 1 | open → control-plane batch | -| I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | open → control-plane batch | +| 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 | open → rekey/invite batch | | I9 | 06 §3 | Rotations carry no `vac` | open → rekey/invite batch | | I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch | @@ -75,7 +75,7 @@ Ranked security > interop > feature inside each group. | I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch | | I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch | | I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch | -| I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | open → control-plane batch | +| 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 | open → rekey/invite batch | ### Features 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/ConcordCommunityState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ConcordCommunityState.kt index 0199c137c3..75a2845c80 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( @@ -64,6 +65,12 @@ data class ConcordCommunityState( fun withDissolved(dissolved: Boolean): ConcordCommunityState = if (dissolved == this.dissolved) this else copy(dissolved = dissolved) companion object { + /** The Pin List sub-kind (CORD-04 §7), carried but not modeled here. */ + private const val VSK_PINS = "11" + + /** 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 @@ -79,10 +86,39 @@ data class ConcordCommunityState( ControlEntityKind.DISSOLVED -> null } + /** + * 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 Pin List by `PIN_MESSAGES` (CORD-04 §7), 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_PINS -> authority.admits(edition, ConcordPermissions.PIN_MESSAGES) + 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 @@ -92,6 +128,7 @@ data class ConcordCommunityState( */ fun authorizedHeads( editions: Collection, + communityId: ByteArray, ownerPubKey: String, floors: Map = emptyMap(), ): Map { @@ -102,15 +139,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 @@ -122,8 +158,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 { @@ -142,7 +184,7 @@ data class ConcordCommunityState( // 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 @@ -154,20 +196,23 @@ 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. 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 616e7efed5..e433de2b29 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 @@ -111,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. */ @@ -126,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 + } } /** 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/cord06Rekey/ConcordRefounding.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefounding.kt index 74fb73b594..02a7e64cdb 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 @@ -129,7 +129,7 @@ object ConcordRefounding { val newEpoch = rootEpoch + 1 val newControlKeys = ControlPlaneKeys.forStaff(newRoot, communityId, newEpoch, newControlRoot) - val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, ownerPubKey) + val controlWraps = compactControlPlane(priorControlWraps, priorControlKeys, newControlKeys, communityId, ownerPubKey) val baseRekeyKey = ConcordKeyDerivation.baseRekeyAddress(priorRoot, communityId, newEpoch) val prevCommit = ConcordKeyDerivation.epochKeyCommitment(rootEpoch, priorRoot).toHexKey() @@ -163,19 +163,27 @@ 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, ): 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,7 +203,7 @@ 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 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 2d834cc15e..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,15 +47,15 @@ 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) @@ -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,22 +100,85 @@ 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 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.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, "00".repeat(32), """{"name":"Doomed"}"""), + edition(ControlEntityKind.METADATA, ControlFixtures.COMMUNITY_ID_HEX, """{"name":"Doomed"}"""), edition(ControlEntityKind.DISSOLVED, "dd".repeat(32), """{}"""), ) - val state = ConcordCommunityState.fold(editions, owner) + 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/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..3e49c39897 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,75 @@ 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 (Pins 11, Signals 12, anything newer) is kept, raw. + val pins = parse(arrayOf("vsk", "11"), arrayOf("eid", eid.toHexKey()), arrayOf("ev", "1")) + assertNotNull(pins) + assertNull(pins.entityKind) + assertEquals("11", pins.vsk) + 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/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..f7ab4d8b4b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneConformanceTest.kt @@ -0,0 +1,276 @@ +/* + * 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.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 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..08cb9f52a8 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,17 +111,17 @@ 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", ) } @@ -132,18 +131,18 @@ class ControlPlaneVersionExhaustionTest { 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 floorsBefore = ControlFixtures.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 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, ControlFixtures.fold(pool, owner).channels.size, "a fresh joiner still sees the channel") + assertEquals(1, ControlFixtures.fold(pool, owner, floorsAfter).channels.size, "and so does a client holding a floor") assertEquals( 1, - ConcordCommunityState.fold(community + repair, owner, floorsAfter).channels.size, + ControlFixtures.fold(community + repair, owner, floorsAfter).channels.size, "the channel survives a Refounding too", ) } @@ -152,25 +151,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 +181,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 +199,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 +215,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/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/cord06Rekey/ConcordRefoundingTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRefoundingTest.kt index dbc6e9c81d..eabc117fa2 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,72 @@ 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") + assertNull(pins.single().entityKind, "a sub-kind we don't model") + assertEquals(signal.id, editions.single { it.vsk == "12" }.rumorId, "the Signal head rides through") + + 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/ControlRootRotationTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ControlRootRotationTest.kt index 7a4440e1a0..838c26011a 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()) } From f231b953843529f4f75c298dd8b0055d42f366b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:20:55 +0000 Subject: [PATCH 5/9] fix(concord): strict chat binding, ms tag, chat kind gate, NIP-44 cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the CORD-01/03 Chat Plane primitives in quartz up to spec b84554e: - I13: the binding is strict — exactly one `channel` and one `epoch` tag, the epoch compared as its canonical decimal string ("04", "+4" and duplicates no longer bind), matching Armada's uniqueTag / checkChannelBinding. Builders drop binding tags smuggled in extraTags (and no longer collapse repeated extra tags such as several emoji). - I15: every ChannelChat rumor carries ["ms", 0..999] after the binding (CORD-02 §4, examples §2); MsTag parses strictly and a malformed or duplicated ms drops the rumor; edit recency uses the same basis. - I16: the inline quote is the four-element ["q", id, "", author]. - S3 (quartz half): ChannelChat.delete builds the in-stream kind 5 of examples §2.4 (binding, e per target, k per target kind). - S9 (chat half): ChannelChat.acceptOpened is the Chat ingest gate — a 20013 seal, a CHAT_KINDS rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed ms; other planes' kinds are refused. - S10: ConcordStreamEnvelope refuses to seal or wrap a plaintext over 65,535 bytes instead of letting NIP-44 switch to its extended format, and refuses an extended-format payload on open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../concord/cord03Channels/ChannelChat.kt | 152 +++++++++-- .../cord03Channels/ConcordChatEditEvent.kt | 12 +- .../cord03Channels/TagArrayBuilderExt.kt | 15 ++ .../concord/cord03Channels/TagArrayExt.kt | 36 ++- .../concord/cord03Channels/tags/EpochTag.kt | 10 +- .../concord/cord03Channels/tags/MsTag.kt | 86 +++++++ .../concord/envelope/ConcordStreamEnvelope.kt | 50 +++- .../ChannelChatConformanceTest.kt | 243 ++++++++++++++++++ .../envelope/ConcordStreamEnvelopeCapTest.kt | 93 +++++++ 9 files changed, 656 insertions(+), 41 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/tags/MsTag.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ChannelChatConformanceTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/envelope/ConcordStreamEnvelopeCapTest.kt 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/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/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/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/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)) + } +} From e94bb2f6374e9ae4c554cfcd24708ee63b893ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:21:15 +0000 Subject: [PATCH 6/9] fix(concord): terminal channel deletion and the 64-byte name cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORD-03 §2 (I14): - Deletion is terminal across the whole accepted edition set: any MANAGE_CHANNELS-gated channel edition with `deleted: true` retires the channel even if a later edition "restores" it (Armada `everDeleted`). - The channel gate enforces the name rule (non-empty, at most 64 UTF-8 bytes): an edition breaking it is unauthorized and the fold falls back to the previous candidate. ConcordModeration.defineChannel refuses to mint one. ControlPlaneVersionExhaustionTest's channel case now poisons with a max-version rename: a max-version delete from an authorized holder is terminal by design now, while a max-version edition still must not pin the channel's content. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../commons/actions/ConcordModeration.kt | 3 + .../cord02Community/ConcordCommunityState.kt | 18 ++- .../concord/cord04Roles/ControlEntities.kt | 18 ++- .../ChannelFoldConformanceTest.kt | 112 ++++++++++++++++++ .../ControlPlaneVersionExhaustionTest.kt | 41 +++++-- 5 files changed, 183 insertions(+), 9 deletions(-) create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt 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 75eb69da6c..16202f3cbc 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 @@ -162,6 +162,9 @@ object ConcordModeration { citation: AuthorityCitation? = null, owner: HexKey, ): Event { + // 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" } val (version, prev) = versioning(current, channelId, owner) val content = contentOver(ChannelEntity.serializer(), channel, current, channelId, owner) return wrap(actor, controlPlane, ControlEntityKind.CHANNEL, channelId, version, prev, content, createdAt, citation) 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 0199c137c3..760bcb09b1 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 @@ -171,8 +171,24 @@ data class ConcordCommunityState( ?.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 } + val channelGate = { edition: ControlEdition -> + (authority.isOwner(edition.author) || authority.hasPermission(edition.author, 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, 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) 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 616e7efed5..6f3e69560e 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 @@ -162,7 +162,23 @@ data class ChannelEntity( val name: String = "", val private: 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 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..881e134384 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord02Community/ChannelFoldConformanceTest.kt @@ -0,0 +1,112 @@ +/* + * 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.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 = ConcordCommunityState.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 = ConcordCommunityState.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", + ConcordCommunityState + .fold(listOf(c0, tooLong), owner) + .channels[chan] + ?.definition + ?.name, + ) + + val empty = chained(1, c0, """{"name":""}""") + assertEquals( + "general", + ConcordCommunityState + .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), + ConcordCommunityState + .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/cord04Roles/ControlPlaneVersionExhaustionTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/ControlPlaneVersionExhaustionTest.kt index c97ed0dba9..e2bbad7c12 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 @@ -127,24 +127,51 @@ class ControlPlaneVersionExhaustionTest { ) } + /** + * 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 poison = edition(ControlEntityKind.CHANNEL, channelEntity, Long.MAX_VALUE, channelV0.hash, """{"name":"PWNED"}""", bob, "chan-poison") val floorsAfter = ConcordCommunityState.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", + ConcordCommunityState + .fold(pool, owner) + .channels[channelEntity] + ?.definition + ?.name, + "a fresh joiner follows the honest chain", + ) + assertEquals( + "general", + ConcordCommunityState + .fold(pool, owner, floorsAfter) + .channels[channelEntity] + ?.definition + ?.name, + "and so does a client holding a floor", + ) + assertEquals( + "general", + ConcordCommunityState + .fold(community + repair, owner, floorsAfter) + .channels[channelEntity] + ?.definition + ?.name, + "the channel stays repaired across a Refounding too", ) } From 4714144bf0afda44e0a8412e64a8c0ae8686b639 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:21:33 +0000 Subject: [PATCH 7/9] fix(concord): private channels on their own keys, in-channel deletes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S2 — a `private: true` channel was keyed from community_root at the root epoch, so posts under the Lock icon landed on a plane every member decrypts. Now (CORD-03 §1): - ConcordActions.currentChannelPlane / historicalChannelPlanes derive a private channel only from the held key in the Community List entry's `privateChannels`, bound to the channel epoch; with no key there is no plane at all — never the root-derived one. - The session, subscription planner, plane registry, the app's verbs (send, image, reply, react, edit, typing) and amy all resolve planes through it. A key delivered later is adopted in place (ConcordCommunitySession.adoptPrivateChannels), and keys an invite carries are stored on join. - A keyless private channel is locked: ConcordChannel.keyHeld / canPost() false, the composer is replaced by a notice, and `amy concord send` fails with `no_channel_key` (`channels` reports `readable`). Creating private channels (key delivery) stays open (F7). S3 — deleting your own Concord message went out as a NIP-17 kind 5 to the p-tagged users, leaking the rumor id outside the community, and never reached the channel. Account.delete / deletePrivately now route Concord notes (messages, replies, reactions) to AccountConcordActions.deleteConcordRumors: the in-stream kind 5, sealed 20013 on the plane of each target's bound epoch. Tapping your own Concord reaction again retracts it the same way. S9 — chat ingest (session, typing, ConcordActions.channelRumors) goes through ChannelChat.acceptOpened, and EventCache.consumeConcordRumor refuses non-chat kinds as defense in depth. channelMessages orders by created_at*1000+ms. CORD-03 §3 — your own kind-1111 thread replies can be edited like kind-9 messages. Review statuses updated for S2, S3, S9 (chat half), S10, I13-I16. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- .../chats/feed/ChatMessageActionSheet.kt | 7 +- .../concord/ConcordChannelScreen.kt | 14 +- cli/README.md | 6 +- .../cli/commands/ConcordChannelCommands.kt | 35 +- .../amethyst/cli/commands/ConcordCommands.kt | 6 + .../cli/commands/ConcordModCommands.kt | 9 +- .../amethyst/cli/stores/ConcordStore.kt | 12 + .../commons/actions/ConcordActions.kt | 146 ++++++++- .../actions/ConcordSubscriptionPlanner.kt | 32 +- .../amethyst/commons/model/Account.kt | 21 +- .../commons/model/AccountConcordActions.kt | 114 ++++++- .../commons/model/cache/EventCache.kt | 6 + .../commons/model/concord/ConcordChannel.kt | 19 +- .../model/concord/ConcordCommunitySession.kt | 138 +++++--- .../model/concord/ConcordPlaneRegistry.kt | 16 +- .../model/concord/ConcordSessionRegistry.kt | 3 + .../ConcordChatPlaneConformanceTest.kt | 307 ++++++++++++++++++ .../composeResources/values/strings.xml | 1 + .../commons/viewmodels/AccountViewModel.kt | 6 +- .../2026-09-29-concord-spec-conformance.md | 16 +- 20 files changed, 789 insertions(+), 125 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt index 889d177e43..90a35e9cc8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/feed/ChatMessageActionSheet.kt @@ -109,6 +109,7 @@ import com.vitorpamplona.amethyst.ui.note.payViaIntentOrManualSplit import com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.OnchainZapSendDialog import com.vitorpamplona.quartz.buzz.stream.StreamMessageV2Event import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nipC7Chats.ChatEvent import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -278,15 +279,15 @@ fun ChatMessageActionSheet( // Editing my own chat message. Two surfaces publish an edit today, gated by type: // - Buzz: kind-40002 stream message → a kind-40003 edit. - // - Concord: kind-9 channel message (carries a ConcordChannel gatherer) → a - // kind-1010 edit wrapped on the channel plane. + // - Concord: kind-9 channel message or kind-1111 thread reply (carries a + // ConcordChannel gatherer) → a kind-3302 edit wrapped on the channel plane. // Both restrict to my own messages; a note is only ever one of the two, so at // most one tile shows and both route through the same edit callback. val isMine = note.author?.pubkeyHex == accountViewModel.userProfile().pubkeyHex val canEditBuzz = onWantsToEditChatMessage != null && note.event is StreamMessageV2Event && isMine val canEditConcord = onWantsToEditChatMessage != null && - note.event is ChatEvent && + (note.event is ChatEvent || note.event is CommentEvent) && isMine && note.inGatherers?.any { it is ConcordChannel } == true // Marmot: my own text message in a group -> a kind-1009 edit inside the group. A diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt index d79851774f..92a08474f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelScreen.kt @@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.observeUserInfo import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.back import com.vitorpamplona.amethyst.commons.resources.concord_dissolved_read_only +import com.vitorpamplona.amethyst.commons.resources.concord_private_channel_no_key import com.vitorpamplona.amethyst.commons.resources.concord_send_image_title import com.vitorpamplona.amethyst.commons.resources.concord_typing_many import com.vitorpamplona.amethyst.commons.resources.concord_typing_one @@ -123,6 +124,7 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon /** @@ -264,6 +266,10 @@ fun ConcordChannelScreen( // CORD-02 §9: an owner-signed tombstone seals the community read-only — the composer is // gone (canPost() is false) and this replaces it so the seal is explained, not silent. ConcordDissolvedNotice() + } else if (!channel.keyHeld) { + // CORD-03 §1: a Private Channel is keyed independently; without its key there is no + // plane only its members can read, so nothing may be posted (never to the root plane). + ConcordReadOnlyNotice(Res.string.concord_private_channel_no_key) } } } @@ -274,9 +280,13 @@ fun ConcordChannelScreen( * The tombstone seals the community: history stays readable, but no member may post again. */ @Composable -private fun ConcordDissolvedNotice() { +private fun ConcordDissolvedNotice() = ConcordReadOnlyNotice(Res.string.concord_dissolved_read_only) + +/** A one-line explanation shown where the composer would be when this channel cannot be posted to. */ +@Composable +private fun ConcordReadOnlyNotice(message: StringResource) { Text( - text = stringRes(Res.string.concord_dissolved_read_only), + text = stringRes(message), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.placeholderText, modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), diff --git a/cli/README.md b/cli/README.md index 799d94bb3b..3749dcc2ea 100644 --- a/cli/README.md +++ b/cli/README.md @@ -676,9 +676,9 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord create --name NAME [--about T] [--relay wss://a,wss://b]` | Create an encrypted Concord community. `--relay` is canonical; `--relays` is accepted as an alias. | | `amy concord list` | List joined Concord communities. | | `amy concord import` | Fetch + decrypt this account's Community List — the kind:33302 fragments plus the retired kind:13302 (carries heldRoots, CORD-06). | -| `amy concord channels COMMUNITY` | List a community's channels. | -| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). | -| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane. | +| `amy concord channels COMMUNITY` | List a community's channels; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). | +| `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | +| `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link. | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, then records it in your Invite List. | | `amy concord join URL` | Redeem an invite link and save the community. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt index 9e2565c088..06a0f5ba93 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordChannelCommands.kt @@ -57,7 +57,13 @@ object ConcordChannelCommands { "banner" to state.metadata?.banner?.let { mapOf("url" to it.url, "key" to it.key, "nonce" to it.nonce, "hash" to it.hash) }, "channels" to state.channels.values.map { - mapOf("id" to it.channelIdHex, "name" to it.definition.name, "private" to it.definition.private) + mapOf( + "id" to it.channelIdHex, + "name" to it.definition.name, + "private" to it.definition.private, + // False for a Private Channel whose key this account does not hold (CORD-03 §1). + "readable" to ConcordActions.canAccessChannel(ConcordCommands.entryFor(sc), state, it.channelIdHex), + ) }, ), ) @@ -80,12 +86,18 @@ object ConcordChannelCommands { ctx.prepare() // CORD-02 §9: a dissolved community is sealed read-only — held keys still open history, but // nothing new is honored, so refuse to post before we ever build/publish a wrap. - if (foldState(ctx, sc).dissolved) { + val state = foldState(ctx, sc) + if (state.dissolved) { return Output.error("dissolved", "community '$handle' has been dissolved and is read-only (CORD-02 §9)") } val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") - val channel = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelId.hexToByteArray(), sc.rootEpoch) - val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, sc.rootEpoch, text, TimeUtils.now()) + // The channel's own plane (CORD-03 §1): root-derived when Public, its held key when + // Private — and a refusal, never the root plane, for a Private Channel we hold no key for. + val plane = + ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId) + ?: return Output.error("no_channel_key", "channel '$channelRef' is not folded, or is private and this account holds no key for it (CORD-03 §1)") + val channel = plane.key + val wrap = ConcordActions.buildChannelMessage(ctx.signer, channel, channelId, plane.epoch, text, TimeUtils.now()) val relays = ConcordCommands.relaysFor(ctx, sc) // A relay that gates writes behind NIP-42 wants the wrap's author (the stream key) authenticated. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) @@ -127,7 +139,20 @@ object ConcordChannelCommands { Context.open(dataDir).use { ctx -> ctx.prepare() val channelId = resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") - val channel = ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch) + val state = foldState(ctx, sc) + // A Private Channel is read only on its own key's plane (CORD-03 §1); --root/--epoch pick a + // root-derived plane and so apply to Public Channels only. + val privatePlane = + if (state.channels[channelId]?.definition?.private == true) { + ConcordActions.currentChannelPlane(ConcordCommands.entryFor(sc), state, channelId) + ?: return Output.error("no_channel_key", "channel '$channelRef' is private and this account holds no key for it (CORD-03 §1)") + } else { + null + } + val channel = privatePlane?.key ?: ConcordActions.publicChannel(rootHex.hexToByteArray(), channelId.hexToByteArray(), epoch) + + @Suppress("NAME_SHADOWING") + val epoch = privatePlane?.epoch ?: epoch val relays = ConcordCommands.relaysFor(ctx, sc) // The channel plane is NIP-42-gated to its own derived stream key; register it so the drain authenticates. ctx.registerConcordStreamKeys(relays, listOf(channel.secretKey)) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 6c2212a4e5..0ab8e4c87c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.cli.stores.StoredHeldRoot +import com.vitorpamplona.amethyst.cli.stores.StoredPrivateChannel import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList @@ -35,6 +36,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEven import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList @@ -248,6 +250,7 @@ object ConcordCommands { // Survives every merge: losing the anchor makes the NEXT exclusion // unrecoverable, so a list entry without one must not clear ours. inviteRef = e.inviteRef ?: prior?.inviteRef ?: "", + privateChannels = e.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, ), ) mapOf( @@ -477,6 +480,7 @@ object ConcordCommands { // The stranded-recovery anchor: if a later Refounding leaves us out, re-resolving // this link is the only way back (CORD-05/06). Stored bare, domain-agnostic. inviteRef = ConcordActions.bareInviteRef(url) ?: "", + privateChannels = ConcordActions.privateChannelKeysOf(bundle).map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, ), ) Output.emit(mapOf("community_id" to bundle.communityId, "name" to bundle.name, "relays" to bundle.relays)) @@ -546,6 +550,7 @@ object ConcordCommands { controlPk = sc.controlPk.ifBlank { null }, controlRoot = sc.controlRoot.ifBlank { null }, heldRoots = sc.heldRoots.map { HeldRoot(it.epoch, it.root, it.controlPk.ifBlank { null }, it.controlRoot.ifBlank { null }) }, + privateChannels = sc.privateChannels.map { PrivateChannelKey(it.channelId, it.key, it.epoch, it.name) }, relays = sc.relays, name = sc.name, inviteRef = sc.inviteRef.ifBlank { null }, @@ -564,6 +569,7 @@ object ConcordCommands { relays = entry.relays, name = entry.name.ifBlank { sc.name }, inviteRef = entry.inviteRef ?: sc.inviteRef, + privateChannels = entry.privateChannels.filter { it.key.isNotBlank() }.map { StoredPrivateChannel(it.channelId, it.key, it.epoch, it.name) }, ) /** diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt index 4a4bf10a49..1f3b1e84e9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordModCommands.kt @@ -483,12 +483,15 @@ object ConcordModCommands { ): Set { val out = HashSet() val relays = ConcordCommands.relaysFor(ctx, sc) - for ((channelIdHex, _) in state.channels) { + val entry = ConcordCommands.entryFor(sc) + for (channelIdHex in state.channels.keys) { + // A Private Channel we hold no key for has no plane we may read (CORD-03 §1). + val plane = ConcordActions.currentChannelPlane(entry, state, channelIdHex) ?: continue runCatching { - val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch) + val key = plane.key ctx.registerConcordStreamKeys(relays, listOf(key.secretKey)) val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } - ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() } + ConcordActions.channelMessages(wraps, key, channelIdHex, plane.epoch).mapTo(out) { it.author.lowercase() } } } return out diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt index c36b1ac915..575f06b26c 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/stores/ConcordStore.kt @@ -53,6 +53,18 @@ data class StoredCommunity( // rekey has no message to miss: re-resolving this link is the only way back. Blank for a direct // invite or a community joined before amy stored it. val inviteRef: String = "", + // Private Channel keys this account holds (CORD-03 §1), from the Community List or an invite. + // A private channel is read and written ONLY on the plane its own key derives; without one it + // is unreadable and `send` refuses rather than fall back to the root-derived plane. + val privateChannels: List = emptyList(), +) + +/** A held Private Channel key at its channel epoch, mirroring quartz `PrivateChannelKey`. */ +data class StoredPrivateChannel( + val channelId: String = "", + val key: String = "", + val epoch: Long = 0, + val name: String = "", ) /** A past community_root for a specific epoch, mirroring quartz `HeldRoot`. */ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt index 0a9699bd1c..1707126224 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordActions.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -68,15 +69,19 @@ data class ConcordChatMessage( ) /** - * One channel's Chat Plane at a prior epoch: the epoch-invariant [channelIdHex], the [epoch] the - * wraps are bound to (for `isBoundTo` validation), and the derived [key] to decrypt them. + * One channel's Chat Plane: the epoch-invariant [channelIdHex], the [epoch] its rumors are bound to + * (for `isBoundTo` validation — the root epoch for a Public Channel, the channel's own epoch for a + * Private one, CORD-03 §1), and the derived [key] its wraps are addressed by and decrypt under. */ -data class HistoricalChannelPlane( +data class ChannelPlane( val channelIdHex: HexKey, val epoch: Long, val key: GroupKey, ) +/** A [ChannelPlane] at a prior epoch (pre-Refounding history). */ +typealias HistoricalChannelPlane = ChannelPlane + /** * Concord community verbs — pure builders, plane-key derivation, relay-filter * assembly, and event folding usable from amy CLI, the Android app, and any other @@ -142,6 +147,92 @@ object ConcordActions { rootEpoch: Long, ): GroupKey = ConcordChannelKeys.publicChannel(communityRoot, channelId, rootEpoch) + private val HEX64 = Regex("^[0-9a-fA-F]{64}$") + + /** + * The independent key this account holds for Private Channel [channelIdHex] (delivered on grant + * and carried in the Community List's `privateChannels`, CORD-03 §1 / CORD-02 §8), or null when + * it holds none. A keyless entry (a writer listing a public channel as `{id, epoch}`) is not a key. + */ + fun heldPrivateChannelKey( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): PrivateChannelKey? = entry.privateChannels.firstOrNull { it.channelId.equals(channelIdHex, ignoreCase = true) && HEX64.matches(it.key) } + + /** + * The Chat Plane a channel is **written** on, or null when this account cannot write it + * (CORD-03 §1): + * - Public: `group_key("concord/channel", community_root, channel_id, root_epoch)`, bound to + * the root epoch; + * - Private: `group_key("concord/channel", channel_key, channel_id, channel_epoch)` from the + * held key, bound to the **channel** epoch — and null when no key is held. A Private Channel + * must never fall back to the root-derived plane: every member decrypts that one, so a post + * there would be public to the whole community under a Lock icon. + */ + fun currentChannelPlane( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + isPrivate: Boolean, + ): ChannelPlane? { + val channelId = channelIdHex.hexToByteArray() + if (isPrivate) { + val held = heldPrivateChannelKey(entry, channelIdHex) ?: return null + return ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch)) + } + return ChannelPlane(channelIdHex, entry.rootEpoch, publicChannel(entry.root.hexToByteArray(), channelId, entry.rootEpoch)) + } + + /** + * [currentChannelPlane] for a channel of the folded [state], or null when the channel is not in + * the fold (unknown or deleted) or is Private with no held key. + */ + fun currentChannelPlane( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + channelIdHex: HexKey, + ): ChannelPlane? { + val def = state.channels[channelIdHex]?.definition ?: return null + return currentChannelPlane(entry, channelIdHex, def.private) + } + + /** + * The older Chat Planes of a channel this account can still read, beside [currentChannelPlane]: + * - Public: its plane under every held prior root ([historicalChannelPlanes]), plus the + * private-era plane when a channel key is held (a channel that was Private before); + * - Private: none. Only the channel-key planes are its own; the root-derived plane is readable + * by every member, so showing it would present public content as private (Armada + * `channelsView`). With no priors kept per channel key, that leaves nothing. + */ + fun historicalChannelPlanes( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + isPrivate: Boolean, + ): List { + if (isPrivate) return emptyList() + val rootEras = historicalChannelPlanes(entry.heldRoots, listOf(channelIdHex)) + val privateEra = + heldPrivateChannelKey(entry, channelIdHex)?.let { held -> + ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelIdHex.hexToByteArray(), held.epoch)) + } + return rootEras + listOfNotNull(privateEra) + } + + /** + * The Private Channel keys an [invite] delivers (CORD-05 §1), as Community List entries. A + * keyless listing (a public channel written as `{id, epoch}`) delivers nothing. + */ + fun privateChannelKeysOf(invite: CommunityInvite): List = + invite.channels + .filter { HEX64.matches(it.id) && HEX64.matches(it.key) } + .map { PrivateChannelKey(it.id.lowercase(), it.key.lowercase(), it.epoch, it.name) } + + /** True when this account can read and write [channelIdHex] as folded in [state]. */ + fun canAccessChannel( + entry: ConcordCommunityListEntry, + state: ConcordCommunityState, + channelIdHex: HexKey, + ): Boolean = currentChannelPlane(entry, state, channelIdHex) != null + /** * How many prior epochs of channel history to backfill. A CORD-06 Refounding rotates the * `community_root` and bumps the epoch, so pre-refounding messages live under a *different* @@ -344,6 +435,24 @@ object ConcordActions { return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) } + /** + * Builds an encrypted-seal **delete** wrap (kind-5 [ChannelChat.delete] of the author's own + * [targets]) on the [channel] plane — the in-stream delete of CORD-01. Never publish a Concord + * delete any other way: a signed kind 5 or a NIP-17 delete would carry the rumor ids outside + * the community. + */ + suspend fun buildChannelDelete( + authorSigner: NostrSigner, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + targets: List, + createdAt: Long, + ): Event { + val rumor = ChannelChat.delete(authorSigner.pubKey, channelId, epoch, targets, createdAt) + return ConcordStreamEnvelope.wrap(rumor, channel, authorSigner, encrypted = true) + } + /** Builds an encrypted-seal reaction wrap (kind 7 against [target]) on the [channel] plane. */ suspend fun buildChannelReaction( authorSigner: NostrSigner, @@ -376,8 +485,9 @@ object ConcordActions { } /** - * Opens the channel [wraps], keeps the kind-9 messages correctly bound to - * [channelId]/[epoch], and returns them oldest-first (createdAt, then id). + * Opens the channel [wraps], keeps the kind-9 messages that pass the Chat ingest gate + * ([channelRumors]), and returns them oldest-first by their CORD-02 §4 send time + * (`created_at * 1000 + ms`), then id. */ fun channelMessages( wraps: List, @@ -385,11 +495,11 @@ object ConcordActions { channelId: HexKey, epoch: Long, ): List = - wraps - .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } - .filter { it.kind == ChatEvent.KIND && ChannelChat.isBoundTo(it, channelId, epoch) } + channelRumors(wraps, channel, channelId, epoch) + .filter { it.kind == ChatEvent.KIND } + .distinctBy { it.id } + .sortedWith(compareBy({ ChannelChat.orderingMs(it) }, { it.id })) .map { ConcordChatMessage(it.id, it.pubKey, it.content, it.createdAt, channelId, epoch) } - .sortedWith(compareBy({ it.createdAt }, { it.id })) /** * Opens the channel [wraps] and returns every validated inner rumor bound to @@ -404,10 +514,20 @@ object ConcordActions { channel: GroupKey, channelId: HexKey, epoch: Long, - ): List = - wraps - .mapNotNull { wrap -> ConcordStreamEnvelope.openOrNull(wrap, channel)?.rumor } - .filter { ChannelChat.isBoundTo(it, channelId, epoch) } + ): List = wraps.mapNotNull { wrap -> openChannelRumor(wrap, channel, channelId, epoch) } + + /** + * Opens one channel [wrap] and returns its rumor only when it passes the Chat ingest gate + * ([ChannelChat.acceptOpened]): an encrypted 20013 seal, a Chat kind (never another plane's + * kind), a strict `channel`/`epoch` binding, and a well-formed `ms`. Anything else is dropped + * here, before it can reach the store. + */ + fun openChannelRumor( + wrap: Event, + channel: GroupKey, + channelId: HexKey, + epoch: Long, + ): Event? = ConcordStreamEnvelope.openOrNull(wrap, channel)?.let { ChannelChat.acceptOpened(it, channelId, epoch) } // ---- invites -------------------------------------------------------------- diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt index 3215cbc06d..9d7768a11f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordSubscriptionPlanner.kt @@ -122,24 +122,28 @@ object ConcordSubscriptionPlanner { entry: ConcordCommunityListEntry, state: ConcordCommunityState, ): List { - val root = entry.root.hexToByteArray() val relays = normalize(entry.relays) + // A Private Channel is subscribed on its own key's plane only, and not at all without a held + // key (CORD-03 §1): never on the root-derived plane every member can read. val current = - state.channels.keys.map { channelIdHex -> - val ch = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - ConcordPlaneSub( - channelId = ConcordChannelId(entry.id, channelIdHex), - pubKeyHex = ch.publicKeyHex, - relays = relays, - ) + state.channels.values.mapNotNull { channel -> + ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private)?.let { plane -> + ConcordPlaneSub( + channelId = ConcordChannelId(entry.id, plane.channelIdHex), + pubKeyHex = plane.key.publicKeyHex, + relays = relays, + ) + } } val historical = - ConcordActions.historicalChannelPlanes(entry.heldRoots, state.channels.keys).map { plane -> - ConcordPlaneSub( - channelId = ConcordChannelId(entry.id, plane.channelIdHex), - pubKeyHex = plane.key.publicKeyHex, - relays = relays, - ) + state.channels.values.flatMap { channel -> + ConcordActions.historicalChannelPlanes(entry, channel.channelIdHex, channel.definition.private).map { plane -> + ConcordPlaneSub( + channelId = ConcordChannelId(entry.id, plane.channelIdHex), + pubKeyHex = plane.key.publicKeyHex, + relays = relays, + ) + } } return current + historical } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt index 0a511017c8..20ec82acb0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Account.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore @@ -783,7 +784,9 @@ class Account( // Invalidate the channel's metadata flow only on a real change so the Messages-row // name + community chip recompose when the fold first resolves them (they observe // metadata.stateFlow via observeChannel), without churning every row every tick. - if (channel.updateFrom(state, relays, myPubKey)) channel.updateChannelInfo() + // A Private Channel is readable/postable only with its held key (CORD-03 §1). + val keyHeld = ConcordActions.canAccessChannel(session.entry, state, channelIdHex) + if (channel.updateFrom(state, relays, myPubKey, keyHeld)) channel.updateChannelInfo() channel.notes .filter { _, note -> note.event?.pubKey?.let { state.authority.isBanned(it) } == true } .forEach { channel.removeNote(it) } @@ -1747,11 +1750,18 @@ class Account( // Marmot messages are retracted inside their group. A public NIP-09 here would e-tag // the group's private rumor ids onto public relays. - val (marmotNotes, otherNotes) = notes.partition { marmot.marmotGroupOf(it) != null } + val (marmotNotes, nonMarmotNotes) = notes.partition { marmot.marmotGroupOf(it) != null } marmotNotes.groupBy { marmot.marmotGroupOf(it)!! }.forEach { (groupId, groupNotes) -> marmot.deleteMarmotMessages(groupId, groupNotes) } + // Concord rumors are retracted inside their channel's plane (CORD-01 Deletions), for the + // same reason: any other route carries the community's rumor ids outside it. + val (concordNotes, otherNotes) = nonMarmotNotes.partition { concord.concordChannelOf(it) != null } + concordNotes.groupBy { concord.concordChannelOf(it)!! }.forEach { (channel, channelNotes) -> + concord.deleteConcordRumors(channel, channelNotes) + } + val (myRumors, myNotes) = otherNotes .filter { it.author == userProfile() && it.event != null } @@ -1796,6 +1806,13 @@ class Account( return } + // In a Concord channel it is an in-channel kind-5 on the channel's plane (CORD-01), never a + // NIP-17 DM to the p-tagged users, which would leak the rumor ids outside the community. + concord.concordChannelOf(target)?.let { channel -> + concord.deleteConcordRumors(channel, notes) + return + } + val myRumors = notes.filter { it.author == userProfile() }.mapNotNull { it.event } if (myRumors.isEmpty()) return diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 7af62cb035..d7d15cdf40 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions @@ -66,6 +67,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCon import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nipC7Chats.ChatEvent @@ -560,6 +562,9 @@ class AccountConcordActions( // Read access to the Control Plane, never write (CORD-05 §1). Absent = the // community is still pre-split, so we fold it at the legacy address. controlPk = bundle.controlPk, + // Private Channel keys the invite delivered (CORD-03 §1 / CORD-05 §1), so those + // channels are read and written on their own planes from the first fold. + privateChannels = ConcordActions.privateChannelKeysOf(bundle), relays = bundle.relays, name = bundle.name, addedAt = TimeUtils.now() * 1000, @@ -591,7 +596,10 @@ class AccountConcordActions( if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + // The channel's own plane: root-derived for a Public Channel, its held key for a Private one, + // and no plane at all (refuse) for a Private Channel whose key we do not hold (CORD-03 §1). + val plane = session.currentChannelPlane(channelIdHex) ?: return false + val channelKey = plane.key // NIP-30 custom-emoji tags for any `:shortcode:` the user typed, so the message renders the // custom image everywhere (the kind-9 rumor carries them; recipients render via the tags). @@ -608,13 +616,13 @@ class AccountConcordActions( // the user attached media); an inline reply is a kind-9 message quoting the parent; a // fresh post is a plain kind-9 message. parent != null && replyMode == ReplyMode.MINICHAT && imetas.isNotEmpty() -> - ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, imetas, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelImageReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, imetas, TimeUtils.now(), emojiTags) parent != null && replyMode == ReplyMode.MINICHAT -> - ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) parent != null -> - ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, entry.rootEpoch, parent, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelInlineReply(account.signer, channelKey, channelIdHex, plane.epoch, parent, text, TimeUtils.now(), emojiTags) else -> - ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags) + ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, TimeUtils.now(), emojiTags) } sendConcordChannelWrap(entry, channelKey, wrap) return true @@ -636,14 +644,15 @@ class AccountConcordActions( if (!account.isWriteable()) return false val session = account.concordSessions.sessionFor(communityId) ?: return false val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val plane = session.currentChannelPlane(channelIdHex) ?: return false + val channelKey = plane.key // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the caption, same as a plain message. val emojiTags = account.emoji .findEmojiTags(text) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, plane.epoch, text, imetas, TimeUtils.now(), emojiTags) sendConcordChannelWrap(entry, channelKey, wrap) return true } @@ -665,9 +674,11 @@ class AccountConcordActions( val target = note.event ?: return false val communityId = channel.channelId.communityId val channelIdHex = channel.channelId.channelId - val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val plane = session.currentChannelPlane(channelIdHex) ?: return false + val channelKey = plane.key // A custom-emoji reaction is a `:shortcode:` content that needs its NIP-30 `emoji` tag to // resolve to an image on the other side; a plain unicode/`+` reaction yields no tags. val emojiTags = @@ -675,7 +686,7 @@ class AccountConcordActions( .findEmojiTags(reaction) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, reaction, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelReaction(account.signer, channelKey, channelIdHex, plane.epoch, target, reaction, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } @@ -686,7 +697,8 @@ class AccountConcordActions( * message's channel/epoch, wraps it on the plane, and publishes it — so the edit stays * inside the encrypted channel (a public edit would e-tag the private rumor id onto * public relays). The receiving side overlays the newest edit onto the target message; - * only the *original author's* edits are applied, so we gate to my own kind-9 messages. + * only the *original author's* edits are applied, so we gate to my own messages — a kind-9 + * message or a kind-1111 thread reply (CORD-03 §3: edits target either by rumor id). * Returns false if [note] isn't an editable Concord message I authored. */ suspend fun editConcordChannelMessage( @@ -696,25 +708,87 @@ class AccountConcordActions( if (!account.isWriteable()) return false val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return false val target = note.event ?: return false - // Edits only apply to plain kind-9 messages, and only the author may edit their own. - if (target !is ChatEvent || target.pubKey != account.signer.pubKey) return false + // Edits apply to messages and thread replies, and only the author may edit their own. + if (!isConcordEditable(target)) return false val communityId = channel.channelId.communityId val channelIdHex = channel.channelId.channelId - val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return false + val session = account.concordSessions.sessionFor(communityId) ?: return false + val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) + val plane = session.currentChannelPlane(channelIdHex) ?: return false + val channelKey = plane.key // Carry NIP-30 custom-emoji tags for any `:shortcode:` in the new text, same as a fresh message. val emojiTags = account.emoji .findEmojiTags(newText) .map { it.toTagArray() } .toTypedArray() - val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, entry.rootEpoch, target, newText, TimeUtils.now(), emojiTags) + val wrap = ConcordActions.buildChannelEdit(account.signer, channelKey, channelIdHex, plane.epoch, target, newText, TimeUtils.now(), emojiTags) publishConcordWrap(entry, wrap) return true } + /** True when [target] is a Concord message this account may edit: my own kind-9 message or kind-1111 reply. */ + fun isConcordEditable(target: Event): Boolean = (target is ChatEvent || target is CommentEvent) && target.pubKey == account.signer.pubKey + + /** + * The Concord channel [note] belongs to: its own gatherer for a message or thread reply, else + * (a reaction, a delete) the channel of the note it points at. Null when it is not Concord. + */ + fun concordChannelOf(note: Note): ConcordChannel? = + note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } + ?: note.replyTo?.firstNotNullOfOrNull { target -> target.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } } + + /** + * Delete my own Concord rumors [notes] (messages, thread replies, reactions) in [channel] the + * way CORD-01 prescribes: a kind-5 rumor with `e` + `k` tags, sealed (20013) and wrapped on the + * channel's own plane, so only the community sees it. Never a signed kind 5 or a NIP-17 delete, + * both of which would carry the rumor ids to people and relays outside the community. + * + * Each target is retracted on the plane that carried it (its bound epoch), falling back to the + * channel's current plane when this account no longer holds that one. A member's delete of + * their own message stays honored after Dissolution (CORD-02 §9), so this is not gated on it. + * Returns false when nothing could be sent (not writeable, no session, no plane, no own notes). + */ + suspend fun deleteConcordRumors( + channel: ConcordChannel, + notes: List, + ): Boolean { + if (!account.isWriteable()) return false + val session = account.concordSessions.sessionFor(channel.channelId.communityId) ?: return false + val channelIdHex = channel.channelId.channelId + val mine = notes.mapNotNull { it.event }.filter { it.pubKey == account.signer.pubKey }.distinctBy { it.id } + if (mine.isEmpty()) return false + val current = session.currentChannelPlane(channelIdHex) + val byPlane = + mine.groupBy { target -> + target.tags.concordEpoch()?.let { session.channelPlaneFor(channelIdHex, it) } ?: current + } + var sent = false + for ((plane, targets) in byPlane) { + if (plane == null) continue + val wrap = ConcordActions.buildChannelDelete(account.signer, plane.key, channelIdHex, plane.epoch, targets, TimeUtils.now()) + publishConcordWrap(session.entry, wrap) + sent = true + } + return sent + } + + /** + * Toggle my [reaction] on Concord message [note]: retract my existing reactions of that content + * with an in-channel delete, else add it ([reactToConcordMessage]). Returns false when nothing + * was sent. + */ + suspend fun toggleConcordReaction( + note: Note, + reaction: String, + ): Boolean { + val channel = concordChannelOf(note) ?: return false + val mine = note.allReactionsOfContentByAuthor(account.userProfile(), reaction) + return if (mine.isNotEmpty()) deleteConcordRumors(channel, mine) else reactToConcordMessage(note, reaction) + } + /** * Publish a typing heartbeat (kind-23311, ephemeral 21059) to a Concord channel — call at * most every few seconds while composing. Not folded locally (we never show our own typing); @@ -736,8 +810,8 @@ class AccountConcordActions( return } val entry = session.entry - val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch) - val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now()) + val plane = session.currentChannelPlane(channelIdHex) ?: return + val wrap = ConcordActions.buildChannelTyping(account.signer, plane.key, channelIdHex, plane.epoch, TimeUtils.now()) val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } if (relays.isNotEmpty()) account.client.publish(wrap, relays) } @@ -1478,6 +1552,8 @@ class AccountConcordActions( val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false val channelId = RandomInstance.bytes(32) val channel = ChannelEntity(name = name.trim()) + // Readers drop an empty or over-64-byte name (CORD-03 §2); never mint one. + if (!channel.hasValidName()) return false val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -1501,6 +1577,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = (standing ?: ChannelEntity()).copy(name = name.trim()) + if (!channel.hasValidName()) return false val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true @@ -1523,6 +1600,7 @@ class AccountConcordActions( ?.get(channelIdHex) ?.definition val channel = (standing ?: ChannelEntity()).copy(name = name.trim(), deleted = true) + if (!channel.hasValidName()) return false val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) return true diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt index 4db15d9ec0..8fb3e1e39f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/cache/EventCache.kt @@ -138,6 +138,7 @@ import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggeredEvent import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListFragmentEvent +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent @@ -971,6 +972,11 @@ open class EventCache : rumor: Event, seenOnRelays: Set = emptySet(), ) { + // Defense in depth behind the session's Chat ingest gate: a channel plane carries Chat kinds + // only (CORD-02 Appendix B). Another plane's kind — a Control edition, a Guestbook motion, a + // rekey blob — must never land in the store as if it came from its own plane. + if (!ChannelChat.isChatKind(rumor.kind)) return + // Attach to the channel BEFORE justConsume sets the event and notifies feeds, // so the note already carries its ConcordChannel gatherer when it flows through // the Messages-list incremental filter (which routes rows by that gatherer). diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt index 83cbd73088..09fb253ac1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChannel.kt @@ -55,6 +55,14 @@ class ConcordChannel( var isPrivate: Boolean = false private set + /** + * False for a Private Channel whose independent key this account does not hold (CORD-03 §1): + * its plane can be neither read nor written, and it must never fall back to the root-derived + * plane every member can decrypt. Always true for a Public Channel. + */ + var keyHeld: Boolean = true + private set + /** The parent community's display name, from its folded metadata. */ var communityName: String? = null private set @@ -107,6 +115,7 @@ class ConcordChannel( state: ConcordCommunityState, relays: Set, myPubKey: HexKey, + keyHeld: Boolean = true, ): Boolean { val def = state.channels[channelId.channelId]?.definition // Channel fields keep their prior value until the channel edition folds. @@ -117,6 +126,7 @@ class ConcordChannel( val newCommunityBanner = state.metadata?.banner val newMembership = ConcordMembership.of(state.authority, myPubKey) val newDissolved = state.dissolved + val newKeyHeld = !newPrivate || keyHeld val changed = channelName != newChannelName || @@ -125,7 +135,8 @@ class ConcordChannel( communityIcon != newCommunityIcon || communityBanner != newCommunityBanner || membership != newMembership || - dissolved != newDissolved + dissolved != newDissolved || + this.keyHeld != newKeyHeld channelName = newChannelName isPrivate = newPrivate @@ -135,6 +146,7 @@ class ConcordChannel( communityRelays = relays membership = newMembership dissolved = newDissolved + this.keyHeld = newKeyHeld return changed } @@ -148,8 +160,11 @@ class ConcordChannel( * ([ConcordMembership.isMember]) **and** the community must not have been dissolved (CORD-02 §9 — * a tombstone seals it read-only for everyone). Deleting one's own past message stays allowed even * after dissolution and does not go through this gate. + * + * A Private Channel whose key this account does not hold is never postable ([keyHeld]): there + * is no plane to write to that only its members can read. */ - fun canPost(): Boolean = membership.isMember() && !dissolved + fun canPost(): Boolean = membership.isMember() && !dissolved && keyHeld // Synthetic note representing this channel in the Messages list before any // message has loaded (so a just-joined channel appears immediately). Mirrors diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 8f40a07e67..7020c2a552 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.model.concord +import com.vitorpamplona.amethyst.commons.actions.ChannelPlane import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock @@ -239,14 +240,20 @@ class ConcordCommunitySession( private val guestbookWraps = LinkedHashMap() private val baseRekeyWraps = LinkedHashMap() - // channel plane pubkey -> (channelIdHex, key), refreshed on each control re-fold. - private var channelKeysByAddress = HashMap>() + // Current channel plane pubkey -> plane (channel id, key, bound epoch), refreshed on each control + // re-fold. A Public Channel's plane derives from the root at the root epoch; a Private one's from + // its held channel key at the channel epoch (CORD-03 §1). A Private Channel we hold no key for has + // no entry at all: it is neither subscribed, read, nor written. + private var channelKeysByAddress = HashMap() - // Prior-epoch channel plane pubkey -> (channelIdHex, key, epoch), for pre-Refounding history. - // A CORD-06 Refounding rotates the root per epoch, so older messages live under a different - // plane per held root; we re-derive those here so historical wraps are subscribed, AUTHed, and - // decrypted alongside the current epoch. Empty when the account holds no prior roots. - private var historicalChannelKeysByAddress = HashMap>() + // Older channel plane pubkey -> plane, for history: a Public Channel's plane under each held + // prior root (a CORD-06 Refounding rotates the root per epoch) plus its private-era plane when a + // channel key is held. Subscribed, AUTHed and decrypted alongside the current planes. + private var historicalChannelKeysByAddress = HashMap() + + // The held Private Channel keys the current channel planes were derived from, so a later list + // entry carrying different keys re-derives them (see [adoptPrivateChannels]). + private var derivedPrivateKeys = privateKeySet(entry) private val _state = MutableStateFlow(null) val state: StateFlow = _state @@ -333,7 +340,26 @@ class ConcordCommunitySession( lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } /** The Chat Plane stream address for [channelIdHex], once this community has folded that channel (else null). */ - fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key } + fun channelPlaneAddress(channelIdHex: HexKey): HexKey? = lock.withLock { channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key } + + /** + * The plane [channelIdHex] is written on now, or null when it is not folded yet or is a Private + * Channel this account holds no key for (CORD-03 §1) — the caller must then refuse to post. + */ + fun currentChannelPlane(channelIdHex: HexKey): ChannelPlane? = lock.withLock { channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex } } + + /** + * The plane of [channelIdHex] bound to [epoch] — current or historical — or null when this + * account holds none. A delete of an older message goes back onto the plane that carried it. + */ + fun channelPlaneFor( + channelIdHex: HexKey, + epoch: Long, + ): ChannelPlane? = + lock.withLock { + channelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch } + ?: historicalChannelKeysByAddress.values.firstOrNull { it.channelIdHex == channelIdHex && it.epoch == epoch } + } /** * Every Chat Plane stream address for [channelIdHex] across epochs: the current one plus each @@ -344,8 +370,8 @@ class ConcordCommunitySession( */ fun channelPlaneAddressesAllEpochs(channelIdHex: HexKey): List = lock.withLock { - val current = channelKeysByAddress.entries.firstOrNull { it.value.first == channelIdHex }?.key - val historical = historicalChannelKeysByAddress.entries.filter { it.value.first == channelIdHex }.map { it.key } + val current = channelKeysByAddress.entries.firstOrNull { it.value.channelIdHex == channelIdHex }?.key + val historical = historicalChannelKeysByAddress.entries.filter { it.value.channelIdHex == channelIdHex }.map { it.key } (listOfNotNull(current) + historical) } @@ -380,9 +406,9 @@ class ConcordCommunitySession( // Prior-epoch Control Planes: the anti-rollback floor is folded from them, so the // gated relays must serve their wraps too. historicalControlKeys.values.mapNotNull { it.first.signer } + - channelKeysByAddress.values.map { it.second } + + channelKeysByAddress.values.map { it.key } + // Prior-epoch channel stream keys so the gated relays serve their older wraps too. - historicalChannelKeysByAddress.values.map { it.second } + historicalChannelKeysByAddress.values.map { it.key } } /** @@ -435,6 +461,33 @@ class ConcordCommunitySession( true } + /** + * Adopt a change to the Private Channel keys the Community List carries for this same community, + * root and epoch (a key delivered on grant, CORD-03 §1): the entry is swapped in place and the + * channel planes re-derived, so a newly held Private Channel is subscribed, read and written on + * its own plane without dropping the buffered Control Plane wraps a rebuild would lose. + * + * Returns false, changing nothing, when [newEntry] is not the same community at the same root, + * epoch and Control Plane material (the caller rebuilds, or adopts that first), or when the held + * channel keys did not change. + */ + fun adoptPrivateChannels(newEntry: ConcordCommunityListEntry): Boolean { + val changed = + lock.withLock { + val cur = entry + if (newEntry.id != cur.id || newEntry.root != cur.root || newEntry.rootEpoch != cur.rootEpoch) return false + if (newEntry.controlPk != cur.controlPk || newEntry.controlRoot != cur.controlRoot) return false + // Compared with what the planes were derived from, not with [entry]: an adoption of + // Control material may already have swapped in an entry carrying the new keys. + if (privateKeySet(newEntry) == derivedPrivateKeys) return false + entry = newEntry + true + } + // Nothing folded yet: the first control wrap derives the planes from the swapped-in entry. + if (changed && lock.withLock { controlWraps.isNotEmpty() }) refold() + return changed + } + /** This account's standing, from the current fold. */ fun membership(): ConcordMembership { val s = _state.value ?: return ConcordMembership.MEMBER @@ -503,15 +556,13 @@ class ConcordCommunitySession( } val current = lock.withLock { channelKeysByAddress[wrap.pubKey] } if (current != null) { - val (channelIdHex, key) = current - return ingestChannelWrap(wrap, channelIdHex, key, entry.rootEpoch, seenOnRelays) + return ingestChannelWrap(wrap, current.channelIdHex, current.key, current.epoch, seenOnRelays) } - // A prior-epoch plane (pre-Refounding history). Decrypt with that epoch's key and - // bind-check against that epoch. Keyed separately from the current buffer so a re-fold - // (which rebuilds only the current-epoch keys) never re-projects the historical ones. + // An older plane (pre-Refounding history, or a Public Channel's private era). Decrypt + // with that plane's key and bind-check against its epoch. Keyed separately from the + // current buffer so a re-fold never re-projects the historical ones. val historical = lock.withLock { historicalChannelKeysByAddress[wrap.pubKey] } ?: return ConcordIngestOutcome.NOT_MINE - val (channelIdHex, key, epoch) = historical - return ingestChannelWrap(wrap, channelIdHex, key, epoch, seenOnRelays) + return ingestChannelWrap(wrap, historical.channelIdHex, historical.key, historical.epoch, seenOnRelays) } } } @@ -551,8 +602,10 @@ class ConcordCommunitySession( key: GroupKey, epoch: Long, ) { - val rumor = ConcordStreamEnvelope.openOrNull(wrap, key)?.rumor ?: return - if (!ChannelChat.isTyping(rumor) || !ChannelChat.isBoundTo(rumor, channelIdHex, epoch)) return + val opened = ConcordStreamEnvelope.openOrNull(wrap, key) ?: return + // The same Chat gate as a stored rumor: encrypted seal, strict binding, well-formed ms. + val rumor = ChannelChat.acceptOpened(opened, channelIdHex, epoch) ?: return + if (!ChannelChat.isTyping(rumor)) return val who = rumor.pubKey.lowercase() if (who == myPubKey.lowercase()) return // never show my own typing back to me // A banned member's messages are dropped everywhere, so their typing heartbeat must be too — @@ -588,31 +641,32 @@ class ConcordCommunitySession( controlFloorsLocked(), ) - val prevChannels = channelKeysByAddress.values.mapTo(HashSet()) { it.first } - val next = HashMap>() - for (channelIdHex in folded.channels.keys) { - val key = ConcordActions.publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - next[key.publicKeyHex] = channelIdHex to key + val prevAddresses = channelKeysByAddress.keys.toHashSet() + val next = HashMap() + // Re-derive the older planes for the same (epoch-invariant) channel ids, so older + // history is subscribed/AUTHed/decrypted. Channels are known only after a fold, hence + // derived here rather than up front. + val historical = HashMap() + for ((channelIdHex, channel) in folded.channels) { + val isPrivate = channel.definition.private + // Null for a Private Channel with no held key: never the root-derived plane. + ConcordActions.currentChannelPlane(entry, channelIdHex, isPrivate)?.let { next[it.key.publicKeyHex] = it } + for (plane in ConcordActions.historicalChannelPlanes(entry, channelIdHex, isPrivate)) { + historical[plane.key.publicKeyHex] = plane + } } channelKeysByAddress = next - - // Re-derive the prior-epoch planes for the same (epoch-invariant) channel ids, so older - // pre-Refounding history is subscribed/AUTHed/decrypted. Channels are known only after a - // fold, hence derived here rather than up front. - val historical = HashMap>() - for (plane in ConcordActions.historicalChannelPlanes(entry.heldRoots, folded.channels.keys)) { - historical[plane.key.publicKeyHex] = Triple(plane.channelIdHex, plane.key, plane.epoch) - } historicalChannelKeysByAddress = historical + derivedPrivateKeys = privateKeySet(entry) _state.value = folded.withDissolved(dissolved) - folded.channels.keys.filterNot { it in prevChannels } + next.filterKeys { it !in prevAddresses }.values.map { it.channelIdHex } } - // Project only channels appearing for the first time. Existing channels' wraps were already - // emitted incrementally as they arrived (a channel plane is only subscribed after it folds, so - // a channel's buffer never pre-dates its first fold) — re-projecting all channels on every - // control edition would be O(channels × history) of redundant decryption. + // Project only channels whose current plane is new (a first fold, or a plane that moved when a + // Private Channel's key arrived). Existing planes' wraps were already emitted incrementally as + // they arrived — re-projecting all channels on every control edition would be + // O(channels × history) of redundant decryption. for (channelIdHex in newChannels) reprojectChannel(channelIdHex) } @@ -677,9 +731,9 @@ class ConcordCommunitySession( * re-fold (keys may change). Prior-epoch wraps in the buffer simply won't open under the current * key and are skipped — they were already emitted when they landed (the sink dedups by id). */ private fun reprojectChannel(channelIdHex: HexKey) { - val key = lock.withLock { channelKeysByAddress.values.firstOrNull { it.first == channelIdHex }?.second } ?: return + val plane = currentChannelPlane(channelIdHex) ?: return val wraps = lock.withLock { channelWrapsById[channelIdHex]?.values?.toList() } ?: return - emitChannelRumors(channelIdHex, key, entry.rootEpoch, wraps) + emitChannelRumors(channelIdHex, plane.key, plane.epoch, wraps) } /** @@ -707,6 +761,8 @@ class ConcordCommunitySession( } companion object { + private fun privateKeySet(e: ConcordCommunityListEntry) = e.privateChannels.mapTo(HashSet()) { Triple(it.channelId.lowercase(), it.key.lowercase(), it.epoch) } + /** A typing heartbeat is considered current for this many seconds after it's seen. */ const val TYPING_STALE_SECS = 8L } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt index cd0cbe7979..4ee986734f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordPlaneRegistry.kt @@ -31,7 +31,6 @@ import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope import com.vitorpamplona.quartz.concord.envelope.OpenedStreamEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray /** What kind of plane an address belongs to. */ enum class ConcordPlaneKind { @@ -105,17 +104,18 @@ class ConcordPlaneRegistry { } } - /** Registers the Chat Plane address of every channel in a folded community [state]. */ + /** + * Registers the current Chat Plane address of every channel in a folded community [state] this + * account can read: a Public Channel's root-derived plane, a Private Channel's held-key plane, + * and nothing for a Private Channel whose key is not held (CORD-03 §1). + */ fun registerChannels( entry: ConcordCommunityListEntry, state: ConcordCommunityState, ) = lock.withLock { - val root = entry.root.hexToByteArray() - for (channelIdHex in state.channels.keys) { - val ch = - com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys - .publicChannel(root, channelIdHex.hexToByteArray(), entry.rootEpoch) - planes[ch.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, channelIdHex), ch) + for (channel in state.channels.values) { + val plane = ConcordActions.currentChannelPlane(entry, channel.channelIdHex, channel.definition.private) ?: continue + planes[plane.key.publicKeyHex] = ConcordPlane(ConcordPlaneKind.CHANNEL, entry.id, ConcordChannelId(entry.id, plane.channelIdHex), plane.key) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt index 22c7dd47de..3ae9882837 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordSessionRegistry.kt @@ -85,6 +85,9 @@ class ConcordSessionRegistry( // buffered wraps and fold the community empty, and the plane's address is // invariant under adoption anyway (CORD-02 §5). existing.adoptControlMaterial(entry) + // Likewise a Private Channel key delivered on grant (CORD-03 §1): re-derive the + // channel planes in place so the channel becomes readable and writable. + existing.adoptPrivateChannels(entry) } } created diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt new file mode 100644 index 0000000000..9605a45227 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordChatPlaneConformanceTest.kt @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.model.concord + +import com.vitorpamplona.amethyst.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot +import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The Chat Plane batch at the commons layer: Private Channels on their own keys (S2), in-stream + * deletes (S3), the Chat ingest gate (S9) and the channel-name build cap (I14). + */ +class ConcordChatPlaneConformanceTest { + private val owner = NostrSignerInternal(KeyPair()) + private val secretId = ByteArray(32) { 0x5C } + private val secretIdHex = secretId.toHexKey() + private val channelKey = ByteArray(32) { 0x3C } + + private fun entryFor( + community: NewConcordCommunity, + privateChannels: List = emptyList(), + heldRoots: List = emptyList(), + ) = ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + controlRoot = community.controlRoot.toHexKey(), + heldRoots = heldRoots, + privateChannels = privateChannels, + relays = listOf("wss://r.example"), + name = "Nostrichs", + ) + + /** Genesis plus a `private:true` channel edition, as the Control Plane delivers them. */ + private suspend fun communityWithPrivateChannel(): Pair> { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val define = + ConcordModeration.defineChannel( + owner, + community.controlPlane, + secretId, + ChannelEntity(name = "secret", private = true), + community.genesisEditions, + createdAt = 2L, + owner = community.ownerPubKey, + ) + return community to (community.genesisWraps + define) + } + + @Test + fun aPrivateChannelWithoutAHeldKeyIsNeverDerivedOnTheRootPlane() = + runTest { + val (community, control) = communityWithPrivateChannel() + val captured = mutableListOf() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + control.forEach { session.ingest(it) } + val state = session.state.value!! + assertTrue(state.channels[secretIdHex]!!.definition.private) + + val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch) + assertFalse(rootPlane.publicKeyHex in session.channelAddresses(), "a private channel must never be subscribed on the root plane") + assertTrue(session.streamKeys().none { it.publicKeyHex == rootPlane.publicKeyHex }) + assertNull(session.currentChannelPlane(secretIdHex), "no plane to write without the key") + assertNull(ConcordActions.currentChannelPlane(session.entry, state, secretIdHex)) + assertFalse(ConcordActions.canAccessChannel(session.entry, state, secretIdHex)) + + // The planner and the plane registry agree: nothing for the keyless private channel. + val subs = ConcordSubscriptionPlanner.channelPlaneSubs(session.entry, state) + assertTrue(subs.none { it.channelId?.channelId == secretIdHex }) + val registry = ConcordPlaneRegistry().apply { registerChannels(session.entry, state) } + assertFalse(registry.isKnownPlane(rootPlane.publicKeyHex)) + + // A post someone made on the root-derived plane never reaches the store. + val leaked = ConcordActions.buildChannelMessage(owner, rootPlane, secretIdHex, community.rootEpoch, "psst", 3L) + assertEquals(ConcordIngestOutcome.NOT_MINE, session.ingest(leaked)) + assertTrue(captured.none { it.content == "psst" }) + + // The channel object is locked: no composer, no post. + val channel = ConcordChannel(ConcordChannelId(community.communityIdHex, secretIdHex)) + channel.updateFrom(state, emptySet(), owner.pubKey, keyHeld = false) + assertFalse(channel.keyHeld) + assertFalse(channel.canPost()) + + // The public #general is untouched. + assertNotNull(session.currentChannelPlane(community.generalChannelIdHex)) + } + + @Test + fun aHeldPrivateKeyReadsAndWritesOnItsOwnPlaneAtTheChannelEpoch() = + runTest { + val (community, control) = communityWithPrivateChannel() + val channelEpoch = 3L + val entry = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), channelEpoch, "secret"))) + val captured = mutableListOf() + val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, rumor, _ -> captured += rumor } + control.forEach { session.ingest(it) } + + val privatePlane = ConcordChannelKeys.privateChannel(channelKey, secretId, channelEpoch) + val plane = session.currentChannelPlane(secretIdHex)!! + assertEquals(privatePlane.publicKeyHex, plane.key.publicKeyHex) + assertEquals(channelEpoch, plane.epoch, "a private channel binds to its own epoch, not the root epoch") + assertTrue(privatePlane.publicKeyHex in session.channelAddresses()) + assertTrue(session.streamKeys().any { it.publicKeyHex == privatePlane.publicKeyHex }) + val rootPlane = ConcordActions.publicChannel(community.communityRoot, secretId, community.rootEpoch) + assertFalse(rootPlane.publicKeyHex in session.channelAddresses()) + + val subs = ConcordSubscriptionPlanner.channelPlaneSubs(entry, session.state.value!!) + assertEquals(listOf(privatePlane.publicKeyHex), subs.filter { it.channelId?.channelId == secretIdHex }.map { it.pubKeyHex }) + + val msg = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, channelEpoch, "members only", 4L) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(msg)) + assertEquals(1, captured.count { it.content == "members only" }) + + // Bound to the ROOT epoch on the private plane: a binding mismatch, dropped. + val wrongEpoch = ConcordActions.buildChannelMessage(owner, privatePlane, secretIdHex, community.rootEpoch, "wrong epoch", 5L) + session.ingest(wrongEpoch) + assertTrue(captured.none { it.content == "wrong epoch" }) + } + + @Test + fun aKeyDeliveredLaterIsAdoptedInPlace() = + runTest { + val (community, control) = communityWithPrivateChannel() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) + control.forEach { session.ingest(it) } + assertNull(session.currentChannelPlane(secretIdHex)) + + val withKey = entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 1L))) + assertTrue(session.adoptPrivateChannels(withKey)) + assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 1L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex) + assertFalse(session.adoptPrivateChannels(withKey), "adopting the same keys again is a no-op") + // The buffered Control Plane survived: still folded. + assertEquals( + "Nostrichs", + session.state.value + ?.metadata + ?.name, + ) + } + + @Test + fun aKeyArrivingWithControlMaterialIsStillAdoptedThroughTheRegistry() = + runTest { + val (community, control) = communityWithPrivateChannel() + // A plain member: holds the control_pk but not the control_root. + val member = + ConcordCommunityListEntry( + id = community.communityIdHex, + owner = community.ownerPubKey, + ownerSalt = community.ownerSalt.toHexKey(), + root = community.communityRoot.toHexKey(), + rootEpoch = community.rootEpoch, + controlPk = community.controlPkHex, + relays = listOf("wss://r.example"), + ) + val registry = ConcordSessionRegistry() + registry.sync(listOf(member), owner.pubKey) + val session = registry.sessionFor(community.communityIdHex)!! + control.forEach { session.ingest(it) } + assertNull(session.currentChannelPlane(secretIdHex)) + + // One list update delivers both the staff write key and the private channel key: the + // Control adoption swaps the entry first, and the channel planes must still follow. + registry.sync(listOf(entryFor(community, listOf(PrivateChannelKey(secretIdHex, channelKey.toHexKey(), 2L)))), owner.pubKey) + assertEquals(session, registry.sessionFor(community.communityIdHex), "adopted in place, not rebuilt") + assertEquals(ConcordChannelKeys.privateChannel(channelKey, secretId, 2L).publicKeyHex, session.currentChannelPlane(secretIdHex)?.key?.publicKeyHex) + } + + @Test + fun aDeleteRidesTheChannelPlaneAndLandsAsAChannelBoundKind5() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val captured = mutableListOf() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + community.genesisWraps.forEach { session.ingest(it) } + val plane = session.currentChannelPlane(community.generalChannelIdHex)!! + + session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "oops", 2L)) + val message = captured.single { it.content == "oops" } + + val delete = ConcordActions.buildChannelDelete(owner, plane.key, plane.channelIdHex, plane.epoch, listOf(message), 3L) + // The wrap is authored by the channel plane, never the author: nothing outside the + // community learns the rumor id. + assertEquals(plane.key.publicKeyHex, delete.pubKey) + assertEquals(ConcordStreamEnvelope.KIND_WRAP, delete.kind) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(delete)) + val kind5 = captured.single { it.kind == 5 } + assertEquals(listOf(message.id), kind5.tags.filter { it[0] == "e" }.map { it[1] }) + assertEquals(listOf("9"), kind5.tags.filter { it[0] == "k" }.map { it[1] }) + assertTrue(ChannelChat.isBoundTo(kind5, plane.channelIdHex, plane.epoch)) + + // A delete of an older message goes back to the plane that carried it. + assertEquals(plane, session.channelPlaneFor(plane.channelIdHex, plane.epoch)) + } + + @Test + fun theHistoricalPlaneOfAnOlderMessageIsFoundForItsDelete() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val priorRoot = KeyPair().pubKey + val session = ConcordCommunitySession(entryFor(community, heldRoots = listOf(HeldRoot(7L, priorRoot.toHexKey()))), owner.pubKey) + community.genesisWraps.forEach { session.ingest(it) } + val prior = session.channelPlaneFor(community.generalChannelIdHex, 7L) + assertEquals(ConcordActions.publicChannel(priorRoot, community.generalChannelId, 7L).publicKeyHex, prior?.key?.publicKeyHex) + } + + @Test + fun chatIngestDropsOtherPlanesKindsAndPlaintextSeals() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val captured = mutableListOf() + val session = ConcordCommunitySession(entryFor(community), owner.pubKey) { _, _, rumor, _ -> captured += rumor } + community.genesisWraps.forEach { session.ingest(it) } + val plane = session.currentChannelPlane(community.generalChannelIdHex)!! + val binding = arrayOf(arrayOf("channel", plane.channelIdHex), arrayOf("epoch", plane.epoch.toString())) + + // A channel key-holder forging a Control edition (kind 3308) into the chat plane. + val forgedControl = RumorAssembler.assembleRumor(owner.pubKey, 2L, 3308, binding, "{}") + session.ingest(ConcordStreamEnvelope.wrap(forgedControl, plane.key, owner, encrypted = true)) + // A Guestbook join (3306) likewise. + val forgedJoin = RumorAssembler.assembleRumor(owner.pubKey, 3L, 3306, binding, "join") + session.ingest(ConcordStreamEnvelope.wrap(forgedJoin, plane.key, owner, encrypted = true)) + // A proper chat message in a plaintext (Control-only) seal. + val plaintextSeal = ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, "plaintext", 4L) + session.ingest(ConcordStreamEnvelope.wrap(plaintextSeal, plane.key, owner, encrypted = false)) + // A chat message with a malformed ms. + val badMs = RumorAssembler.assembleRumor(owner.pubKey, 5L, 9, binding + arrayOf(arrayOf("ms", "01")), "bad ms") + session.ingest(ConcordStreamEnvelope.wrap(badMs, plane.key, owner, encrypted = true)) + + assertTrue(captured.isEmpty(), "none of these may reach the store: ${captured.map { it.kind }}") + + // And the good path still lands. + session.ingest(ConcordActions.buildChannelMessage(owner, plane.key, plane.channelIdHex, plane.epoch, "ok", 6L)) + assertEquals(listOf("ok"), captured.map { it.content }) + } + + @Test + fun channelMessagesSortByTheMillisecondBasis() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + val plane = ConcordActions.currentChannelPlane(entryFor(community), community.generalChannelIdHex, isPrivate = false)!! + + suspend fun wrap( + text: String, + secs: Long, + ms: Int, + ) = ConcordStreamEnvelope.wrap(ChannelChat.message(owner.pubKey, plane.channelIdHex, plane.epoch, text, secs, ms = ms), plane.key, owner, encrypted = true) + val msgs = ConcordActions.channelMessages(listOf(wrap("third", 11, 0), wrap("second", 10, 950), wrap("first", 10, 100)), plane.key, plane.channelIdHex, plane.epoch) + assertEquals(listOf("first", "second", "third"), msgs.map { it.content }) + } + + @Test + fun aChannelNameOverTheCapIsNeverMinted() = + runTest { + val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example")) + assertFailsWith { + ConcordModeration.defineChannel(owner, community.controlPlane, secretId, ChannelEntity(name = "x".repeat(65)), community.genesisEditions, 2L, owner = community.ownerPubKey) + } + assertFailsWith { + ConcordModeration.defineChannel(owner, community.controlPlane, secretId, ChannelEntity(name = ""), community.genesisEditions, 2L, owner = community.ownerPubKey) + } + } +} diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index e50c6fd0f3..1003a3ee26 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -599,6 +599,7 @@ Add a banner Where this community's encrypted planes are published and read. This community has been dissolved and is now read-only. You can still read its history, but no new messages can be posted. + This is a private channel and you don't hold its key, so you can't read it or post here. Name About (optional) Ban diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt index 54f5d4a7aa..d29cb0a26a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AccountViewModel.kt @@ -515,10 +515,10 @@ class AccountViewModel( reaction: String, ) { // Concord messages are encrypted: a public kind-7 would e-tag the private rumor id onto - // public relays. Route the reaction through a channel-plane wrap instead. (Retraction of an - // existing Concord reaction is a follow-up; for now this only adds one.) + // public relays. Route the reaction through a channel-plane wrap instead; tapping it again + // retracts it with an in-channel kind-5 delete (CORD-01), never a NIP-17 one. if (note.inGatherers?.any { it is ConcordChannel } == true) { - launchSigner { account.concord.reactToConcordMessage(note, reaction) } + launchSigner { account.concord.toggleConcordReaction(note, reaction) } return } diff --git a/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 712d3bf2f3..f6fdd10cdf 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -41,15 +41,15 @@ Ranked security > interop > feature inside each group. | # | Spec | Finding | Status | |---|---|---|---| | S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 | -| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | open → chat-plane batch | -| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | open → chat-plane batch | +| S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | **fixed** — `ConcordActions.currentChannelPlane`/`historicalChannelPlanes` derive a private channel only from the entry's held `privateChannels` key at its channel epoch (never the root plane); session, planner, plane registry, app verbs and CLI all go through it; a keyless private channel has no plane (`ConcordChannel.keyHeld`/`canPost()` false, composer replaced by a notice, `amy concord send` → `no_channel_key`); held keys adopt in place, and invite-delivered keys are stored on join. Creating private channels stays open (F7) | +| S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | **fixed** — `ChannelChat.delete` (examples §2.4: binding + `e` per target + `k` per kind) sealed 20013 on the channel plane (`ConcordActions.buildChannelDelete`); `Account.delete`/`deletePrivately` route Concord notes (messages, replies, reactions) to `AccountConcordActions.deleteConcordRumors`, each target on the plane of its bound epoch (Armada always uses the current plane); tapping an own Concord reaction retracts it the same way | | S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch | | S5 | 04 §1 | Grant `eid` never checked against `grant_locator(cid, member)`; a second grant chain at a random coordinate overrides the canonical one, order-dependent | open → control-plane batch | | S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | open → control-plane batch | | S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | open → control-plane batch | | S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch | -| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | open → control-plane + chat-plane batches | -| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch | +| S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | chat half **fixed** — `ChannelChat.acceptOpened` requires a 20013 seal, a `CHAT_KINDS` rumor (9, 1111, 7, 5, 3302, 23311, 1740), the strict binding and a well-formed `ms`, for stored and typing wraps; `EventCache.consumeConcordRumor` refuses non-chat kinds too. Control half → control-plane batch | +| S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | **fixed** — `ConcordStreamEnvelope` seal/wrap refuse a plaintext over 65,535 UTF-8 bytes (Armada `encryptChecked`), and open refuses an extended-format payload before decrypting | | S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch | | S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch | | S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch | @@ -71,10 +71,10 @@ Ranked security > interop > feature inside each group. | I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch | | I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch | | I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | open → rekey/invite batch | -| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | open → chat-plane batch | -| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch | -| I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch | -| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch | +| I13 | 03 §3 | Binding check not strict (duplicates accepted, `"04"`/`"+4"` parse) | **fixed** — exactly one `channel` and one `epoch` tag, epoch compared as its canonical decimal string (Armada `uniqueTag`/`checkChannelBinding`); builders drop binding tags smuggled in `extraTags` | +| I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | **fixed** — any gated channel edition with `deleted:true` retires the channel for good (Armada `everDeleted`); the channel gate refuses an empty or >64-byte name so the fold falls back to the previous candidate, and `defineChannel`/create/rename refuse to mint one | +| I15 | 02 §4 | No `ms` tag on chat rumors | **fixed** — every `ChannelChat` rumor carries `["ms", 0..999]` after the binding; malformed/duplicated `ms` drops the rumor; `channelMessages` and edit recency order by `created_at*1000+ms` (the shared feed still sorts by `created_at`; open PR #7 may drop `ms`) | +| I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | **fixed** — four-element `q` (the `p` credit stays, as Armada keeps it). Also CORD-03 §3: your own kind-1111 thread replies can now be edited (kind 3302) like kind-9 messages | | I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | open → control-plane batch | | I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | open → rekey/invite batch | From 7e38e3d63160c85d5488029ad7693ae11f596741 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:23:33 +0000 Subject: [PATCH 8/9] wip(concord): pins + disappearing quartz layer (pre-merge) --- .../cord02Community/ConcordCommunityState.kt | 8 +- .../cord03Channels/ConcordDisappearing.kt | 127 +++++++ .../concord/cord04Roles/ControlEntityKind.kt | 3 + .../cord04Roles/pins/ConcordPinLists.kt | 65 ++++ .../concord/cord04Roles/pins/ConcordPins.kt | 355 ++++++++++++++++++ .../cord04Roles/pins/PinKeyDisclosure.kt | 99 +++++ .../concord/crypto/ConcordKeyDerivation.kt | 9 + .../quartz/concord/crypto/ConcordLabels.kt | 3 + .../cord03Channels/ConcordDisappearingTest.kt | 72 ++++ .../cord04Roles/pins/ConcordPinsTest.kt | 190 ++++++++++ 10 files changed, 925 insertions(+), 6 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearing.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinLists.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPins.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/PinKeyDisclosure.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord03Channels/ConcordDisappearingTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord04Roles/pins/ConcordPinsTest.kt 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 75a2845c80..f15c77edb1 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 @@ -65,9 +65,6 @@ data class ConcordCommunityState( fun withDissolved(dissolved: Boolean): ConcordCommunityState = if (dissolved == this.dissolved) this else copy(dissolved = dissolved) companion object { - /** The Pin List sub-kind (CORD-04 §7), carried but not modeled here. */ - private const val VSK_PINS = "11" - /** The Community Signals sub-kind (CORD-04 §8, upstream PR #17), carried but not modeled here. */ private const val VSK_SIGNALS = "12" @@ -84,6 +81,7 @@ 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 } /** @@ -91,8 +89,7 @@ data class ConcordCommunityState( * 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 Pin List by `PIN_MESSAGES` (CORD-04 §7), a Signal by - * `MANAGE_CHANNELS` (the one gate Armada implements, `pause`), and anything newer by any + * 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( @@ -108,7 +105,6 @@ data class ConcordCommunityState( edition: ControlEdition, ): Boolean = when (edition.vsk) { - VSK_PINS -> authority.admits(edition, ConcordPermissions.PIN_MESSAGES) VSK_SIGNALS -> authority.admits(edition, ConcordPermissions.MANAGE_CHANNELS) else -> authority.isOwner(edition.author) || (authority.isStaff(edition.author) && authority.citationSatisfied(edition)) } 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..9c7c4089d6 --- /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 = 1740 + + 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/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/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/crypto/ConcordKeyDerivation.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/crypto/ConcordKeyDerivation.kt index 009a9372ba..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 @@ -236,6 +236,15 @@ object ConcordKeyDerivation { */ 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/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/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)) + } +} From 2a568f45db1c4df588d3c2e32627f77945e08d56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 15:24:29 +0000 Subject: [PATCH 9/9] fix(concord): CORD-05/06 conformance for rekeys, refoundings and invites Rekeys and Refoundings (CORD-06): - Chunks are 1-based on the wire and parsed strictly (0 / i > n refused), so Armada no longer drops our Refoundings (I8). - Chunks are budgeted by bytes (40,960-byte rumor, Armada's ceiling) as well as the 120-blob cap, keeping the wrap under NIP-44's 65,535-byte plaintext with 136-byte staff blobs (I10). - Rotations cite the rotator's Grant (`vac`) on every chunk; receivers honor only BAN holders whose citation their fold has synced, in the app drain and `amy concord rekey` (I9). - Racing authorized rotations converge on the lowest new root; sessions watch their own epoch's rekey address and heal down-only, keeping the losing root for its messages; retries reuse reserved keys (I12). - Rekey rumors must ride an encrypted 20013 seal. - Compaction aborts when a head the refounder honors cannot be carried; the plane is swept paged first, rekey chunks are confirmed before the compacted plane is published (S12). - No rekey adoption, recovery or Refounding for a dissolved community, in the app and the CLI (S13). Invites (CORD-05): - Bundles are bounded (>256 channels refused, relays capped at 5) (S11); invite fragments carry and accept at most 3 bootstrap relays (I11). - classify verifies kind, author == link signer, d == "" and the signature itself, so a forged newer revocation cannot kill a link (S14); the invite preview honours revocation the same way. - Stranded recovery only detects: a bundle never moves a held community's base; the user re-opening the link (or `amy concord recover --rejoin`) is the way forward (S4). - Joins echo invite attribution in the Guestbook Join, mints set creator_npub, `amy concord join` publishes a Guestbook Join; the Invite List merge is first-wins and carries untyped tombstones (I18). Review: quartz/plans/2026-09-29-concord-spec-conformance.md Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- cli/README.md | 7 +- .../amethyst/cli/commands/ConcordCommands.kt | 177 +++++--- .../cli/commands/ConcordModCommands.kt | 100 +++-- .../amethyst/cli/stores/ConcordStore.kt | 11 + .../commons/actions/ConcordActions.kt | 57 ++- .../commons/actions/ConcordReceive.kt | 73 +++- .../actions/ConcordSubscriptionPlanner.kt | 10 +- .../commons/model/AccountConcordActions.kt | 379 +++++++++++++----- .../model/concord/ConcordCommunitySession.kt | 30 +- .../actions/ConcordRotationReceiveTest.kt | 263 ++++++++++++ .../2026-09-29-concord-spec-conformance.md | 28 +- .../cord05Invites/ConcordDirectInvite.kt | 3 +- .../cord05Invites/ConcordInviteBundle.kt | 70 +++- .../cord05Invites/ConcordInviteLink.kt | 36 +- .../cord05Invites/ConcordInviteList.kt | 50 ++- .../cord05Invites/ConcordStrandedRecovery.kt | 70 ++-- .../concord/cord06Rekey/ConcordRefounding.kt | 275 +++++++++++-- .../concord/cord06Rekey/ConcordRekey.kt | 100 ++++- .../cord06Rekey/ConcordRotationAuthority.kt | 94 +++++ .../ConcordInviteClassifyTest.kt | 127 ++++-- .../cord05Invites/ConcordInviteLinkTest.kt | 50 ++- .../cord05Invites/ConcordInviteListTest.kt | 36 ++ .../ConcordInviteRelayopInteropTest.kt | 2 +- .../ConcordStrandedRecoveryTest.kt | 37 +- .../ConcordRekeyConformanceTest.kt | 368 +++++++++++++++++ .../cord06Rekey/ControlRootRotationTest.kt | 4 +- 26 files changed, 2075 insertions(+), 382 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordRotationReceiveTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt diff --git a/cli/README.md b/cli/README.md index 799d94bb3b..6854e836a3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -679,9 +679,12 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `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 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 grant COMMUNITY USER ROLE-ID` | Grant a role to a member. | diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordCommands.kt index 6c2212a4e5..1cd6a91b71 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 @@ -33,6 +33,7 @@ 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.cord04Roles.AuthorityResolver @@ -43,6 +44,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 @@ -76,9 +78,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 @@ -278,7 +282,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 @@ -426,7 +432,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") @@ -462,7 +468,7 @@ object ConcordCommands { 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, @@ -477,15 +483,53 @@ 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)) + ) + 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() @@ -567,23 +611,21 @@ object ConcordCommands { ) /** - * `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, @@ -591,6 +633,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 = @@ -604,54 +647,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 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, @@ -671,8 +728,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, @@ -688,21 +748,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 } @@ -711,12 +762,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.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 4a4bf10a49..da7997bdc6 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,6 +26,7 @@ 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 @@ -35,6 +36,9 @@ 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 @@ -279,6 +283,11 @@ 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) @@ -299,8 +308,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 @@ -327,45 +350,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.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 @@ -393,7 +438,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, @@ -415,6 +460,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, ), ) 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..9668912d23 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,17 @@ 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 = "", + // 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 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..8d1711795d 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 @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity 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 @@ -190,6 +191,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. */ @@ -426,6 +440,8 @@ object ConcordActions { name: String, relays: List, controlPk: HexKey? = null, + creator: HexKey? = null, + label: String? = null, ): CommunityInvite = CommunityInvite( communityId = communityIdHex, @@ -436,6 +452,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 +515,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 +546,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 +642,8 @@ object ConcordActions { staffXOnly: Set, createdAt: Long, ownerPubKey: HexKey, + authority: AuthorityCitation? = null, + mustCarry: Map = emptyMap(), ): RefoundingBuild = ConcordRefounding.build( rotatorSigner = rotatorSigner, @@ -624,6 +658,8 @@ object ConcordActions { staffXOnly = staffXOnly, createdAt = createdAt, ownerPubKey = ownerPubKey, + authority = authority, + mustCarry = mustCarry, ) /** @@ -632,7 +668,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 +679,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/ConcordReceive.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordReceive.kt index b807ae24de..a915430e90 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.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.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 3215cbc06d..4596cc8327 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr 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 @@ -78,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) @@ -104,10 +107,13 @@ object ConcordSubscriptionPlanner { val guestbook = ConcordActions.guestbookPlane(root, communityId, e.rootEpoch) val nextRekey = ConcordActions.nextBaseRekeyPlane(root, communityId, e.rootEpoch) val dissolved = ConcordDissolution.planeKey(e.id) - listOf( + 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) }, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/AccountConcordActions.kt index 7af62cb035..ba8bbd6892 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 @@ -52,6 +52,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 @@ -59,7 +63,9 @@ 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 @@ -77,6 +83,9 @@ 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" @@ -98,6 +107,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, @@ -293,7 +305,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, @@ -351,7 +363,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 } @@ -496,7 +512,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 @@ -508,10 +524,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 @@ -550,6 +581,19 @@ class AccountConcordActions( 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, @@ -568,7 +612,7 @@ class AccountConcordActions( // recoverStrandedConcordCommunities(). inviteRef = ConcordActions.bareInviteRef(url), ) - joinConcordCommunity(entry) + joinConcordCommunity(entry, inviteCreator, inviteLabel) return ConcordInviteResult.Joined(bundle.communityId) } @@ -1081,6 +1125,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 @@ -1102,6 +1152,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 @@ -1121,7 +1184,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() } @@ -1131,52 +1194,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.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 } } /** @@ -1269,43 +1393,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) } } @@ -1341,6 +1501,14 @@ class AccountConcordActions( } } + 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. @@ -1354,20 +1522,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. @@ -1381,6 +1547,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 = ( @@ -1391,36 +1572,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" } } } @@ -1541,7 +1700,13 @@ 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 + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordCommunitySession.kt index 8f40a07e67..626a7d397b 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 @@ -31,6 +31,7 @@ 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 @@ -138,6 +139,13 @@ 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. @@ -163,6 +171,9 @@ class ConcordCommunitySession( /** 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. @@ -176,7 +187,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) @@ -238,6 +251,7 @@ 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>() @@ -328,6 +342,7 @@ class ConcordCommunitySession( address == controlPlaneAddress || address == guestbookAddress || address == nextBaseRekeyAddress || + address == siblingBaseRekeyAddress || address == dissolvedAddress || address in historicalControlKeys || lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } @@ -355,6 +370,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 @@ -389,7 +410,7 @@ class ConcordCommunitySession( * The auxiliary plane keys (Guestbook, next base-rekey, and the CORD-02 §9 dissolution address) * for their own isolated AUTH. */ - fun auxStreamKeys(): List = listOf(guestbookKey, nextBaseRekeyKey, dissolvedKey) + 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) } @@ -489,6 +510,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 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..ec6ba6a5da --- /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, 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.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/quartz/plans/2026-09-29-concord-spec-conformance.md b/quartz/plans/2026-09-29-concord-spec-conformance.md index 712d3bf2f3..9bfe0606c8 100644 --- a/quartz/plans/2026-09-29-concord-spec-conformance.md +++ b/quartz/plans/2026-09-29-concord-spec-conformance.md @@ -43,17 +43,17 @@ Ranked security > interop > feature inside each group. | S1 | 02 §9 | Dissolution: no `dissolved_pk` plane, no `eid` binding, spec tombstone (chainless, no `ev`) could not even parse; a vsk-10 Control Plane edition dissolved with no binding check | **fixed** — `ConcordDissolution` (derive, build, verify with `eid == community_id`, 20014 seal, owner author); session + planner subscribe the plane; CLI `amy concord dissolve`; the Control Plane fold no longer reads vsk 10 | | S2 | 03 §1-2 | Posts into a `private:true` channel go to the **root-derived** plane every member can decrypt, under a Lock icon; real private channels can't be read | open → chat-plane batch | | S3 | 01 Deletions | Deleting your own Concord message sends a *signed* NIP-17 kind 5 to the p-tagged users (leaks the rumor id outside the community) and never reaches the channel | open → chat-plane batch | -| S4 | 06 §2 | Stranded recovery adopts a bundle's newer `community_root` with no continuity or authority check — a link creator can relocate every member who joined through their link | open → rekey/invite batch | +| 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 | open → control-plane batch | | S6 | 04 §4 | Banlist unions every fork instead of folding to one head; a ban on a losing fork can never be undone; banlist `eid` unchecked | open → control-plane batch | | S7 | 04 §1 | Equal-version ties break on rumor id only, not authority-first; a low-ranked holder can grind an id to beat the owner | open → control-plane batch | | S8 | 04 §1 | Metadata `eid` not required to equal `community_id`; a fresh coordinate at a high version bypasses the chain | open → control-plane batch | | S9 | 02 §5 / App. B | Seal kind never enforced on read (Control must be 20014, Chat/rekey 20013); any rumor kind from a channel lands in `LocalCache` | open → control-plane + chat-plane batches | | S10 | App. B | NIP-44 65,535-byte plaintext cap not enforced; quartz silently switches to the extended format strict readers reject | open → chat-plane batch | -| S11 | 05 §1 | Bundle bounds (channel count, relay cap) not enforced; the join fetches from every relay a bundle names | open → rekey/invite batch | -| S12 | 06 §3 | Compaction doesn't abort on an incomplete fold, and republishes the compacted plane before the root roll is confirmed | open → rekey/invite batch | -| S13 | 02 §9 | "Death wins every race": rekey adoption / recovery / refounding don't check for dissolution | open → rekey/invite batch | -| S14 | 05 §2 | `classify` trusts the relay filter: no signature, `pubkey == link_signer`, or `d == ""` check; a relay can forge a revocation | open → rekey/invite batch | +| 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) @@ -66,17 +66,17 @@ Ranked security > interop > feature inside each group. | I5 | 04 §2 | Role content lacks `role_id`; Armada ignores every role we mint | open → control-plane batch | | I6 | 04 §1 | First edition is v0; spec says versions start at 1 | open → control-plane batch | | I7 | 04 §7 | Unknown-vsk editions (pins, signals) dropped by our compaction | open → control-plane batch | -| I8 | 06 | Rekey `chunk` index is 0-based; Armada requires 1-based and drops all our Refoundings | open → rekey/invite batch | -| I9 | 06 §3 | Rotations carry no `vac` | open → rekey/invite batch | -| I10 | 06 | 120 base blobs per chunk can overflow NIP-44; Armada budgets 99 @104 B / 90 @136 B | open → rekey/invite batch | -| I11 | 05 §3 | Invite links carry more than 3 bootstrap relays; Armada's decoder throws | open → rekey/invite batch | -| I12 | 06 §3 | No race convergence (lowest new root), not idempotent on retry | open → rekey/invite batch | +| 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) | open → chat-plane batch | | I14 | 03 §2 | Channel deletion not terminal across the chain; no 64-byte name cap | open → chat-plane batch | | I15 | 02 §4 | No `ms` tag on chat rumors | open → chat-plane batch | | I16 | examples §2.1 | Inline quote `q` tag is 2-element, Armada writes `["q", id, "", author]` | open → chat-plane batch | | I17 | 04 §2, 02 §6 | Caps (role name, roles per member/community, metadata name/description) not enforced | open → control-plane batch | -| I18 | 05 §1, §4 | Join doesn't echo invite attribution; CLI join publishes no Guestbook Join; Invite List merge lets the patch win; malformed tombstones dropped | open → rekey/invite batch | +| 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 @@ -95,6 +95,12 @@ Ranked security > interop > feature inside each group. ## 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 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..aef5fec8f2 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, 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) @@ -169,6 +223,7 @@ object ConcordRefounding { priorControlKeys: ControlPlaneKeys, newControlKeys: ControlPlaneKeys, ownerPubKey: HexKey, + mustCarry: Map = emptyMap(), ): List { // entity coordinate -> every edition we can open, paired with its verified seal. val byCoordinate = HashMap>>() @@ -197,19 +252,45 @@ object ConcordRefounding { val editions = byCoordinate.values.flatten() val honored = ConcordCommunityState.authorizedHeads(editions.map { it.first }, 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, + ownerPubKey: HexKey, + ): Map = ConcordCommunityState.authorizedHeads(editions, 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 +306,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 +322,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 +343,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 +370,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..4874639ab1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRotationAuthority.kt @@ -0,0 +1,94 @@ +/* + * 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, + ownerPubKey: HexKey, + ): Map = ConcordCommunityState.authorizedHeads(editions, 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/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/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/ConcordRekeyConformanceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/concord/cord06Rekey/ConcordRekeyConformanceTest.kt new file mode 100644 index 0000000000..ae433b9faa --- /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, 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, 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, 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..7c8d0bfe15 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 @@ -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)