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 457be94472..1e299b4191 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 @@ -63,6 +63,8 @@ object ConcordCommands { | [--epoch N] [--root HEX] --epoch/--root read a prior epoch's plane | concord invite COMMUNITY [--base URL] mint + publish a shareable invite link | concord join URL redeem an invite link and save the community + | concord rekey [COMMUNITY] follow a Refounding we were re-keyed for: + | open our blob and adopt the new epoch | concord recover [COMMUNITY] re-resolve the joined-through invite link and | follow a Refounding we were left out of | (CORD-06); refuses if that epoch banned us @@ -71,6 +73,9 @@ object ConcordCommands { | concord grant COMMUNITY USER ROLE-ID grant a role to a member | concord ban COMMUNITY USER ban a member | concord unban COMMUNITY USER unban a member + | concord refound COMMUNITY --remove U[,U] CORD-06 Refounding: rotate the root (and the + | control_root) so removed members lose every + | key — the hard removal a ban cannot give """.trimMargin() suspend fun dispatch( @@ -80,7 +85,7 @@ object ConcordCommands { route( "concord", tail, - "concord ", + "concord ", help = USAGE, routes = mapOf( @@ -93,11 +98,13 @@ object ConcordCommands { "invite" to { rest -> invite(dataDir, rest) }, "join" to { rest -> join(dataDir, rest) }, "recover" to { rest -> recover(dataDir, rest) }, + "rekey" to { rest -> rekey(dataDir, rest) }, "roles" to { rest -> ConcordModCommands.roles(dataDir, rest) }, "role" to { rest -> ConcordModCommands.defineRole(dataDir, rest) }, "grant" to { rest -> ConcordModCommands.grant(dataDir, rest) }, "ban" to { rest -> ConcordModCommands.ban(dataDir, rest) }, "unban" to { rest -> ConcordModCommands.unban(dataDir, rest) }, + "refound" to { rest -> ConcordModCommands.refound(dataDir, rest) }, ), ) @@ -486,6 +493,68 @@ object ConcordCommands { } } + /** + * `concord rekey [COMMUNITY]` — follow a Refounding we WERE re-keyed for (CORD-06). + * + * The normal counterpart to [recover]: a retained member gets a per-recipient blob on the next + * epoch's base-rekey plane, and opening it yields the new root. Amethyst drains this on its + * revision tick; amy has no tick, so it is a verb. Without it a Refounding launched from the CLI + * strands every other CLI member even though their blob is sitting on the relay. + * + * The rotator is authorized against the roster of the epoch being **left** — `hasPermission`, + * never `effectivePermissions`, so a banned BAN-holder cannot rotate us (CORD-06). Fails closed: + * a plane that will not fold yields no verdict and the community is skipped. + */ + private suspend fun rekey( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positionalOrNull(0) + args.rejectUnknown() + val store = ConcordStore(dataDir.concordFile) + val targets = if (handle != null) listOf(store.find(handle) ?: return notFound(handle)) else store.load() + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val results = mutableListOf>() + for (sc in targets) { + val relays = relaysFor(ctx, sc) + val baseRekey = ConcordActions.nextBaseRekeyPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(baseRekey.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(baseRekey.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + val received = + ConcordActions.openBaseRekey(wraps, baseRekey, ctx.signer, sc.communityId, sc.root.hexToByteArray(), sc.rootEpoch) + if (received == null) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "no_blob_for_us", "root_epoch" to sc.rootEpoch) + continue + } + if (received.newEpoch <= sc.rootEpoch) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "already_current", "root_epoch" to sc.rootEpoch) + continue + } + // Authorize the rotator against the epoch we are LEAVING — the last plane we can fold. + val cp = controlPlaneKeysFor(sc) + ctx.registerConcordStreamKeys(relays, listOfNotNull(cp.signer?.secretKey)) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val editions = ConcordActions.controlEditions(controlWraps, cp) + if (editions.isEmpty()) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "control_plane_not_folded") + continue + } + if (!ConcordReceive.isAuthorizedRotator(AuthorityResolver.resolve(editions, sc.owner), received.rotator)) { + results += mapOf("community_id" to sc.communityId, "name" to sc.name, "rekeyed" to false, "reason" to "unauthorized_rotator", "rotator" to received.rotator) + continue + } + val adopted = ConcordReceive.withAdoptedRoot(entryFor(sc), received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot) + store.upsert(storedFrom(sc, adopted)) + 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)) + return 0 + } + } + fun notFound(handle: String): Int { Output.error("not_found", "no joined community matching '$handle' — run `amy concord list`") return 1 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 a2d364e8ec..a9e8b0d2d8 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.cli.stores.ConcordStore import com.vitorpamplona.amethyst.cli.stores.StoredCommunity import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration +import com.vitorpamplona.amethyst.commons.actions.ConcordReceive import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition @@ -206,6 +207,176 @@ object ConcordModCommands { operator fun component2() = editions } + /** + * `concord refound COMMUNITY --remove USER[,USER…]` — a CORD-06 Refounding: the hard removal. + * + * A ban only strips standing; the removed member keeps every key they ever held, so the room is + * only truly closed to them by rotating the `community_root` (and, since CORD-02 §2, a fresh + * `control_root` beside it, so a demoted staffer's retained secret dies with the epoch). The + * compacted Control Plane is re-sealed at the new epoch and each retained member gets a rekey + * blob; nobody else can follow. + * + * Authority mirrors Amethyst exactly: `hasPermission`, never `effectivePermissions`, so a banned + * BAN-holder cannot launch one; the owner is never a valid target; and removal takes the same + * rank rule as a ban (CORD-04 §3) — an admin cannot Refound a peer admin out. + * + * **The recipient set is a floor, not a census.** It is the roster ∪ Guestbook ∪ the authors of + * every channel message we can decrypt ∪ ourselves, minus the removed and already-banned — the + * same union Amethyst builds, because a member who only ever posted holds no role and leaves no + * Guestbook motion, and omitting them silently expels them. A member with no trace at all still + * cannot be re-keyed; `concord recover` is how they get back. + */ + suspend fun refound( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val handle = args.positional(0, "community") + val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound --remove USER[,USER…]").let { 2 } + args.rejectUnknown() + val sc = ConcordStore(dataDir.concordFile).find(handle) ?: return ConcordCommands.notFound(handle) + + Context.open(dataDir).use { ctx -> + ctx.prepare() + val removed = + removeArg + .split(',') + .map { it.trim() } + .filter { it.isNotEmpty() } + .map { ctx.requireUserHex(it).lowercase() } + .toSet() + if (removed.isEmpty()) return Output.error("bad_args", "--remove needs at least one user") + + val loaded = load(ctx, sc, dataDir) + val (cp, editions) = loaded + val state = ConcordCommunityState.fold(editions, sc.owner) + val authority = state.authority + val me = ctx.signer.pubKey + + if (!ConcordReceive.isAuthorizedRotator(authority, me)) { + return Output.error("forbidden", "this account cannot refound: a Refounding takes BAN (or ownership), and a banned holder is refused (CORD-06)") + } + if (removed.any { authority.isOwner(it) }) { + return Output.error("forbidden", "the owner is never a valid removal target (CORD-04 §3)") + } + // An admin cannot Refound a peer admin out any more than they could ban one. + if (!authority.isOwner(me) && removed.any { !authority.canActOn(me, it, ConcordPermissions.BAN) }) { + return Output.error("forbidden", "you do not outrank every member you are removing (CORD-04 §3, equal cannot act on equal)") + } + // A Refounding writes the current plane (the pre-rotation bans) and the new one, so on a + // split epoch it takes the current control_root (CORD-02 §2). + writeGuard(cp)?.let { return it } + + val relays = ConcordCommands.relaysFor(ctx, sc) + + // 1. Ban the removed on the CURRENT plane, so the compacted snapshot — and therefore the + // new epoch — carries the ban. Each edition chains onto the updated banlist head. + var chain = editions + for (target in removed) { + val banWrap = ConcordModeration.ban(ctx.signer, cp, sc.communityId.hexToByteArray(), target, chain, TimeUtils.now(), owner = sc.owner) + ctx.publish(banWrap, relays) + chain = chain + (ConcordActions.controlEditions(listOf(banWrap), cp)) + } + + // 2. Everyone we are keeping. See the note above on why this reaches past the roster. + val recipients = + (rosterOf(authority) + guestbookMembersOf(ctx, sc) + channelAuthorsOf(ctx, sc, state) + me) + .mapTo(HashSet()) { it.lowercase() } + .apply { + removeAll(removed) + removeAll(authority.bannedMembers().map { it.lowercase() }.toSet()) + }.toList() + + // 3. Build: new root + fresh control_root, compacted plane, per-recipient blobs (staff + // get the 136-byte form carrying the secret, everyone else the 104-byte pubkey one). + val newRoot = RandomInstance.bytes(32) + val newControlRoot = RandomInstance.bytes(32) + val controlWraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(cp.address)) }, pendingOnAuthRequired = true).map { it.second } + val build = + ConcordActions.buildRefounding( + rotatorSigner = ctx.signer, + communityId = sc.communityId, + priorRoot = sc.root.hexToByteArray(), + newRoot = newRoot, + newControlRoot = newControlRoot, + rootEpoch = sc.rootEpoch, + priorControlWraps = controlWraps, + priorControlKeys = cp, + recipientsXOnly = recipients, + staffXOnly = authority.staffMembers(), + createdAt = TimeUtils.now(), + ownerPubKey = sc.owner, + ) + + // 4. The compacted plane (the new epoch's state) then the blobs (the key that opens it). + build.controlWraps.forEach { ctx.publish(it, relays) } + build.rekeyWraps.forEach { ctx.publish(it, relays) } + + // 5. Adopt the new epoch ourselves — the same pure rewrite Amethyst uses, banking the + // epoch we are leaving for the anti-rollback floor. + val adopted = + ConcordReceive.withAdoptedRoot( + ConcordCommands.entryFor(loaded.community), + newRoot, + build.newEpoch, + build.newControlKeys.address.hexToByteArray(), + newControlRoot, + ) + ConcordStore(dataDir.concordFile).upsert(ConcordCommands.storedFrom(loaded.community, adopted)) + + Output.emit( + mapOf( + "community_id" to sc.communityId, + "removed" to removed.toList(), + "from_epoch" to sc.rootEpoch, + "root_epoch" to build.newEpoch, + "recipients" to recipients.size, + "control_wraps" to build.controlWraps.size, + "rekey_wraps" to build.rekeyWraps.size, + ), + ) + return 0 + } + } + + /** Owner + everyone holding a role — owner-rooted, so it cannot be padded from outside. */ + private fun rosterOf(authority: com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver): Set = (authority.roleHolders() + authority.staffMembers()).mapTo(HashSet()) { it.lowercase() } + + /** Live Guestbook membership at this epoch (joins minus later leaves, CORD-02 §5). */ + private suspend fun guestbookMembersOf( + ctx: Context, + sc: StoredCommunity, + ): Set = + runCatching { + val gb = ConcordActions.guestbookPlane(sc.root.hexToByteArray(), sc.communityId.hexToByteArray(), sc.rootEpoch) + val relays = ConcordCommands.relaysFor(ctx, sc) + ctx.registerConcordStreamKeys(relays, listOf(gb.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(gb.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.guestbookMembers(wraps, gb).mapTo(HashSet()) { it.lowercase() } + }.getOrDefault(emptySet()) + + /** + * Authors of every channel message we can decrypt. Most members never send a Guestbook motion, + * so without this a Refounding silently expels everyone who had only ever posted. + */ + private suspend fun channelAuthorsOf( + ctx: Context, + sc: StoredCommunity, + state: ConcordCommunityState, + ): Set { + val out = HashSet() + val relays = ConcordCommands.relaysFor(ctx, sc) + for ((channelIdHex, _) in state.channels) { + runCatching { + val key = ConcordActions.publicChannel(sc.root.hexToByteArray(), channelIdHex.hexToByteArray(), sc.rootEpoch) + ctx.registerConcordStreamKeys(relays, listOf(key.secretKey)) + val wraps = ctx.drain(relays.associateWith { listOf(ConcordActions.planeFilter(key.publicKeyHex)) }, pendingOnAuthRequired = true).map { it.second } + ConcordActions.channelMessages(wraps, key, channelIdHex, sc.rootEpoch).mapTo(out) { it.author.lowercase() } + } + } + return out + } + /** Drain the control plane and return its keys + current editions to chain onto. */ private suspend fun load( ctx: Context,