mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
feat(cli): add amy concord refound + rekey — the rotation half of CORD-06
`refound` is the hard removal a ban cannot give: a ban only strips standing,
while the removed member keeps every key they ever held. 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 — is what actually
closes the room. The compacted Control Plane is re-sealed at the new epoch
and each retained member gets a rekey blob.
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 reaches past the roster to the Guestbook AND the authors of
every channel message we can decrypt, because a member who only ever posted
holds no role and files no Guestbook motion — building the set without them
silently expels them. It is still a floor, not a census.
`rekey` is the receive half, and without it `refound` was actively harmful
from the CLI: a retained member's blob sat on the relay unopened, so a
Refounding launched from amy stranded every other amy member. It authorizes
the rotator against the roster of the epoch being LEFT and fails closed.
Verified end to end against a loopback geode — the full cycle, which was not
previously expressible from the CLI at all:
alice refound --remove <stranger> → epoch 0 → 1, recipients=2
(bob is kept because he POSTED, holding no role — the author harvest)
bob rekey → epoch 0 → 1, same root + control_pk
bob sends, alice reads it at the new epoch
alice roles → the removed member is banned
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4022a6a5da
commit
0aa3adfc07
@@ -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 <create|list|import|channels|send|read|invite|join|recover|roles|role|grant|ban|unban>",
|
||||
"concord <create|list|import|channels|send|read|invite|join|recover|rekey|roles|role|grant|ban|unban|refound>",
|
||||
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<String>,
|
||||
): 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<Map<String, Any?>>()
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
): Int {
|
||||
val args = Args(rest)
|
||||
val handle = args.positional(0, "community")
|
||||
val removeArg = args.flag("remove") ?: return Output.error("bad_args", "refound <community> --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<String> = (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<String> =
|
||||
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<String> {
|
||||
val out = HashSet<String>()
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user