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 265fdf5e87..9eda3f4bfc 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 @@ -33,6 +33,7 @@ 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.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeys import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing @@ -212,24 +213,30 @@ object ConcordActions { /** * 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. + * - Public: its plane under every held prior root ([historicalChannelPlanes]), plus every + * private-era plane a channel key is held for (a channel that was Private before); + * - Private: the planes of the older channel keys the entry still carries (its `seed` and a + * peer's `priors`, [ConcordChannelKeyring.historicalKeys]) — history across a channel rekey. + * Never the root-derived plane: every member reads that one, so showing it would present + * public content as private (Armada `channelsView`). */ fun historicalChannelPlanes( entry: ConcordCommunityListEntry, channelIdHex: HexKey, isPrivate: Boolean, ): List { - if (isPrivate) return emptyList() + val channelId = channelIdHex.hexToByteArray() + val olderKeys = + ConcordChannelKeyring.historicalKeys(entry, channelIdHex).map { old -> + ChannelPlane(channelIdHex, old.epoch, ConcordChannelKeys.privateChannel(old.key.hexToByteArray(), channelId, old.epoch)) + } + if (isPrivate) return olderKeys 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)) + ChannelPlane(channelIdHex, held.epoch, ConcordChannelKeys.privateChannel(held.key.hexToByteArray(), channelId, held.epoch)) } - return rootEras + listOfNotNull(privateEra) + return rootEras + listOfNotNull(privateEra) + olderKeys } /** @@ -676,6 +683,7 @@ object ConcordActions { expiresAtMs: Long? = null, name: String = entry.name, icon: ImagePointer? = null, + onlyChannelIds: Set? = null, ): CommunityInvite = CommunityInvite( communityId = entry.id, @@ -684,7 +692,12 @@ object ConcordActions { communityRoot = entry.root, rootEpoch = entry.rootEpoch, controlPk = entry.controlPk, - channels = ConcordInviteVend.toInviteChannels(ConcordInviteVend.vendableChannels(entry.privateChannels, authority, recipient)), + channels = + ConcordInviteVend.toInviteChannels( + ConcordInviteVend + .vendableChannels(entry.privateChannels, authority, recipient) + .filter { onlyChannelIds == null || it.channelId.lowercase() in onlyChannelIds }, + ), relays = entry.relays.take(ConcordInviteBundle.MAX_COMMUNITY_RELAYS), name = name.ifBlank { entry.name }, icon = icon, @@ -705,6 +718,7 @@ object ConcordActions { sender: HexKey, recipient: HexKey, expiresAtMs: Long? = null, + onlyChannelIds: Set? = null, ): ConcordDirectInviteDraft { val to = recipient.lowercase() if (!HEX64.matches(to)) return ConcordDirectInviteDraft.Refused(ConcordDirectInviteSendResult.INVALID_RECIPIENT) @@ -719,6 +733,7 @@ object ConcordActions { expiresAtMs = expiresAtMs, name = state.metadata?.name ?: entry.name, icon = state.metadata?.icon, + onlyChannelIds = onlyChannelIds?.mapTo(HashSet()) { it.lowercase() }, ), ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt new file mode 100644 index 0000000000..7cdd4bd189 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannels.kt @@ -0,0 +1,387 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.actions + +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation +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.ControlEdition +import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity +import com.vitorpamplona.quartz.concord.cord04Roles.RoleScope +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRotation +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRotationAuthority +import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys +import com.vitorpamplona.quartz.concord.crypto.GroupKey +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.signers.NostrSigner +import com.vitorpamplona.quartz.utils.RandomInstance + +/** + * A Private Channel just minted or privatised (CORD-03 §2): its [channelIdHex], the independent + * [key] to store in the Community List **before** [wraps] publish (a lost List write would orphan + * the only copy), the access Role minted beside it ([roleIdHex]), and the Control editions to + * publish in order — the Role first (an orphan Role is inert), then the channel edition. + */ +class PrivateChannelBuild( + val channelIdHex: HexKey, + val key: PrivateChannelKey, + val roleIdHex: HexKey, + val wraps: List, +) + +/** + * Private Channels end to end (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) as pure + * builders and decisions, shared by the app and `amy`. The network and the Community List write + * stay with the caller. + * + * Who may read a Private Channel is its Roles: a Role scoped `{kind:"channel", channel_id}` IS its + * access list ([ConcordInviteVend.isEntitled]). Read access is enforced by key possession alone, so + * this decides who a key is delivered TO (a Direct Invite on grant) and who a rotation keeps (a + * channel rekey on revoke). Pinned to Armada's `channelAccess.ts`, `useCommunityActions` + * (`createChannel`, `privatiseChannel`, `publiciseChannel`) and `useRekey` (`useChannelRekey`, + * `useChannelRekeyWatch`). + */ +object ConcordPrivateChannels { + /** + * The position a new access Role takes (Armada `accessRolePosition`): the bottom of the roster, + * never above what [actor] may mint (the owner mints from 1, anyone else strictly below their + * own rank), so a grantee is never promoted by being let into a channel. Null when [actor] holds + * no rank to mint from. + */ + fun accessRolePosition( + authority: AuthorityResolver?, + actor: HexKey, + owner: HexKey, + ): Long? { + val ceiling = + if (actor.equals(owner, ignoreCase = true)) { + 1L + } else { + (authority?.rank(actor) ?: return null) + 1 + } + val lowest = + authority + ?.roles() + ?.values + ?.filterNot { it.deleted } + ?.maxOfOrNull { it.position } ?: 0L + return maxOf(ceiling, lowest + 1) + } + + /** [name] cut to the protocol's 64-byte cap on a character boundary (Armada slices the same way). */ + private fun fitName(name: String): String { + var out = name + while (!ConcordLimits.nameFits(out)) out = out.dropLast(1) + return out + } + + /** The bit-less access Role scoped to [channelIdHex] (CORD-04 §2): read access is the key, never a bit. */ + fun accessRole( + name: String, + channelIdHex: HexKey, + position: Long, + ): RoleEntity = + RoleEntity( + name = fitName(name), + position = position, + permissions = "0", + scope = RoleScope(kind = "channel", channelId = channelIdHex.lowercase()), + ) + + /** + * A new channel (CORD-03 §2): a random `channel_id`, and for a Private one an independent key + * at channel epoch 0 plus its access Role (Armada `createChannel`: a born-private channel is + * epoch 0; the first *privatisation* of a public channel is epoch 1). Returns null when the + * name is invalid or the actor has no rank to mint the Role from. + */ + suspend fun create( + actor: NostrSigner, + cp: ControlPlaneKeys, + communityId: ByteArray, + name: String, + accessRoleName: String?, + current: List, + authority: AuthorityResolver?, + owner: HexKey, + createdAt: Long, + ): PrivateChannelBuild? { + val channel = ChannelEntity(name = name.trim(), private = true) + if (!channel.hasValidName()) return null + val position = accessRolePosition(authority, actor.pubKey, owner) ?: return null + val channelId = RandomInstance.bytes(32) + val channelIdHex = channelId.toHexKey() + val roleId = RandomInstance.bytes(32) + val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: channel.name, channelIdHex, position) + val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = owner) + val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelId, channel, current, createdAt, owner = owner) + return PrivateChannelBuild( + channelIdHex = channelIdHex, + key = PrivateChannelKey(channelIdHex, ConcordChannelRekey.mintKey().toHexKey(), 0, channel.name), + roleIdHex = roleId.toHexKey(), + wraps = listOf(roleWrap, channelWrap), + ) + } + + /** + * Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh independent key at + * the NEXT channel epoch ([ConcordChannelKeyring.nextChannelEpoch], floored at [observedFloor], + * the highest channel rotation seen on the wire), a new access Role, and the channel edition + * flipping `private` on — every other field of [standing] carried through. Protects the future + * only: the public era stays readable to every member. Null when the actor can't mint the Role. + */ + suspend fun privatize( + actor: NostrSigner, + cp: ControlPlaneKeys, + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + standing: ChannelEntity, + accessRoleName: String?, + current: List, + authority: AuthorityResolver?, + createdAt: Long, + observedFloor: Long = 0, + ): PrivateChannelBuild? { + if (standing.private || standing.deleted) return null + val position = accessRolePosition(authority, actor.pubKey, entry.owner) ?: return null + val communityId = entry.id.hexToByteArray() + val roleId = RandomInstance.bytes(32) + val role = accessRole(accessRoleName?.trim()?.ifBlank { null } ?: standing.name, channelIdHex, position) + val roleWrap = ConcordModeration.defineRole(actor, cp, communityId, roleId, role, current, createdAt, owner = entry.owner) + val channelWrap = ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = true), current, createdAt, owner = entry.owner) + val epoch = ConcordChannelKeyring.nextChannelEpoch(entry, channelIdHex, observedFloor) + return PrivateChannelBuild( + channelIdHex = channelIdHex.lowercase(), + key = PrivateChannelKey(channelIdHex.lowercase(), ConcordChannelRekey.mintKey().toHexKey(), epoch, standing.name), + roleIdHex = roleId.toHexKey(), + wraps = listOf(roleWrap, channelWrap), + ) + } + + /** + * Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The + * channel derives from the `community_root` from here on; the held key stays in the List so its + * holders keep reading the private era, which a later joiner never can. Null when it is not + * private. + */ + suspend fun publicize( + actor: NostrSigner, + cp: ControlPlaneKeys, + communityId: ByteArray, + channelIdHex: HexKey, + standing: ChannelEntity, + current: List, + owner: HexKey, + createdAt: Long, + ): Event? { + if (!standing.private || standing.deleted) return null + return ConcordModeration.defineChannel(actor, cp, communityId, channelIdHex.hexToByteArray(), standing.copy(private = false), current, createdAt, owner = owner) + } + + // ---- channel rotations (CORD-06 §1-2) ------------------------------------- + + /** + * Whether [actor] may launch a single-channel Rekey cutting [removed] (CORD-06 §3 Authority): + * `MANAGE_CHANNELS` (or `BAN`, a Refounding's) and strictly outranking every removed target. + * The owner may always; the owner is never a valid target. + */ + fun canRotate( + authority: AuthorityResolver, + actor: HexKey, + removed: Collection, + bit: Int = ConcordPermissions.MANAGE_CHANNELS, + ): Boolean { + if (authority.isOwner(actor)) return true + if (!authority.hasPermission(actor, bit)) return false + return removed.all { it.equals(actor, ignoreCase = true) || authority.canActOn(actor, it, bit) } + } + + /** + * The members a rotation of [channelIdHex] keeps (Armada `handleRotateChannelKey`): exactly + * those entitled today ([ConcordInviteVend.entitledMembers]) plus the [rotator], who must keep + * every key or nobody could rotate the channel next time. + */ + fun keepSet( + authority: AuthorityResolver, + channelIdHex: HexKey, + rotator: HexKey, + ): Set = ConcordInviteVend.entitledMembers(authority, channelIdHex) + rotator.lowercase() + + /** + * The wraps of one rotation of [held] to [newKey] for [keep], sealed under [sealingRoot] (the + * current root for a single-channel Rekey, the PRIOR root inside a Refounding — CORD-06 §3), and + * citing [authority] on every chunk. + */ + suspend fun buildRotation( + rotator: NostrSigner, + sealingRoot: ByteArray, + held: PrivateChannelKey, + newKey: ByteArray, + keep: Collection, + createdAt: Long, + authority: AuthorityCitation?, + ): List = + ConcordChannelRekey.build( + rotatorSigner = rotator, + sealingRoot = sealingRoot, + channelId = held.channelId.hexToByteArray(), + heldKey = held.key.hexToByteArray(), + heldEpoch = held.epoch, + newKey = newKey, + recipients = keep + rotator.pubKey, + createdAt = createdAt, + authority = authority, + ) + + /** The roots a member watches channel rekeys under: the current one and the canonical prior one (CORD-06 §3). */ + fun watchRoots(entry: ConcordCommunityListEntry): List { + val prior = ConcordRefounding.canonicalHeldRoots(entry.heldRoots).filter { it.epoch == entry.rootEpoch - 1 } + return (listOf(entry.root) + prior.map { it.key }).distinct().map { it.hexToByteArray() } + } + + /** + * Every channel-rekey address this entry should watch (CORD-06 §2): per held Private Channel, + * the next [ConcordChannelRekey.LOOKAHEAD] channel epochs past the held one, under each of + * [watchRoots]. Address hex → key. + */ + fun watchKeys(entry: ConcordCommunityListEntry): Map { + val out = LinkedHashMap() + val roots = watchRoots(entry) + for (held in entry.privateChannels) { + if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue + val channelId = held.channelId.hexToByteArray() + for (root in roots) { + for (ahead in 1..ConcordChannelRekey.LOOKAHEAD) { + val key = ConcordChannelRekey.address(root, channelId, held.epoch + ahead) + out[key.publicKeyHex] = key + } + } + } + return out + } + + /** + * Whether a received channel rotation's Rotator may be honored (CORD-06 §3 Authority): the owner, + * or a non-banned holder of `MANAGE_CHANNELS` (a single-channel Rekey) or `BAN` (a Refounding's + * channel rekeys), whose `vac` cites a Grant our fold has synced. Key possession is never + * authority. + */ + fun isHonoredRotation( + entry: ConcordCommunityListEntry, + editions: Collection, + authority: AuthorityResolver, + rotation: ChannelRotation, + ): Boolean { + val rotator = rotation.rotator + if (!authority.isOwner(rotator)) { + if (authority.isBanned(rotator)) return false + if (!authority.hasPermission(rotator, ConcordPermissions.MANAGE_CHANNELS) && !authority.hasPermission(rotator, ConcordPermissions.BAN)) return false + } + val heads = ConcordRotationAuthority.headsOf(editions, entry.id, entry.owner) + return ConcordRotationAuthority.citationSatisfied(entry.id, rotator, entry.owner, rotation.authority, heads) + } + + /** Whether [rotator] strictly outranks [me] — only such a Rotator's omission is a cut (CORD-06 §3). */ + fun outranks( + authority: AuthorityResolver, + rotator: HexKey, + me: HexKey, + ): Boolean { + if (authority.isOwner(me)) return false + val theirs = authority.rank(rotator) ?: return false + val mine = authority.rank(me) ?: Long.MAX_VALUE + return theirs < mine + } + + /** + * What the buffered channel-rekey [wraps] mean for each Private Channel [entry] holds a key for + * (CORD-06 §2), per channel id: an adoption moves that channel's key forward, a cut drops it and + * records `channel_cuts`. Channels with nothing to do are omitted. The caller applies the result + * inside its List write ([applyOutcome]). + */ + suspend fun receive( + entry: ConcordCommunityListEntry, + wraps: Collection, + editions: Collection, + authority: AuthorityResolver, + recipient: NostrSigner, + ): Map { + if (wraps.isEmpty()) return emptyMap() + val keys = watchKeys(entry) + val me = recipient.pubKey + val joinedAtSecs = entry.addedAt / 1000 + val out = LinkedHashMap() + for (held in entry.privateChannels) { + if (ConcordChannelKeyring.heldKey(entry, held.channelId) == null) continue + val id = held.channelId.lowercase() + val rotations = ConcordChannelRekey.rotations(wraps, keys, id) + if (rotations.isEmpty()) continue + val outcome = + ConcordChannelRekey.walk( + rotations = rotations, + channelIdHex = id, + heldKey = held.key.hexToByteArray(), + heldEpoch = held.epoch, + recipientSigner = recipient, + joinedAtSecs = joinedAtSecs, + honored = { isHonoredRotation(entry, editions, authority, it) }, + outranksMe = { outranks(authority, it, me) }, + ) + if (outcome !is ChannelRekeyOutcome.None) out[id] = outcome + } + return out + } + + /** + * [current] with [outcomes] applied — only where the channel is still at the epoch the outcome + * was computed from ([fromEpochs]), so a write racing another adoption never rolls a key back — + * or null when nothing changed. + */ + fun applyOutcome( + current: ConcordCommunityListEntry, + outcomes: Map, + fromEpochs: Map, + ): ConcordCommunityListEntry? { + var next = current + for ((id, outcome) in outcomes) { + val held = ConcordChannelKeyring.heldKey(next, id) ?: continue + if (held.epoch != fromEpochs[id]) continue + next = + when (outcome) { + is ChannelRekeyOutcome.Adopted -> ConcordChannelKeyring.withRotatedKey(next, id, outcome.key.toHexKey(), outcome.epoch) ?: next + is ChannelRekeyOutcome.Removed -> ConcordChannelKeyring.withoutChannel(next, id, outcome.epoch) + ChannelRekeyOutcome.None -> next + } + } + return if (next === current) null else next + } +} 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 18f47edd00..1cffadf0cb 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 @@ -96,7 +96,8 @@ object ConcordSubscriptionPlanner { * The off-channel planes every joined community subscribes to upfront (known * from the entry alone): the Guestbook Plane (membership motions) and the * next-epoch base-rekey address (so an inbound Refounding is received live, - * CORD-06), and the dissolution tombstone address (CORD-02 §9). All are kind-1059 + * CORD-06), the dissolution tombstone address (CORD-02 §9), and every held Private Channel's + * next channel-rekey addresses (CORD-06 §2). All are kind-1059 * wraps authored by their derived stream address. */ fun auxiliaryPlaneSubs(entries: List): List = @@ -114,7 +115,10 @@ object ConcordSubscriptionPlanner { 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) }, - ) + ) + + // Each held Private Channel's next channel-rekey addresses (CORD-06 §2), so a rotation + // that moves the key forward — or cuts us — is received live. + ConcordPrivateChannels.watchKeys(e).keys.map { ConcordPlaneSub(channelId = null, pubKeyHex = it, relays = relays) } } /** 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 7c17764ec3..ff9cb04c6d 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 @@ -4185,6 +4185,11 @@ class Account( // A promotion to staff delivers the Control Plane write key inside the Grant // itself (CORD-04 §3), so the fold that seats the role is also when it arrives. runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) } + // A Private Channel rotation lands on its channel-rekey address (CORD-06 §2): adopt the new + // key, or drop the channel when it cut us. + runCatching { concord.drainConcordChannelRekeys() }.onFailure { Log.w("Concord", "channel rekey drain failed", it) } + // A Grant folding late turns a parked catch-up invite into one we adopt without a click. + runCatching { concord.drainConcordCatchUps() }.onFailure { Log.w("Concord", "catch-up drain failed", it) } // A rotation we were *excluded* from produces no rekey to drain, so it can only be // found by re-resolving the invite link we joined through. Rate-limited internally. runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) } 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 325314bee5..f56dbe67d7 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,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordPinContext import com.vitorpamplona.amethyst.commons.actions.ConcordPinOutcome import com.vitorpamplona.amethyst.commons.actions.ConcordPinWrite import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner import com.vitorpamplona.amethyst.commons.defaults.DefaultDmIndexerRelays @@ -53,8 +54,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordListTooLargeExcep 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.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent import com.vitorpamplona.quartz.concord.cord03Channels.concordEpoch +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityCitation import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits @@ -68,8 +71,11 @@ 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.ConcordInviteRegistry +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordRefounding import com.vitorpamplona.quartz.concord.cord06Rekey.IncompleteControlPlaneException import com.vitorpamplona.quartz.concord.cord06Rekey.PendingRefounding @@ -131,6 +137,9 @@ private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L */ private const val MAX_REFOUNDING_RECIPIENTS = 5_000 +// How many channel epochs a privatisation probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). +private const val MAX_PROBED_CHANNEL_EPOCH = 32L + /** A lowercase 32-byte hex key (the Guestbook `invite` tag's creator). */ private val HEX64 = Regex("^[0-9a-f]{64}$") @@ -191,6 +200,21 @@ class AccountConcordActions( /** 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) } + /** + * Rewrites the held entry for [communityId] through [transform], applied to the entry **as the + * List holds it inside the write** ([ConcordChannelListState.update]) — never to a snapshot read + * before a suspension, which a concurrent rekey or import could have moved on. True only when + * [transform] produced a change and it was written. + */ + private suspend fun updateConcordEntry( + communityId: String, + transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?, + ): Boolean { + var changed = false + val ok = writeConcordList { list -> list.update(communityId) { cur -> transform(cur)?.also { changed = true } } } + return ok && changed + } + /** Publishes a Guestbook JOIN (kind 3306) for [entry] to its community relays. */ private suspend fun announceConcordGuestbookJoin( entry: ConcordCommunityListEntry, @@ -814,9 +838,42 @@ class AccountConcordActions( val filter = ConcordActions.directInvitesFilter(account.signer.pubKey, directInviteInbox.since()) val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(filter) }) wraps.distinctBy { it.id }.forEach { directInviteInbox.offer(it) } + drainConcordCatchUps() return (directInviteInbox.pending.value.keys - before).size } + /** + * Adopts, without a click, every parked catch-up the held fold says is exactly the delivery a + * Grant prescribes (Armada `judgeCatchUp`, [ConcordInviteVend.judgeCatchUp]): a staff sender, a + * recipient who isn't banned, and only live Private Channels our Roles entitle us to. Consent + * came from the Grant. Anything else waits for a manual Accept. Runs after an inbox sweep and on + * the revision tick (a Grant folding late turns a waiting catch-up adoptable). + */ + internal suspend fun drainConcordCatchUps() { + if (!account.isWriteable()) return + val me = account.signer.pubKey + val now = TimeUtils.nowMillis() + for (opened in directInviteInbox.pending.value.values) { + if (opened.isExpired(now)) continue + val held = + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id.equals(opened.invite.communityId, ignoreCase = true) } ?: continue + val state = + account.concordSessions + .sessionFor(held.id) + ?.state + ?.value ?: continue + if (state.dissolved) continue + val verdict = ConcordInviteVend.judgeCatchUp(state.authority, state.privateChannelIds, me, opened.sender, opened.invite, held) + if (verdict != ConcordInviteVend.CatchUpVerdict.ADOPT) continue + val ids = ConcordInviteVend.catchUpChannelIds(held, opened.invite) + if (updateConcordEntry(held.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, opened.invite, ids) }) { + Log.i("Concord") { "Adopted a granted Private Channel key for ${held.id} from ${opened.sender}" } + directInviteInbox.resolve(opened.wrapId) + } + } + } + /** * The recipient's giftwrap inbox (CORD-05 §6): their kind-10050 DM relays, else NIP-65 read * relays — from the cache when we have their lists, fetched otherwise — else the stock set. @@ -852,6 +909,7 @@ class AccountConcordActions( communityId: String, recipientPubKey: HexKey, expiresAtMs: Long? = null, + onlyChannelIds: Set? = null, ): ConcordDirectInviteSendResult { if (!account.isWriteable()) return ConcordDirectInviteSendResult.NOT_WRITEABLE val recipient = recipientPubKey.lowercase() @@ -865,7 +923,7 @@ class AccountConcordActions( ?.state ?.value ?: return ConcordDirectInviteSendResult.ROSTER_NOT_LOADED val invite = - when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs)) { + when (val draft = ConcordActions.draftDirectInvite(entry, state, account.signer.pubKey, recipient, expiresAtMs, onlyChannelIds)) { is ConcordDirectInviteDraft.Refused -> return draft.reason is ConcordDirectInviteDraft.Ready -> draft.invite } @@ -907,9 +965,12 @@ class AccountConcordActions( // No folded roster yet: whether it bans us is unknown, so the invite waits. DirectInviteAcceptPlan.RosterNotLoaded -> ConcordInviteResult.NotReachable DirectInviteAcceptPlan.NothingNew -> ConcordInviteResult.Joined(bundle.communityId) - // Keys only, on the held base: no second Guestbook Join. - is DirectInviteAcceptPlan.CatchUp -> - if (persistConcordEntry(plan.entry)) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + // Keys only, on the held base: no second Guestbook Join. Re-applied to the entry the + // List holds at write time, so a root imported meanwhile is never written back over. + is DirectInviteAcceptPlan.CatchUp -> { + val ok = writeConcordList { list -> list.update(plan.entry.id) { cur -> ConcordInviteVend.adoptCatchUp(cur, bundle, plan.channelIds) } } + if (ok) ConcordInviteResult.Joined(bundle.communityId) else ConcordInviteResult.NotReachable + } DirectInviteAcceptPlan.Join -> joinValidatedConcordInvite( bundle = bundle, @@ -1307,6 +1368,7 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + val before = session.state.value?.authority // A Grant that first makes its member staff must deliver the control_root in the same // edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the // roles carry a Control-writing bit and we hold the secret to hand over. @@ -1324,6 +1386,8 @@ class AccountConcordActions( epoch = session.entry.rootEpoch, ) publishConcordWrap(session.entry, wrap) + // Role-gated channel keys follow the Grant: vend what it opened, rotate what it closed. + reconcileConcordChannelAccess(communityId, before) return true } @@ -1429,8 +1493,10 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false + val before = session.state.value?.authority val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, grantWrap) + reconcileConcordChannelAccess(communityId, before) return true } @@ -1487,14 +1553,20 @@ class AccountConcordActions( val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false + val before = session.state.value?.authority val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner) publishConcordWrap(session.entry, wrap) // Judged on the fold that now carries the ban (publishConcordWrap ingests it first). val state = session.state.value if (state != null && state.banRequiresRefounding(listOf(member))) { + // The Refounding rotates every held Private Channel to its entitled set (CORD-06 §3). if (!refoundConcordCommunity(communityId, setOf(member))) { Log.w("Concord") { "Banned $member from the Private community $communityId, but its Refounding did not complete" } } + } else { + // A Public ban keeps the base, so the Private Channels the target could read are cut by + // their own rekeys (CORD-04 §6: "a Private-Channel rekey for a channel-scoped cut"). + reconcileConcordChannelAccess(communityId, before) } return true } @@ -1850,9 +1922,14 @@ class AccountConcordActions( } if (!compactionLanded) Log.w("Concord") { "Refounding ${entry.id}: some compacted Control Plane heads were not accepted at epoch ${build.newEpoch}" } + // 5b. Rotate every held Private Channel (CORD-06 §3), each to its OWN entitled set among the + // kept members, sealed under the PRIOR root so a base-fork loser can still open it. A + // channel that fails to land keeps its key (and is logged): resumable, not atomic. + val rotatedEntry = rotatePrivateChannelsForRefounding(entry, recipients.toSet(), priorRoot, citation) + // 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) + val adopted = adoptConcordRoot(rotatedEntry, 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, @@ -1864,6 +1941,40 @@ class AccountConcordActions( return compactionLanded } + /** + * The Refounding's channel duty (CORD-06 §3): every held key of a live Private Channel is + * rotated to the members still entitled to it ∩ [kept], plus ourselves, sealed under + * [priorRoot]. Returns [entry] with each rotated channel moved to its new key (the caller adopts + * the new root from it); a channel whose rotation didn't land keeps its old key. + */ + private suspend fun rotatePrivateChannelsForRefounding( + entry: ConcordCommunityListEntry, + kept: Set, + priorRoot: ByteArray, + citation: AuthorityCitation?, + ): ConcordCommunityListEntry { + val session = account.concordSessions.sessionFor(entry.id) ?: return entry + val state = session.state.value ?: return entry + val me = account.signer.pubKey.lowercase() + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + val keptLower = kept.mapTo(HashSet()) { it.lowercase() } + var next = entry + for (held in entry.privateChannels) { + val id = held.channelId.lowercase() + if (id !in state.privateChannelIds || ConcordChannelKeyring.heldKey(entry, id) == null) continue + val keep = ConcordPrivateChannels.keepSet(state.authority, id, me).filterTo(HashSet()) { it in keptLower || it == me } + val newKey = ConcordChannelRekey.mintKey() + val wraps = ConcordPrivateChannels.buildRotation(account.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation) + val landed = wraps.all { runCatching { account.client.publishAndConfirm(it, publishTo) }.getOrDefault(false) } + if (!landed) { + Log.w("Concord") { "Refounding ${entry.id}: the rekey of private channel $id did not land; it keeps its key" } + continue + } + next = ConcordChannelKeyring.withRotatedKey(next, id, newKey.toHexKey(), held.epoch + 1) ?: next + } + return next + } + // 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. @@ -2266,10 +2377,13 @@ class AccountConcordActions( suspend fun createConcordChannel( communityId: String, name: String, + private: Boolean = false, + accessRoleName: String? = null, ): Boolean { val session = account.concordSessions.sessionFor(communityId) ?: return false if (!account.isWriteable()) return false val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + if (private) return createPrivateConcordChannel(session, cp, communityId, name, accessRoleName) 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. @@ -2326,6 +2440,244 @@ class AccountConcordActions( return true } + // ── Private Channels (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-3) ────────────────── + // A Private Channel reads on its own independent key. Its access list is the Roles scoped to it: + // a Grant that opens one vends the key by Direct Invite, a Grant (or ban) that closes one rotates + // it to the members still entitled. The protocol decisions live in ConcordPrivateChannels / + // ConcordChannelRekey (shared with `amy`); only the network and the List write are here. + + /** + * A new Private Channel (CORD-03 §2): an independent key at channel epoch 0 stored in the List + * FIRST (a lost List write would orphan the only copy), then its access Role and the channel + * edition. Nobody holds the Role yet; granting it vends the key ([grantConcordRole]). + */ + private suspend fun createPrivateConcordChannel( + session: ConcordCommunitySession, + cp: ControlPlaneKeys, + communityId: String, + name: String, + accessRoleName: String?, + ): Boolean { + val build = + ConcordPrivateChannels.create( + actor = account.signer, + cp = cp, + communityId = communityId.hexToByteArray(), + name = name, + accessRoleName = accessRoleName, + current = session.controlEditions(), + authority = session.state.value?.authority, + owner = session.entry.owner, + createdAt = TimeUtils.now(), + ) ?: return false + if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false + build.wraps.forEach { publishConcordWrap(session.entry, it) } + return true + } + + /** + * Converts the Public channel [channelIdHex] to Private (CORD-03 §2): a fresh key at the NEXT + * channel epoch — floored at the highest channel rotation seen on the wire, since a privatiser + * may never have held an earlier generation and a reused epoch is silent and unrecoverable — plus + * a new access Role, then the flag. Protects the future only. MANAGE_CHANNELS. + */ + suspend fun privatizeConcordChannel( + communityId: String, + channelIdHex: HexKey, + accessRoleName: String? = null, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + val state = session.state.value ?: return false + val standing = state.channels[channelIdHex]?.definition ?: return false + if (standing.private) return false + val floor = observedChannelEpochFloor(session.entry, channelIdHex) ?: return false + val build = + ConcordPrivateChannels.privatize( + actor = account.signer, + cp = cp, + entry = session.entry, + channelIdHex = channelIdHex, + standing = standing, + accessRoleName = accessRoleName, + current = session.controlEditions(), + authority = state.authority, + createdAt = TimeUtils.now(), + observedFloor = floor, + ) ?: return false + if (!updateConcordEntry(communityId) { cur -> ConcordChannelKeyring.withChannelKey(cur, build.key) }) return false + build.wraps.forEach { publishConcordWrap(session.entry, it) } + return true + } + + /** + * The highest channel epoch a rotation of [channelIdHex] was ever published at, read off the + * rekey addresses every held root derives (CORD-06 §2 addresses need no channel key), or null + * when the read is inconclusive — a rotation at the window's top may have more above it, and + * minting on a guess could reuse an epoch (Armada `channelEpochFloor`). 0 when none is seen or no + * relay answers. + */ + private suspend fun observedChannelEpochFloor( + entry: ConcordCommunityListEntry, + channelIdHex: HexKey, + ): Long? { + val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (relays.isEmpty()) return 0 + val channelId = channelIdHex.hexToByteArray() + val window = HashMap() + for (root in (listOf(entry.root) + entry.heldRoots.map { it.key }).distinct()) { + for (epoch in 1L..MAX_PROBED_CHANNEL_EPOCH) window[ConcordChannelRekey.address(root.hexToByteArray(), channelId, epoch).publicKeyHex] = epoch + } + val seen = runCatching { account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }) }.getOrDefault(emptyList()) + val highest = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0 + return if (highest >= MAX_PROBED_CHANNEL_EPOCH) null else highest + } + + /** + * Converts the Private channel [channelIdHex] back to Public (CORD-03 §2): the flag only. The + * held key stays, so its holders keep reading the private era. MANAGE_CHANNELS. + */ + suspend fun publicizeConcordChannel( + communityId: String, + channelIdHex: HexKey, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false + val standing = + session.state.value + ?.channels + ?.get(channelIdHex) + ?.definition ?: return false + val wrap = ConcordPrivateChannels.publicize(account.signer, cp, communityId.hexToByteArray(), channelIdHex, standing, session.controlEditions(), session.entry.owner, TimeUtils.now()) ?: return false + publishConcordWrap(session.entry, wrap) + return true + } + + // Channel keys reserved for a rotation in flight, keyed by (community, channel, new epoch, + // prevcommit): a retry re-delivers the SAME key rather than minting a sibling that would split + // the members across two keys at one epoch (Armada `mintOrReuseRotationKey`). Process-local. + private val pendingConcordChannelRotations = ConcurrentMap() + + /** + * Rotates Private Channel [channelIdHex] (a single-channel Rekey, CORD-06 §1-2) to exactly the + * members entitled to it today plus ourselves, cutting everyone else. [removed] names who the + * rotation cuts, for the authority check — the Rotator must hold MANAGE_CHANNELS and strictly + * outrank each of them; null takes every known member who is not kept. Every chunk must land on + * a relay before we adopt the new key. Returns whether the rotation was published and adopted. + */ + suspend fun rekeyConcordChannel( + communityId: String, + channelIdHex: HexKey, + removed: Set? = null, + ): Boolean { + val session = account.concordSessions.sessionFor(communityId) ?: return false + if (!account.isWriteable()) return false + val state = session.state.value ?: return false + if (state.dissolved) return false + val entry = session.entry + val me = account.signer.pubKey + val held = ConcordChannelKeyring.heldKey(entry, channelIdHex) ?: return false + val authority = state.authority + val keep = ConcordPrivateChannels.keepSet(authority, channelIdHex, me) + val cut = removed ?: (session.allMembers() - keep) + if (!ConcordPrivateChannels.canRotate(authority, me, cut)) { + Log.w("Concord") { "Refusing to rotate $channelIdHex in $communityId: not MANAGE_CHANNELS, or does not outrank a cut member (CORD-06 §3)" } + return false + } + val editions = session.controlEditions() + val citation = ConcordReceive.rotationCitation(entry, editions, me) + if (citation == null && !authority.isOwner(me)) return false + val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + if (publishTo.isEmpty()) return false + + val newEpoch = held.epoch + 1 + val reservation = "${entry.id}:${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}" + val newKey = pendingConcordChannelRotations.getOrPut(reservation) { ConcordChannelRekey.mintKey() } + val wraps = ConcordPrivateChannels.buildRotation(account.signer, entry.root.hexToByteArray(), held, newKey, keep, TimeUtils.now(), citation) + for (wrap in wraps) { + if (!runCatching { account.client.publishAndConfirm(wrap, publishTo) }.getOrDefault(false)) { + Log.w("Concord") { "Channel rekey of $channelIdHex aborted: a chunk was not accepted by any relay; retrying reuses the same key" } + return false + } + } + // Adopt at once: the rotator must never keep writing under the severed key. + val adopted = + updateConcordEntry(entry.id) { cur -> + if (ConcordChannelKeyring.heldKey(cur, channelIdHex)?.epoch != held.epoch) null else ConcordChannelKeyring.withRotatedKey(cur, channelIdHex, newKey.toHexKey(), newEpoch) + } + if (adopted) pendingConcordChannelRotations.remove(reservation) + return adopted + } + + /** + * Follows a roster change with the keys it implies (Armada `handleToggleRole`): every Private + * Channel whose entitled set moved between [before] and the current fold ([ConcordInviteVend.accessChanges]) + * — a member who gained one is handed its key by Direct Invite (only the channels gained), and + * one who lost one is cut by rotating it. Only keys we hold can move; a channel we can't vend or + * rotate is logged, since a revoke that cuts nobody is the failure to hear about. + */ + private suspend fun reconcileConcordChannelAccess( + communityId: String, + before: AuthorityResolver?, + ) { + val session = account.concordSessions.sessionFor(communityId) ?: return + val state = session.state.value ?: return + if (before == null || state.dissolved) return + val me = account.signer.pubKey.lowercase() + val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds) + if (changes.isEmpty()) return + + val vend = HashMap>() + for (change in changes) { + val held = ConcordChannelKeyring.heldKey(session.entry, change.channelIdHex) + if (held == null) { + Log.w("Concord") { "Access to ${change.channelIdHex} changed, but we hold no key to vend or rotate it" } + continue + } + for (member in change.gained - me) vend.getOrPut(member) { HashSet() }.add(change.channelIdHex) + val cut = change.lost - me + if (cut.isNotEmpty() && !rekeyConcordChannel(communityId, change.channelIdHex, cut)) { + Log.w("Concord") { "Could not rotate ${change.channelIdHex}: ${cut.size} member(s) may keep reading it until someone who can rotates it" } + } + } + for ((member, channels) in vend) { + val sent = sendConcordDirectInvite(communityId, member, onlyChannelIds = channels) + if (sent != ConcordDirectInviteSendResult.SENT) Log.w("Concord") { "Could not deliver ${channels.size} channel key(s) to $member: $sent" } + } + } + + /** + * Drains the buffered channel rekeys of every joined community (CORD-06 §2 receive path): for + * each held Private Channel, a complete, honored rotation carrying our blob off the key we hold + * moves the key forward; a complete one from a Rotator who outranks us that omits us drops it and + * records the cut. Runs on the revision tick; idempotent once applied (the held epoch moves on). + */ + internal suspend fun drainConcordChannelRekeys() { + if (!account.isWriteable()) return + for (session in account.concordSessions.sessions()) { + val state = session.state.value ?: continue + // Death wins every race (CORD-02 §9). + if (state.dissolved) continue + val wraps = session.pendingChannelRekeyWraps() + if (wraps.isEmpty()) continue + val entry = session.entry + val outcomes = ConcordPrivateChannels.receive(entry, wraps, session.controlEditions(), state.authority, account.signer) + if (outcomes.isEmpty()) continue + val fromEpochs = entry.privateChannels.associate { it.channelId.lowercase() to it.epoch } + if (updateConcordEntry(entry.id) { cur -> ConcordPrivateChannels.applyOutcome(cur, outcomes, fromEpochs) }) { + for ((id, outcome) in outcomes) { + when (outcome) { + is ChannelRekeyOutcome.Adopted -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: adopted epoch ${outcome.epoch}" } + is ChannelRekeyOutcome.Removed -> Log.i("Concord") { "Channel rekey ${entry.id}/$id: cut at epoch ${outcome.epoch}" } + ChannelRekeyOutcome.None -> Unit + } + } + } + } + } + /** * Read-only preview of an invite link: parse it, fetch the kind-33301 bundle from * the link's relays (+ our outbox), and unlock it with the fragment token — WITHOUT 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 1b6fa04690..18f087e773 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 @@ -243,6 +243,28 @@ class ConcordChannelListState( write(set, doc.entries.filterNot { it.id == entry.id } + live, doc.residue) } + /** + * Read-modify-write one membership: [transform] receives the entry for [communityId] **as the + * List holds it inside the write lock** and returns its replacement, or null to write nothing. + * Returns the fragment events to publish (empty when the community isn't held or nothing + * changed). + * + * Use this, never [follow] with a snapshot, for any change that touches one field of a live + * entry (a channel key, a cut): the List can move between reading a snapshot and writing it — + * a rekey adopted, a new root imported — and following the stale copy would write the old + * root back over it. + */ + suspend fun update( + communityId: String, + transform: (ConcordCommunityListEntry) -> ConcordCommunityListEntry?, + ): List = + writeLock.withLock { + val (set, doc) = snapshot() + val current = doc.entries.firstOrNull { it.id == communityId } ?: return@withLock emptyList() + val next = transform(current) ?: return@withLock emptyList() + write(set, doc.entries.map { if (it.id == communityId) next else it }, 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 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 b736c47363..404d998436 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 @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.commons.actions.ConcordLocalEdit import com.vitorpamplona.amethyst.commons.actions.ConcordPinSource import com.vitorpamplona.amethyst.commons.actions.ConcordPinVerifier import com.vitorpamplona.amethyst.commons.actions.ConcordPinning +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry @@ -283,6 +284,14 @@ class ConcordCommunitySession( private val baseRekeyWraps = LinkedHashMap() private val siblingRekeyWraps = LinkedHashMap() + // Channel-rekey addresses (CORD-06 §2) for each held Private Channel's next epochs, under the + // current root and the prior one (a Refounding seals its channel rekeys under the prior root, + // CORD-06 §3) -> key. Re-derived whenever the held channel keys change, since each adoption + // moves the window forward. + @Volatile + private var channelRekeyKeys: Map = ConcordPrivateChannels.watchKeys(entry) + private val channelRekeyWraps = LinkedHashMap() + // 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 @@ -389,6 +398,7 @@ class ConcordCommunitySession( address == nextBaseRekeyAddress || address == siblingBaseRekeyAddress || address == dissolvedAddress || + address in channelRekeyKeys || address in historicalControlKeys || lock.withLock { address in channelKeysByAddress || address in historicalChannelKeysByAddress } @@ -474,7 +484,13 @@ 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 = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + fun auxStreamKeys(): List = listOfNotNull(guestbookKey, nextBaseRekeyKey, dissolvedKey, siblingBaseRekeyKey) + channelRekeyKeys.values + + /** The channel-rekey addresses this session watches (CORD-06 §2), for the auxiliary subscription. */ + fun channelRekeyAddresses(): Set = channelRekeyKeys.keys + + /** The buffered kind-3303 wraps seen at [channelRekeyAddresses], for the account's channel-rekey drain. */ + fun pendingChannelRekeyWraps(): List = lock.withLock { channelRekeyWraps.values.toList() } /** The community's current Control Plane editions — the input a moderation edition chains onto. */ fun controlEditions(): List = lock.withLock { editionsLocked(controlWraps.values.toList(), controlKeys) } @@ -540,6 +556,7 @@ class ConcordCommunitySession( // Control material may already have swapped in an entry carrying the new keys. if (privateKeySet(newEntry) == derivedPrivateKeys) return false entry = newEntry + channelRekeyKeys = ConcordPrivateChannels.watchKeys(newEntry) true } // Nothing folded yet: the first control wrap derives the planes from the swapped-in entry. @@ -606,6 +623,14 @@ class ConcordCommunitySession( lock.withLock { siblingRekeyWraps[wrap.id] = wrap } return ConcordIngestOutcome.STRUCTURAL } + in channelRekeyKeys -> { + // Buffer only, like the base rekeys: opening a blob takes the account signer, and the + // rotator's authority is judged against the fold at drain time. + lock.withLock { + if (channelRekeyWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup + } + 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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt index 10e74ca258..2474d8ff70 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInbox.kt @@ -75,9 +75,14 @@ sealed interface DirectInviteAcceptPlan { /** A community we don't hold: run the shared join path. */ data object Join : DirectInviteAcceptPlan - /** A held community: store [entry] — the held one plus the newly granted Private Channel keys. */ + /** + * A held community: store [entry] — the held one plus the newly granted Private Channel keys + * ([channelIds]). A writer re-applies [channelIds] to the entry it reads inside the List write + * ([ConcordInviteVend.adoptCatchUp] with `only`), never [entry] itself, which is a snapshot. + */ class CatchUp( val entry: ConcordCommunityListEntry, + val channelIds: List, ) : DirectInviteAcceptPlan /** A held community the bundle adds nothing to (or can't: a different base, or dissolved). */ @@ -216,9 +221,11 @@ class ConcordDirectInviteInbox( * - not held → [DirectInviteAcceptPlan.Join] (the shared join path, which still ban-gates * against the community's own Control Plane); * - held on the SAME base with new Private Channel keys → [DirectInviteAcceptPlan.CatchUp], - * the held entry with only those keys merged in — never moving the base (Armada - * `catchUpChannelIds`) — unless the held roster bans [me]; refused while the roster isn't - * folded ([DirectInviteAcceptPlan.RosterNotLoaded]); + * the held entry with only those keys ADDED — never moving the base, never replacing a + * held key (Armada `catchUpChannelIds`) — and only from a sender who is staff in the held + * fold, for channels it knows as live Private Channels + * ([ConcordInviteVend.admissibleCatchUpIds]); refused when the held roster bans [me], and + * while it isn't folded ([DirectInviteAcceptPlan.RosterNotLoaded]); * - held otherwise (nothing new, a different base, dissolved) → [DirectInviteAcceptPlan.NothingNew]. */ fun acceptPlan( @@ -230,12 +237,15 @@ class ConcordDirectInviteInbox( ): DirectInviteAcceptPlan { if (opened.isExpired(nowMs)) return DirectInviteAcceptPlan.Expired if (held == null) return DirectInviteAcceptPlan.Join - val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite) ?: return DirectInviteAcceptPlan.NothingNew + if (ConcordInviteVend.catchUpChannelIds(held, opened.invite).isEmpty()) return DirectInviteAcceptPlan.NothingNew if (heldState == null) return DirectInviteAcceptPlan.RosterNotLoaded // Death wins every race (CORD-02 §9): a dissolved community takes no new keys. if (heldState.dissolved) return DirectInviteAcceptPlan.NothingNew if (heldState.authority.isBanned(me)) return DirectInviteAcceptPlan.Banned - return DirectInviteAcceptPlan.CatchUp(adopted) + val ids = ConcordInviteVend.admissibleCatchUpIds(held, opened.invite, heldState.authority, heldState.privateChannelIds, opened.sender) + if (ids.isEmpty()) return DirectInviteAcceptPlan.NothingNew + val adopted = ConcordInviteVend.adoptCatchUp(held, opened.invite, ids) ?: return DirectInviteAcceptPlan.NothingNew + return DirectInviteAcceptPlan.CatchUp(adopted, ids) } /** diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt new file mode 100644 index 0000000000..c6355c62c4 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/actions/ConcordPrivateChannelsTest.kt @@ -0,0 +1,237 @@ +/* + * 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.ConcordDirectInviteDraft +import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession +import com.vitorpamplona.amethyst.commons.model.concord.ConcordIngestOutcome +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.NewConcordCommunity +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteVend +import com.vitorpamplona.quartz.concord.cord06Rekey.ChannelRekeyOutcome +import com.vitorpamplona.quartz.concord.cord06Rekey.ConcordChannelRekey +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.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Private Channels end to end, headless (CORD-03 §1-2, CORD-04 §2, CORD-05 §6, CORD-06 §1-2): create + * with an access Role, vend on grant through a Direct Invite limited to the gained channel, the + * recipient's click-free adoption, rotate on revoke to the remaining entitled set, and each member's + * receive (the kept adopts, the cut drops the key and records the cut). Plus privatise/publicise. + */ +class ConcordPrivateChannelsTest { + private val owner = NostrSignerInternal(KeyPair()) + private val alice = NostrSignerInternal(KeyPair()) + private val bob = NostrSignerInternal(KeyPair()) + + private class World( + val community: NewConcordCommunity, + val editions: MutableList, + ) { + fun add(wrap: Event) { + editions += ConcordActions.controlEditions(listOf(wrap), community.controlPlane) + } + + fun state(): ConcordCommunityState = ConcordCommunityState.fold(editions, community.communityId, community.ownerPubKey) + } + + private suspend fun world(): World { + val c = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://relay.example")) + return World(c, ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList()) + } + + private fun entryOf( + c: NewConcordCommunity, + channels: List = emptyList(), + ) = ConcordCommunityListEntry( + id = c.communityIdHex, + owner = c.ownerPubKey, + ownerSalt = c.ownerSalt.toHexKey(), + root = c.communityRoot.toHexKey(), + rootEpoch = c.rootEpoch, + controlPk = c.controlPkHex, + privateChannels = channels, + relays = listOf("wss://relay.example"), + name = "Nostrichs", + addedAt = 1_000L, + ) + + @Test + fun aPrivateChannelIsVendedOnGrantAndRotatedOnRevoke() = + runTest { + val w = world() + val c = w.community + val cid = c.communityId + + // 1. Create: an access Role at the bottom of the roster, the channel flagged private, a key at epoch 0. + val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, cid, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L)) + build.wraps.forEach { w.add(it) } + val ch = build.channelIdHex + assertEquals(0L, build.key.epoch) + assertTrue(ch in w.state().privateChannelIds) + val role = assertNotNull(w.state().roles[build.roleIdHex]) + assertEquals("channel", role.scope?.kind) + assertEquals(ch, role.scope?.channelId) + assertEquals("0", role.permissions) + val ownerEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + + // 2. Grant alice and bob the access Role: both gained the channel. + val beforeGrant = w.state().authority + w.add(ConcordModeration.grant(owner, c.controlPlane, cid, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + val granted = ConcordInviteVend.accessChanges(beforeGrant, w.state().authority, w.state().privateChannelIds).single() + assertEquals(setOf(alice.pubKey, bob.pubKey), granted.gained) + + // 3. Vend: a Direct Invite limited to the gained channel, from staff, adopted by alice without a click. + val draft = assertIs(ConcordActions.draftDirectInvite(ownerEntry, w.state(), owner.pubKey, alice.pubKey, onlyChannelIds = setOf(ch))) + assertEquals(listOf(ch), draft.invite.channels.map { it.id }) + val aliceHeld = entryOf(c) + assertEquals( + ConcordInviteVend.CatchUpVerdict.ADOPT, + ConcordInviteVend.judgeCatchUp(w.state().authority, w.state().privateChannelIds, alice.pubKey, owner.pubKey, draft.invite, aliceHeld), + ) + val aliceEntry = assertNotNull(ConcordInviteVend.adoptCatchUp(aliceHeld, draft.invite)) + val bobEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + assertEquals(build.key.key, ConcordChannelKeyring.heldKey(aliceEntry, ch)?.key) + + // 4. Revoke bob: he lost the channel, so the owner rotates it to the remaining entitled set. + val beforeRevoke = w.state().authority + w.add(ConcordModeration.grant(owner, c.controlPlane, cid, bob.pubKey, emptyList(), w.editions, 4L, owner = c.ownerPubKey)) + val revoked = ConcordInviteVend.accessChanges(beforeRevoke, w.state().authority, w.state().privateChannelIds).single() + assertEquals(setOf(bob.pubKey), revoked.lost) + val keep = ConcordPrivateChannels.keepSet(w.state().authority, ch, owner.pubKey) + assertEquals(setOf(owner.pubKey, alice.pubKey), keep) + assertTrue(ConcordPrivateChannels.canRotate(w.state().authority, owner.pubKey, revoked.lost)) + // A plain member can't rotate anyone out. + assertFalse(ConcordPrivateChannels.canRotate(w.state().authority, alice.pubKey, setOf(bob.pubKey))) + + val newKey = ConcordChannelRekey.mintKey() + val held = assertNotNull(ConcordChannelKeyring.heldKey(ownerEntry, ch)) + val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, held, newKey, keep, 5L, authority = null) + + // 5. Receive: alice adopts epoch 1, bob is cut and the cut is recorded. + val aliceOut = ConcordPrivateChannels.receive(aliceEntry, wraps, w.editions, w.state().authority, alice) + val adopted = assertIs(aliceOut[ch]) + assertEquals(1L, adopted.epoch) + val aliceNext = assertNotNull(ConcordPrivateChannels.applyOutcome(aliceEntry, aliceOut, mapOf(ch to 0L))) + assertEquals(newKey.toHexKey(), ConcordChannelKeyring.heldKey(aliceNext, ch)?.key) + assertEquals(1L, ConcordChannelKeyring.heldKey(aliceNext, ch)?.epoch) + + val bobOut = ConcordPrivateChannels.receive(bobEntry, wraps, w.editions, w.state().authority, bob) + assertEquals(1L, assertIs(bobOut[ch]).epoch) + val bobNext = assertNotNull(ConcordPrivateChannels.applyOutcome(bobEntry, bobOut, mapOf(ch to 0L))) + assertNull(ConcordChannelKeyring.heldKey(bobNext, ch)) + assertEquals(mapOf(ch to 1L), ConcordChannelKeyring.cutsOf(bobNext)) + // The stale epoch-0 key can't come back through a bundle. + assertTrue(ConcordInviteVend.catchUpChannelIds(bobNext, draft.invite).isEmpty()) + + // A result computed from a stale epoch never rolls the List back. + assertNull(ConcordPrivateChannels.applyOutcome(aliceNext, aliceOut, mapOf(ch to 0L))) + } + + @Test + fun aRotationFromAMemberWithoutAuthorityIsIgnored() = + runTest { + val w = world() + val c = w.community + val build = assertNotNull(ConcordPrivateChannels.create(owner, c.controlPlane, c.communityId, "mods", null, w.editions, w.state().authority, c.ownerPubKey, 2L)) + build.wraps.forEach { w.add(it) } + w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, alice.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + w.add(ConcordModeration.grant(owner, c.controlPlane, c.communityId, bob.pubKey, listOf(build.roleIdHex), w.editions, 3L, owner = c.ownerPubKey)) + val aliceEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + + // Bob holds the key but no MANAGE_CHANNELS: holding a key is never authority (CORD-06 §3). + val forged = ConcordPrivateChannels.buildRotation(bob, c.communityRoot, build.key, ConcordChannelRekey.mintKey(), setOf(bob.pubKey), 5L, authority = null) + assertTrue(ConcordPrivateChannels.receive(aliceEntry, forged, w.editions, w.state().authority, alice).isEmpty()) + } + + @Test + fun aSessionWatchesAndBuffersItsChannelRekeys() = + runTest { + val c = world().community + val chId = ByteArray(32) { 0x5C } + val key = PrivateChannelKey(chId.toHexKey(), "10".repeat(32), 3, "mods") + val entry = entryOf(c, listOf(key)) + val session = ConcordCommunitySession(entry, alice.pubKey) + + // The next LOOKAHEAD channel epochs past the held one, under the current root. + val next = ConcordChannelRekey.address(c.communityRoot, chId, 4).publicKeyHex + assertTrue(next in session.channelRekeyAddresses()) + assertTrue(ConcordChannelRekey.address(c.communityRoot, chId, 3 + ConcordChannelRekey.LOOKAHEAD.toLong()).publicKeyHex in session.channelRekeyAddresses()) + assertFalse(ConcordChannelRekey.address(c.communityRoot, chId, 3).publicKeyHex in session.channelRekeyAddresses()) + assertTrue(session.ownsPlane(next)) + // Also subscribed with the auxiliary planes. + assertTrue(ConcordSubscriptionPlanner.auxiliaryPlaneSubs(listOf(entry)).any { it.pubKeyHex == next }) + + val wraps = ConcordPrivateChannels.buildRotation(owner, c.communityRoot, key, ConcordChannelRekey.mintKey(), setOf(alice.pubKey), 5L, null) + assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(wraps.single())) + assertEquals(ConcordIngestOutcome.NON_STRUCTURAL, session.ingest(wraps.single())) + assertEquals(listOf(wraps.single().id), session.pendingChannelRekeyWraps().map { it.id }) + } + + @Test + fun privatizeClimbsTheChannelEpochAndPublicizeFlipsTheFlagBack() = + runTest { + val w = world() + val c = w.community + val general = c.generalChannelIdHex + val standing = assertNotNull(w.state().channels[general]).definition + assertFalse(standing.private) + + val build = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, "insiders", w.editions, w.state().authority, 2L)) + build.wraps.forEach { w.add(it) } + // The first privatisation is epoch 1 (CORD-03 §2); a floor seen on the wire lifts it. + assertEquals(1L, build.key.epoch) + assertTrue(general in w.state().privateChannelIds) + assertEquals("insiders", w.state().roles[build.roleIdHex]?.name) + val later = assertNotNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, standing, null, w.editions, w.state().authority, 2L, observedFloor = 4)) + assertEquals(5L, later.key.epoch) + // Already private: nothing to do. + assertNull(ConcordPrivateChannels.privatize(owner, c.controlPlane, entryOf(c), general, w.state().channels[general]!!.definition, null, w.editions, w.state().authority, 2L)) + + val flip = assertNotNull(ConcordPrivateChannels.publicize(owner, c.controlPlane, c.communityId, general, w.state().channels[general]!!.definition, w.editions, c.ownerPubKey, 3L)) + w.add(flip) + assertFalse(general in w.state().privateChannelIds) + // The held private-era key keeps reading its history once the channel is public again. + val heldEntry = assertNotNull(ConcordChannelKeyring.withChannelKey(entryOf(c), build.key)) + val planes = ConcordActions.historicalChannelPlanes(heldEntry, general, isPrivate = false) + assertTrue(planes.any { it.epoch == 1L }) + // And the next privatisation climbs past it. + assertEquals(2L, ConcordChannelKeyring.nextChannelEpoch(heldEntry, general)) + assertTrue(general.hexToByteArray().size == 32) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt index d50201fabb..f803207754 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/concord/ConcordDirectInviteInboxTest.kt @@ -27,8 +27,10 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntr import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord02Community.NewConcordCommunity import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite import com.vitorpamplona.quartz.concord.cord05Invites.InviteChannel +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 @@ -91,6 +93,13 @@ class ConcordDirectInviteInboxTest { private fun stateOf(c: NewConcordCommunity): ConcordCommunityState = ConcordCommunityState.fold(ConcordActions.controlEditions(c.genesisWraps, c.controlPlane), c.communityId, c.ownerPubKey) + /** [c]'s fold with [vip] defined as a live Private Channel. */ + private suspend fun stateWithVip(c: NewConcordCommunity): ConcordCommunityState { + val editions = ConcordActions.controlEditions(c.genesisWraps, c.controlPlane).toMutableList() + editions += ConcordActions.controlEditions(listOf(ConcordModeration.defineChannel(owner, c.controlPlane, c.communityId, vip.hexToByteArray(), ChannelEntity(name = "vip", private = true), editions, createdAt = 2L, owner = c.ownerPubKey)), c.controlPlane) + return ConcordCommunityState.fold(editions, c.communityId, c.ownerPubKey) + } + @Test fun aValidWrapIsParkedWithItsVerifiedSenderAndDedupedByWrapId() = runTest { @@ -221,17 +230,23 @@ class ConcordDirectInviteInboxTest { runTest { val c = community() val held = heldEntryOf(c) - val state = stateOf(c) + val state = stateWithVip(c) val grant = listOf(InviteChannel(vip, "db".repeat(32), 0, "vip")) - // Same base, new key: a catch-up that keeps the held base and anchor. - val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + // Same base, new key, from staff (the owner): a catch-up that keeps the held base and anchor. + val catchUp = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = grant)), me)) val plan = assertIs(ConcordDirectInviteInbox.acceptPlan(catchUp, held, state, me.pubKey)) assertEquals(held.root, plan.entry.root) assertEquals(held.rootEpoch, plan.entry.rootEpoch) assertEquals(held.controlPk, plan.entry.controlPk) assertEquals("anchor", plan.entry.inviteRef) assertEquals(listOf(vip), plan.entry.privateChannels.map { it.channelId }) + assertEquals(listOf(vip), plan.channelIds) + + // A plain keyholder can't plant a key, and a channel the fold doesn't know as Private isn't one. + val fromMember = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, channels = grant)), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(fromMember, held, state, me.pubKey)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, held, stateOf(c), me.pubKey)) // No fold yet: the ban verdict is unknown, so it waits. assertEquals(DirectInviteAcceptPlan.RosterNotLoaded, ConcordDirectInviteInbox.acceptPlan(catchUp, held, null, me.pubKey)) @@ -240,6 +255,11 @@ class ConcordDirectInviteInboxTest { val holding = held.let { ConcordCommunityListEntry(it.id, it.owner, it.ownerSalt, it.root, it.rootEpoch, it.controlPk, privateChannels = listOf(PrivateChannelKey(vip, "db".repeat(32), 0, "vip")), relays = it.relays, name = it.name) } assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(catchUp, holding, state, me.pubKey)) + // Even from staff, a bundle never REPLACES a held key — not at a higher, nor an absurd, epoch. + // A held key moves only through a channel rekey, whose prevcommit proves continuity. + val hijack = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(owner, me.pubKey, inviteFor(c, channels = listOf(InviteChannel(vip, "ee".repeat(32), 1_000_000_000L, "vip")))), me)) + assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(hijack, holding, state, me.pubKey)) + // A different base for a held community is never adopted, keys or not. val baseMove = assertNotNull(ConcordActions.openDirectInvite(ConcordActions.buildDirectInvite(sender, me.pubKey, inviteFor(c, root = "99".repeat(32), channels = grant)), me)) assertEquals(DirectInviteAcceptPlan.NothingNew, ConcordDirectInviteInbox.acceptPlan(baseMove, held, state, me.pubKey))