From a6db681462eac9afda0277591f5c291f059a06f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 19:17:33 +0000 Subject: [PATCH] feat(cli): amy concord channel create|privatize|publicize|rekey, channel rekeys in rekey/grant/refound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `concord channel create COMMUNITY NAME [--private [--role NAME]]`, `channel privatize` (next channel epoch, probed off the wire), `channel publicize`, `channel rekey` (reserved key in concord.json so a re-run re-delivers the same one) — thin assembly over ConcordPrivateChannels. - `concord rekey` also follows every held private channel's rotations (`channel_rekeys`: adopt, or drop + record the cut). - `concord grant` vends the private channels a Grant opens (Direct Invite limited to them) and rotates the ones it closes. - `concord refound` rotates every held private channel to its entitled kept set under the prior root before adopting the new epoch. - `concord accept` re-applies a catch-up to the record as stored at write time; the store keeps `channelCuts`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PmuzkD5qdqgv6dMeRMoC5N --- cli/README.md | 10 +- .../amethyst/cli/commands/ConcordCommands.kt | 46 +- .../cli/commands/ConcordModCommands.kt | 36 +- .../commands/ConcordPrivateChannelCommands.kt | 409 ++++++++++++++++++ .../amethyst/cli/stores/ConcordStore.kt | 7 + 5 files changed, 495 insertions(+), 13 deletions(-) create mode 100644 cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt diff --git a/cli/README.md b/cli/README.md index 8bc626321f..c22f4c7fe2 100644 --- a/cli/README.md +++ b/cli/README.md @@ -677,6 +677,10 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `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; `readable` is false for a private channel whose key this account does not hold (CORD-03 §1). | +| `amy concord channel create COMMUNITY NAME [--private [--role NAME]]` | Create a channel (MANAGE_CHANNELS). `--private` gives it its own independent key at channel epoch 0 (stored before anything publishes) plus a bit-less access Role scoped to it (CORD-04 §2, default name = the channel's); nobody holds that Role yet — `concord grant` it to let members read. | +| `amy concord channel privatize COMMUNITY CHANNEL [--role NAME]` | Convert a Public channel to Private (CORD-03 §2): a fresh key at the next channel epoch — floored at the highest channel rotation found on the wire, refused (`inconclusive`) past 32 — plus an access Role, then the flag. Protects the future only. | +| `amy concord channel publicize COMMUNITY CHANNEL` | Convert a Private channel back to Public (flag only); the held key stays so the private era keeps reading. | +| `amy concord channel rekey COMMUNITY CHANNEL` | Rotate a Private channel's key (CORD-06 §1-2) to exactly the members its Roles entitle today plus us: 72-byte scope-bound blobs at the channel-rekey address, `vac` on every chunk. Needs MANAGE_CHANNELS and outranking every cut role holder; the key is reserved in `concord.json` so a re-run re-delivers the same one. | | `amy concord send COMMUNITY CHANNEL TEXT` | Post a message (CHANNEL = `general`\|name\|id). A private channel posts on its own key's plane; without a held key it fails with `no_channel_key` instead of falling back to the community-wide plane. | | `amy concord read COMMUNITY CHANNEL [--limit N] [--epoch N] [--root HEX]` | Read a channel's messages (default 50); `--epoch`/`--root` read a prior epoch's plane (public channels; a private channel reads its held key's plane). | | `amy concord invite COMMUNITY [--base URL]` | Mint + publish a shareable invite link (at most 3 bootstrap relays ride in the fragment, CORD-05 §3; the bundle names this account as creator), then publish this account's Invite Registry (`vsk 8`, CORD-05 §5) listing its live link signers — expired links pruned. Output adds `registry_published`, `public` and `live_invite_links`. | @@ -686,12 +690,12 @@ also carried on-relay as the encrypted, fragmented kind:33302 Community List | `amy concord decline WRAP-ID` | Discard a Direct Invite; its wrap id is remembered in `concord-invites.json` so it never resurfaces. | | `amy concord revoke COMMUNITY TOKEN\|URL` | Retire a link you minted: publishes a `vsk=9` tombstone at its coordinate, records it in your Invite List, then republishes your Invite Registry without it. When it was the community's last live link the output carries `privatized: true` / `refound_required: true`: the community is Private now, and `concord refound COMMUNITY --privatize` rotates its keys (CORD-05 §2). | | `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 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). Then follows every held private channel's own rotations (`channel_rekeys`): a complete, honored rotation off the key we hold is adopted; one from a rotator who outranks us that leaves us out drops the key and records the cut, so no older key comes back. | | `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…]` / `--privatize` | 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. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). | +| `amy concord refound COMMUNITY --remove U[,U…]` / `--privatize` | CORD-06 Refounding. Aborts unless the whole Control Plane folds; publishes the rekey chunks first (each confirmed), the compacted plane after, then rotates every held private channel to its entitled kept set, sealed under the prior root (`channels_rotated`); reserves its keys so a re-run resumes with the same root; refused for a dissolved community. `--privatize` removes nobody: it converts a Public community to Private (owed once its last live invite link is revoked). | | `amy concord roles COMMUNITY` | List live roles + the current banlist (CORD-04), plus the community's mode from the folded Invite Registries (CORD-05 §5): `public` (true while any live invite link exists), `live_invite_links`, and `invite_registries` (links per creator). | | `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 grant COMMUNITY USER ROLE-ID` | Grant a role to a member. Private-channel keys follow the Grant (CORD-03/06): every channel it opens to a member is vended to them by Direct Invite carrying only those channels (`channel_keys_vended`); every channel it closes is rotated (`channels_rotated`, or `channels_not_rotated` with the reason). Only keys this account holds can move (`channels_not_held`). | | `amy concord ban COMMUNITY USER` / `unban COMMUNITY USER` | Ban / unban a member. A ban reports `public` and `refound_required`: a Public ban is the Banlist alone, while a ban from a Private community owes a Refounding (`concord refound COMMUNITY --remove USER`, CORD-06 §3). | | `amy concord pins COMMUNITY CHANNEL` | The channel's Pin List (CORD-04 §7), every entry verified from its proof bundle; entries the author deleted are listed under `deleted`, `edited`/`stale_edit` flag revisions, and `sealed_unavailable` means the list is sealed under a key this account never held (unreadable, not empty). | | `amy concord pin COMMUNITY CHANNEL RUMOR_ID` / `unpin COMMUNITY CHANNEL RUMOR_ID` | Pin / unpin a message (PIN_MESSAGES or owner, plus the control write key). Pinning reopens the message's wrap to prove it with its original seal; a private channel's list is sealed under its current key. Refused (`list_unavailable`, `too_many_pins`, `too_large`, …) rather than published when the list is unreadable or a cap would break. | 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 8d7dcf5b34..31b9eaf2c0 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 @@ -44,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord02Community.ConcordDissolution import com.vitorpamplona.quartz.concord.cord02Community.ConcordListFragmentSet import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -53,6 +54,7 @@ import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument 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.ConcordInviteVend import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus import com.vitorpamplona.quartz.concord.cord06Rekey.ReceivedRefounding import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys @@ -79,6 +81,15 @@ object ConcordCommands { | concord import fetch + decrypt this account's kind:33302 | community list (carries heldRoots, CORD-06) | concord channels COMMUNITY list a community's channels + | concord channel create COMMUNITY NAME create a channel (MANAGE_CHANNELS); --private + | [--private [--role NAME]] gives it its own key at channel epoch 0 plus a + | bit-less access Role (default: the channel name); + | grant that Role to let members read it + | concord channel privatize COMMUNITY CHANNEL convert a Public channel to Private: a fresh key + | [--role NAME] at the next channel epoch + an access Role + | concord channel publicize COMMUNITY CHANNEL convert a Private channel back to Public (flag only) + | concord channel rekey COMMUNITY CHANNEL rotate a Private channel's key to exactly the + | members its Roles entitle today (CORD-06) | concord send COMMUNITY CHANNEL TEXT post a message (CHANNEL = general|name|id) | concord read COMMUNITY CHANNEL [--limit N] read a channel's messages (default 50); | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane @@ -96,7 +107,9 @@ object ConcordCommands { | it in your invite list so it stays retired | 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 + | open our blob and adopt the new epoch; then + | follow each held private channel's rotations + | (adopt the new key, or drop it when cut) | 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 @@ -106,7 +119,9 @@ object ConcordCommands { | and public: true/false + live invite links | from the folded registries (CORD-05 §5) | 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 + | concord grant COMMUNITY USER ROLE-ID grant a role to a member; the private channels it + | opens are vended by Direct Invite, the ones it + | closes are rotated (CORD-03/06) | concord ban COMMUNITY USER ban a member | concord pins COMMUNITY CHANNEL the channel's verified Pin List (CORD-04 §7) | concord pin COMMUNITY CHANNEL RUMOR_ID pin a message (PIN_MESSAGES); proves it with @@ -133,7 +148,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -141,6 +156,7 @@ object ConcordCommands { "list" to { rest -> list(dataDir, rest) }, "import" to { rest -> import(dataDir, rest) }, "channels" to { rest -> ConcordChannelCommands.channels(dataDir, rest) }, + "channel" to { rest -> ConcordPrivateChannelCommands.channel(dataDir, rest) }, "send" to { rest -> ConcordChannelCommands.send(dataDir, rest) }, "read" to { rest -> ConcordChannelCommands.read(dataDir, rest) }, "invite" to { rest -> invite(dataDir, rest) }, @@ -729,9 +745,12 @@ object ConcordCommands { 0 } is DirectInviteAcceptPlan.CatchUp -> { - val held = heldSc!! - store.upsert(storedFrom(held, plan.entry)) - val added = plan.entry.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } } + // Re-applied to the record as stored NOW (the fold above took a while), never the + // snapshot the plan was computed from. + val held = store.load().firstOrNull { it.communityId == heldSc!!.communityId } ?: heldSc!! + val adopted = ConcordInviteVend.adoptCatchUp(entryFor(held), opened.invite, plan.channelIds) ?: plan.entry + store.upsert(storedFrom(held, adopted)) + val added = adopted.privateChannels.filter { pc -> held.privateChannels.none { it.channelId.equals(pc.channelId, ignoreCase = true) && it.epoch == pc.epoch } } Output.emit(done(mapOf("joined" to true, "catch_up" to true, "channels" to added.map { mapOf("id" to it.channelId, "name" to it.name, "epoch" to it.epoch) }))) 0 } @@ -840,7 +859,9 @@ object ConcordCommands { } /** The quartz list entry a [StoredCommunity] describes — the shape every commons helper takes. */ - fun entryFor(sc: StoredCommunity) = + fun entryFor(sc: StoredCommunity) = sc.channelCuts.entries.fold(entryShape(sc)) { entry, (id, epoch) -> ConcordChannelKeyring.withCut(entry, id, epoch) } + + private fun entryShape(sc: StoredCommunity) = ConcordCommunityListEntry( id = sc.communityId, owner = sc.owner, @@ -870,6 +891,7 @@ object ConcordCommands { 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) }, + channelCuts = ConcordChannelKeyring.cutsOf(entry), ) /** @@ -1048,7 +1070,15 @@ object ConcordCommands { 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)) + // Then every held Private Channel's own rotations (CORD-06 §2), off the records as stored + // now — a base adoption above may have moved the root they are sealed under. + val channelResults = mutableListOf>() + for (id in targets.map { it.communityId }) { + val fresh = store.load().firstOrNull { it.communityId == id } ?: continue + if (fresh.privateChannels.isEmpty() || isDissolved(ctx, fresh)) continue + channelResults += ConcordPrivateChannelCommands.drainChannelRekeys(ctx, store, fresh) + } + Output.emit(mapOf("communities" to results, "channel_rekeys" to channelResults)) return 0 } } 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 c8d9f65c3b..66a90e5811 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 @@ -29,10 +29,13 @@ 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.ConcordPrivateChannels 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.cord03Channels.ConcordChannelKeyring import com.vitorpamplona.quartz.concord.cord03Channels.ConcordDisappearing +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver import com.vitorpamplona.quartz.concord.cord04Roles.ConcordLimits import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -41,6 +44,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteRegistry import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus +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 @@ -204,7 +208,11 @@ object ConcordModCommands { ) val ack = ctx.publish(wrap, ConcordCommands.relaysFor(ctx, sc)) RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } - Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + RawEventSupport.ackFields(ack)) + // Role-gated channel keys follow the Grant (CORD-03/06): vend what it opened, rotate what it closed. + val before = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner) + val after = editions + ConcordActions.controlEditions(listOf(wrap), cp) + val access = ConcordPrivateChannelCommands.reconcileAccess(ctx, ConcordStore(dataDir.concordFile), loaded.community, before, after) + Output.emit(mapOf("member" to member, "roles" to listOf(roleId)) + access + RawEventSupport.ackFields(ack)) return 0 } } @@ -544,11 +552,33 @@ object ConcordModCommands { } val compactionFailures = build.controlWraps.count { wrap -> ctx.publish(wrap, relays).values.none { it.accepted } } + // 4b. 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. One that no relay takes keeps its key and is reported: resumable, not atomic. + val afterBans = ConcordCommunityState.fold(chain, sc.communityId.hexToByteArray(), sc.owner) + val kept = recipients.mapTo(HashSet()) { it.lowercase() } + var withChannels = ConcordCommands.entryFor(loaded.community) + val channelsRotated = mutableListOf() + val channelsNotRotated = mutableListOf() + for (held in withChannels.privateChannels) { + val id = held.channelId.lowercase() + if (id !in afterBans.privateChannelIds || ConcordChannelKeyring.heldKey(withChannels, id) == null) continue + val keep = ConcordPrivateChannels.keepSet(afterBans.authority, id, me).filterTo(HashSet()) { it in kept || it == me.lowercase() } + val newKey = ConcordChannelRekey.mintKey() + val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, priorRoot, held, newKey, keep, TimeUtils.now(), citation) + if (wraps.all { wrap -> ctx.publish(wrap, relays).values.any { it.accepted } }) { + withChannels = ConcordChannelKeyring.withRotatedKey(withChannels, id, newKey.toHexKey(), held.epoch + 1) ?: withChannels + channelsRotated += id + } else { + channelsNotRotated += id + } + } + // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the // epoch we are leaving for the anti-rollback floor — and drop the reservation. val adopted = ConcordReceive.withAdoptedRoot( - ConcordCommands.entryFor(loaded.community), + withChannels, keys.newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), @@ -606,6 +636,8 @@ object ConcordModCommands { "rekey_wraps" to build.rekeyWraps.size, "compaction_failures" to compactionFailures, "invites_refreshed" to refreshed, + "channels_rotated" to channelsRotated, + "channels_not_rotated" to channelsNotRotated, ), ) return 0 diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt new file mode 100644 index 0000000000..06c1e7152d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/ConcordPrivateChannelCommands.kt @@ -0,0 +1,409 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +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.commons.actions.ConcordActions +import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordPrivateChannels +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive +import com.vitorpamplona.amethyst.commons.model.ConcordDirectInviteDraft +import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState +import com.vitorpamplona.quartz.concord.cord02Community.PrivateChannelKey +import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelKeyring +import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver +import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity +import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions +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.marmot.RecipientRelayFetcher +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.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * `amy concord channel create|privatize|publicize|rekey` — Private Channels (CORD-03 §1-2, + * CORD-06 §1-2). Thin assembly over [ConcordPrivateChannels] (commons); the decisions — the key + * epoch, the access Role, who a rotation keeps, whether a received rotation is honored — are all + * shared with Amethyst. Like every amy verb that holds secrets, keys live in the local store only + * (amy does not republish the Community List). + */ +object ConcordPrivateChannelCommands { + /** How many channel epochs `privatize` probes for earlier rotations (Armada MAX_PROBED_CHANNEL_EPOCH). */ + private const val MAX_PROBED_CHANNEL_EPOCH = 32L + + suspend fun channel( + dataDir: DataDir, + tail: Array, + ): Int = + route( + "concord channel", + tail, + "concord channel ", + routes = + mapOf( + "create" to { rest -> create(dataDir, rest) }, + "privatize" to { rest -> privatize(dataDir, rest) }, + "publicize" to { rest -> publicize(dataDir, rest) }, + "rekey" to { rest -> rekey(dataDir, rest) }, + ), + ) + + /** Publishes [wraps] in order to [sc]'s relays; the first one no relay takes stops the run. */ + private suspend fun publishAll( + ctx: Context, + sc: StoredCommunity, + wraps: List, + ): Int? { + val relays = ConcordCommands.relaysFor(ctx, sc) + for (wrap in wraps) { + val ack = ctx.publish(wrap, relays) + RawEventSupport.publishGuard(ack, wrap.id)?.let { return it } + } + return null + } + + private fun canManageChannels( + authority: AuthorityResolver, + me: HexKey, + ): Boolean = authority.isOwner(me) || authority.hasPermission(me, ConcordPermissions.MANAGE_CHANNELS) + + private fun forbidden(): Int = Output.error("forbidden", "this needs the Manage-channels permission (CORD-03 §2); readers would drop the edition") + + /** Stores [sc] with the Private Channel [key] (refused when it would not move the channel forward). */ + private fun storeKey( + store: ConcordStore, + sc: StoredCommunity, + key: PrivateChannelKey, + ): Boolean { + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val next = ConcordChannelKeyring.withChannelKey(ConcordCommands.entryFor(fresh), key) ?: return false + store.upsert(ConcordCommands.storedFrom(fresh, next)) + return true + } + + /** `concord channel create COMMUNITY NAME [--private [--role NAME]]`. */ + private suspend fun create( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val name = args.positional(1, "name") + val private = args.bool("private") + val roleName = args.flag("role") + args.rejectUnknown() + if (!ChannelEntity(name = name.trim()).hasValidName()) return Output.error("bad_args", "a channel name must be 1..${ChannelEntity.NAME_MAX_BYTES} UTF-8 bytes").let { 2 } + if (roleName != null && !private) return Output.error("bad_args", "--role names a Private channel's access Role; add --private").let { 2 } + val store = ConcordStore(dataDir.concordFile) + val sc = store.find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val loaded = ConcordModCommands.load(ctx, sc, dataDir) + val (cp, editions) = loaded + ConcordModCommands.writeGuard(cp)?.let { return it } + val authority = AuthorityResolver.resolve(editions, sc.communityId.hexToByteArray(), sc.owner) + if (!canManageChannels(authority, ctx.signer.pubKey)) return forbidden() + + if (!private) { + val channelId = RandomInstance.bytes(32) + val wrap = ConcordModeration.defineChannel(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, ChannelEntity(name = name.trim()), editions, TimeUtils.now(), owner = sc.owner) + publishAll(ctx, sc, listOf(wrap))?.let { return it } + Output.emit(mapOf("channel_id" to channelId.toHexKey(), "name" to name.trim(), "private" to false)) + return 0 + } + + val build = + ConcordPrivateChannels.create(ctx.signer, cp, sc.communityId.hexToByteArray(), name, roleName, editions, authority, sc.owner, TimeUtils.now()) + ?: return Output.error("forbidden", "no rank to mint this channel's access Role from") + // The key goes into the store BEFORE the editions publish: otherwise a crash orphans the only copy. + if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key") + publishAll(ctx, sc, build.wraps)?.let { return it } + Output.emit( + mapOf( + "channel_id" to build.channelIdHex, + "name" to name.trim(), + "private" to true, + "channel_epoch" to build.key.epoch, + "access_role_id" to build.roleIdHex, + ), + ) + return 0 + } + } + + /** `concord channel privatize COMMUNITY CHANNEL [--role NAME]`. */ + private suspend fun privatize( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + val roleName = args.flag("role") + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val sc = store.find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val loaded = ConcordModCommands.load(ctx, sc, dataDir) + val (cp, editions) = loaded + ConcordModCommands.writeGuard(cp)?.let { return it } + val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) + if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden() + val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane") + if (standing.private) return Output.error("already_private", "channel '$channelRef' is already private") + + // The next channel epoch must climb past every generation ever used — including ones this + // account never held — so probe the rekey addresses the roots derive (CORD-03 §2). + val entry = ConcordCommands.entryFor(loaded.community) + 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.hexToByteArray(), epoch).publicKeyHex] = epoch + } + val relays = ConcordCommands.relaysFor(ctx, sc) + val seen = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(window.keys.toList())) }).map { it.second } + val floor = seen.mapNotNull { window[it.pubKey] }.maxOrNull() ?: 0 + if (floor >= MAX_PROBED_CHANNEL_EPOCH) return Output.error("inconclusive", "this channel has rotated at least $MAX_PROBED_CHANNEL_EPOCH times; its next epoch can't be established safely") + + val build = + ConcordPrivateChannels.privatize(ctx.signer, cp, entry, channelId, standing, roleName, editions, state.authority, TimeUtils.now(), floor) + ?: return Output.error("forbidden", "no rank to mint this channel's access Role from") + if (!storeKey(store, loaded.community, build.key)) return Output.error("conflict", "could not store the new channel key") + publishAll(ctx, sc, build.wraps)?.let { return it } + Output.emit(mapOf("channel_id" to channelId, "private" to true, "channel_epoch" to build.key.epoch, "access_role_id" to build.roleIdHex)) + return 0 + } + } + + /** `concord channel publicize COMMUNITY CHANNEL`. */ + private suspend fun publicize( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val (cp, editions) = ConcordModCommands.load(ctx, sc, dataDir) + ConcordModCommands.writeGuard(cp)?.let { return it } + val state = ConcordCommunityState.fold(editions, sc.communityId.hexToByteArray(), sc.owner) + if (!canManageChannels(state.authority, ctx.signer.pubKey)) return forbidden() + val standing = state.channels[channelId]?.definition ?: return Output.error("not_found", "channel '$channelRef' is not in the folded Control Plane") + val wrap = + ConcordPrivateChannels.publicize(ctx.signer, cp, sc.communityId.hexToByteArray(), channelId, standing, editions, sc.owner, TimeUtils.now()) + ?: return Output.error("already_public", "channel '$channelRef' is not private") + publishAll(ctx, sc, listOf(wrap))?.let { return it } + Output.emit(mapOf("channel_id" to channelId, "private" to false)) + return 0 + } + } + + /** `concord channel rekey COMMUNITY CHANNEL` — rotate to exactly the members entitled today. */ + private suspend fun rekey( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val channelRef = args.positional(1, "channel") + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val sc = store.find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + if (ConcordCommands.isDissolved(ctx, sc)) return Output.error("dissolved", "community '$handle' has been dissolved (CORD-02 §9)") + val channelId = ConcordChannelCommands.resolve(ctx, sc, channelRef) ?: return Output.error("not_found", "no channel '$channelRef'") + val loaded = ConcordModCommands.load(ctx, sc, dataDir) + val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner) + // The known role holders a rotation to the entitled set leaves out; a roleless member ranks + // last, so any MANAGE_CHANNELS holder outranks them and they need no check. + val keep = ConcordPrivateChannels.keepSet(authority, channelId, ctx.signer.pubKey) + val cut = (authority.roleHolders() + authority.owner()).filterTo(HashSet()) { it !in keep } + return rotate(ctx, store, loaded.community, channelId, authority, loaded.editions, cut) + } + } + + /** A rotation's result: [error] (code to message) when refused or unpublished, else what landed. */ + internal class Rotation( + val error: Pair? = null, + val newEpoch: Long = 0, + val kept: Int = 0, + val chunks: Int = 0, + ) + + /** [rotateSilently] with its outcome emitted as the command's JSON line. */ + internal suspend fun rotate( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + channelId: HexKey, + authority: AuthorityResolver, + editions: List, + cut: Set, + ): Int { + val r = rotateSilently(ctx, store, sc, channelId, authority, editions, cut) + r.error?.let { (code, message) -> return Output.error(code, message) } + Output.emit(mapOf("channel_id" to channelId, "rekeyed" to true, "channel_epoch" to r.newEpoch, "kept" to r.kept, "chunks" to r.chunks)) + return 0 + } + + /** + * Rotates [channelId] to its entitled set (CORD-06 §1-2), cutting [cut]: authority checked, the + * key reserved in the store before anything publishes (a retry re-delivers the same key), every + * chunk accepted by a relay before the new key is adopted locally. Prints nothing. + */ + internal suspend fun rotateSilently( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + channelId: HexKey, + authority: AuthorityResolver, + editions: List, + cut: Set, + ): Rotation { + val me = ctx.signer.pubKey + val entry = ConcordCommands.entryFor(sc) + val held = ConcordChannelKeyring.heldKey(entry, channelId) ?: return Rotation("no_channel_key" to "this account holds no key for channel $channelId, so it cannot rotate it") + if (!ConcordPrivateChannels.canRotate(authority, me, cut)) { + return Rotation("forbidden" to "rotating needs the Manage-channels permission and outranking every member it cuts (CORD-06 §3)") + } + val citation = ConcordReceive.rotationCitation(entry, editions, me) + if (citation == null && !authority.isOwner(me)) return Rotation("forbidden" to "no Grant of ours to cite; nobody would honor this rotation (CORD-06 §3)") + + val newEpoch = held.epoch + 1 + val reservation = "${held.channelId.lowercase()}:$newEpoch:${ConcordChannelRekey.prevCommit(held.epoch, held.key.hexToByteArray())}" + val newKeyHex = sc.pendingChannelRotations[reservation] ?: ConcordChannelRekey.mintKey().toHexKey() + store.upsert(sc.copy(pendingChannelRotations = sc.pendingChannelRotations + (reservation to newKeyHex))) + + val keep = ConcordPrivateChannels.keepSet(authority, channelId, me) + val wraps = ConcordPrivateChannels.buildRotation(ctx.signer, sc.root.hexToByteArray(), held, newKeyHex.hexToByteArray(), keep, TimeUtils.now(), citation) + val relays = ConcordCommands.relaysFor(ctx, sc) + for (wrap in wraps) { + if (ctx.publish(wrap, relays).values.none { it.accepted }) { + return Rotation("rejected" to "no relay accepted chunk ${wrap.id} of the rotation; re-running re-delivers the same key") + } + } + + // Adopt at once: the rotator must never keep writing under the severed key. + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val next = ConcordChannelKeyring.withRotatedKey(ConcordCommands.entryFor(fresh), channelId, newKeyHex, newEpoch) + if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next).copy(pendingChannelRotations = fresh.pendingChannelRotations - reservation)) + return Rotation(newEpoch = newEpoch, kept = keep.size, chunks = wraps.size) + } + + /** + * Follows every held Private Channel's rotations for [sc] (CORD-06 §2): drains the watched + * channel-rekey addresses, adopts a key carried off the one we hold, or drops the channel (and + * records the cut) when a rotation from someone who outranks us left us out. Returns one result + * per channel acted on. + */ + internal suspend fun drainChannelRekeys( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + ): List> { + val entry = ConcordCommands.entryFor(sc) + val keys = ConcordPrivateChannels.watchKeys(entry) + if (keys.isEmpty()) return emptyList() + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, keys.values.map { it.secretKey }) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilterFor(keys.keys.toList())) }, pendingOnAuthRequired = true).map { it.second } + if (wraps.isEmpty()) return emptyList() + val loaded = ConcordModCommands.load(ctx, sc) + val authority = AuthorityResolver.resolve(loaded.editions, sc.communityId.hexToByteArray(), sc.owner) + val outcomes = ConcordPrivateChannels.receive(entry, wraps, loaded.editions, authority, ctx.signer) + if (outcomes.isEmpty()) return emptyList() + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val next = ConcordPrivateChannels.applyOutcome(ConcordCommands.entryFor(fresh), outcomes, entry.privateChannels.associate { it.channelId.lowercase() to it.epoch }) + if (next != null) store.upsert(ConcordCommands.storedFrom(fresh, next)) + return outcomes.map { (id, outcome) -> + when (outcome) { + is ChannelRekeyOutcome.Adopted -> mapOf("community_id" to sc.communityId, "channel_id" to id, "adopted" to true, "channel_epoch" to outcome.epoch) + is ChannelRekeyOutcome.Removed -> mapOf("community_id" to sc.communityId, "channel_id" to id, "removed" to true, "channel_epoch" to outcome.epoch) + ChannelRekeyOutcome.None -> mapOf("community_id" to sc.communityId, "channel_id" to id) + } + } + } + + /** + * After a Grant: vends every Private Channel it opened to its member by Direct Invite (only those + * channels), and rotates every one it closed (CORD-03/06; Armada `handleToggleRole`). Returns what + * it did, for the grant command's output. + */ + internal suspend fun reconcileAccess( + ctx: Context, + store: ConcordStore, + sc: StoredCommunity, + before: AuthorityResolver, + afterEditions: List, + ): Map { + val state = ConcordCommunityState.fold(afterEditions, sc.communityId.hexToByteArray(), sc.owner) + val me = ctx.signer.pubKey.lowercase() + val entry = ConcordCommands.entryFor(sc) + val changes = ConcordInviteVend.accessChanges(before, state.authority, state.privateChannelIds) + val vended = mutableListOf>() + val rotated = mutableListOf() + val unrotated = mutableListOf>() + val unheld = mutableListOf() + val byMember = HashMap>() + for (change in changes) { + if (ConcordChannelKeyring.heldKey(entry, change.channelIdHex) == null) { + unheld += change.channelIdHex + continue + } + for (m in change.gained - me) byMember.getOrPut(m) { HashSet() }.add(change.channelIdHex) + val cut = change.lost - me + if (cut.isNotEmpty()) { + val fresh = store.load().firstOrNull { it.communityId == sc.communityId } ?: sc + val r = rotateSilently(ctx, store, fresh, change.channelIdHex, state.authority, afterEditions, cut) + if (r.error == null) rotated += change.channelIdHex else unrotated += mapOf("channel_id" to change.channelIdHex, "reason" to r.error.second) + } + } + for ((member, channels) in byMember) { + val draft = ConcordActions.draftDirectInvite(entry, state, me, member, onlyChannelIds = channels) as? ConcordDirectInviteDraft.Ready ?: continue + val wrap = ConcordActions.buildDirectInvite(ctx.signer, member, draft.invite) + val lists = ctx.cachedRelayListsOf(member) ?: RecipientRelayFetcher.fetchRelayLists(ctx.client, member, ctx.bootstrapRelays()) + val ack = ctx.publish(wrap, ConcordActions.directInviteDeliveryRelays(lists)) + vended += mapOf("member" to member, "channels" to draft.invite.channels.map { it.id }, "delivered" to ack.values.any { it.accepted }) + } + return mapOf("channel_keys_vended" to vended, "channels_rotated" to rotated, "channels_not_rotated" to unrotated, "channels_not_held" to unheld) + } +} 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 cbfc24fd48..225bf928e8 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 @@ -57,6 +57,13 @@ data class StoredCommunity( // 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(), + // Per Private Channel, the channel epoch whose rotation cut this account out (CORD-06 §2; the + // reference client's `channel_cuts`): a key below it is never adopted again from a bundle. + val channelCuts: Map = emptyMap(), + // Channel keys reserved for a Private Channel rotation this account started but has not yet + // adopted, keyed "channelId:newEpoch:prevcommit" (CORD-06): a retried `channel rekey` must + // re-deliver the SAME key, never a sibling that splits the members at one epoch. + val pendingChannelRotations: Map = emptyMap(), // 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,